feat(datasets): periods (start_date/end_date), dg_id column, date filters; rename observation dates

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-09-05 13:16:07 +05:00
parent 37d4f3519b
commit 922812b765
10 changed files with 729 additions and 181 deletions

View File

@@ -71,20 +71,28 @@ type DatasetFilter struct {
FileType *FileType
Automated *bool
Status *string
// DatasetGroupID restricts the listing to one dataset group.
DatasetGroupID *uuid.UUID
// StartDateFrom / StartDateTo and EndDateFrom / EndDateTo are inclusive
// YYYY-MM-DD bounds on the dataset's period columns.
StartDateFrom *string
StartDateTo *string
EndDateFrom *string
EndDateTo *string
}
// Observation is a single unpivoted value from a dataset's attribute table,
// keyed by KATO code and date (optionally a date..DateTo period). Exactly one
// of Value / ValueText is typically set (numeric vs non-numeric cell); both
// keyed by KATO code and date (optionally a StartDate..EndDate period). Exactly
// one of Value / ValueText is typically set (numeric vs non-numeric cell); both
// may be nil for an empty cell.
type Observation struct {
ID uuid.UUID `json:"id"`
DatasetID uuid.UUID `json:"dataset_id"`
KatoCode string `json:"kato_code"`
Date string `json:"date"`
// DateTo is the period's end date. Nil for a point-in-time observation
StartDate string `json:"start_date"`
// EndDate is the period's end date. Nil for a point-in-time observation
// (pre-existing behavior).
DateTo *string `json:"date_to,omitempty"`
EndDate *string `json:"end_date,omitempty"`
Value *float64 `json:"value"`
ValueText *string `json:"value_text"`
}
@@ -185,21 +193,29 @@ type AttributeColumn struct {
}
// YearColumn maps an attribute column to the date (or date period) it
// represents, e.g. {"column": "F_2023", "date": "2023-01-01"} for a single
// point in time, or {"column": "F_2020_2023", "date": "2020-01-01", "date_to":
// "2023-12-31"} for a period. DateTo is optional; when nil the mapping is a
// single point in time, preserving the original (pre-period) behavior.
// represents, e.g. {"column": "F_2023", "start_date": "2023-01-01"} for a
// single point in time, or {"column": "F_2020_2023", "start_date":
// "2020-01-01", "end_date": "2023-12-31"} for a period. EndDate is optional;
// when nil the mapping is a single point in time, preserving the original
// (pre-period) behavior.
type YearColumn struct {
Column string `json:"column"`
Date string `json:"date"`
DateTo *string `json:"date_to,omitempty"`
Column string `json:"column"`
StartDate string `json:"start_date"`
EndDate *string `json:"end_date,omitempty"`
}
// DatasetSummary is the lightweight view of a dataset used in list responses.
// It omits the heavy geometry/attribute/JSONB fields.
type DatasetSummary struct {
ID uuid.UUID `json:"id"`
CategoryID uuid.UUID `json:"category_id"`
ID uuid.UUID `json:"id"`
CategoryID uuid.UUID `json:"category_id"`
// DatasetGroupID links the dataset to its dataset group ("одноимённые"
// datasets versioned by period). Nullable.
DatasetGroupID *uuid.UUID `json:"dg_id"`
// StartDate / EndDate is the period the dataset describes (YYYY-MM-DD).
// Both nullable; EndDate drives the "freshest in group" rule.
StartDate *string `json:"start_date"`
EndDate *string `json:"end_date"`
Name string `json:"name"`
Description *string `json:"description"`
Unit *string `json:"unit"`
@@ -212,13 +228,20 @@ type DatasetSummary struct {
// Dataset is a geo file stored in the object store and grouped under a category.
type Dataset struct {
ID uuid.UUID `json:"id"`
CategoryID uuid.UUID `json:"category_id"`
Name string `json:"name"`
Description *string `json:"description"`
Unit *string `json:"unit"`
Filename string `json:"filename"`
StorageKey string `json:"storage_key"`
ID uuid.UUID `json:"id"`
CategoryID uuid.UUID `json:"category_id"`
// DatasetGroupID links the dataset to its dataset group ("одноимённые"
// datasets versioned by period). Nullable.
DatasetGroupID *uuid.UUID `json:"dg_id"`
// StartDate / EndDate is the period the dataset describes (YYYY-MM-DD).
// Both nullable; EndDate drives the "freshest in group" rule.
StartDate *string `json:"start_date"`
EndDate *string `json:"end_date"`
Name string `json:"name"`
Description *string `json:"description"`
Unit *string `json:"unit"`
Filename string `json:"filename"`
StorageKey string `json:"storage_key"`
// CogStorageKey points to the Cloud-Optimized GeoTIFF for rasters. Nullable.
CogStorageKey *string `json:"cog_storage_key"`
FileType FileType `json:"file_type"`
@@ -262,3 +285,37 @@ type Dataset struct {
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// DatasetFields is the set of editable metadata columns written by
// UpdateFields. Nil pointers clear the corresponding nullable column.
type DatasetFields struct {
CategoryID uuid.UUID
Name string
Description *string
Unit *string
DatasetGroupID *uuid.UUID
StartDate *string
EndDate *string
}
// ValidateDatePeriod checks that start/end (YYYY-MM-DD, either may be nil)
// parse and that end is not before start. Returns an ErrValidation-wrapped
// error naming the offending field.
func ValidateDatePeriod(start, end *string) error {
var from, to time.Time
var err error
if start != nil {
if from, err = time.Parse("2006-01-02", *start); err != nil {
return fmt.Errorf("%w: start_date %q is not YYYY-MM-DD", ErrValidation, *start)
}
}
if end != nil {
if to, err = time.Parse("2006-01-02", *end); err != nil {
return fmt.Errorf("%w: end_date %q is not YYYY-MM-DD", ErrValidation, *end)
}
}
if start != nil && end != nil && to.Before(from) {
return fmt.Errorf("%w: end_date %q is before start_date %q", ErrValidation, *end, *start)
}
return nil
}

View File

@@ -1,6 +1,9 @@
package domain
import "testing"
import (
"errors"
"testing"
)
func TestFileTypeValid(t *testing.T) {
valid := []FileType{FileTypeVectorWithKato, FileTypeVector, FileTypeRaster}
@@ -72,3 +75,31 @@ func TestExtensionAllowedFor(t *testing.T) {
}
}
}
func TestValidateDatePeriod(t *testing.T) {
s := func(v string) *string { return &v }
cases := []struct {
name string
start, end *string
wantErr bool
}{
{"both nil", nil, nil, false},
{"start only", s("2020-01-01"), nil, false},
{"ordered", s("2020-01-01"), s("2020-12-31"), false},
{"same day", s("2020-01-01"), s("2020-01-01"), false},
{"reversed", s("2020-12-31"), s("2020-01-01"), true},
{"bad start", s("01.01.2020"), nil, true},
{"bad end", nil, s("2020-13-01"), true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := ValidateDatePeriod(tc.start, tc.end)
if (err != nil) != tc.wantErr {
t.Fatalf("err=%v wantErr=%v", err, tc.wantErr)
}
if err != nil && !errors.Is(err, ErrValidation) {
t.Fatalf("expected ErrValidation, got %v", err)
}
})
}
}