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