feat: cache geojson data for vector files
This commit is contained in:
@@ -146,6 +146,16 @@ func (r *stubDatasetRepo) SetProperties(_ context.Context, id uuid.UUID, propert
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *stubDatasetRepo) SetGeoJSON(_ context.Context, id uuid.UUID, geojson []byte) error {
|
||||
d, ok := r.store[id]
|
||||
if !ok {
|
||||
return domain.ErrNotFound
|
||||
}
|
||||
d.GeoJSON = geojson
|
||||
r.store[id] = d
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *stubDatasetRepo) ReplaceObservations(_ context.Context, id uuid.UUID, obs []domain.Observation) error {
|
||||
r.observations[id] = obs
|
||||
return nil
|
||||
@@ -784,6 +794,10 @@ func TestDatasetService_Extract(t *testing.T) {
|
||||
KatoColumn: &kato,
|
||||
YearColumns: []domain.YearColumn{{Column: "F_2023", Date: "2023-01-01"}},
|
||||
}
|
||||
repo.districts["751010000"] = domain.District{
|
||||
Kato: "751010000", Name: "Almaty",
|
||||
Geometry: json.RawMessage(`{"type":"Polygon","coordinates":[[[76,43],[77,43],[77,44],[76,43]]]}`),
|
||||
}
|
||||
rows := []map[string]string{{"като": "751010000", "F_2023": "100"}}
|
||||
rp := RowParser(func(string, []byte) ([]map[string]string, error) { return rows, nil })
|
||||
geom := []byte(`{"type":"GeometryCollection","geometries":[]}`)
|
||||
@@ -803,10 +817,36 @@ func TestDatasetService_Extract(t *testing.T) {
|
||||
if len(got) != 1 || got[0].KatoCode != "751010000" || got[0].Value == nil || *got[0].Value != 100 {
|
||||
t.Fatalf("unexpected observations: %+v", got)
|
||||
}
|
||||
// Extraction also assembles and persists the GeoJSON (district-joined).
|
||||
var fc domain.FeatureCollection
|
||||
if err := json.Unmarshal(repo.store[id].GeoJSON, &fc); err != nil {
|
||||
t.Fatalf("geojson not persisted / invalid: %v", err)
|
||||
}
|
||||
if len(fc.Features) != 1 || fc.Features[0].Properties["name"] != "Almaty" {
|
||||
t.Fatalf("unexpected persisted geojson: %s", repo.store[id].GeoJSON)
|
||||
}
|
||||
}
|
||||
|
||||
// mustGeoJSON generates and persists the dataset's GeoJSON (as the worker does),
|
||||
// then reads it back through the .geojson accessor and decodes it into a
|
||||
// FeatureCollection so tests can assert on its content.
|
||||
func mustGeoJSON(t *testing.T, svc *DatasetService, id uuid.UUID) domain.FeatureCollection {
|
||||
t.Helper()
|
||||
if err := svc.generateGeoJSON(context.Background(), id); err != nil {
|
||||
t.Fatalf("generateGeoJSON: %v", err)
|
||||
}
|
||||
raw, err := svc.GeoJSON(context.Background(), id)
|
||||
if err != nil {
|
||||
t.Fatalf("GeoJSON: %v", err)
|
||||
}
|
||||
var fc domain.FeatureCollection
|
||||
if err := json.Unmarshal(raw, &fc); err != nil {
|
||||
t.Fatalf("decode geojson: %v", err)
|
||||
}
|
||||
return fc
|
||||
}
|
||||
|
||||
func TestDatasetService_GeoJSON_DistrictJoin(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
id := uuid.New()
|
||||
repo := newStubDatasetRepo()
|
||||
repo.store[id] = domain.Dataset{ID: id, FileType: domain.FileTypeVectorWithKato, Status: domain.DatasetStatusReady}
|
||||
@@ -823,10 +863,7 @@ func TestDatasetService_GeoJSON_DistrictJoin(t *testing.T) {
|
||||
}
|
||||
|
||||
svc := newDatasetService(repo, &stubStore{}, true)
|
||||
fc, err := svc.GeoJSON(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
fc := mustGeoJSON(t, svc, id)
|
||||
if fc.Type != domain.GeoJSONFeatureCollection {
|
||||
t.Fatalf("type = %q", fc.Type)
|
||||
}
|
||||
@@ -858,7 +895,6 @@ func TestDatasetService_GeoJSON_DistrictJoin(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestDatasetService_GeoJSON_IgnoresDatasetGeometry(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
id := uuid.New()
|
||||
repo := newStubDatasetRepo()
|
||||
// Dataset HAS its own geometry, which GeoJSON must ignore entirely for a
|
||||
@@ -876,10 +912,7 @@ func TestDatasetService_GeoJSON_IgnoresDatasetGeometry(t *testing.T) {
|
||||
repo.districts["710000000"] = domain.District{Kato: "710000000", Name: "Astana", Geometry: district}
|
||||
|
||||
svc := newDatasetService(repo, &stubStore{}, true)
|
||||
fc, err := svc.GeoJSON(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
fc := mustGeoJSON(t, svc, id)
|
||||
if len(fc.Features) != 1 {
|
||||
t.Fatalf("want 1 feature (unmatched KATO skipped), got %d", len(fc.Features))
|
||||
}
|
||||
@@ -902,6 +935,58 @@ func TestDatasetService_GeoJSON_IgnoresDatasetGeometry(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatasetService_GeoJSON_ReturnsStoredColumn(t *testing.T) {
|
||||
id := uuid.New()
|
||||
repo := newStubDatasetRepo()
|
||||
stored := json.RawMessage(`{"type":"FeatureCollection","features":[{"type":"Feature","geometry":null,"properties":{"kato":"710000000"}}]}`)
|
||||
repo.store[id] = domain.Dataset{
|
||||
ID: id, FileType: domain.FileTypeVectorWithKato, Status: domain.DatasetStatusReady,
|
||||
GeoJSON: stored,
|
||||
}
|
||||
// Observations/districts are intentionally left empty: GeoJSON must serve the
|
||||
// persisted column verbatim without re-assembling anything.
|
||||
svc := newDatasetService(repo, &stubStore{}, true)
|
||||
|
||||
raw, err := svc.GeoJSON(context.Background(), id)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if string(raw) != string(stored) {
|
||||
t.Fatalf("want stored column verbatim, got %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatasetService_GeoJSON_LazilyGeneratesAndCaches(t *testing.T) {
|
||||
// A dataset that became ready before the geojson column existed: the first
|
||||
// request assembles it, persists it, and serves it from the cache thereafter.
|
||||
id := uuid.New()
|
||||
repo := newStubDatasetRepo()
|
||||
repo.store[id] = domain.Dataset{ID: id, FileType: domain.FileTypeVectorWithKato, Status: domain.DatasetStatusReady}
|
||||
v := 100.0
|
||||
repo.observations[id] = []domain.Observation{{KatoCode: "710000000", Date: "2020-01-01", Value: &v}}
|
||||
repo.districts["710000000"] = domain.District{
|
||||
Kato: "710000000", Name: "Astana",
|
||||
Geometry: json.RawMessage(`{"type":"Polygon","coordinates":[[[71,51],[72,51],[72,52],[71,51]]]}`),
|
||||
}
|
||||
svc := newDatasetService(repo, &stubStore{}, true)
|
||||
|
||||
raw, err := svc.GeoJSON(context.Background(), id)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
var fc domain.FeatureCollection
|
||||
if err := json.Unmarshal(raw, &fc); err != nil {
|
||||
t.Fatalf("decode geojson: %v", err)
|
||||
}
|
||||
if len(fc.Features) != 1 || fc.Features[0].Properties["name"] != "Astana" {
|
||||
t.Fatalf("lazy-assembled geojson wrong: %s", raw)
|
||||
}
|
||||
// It must be cached on the dataset so subsequent requests skip re-assembly.
|
||||
if string(repo.store[id].GeoJSON) != string(raw) {
|
||||
t.Fatalf("geojson not cached after first request: %s", repo.store[id].GeoJSON)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatasetService_GeoJSON_ConflictWhenNotReady(t *testing.T) {
|
||||
id := uuid.New()
|
||||
repo := newStubDatasetRepo()
|
||||
@@ -934,10 +1019,7 @@ func TestDatasetService_GeoJSON_Vector_GeometryOnly(t *testing.T) {
|
||||
}
|
||||
svc := newDatasetService(repo, &stubStore{}, true)
|
||||
|
||||
fc, err := svc.GeoJSON(context.Background(), id)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
fc := mustGeoJSON(t, svc, id)
|
||||
if len(fc.Features) != 1 {
|
||||
t.Fatalf("want a single geometry-only feature, got %d", len(fc.Features))
|
||||
}
|
||||
@@ -948,9 +1030,6 @@ func TestDatasetService_GeoJSON_Vector_GeometryOnly(t *testing.T) {
|
||||
if len(f.Properties) != 0 {
|
||||
t.Fatalf("vector feature should have empty properties, got %+v", f.Properties)
|
||||
}
|
||||
if _, err := json.Marshal(fc); err != nil {
|
||||
t.Fatalf("feature collection not valid JSON: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatasetService_GeoJSON_Vector_TableDataAsProperties(t *testing.T) {
|
||||
@@ -964,10 +1043,7 @@ func TestDatasetService_GeoJSON_Vector_TableDataAsProperties(t *testing.T) {
|
||||
}
|
||||
svc := newDatasetService(repo, &stubStore{}, true)
|
||||
|
||||
fc, err := svc.GeoJSON(context.Background(), id)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
fc := mustGeoJSON(t, svc, id)
|
||||
if len(fc.Features) != 1 {
|
||||
t.Fatalf("want 1 feature, got %d", len(fc.Features))
|
||||
}
|
||||
@@ -988,11 +1064,10 @@ func TestDatasetService_GeoJSON_Vector_MultiRowTableData(t *testing.T) {
|
||||
}
|
||||
svc := newDatasetService(repo, &stubStore{}, true)
|
||||
|
||||
fc, err := svc.GeoJSON(context.Background(), id)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
rows, ok := fc.Features[0].Properties["rows"].([]map[string]any)
|
||||
fc := mustGeoJSON(t, svc, id)
|
||||
// After the JSON round trip through the stored column, the nested rows decode
|
||||
// into a generic []any of objects.
|
||||
rows, ok := fc.Features[0].Properties["rows"].([]any)
|
||||
if !ok || len(rows) != 2 {
|
||||
t.Fatalf("multi-row table data not kept under \"rows\": %+v", fc.Features[0].Properties)
|
||||
}
|
||||
@@ -1004,10 +1079,7 @@ func TestDatasetService_GeoJSON_Vector_NoGeometry(t *testing.T) {
|
||||
repo.store[id] = domain.Dataset{ID: id, FileType: domain.FileTypeVector, Status: domain.DatasetStatusReady}
|
||||
svc := newDatasetService(repo, &stubStore{}, true)
|
||||
|
||||
fc, err := svc.GeoJSON(context.Background(), id)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
fc := mustGeoJSON(t, svc, id)
|
||||
if fc.Type != domain.GeoJSONFeatureCollection {
|
||||
t.Fatalf("type = %q", fc.Type)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user