Add convective outlook Postgres read adapter
This commit is contained in:
60
internal/adapters/outbound/postgres/outlooks_mapper.go
Normal file
60
internal/adapters/outbound/postgres/outlooks_mapper.go
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
// outlooks_mapper.go maps outlook rows into weather model payloads.
|
||||||
|
// Layer: adapters/outbound/postgres outlook feature.
|
||||||
|
package postgres
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func mapOutlookRunParentRow(row outlookRunParentRow) model.WeatherOutlookRun {
|
||||||
|
return model.WeatherOutlookRun{
|
||||||
|
LocationID: stringValue(row.LocationID),
|
||||||
|
LocationName: stringValue(row.LocationName),
|
||||||
|
Latitude: float64Ptr(row.Latitude),
|
||||||
|
Longitude: float64Ptr(row.Longitude),
|
||||||
|
AsOf: row.AsOf.UTC(),
|
||||||
|
IssuedAt: timePtr(row.IssuedAt),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapOutlookRow(row outlookRow) (model.WeatherOutlook, error) {
|
||||||
|
geometry := []byte(row.GeometryJSON)
|
||||||
|
if !json.Valid(geometry) {
|
||||||
|
return model.WeatherOutlook{}, fmt.Errorf("decode outlook geometry: invalid JSON")
|
||||||
|
}
|
||||||
|
|
||||||
|
return model.WeatherOutlook{
|
||||||
|
ID: row.OutlookID,
|
||||||
|
Provider: row.Provider,
|
||||||
|
Product: row.Product,
|
||||||
|
Day: row.Day,
|
||||||
|
OutlookType: row.OutlookType,
|
||||||
|
Label: row.Label,
|
||||||
|
LabelText: stringValue(row.LabelText),
|
||||||
|
SeverityRank: intPtr(row.SeverityRank),
|
||||||
|
ValidFrom: row.ValidFrom.UTC(),
|
||||||
|
ValidTo: row.ValidTo.UTC(),
|
||||||
|
IssuedAt: row.IssuedAt.UTC(),
|
||||||
|
ExpiresAt: row.ExpiresAt.UTC(),
|
||||||
|
Forecaster: stringValue(row.Forecaster),
|
||||||
|
Headline: stringValue(row.Headline),
|
||||||
|
Summary: stringValue(row.Summary),
|
||||||
|
Discussion: stringValue(row.Discussion),
|
||||||
|
SourceURL: stringValue(row.SourceURL),
|
||||||
|
ImageURL: stringValue(row.ImageURL),
|
||||||
|
ContainsLocation: row.ContainsLocation,
|
||||||
|
Geometry: json.RawMessage(append([]byte(nil), geometry...)),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func intPtr(v sql.NullInt64) *int {
|
||||||
|
if !v.Valid {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := int(v.Int64)
|
||||||
|
return &out
|
||||||
|
}
|
||||||
174
internal/adapters/outbound/postgres/outlooks_mapper_test.go
Normal file
174
internal/adapters/outbound/postgres/outlooks_mapper_test.go
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
// outlooks_mapper_test.go validates outlook row mapping.
|
||||||
|
// Layer: adapters/outbound/postgres outlook mapper tests.
|
||||||
|
package postgres
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMapOutlookRunParentRowNullables(t *testing.T) {
|
||||||
|
asOf := time.Date(2026, 6, 11, 11, 30, 0, 0, time.FixedZone("CDT", -5*3600))
|
||||||
|
issuedAt := asOf.Add(-30 * time.Minute)
|
||||||
|
latitude := 38.62
|
||||||
|
|
||||||
|
run := mapOutlookRunParentRow(outlookRunParentRow{
|
||||||
|
EventID: "evt-outlook-run",
|
||||||
|
LocationID: sql.NullString{String: "stl", Valid: true},
|
||||||
|
LocationName: sql.NullString{String: "St. Louis", Valid: true},
|
||||||
|
Latitude: sql.NullFloat64{Float64: latitude, Valid: true},
|
||||||
|
Longitude: sql.NullFloat64{Valid: false},
|
||||||
|
AsOf: asOf,
|
||||||
|
IssuedAt: sql.NullTime{Time: issuedAt, Valid: true},
|
||||||
|
})
|
||||||
|
|
||||||
|
if run.LocationID != "stl" || run.LocationName != "St. Louis" {
|
||||||
|
t.Fatalf("unexpected location metadata: %+v", run)
|
||||||
|
}
|
||||||
|
if run.Latitude == nil || *run.Latitude != latitude {
|
||||||
|
t.Fatalf("expected latitude pointer %v, got %v", latitude, run.Latitude)
|
||||||
|
}
|
||||||
|
if run.Longitude != nil {
|
||||||
|
t.Fatalf("expected nil longitude, got %v", *run.Longitude)
|
||||||
|
}
|
||||||
|
if run.AsOf.Location().String() != "UTC" {
|
||||||
|
t.Fatalf("expected asOf UTC normalization, got %s", run.AsOf.Location())
|
||||||
|
}
|
||||||
|
if run.IssuedAt == nil || run.IssuedAt.Location().String() != "UTC" {
|
||||||
|
t.Fatalf("expected issuedAt UTC pointer, got %v", run.IssuedAt)
|
||||||
|
}
|
||||||
|
if run.Outlooks != nil {
|
||||||
|
t.Fatalf("expected nil outlooks before child load, got %+v", run.Outlooks)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMapOutlookRunParentRowMissingOptionals(t *testing.T) {
|
||||||
|
run := mapOutlookRunParentRow(outlookRunParentRow{
|
||||||
|
AsOf: time.Date(2026, 6, 11, 16, 30, 0, 0, time.UTC),
|
||||||
|
})
|
||||||
|
|
||||||
|
if run.LocationID != "" || run.LocationName != "" {
|
||||||
|
t.Fatalf("expected empty location metadata, got %+v", run)
|
||||||
|
}
|
||||||
|
if run.Latitude != nil || run.Longitude != nil || run.IssuedAt != nil {
|
||||||
|
t.Fatalf("expected nil optional pointers, got %+v", run)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMapOutlookRowMapsFields(t *testing.T) {
|
||||||
|
validFrom := time.Date(2026, 6, 11, 7, 0, 0, 0, time.FixedZone("CDT", -5*3600))
|
||||||
|
validTo := validFrom.Add(12 * time.Hour)
|
||||||
|
issuedAt := validFrom.Add(-1 * time.Hour)
|
||||||
|
expiresAt := validTo
|
||||||
|
severityRank := int64(5)
|
||||||
|
geometry := `{"type":"Polygon","coordinates":[[[-91,38],[-90,38],[-90,39],[-91,38]]]}`
|
||||||
|
|
||||||
|
outlook, err := mapOutlookRow(outlookRow{
|
||||||
|
OutlookIndex: 3,
|
||||||
|
OutlookID: "spc-20260611-1300-day1-cat-slight",
|
||||||
|
Provider: "spc",
|
||||||
|
Product: "convective",
|
||||||
|
Day: 1,
|
||||||
|
OutlookType: "categorical",
|
||||||
|
Label: "SLGT",
|
||||||
|
LabelText: sql.NullString{String: "Slight Risk", Valid: true},
|
||||||
|
SeverityRank: sql.NullInt64{Int64: severityRank, Valid: true},
|
||||||
|
ValidFrom: validFrom,
|
||||||
|
ValidTo: validTo,
|
||||||
|
IssuedAt: issuedAt,
|
||||||
|
ExpiresAt: expiresAt,
|
||||||
|
Forecaster: sql.NullString{String: "DIAL", Valid: true},
|
||||||
|
Headline: sql.NullString{String: "Severe storms possible", Valid: true},
|
||||||
|
Summary: sql.NullString{String: "Scattered severe storms are possible.", Valid: true},
|
||||||
|
Discussion: sql.NullString{String: "Discussion text.", Valid: true},
|
||||||
|
SourceURL: sql.NullString{String: "https://www.spc.noaa.gov/products/outlook/day1otlk.html", Valid: true},
|
||||||
|
ImageURL: sql.NullString{String: "https://www.spc.noaa.gov/products/outlook/day1otlk.gif", Valid: true},
|
||||||
|
ContainsLocation: true,
|
||||||
|
GeometryJSON: geometry,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if outlook.ID != "spc-20260611-1300-day1-cat-slight" {
|
||||||
|
t.Fatalf("unexpected outlook id: %q", outlook.ID)
|
||||||
|
}
|
||||||
|
if outlook.Provider != "spc" || outlook.Product != "convective" || outlook.Day != 1 || outlook.OutlookType != "categorical" {
|
||||||
|
t.Fatalf("unexpected core fields: %+v", outlook)
|
||||||
|
}
|
||||||
|
if outlook.Label != "SLGT" || outlook.LabelText != "Slight Risk" {
|
||||||
|
t.Fatalf("unexpected label fields: %+v", outlook)
|
||||||
|
}
|
||||||
|
if outlook.SeverityRank == nil || *outlook.SeverityRank != int(severityRank) {
|
||||||
|
t.Fatalf("expected severity rank %d, got %v", severityRank, outlook.SeverityRank)
|
||||||
|
}
|
||||||
|
if outlook.Forecaster != "DIAL" || outlook.Headline == "" || outlook.Summary == "" || outlook.Discussion == "" {
|
||||||
|
t.Fatalf("unexpected text fields: %+v", outlook)
|
||||||
|
}
|
||||||
|
if outlook.SourceURL == "" || outlook.ImageURL == "" {
|
||||||
|
t.Fatalf("expected source and image URLs: %+v", outlook)
|
||||||
|
}
|
||||||
|
if !outlook.ContainsLocation {
|
||||||
|
t.Fatal("expected containsLocation true")
|
||||||
|
}
|
||||||
|
if outlook.ValidFrom.Location().String() != "UTC" ||
|
||||||
|
outlook.ValidTo.Location().String() != "UTC" ||
|
||||||
|
outlook.IssuedAt.Location().String() != "UTC" ||
|
||||||
|
outlook.ExpiresAt.Location().String() != "UTC" {
|
||||||
|
t.Fatalf("expected UTC timestamps, got %s %s %s %s", outlook.ValidFrom, outlook.ValidTo, outlook.IssuedAt, outlook.ExpiresAt)
|
||||||
|
}
|
||||||
|
if string(outlook.Geometry) != geometry {
|
||||||
|
t.Fatalf("expected geometry bytes preserved, got %s", outlook.Geometry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMapOutlookRowMissingOptionals(t *testing.T) {
|
||||||
|
outlook, err := mapOutlookRow(outlookRow{
|
||||||
|
OutlookID: "tor-1",
|
||||||
|
Provider: "spc",
|
||||||
|
Product: "convective",
|
||||||
|
Day: 1,
|
||||||
|
OutlookType: "tornado",
|
||||||
|
Label: "2%",
|
||||||
|
ValidFrom: time.Date(2026, 6, 11, 12, 0, 0, 0, time.UTC),
|
||||||
|
ValidTo: time.Date(2026, 6, 12, 0, 0, 0, 0, time.UTC),
|
||||||
|
IssuedAt: time.Date(2026, 6, 11, 11, 0, 0, 0, time.UTC),
|
||||||
|
ExpiresAt: time.Date(2026, 6, 12, 0, 0, 0, 0, time.UTC),
|
||||||
|
ContainsLocation: false,
|
||||||
|
GeometryJSON: `{"type":"Point","coordinates":[-90.2,38.6]}`,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if outlook.LabelText != "" || outlook.Forecaster != "" || outlook.Headline != "" ||
|
||||||
|
outlook.Summary != "" || outlook.Discussion != "" || outlook.SourceURL != "" || outlook.ImageURL != "" {
|
||||||
|
t.Fatalf("expected optional strings to map to empty values, got %+v", outlook)
|
||||||
|
}
|
||||||
|
if outlook.SeverityRank != nil {
|
||||||
|
t.Fatalf("expected nil severity rank, got %v", *outlook.SeverityRank)
|
||||||
|
}
|
||||||
|
if outlook.ContainsLocation {
|
||||||
|
t.Fatal("expected containsLocation false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMapOutlookRowRejectsInvalidGeometry(t *testing.T) {
|
||||||
|
_, err := mapOutlookRow(outlookRow{
|
||||||
|
OutlookID: "bad-geometry",
|
||||||
|
Provider: "spc",
|
||||||
|
Product: "convective",
|
||||||
|
Day: 1,
|
||||||
|
OutlookType: "categorical",
|
||||||
|
Label: "SLGT",
|
||||||
|
ValidFrom: time.Date(2026, 6, 11, 12, 0, 0, 0, time.UTC),
|
||||||
|
ValidTo: time.Date(2026, 6, 12, 0, 0, 0, 0, time.UTC),
|
||||||
|
IssuedAt: time.Date(2026, 6, 11, 11, 0, 0, 0, time.UTC),
|
||||||
|
ExpiresAt: time.Date(2026, 6, 12, 0, 0, 0, 0, time.UTC),
|
||||||
|
GeometryJSON: `{"type":"Point"`,
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected invalid geometry error")
|
||||||
|
}
|
||||||
|
}
|
||||||
45
internal/adapters/outbound/postgres/outlooks_queries.go
Normal file
45
internal/adapters/outbound/postgres/outlooks_queries.go
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
// outlooks_queries.go contains SQL text for outlook reads.
|
||||||
|
// Layer: adapters/outbound/postgres outlook feature.
|
||||||
|
package postgres
|
||||||
|
|
||||||
|
const (
|
||||||
|
queryLatestConvectiveOutlookRun = `
|
||||||
|
SELECT
|
||||||
|
event_id,
|
||||||
|
location_id,
|
||||||
|
location_name,
|
||||||
|
latitude,
|
||||||
|
longitude,
|
||||||
|
as_of,
|
||||||
|
issued_at
|
||||||
|
FROM outlook_runs
|
||||||
|
ORDER BY as_of DESC, event_emitted_at DESC
|
||||||
|
LIMIT 1`
|
||||||
|
|
||||||
|
queryOutlooksForRun = `
|
||||||
|
SELECT
|
||||||
|
outlook_index,
|
||||||
|
outlook_id,
|
||||||
|
provider,
|
||||||
|
product,
|
||||||
|
day,
|
||||||
|
outlook_type,
|
||||||
|
label,
|
||||||
|
label_text,
|
||||||
|
severity_rank,
|
||||||
|
valid_from,
|
||||||
|
valid_to,
|
||||||
|
issued_at,
|
||||||
|
expires_at,
|
||||||
|
forecaster,
|
||||||
|
headline,
|
||||||
|
summary,
|
||||||
|
discussion,
|
||||||
|
source_url,
|
||||||
|
image_url,
|
||||||
|
contains_location,
|
||||||
|
geometry_json
|
||||||
|
FROM outlooks
|
||||||
|
WHERE run_event_id = $1
|
||||||
|
ORDER BY outlook_index ASC`
|
||||||
|
)
|
||||||
92
internal/adapters/outbound/postgres/outlooks_read.go
Normal file
92
internal/adapters/outbound/postgres/outlooks_read.go
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
// outlooks_read.go executes outlook-run and outlook queries.
|
||||||
|
// Layer: adapters/outbound/postgres outlook feature.
|
||||||
|
package postgres
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (r *Repository) LatestConvectiveOutlookRun(ctx context.Context) (*model.WeatherOutlookRun, error) {
|
||||||
|
if r == nil || r.db == nil {
|
||||||
|
return nil, fmt.Errorf("postgres repository is not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
var row outlookRunParentRow
|
||||||
|
err := r.db.QueryRowContext(ctx, queryLatestConvectiveOutlookRun).Scan(
|
||||||
|
&row.EventID,
|
||||||
|
&row.LocationID,
|
||||||
|
&row.LocationName,
|
||||||
|
&row.Latitude,
|
||||||
|
&row.Longitude,
|
||||||
|
&row.AsOf,
|
||||||
|
&row.IssuedAt,
|
||||||
|
)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("query latest convective outlook run: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
run := mapOutlookRunParentRow(row)
|
||||||
|
outlooks, err := r.loadOutlooks(ctx, row.EventID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
run.Outlooks = outlooks
|
||||||
|
|
||||||
|
return &run, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) loadOutlooks(ctx context.Context, eventID string) ([]model.WeatherOutlook, error) {
|
||||||
|
rows, err := r.db.QueryContext(ctx, queryOutlooksForRun, eventID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("query outlooks: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
out := make([]model.WeatherOutlook, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
var row outlookRow
|
||||||
|
if err := rows.Scan(
|
||||||
|
&row.OutlookIndex,
|
||||||
|
&row.OutlookID,
|
||||||
|
&row.Provider,
|
||||||
|
&row.Product,
|
||||||
|
&row.Day,
|
||||||
|
&row.OutlookType,
|
||||||
|
&row.Label,
|
||||||
|
&row.LabelText,
|
||||||
|
&row.SeverityRank,
|
||||||
|
&row.ValidFrom,
|
||||||
|
&row.ValidTo,
|
||||||
|
&row.IssuedAt,
|
||||||
|
&row.ExpiresAt,
|
||||||
|
&row.Forecaster,
|
||||||
|
&row.Headline,
|
||||||
|
&row.Summary,
|
||||||
|
&row.Discussion,
|
||||||
|
&row.SourceURL,
|
||||||
|
&row.ImageURL,
|
||||||
|
&row.ContainsLocation,
|
||||||
|
&row.GeometryJSON,
|
||||||
|
); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan outlook row: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
outlook, err := mapOutlookRow(row)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("map outlook row: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, outlook)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("iterate outlook rows: %w", err)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
42
internal/adapters/outbound/postgres/outlooks_rows.go
Normal file
42
internal/adapters/outbound/postgres/outlooks_rows.go
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
// outlooks_rows.go defines row DTOs for outlook reads.
|
||||||
|
// Layer: adapters/outbound/postgres outlook feature.
|
||||||
|
package postgres
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type outlookRunParentRow struct {
|
||||||
|
EventID string
|
||||||
|
LocationID sql.NullString
|
||||||
|
LocationName sql.NullString
|
||||||
|
Latitude sql.NullFloat64
|
||||||
|
Longitude sql.NullFloat64
|
||||||
|
AsOf time.Time
|
||||||
|
IssuedAt sql.NullTime
|
||||||
|
}
|
||||||
|
|
||||||
|
type outlookRow struct {
|
||||||
|
OutlookIndex int
|
||||||
|
OutlookID string
|
||||||
|
Provider string
|
||||||
|
Product string
|
||||||
|
Day int
|
||||||
|
OutlookType string
|
||||||
|
Label string
|
||||||
|
LabelText sql.NullString
|
||||||
|
SeverityRank sql.NullInt64
|
||||||
|
ValidFrom time.Time
|
||||||
|
ValidTo time.Time
|
||||||
|
IssuedAt time.Time
|
||||||
|
ExpiresAt time.Time
|
||||||
|
Forecaster sql.NullString
|
||||||
|
Headline sql.NullString
|
||||||
|
Summary sql.NullString
|
||||||
|
Discussion sql.NullString
|
||||||
|
SourceURL sql.NullString
|
||||||
|
ImageURL sql.NullString
|
||||||
|
ContainsLocation bool
|
||||||
|
GeometryJSON string
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user