fix: Change geojson properties structure

This commit is contained in:
Bakhtiyar Issakhmetov 2026-07-01 00:48:02 +05:00
parent 8d9e11db0c
commit f74253140f
4 changed files with 87 additions and 45 deletions

View File

@ -81,7 +81,7 @@ the KATO column and the year columns:
`?kato_code=`). `?kato_code=`).
```sh ```sh
curl -X POST localhost:8080/datasets/<id>/mapping -H 'Content-Type: application/json' -d '{ curl -X POST https://dssgis.dwh.kz/datasets/<id>/mapping -H 'Content-Type: application/json' -d '{
"kato_column": "като", "kato_column": "като",
"year_columns": [ "year_columns": [
{"column": "F_2023", "date": "2023-01-01"}, {"column": "F_2023", "date": "2023-01-01"},
@ -131,7 +131,7 @@ server runs it is served at `/openapi.yaml`, with an interactive **Redoc** UI at
Example upload: Example upload:
```sh ```sh
curl -X POST localhost:8080/datasets \ curl -X POST https://dssgis.dwh.kz/datasets \
-F file=@sample.geojson -F file_type=vector -F category_id=<uuid> \ -F file=@sample.geojson -F file_type=vector -F category_id=<uuid> \
-F code=POP_2026 -F name=Population -F description="Resident population" -F unit=people -F code=POP_2026 -F name=Population -F description="Resident population" -F unit=people
``` ```

View File

@ -22,8 +22,8 @@ info:
name: Proprietary name: Proprietary
servers: servers:
- url: http://localhost:8080 - url: https://dssgis.dwh.kz/
description: Local development description: Production
tags: tags:
- name: Health - name: Health
@ -291,12 +291,13 @@ paths:
A `vector_with_kato` dataset is built from its observations. When it has A `vector_with_kato` dataset is built from its observations. When it has
its own (dissolved) geometry, the observations are taken to describe that its own (dissolved) geometry, the observations are taken to describe that
whole geometry: a single Feature wraps it, and its properties hold only whole geometry: a single Feature wraps it, and its properties nest the
the observations, keyed by KATO code at the top level (each KATO mapping observations under a `data` object, keyed by KATO code (each KATO mapping
to its date->value pairs). Otherwise one Feature is emitted per KATO, its to its district `name` and its own `data` map of date->value pairs).
boundary taken from the `districts` table and the per-year values Otherwise one Feature is emitted per KATO, its
flattened into the Feature's properties keyed by date; KATO codes with boundary taken from the `districts` table and the per-year values nested
no matching district are skipped. under a `data` object (keyed by date) alongside `kato` and `name`; KATO
codes with no matching district are skipped.
Only `ready` datasets are served: a dataset still being processed Only `ready` datasets are served: a dataset still being processed
returns 409. Other file types (e.g. `raster`) return 422. returns 409. Other file types (e.g. `raster`) return 422.
@ -321,9 +322,9 @@ paths:
Like `/datasets/{id}.geojson`, but ignores any geometry the dataset Like `/datasets/{id}.geojson`, but ignores any geometry the dataset
carries and always joins the `districts` table on KATO code: one Feature carries and always joins the `districts` table on KATO code: one Feature
is emitted per KATO, its boundary taken from the matching district and is emitted per KATO, its boundary taken from the matching district and
the per-year observation values mapped into the Feature's properties the per-year observation values nested under a `data` object (keyed by
keyed by date (alongside `kato` and `name`). KATO codes with no matching date) in the Feature's properties, alongside `kato` and `name`. KATO
district are skipped. codes with no matching district are skipped.
Only `ready` datasets are served: a dataset still being processed Only `ready` datasets are served: a dataset still being processed
returns 409. Other file types return 422. returns 409. Other file types return 422.

View File

@ -628,11 +628,13 @@ func (s *DatasetService) ListObservations(ctx context.Context, id uuid.UUID, kat
// //
// A vector_with_kato dataset is built from its observations: when it carries its // A vector_with_kato dataset is built from its observations: when it carries its
// own (dissolved) geometry the observations are taken to describe that whole // own (dissolved) geometry the observations are taken to describe that whole
// geometry, yielding a single Feature whose properties hold only the // geometry, yielding a single Feature whose properties nest the observations
// observations keyed by KATO code (each KATO mapping to its date->value pairs); // under `data`, keyed by KATO code (each KATO mapping to its district `name` and
// otherwise one Feature is emitted per KATO, its boundary taken from the // its own `data` map of date->value pairs); otherwise one Feature is emitted per
// districts table and its observation values placed into the Feature's // KATO, its boundary taken from the
// properties keyed by date. KATO codes with no matching district are skipped. // 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.
// //
// Only ready datasets are served; a dataset still being processed yields a // Only ready datasets are served; a dataset still being processed yields a
// conflict. // conflict.
@ -665,24 +667,33 @@ func (s *DatasetService) GeoJSON(ctx context.Context, id uuid.UUID) (domain.Feat
// The dataset has its own geometry (the dissolved union of all features): the // The dataset has its own geometry (the dissolved union of all features): the
// observations describe that whole geometry, so emit a single Feature wrapping // observations describe that whole geometry, so emit a single Feature wrapping
// it whose properties hold only the observations, keyed by KATO code. // it whose properties nest the observations under `data`, keyed by KATO code.
// Each KATO entry carries the district `name` alongside its own `data` map of
// date->value pairs.
if hasGeometry(dataset.Geometry) { if hasGeometry(dataset.Geometry) {
props := make(map[string]any, len(order)) districts, err := s.repo.DistrictGeometriesByKato(ctx, order)
if err != nil {
return domain.FeatureCollection{}, err
}
data := make(map[string]any, len(order))
for _, kato := range order { for _, kato := range order {
props[kato] = grouped[kato] data[kato] = map[string]any{
"name": districts[kato].Name,
"data": grouped[kato],
}
} }
return domain.FeatureCollection{ return domain.FeatureCollection{
Type: domain.GeoJSONFeatureCollection, Type: domain.GeoJSONFeatureCollection,
Features: []domain.Feature{{ Features: []domain.Feature{{
Type: domain.GeoJSONFeature, Type: domain.GeoJSONFeature,
Geometry: dataset.Geometry, Geometry: dataset.Geometry,
Properties: props, Properties: map[string]any{"data": data},
}}, }},
}, nil }, nil
} }
// No geometry: build one Feature per KATO from the districts table. // No geometry: build one Feature per KATO from the districts table.
features, err := s.districtFeatures(ctx, grouped, order) features, err := s.districtFeatures(ctx, grouped, order, true)
if err != nil { if err != nil {
return domain.FeatureCollection{}, err return domain.FeatureCollection{}, err
} }
@ -692,9 +703,9 @@ func (s *DatasetService) GeoJSON(ctx context.Context, id uuid.UUID) (domain.Feat
// KatoGeoJSON assembles a GeoJSON FeatureCollection (RFC 7946) for a // KatoGeoJSON assembles a GeoJSON FeatureCollection (RFC 7946) for a
// vector_with_kato dataset by always joining the districts table on KATO code, // vector_with_kato dataset by always joining the districts table on KATO code,
// ignoring any geometry the dataset carries. One Feature is emitted per KATO, // ignoring any geometry the dataset carries. One Feature is emitted per KATO,
// its boundary taken from the districts table and its observation values placed // its boundary taken from the districts table and its observation values nested
// into the Feature's properties keyed by date (alongside `kato` and `name`). // under a `data` object (keyed by date) in the Feature's properties, alongside
// KATO codes with no matching district are skipped. Plain vector datasets are // `kato` and `name`. KATO codes with no matching district are skipped. Plain vector datasets are
// not supported (they have no KATO observations). Only ready datasets are // not supported (they have no KATO observations). Only ready datasets are
// served; a dataset still being processed yields a conflict. // served; a dataset still being processed yields a conflict.
func (s *DatasetService) KatoGeoJSON(ctx context.Context, id uuid.UUID) (domain.FeatureCollection, error) { func (s *DatasetService) KatoGeoJSON(ctx context.Context, id uuid.UUID) (domain.FeatureCollection, error) {
@ -706,7 +717,7 @@ func (s *DatasetService) KatoGeoJSON(ctx context.Context, id uuid.UUID) (domain.
return domain.FeatureCollection{}, err return domain.FeatureCollection{}, err
} }
grouped, order := groupObservationsByKato(obs) grouped, order := groupObservationsByKato(obs)
features, err := s.districtFeatures(ctx, grouped, order) features, err := s.districtFeatures(ctx, grouped, order, true)
if err != nil { if err != nil {
return domain.FeatureCollection{}, err return domain.FeatureCollection{}, err
} }
@ -738,11 +749,13 @@ func (s *DatasetService) loadGeoJSONDataset(ctx context.Context, id uuid.UUID, a
return dataset, nil return dataset, nil
} }
// districtFeatures builds one Feature per KATO from the districts table, placing // districtFeatures builds one Feature per KATO from the districts table,
// the grouped observation values into each Feature's properties keyed by date // alongside `kato` and `name` in each Feature's properties. When nestData is
// (alongside `kato` and `name`). KATO codes with no matching district are // true the grouped observation values (keyed by date) are placed under a nested
// skipped. order drives the deterministic feature order. // `data` object; otherwise they are spread as flat date-keyed properties. KATO
func (s *DatasetService) districtFeatures(ctx context.Context, grouped map[string]map[string]any, order []string) ([]domain.Feature, error) { // 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, order []string, nestData bool) ([]domain.Feature, error) {
districts, err := s.repo.DistrictGeometriesByKato(ctx, order) districts, err := s.repo.DistrictGeometriesByKato(ctx, order)
if err != nil { if err != nil {
return nil, err return nil, err
@ -754,9 +767,13 @@ func (s *DatasetService) districtFeatures(ctx context.Context, grouped map[strin
continue // skip KATO codes with no district boundary continue // skip KATO codes with no district boundary
} }
props := map[string]any{"kato": kato, "name": dist.Name} props := map[string]any{"kato": kato, "name": dist.Name}
if nestData {
props["data"] = grouped[kato]
} else {
for date, value := range grouped[kato] { for date, value := range grouped[kato] {
props[date] = value props[date] = value
} }
}
features = append(features, domain.Feature{ features = append(features, domain.Feature{
Type: domain.GeoJSONFeature, Type: domain.GeoJSONFeature,
Geometry: dist.Geometry, Geometry: dist.Geometry,

View File

@ -843,8 +843,12 @@ func TestDatasetService_GeoJSON_DistrictJoin(t *testing.T) {
if f.Properties["kato"] != "710000000" || f.Properties["name"] != "Astana" { if f.Properties["kato"] != "710000000" || f.Properties["name"] != "Astana" {
t.Fatalf("unexpected properties: %+v", f.Properties) t.Fatalf("unexpected properties: %+v", f.Properties)
} }
if f.Properties["2020-01-01"] != 100.0 || f.Properties["2021-01-01"] != 150.0 { data, ok := f.Properties["data"].(map[string]any)
t.Fatalf("year values not flattened into properties: %+v", f.Properties) if !ok {
t.Fatalf("observations not nested under data: %+v", f.Properties)
}
if data["2020-01-01"] != 100.0 || data["2021-01-01"] != 150.0 {
t.Fatalf("year values not nested under data: %+v", data)
} }
// The whole thing must marshal to valid GeoJSON. // The whole thing must marshal to valid GeoJSON.
@ -867,6 +871,7 @@ func TestDatasetService_GeoJSON_UsesDatasetGeometry(t *testing.T) {
{KatoCode: "710000000", Date: "2020-01-01", Value: &v}, {KatoCode: "710000000", Date: "2020-01-01", Value: &v},
{KatoCode: "710000000", Date: "2021-01-01", Value: &v}, {KatoCode: "710000000", Date: "2021-01-01", Value: &v},
} }
repo.districts["710000000"] = domain.District{Kato: "710000000", Name: "Astana"}
svc := newDatasetService(repo, &stubStore{}, true) svc := newDatasetService(repo, &stubStore{}, true)
fc, err := svc.GeoJSON(ctx, id) fc, err := svc.GeoJSON(ctx, id)
@ -880,16 +885,28 @@ func TestDatasetService_GeoJSON_UsesDatasetGeometry(t *testing.T) {
if string(f.Geometry) != string(geom) { if string(f.Geometry) != string(geom) {
t.Fatalf("feature should reuse dataset geometry, got %s", f.Geometry) t.Fatalf("feature should reuse dataset geometry, got %s", f.Geometry)
} }
// Properties hold only the observations, keyed by KATO at the top level. // Properties nest the observations under `data`, keyed by KATO; each KATO
if _, ok := f.Properties["name"]; ok { // entry carries the district name and its own nested `data` map.
t.Fatalf("properties should contain only observations, got metadata: %+v", f.Properties) if _, flat := f.Properties["710000000"]; flat {
t.Fatalf("observations must be nested under data, not at top level: %+v", f.Properties)
} }
kato, ok := f.Properties["710000000"].(map[string]any) data, ok := f.Properties["data"].(map[string]any)
if !ok { if !ok {
t.Fatalf("observations not keyed by KATO at top level: %+v", f.Properties) t.Fatalf("observations not nested under data: %+v", f.Properties)
} }
if kato["2020-01-01"] != 42.0 || kato["2021-01-01"] != 42.0 { kato, ok := data["710000000"].(map[string]any)
t.Fatalf("year values missing under KATO: %+v", kato) if !ok {
t.Fatalf("observations not keyed by KATO under data: %+v", data)
}
if kato["name"] != "Astana" {
t.Fatalf("district name missing per KATO: %+v", kato)
}
values, ok := kato["data"].(map[string]any)
if !ok {
t.Fatalf("per-KATO observations not nested under data: %+v", kato)
}
if values["2020-01-01"] != 42.0 || values["2021-01-01"] != 42.0 {
t.Fatalf("year values missing under KATO data: %+v", values)
} }
} }
@ -925,8 +942,15 @@ func TestDatasetService_KatoGeoJSON_IgnoresDatasetGeometry(t *testing.T) {
if f.Properties["kato"] != "710000000" || f.Properties["name"] != "Astana" { if f.Properties["kato"] != "710000000" || f.Properties["name"] != "Astana" {
t.Fatalf("unexpected properties: %+v", f.Properties) t.Fatalf("unexpected properties: %+v", f.Properties)
} }
if f.Properties["2020-01-01"] != 100.0 { data, ok := f.Properties["data"].(map[string]any)
t.Fatalf("observation value not mapped onto polygon: %+v", f.Properties) if !ok {
t.Fatalf("observations must be nested under data: %+v", f.Properties)
}
if data["2020-01-01"] != 100.0 {
t.Fatalf("observation value not mapped under data: %+v", data)
}
if _, flat := f.Properties["2020-01-01"]; flat {
t.Fatalf("observation dates must not be flat in properties: %+v", f.Properties)
} }
} }