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:
29
README.md
29
README.md
@@ -72,10 +72,10 @@ the KATO column and the year columns:
|
|||||||
`parse_error`).
|
`parse_error`).
|
||||||
3. The client polls `GET /datasets/{id}` until `awaiting_mapping`, then submits
|
3. The client polls `GET /datasets/{id}` until `awaiting_mapping`, then submits
|
||||||
`POST /datasets/{id}/mapping` with the chosen `kato_column` and a
|
`POST /datasets/{id}/mapping` with the chosen `kato_column` and a
|
||||||
`year_columns` map (each `{column, date}`). Validated against the detected
|
`year_columns` map (each `{column, start_date}`, optionally `end_date` for
|
||||||
columns; `status` → `extracting`.
|
a period). Validated against the detected columns; `status` → `extracting`.
|
||||||
4. A second worker job **unpivots** the attribute table into long-format
|
4. A second worker job **unpivots** the attribute table into long-format
|
||||||
`dataset_observations` — one row per `(kato_code, date)` with a numeric
|
`dataset_observations` — one row per `(kato_code, start_date)` with a numeric
|
||||||
`value` (or `value_text` for non-numeric cells); `status` → `ready`. Read
|
`value` (or `value_text` for non-numeric cells); `status` → `ready`. Read
|
||||||
them via `GET /datasets/{id}/observations` (paginated, optional
|
them via `GET /datasets/{id}/observations` (paginated, optional
|
||||||
`?kato_code=`).
|
`?kato_code=`).
|
||||||
@@ -84,12 +84,25 @@ the KATO column and the year columns:
|
|||||||
curl -X POST https://dssgis.dwh.kz/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", "start_date": "2023-01-01"},
|
||||||
{"column": "D_2025", "date": "2025-01-01"}
|
{"column": "D_2025", "start_date": "2025-01-01"}
|
||||||
]
|
]
|
||||||
}'
|
}'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Dataset periods
|
||||||
|
|
||||||
|
A dataset carries an optional period — `start_date` and `end_date`
|
||||||
|
(`YYYY-MM-DD`, both nullable, `end_date` inclusive and never before
|
||||||
|
`start_date`) — plus an optional `dg_id` linking it to a *dataset group*: the
|
||||||
|
"одноимённые" datasets that describe the same thing for different periods. All
|
||||||
|
three are accepted on `POST /datasets` (form fields), `PUT /datasets/{id}` and
|
||||||
|
`PATCH /datasets/{id}` (an explicit `null`, or omission on PUT, clears them).
|
||||||
|
|
||||||
|
`GET /datasets` filters on them with `?dg_id=`, and with the inclusive bounds
|
||||||
|
`?start_date_from=`, `?start_date_to=`, `?end_date_from=` and `?end_date_to=`
|
||||||
|
(each `YYYY-MM-DD`).
|
||||||
|
|
||||||
## Getting started
|
## Getting started
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -117,13 +130,13 @@ server runs it is served at `/openapi.yaml`, with an interactive **Redoc** UI at
|
|||||||
| GET | `/categories/{id}` | get |
|
| GET | `/categories/{id}` | get |
|
||||||
| PUT | `/categories/{id}` | update |
|
| PUT | `/categories/{id}` | update |
|
||||||
| DELETE | `/categories/{id}` | delete |
|
| DELETE | `/categories/{id}` | delete |
|
||||||
| GET | `/datasets` | paginated list of summaries (`?page=`, `?page_size=`, `?category_id=`) |
|
| GET | `/datasets` | paginated list of summaries (`?page=`, `?page_size=`, `?category_id=`, `?dg_id=`, `?start_date_from=`, `?start_date_to=`, `?end_date_from=`, `?end_date_to=`) |
|
||||||
| POST | `/datasets` | upload (multipart: `file`, `file_type`, `category_id`, `code`, `name`, `description?`, `unit?`, `meta?` (JSON), `automated?` (bool)) |
|
| POST | `/datasets` | upload (multipart: `file`, `file_type`, `category_id`, `code`, `name`, `description?`, `unit?`, `meta?` (JSON), `automated?` (bool), `dg_id?`, `start_date?`, `end_date?`) |
|
||||||
| GET | `/datasets/{id}` | full dataset (geometry as GeoJSON, `bbox` for rasters) |
|
| GET | `/datasets/{id}` | full dataset (geometry as GeoJSON, `bbox` for rasters) |
|
||||||
| GET | `/datasets/{id}.geojson` | GeoJSON `FeatureCollection`; plain `vector` returns its geometry as a single feature with the extracted attribute table as top-level properties; `vector_with_kato` always ignores its own geometry and joins the `districts` table by KATO, mapping observations onto each polygon |
|
| GET | `/datasets/{id}.geojson` | GeoJSON `FeatureCollection`; plain `vector` returns its geometry as a single feature with the extracted attribute table as top-level properties; `vector_with_kato` always ignores its own geometry and joins the `districts` table by KATO, mapping observations onto each polygon |
|
||||||
| GET | `/datasets/{id}/status` | processing status; long-polls with `?current=<status>` (holds up to `?wait=` secs, default 25, max 60) |
|
| GET | `/datasets/{id}/status` | processing status; long-polls with `?current=<status>` (holds up to `?wait=` secs, default 25, max 60) |
|
||||||
| GET | `/datasets/{id}/download` | download the stored file |
|
| GET | `/datasets/{id}/download` | download the stored file |
|
||||||
| POST | `/datasets/{id}/mapping` | set KATO column + year→date map (vector_with_kato) |
|
| POST | `/datasets/{id}/mapping` | set KATO column + year→period map (vector_with_kato) |
|
||||||
| GET | `/datasets/{id}/observations` | paginated unpivoted values (`?kato_code=`, `?page=`, `?page_size=`) |
|
| GET | `/datasets/{id}/observations` | paginated unpivoted values (`?kato_code=`, `?page=`, `?page_size=`) |
|
||||||
| DELETE | `/datasets/{id}` | delete (row + object) |
|
| DELETE | `/datasets/{id}` | delete (row + object) |
|
||||||
|
|
||||||
|
|||||||
146
api/openapi.yaml
146
api/openapi.yaml
@@ -194,6 +194,41 @@ paths:
|
|||||||
type: string
|
type: string
|
||||||
enum:
|
enum:
|
||||||
[pending, parsing, processing, awaiting_mapping, extracting, ready, failed]
|
[pending, parsing, processing, awaiting_mapping, extracting, ready, failed]
|
||||||
|
- name: dg_id
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
description: Filter to a dataset group by id.
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
- name: start_date_from
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
description: Keep datasets whose `start_date` is on or after this date.
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: date
|
||||||
|
- name: start_date_to
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
description: Keep datasets whose `start_date` is on or before this date.
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: date
|
||||||
|
- name: end_date_from
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
description: Keep datasets whose `end_date` is on or after this date.
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: date
|
||||||
|
- name: end_date_to
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
description: Keep datasets whose `end_date` is on or before this date.
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: date
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: A page of dataset summaries
|
description: A page of dataset summaries
|
||||||
@@ -240,6 +275,20 @@ paths:
|
|||||||
description: Arbitrary user JSON (sent as a JSON string).
|
description: Arbitrary user JSON (sent as a JSON string).
|
||||||
automated:
|
automated:
|
||||||
type: boolean
|
type: boolean
|
||||||
|
dg_id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
description: Optional dataset group the dataset belongs to.
|
||||||
|
start_date:
|
||||||
|
type: string
|
||||||
|
format: date
|
||||||
|
description: Start of the period the dataset describes.
|
||||||
|
end_date:
|
||||||
|
type: string
|
||||||
|
format: date
|
||||||
|
description: >-
|
||||||
|
End of the period (inclusive); must not be before
|
||||||
|
`start_date`.
|
||||||
responses:
|
responses:
|
||||||
"201":
|
"201":
|
||||||
description: Dataset created; processing enqueued
|
description: Dataset created; processing enqueued
|
||||||
@@ -275,7 +324,8 @@ paths:
|
|||||||
Updates category, name, description, and unit. Every field is
|
Updates category, name, description, and unit. Every field is
|
||||||
required; the file, file type, and processing/lifecycle state are
|
required; the file, file type, and processing/lifecycle state are
|
||||||
never touched here. Sending an empty string for `description` or
|
never touched here. Sending an empty string for `description` or
|
||||||
`unit` clears that (optional) field.
|
`unit` clears that (optional) field. `dg_id`, `start_date` and
|
||||||
|
`end_date` are optional: omitting one clears that (nullable) column.
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
content:
|
content:
|
||||||
@@ -295,9 +345,9 @@ paths:
|
|||||||
summary: Partially update a dataset's editable metadata
|
summary: Partially update a dataset's editable metadata
|
||||||
description: |
|
description: |
|
||||||
Updates only the fields present in the body, from category, name,
|
Updates only the fields present in the body, from category, name,
|
||||||
description, and unit. The file, file type, and processing/lifecycle
|
description, unit, `dg_id`, `start_date` and `end_date`. The file,
|
||||||
state are never touched here. Sending an explicit `null` for
|
file type, and processing/lifecycle state are never touched here.
|
||||||
`description` or `unit` clears that (optional) field; omitting a
|
Sending an explicit `null` for a nullable field clears it; omitting a
|
||||||
field leaves it unchanged.
|
field leaves it unchanged.
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
@@ -492,9 +542,9 @@ paths:
|
|||||||
the dataset's detected `attribute_columns`. Moves the dataset to
|
the dataset's detected `attribute_columns`. Moves the dataset to
|
||||||
`extracting`; observations are produced asynchronously.
|
`extracting`; observations are produced asynchronously.
|
||||||
|
|
||||||
Each year column maps to either a single point in time (`date` only,
|
Each year column maps to either a single point in time (`start_date`
|
||||||
the original behavior) or a period (`date` and `date_to`, inclusive).
|
only, the original behavior) or a period (`start_date` and `end_date`,
|
||||||
`date_to`, when present, must not be before `date`.
|
inclusive). `end_date`, when present, must not be before `start_date`.
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
content:
|
content:
|
||||||
@@ -685,6 +735,20 @@ components:
|
|||||||
unit:
|
unit:
|
||||||
type: string
|
type: string
|
||||||
description: An empty string clears the field.
|
description: An empty string clears the field.
|
||||||
|
dg_id:
|
||||||
|
type: [string, "null"]
|
||||||
|
format: uuid
|
||||||
|
description: Dataset group; omitted or null clears the field.
|
||||||
|
start_date:
|
||||||
|
type: [string, "null"]
|
||||||
|
format: date
|
||||||
|
description: Start of the dataset's period; omitted or null clears it.
|
||||||
|
end_date:
|
||||||
|
type: [string, "null"]
|
||||||
|
format: date
|
||||||
|
description: >-
|
||||||
|
End of the dataset's period (inclusive); omitted or null clears it.
|
||||||
|
Must not be before `start_date`.
|
||||||
|
|
||||||
DatasetPatch:
|
DatasetPatch:
|
||||||
type: object
|
type: object
|
||||||
@@ -703,6 +767,20 @@ components:
|
|||||||
unit:
|
unit:
|
||||||
type: [string, "null"]
|
type: [string, "null"]
|
||||||
description: null clears the field.
|
description: null clears the field.
|
||||||
|
dg_id:
|
||||||
|
type: [string, "null"]
|
||||||
|
format: uuid
|
||||||
|
description: null clears the field.
|
||||||
|
start_date:
|
||||||
|
type: [string, "null"]
|
||||||
|
format: date
|
||||||
|
description: null clears the field.
|
||||||
|
end_date:
|
||||||
|
type: [string, "null"]
|
||||||
|
format: date
|
||||||
|
description: >-
|
||||||
|
null clears the field. Must not be before the resulting
|
||||||
|
`start_date`.
|
||||||
|
|
||||||
AttributeColumn:
|
AttributeColumn:
|
||||||
type: object
|
type: object
|
||||||
@@ -717,23 +795,23 @@ components:
|
|||||||
|
|
||||||
YearColumn:
|
YearColumn:
|
||||||
type: object
|
type: object
|
||||||
required: [column, date]
|
required: [column, start_date]
|
||||||
properties:
|
properties:
|
||||||
column:
|
column:
|
||||||
type: string
|
type: string
|
||||||
date:
|
start_date:
|
||||||
type: string
|
type: string
|
||||||
format: date
|
format: date
|
||||||
description: >-
|
description: >-
|
||||||
Point in time, or the start of the period when `date_to` is also
|
Point in time, or the start of the period when `end_date` is also
|
||||||
set.
|
set.
|
||||||
date_to:
|
end_date:
|
||||||
type: [string, "null"]
|
type: [string, "null"]
|
||||||
format: date
|
format: date
|
||||||
description: >-
|
description: >-
|
||||||
Optional period end (inclusive), must not be before `date`. Omitted
|
Optional period end (inclusive), must not be before `start_date`.
|
||||||
or null means a single point in time (the original, pre-period
|
Omitted or null means a single point in time (the original,
|
||||||
behavior).
|
pre-period behavior).
|
||||||
|
|
||||||
GeoJSONGeometry:
|
GeoJSONGeometry:
|
||||||
type: [object, "null"]
|
type: [object, "null"]
|
||||||
@@ -763,7 +841,7 @@ components:
|
|||||||
mapping to its date->value pairs.
|
mapping to its date->value pairs.
|
||||||
|
|
||||||
For a `vector_with_kato` dataset mapped with one or more period
|
For a `vector_with_kato` dataset mapped with one or more period
|
||||||
year columns (`date_to` set), a sibling `periods` object is also
|
year columns (`end_date` set), a sibling `periods` object is also
|
||||||
present, mapping each period's start date (the same key used in
|
present, mapping each period's start date (the same key used in
|
||||||
`data`) to its end date. `periods` is omitted entirely when the
|
`data`) to its end date. `periods` is omitted entirely when the
|
||||||
dataset has no period mappings.
|
dataset has no period mappings.
|
||||||
@@ -792,6 +870,19 @@ components:
|
|||||||
category_id:
|
category_id:
|
||||||
type: string
|
type: string
|
||||||
format: uuid
|
format: uuid
|
||||||
|
dg_id:
|
||||||
|
type: [string, "null"]
|
||||||
|
format: uuid
|
||||||
|
description: The dataset group this dataset belongs to, if any.
|
||||||
|
start_date:
|
||||||
|
type: [string, "null"]
|
||||||
|
format: date
|
||||||
|
description: Start of the period the dataset describes.
|
||||||
|
end_date:
|
||||||
|
type: [string, "null"]
|
||||||
|
format: date
|
||||||
|
description: >-
|
||||||
|
End of the period (inclusive). Drives the "freshest in group" rule.
|
||||||
name:
|
name:
|
||||||
type: string
|
type: string
|
||||||
description:
|
description:
|
||||||
@@ -824,6 +915,19 @@ components:
|
|||||||
category_id:
|
category_id:
|
||||||
type: string
|
type: string
|
||||||
format: uuid
|
format: uuid
|
||||||
|
dg_id:
|
||||||
|
type: [string, "null"]
|
||||||
|
format: uuid
|
||||||
|
description: The dataset group this dataset belongs to, if any.
|
||||||
|
start_date:
|
||||||
|
type: [string, "null"]
|
||||||
|
format: date
|
||||||
|
description: Start of the period the dataset describes.
|
||||||
|
end_date:
|
||||||
|
type: [string, "null"]
|
||||||
|
format: date
|
||||||
|
description: >-
|
||||||
|
End of the period (inclusive). Drives the "freshest in group" rule.
|
||||||
name:
|
name:
|
||||||
type: string
|
type: string
|
||||||
description:
|
description:
|
||||||
@@ -901,12 +1005,12 @@ components:
|
|||||||
examples:
|
examples:
|
||||||
- kato_column: "като"
|
- kato_column: "като"
|
||||||
year_columns:
|
year_columns:
|
||||||
- { column: F_2023, date: "2023-01-01" }
|
- { column: F_2023, start_date: "2023-01-01" }
|
||||||
- { column: D_2025, date: "2025-01-01" }
|
- { column: D_2025, start_date: "2025-01-01" }
|
||||||
|
|
||||||
Observation:
|
Observation:
|
||||||
type: object
|
type: object
|
||||||
required: [id, dataset_id, kato_code, date]
|
required: [id, dataset_id, kato_code, start_date]
|
||||||
properties:
|
properties:
|
||||||
id:
|
id:
|
||||||
type: string
|
type: string
|
||||||
@@ -916,15 +1020,15 @@ components:
|
|||||||
format: uuid
|
format: uuid
|
||||||
kato_code:
|
kato_code:
|
||||||
type: string
|
type: string
|
||||||
date:
|
start_date:
|
||||||
type: string
|
type: string
|
||||||
format: date
|
format: date
|
||||||
date_to:
|
end_date:
|
||||||
type: [string, "null"]
|
type: [string, "null"]
|
||||||
format: date
|
format: date
|
||||||
description: >-
|
description: >-
|
||||||
Period end (inclusive), carried over from the year mapping's
|
Period end (inclusive), carried over from the year mapping's
|
||||||
`date_to`. Null for a point-in-time observation.
|
`end_date`. Null for a point-in-time observation.
|
||||||
value:
|
value:
|
||||||
type: [number, "null"]
|
type: [number, "null"]
|
||||||
description: Numeric cell value, or null when non-numeric/empty.
|
description: Numeric cell value, or null when non-numeric/empty.
|
||||||
|
|||||||
@@ -71,20 +71,28 @@ type DatasetFilter struct {
|
|||||||
FileType *FileType
|
FileType *FileType
|
||||||
Automated *bool
|
Automated *bool
|
||||||
Status *string
|
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,
|
// 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
|
// keyed by KATO code and date (optionally a StartDate..EndDate period). Exactly
|
||||||
// of Value / ValueText is typically set (numeric vs non-numeric cell); both
|
// one of Value / ValueText is typically set (numeric vs non-numeric cell); both
|
||||||
// may be nil for an empty cell.
|
// may be nil for an empty cell.
|
||||||
type Observation struct {
|
type Observation struct {
|
||||||
ID uuid.UUID `json:"id"`
|
ID uuid.UUID `json:"id"`
|
||||||
DatasetID uuid.UUID `json:"dataset_id"`
|
DatasetID uuid.UUID `json:"dataset_id"`
|
||||||
KatoCode string `json:"kato_code"`
|
KatoCode string `json:"kato_code"`
|
||||||
Date string `json:"date"`
|
StartDate string `json:"start_date"`
|
||||||
// DateTo is the period's end date. Nil for a point-in-time observation
|
// EndDate is the period's end date. Nil for a point-in-time observation
|
||||||
// (pre-existing behavior).
|
// (pre-existing behavior).
|
||||||
DateTo *string `json:"date_to,omitempty"`
|
EndDate *string `json:"end_date,omitempty"`
|
||||||
Value *float64 `json:"value"`
|
Value *float64 `json:"value"`
|
||||||
ValueText *string `json:"value_text"`
|
ValueText *string `json:"value_text"`
|
||||||
}
|
}
|
||||||
@@ -185,14 +193,15 @@ type AttributeColumn struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// YearColumn maps an attribute column to the date (or date period) it
|
// 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
|
// represents, e.g. {"column": "F_2023", "start_date": "2023-01-01"} for a
|
||||||
// point in time, or {"column": "F_2020_2023", "date": "2020-01-01", "date_to":
|
// single point in time, or {"column": "F_2020_2023", "start_date":
|
||||||
// "2023-12-31"} for a period. DateTo is optional; when nil the mapping is a
|
// "2020-01-01", "end_date": "2023-12-31"} for a period. EndDate is optional;
|
||||||
// single point in time, preserving the original (pre-period) behavior.
|
// when nil the mapping is a single point in time, preserving the original
|
||||||
|
// (pre-period) behavior.
|
||||||
type YearColumn struct {
|
type YearColumn struct {
|
||||||
Column string `json:"column"`
|
Column string `json:"column"`
|
||||||
Date string `json:"date"`
|
StartDate string `json:"start_date"`
|
||||||
DateTo *string `json:"date_to,omitempty"`
|
EndDate *string `json:"end_date,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// DatasetSummary is the lightweight view of a dataset used in list responses.
|
// DatasetSummary is the lightweight view of a dataset used in list responses.
|
||||||
@@ -200,6 +209,13 @@ type YearColumn struct {
|
|||||||
type DatasetSummary struct {
|
type DatasetSummary struct {
|
||||||
ID uuid.UUID `json:"id"`
|
ID uuid.UUID `json:"id"`
|
||||||
CategoryID uuid.UUID `json:"category_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"`
|
Name string `json:"name"`
|
||||||
Description *string `json:"description"`
|
Description *string `json:"description"`
|
||||||
Unit *string `json:"unit"`
|
Unit *string `json:"unit"`
|
||||||
@@ -214,6 +230,13 @@ type DatasetSummary struct {
|
|||||||
type Dataset struct {
|
type Dataset struct {
|
||||||
ID uuid.UUID `json:"id"`
|
ID uuid.UUID `json:"id"`
|
||||||
CategoryID uuid.UUID `json:"category_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"`
|
Name string `json:"name"`
|
||||||
Description *string `json:"description"`
|
Description *string `json:"description"`
|
||||||
Unit *string `json:"unit"`
|
Unit *string `json:"unit"`
|
||||||
@@ -262,3 +285,37 @@ type Dataset struct {
|
|||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_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
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package domain
|
package domain
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
func TestFileTypeValid(t *testing.T) {
|
func TestFileTypeValid(t *testing.T) {
|
||||||
valid := []FileType{FileTypeVectorWithKato, FileTypeVector, FileTypeRaster}
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ func NewDatasetRepository(pool *pgxpool.Pool) *DatasetRepository {
|
|||||||
// datasetColumns lists the dataset columns for SELECT and RETURNING. The
|
// datasetColumns lists the dataset columns for SELECT and RETURNING. The
|
||||||
// geometry is exposed as GeoJSON (jsonb) rather than its raw EWKB form, and a
|
// geometry is exposed as GeoJSON (jsonb) rather than its raw EWKB form, and a
|
||||||
// bounding box array is derived for raster datasets only.
|
// bounding box array is derived for raster datasets only.
|
||||||
const datasetColumns = `id, category_id, name, description, unit, filename, storage_key, cog_storage_key, file_type, size_bytes, content_type, properties, meta, automated, status, attribute_columns, kato_column, year_columns, parse_error, ST_AsGeoJSON(geometry)::jsonb AS geometry,
|
const datasetColumns = `id, category_id, dg_id, to_char(start_date, 'YYYY-MM-DD') AS start_date, to_char(end_date, 'YYYY-MM-DD') AS end_date, name, description, unit, filename, storage_key, cog_storage_key, file_type, size_bytes, content_type, properties, meta, automated, status, attribute_columns, kato_column, year_columns, parse_error, ST_AsGeoJSON(geometry)::jsonb AS geometry,
|
||||||
CASE WHEN file_type = 'raster' AND geometry IS NOT NULL
|
CASE WHEN file_type = 'raster' AND geometry IS NOT NULL
|
||||||
THEN ARRAY[ST_XMin(geometry), ST_YMin(geometry), ST_XMax(geometry), ST_YMax(geometry)]
|
THEN ARRAY[ST_XMin(geometry), ST_YMin(geometry), ST_XMax(geometry), ST_YMax(geometry)]
|
||||||
ELSE NULL END AS bbox,
|
ELSE NULL END AS bbox,
|
||||||
@@ -37,7 +37,7 @@ const datasetColumns = `id, category_id, name, description, unit, filename, stor
|
|||||||
func scanDataset(row pgx.Row) (domain.Dataset, error) {
|
func scanDataset(row pgx.Row) (domain.Dataset, error) {
|
||||||
var d domain.Dataset
|
var d domain.Dataset
|
||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
&d.ID, &d.CategoryID, &d.Name, &d.Description, &d.Unit,
|
&d.ID, &d.CategoryID, &d.DatasetGroupID, &d.StartDate, &d.EndDate, &d.Name, &d.Description, &d.Unit,
|
||||||
&d.Filename, &d.StorageKey, &d.CogStorageKey, &d.FileType, &d.SizeBytes, &d.ContentType,
|
&d.Filename, &d.StorageKey, &d.CogStorageKey, &d.FileType, &d.SizeBytes, &d.ContentType,
|
||||||
&d.Properties, &d.Meta, &d.Automated, &d.Status,
|
&d.Properties, &d.Meta, &d.Automated, &d.Status,
|
||||||
&d.AttributeColumns, &d.KatoColumn, &d.YearColumns, &d.ParseError,
|
&d.AttributeColumns, &d.KatoColumn, &d.YearColumns, &d.ParseError,
|
||||||
@@ -59,28 +59,28 @@ func nullableJSON(raw json.RawMessage) any {
|
|||||||
// Create inserts a new dataset and returns the stored row.
|
// Create inserts a new dataset and returns the stored row.
|
||||||
func (r *DatasetRepository) Create(ctx context.Context, d domain.Dataset) (domain.Dataset, error) {
|
func (r *DatasetRepository) Create(ctx context.Context, d domain.Dataset) (domain.Dataset, error) {
|
||||||
row := r.pool.QueryRow(ctx,
|
row := r.pool.QueryRow(ctx,
|
||||||
`INSERT INTO datasets (category_id, name, description, unit, filename, storage_key, file_type, size_bytes, content_type, properties, meta, automated, status)
|
`INSERT INTO datasets (category_id, name, description, unit, filename, storage_key, file_type, size_bytes, content_type, properties, meta, automated, status, dg_id, start_date, end_date)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15::date, $16::date)
|
||||||
RETURNING `+datasetColumns,
|
RETURNING `+datasetColumns,
|
||||||
d.CategoryID, d.Name, d.Description, d.Unit, d.Filename, d.StorageKey, d.FileType, d.SizeBytes, d.ContentType,
|
d.CategoryID, d.Name, d.Description, d.Unit, d.Filename, d.StorageKey, d.FileType, d.SizeBytes, d.ContentType,
|
||||||
nullableJSON(d.Properties), nullableJSON(d.Meta), d.Automated, d.Status,
|
nullableJSON(d.Properties), nullableJSON(d.Meta), d.Automated, d.Status,
|
||||||
|
d.DatasetGroupID, d.StartDate, d.EndDate,
|
||||||
)
|
)
|
||||||
out, err := scanDataset(row)
|
out, err := scanDataset(row)
|
||||||
return out, mapError(err)
|
return out, mapError(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateFields overwrites a dataset's editable metadata columns (category,
|
// UpdateFields overwrites a dataset's editable metadata columns. Nil pointers
|
||||||
// name, description, unit). description and unit are nullable: a nil pointer
|
// clear the nullable columns. Returns domain.ErrNotFound if the dataset does
|
||||||
// clears the column. Returns domain.ErrNotFound if the dataset does not
|
// not exist, or domain.ErrConflict if the category/group does not exist.
|
||||||
// exist, or domain.ErrConflict if categoryID does not reference an existing
|
func (r *DatasetRepository) UpdateFields(ctx context.Context, id uuid.UUID, f domain.DatasetFields) (domain.Dataset, error) {
|
||||||
// category.
|
|
||||||
func (r *DatasetRepository) UpdateFields(ctx context.Context, id, categoryID uuid.UUID, name string, description, unit *string) (domain.Dataset, error) {
|
|
||||||
row := r.pool.QueryRow(ctx,
|
row := r.pool.QueryRow(ctx,
|
||||||
`UPDATE datasets
|
`UPDATE datasets
|
||||||
SET category_id = $2, name = $3, description = $4, unit = $5, updated_at = now()
|
SET category_id = $2, name = $3, description = $4, unit = $5,
|
||||||
|
dg_id = $6, start_date = $7::date, end_date = $8::date, updated_at = now()
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
RETURNING `+datasetColumns,
|
RETURNING `+datasetColumns,
|
||||||
id, categoryID, name, description, unit,
|
id, f.CategoryID, f.Name, f.Description, f.Unit, f.DatasetGroupID, f.StartDate, f.EndDate,
|
||||||
)
|
)
|
||||||
out, err := scanDataset(row)
|
out, err := scanDataset(row)
|
||||||
return out, mapError(err)
|
return out, mapError(err)
|
||||||
@@ -276,23 +276,23 @@ func (r *DatasetRepository) ReplaceObservations(ctx context.Context, datasetID u
|
|||||||
if len(obs) > 0 {
|
if len(obs) > 0 {
|
||||||
rows := make([][]any, len(obs))
|
rows := make([][]any, len(obs))
|
||||||
for i, o := range obs {
|
for i, o := range obs {
|
||||||
d, err := time.Parse("2006-01-02", o.Date)
|
d, err := time.Parse("2006-01-02", o.StartDate)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("invalid observation date %q: %w", o.Date, err)
|
return fmt.Errorf("invalid observation start_date %q: %w", o.StartDate, err)
|
||||||
}
|
}
|
||||||
var dateTo any
|
var endDate any
|
||||||
if o.DateTo != nil {
|
if o.EndDate != nil {
|
||||||
dt, err := time.Parse("2006-01-02", *o.DateTo)
|
dt, err := time.Parse("2006-01-02", *o.EndDate)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("invalid observation date_to %q: %w", *o.DateTo, err)
|
return fmt.Errorf("invalid observation end_date %q: %w", *o.EndDate, err)
|
||||||
}
|
}
|
||||||
dateTo = dt
|
endDate = dt
|
||||||
}
|
}
|
||||||
rows[i] = []any{datasetID, o.KatoCode, d, dateTo, o.Value, o.ValueText}
|
rows[i] = []any{datasetID, o.KatoCode, d, endDate, o.Value, o.ValueText}
|
||||||
}
|
}
|
||||||
_, err := tx.CopyFrom(ctx,
|
_, err := tx.CopyFrom(ctx,
|
||||||
pgx.Identifier{"dataset_observations"},
|
pgx.Identifier{"dataset_observations"},
|
||||||
[]string{"dataset_id", "kato_code", "date", "date_to", "value", "value_text"},
|
[]string{"dataset_id", "kato_code", "start_date", "end_date", "value", "value_text"},
|
||||||
pgx.CopyFromRows(rows),
|
pgx.CopyFromRows(rows),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -303,16 +303,16 @@ func (r *DatasetRepository) ReplaceObservations(ctx context.Context, datasetID u
|
|||||||
return mapError(tx.Commit(ctx))
|
return mapError(tx.Commit(ctx))
|
||||||
}
|
}
|
||||||
|
|
||||||
const observationColumns = `id, dataset_id, kato_code, to_char(date, 'YYYY-MM-DD') AS date, to_char(date_to, 'YYYY-MM-DD') AS date_to, value, value_text`
|
const observationColumns = `id, dataset_id, kato_code, to_char(start_date, 'YYYY-MM-DD') AS start_date, to_char(end_date, 'YYYY-MM-DD') AS end_date, value, value_text`
|
||||||
|
|
||||||
func scanObservation(row pgx.Row) (domain.Observation, error) {
|
func scanObservation(row pgx.Row) (domain.Observation, error) {
|
||||||
var o domain.Observation
|
var o domain.Observation
|
||||||
err := row.Scan(&o.ID, &o.DatasetID, &o.KatoCode, &o.Date, &o.DateTo, &o.Value, &o.ValueText)
|
err := row.Scan(&o.ID, &o.DatasetID, &o.KatoCode, &o.StartDate, &o.EndDate, &o.Value, &o.ValueText)
|
||||||
return o, err
|
return o, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListObservations returns a page of observations for a dataset, optionally
|
// ListObservations returns a page of observations for a dataset, optionally
|
||||||
// filtered by KATO code, ordered by (kato_code, date).
|
// filtered by KATO code, ordered by (kato_code, start_date).
|
||||||
func (r *DatasetRepository) ListObservations(ctx context.Context, datasetID uuid.UUID, katoCode *string, limit, offset int) ([]domain.Observation, error) {
|
func (r *DatasetRepository) ListObservations(ctx context.Context, datasetID uuid.UUID, katoCode *string, limit, offset int) ([]domain.Observation, error) {
|
||||||
base := `SELECT ` + observationColumns + ` FROM dataset_observations WHERE dataset_id = $1`
|
base := `SELECT ` + observationColumns + ` FROM dataset_observations WHERE dataset_id = $1`
|
||||||
|
|
||||||
@@ -322,11 +322,11 @@ func (r *DatasetRepository) ListObservations(ctx context.Context, datasetID uuid
|
|||||||
)
|
)
|
||||||
if katoCode != nil {
|
if katoCode != nil {
|
||||||
rows, err = r.pool.Query(ctx,
|
rows, err = r.pool.Query(ctx,
|
||||||
base+` AND kato_code = $2 ORDER BY kato_code, date LIMIT $3 OFFSET $4`,
|
base+` AND kato_code = $2 ORDER BY kato_code, start_date LIMIT $3 OFFSET $4`,
|
||||||
datasetID, *katoCode, limit, offset)
|
datasetID, *katoCode, limit, offset)
|
||||||
} else {
|
} else {
|
||||||
rows, err = r.pool.Query(ctx,
|
rows, err = r.pool.Query(ctx,
|
||||||
base+` ORDER BY kato_code, date LIMIT $2 OFFSET $3`,
|
base+` ORDER BY kato_code, start_date LIMIT $2 OFFSET $3`,
|
||||||
datasetID, limit, offset)
|
datasetID, limit, offset)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -368,12 +368,25 @@ func (r *DatasetRepository) GetByID(ctx context.Context, id uuid.UUID) (domain.D
|
|||||||
return out, mapError(err)
|
return out, mapError(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
const datasetSummaryColumns = `id, category_id, name, description, unit, file_type, size_bytes, status, created_at, updated_at`
|
// LatestInGroup returns the freshest dataset of a group: greatest end_date
|
||||||
|
// (NULL end_date sorts last), ties broken by created_at DESC. status and
|
||||||
|
// fileType optionally restrict candidates. Returns domain.ErrNotFound when no
|
||||||
|
// dataset matches.
|
||||||
|
func (r *DatasetRepository) LatestInGroup(ctx context.Context, groupID uuid.UUID, status *string, fileType *domain.FileType) (domain.Dataset, error) {
|
||||||
|
where, args := datasetFilterClause(domain.DatasetFilter{DatasetGroupID: &groupID, Status: status, FileType: fileType}, nil)
|
||||||
|
row := r.pool.QueryRow(ctx,
|
||||||
|
`SELECT `+datasetColumns+` FROM datasets`+where+
|
||||||
|
` ORDER BY end_date DESC NULLS LAST, created_at DESC LIMIT 1`, args...)
|
||||||
|
out, err := scanDataset(row)
|
||||||
|
return out, mapError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
const datasetSummaryColumns = `id, category_id, dg_id, to_char(start_date, 'YYYY-MM-DD') AS start_date, to_char(end_date, 'YYYY-MM-DD') AS end_date, name, description, unit, file_type, size_bytes, status, created_at, updated_at`
|
||||||
|
|
||||||
func scanDatasetSummary(row pgx.Row) (domain.DatasetSummary, error) {
|
func scanDatasetSummary(row pgx.Row) (domain.DatasetSummary, error) {
|
||||||
var d domain.DatasetSummary
|
var d domain.DatasetSummary
|
||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
&d.ID, &d.CategoryID, &d.Name, &d.Description, &d.Unit,
|
&d.ID, &d.CategoryID, &d.DatasetGroupID, &d.StartDate, &d.EndDate, &d.Name, &d.Description, &d.Unit,
|
||||||
&d.FileType, &d.SizeBytes, &d.Status, &d.CreatedAt, &d.UpdatedAt,
|
&d.FileType, &d.SizeBytes, &d.Status, &d.CreatedAt, &d.UpdatedAt,
|
||||||
)
|
)
|
||||||
return d, err
|
return d, err
|
||||||
@@ -400,6 +413,22 @@ func datasetFilterClause(f domain.DatasetFilter, args []any) (string, []any) {
|
|||||||
args = append(args, *f.Status)
|
args = append(args, *f.Status)
|
||||||
conds = append(conds, fmt.Sprintf("status = $%d", len(args)))
|
conds = append(conds, fmt.Sprintf("status = $%d", len(args)))
|
||||||
}
|
}
|
||||||
|
if f.DatasetGroupID != nil {
|
||||||
|
args = append(args, *f.DatasetGroupID)
|
||||||
|
conds = append(conds, fmt.Sprintf("dg_id = $%d", len(args)))
|
||||||
|
}
|
||||||
|
for _, b := range []struct {
|
||||||
|
col, op string
|
||||||
|
val *string
|
||||||
|
}{
|
||||||
|
{"start_date", ">=", f.StartDateFrom}, {"start_date", "<=", f.StartDateTo},
|
||||||
|
{"end_date", ">=", f.EndDateFrom}, {"end_date", "<=", f.EndDateTo},
|
||||||
|
} {
|
||||||
|
if b.val != nil {
|
||||||
|
args = append(args, *b.val)
|
||||||
|
conds = append(conds, fmt.Sprintf("%s %s $%d::date", b.col, b.op, len(args)))
|
||||||
|
}
|
||||||
|
}
|
||||||
if len(conds) == 0 {
|
if len(conds) == 0 {
|
||||||
return "", args
|
return "", args
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,14 +9,14 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// ListAllObservations returns every observation for a dataset ordered by
|
// ListAllObservations returns every observation for a dataset ordered by
|
||||||
// (kato_code, date). Unlike ListObservations it is unpaginated: it backs GeoJSON
|
// (kato_code, start_date). Unlike ListObservations it is unpaginated: it backs GeoJSON
|
||||||
// assembly, where all of a dataset's observations are needed at once.
|
// assembly, where all of a dataset's observations are needed at once.
|
||||||
func (r *DatasetRepository) ListAllObservations(ctx context.Context, datasetID uuid.UUID) ([]domain.Observation, error) {
|
func (r *DatasetRepository) ListAllObservations(ctx context.Context, datasetID uuid.UUID) ([]domain.Observation, error) {
|
||||||
rows, err := r.pool.Query(ctx,
|
rows, err := r.pool.Query(ctx,
|
||||||
`SELECT `+observationColumns+`
|
`SELECT `+observationColumns+`
|
||||||
FROM dataset_observations
|
FROM dataset_observations
|
||||||
WHERE dataset_id = $1
|
WHERE dataset_id = $1
|
||||||
ORDER BY kato_code, date`,
|
ORDER BY kato_code, start_date`,
|
||||||
datasetID)
|
datasetID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, mapError(err)
|
return nil, mapError(err)
|
||||||
|
|||||||
@@ -37,7 +37,8 @@ var (
|
|||||||
type DatasetRepository interface {
|
type DatasetRepository interface {
|
||||||
Create(ctx context.Context, d domain.Dataset) (domain.Dataset, error)
|
Create(ctx context.Context, d domain.Dataset) (domain.Dataset, error)
|
||||||
GetByID(ctx context.Context, id uuid.UUID) (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)
|
ListSummaries(ctx context.Context, filter domain.DatasetFilter, limit, offset int) ([]domain.DatasetSummary, error)
|
||||||
Count(ctx context.Context, filter domain.DatasetFilter) (int, error)
|
Count(ctx context.Context, filter domain.DatasetFilter) (int, error)
|
||||||
Delete(ctx context.Context, id uuid.UUID) error
|
Delete(ctx context.Context, id uuid.UUID) error
|
||||||
@@ -125,6 +126,11 @@ type UploadInput struct {
|
|||||||
ContentType string
|
ContentType string
|
||||||
Size int64
|
Size int64
|
||||||
Reader io.Reader
|
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.
|
// 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 {
|
if err := s.ensureCategoryExists(ctx, in.CategoryID); err != nil {
|
||||||
return domain.Dataset{}, err
|
return domain.Dataset{}, err
|
||||||
}
|
}
|
||||||
|
if err := domain.ValidateDatePeriod(in.StartDate, in.EndDate); err != nil {
|
||||||
|
return domain.Dataset{}, err
|
||||||
|
}
|
||||||
|
|
||||||
name := in.Name
|
name := in.Name
|
||||||
if name == "" {
|
if name == "" {
|
||||||
@@ -211,6 +220,9 @@ func (s *DatasetService) Upload(ctx context.Context, in UploadInput) (domain.Dat
|
|||||||
|
|
||||||
dataset, err := s.repo.Create(ctx, domain.Dataset{
|
dataset, err := s.repo.Create(ctx, domain.Dataset{
|
||||||
CategoryID: in.CategoryID,
|
CategoryID: in.CategoryID,
|
||||||
|
DatasetGroupID: in.DatasetGroupID,
|
||||||
|
StartDate: in.StartDate,
|
||||||
|
EndDate: in.EndDate,
|
||||||
Name: name,
|
Name: name,
|
||||||
Description: in.Description,
|
Description: in.Description,
|
||||||
Unit: in.Unit,
|
Unit: in.Unit,
|
||||||
@@ -682,17 +694,17 @@ func (s *DatasetService) SaveMapping(ctx context.Context, id uuid.UUID, in Mappi
|
|||||||
if _, ok := known[yc.Column]; !ok {
|
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)
|
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 {
|
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 {
|
if yc.EndDate != nil {
|
||||||
to, err := time.Parse("2006-01-02", *yc.DateTo)
|
to, err := time.Parse("2006-01-02", *yc.EndDate)
|
||||||
if err != nil {
|
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) {
|
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
|
// buildObservations unpivots rows into observations. Rows without a KATO code
|
||||||
// are skipped; duplicate (kato, date, date_to) tuples keep the last value.
|
// are skipped; duplicate (kato, start_date, end_date) tuples keep the last
|
||||||
// Numeric cells populate Value, others ValueText. A year column's DateTo (nil
|
// value. Numeric cells populate Value, others ValueText. A year column's
|
||||||
// for a point-in-time mapping) carries through to the observation unchanged.
|
// 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 {
|
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))
|
obs := make([]domain.Observation, 0, len(rows)*len(years))
|
||||||
index := make(map[string]int)
|
index := make(map[string]int)
|
||||||
@@ -761,7 +774,7 @@ func buildObservations(datasetID uuid.UUID, katoColumn string, years []domain.Ye
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
for _, yc := range years {
|
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 raw := strings.TrimSpace(row[yc.Column]); raw != "" {
|
||||||
if f, err := strconv.ParseFloat(raw, 64); err == nil {
|
if f, err := strconv.ParseFloat(raw, 64); err == nil {
|
||||||
o.Value = &f
|
o.Value = &f
|
||||||
@@ -769,7 +782,7 @@ func buildObservations(datasetID uuid.UUID, katoColumn string, years []domain.Ye
|
|||||||
o.ValueText = &raw
|
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 {
|
if i, ok := index[key]; ok {
|
||||||
obs[i] = o
|
obs[i] = o
|
||||||
} else {
|
} else {
|
||||||
@@ -781,12 +794,12 @@ func buildObservations(datasetID uuid.UUID, katoColumn string, years []domain.Ye
|
|||||||
return obs
|
return obs
|
||||||
}
|
}
|
||||||
|
|
||||||
// dateToKey normalizes an optional period end date for use in a dedup key.
|
// endDateKey normalizes an optional period end date for use in a dedup key.
|
||||||
func dateToKey(dateTo *string) string {
|
func endDateKey(endDate *string) string {
|
||||||
if dateTo == nil {
|
if endDate == nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
return *dateTo
|
return *endDate
|
||||||
}
|
}
|
||||||
|
|
||||||
// ObservationPage is a page of observations with pagination metadata.
|
// 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
|
// boundary taken from the districts table and its observation values nested
|
||||||
// under a `data` object (keyed by date) alongside `kato` and `name`. KATO codes
|
// 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
|
// 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
|
// (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
|
// when every observation is point-in-time, so a dataset mapped without periods
|
||||||
// produces byte-identical output to before periods existed.
|
// 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
|
// the grouped observation values (keyed by date) nested under a `data` object
|
||||||
// alongside `kato` and `name` in each Feature's properties. When periods holds
|
// 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->
|
// 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
|
// period observations gets no `periods` key at all. KATO codes with no
|
||||||
// matching district are skipped. order drives the deterministic feature order.
|
// 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) {
|
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
|
// groupObservationsByKato groups observations by KATO code into date->value
|
||||||
// maps. The value is the numeric Value when present, otherwise ValueText,
|
// 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
|
// otherwise nil (an empty cell). It also groups, per KATO, a start_date->end_date map
|
||||||
// for observations that carry a period (DateTo set); a KATO with no period
|
// 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
|
// 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
|
// periods" apart from "empty periods". order lists KATO codes in first-seen
|
||||||
// order so the resulting feature order is deterministic.
|
// 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
|
grouped[o.KatoCode] = values
|
||||||
order = append(order, o.KatoCode)
|
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]
|
p, ok := periods[o.KatoCode]
|
||||||
if !ok {
|
if !ok {
|
||||||
p = make(map[string]string)
|
p = make(map[string]string)
|
||||||
periods[o.KatoCode] = p
|
periods[o.KatoCode] = p
|
||||||
}
|
}
|
||||||
p[o.Date] = *o.DateTo
|
p[o.StartDate] = *o.EndDate
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return grouped, periods, order
|
return grouped, periods, order
|
||||||
@@ -1074,6 +1087,10 @@ type DatasetUpdateInput struct {
|
|||||||
Name string
|
Name string
|
||||||
Description *string
|
Description *string
|
||||||
Unit *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,
|
// 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 {
|
if err := s.ensureCategoryExists(ctx, in.CategoryID); err != nil {
|
||||||
return domain.Dataset{}, err
|
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
|
// DatasetPatchInput carries a sparse set of metadata field changes. A nil
|
||||||
@@ -1096,6 +1124,13 @@ type DatasetPatchInput struct {
|
|||||||
DescriptionSet bool
|
DescriptionSet bool
|
||||||
Unit *string
|
Unit *string
|
||||||
UnitSet bool
|
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,
|
// 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 {
|
if in.UnitSet {
|
||||||
unit = in.Unit
|
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
|
// 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
|
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]
|
d, ok := r.store[id]
|
||||||
if !ok {
|
if !ok {
|
||||||
return domain.Dataset{}, domain.ErrNotFound
|
return domain.Dataset{}, domain.ErrNotFound
|
||||||
}
|
}
|
||||||
d.CategoryID = categoryID
|
d.CategoryID = f.CategoryID
|
||||||
d.Name = name
|
d.Name = f.Name
|
||||||
d.Description = description
|
d.Description = f.Description
|
||||||
d.Unit = unit
|
d.Unit = f.Unit
|
||||||
|
d.DatasetGroupID = f.DatasetGroupID
|
||||||
|
d.StartDate = f.StartDate
|
||||||
|
d.EndDate = f.EndDate
|
||||||
r.store[id] = d
|
r.store[id] = d
|
||||||
return d, nil
|
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) {
|
func (r *stubDatasetRepo) ListSummaries(_ context.Context, filter domain.DatasetFilter, limit, offset int) ([]domain.DatasetSummary, error) {
|
||||||
r.lastFilter = filter
|
r.lastFilter = filter
|
||||||
r.lastLimit = limit
|
r.lastLimit = limit
|
||||||
@@ -756,7 +797,7 @@ func TestDatasetService_SaveMapping(t *testing.T) {
|
|||||||
Status: domain.DatasetStatusAwaitingMapping,
|
Status: domain.DatasetStatusAwaitingMapping,
|
||||||
AttributeColumns: []domain.AttributeColumn{{Name: "като"}, {Name: "F_2023"}},
|
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) {
|
newSvc := func() (*stubDatasetRepo, *DatasetService) {
|
||||||
repo := newStubDatasetRepo()
|
repo := newStubDatasetRepo()
|
||||||
@@ -774,31 +815,31 @@ func TestDatasetService_SaveMapping(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("unknown year column", func(t *testing.T) {
|
t.Run("unknown year column", func(t *testing.T) {
|
||||||
_, svc := newSvc()
|
_, 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) {
|
if !errors.Is(err, domain.ErrValidation) {
|
||||||
t.Fatalf("want ErrValidation, got %v", err)
|
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()
|
_, 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) {
|
if !errors.Is(err, domain.ErrValidation) {
|
||||||
t.Fatalf("want ErrValidation, got %v", err)
|
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()
|
_, 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) {
|
if !errors.Is(err, domain.ErrValidation) {
|
||||||
t.Fatalf("want ErrValidation, got %v", err)
|
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()
|
_, 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) {
|
if !errors.Is(err, domain.ErrValidation) {
|
||||||
t.Fatalf("want ErrValidation, got %v", err)
|
t.Fatalf("want ErrValidation, got %v", err)
|
||||||
}
|
}
|
||||||
@@ -808,12 +849,12 @@ func TestDatasetService_SaveMapping(t *testing.T) {
|
|||||||
repo, svc := newSvc()
|
repo, svc := newSvc()
|
||||||
got, err := svc.SaveMapping(ctx, id, MappingInput{
|
got, err := svc.SaveMapping(ctx, id, MappingInput{
|
||||||
KatoColumn: "като",
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
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)
|
t.Fatalf("period not saved: %+v", got.YearColumns)
|
||||||
}
|
}
|
||||||
if len(repo.store[id].YearColumns) != 1 {
|
if len(repo.store[id].YearColumns) != 1 {
|
||||||
@@ -866,8 +907,8 @@ func TestDatasetService_SaveMapping(t *testing.T) {
|
|||||||
func TestBuildObservations(t *testing.T) {
|
func TestBuildObservations(t *testing.T) {
|
||||||
id := uuid.New()
|
id := uuid.New()
|
||||||
years := []domain.YearColumn{
|
years := []domain.YearColumn{
|
||||||
{Column: "F_2023", Date: "2023-01-01"},
|
{Column: "F_2023", StartDate: "2023-01-01"},
|
||||||
{Column: "D_2025", Date: "2025-01-01"},
|
{Column: "D_2025", StartDate: "2025-01-01"},
|
||||||
}
|
}
|
||||||
rows := []map[string]string{
|
rows := []map[string]string{
|
||||||
{"като": "751010000", "F_2023": "100", "D_2025": "n/a"},
|
{"като": "751010000", "F_2023": "100", "D_2025": "n/a"},
|
||||||
@@ -882,7 +923,7 @@ func TestBuildObservations(t *testing.T) {
|
|||||||
|
|
||||||
byKey := map[string]domain.Observation{}
|
byKey := map[string]domain.Observation{}
|
||||||
for _, o := range obs {
|
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 {
|
if o := byKey["751010000|2023-01-01"]; o.Value == nil || *o.Value != 100 {
|
||||||
t.Errorf("numeric cell not stored as value: %+v", o)
|
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)
|
t.Errorf("non-numeric cell not stored as value_text: %+v", o)
|
||||||
}
|
}
|
||||||
for _, o := range obs {
|
for _, o := range obs {
|
||||||
if o.DateTo != nil {
|
if o.EndDate != nil {
|
||||||
t.Fatalf("point-in-time mapping must not set DateTo, got %+v", o)
|
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) {
|
func TestBuildObservations_Period(t *testing.T) {
|
||||||
id := uuid.New()
|
id := uuid.New()
|
||||||
years := []domain.YearColumn{
|
years := []domain.YearColumn{
|
||||||
{Column: "F_2020_2023", Date: "2020-01-01", DateTo: strPtr("2023-12-31")}, // period
|
{Column: "F_2020_2023", StartDate: "2020-01-01", EndDate: strPtr("2023-12-31")}, // period
|
||||||
{Column: "F_2024", Date: "2024-01-01"}, // point-in-time, unaffected
|
{Column: "F_2024", StartDate: "2024-01-01"}, // point-in-time, unaffected
|
||||||
}
|
}
|
||||||
rows := []map[string]string{
|
rows := []map[string]string{
|
||||||
{"като": "751010000", "F_2020_2023": "100", "F_2024": "200"},
|
{"като": "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))
|
t.Fatalf("want 2 observations, got %d", len(obs))
|
||||||
}
|
}
|
||||||
|
|
||||||
byDate := map[string]domain.Observation{}
|
byStartDate := map[string]domain.Observation{}
|
||||||
for _, o := range obs {
|
for _, o := range obs {
|
||||||
byDate[o.Date] = o
|
byStartDate[o.StartDate] = o
|
||||||
}
|
}
|
||||||
|
|
||||||
period := byDate["2020-01-01"]
|
period := byStartDate["2020-01-01"]
|
||||||
if period.DateTo == nil || *period.DateTo != "2023-12-31" {
|
if period.EndDate == nil || *period.EndDate != "2023-12-31" {
|
||||||
t.Fatalf("period observation missing date_to: %+v", period)
|
t.Fatalf("period observation missing end_date: %+v", period)
|
||||||
}
|
}
|
||||||
if period.Value == nil || *period.Value != 100 {
|
if period.Value == nil || *period.Value != 100 {
|
||||||
t.Fatalf("period observation value wrong: %+v", period)
|
t.Fatalf("period observation value wrong: %+v", period)
|
||||||
}
|
}
|
||||||
|
|
||||||
point := byDate["2024-01-01"]
|
point := byStartDate["2024-01-01"]
|
||||||
if point.DateTo != nil {
|
if point.EndDate != nil {
|
||||||
t.Fatalf("point-in-time observation must have nil date_to (backward compat), got %+v", point)
|
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",
|
Filename: "r.geojson", StorageKey: "k",
|
||||||
Status: domain.DatasetStatusExtracting,
|
Status: domain.DatasetStatusExtracting,
|
||||||
KatoColumn: &kato,
|
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{
|
repo.districts["751010000"] = domain.District{
|
||||||
Kato: "751010000", Name: "Almaty",
|
Kato: "751010000", Name: "Almaty",
|
||||||
@@ -1002,9 +1043,9 @@ func TestDatasetService_GeoJSON_DistrictJoin(t *testing.T) {
|
|||||||
|
|
||||||
v2020, v2021 := 100.0, 150.0
|
v2020, v2021 := 100.0, 150.0
|
||||||
repo.observations[id] = []domain.Observation{
|
repo.observations[id] = []domain.Observation{
|
||||||
{KatoCode: "710000000", Date: "2020-01-01", Value: &v2020},
|
{KatoCode: "710000000", StartDate: "2020-01-01", Value: &v2020},
|
||||||
{KatoCode: "710000000", Date: "2021-01-01", Value: &v2021},
|
{KatoCode: "710000000", StartDate: "2021-01-01", Value: &v2021},
|
||||||
{KatoCode: "999999999", Date: "2020-01-01", Value: &v2020}, // no district -> skipped
|
{KatoCode: "999999999", StartDate: "2020-01-01", Value: &v2020}, // no district -> skipped
|
||||||
}
|
}
|
||||||
repo.districts["710000000"] = domain.District{
|
repo.districts["710000000"] = domain.District{
|
||||||
Kato: "710000000", Name: "Astana",
|
Kato: "710000000", Name: "Astana",
|
||||||
@@ -1050,8 +1091,8 @@ func TestDatasetService_GeoJSON_Period(t *testing.T) {
|
|||||||
|
|
||||||
period, point := 100.0, 200.0
|
period, point := 100.0, 200.0
|
||||||
repo.observations[id] = []domain.Observation{
|
repo.observations[id] = []domain.Observation{
|
||||||
{KatoCode: "710000000", Date: "2020-01-01", DateTo: strPtr("2023-12-31"), Value: &period},
|
{KatoCode: "710000000", StartDate: "2020-01-01", EndDate: strPtr("2023-12-31"), Value: &period},
|
||||||
{KatoCode: "710000000", Date: "2024-01-01", Value: &point}, // point-in-time, no period
|
{KatoCode: "710000000", StartDate: "2024-01-01", Value: &point}, // point-in-time, no period
|
||||||
}
|
}
|
||||||
repo.districts["710000000"] = domain.District{
|
repo.districts["710000000"] = domain.District{
|
||||||
Kato: "710000000", Name: "Astana",
|
Kato: "710000000", Name: "Astana",
|
||||||
@@ -1092,7 +1133,7 @@ func TestDatasetService_GeoJSON_NoPeriods_OmitsPeriodsKey(t *testing.T) {
|
|||||||
repo := newStubDatasetRepo()
|
repo := newStubDatasetRepo()
|
||||||
repo.store[id] = domain.Dataset{ID: id, FileType: domain.FileTypeVectorWithKato, Status: domain.DatasetStatusReady}
|
repo.store[id] = domain.Dataset{ID: id, FileType: domain.FileTypeVectorWithKato, Status: domain.DatasetStatusReady}
|
||||||
v := 100.0
|
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{
|
repo.districts["710000000"] = domain.District{
|
||||||
Kato: "710000000", Name: "Astana",
|
Kato: "710000000", Name: "Astana",
|
||||||
Geometry: json.RawMessage(`{"type":"Polygon","coordinates":[[[71,51],[72,51],[72,52],[71,51]]]}`),
|
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
|
v := 100.0
|
||||||
repo.observations[id] = []domain.Observation{
|
repo.observations[id] = []domain.Observation{
|
||||||
{KatoCode: "710000000", Date: "2020-01-01", Value: &v},
|
{KatoCode: "710000000", StartDate: "2020-01-01", Value: &v},
|
||||||
{KatoCode: "999999999", Date: "2020-01-01", Value: &v}, // no district -> skipped
|
{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]]]}`)
|
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}
|
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 := newStubDatasetRepo()
|
||||||
repo.store[id] = domain.Dataset{ID: id, FileType: domain.FileTypeVectorWithKato, Status: domain.DatasetStatusReady}
|
repo.store[id] = domain.Dataset{ID: id, FileType: domain.FileTypeVectorWithKato, Status: domain.DatasetStatusReady}
|
||||||
v := 100.0
|
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{
|
repo.districts["710000000"] = domain.District{
|
||||||
Kato: "710000000", Name: "Astana",
|
Kato: "710000000", Name: "Astana",
|
||||||
Geometry: json.RawMessage(`{"type":"Polygon","coordinates":[[[71,51],[72,51],[72,52],[71,51]]]}`),
|
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)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -81,8 +81,8 @@ func (h *DatasetHandler) status(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
type yearColumnInput struct {
|
type yearColumnInput struct {
|
||||||
Column string `json:"column" validate:"required"`
|
Column string `json:"column" validate:"required"`
|
||||||
Date string `json:"date" validate:"required,datetime=2006-01-02"`
|
StartDate string `json:"start_date" validate:"required,datetime=2006-01-02"`
|
||||||
DateTo *string `json:"date_to" validate:"omitempty,datetime=2006-01-02"`
|
EndDate *string `json:"end_date" validate:"omitempty,datetime=2006-01-02"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type mappingRequest struct {
|
type mappingRequest struct {
|
||||||
@@ -109,7 +109,7 @@ func (h *DatasetHandler) mapping(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
in := service.MappingInput{KatoColumn: req.KatoColumn}
|
in := service.MappingInput{KatoColumn: req.KatoColumn}
|
||||||
for _, yc := range req.YearColumns {
|
for _, yc := range req.YearColumns {
|
||||||
in.YearColumns = append(in.YearColumns, domain.YearColumn{Column: yc.Column, Date: yc.Date, DateTo: yc.DateTo})
|
in.YearColumns = append(in.YearColumns, domain.YearColumn{Column: yc.Column, StartDate: yc.StartDate, EndDate: yc.EndDate})
|
||||||
}
|
}
|
||||||
|
|
||||||
dataset, err := h.svc.SaveMapping(r.Context(), id, in)
|
dataset, err := h.svc.SaveMapping(r.Context(), id, in)
|
||||||
@@ -150,6 +150,17 @@ func (h *DatasetHandler) upload(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The dataset group is optional; the period is validated by the service.
|
||||||
|
var groupID *uuid.UUID
|
||||||
|
if v := optionalFormValue(r, "dg_id"); v != nil {
|
||||||
|
gid, err := uuid.Parse(*v)
|
||||||
|
if err != nil {
|
||||||
|
httputil.WriteError(w, http.StatusUnprocessableEntity, "dg_id must be a valid UUID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
groupID = &gid
|
||||||
|
}
|
||||||
|
|
||||||
file, header, err := r.FormFile("file")
|
file, header, err := r.FormFile("file")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
httputil.WriteError(w, http.StatusBadRequest, "file is required")
|
httputil.WriteError(w, http.StatusBadRequest, "file is required")
|
||||||
@@ -174,6 +185,9 @@ func (h *DatasetHandler) upload(w http.ResponseWriter, r *http.Request) {
|
|||||||
ContentType: contentType,
|
ContentType: contentType,
|
||||||
Size: header.Size,
|
Size: header.Size,
|
||||||
Reader: file,
|
Reader: file,
|
||||||
|
DatasetGroupID: groupID,
|
||||||
|
StartDate: optionalFormValue(r, "start_date"),
|
||||||
|
EndDate: optionalFormValue(r, "end_date"),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respondDomainError(w, err)
|
respondDomainError(w, err)
|
||||||
@@ -226,7 +240,32 @@ func (h *DatasetHandler) list(w http.ResponseWriter, r *http.Request) {
|
|||||||
categoryCode = &v
|
categoryCode = &v
|
||||||
}
|
}
|
||||||
|
|
||||||
filter := domain.DatasetFilter{CategoryID: categoryID}
|
groupID, ok := parseOptionalUUIDQuery(w, r, "dg_id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
filter := domain.DatasetFilter{CategoryID: categoryID, DatasetGroupID: groupID}
|
||||||
|
|
||||||
|
for _, b := range []struct {
|
||||||
|
name string
|
||||||
|
dst **string
|
||||||
|
}{
|
||||||
|
{"start_date_from", &filter.StartDateFrom},
|
||||||
|
{"start_date_to", &filter.StartDateTo},
|
||||||
|
{"end_date_from", &filter.EndDateFrom},
|
||||||
|
{"end_date_to", &filter.EndDateTo},
|
||||||
|
} {
|
||||||
|
v := strings.TrimSpace(r.URL.Query().Get(b.name))
|
||||||
|
if v == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err := time.Parse("2006-01-02", v); err != nil {
|
||||||
|
httputil.WriteError(w, http.StatusBadRequest, b.name+" must be YYYY-MM-DD")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
*b.dst = &v
|
||||||
|
}
|
||||||
|
|
||||||
if v := strings.TrimSpace(r.URL.Query().Get("file_type")); v != "" {
|
if v := strings.TrimSpace(r.URL.Query().Get("file_type")); v != "" {
|
||||||
ft := domain.FileType(v)
|
ft := domain.FileType(v)
|
||||||
@@ -320,10 +359,15 @@ type datasetPutRequest struct {
|
|||||||
Name string `json:"name" validate:"required,max=255"`
|
Name string `json:"name" validate:"required,max=255"`
|
||||||
Description *string `json:"description" validate:"required"`
|
Description *string `json:"description" validate:"required"`
|
||||||
Unit *string `json:"unit" validate:"required"`
|
Unit *string `json:"unit" validate:"required"`
|
||||||
|
// DgID, StartDate and EndDate are optional; omitting them (or sending null
|
||||||
|
// or a blank string) clears the corresponding nullable column.
|
||||||
|
DgID *string `json:"dg_id" validate:"omitempty,uuid"`
|
||||||
|
StartDate *string `json:"start_date" validate:"omitempty,datetime=2006-01-02"`
|
||||||
|
EndDate *string `json:"end_date" validate:"omitempty,datetime=2006-01-02"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// update replaces a dataset's editable metadata (category, name, description,
|
// update replaces a dataset's editable metadata (category, name, description,
|
||||||
// unit).
|
// unit, dataset group, period).
|
||||||
func (h *DatasetHandler) update(w http.ResponseWriter, r *http.Request) {
|
func (h *DatasetHandler) update(w http.ResponseWriter, r *http.Request) {
|
||||||
id, ok := parseUUIDParam(w, r, "id")
|
id, ok := parseUUIDParam(w, r, "id")
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -344,11 +388,24 @@ func (h *DatasetHandler) update(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var groupID *uuid.UUID
|
||||||
|
if raw := optionalPtr(req.DgID); raw != nil {
|
||||||
|
gid, err := uuid.Parse(*raw)
|
||||||
|
if err != nil {
|
||||||
|
httputil.WriteError(w, http.StatusUnprocessableEntity, "dg_id must be a valid UUID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
groupID = &gid
|
||||||
|
}
|
||||||
|
|
||||||
dataset, err := h.svc.Update(r.Context(), id, service.DatasetUpdateInput{
|
dataset, err := h.svc.Update(r.Context(), id, service.DatasetUpdateInput{
|
||||||
CategoryID: categoryID,
|
CategoryID: categoryID,
|
||||||
Name: strings.TrimSpace(req.Name),
|
Name: strings.TrimSpace(req.Name),
|
||||||
Description: normalizeOptional(*req.Description),
|
Description: normalizeOptional(*req.Description),
|
||||||
Unit: normalizeOptional(*req.Unit),
|
Unit: normalizeOptional(*req.Unit),
|
||||||
|
DatasetGroupID: groupID,
|
||||||
|
StartDate: optionalPtr(req.StartDate),
|
||||||
|
EndDate: optionalPtr(req.EndDate),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respondDomainError(w, err)
|
respondDomainError(w, err)
|
||||||
@@ -360,11 +417,13 @@ func (h *DatasetHandler) update(w http.ResponseWriter, r *http.Request) {
|
|||||||
// datasetPatchFields lists the JSON keys accepted by PATCH /datasets/{id}.
|
// datasetPatchFields lists the JSON keys accepted by PATCH /datasets/{id}.
|
||||||
var datasetPatchFields = map[string]struct{}{
|
var datasetPatchFields = map[string]struct{}{
|
||||||
"category_id": {}, "name": {}, "description": {}, "unit": {},
|
"category_id": {}, "name": {}, "description": {}, "unit": {},
|
||||||
|
"dg_id": {}, "start_date": {}, "end_date": {},
|
||||||
}
|
}
|
||||||
|
|
||||||
// patch applies a sparse update to a dataset's editable metadata (category,
|
// patch applies a sparse update to a dataset's editable metadata (category,
|
||||||
// name, description, unit): only fields present in the body are changed.
|
// name, description, unit, dg_id, start_date, end_date): only fields present in
|
||||||
// Sending an explicit null for description/unit clears that column.
|
// the body are changed. Sending an explicit null for a nullable field clears
|
||||||
|
// that column.
|
||||||
func (h *DatasetHandler) patch(w http.ResponseWriter, r *http.Request) {
|
func (h *DatasetHandler) patch(w http.ResponseWriter, r *http.Request) {
|
||||||
id, ok := parseUUIDParam(w, r, "id")
|
id, ok := parseUUIDParam(w, r, "id")
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -421,6 +480,35 @@ func (h *DatasetHandler) patch(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
in.Unit, in.UnitSet = unit, true
|
in.Unit, in.UnitSet = unit, true
|
||||||
|
case "dg_id":
|
||||||
|
raw, err := decodeOptionalPatchString(val)
|
||||||
|
if err != nil {
|
||||||
|
httputil.WriteError(w, http.StatusUnprocessableEntity, "dg_id must be a valid UUID or null")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if raw != nil {
|
||||||
|
gid, err := uuid.Parse(*raw)
|
||||||
|
if err != nil {
|
||||||
|
httputil.WriteError(w, http.StatusUnprocessableEntity, "dg_id must be a valid UUID or null")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
in.DatasetGroupID = &gid
|
||||||
|
}
|
||||||
|
in.DatasetGroupIDSet = true
|
||||||
|
case "start_date":
|
||||||
|
date, err := decodeOptionalPatchDate(val)
|
||||||
|
if err != nil {
|
||||||
|
httputil.WriteError(w, http.StatusUnprocessableEntity, "start_date must be YYYY-MM-DD or null")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
in.StartDate, in.StartDateSet = date, true
|
||||||
|
case "end_date":
|
||||||
|
date, err := decodeOptionalPatchDate(val)
|
||||||
|
if err != nil {
|
||||||
|
httputil.WriteError(w, http.StatusUnprocessableEntity, "end_date must be YYYY-MM-DD or null")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
in.EndDate, in.EndDateSet = date, true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -432,6 +520,15 @@ func (h *DatasetHandler) patch(w http.ResponseWriter, r *http.Request) {
|
|||||||
httputil.WriteJSON(w, http.StatusOK, dataset)
|
httputil.WriteJSON(w, http.StatusOK, dataset)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// optionalPtr normalizes an optional request field: a nil or blank value
|
||||||
|
// yields nil, so an omitted or empty field clears a nullable column.
|
||||||
|
func optionalPtr(v *string) *string {
|
||||||
|
if v == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return normalizeOptional(*v)
|
||||||
|
}
|
||||||
|
|
||||||
// normalizeOptional trims s and returns nil when blank, so an empty string
|
// normalizeOptional trims s and returns nil when blank, so an empty string
|
||||||
// clears a nullable column rather than being stored verbatim.
|
// clears a nullable column rather than being stored verbatim.
|
||||||
func normalizeOptional(s string) *string {
|
func normalizeOptional(s string) *string {
|
||||||
@@ -455,6 +552,20 @@ func decodeOptionalPatchString(raw json.RawMessage) (*string, error) {
|
|||||||
return normalizeOptional(s), nil
|
return normalizeOptional(s), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// decodeOptionalPatchDate unmarshals a PATCH date field that may be a
|
||||||
|
// YYYY-MM-DD JSON string or null, returning nil (clear the column) for null or
|
||||||
|
// a blank string.
|
||||||
|
func decodeOptionalPatchDate(raw json.RawMessage) (*string, error) {
|
||||||
|
v, err := decodeOptionalPatchString(raw)
|
||||||
|
if err != nil || v == nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if _, err := time.Parse("2006-01-02", *v); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
|
||||||
// geojson returns the dataset's pre-assembled GeoJSON FeatureCollection (RFC
|
// geojson returns the dataset's pre-assembled GeoJSON FeatureCollection (RFC
|
||||||
// 7946), generated and stored at processing time. A vector_with_kato dataset's
|
// 7946), generated and stored at processing time. A vector_with_kato dataset's
|
||||||
// collection joins the districts table on KATO code (one feature per KATO with
|
// collection joins the districts table on KATO code (one feature per KATO with
|
||||||
|
|||||||
67
migrations/00010_dataset_groups_and_dates.sql
Normal file
67
migrations/00010_dataset_groups_and_dates.sql
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
-- +goose Up
|
||||||
|
-- Dataset groups: "одноимённые" datasets versioned by period. A group may carry a
|
||||||
|
-- shared SLD used as a fallback for its datasets.
|
||||||
|
CREATE TABLE dataset_groups (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
code VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
description TEXT NOT NULL DEFAULT '',
|
||||||
|
sld_storage_key TEXT,
|
||||||
|
sld_filename VARCHAR(255),
|
||||||
|
sld_content_type VARCHAR(255),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE datasets ADD COLUMN dg_id UUID REFERENCES dataset_groups (id) ON DELETE RESTRICT;
|
||||||
|
ALTER TABLE datasets ADD COLUMN start_date DATE;
|
||||||
|
ALTER TABLE datasets ADD COLUMN end_date DATE;
|
||||||
|
ALTER TABLE datasets ADD CONSTRAINT chk_datasets_period
|
||||||
|
CHECK (start_date IS NULL OR end_date IS NULL OR end_date >= start_date);
|
||||||
|
CREATE INDEX idx_datasets_dg_id_end_date ON datasets (dg_id, end_date DESC NULLS LAST, created_at DESC);
|
||||||
|
CREATE INDEX idx_datasets_start_date ON datasets (start_date);
|
||||||
|
CREATE INDEX idx_datasets_end_date ON datasets (end_date);
|
||||||
|
|
||||||
|
-- Period columns are renamed everywhere: observations, and the year_columns
|
||||||
|
-- mapping JSON ({column, date, date_to} -> {column, start_date, end_date}).
|
||||||
|
ALTER TABLE dataset_observations RENAME COLUMN date TO start_date;
|
||||||
|
ALTER TABLE dataset_observations RENAME COLUMN date_to TO end_date;
|
||||||
|
DROP INDEX uq_dataset_observations;
|
||||||
|
DROP INDEX idx_dataset_observations_kato_date;
|
||||||
|
CREATE UNIQUE INDEX uq_dataset_observations ON dataset_observations (dataset_id, kato_code, start_date, COALESCE(end_date, start_date));
|
||||||
|
CREATE INDEX idx_dataset_observations_kato_date ON dataset_observations (kato_code, start_date);
|
||||||
|
|
||||||
|
UPDATE datasets
|
||||||
|
SET year_columns = (
|
||||||
|
SELECT jsonb_agg(
|
||||||
|
(elem - 'date' - 'date_to')
|
||||||
|
|| jsonb_build_object('start_date', elem->'date')
|
||||||
|
|| CASE WHEN elem ? 'date_to' AND elem->'date_to' IS NOT NULL
|
||||||
|
THEN jsonb_build_object('end_date', elem->'date_to') ELSE '{}'::jsonb END)
|
||||||
|
FROM jsonb_array_elements(year_columns) AS elem)
|
||||||
|
WHERE year_columns IS NOT NULL AND jsonb_typeof(year_columns) = 'array' AND jsonb_array_length(year_columns) > 0;
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
UPDATE datasets
|
||||||
|
SET year_columns = (
|
||||||
|
SELECT jsonb_agg(
|
||||||
|
(elem - 'start_date' - 'end_date')
|
||||||
|
|| jsonb_build_object('date', elem->'start_date')
|
||||||
|
|| CASE WHEN elem ? 'end_date' AND elem->'end_date' IS NOT NULL
|
||||||
|
THEN jsonb_build_object('date_to', elem->'end_date') ELSE '{}'::jsonb END)
|
||||||
|
FROM jsonb_array_elements(year_columns) AS elem)
|
||||||
|
WHERE year_columns IS NOT NULL AND jsonb_typeof(year_columns) = 'array' AND jsonb_array_length(year_columns) > 0;
|
||||||
|
DROP INDEX uq_dataset_observations;
|
||||||
|
DROP INDEX idx_dataset_observations_kato_date;
|
||||||
|
ALTER TABLE dataset_observations RENAME COLUMN start_date TO date;
|
||||||
|
ALTER TABLE dataset_observations RENAME COLUMN end_date TO date_to;
|
||||||
|
CREATE UNIQUE INDEX uq_dataset_observations ON dataset_observations (dataset_id, kato_code, date, COALESCE(date_to, date));
|
||||||
|
CREATE INDEX idx_dataset_observations_kato_date ON dataset_observations (kato_code, date);
|
||||||
|
DROP INDEX idx_datasets_dg_id_end_date;
|
||||||
|
DROP INDEX idx_datasets_start_date;
|
||||||
|
DROP INDEX idx_datasets_end_date;
|
||||||
|
ALTER TABLE datasets DROP CONSTRAINT chk_datasets_period;
|
||||||
|
ALTER TABLE datasets DROP COLUMN dg_id;
|
||||||
|
ALTER TABLE datasets DROP COLUMN start_date;
|
||||||
|
ALTER TABLE datasets DROP COLUMN end_date;
|
||||||
|
DROP TABLE dataset_groups;
|
||||||
Reference in New Issue
Block a user