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:
@@ -37,7 +37,8 @@ var (
|
||||
type DatasetRepository interface {
|
||||
Create(ctx context.Context, d domain.Dataset) (domain.Dataset, error)
|
||||
GetByID(ctx context.Context, id uuid.UUID) (domain.Dataset, error)
|
||||
UpdateFields(ctx context.Context, id, categoryID uuid.UUID, name string, description, unit *string) (domain.Dataset, error)
|
||||
UpdateFields(ctx context.Context, id uuid.UUID, f domain.DatasetFields) (domain.Dataset, error)
|
||||
LatestInGroup(ctx context.Context, groupID uuid.UUID, status *string, fileType *domain.FileType) (domain.Dataset, error)
|
||||
ListSummaries(ctx context.Context, filter domain.DatasetFilter, limit, offset int) ([]domain.DatasetSummary, error)
|
||||
Count(ctx context.Context, filter domain.DatasetFilter) (int, error)
|
||||
Delete(ctx context.Context, id uuid.UUID) error
|
||||
@@ -125,6 +126,11 @@ type UploadInput struct {
|
||||
ContentType string
|
||||
Size int64
|
||||
Reader io.Reader
|
||||
// DatasetGroupID optionally files the dataset under a dataset group.
|
||||
DatasetGroupID *uuid.UUID
|
||||
// StartDate / EndDate is the period the dataset describes (YYYY-MM-DD).
|
||||
StartDate *string
|
||||
EndDate *string
|
||||
}
|
||||
|
||||
// DatasetService implements dataset business rules and object storage handling.
|
||||
@@ -190,6 +196,9 @@ func (s *DatasetService) Upload(ctx context.Context, in UploadInput) (domain.Dat
|
||||
if err := s.ensureCategoryExists(ctx, in.CategoryID); err != nil {
|
||||
return domain.Dataset{}, err
|
||||
}
|
||||
if err := domain.ValidateDatePeriod(in.StartDate, in.EndDate); err != nil {
|
||||
return domain.Dataset{}, err
|
||||
}
|
||||
|
||||
name := in.Name
|
||||
if name == "" {
|
||||
@@ -210,18 +219,21 @@ func (s *DatasetService) Upload(ctx context.Context, in UploadInput) (domain.Dat
|
||||
}
|
||||
|
||||
dataset, err := s.repo.Create(ctx, domain.Dataset{
|
||||
CategoryID: in.CategoryID,
|
||||
Name: name,
|
||||
Description: in.Description,
|
||||
Unit: in.Unit,
|
||||
Meta: in.Meta,
|
||||
Automated: in.Automated,
|
||||
Status: status,
|
||||
Filename: in.Filename,
|
||||
StorageKey: storageKey,
|
||||
FileType: in.FileType,
|
||||
SizeBytes: in.Size,
|
||||
ContentType: in.ContentType,
|
||||
CategoryID: in.CategoryID,
|
||||
DatasetGroupID: in.DatasetGroupID,
|
||||
StartDate: in.StartDate,
|
||||
EndDate: in.EndDate,
|
||||
Name: name,
|
||||
Description: in.Description,
|
||||
Unit: in.Unit,
|
||||
Meta: in.Meta,
|
||||
Automated: in.Automated,
|
||||
Status: status,
|
||||
Filename: in.Filename,
|
||||
StorageKey: storageKey,
|
||||
FileType: in.FileType,
|
||||
SizeBytes: in.Size,
|
||||
ContentType: in.ContentType,
|
||||
})
|
||||
if err != nil {
|
||||
// Compensate: the row was not written, so the object would be orphaned.
|
||||
@@ -682,17 +694,17 @@ func (s *DatasetService) SaveMapping(ctx context.Context, id uuid.UUID, in Mappi
|
||||
if _, ok := known[yc.Column]; !ok {
|
||||
return domain.Dataset{}, fmt.Errorf("%w: year column %q is not among the detected columns", domain.ErrValidation, yc.Column)
|
||||
}
|
||||
from, err := time.Parse("2006-01-02", yc.Date)
|
||||
from, err := time.Parse("2006-01-02", yc.StartDate)
|
||||
if err != nil {
|
||||
return domain.Dataset{}, fmt.Errorf("%w: invalid date %q for column %q (want YYYY-MM-DD)", domain.ErrValidation, yc.Date, yc.Column)
|
||||
return domain.Dataset{}, fmt.Errorf("%w: invalid start_date %q for column %q (want YYYY-MM-DD)", domain.ErrValidation, yc.StartDate, yc.Column)
|
||||
}
|
||||
if yc.DateTo != nil {
|
||||
to, err := time.Parse("2006-01-02", *yc.DateTo)
|
||||
if yc.EndDate != nil {
|
||||
to, err := time.Parse("2006-01-02", *yc.EndDate)
|
||||
if err != nil {
|
||||
return domain.Dataset{}, fmt.Errorf("%w: invalid date_to %q for column %q (want YYYY-MM-DD)", domain.ErrValidation, *yc.DateTo, yc.Column)
|
||||
return domain.Dataset{}, fmt.Errorf("%w: invalid end_date %q for column %q (want YYYY-MM-DD)", domain.ErrValidation, *yc.EndDate, yc.Column)
|
||||
}
|
||||
if to.Before(from) {
|
||||
return domain.Dataset{}, fmt.Errorf("%w: date_to %q is before date %q for column %q", domain.ErrValidation, *yc.DateTo, yc.Date, yc.Column)
|
||||
return domain.Dataset{}, fmt.Errorf("%w: end_date %q is before start_date %q for column %q", domain.ErrValidation, *yc.EndDate, yc.StartDate, yc.Column)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -748,9 +760,10 @@ func (s *DatasetService) Extract(ctx context.Context, id uuid.UUID) error {
|
||||
}
|
||||
|
||||
// buildObservations unpivots rows into observations. Rows without a KATO code
|
||||
// are skipped; duplicate (kato, date, date_to) tuples keep the last value.
|
||||
// Numeric cells populate Value, others ValueText. A year column's DateTo (nil
|
||||
// for a point-in-time mapping) carries through to the observation unchanged.
|
||||
// are skipped; duplicate (kato, start_date, end_date) tuples keep the last
|
||||
// value. Numeric cells populate Value, others ValueText. A year column's
|
||||
// EndDate (nil for a point-in-time mapping) carries through to the observation
|
||||
// unchanged.
|
||||
func buildObservations(datasetID uuid.UUID, katoColumn string, years []domain.YearColumn, rows []map[string]string) []domain.Observation {
|
||||
obs := make([]domain.Observation, 0, len(rows)*len(years))
|
||||
index := make(map[string]int)
|
||||
@@ -761,7 +774,7 @@ func buildObservations(datasetID uuid.UUID, katoColumn string, years []domain.Ye
|
||||
continue
|
||||
}
|
||||
for _, yc := range years {
|
||||
o := domain.Observation{DatasetID: datasetID, KatoCode: kato, Date: yc.Date, DateTo: yc.DateTo}
|
||||
o := domain.Observation{DatasetID: datasetID, KatoCode: kato, StartDate: yc.StartDate, EndDate: yc.EndDate}
|
||||
if raw := strings.TrimSpace(row[yc.Column]); raw != "" {
|
||||
if f, err := strconv.ParseFloat(raw, 64); err == nil {
|
||||
o.Value = &f
|
||||
@@ -769,7 +782,7 @@ func buildObservations(datasetID uuid.UUID, katoColumn string, years []domain.Ye
|
||||
o.ValueText = &raw
|
||||
}
|
||||
}
|
||||
key := kato + "\x00" + yc.Date + "\x00" + dateToKey(yc.DateTo)
|
||||
key := kato + "\x00" + yc.StartDate + "\x00" + endDateKey(yc.EndDate)
|
||||
if i, ok := index[key]; ok {
|
||||
obs[i] = o
|
||||
} else {
|
||||
@@ -781,12 +794,12 @@ func buildObservations(datasetID uuid.UUID, katoColumn string, years []domain.Ye
|
||||
return obs
|
||||
}
|
||||
|
||||
// dateToKey normalizes an optional period end date for use in a dedup key.
|
||||
func dateToKey(dateTo *string) string {
|
||||
if dateTo == nil {
|
||||
// endDateKey normalizes an optional period end date for use in a dedup key.
|
||||
func endDateKey(endDate *string) string {
|
||||
if endDate == nil {
|
||||
return ""
|
||||
}
|
||||
return *dateTo
|
||||
return *endDate
|
||||
}
|
||||
|
||||
// ObservationPage is a page of observations with pagination metadata.
|
||||
@@ -888,7 +901,7 @@ func (s *DatasetService) generateGeoJSON(ctx context.Context, id uuid.UUID) erro
|
||||
// boundary taken from the districts table and its observation values nested
|
||||
// under a `data` object (keyed by date) alongside `kato` and `name`. KATO codes
|
||||
// with no matching district are skipped. When any observation carries a period
|
||||
// (DateTo set), its end date is additionally exposed under a `periods` object
|
||||
// (EndDate set), its end date is additionally exposed under a `periods` object
|
||||
// (keyed by the same start date used in `data`); `periods` is omitted entirely
|
||||
// when every observation is point-in-time, so a dataset mapped without periods
|
||||
// produces byte-identical output to before periods existed.
|
||||
@@ -954,7 +967,7 @@ func (s *DatasetService) loadGeoJSONDataset(ctx context.Context, id uuid.UUID) (
|
||||
// the grouped observation values (keyed by date) nested under a `data` object
|
||||
// alongside `kato` and `name` in each Feature's properties. When periods holds
|
||||
// an entry for the KATO (at least one observation had a period), its date->
|
||||
// date_to map is additionally nested under a `periods` object; a KATO with no
|
||||
// end_date map is additionally nested under a `periods` object; a KATO with no
|
||||
// period observations gets no `periods` key at all. KATO codes with no
|
||||
// matching district are skipped. order drives the deterministic feature order.
|
||||
func (s *DatasetService) districtFeatures(ctx context.Context, grouped map[string]map[string]any, periods map[string]map[string]string, order []string) ([]domain.Feature, error) {
|
||||
@@ -1018,8 +1031,8 @@ func vectorFeatureProperties(raw json.RawMessage) map[string]any {
|
||||
|
||||
// groupObservationsByKato groups observations by KATO code into date->value
|
||||
// maps. The value is the numeric Value when present, otherwise ValueText,
|
||||
// otherwise nil (an empty cell). It also groups, per KATO, a date->date_to map
|
||||
// for observations that carry a period (DateTo set); a KATO with no period
|
||||
// otherwise nil (an empty cell). It also groups, per KATO, a start_date->end_date map
|
||||
// for observations that carry a period (EndDate set); a KATO with no period
|
||||
// observations gets no entry in periods at all, so callers can tell "no
|
||||
// periods" apart from "empty periods". order lists KATO codes in first-seen
|
||||
// order so the resulting feature order is deterministic.
|
||||
@@ -1034,15 +1047,15 @@ func groupObservationsByKato(obs []domain.Observation) (grouped map[string]map[s
|
||||
grouped[o.KatoCode] = values
|
||||
order = append(order, o.KatoCode)
|
||||
}
|
||||
values[o.Date] = observationValue(o)
|
||||
values[o.StartDate] = observationValue(o)
|
||||
|
||||
if o.DateTo != nil {
|
||||
if o.EndDate != nil {
|
||||
p, ok := periods[o.KatoCode]
|
||||
if !ok {
|
||||
p = make(map[string]string)
|
||||
periods[o.KatoCode] = p
|
||||
}
|
||||
p[o.Date] = *o.DateTo
|
||||
p[o.StartDate] = *o.EndDate
|
||||
}
|
||||
}
|
||||
return grouped, periods, order
|
||||
@@ -1074,6 +1087,10 @@ type DatasetUpdateInput struct {
|
||||
Name string
|
||||
Description *string
|
||||
Unit *string
|
||||
// DatasetGroupID, StartDate and EndDate are nullable: nil clears them.
|
||||
DatasetGroupID *uuid.UUID
|
||||
StartDate *string
|
||||
EndDate *string
|
||||
}
|
||||
|
||||
// Update replaces a dataset's editable metadata (category, name, description,
|
||||
@@ -1082,7 +1099,18 @@ func (s *DatasetService) Update(ctx context.Context, id uuid.UUID, in DatasetUpd
|
||||
if err := s.ensureCategoryExists(ctx, in.CategoryID); err != nil {
|
||||
return domain.Dataset{}, err
|
||||
}
|
||||
return s.repo.UpdateFields(ctx, id, in.CategoryID, in.Name, in.Description, in.Unit)
|
||||
if err := domain.ValidateDatePeriod(in.StartDate, in.EndDate); err != nil {
|
||||
return domain.Dataset{}, err
|
||||
}
|
||||
return s.repo.UpdateFields(ctx, id, domain.DatasetFields{
|
||||
CategoryID: in.CategoryID,
|
||||
Name: in.Name,
|
||||
Description: in.Description,
|
||||
Unit: in.Unit,
|
||||
DatasetGroupID: in.DatasetGroupID,
|
||||
StartDate: in.StartDate,
|
||||
EndDate: in.EndDate,
|
||||
})
|
||||
}
|
||||
|
||||
// DatasetPatchInput carries a sparse set of metadata field changes. A nil
|
||||
@@ -1096,6 +1124,13 @@ type DatasetPatchInput struct {
|
||||
DescriptionSet bool
|
||||
Unit *string
|
||||
UnitSet bool
|
||||
|
||||
DatasetGroupID *uuid.UUID
|
||||
DatasetGroupIDSet bool
|
||||
StartDate *string
|
||||
StartDateSet bool
|
||||
EndDate *string
|
||||
EndDateSet bool
|
||||
}
|
||||
|
||||
// Patch applies a sparse update to a dataset's editable metadata (category,
|
||||
@@ -1125,8 +1160,31 @@ func (s *DatasetService) Patch(ctx context.Context, id uuid.UUID, in DatasetPatc
|
||||
if in.UnitSet {
|
||||
unit = in.Unit
|
||||
}
|
||||
groupID := current.DatasetGroupID
|
||||
if in.DatasetGroupIDSet {
|
||||
groupID = in.DatasetGroupID
|
||||
}
|
||||
startDate := current.StartDate
|
||||
if in.StartDateSet {
|
||||
startDate = in.StartDate
|
||||
}
|
||||
endDate := current.EndDate
|
||||
if in.EndDateSet {
|
||||
endDate = in.EndDate
|
||||
}
|
||||
if err := domain.ValidateDatePeriod(startDate, endDate); err != nil {
|
||||
return domain.Dataset{}, err
|
||||
}
|
||||
|
||||
return s.repo.UpdateFields(ctx, id, categoryID, name, description, unit)
|
||||
return s.repo.UpdateFields(ctx, id, domain.DatasetFields{
|
||||
CategoryID: categoryID,
|
||||
Name: name,
|
||||
Description: description,
|
||||
Unit: unit,
|
||||
DatasetGroupID: groupID,
|
||||
StartDate: startDate,
|
||||
EndDate: endDate,
|
||||
})
|
||||
}
|
||||
|
||||
// ensureCategoryExists validates that categoryID references an existing
|
||||
|
||||
@@ -56,19 +56,60 @@ func (r *stubDatasetRepo) GetByID(_ context.Context, id uuid.UUID) (domain.Datas
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func (r *stubDatasetRepo) UpdateFields(_ context.Context, id, categoryID uuid.UUID, name string, description, unit *string) (domain.Dataset, error) {
|
||||
func (r *stubDatasetRepo) UpdateFields(_ context.Context, id uuid.UUID, f domain.DatasetFields) (domain.Dataset, error) {
|
||||
d, ok := r.store[id]
|
||||
if !ok {
|
||||
return domain.Dataset{}, domain.ErrNotFound
|
||||
}
|
||||
d.CategoryID = categoryID
|
||||
d.Name = name
|
||||
d.Description = description
|
||||
d.Unit = unit
|
||||
d.CategoryID = f.CategoryID
|
||||
d.Name = f.Name
|
||||
d.Description = f.Description
|
||||
d.Unit = f.Unit
|
||||
d.DatasetGroupID = f.DatasetGroupID
|
||||
d.StartDate = f.StartDate
|
||||
d.EndDate = f.EndDate
|
||||
r.store[id] = d
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func (r *stubDatasetRepo) LatestInGroup(_ context.Context, groupID uuid.UUID, status *string, fileType *domain.FileType) (domain.Dataset, error) {
|
||||
var best *domain.Dataset
|
||||
for _, d := range r.store {
|
||||
d := d
|
||||
if d.DatasetGroupID == nil || *d.DatasetGroupID != groupID {
|
||||
continue
|
||||
}
|
||||
if status != nil && d.Status != *status {
|
||||
continue
|
||||
}
|
||||
if fileType != nil && d.FileType != *fileType {
|
||||
continue
|
||||
}
|
||||
if best == nil || laterPeriod(d, *best) {
|
||||
best = &d
|
||||
}
|
||||
}
|
||||
if best == nil {
|
||||
return domain.Dataset{}, domain.ErrNotFound
|
||||
}
|
||||
return *best, nil
|
||||
}
|
||||
|
||||
// laterPeriod reports whether a is fresher than b: greater end_date, NULL
|
||||
// end_date last, then newer created_at.
|
||||
func laterPeriod(a, b domain.Dataset) bool {
|
||||
switch {
|
||||
case a.EndDate != nil && b.EndDate == nil:
|
||||
return true
|
||||
case a.EndDate == nil && b.EndDate != nil:
|
||||
return false
|
||||
case a.EndDate != nil && b.EndDate != nil && *a.EndDate != *b.EndDate:
|
||||
return *a.EndDate > *b.EndDate
|
||||
default:
|
||||
return a.CreatedAt.After(b.CreatedAt)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *stubDatasetRepo) ListSummaries(_ context.Context, filter domain.DatasetFilter, limit, offset int) ([]domain.DatasetSummary, error) {
|
||||
r.lastFilter = filter
|
||||
r.lastLimit = limit
|
||||
@@ -756,7 +797,7 @@ func TestDatasetService_SaveMapping(t *testing.T) {
|
||||
Status: domain.DatasetStatusAwaitingMapping,
|
||||
AttributeColumns: []domain.AttributeColumn{{Name: "като"}, {Name: "F_2023"}},
|
||||
}
|
||||
validYears := []domain.YearColumn{{Column: "F_2023", Date: "2023-01-01"}}
|
||||
validYears := []domain.YearColumn{{Column: "F_2023", StartDate: "2023-01-01"}}
|
||||
|
||||
newSvc := func() (*stubDatasetRepo, *DatasetService) {
|
||||
repo := newStubDatasetRepo()
|
||||
@@ -774,31 +815,31 @@ func TestDatasetService_SaveMapping(t *testing.T) {
|
||||
|
||||
t.Run("unknown year column", func(t *testing.T) {
|
||||
_, svc := newSvc()
|
||||
_, err := svc.SaveMapping(ctx, id, MappingInput{KatoColumn: "като", YearColumns: []domain.YearColumn{{Column: "X", Date: "2023-01-01"}}})
|
||||
_, err := svc.SaveMapping(ctx, id, MappingInput{KatoColumn: "като", YearColumns: []domain.YearColumn{{Column: "X", StartDate: "2023-01-01"}}})
|
||||
if !errors.Is(err, domain.ErrValidation) {
|
||||
t.Fatalf("want ErrValidation, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("bad date", func(t *testing.T) {
|
||||
t.Run("bad start_date", func(t *testing.T) {
|
||||
_, svc := newSvc()
|
||||
_, err := svc.SaveMapping(ctx, id, MappingInput{KatoColumn: "като", YearColumns: []domain.YearColumn{{Column: "F_2023", Date: "2023"}}})
|
||||
_, err := svc.SaveMapping(ctx, id, MappingInput{KatoColumn: "като", YearColumns: []domain.YearColumn{{Column: "F_2023", StartDate: "2023"}}})
|
||||
if !errors.Is(err, domain.ErrValidation) {
|
||||
t.Fatalf("want ErrValidation, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("bad date_to", func(t *testing.T) {
|
||||
t.Run("bad end_date", func(t *testing.T) {
|
||||
_, svc := newSvc()
|
||||
_, err := svc.SaveMapping(ctx, id, MappingInput{KatoColumn: "като", YearColumns: []domain.YearColumn{{Column: "F_2023", Date: "2023-01-01", DateTo: strPtr("2023")}}})
|
||||
_, err := svc.SaveMapping(ctx, id, MappingInput{KatoColumn: "като", YearColumns: []domain.YearColumn{{Column: "F_2023", StartDate: "2023-01-01", EndDate: strPtr("2023")}}})
|
||||
if !errors.Is(err, domain.ErrValidation) {
|
||||
t.Fatalf("want ErrValidation, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("date_to before date", func(t *testing.T) {
|
||||
t.Run("end_date before start_date", func(t *testing.T) {
|
||||
_, svc := newSvc()
|
||||
_, err := svc.SaveMapping(ctx, id, MappingInput{KatoColumn: "като", YearColumns: []domain.YearColumn{{Column: "F_2023", Date: "2023-12-31", DateTo: strPtr("2023-01-01")}}})
|
||||
_, err := svc.SaveMapping(ctx, id, MappingInput{KatoColumn: "като", YearColumns: []domain.YearColumn{{Column: "F_2023", StartDate: "2023-12-31", EndDate: strPtr("2023-01-01")}}})
|
||||
if !errors.Is(err, domain.ErrValidation) {
|
||||
t.Fatalf("want ErrValidation, got %v", err)
|
||||
}
|
||||
@@ -808,12 +849,12 @@ func TestDatasetService_SaveMapping(t *testing.T) {
|
||||
repo, svc := newSvc()
|
||||
got, err := svc.SaveMapping(ctx, id, MappingInput{
|
||||
KatoColumn: "като",
|
||||
YearColumns: []domain.YearColumn{{Column: "F_2023", Date: "2020-01-01", DateTo: strPtr("2023-12-31")}},
|
||||
YearColumns: []domain.YearColumn{{Column: "F_2023", StartDate: "2020-01-01", EndDate: strPtr("2023-12-31")}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(got.YearColumns) != 1 || got.YearColumns[0].DateTo == nil || *got.YearColumns[0].DateTo != "2023-12-31" {
|
||||
if len(got.YearColumns) != 1 || got.YearColumns[0].EndDate == nil || *got.YearColumns[0].EndDate != "2023-12-31" {
|
||||
t.Fatalf("period not saved: %+v", got.YearColumns)
|
||||
}
|
||||
if len(repo.store[id].YearColumns) != 1 {
|
||||
@@ -866,8 +907,8 @@ func TestDatasetService_SaveMapping(t *testing.T) {
|
||||
func TestBuildObservations(t *testing.T) {
|
||||
id := uuid.New()
|
||||
years := []domain.YearColumn{
|
||||
{Column: "F_2023", Date: "2023-01-01"},
|
||||
{Column: "D_2025", Date: "2025-01-01"},
|
||||
{Column: "F_2023", StartDate: "2023-01-01"},
|
||||
{Column: "D_2025", StartDate: "2025-01-01"},
|
||||
}
|
||||
rows := []map[string]string{
|
||||
{"като": "751010000", "F_2023": "100", "D_2025": "n/a"},
|
||||
@@ -882,7 +923,7 @@ func TestBuildObservations(t *testing.T) {
|
||||
|
||||
byKey := map[string]domain.Observation{}
|
||||
for _, o := range obs {
|
||||
byKey[o.KatoCode+"|"+o.Date] = o
|
||||
byKey[o.KatoCode+"|"+o.StartDate] = o
|
||||
}
|
||||
if o := byKey["751010000|2023-01-01"]; o.Value == nil || *o.Value != 100 {
|
||||
t.Errorf("numeric cell not stored as value: %+v", o)
|
||||
@@ -891,8 +932,8 @@ func TestBuildObservations(t *testing.T) {
|
||||
t.Errorf("non-numeric cell not stored as value_text: %+v", o)
|
||||
}
|
||||
for _, o := range obs {
|
||||
if o.DateTo != nil {
|
||||
t.Fatalf("point-in-time mapping must not set DateTo, got %+v", o)
|
||||
if o.EndDate != nil {
|
||||
t.Fatalf("point-in-time mapping must not set EndDate, got %+v", o)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -900,8 +941,8 @@ func TestBuildObservations(t *testing.T) {
|
||||
func TestBuildObservations_Period(t *testing.T) {
|
||||
id := uuid.New()
|
||||
years := []domain.YearColumn{
|
||||
{Column: "F_2020_2023", Date: "2020-01-01", DateTo: strPtr("2023-12-31")}, // period
|
||||
{Column: "F_2024", Date: "2024-01-01"}, // point-in-time, unaffected
|
||||
{Column: "F_2020_2023", StartDate: "2020-01-01", EndDate: strPtr("2023-12-31")}, // period
|
||||
{Column: "F_2024", StartDate: "2024-01-01"}, // point-in-time, unaffected
|
||||
}
|
||||
rows := []map[string]string{
|
||||
{"като": "751010000", "F_2020_2023": "100", "F_2024": "200"},
|
||||
@@ -912,22 +953,22 @@ func TestBuildObservations_Period(t *testing.T) {
|
||||
t.Fatalf("want 2 observations, got %d", len(obs))
|
||||
}
|
||||
|
||||
byDate := map[string]domain.Observation{}
|
||||
byStartDate := map[string]domain.Observation{}
|
||||
for _, o := range obs {
|
||||
byDate[o.Date] = o
|
||||
byStartDate[o.StartDate] = o
|
||||
}
|
||||
|
||||
period := byDate["2020-01-01"]
|
||||
if period.DateTo == nil || *period.DateTo != "2023-12-31" {
|
||||
t.Fatalf("period observation missing date_to: %+v", period)
|
||||
period := byStartDate["2020-01-01"]
|
||||
if period.EndDate == nil || *period.EndDate != "2023-12-31" {
|
||||
t.Fatalf("period observation missing end_date: %+v", period)
|
||||
}
|
||||
if period.Value == nil || *period.Value != 100 {
|
||||
t.Fatalf("period observation value wrong: %+v", period)
|
||||
}
|
||||
|
||||
point := byDate["2024-01-01"]
|
||||
if point.DateTo != nil {
|
||||
t.Fatalf("point-in-time observation must have nil date_to (backward compat), got %+v", point)
|
||||
point := byStartDate["2024-01-01"]
|
||||
if point.EndDate != nil {
|
||||
t.Fatalf("point-in-time observation must have nil end_date (backward compat), got %+v", point)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -940,7 +981,7 @@ func TestDatasetService_Extract(t *testing.T) {
|
||||
Filename: "r.geojson", StorageKey: "k",
|
||||
Status: domain.DatasetStatusExtracting,
|
||||
KatoColumn: &kato,
|
||||
YearColumns: []domain.YearColumn{{Column: "F_2023", Date: "2023-01-01"}},
|
||||
YearColumns: []domain.YearColumn{{Column: "F_2023", StartDate: "2023-01-01"}},
|
||||
}
|
||||
repo.districts["751010000"] = domain.District{
|
||||
Kato: "751010000", Name: "Almaty",
|
||||
@@ -1002,9 +1043,9 @@ func TestDatasetService_GeoJSON_DistrictJoin(t *testing.T) {
|
||||
|
||||
v2020, v2021 := 100.0, 150.0
|
||||
repo.observations[id] = []domain.Observation{
|
||||
{KatoCode: "710000000", Date: "2020-01-01", Value: &v2020},
|
||||
{KatoCode: "710000000", Date: "2021-01-01", Value: &v2021},
|
||||
{KatoCode: "999999999", Date: "2020-01-01", Value: &v2020}, // no district -> skipped
|
||||
{KatoCode: "710000000", StartDate: "2020-01-01", Value: &v2020},
|
||||
{KatoCode: "710000000", StartDate: "2021-01-01", Value: &v2021},
|
||||
{KatoCode: "999999999", StartDate: "2020-01-01", Value: &v2020}, // no district -> skipped
|
||||
}
|
||||
repo.districts["710000000"] = domain.District{
|
||||
Kato: "710000000", Name: "Astana",
|
||||
@@ -1050,8 +1091,8 @@ func TestDatasetService_GeoJSON_Period(t *testing.T) {
|
||||
|
||||
period, point := 100.0, 200.0
|
||||
repo.observations[id] = []domain.Observation{
|
||||
{KatoCode: "710000000", Date: "2020-01-01", DateTo: strPtr("2023-12-31"), Value: &period},
|
||||
{KatoCode: "710000000", Date: "2024-01-01", Value: &point}, // point-in-time, no period
|
||||
{KatoCode: "710000000", StartDate: "2020-01-01", EndDate: strPtr("2023-12-31"), Value: &period},
|
||||
{KatoCode: "710000000", StartDate: "2024-01-01", Value: &point}, // point-in-time, no period
|
||||
}
|
||||
repo.districts["710000000"] = domain.District{
|
||||
Kato: "710000000", Name: "Astana",
|
||||
@@ -1092,7 +1133,7 @@ func TestDatasetService_GeoJSON_NoPeriods_OmitsPeriodsKey(t *testing.T) {
|
||||
repo := newStubDatasetRepo()
|
||||
repo.store[id] = domain.Dataset{ID: id, FileType: domain.FileTypeVectorWithKato, Status: domain.DatasetStatusReady}
|
||||
v := 100.0
|
||||
repo.observations[id] = []domain.Observation{{KatoCode: "710000000", Date: "2020-01-01", Value: &v}}
|
||||
repo.observations[id] = []domain.Observation{{KatoCode: "710000000", StartDate: "2020-01-01", Value: &v}}
|
||||
repo.districts["710000000"] = domain.District{
|
||||
Kato: "710000000", Name: "Astana",
|
||||
Geometry: json.RawMessage(`{"type":"Polygon","coordinates":[[[71,51],[72,51],[72,52],[71,51]]]}`),
|
||||
@@ -1116,8 +1157,8 @@ func TestDatasetService_GeoJSON_IgnoresDatasetGeometry(t *testing.T) {
|
||||
}
|
||||
v := 100.0
|
||||
repo.observations[id] = []domain.Observation{
|
||||
{KatoCode: "710000000", Date: "2020-01-01", Value: &v},
|
||||
{KatoCode: "999999999", Date: "2020-01-01", Value: &v}, // no district -> skipped
|
||||
{KatoCode: "710000000", StartDate: "2020-01-01", Value: &v},
|
||||
{KatoCode: "999999999", StartDate: "2020-01-01", Value: &v}, // no district -> skipped
|
||||
}
|
||||
district := json.RawMessage(`{"type":"Polygon","coordinates":[[[71,51],[72,51],[72,52],[71,51]]]}`)
|
||||
repo.districts["710000000"] = domain.District{Kato: "710000000", Name: "Astana", Geometry: district}
|
||||
@@ -1174,7 +1215,7 @@ func TestDatasetService_GeoJSON_LazilyGeneratesAndCaches(t *testing.T) {
|
||||
repo := newStubDatasetRepo()
|
||||
repo.store[id] = domain.Dataset{ID: id, FileType: domain.FileTypeVectorWithKato, Status: domain.DatasetStatusReady}
|
||||
v := 100.0
|
||||
repo.observations[id] = []domain.Observation{{KatoCode: "710000000", Date: "2020-01-01", Value: &v}}
|
||||
repo.observations[id] = []domain.Observation{{KatoCode: "710000000", StartDate: "2020-01-01", Value: &v}}
|
||||
repo.districts["710000000"] = domain.District{
|
||||
Kato: "710000000", Name: "Astana",
|
||||
Geometry: json.RawMessage(`{"type":"Polygon","coordinates":[[[71,51],[72,51],[72,52],[71,51]]]}`),
|
||||
@@ -1694,3 +1735,40 @@ func TestDatasetService_Patch_NotFound(t *testing.T) {
|
||||
t.Fatalf("want ErrNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatasetService_Upload_StoresPeriodAndGroup(t *testing.T) {
|
||||
repo := newStubDatasetRepo()
|
||||
svc := newDatasetService(repo, &stubStore{}, true)
|
||||
|
||||
gid := uuid.New()
|
||||
start, end := "2020-01-01", "2020-12-31"
|
||||
in := validUpload()
|
||||
in.DatasetGroupID = &gid
|
||||
in.StartDate = &start
|
||||
in.EndDate = &end
|
||||
|
||||
ds, err := svc.Upload(context.Background(), in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ds.DatasetGroupID == nil || *ds.DatasetGroupID != gid || *ds.StartDate != start || *ds.EndDate != end {
|
||||
t.Fatalf("period/group not stored: %+v", ds)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatasetService_Upload_RejectsReversedPeriod(t *testing.T) {
|
||||
store := &stubStore{}
|
||||
svc := newDatasetService(newStubDatasetRepo(), store, true)
|
||||
|
||||
start, end := "2021-01-01", "2020-12-31"
|
||||
in := validUpload()
|
||||
in.StartDate = &start
|
||||
in.EndDate = &end
|
||||
|
||||
if _, err := svc.Upload(context.Background(), in); !errors.Is(err, domain.ErrValidation) {
|
||||
t.Fatalf("want ErrValidation, got %v", err)
|
||||
}
|
||||
if len(store.put) != 0 {
|
||||
t.Fatalf("nothing should be uploaded on validation failure, got %v", store.put)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user