@nspot/geo-engine 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,665 @@
1
+ -- ============================================================================
2
+ -- 009 — localities: named points beside regions
3
+ --
4
+ -- A locality is a place with a name and a code but no boundary: Romania's
5
+ -- villages and the component localities of its towns. It has a label point
6
+ -- (where its name goes on a map) or, when its source has none, a reason in
7
+ -- point_gap. Its identity is (country, code), the target of host foreign keys,
8
+ -- so a host's rows can name a locality the same way in every database.
9
+ -- Localities have no membership: a host row declares its locality.
10
+ -- ============================================================================
11
+
12
+ -- Lower case ASCII for search: the Latin diacritics of the region folded, in
13
+ -- both cases (lower() leaves them alone under lc_ctype C), ß as ss, runs of
14
+ -- whitespace and hyphens as one space, trimmed.
15
+ CREATE OR REPLACE FUNCTION geo.fold(p text) RETURNS text
16
+ LANGUAGE sql IMMUTABLE PARALLEL SAFE STRICT AS $$
17
+ SELECT btrim(regexp_replace(lower(translate(replace(replace(p, 'ß', 'ss'), 'ẞ', 'ss'),
18
+ 'șşțţăâîáàäãåéèëêíìïóòöőôõúùüűûýÿçčćñňšśžźżłđďťř'
19
+ || 'ȘŞȚŢĂÂÎÁÀÄÃÅÉÈËÊÍÌÏÓÒÖŐÔÕÚÙÜŰÛÝŸÇČĆÑŇŠŚŽŹŻŁĐĎŤŘ',
20
+ 'ssttaaiaaaaaeeeeiiioooooouuuuuyycccnnsszzzlddtr'
21
+ || 'ssttaaiaaaaaeeeeiiioooooouuuuuyycccnnsszzzlddtr')), '[\s-]+', ' ', 'g'))
22
+ $$;
23
+
24
+ CREATE TABLE geo.localities (
25
+ id serial PRIMARY KEY,
26
+ country char(2) NOT NULL REFERENCES geo.countries(code) ON DELETE CASCADE,
27
+ code text NOT NULL,
28
+ name text NOT NULL,
29
+ name_key text GENERATED ALWAYS AS (geo.fold(name)) STORED,
30
+ parent_id int REFERENCES geo.regions(id) ON DELETE SET NULL,
31
+ declared_parent text,
32
+ declared_parent_country char(2),
33
+ point geometry(Point, 4326),
34
+ point_gap text,
35
+ tags text[] NOT NULL DEFAULT '{}',
36
+ properties jsonb NOT NULL DEFAULT '{}',
37
+ pack_id int REFERENCES geo.packs(id) ON DELETE SET NULL,
38
+ created_at timestamptz NOT NULL DEFAULT now(),
39
+ updated_at timestamptz NOT NULL DEFAULT now(),
40
+ UNIQUE (country, code),
41
+ CHECK (code <> '' AND name <> ''),
42
+ CHECK ((point IS NULL) = (point_gap IS NOT NULL))
43
+ );
44
+ CREATE INDEX localities_point_gix ON geo.localities USING GIST (point);
45
+ CREATE INDEX localities_point_geog_gix ON geo.localities USING GIST ((point::geography));
46
+ CREATE INDEX localities_parent_idx ON geo.localities (parent_id);
47
+ CREATE INDEX localities_pack_idx ON geo.localities (pack_id);
48
+ CREATE TRIGGER localities_updated_at_trg BEFORE UPDATE ON geo.localities
49
+ FOR EACH ROW EXECUTE FUNCTION geo.set_updated_at();
50
+
51
+ COMMENT ON TABLE geo.localities IS
52
+ 'Named points: a code, a name, a parent region, a label point or a gap reason. Hosts reference (country, code).';
53
+
54
+ -- A GeoJSON Point as a 4326 point, NULL for NULL; p_where names the locality in errors.
55
+ CREATE OR REPLACE FUNCTION geo.locality_point(p_geojson jsonb, p_where text) RETURNS geometry
56
+ LANGUAGE plpgsql IMMUTABLE AS $$
57
+ DECLARE
58
+ g geometry;
59
+ BEGIN
60
+ IF p_geojson IS NULL OR jsonb_typeof(p_geojson) = 'null' THEN
61
+ RETURN NULL;
62
+ END IF;
63
+ IF jsonb_typeof(p_geojson) <> 'object' OR p_geojson->>'type' IS DISTINCT FROM 'Point' THEN
64
+ RAISE EXCEPTION '%: a locality''s point must be a GeoJSON Point or null', p_where
65
+ USING ERRCODE = 'invalid_parameter_value';
66
+ END IF;
67
+ IF jsonb_typeof(p_geojson->'coordinates') IS DISTINCT FROM 'array'
68
+ OR jsonb_array_length(p_geojson->'coordinates') NOT IN (2, 3)
69
+ OR EXISTS (SELECT 1 FROM jsonb_array_elements(p_geojson->'coordinates') c
70
+ WHERE jsonb_typeof(c) <> 'number') THEN
71
+ RAISE EXCEPTION '%: a Point''s coordinates must be [lng, lat], got %', p_where, p_geojson->'coordinates'
72
+ USING ERRCODE = 'invalid_parameter_value';
73
+ END IF;
74
+ g := ST_SetSRID(ST_GeomFromGeoJSON(p_geojson), 4326);
75
+ IF ST_X(g) NOT BETWEEN -180 AND 180 OR ST_Y(g) NOT BETWEEN -90 AND 90 THEN
76
+ RAISE EXCEPTION '%: point [%, %] is outside -180..180, -90..90', p_where, ST_X(g), ST_Y(g)
77
+ USING ERRCODE = 'invalid_parameter_value';
78
+ END IF;
79
+ RETURN ST_Force2D(g);
80
+ END $$;
81
+
82
+ -- What every read returns. distance_m, rounded to 0.1 m, is added last when given.
83
+ CREATE OR REPLACE FUNCTION geo.locality_summary(l geo.localities, p_distance_m float8 DEFAULT NULL) RETURNS json
84
+ LANGUAGE sql STABLE AS $$
85
+ -- Both branches build the same object and must stay in step; the second only
86
+ -- appends distance_m last, which one json_build_object cannot leave out.
87
+ SELECT CASE WHEN p_distance_m IS NULL THEN
88
+ json_build_object(
89
+ 'country', l.country, 'code', l.code, 'name', l.name,
90
+ 'point', CASE WHEN l.point IS NULL THEN NULL ELSE json_build_array(ST_X(l.point), ST_Y(l.point)) END,
91
+ 'point_gap', l.point_gap,
92
+ 'parent', (SELECT json_build_object('slug', r.slug, 'name', r.name) FROM geo.regions r WHERE r.id = l.parent_id),
93
+ 'tags', to_json(l.tags), 'properties', l.properties,
94
+ 'pack', (SELECT p.slug FROM geo.packs p WHERE p.id = l.pack_id))
95
+ ELSE
96
+ json_build_object(
97
+ 'country', l.country, 'code', l.code, 'name', l.name,
98
+ 'point', CASE WHEN l.point IS NULL THEN NULL ELSE json_build_array(ST_X(l.point), ST_Y(l.point)) END,
99
+ 'point_gap', l.point_gap,
100
+ 'parent', (SELECT json_build_object('slug', r.slug, 'name', r.name) FROM geo.regions r WHERE r.id = l.parent_id),
101
+ 'tags', to_json(l.tags), 'properties', l.properties,
102
+ 'pack', (SELECT p.slug FROM geo.packs p WHERE p.id = l.pack_id),
103
+ 'distance_m', round(p_distance_m::numeric, 1))
104
+ END
105
+ $$;
106
+
107
+ -- Link every pack locality to the pack region its pack declared, in any pack,
108
+ -- never a local region (as geo.link_pack_parents does for regions). Returns how
109
+ -- many links changed.
110
+ CREATE OR REPLACE FUNCTION geo.link_pack_locality_parents() RETURNS int
111
+ LANGUAGE sql AS $$
112
+ WITH target AS (
113
+ SELECT l.id, (
114
+ SELECT r.id FROM geo.regions r JOIN geo.countries c ON c.id = r.country_id
115
+ WHERE r.pack_id IS NOT NULL AND r.slug = l.declared_parent AND c.code = l.declared_parent_country) AS parent
116
+ FROM geo.localities l
117
+ WHERE l.pack_id IS NOT NULL
118
+ ), changed AS (
119
+ UPDATE geo.localities l SET parent_id = t.parent
120
+ FROM target t
121
+ WHERE l.id = t.id AND l.parent_id IS DISTINCT FROM t.parent
122
+ RETURNING 1
123
+ )
124
+ SELECT count(*)::int FROM changed
125
+ $$;
126
+
127
+ -- "XA 1, XA 2, … and 5 more": the first 20 of a list of codes.
128
+ CREATE OR REPLACE FUNCTION geo.locality_list(p_codes text[]) RETURNS text
129
+ LANGUAGE sql IMMUTABLE AS $$
130
+ SELECT array_to_string(p_codes[1:20], ', ')
131
+ || CASE WHEN cardinality(p_codes) > 20 THEN format(' and %s more', cardinality(p_codes) - 20) ELSE '' END
132
+ $$;
133
+
134
+ -- Delete the given pack's localities that p_keep does not name (NULL keeps none).
135
+ -- A host foreign key that still references one aborts with 23503, naming the
136
+ -- pack (p_label) and the localities leaving, Postgres's own DETAIL kept. Set-based
137
+ -- (an anti-join and a join on ids), since a pack may hold 13,749 of them.
138
+ CREATE OR REPLACE FUNCTION geo.delete_pack_localities(p_pack_id int, p_keep jsonb, p_label text) RETURNS int
139
+ LANGUAGE plpgsql AS $$
140
+ DECLARE
141
+ ids int[];
142
+ leaving text[];
143
+ n int;
144
+ d text;
145
+ BEGIN
146
+ SELECT array_agg(l.id), array_agg(l.country || ' ' || l.code ORDER BY l.country, l.code) INTO ids, leaving
147
+ FROM geo.localities l
148
+ LEFT JOIN (
149
+ SELECT upper(f->'properties'->>'country') AS country, f->'properties'->>'code' AS code
150
+ FROM jsonb_array_elements(coalesce(p_keep, '[]'::jsonb)) f
151
+ ) k ON k.country = l.country::text AND k.code = l.code
152
+ WHERE l.pack_id = p_pack_id AND k.code IS NULL;
153
+ IF ids IS NULL THEN
154
+ RETURN 0;
155
+ END IF;
156
+ BEGIN
157
+ DELETE FROM geo.localities l USING unnest(ids) AS gone(id) WHERE l.id = gone.id;
158
+ GET DIAGNOSTICS n = ROW_COUNT;
159
+ EXCEPTION WHEN foreign_key_violation THEN
160
+ GET STACKED DIAGNOSTICS d = PG_EXCEPTION_DETAIL;
161
+ RAISE EXCEPTION '% would remove localities a host still references; leaving: %', p_label, geo.locality_list(leaving)
162
+ USING ERRCODE = 'foreign_key_violation', DETAIL = d;
163
+ END;
164
+ RETURN n;
165
+ END $$;
166
+
167
+ -- The localities section of a pack, after its regions: check every feature,
168
+ -- refuse codes another owner holds, upsert, delete the ones that left, record
169
+ -- declared parents. Returns how many the pack has.
170
+ CREATE OR REPLACE FUNCTION geo.install_pack_localities(p_pack_id int, p_feats jsonb, p_label text) RETURNS int
171
+ LANGUAGE plpgsql AS $$
172
+ DECLARE
173
+ v jsonb;
174
+ i int := 0;
175
+ where_ text;
176
+ held text[];
177
+ written int;
178
+ BEGIN
179
+ FOR v IN SELECT * FROM jsonb_array_elements(p_feats) LOOP
180
+ where_ := format('locality %s (%s %s)', i, upper(v->'properties'->>'country'), v->'properties'->>'code');
181
+ IF coalesce(v->'properties'->>'country', '') = '' OR coalesce(v->'properties'->>'code', '') = ''
182
+ OR coalesce(v->'properties'->>'name', '') = '' THEN
183
+ RAISE EXCEPTION 'locality %: country, code and name are required', i USING ERRCODE = 'invalid_parameter_value';
184
+ END IF;
185
+ IF NOT EXISTS (SELECT 1 FROM geo.countries c WHERE c.code = upper(v->'properties'->>'country')) THEN
186
+ RAISE EXCEPTION '%: unknown country %', where_, upper(v->'properties'->>'country')
187
+ USING ERRCODE = 'invalid_parameter_value';
188
+ END IF;
189
+ IF (coalesce(jsonb_typeof(v->'geometry'), 'null') = 'object') = (coalesce(v->'properties'->>'point_gap', '') <> '') THEN
190
+ RAISE EXCEPTION '%: give a point or a point_gap, not both and not neither', where_
191
+ USING ERRCODE = 'invalid_parameter_value';
192
+ END IF;
193
+ PERFORM geo.locality_point(v->'geometry', where_);
194
+ i := i + 1;
195
+ END LOOP;
196
+
197
+ -- a code twice in one country: the second occurrence, set-based
198
+ SELECT format('locality %s (%s %s): code given twice', g.n - 1, g.country, g.code) INTO where_
199
+ FROM (
200
+ SELECT t.n, upper(t.feat->'properties'->>'country') AS country, t.feat->'properties'->>'code' AS code,
201
+ row_number() OVER (PARTITION BY upper(t.feat->'properties'->>'country'), t.feat->'properties'->>'code'
202
+ ORDER BY t.n) AS k
203
+ FROM jsonb_array_elements(p_feats) WITH ORDINALITY AS t(feat, n)
204
+ ) g
205
+ WHERE g.k = 2
206
+ ORDER BY g.n
207
+ LIMIT 1;
208
+ IF FOUND THEN
209
+ RAISE EXCEPTION '%', where_ USING ERRCODE = 'invalid_parameter_value';
210
+ END IF;
211
+
212
+ SELECT array_agg(l.country || ' ' || l.code ORDER BY l.country, l.code) INTO held
213
+ FROM jsonb_array_elements(p_feats) f
214
+ JOIN geo.localities l ON l.country = upper(f->'properties'->>'country') AND l.code = f->'properties'->>'code'
215
+ WHERE l.pack_id IS DISTINCT FROM p_pack_id;
216
+ IF held IS NOT NULL THEN
217
+ RAISE EXCEPTION '% has localities another owner holds: %', p_label, geo.locality_list(held)
218
+ USING ERRCODE = 'unique_violation';
219
+ END IF;
220
+
221
+ INSERT INTO geo.localities AS l
222
+ (country, code, name, declared_parent, declared_parent_country, point, point_gap, tags, properties, pack_id)
223
+ SELECT upper(f->'properties'->>'country'), f->'properties'->>'code', f->'properties'->>'name',
224
+ nullif(f->'properties'->>'parent', ''),
225
+ CASE WHEN nullif(f->'properties'->>'parent', '') IS NULL THEN NULL
226
+ ELSE upper(coalesce(f->'properties'->>'parent_country', f->'properties'->>'country')) END,
227
+ geo.locality_point(f->'geometry', 'locality'),
228
+ nullif(f->'properties'->>'point_gap', ''),
229
+ ARRAY(SELECT jsonb_array_elements_text(coalesce(f->'properties'->'tags', '[]'::jsonb))),
230
+ coalesce(f->'properties'->'properties', '{}'::jsonb),
231
+ p_pack_id
232
+ FROM jsonb_array_elements(p_feats) f
233
+ ON CONFLICT (country, code) DO UPDATE SET
234
+ name = EXCLUDED.name, declared_parent = EXCLUDED.declared_parent,
235
+ declared_parent_country = EXCLUDED.declared_parent_country, point = EXCLUDED.point,
236
+ point_gap = EXCLUDED.point_gap, tags = EXCLUDED.tags, properties = EXCLUDED.properties
237
+ WHERE l.pack_id = p_pack_id;
238
+ -- a code another owner took since the check above is skipped by the WHERE: refuse it
239
+ GET DIAGNOSTICS written = ROW_COUNT;
240
+ IF written < i THEN
241
+ RAISE EXCEPTION '% has localities another owner holds', p_label USING ERRCODE = 'unique_violation';
242
+ END IF;
243
+
244
+ PERFORM geo.delete_pack_localities(p_pack_id, p_feats, p_label);
245
+ RETURN i;
246
+ END $$;
247
+
248
+ CREATE OR REPLACE FUNCTION geo.install_pack(p_pack jsonb) RETURNS jsonb
249
+ LANGUAGE plpgsql AS $$
250
+ DECLARE
251
+ hdr jsonb := p_pack->'pack';
252
+ feats jsonb := coalesce(p_pack->'regions'->'features', '[]'::jsonb);
253
+ pid int;
254
+ f jsonb;
255
+ n_countries int := 0;
256
+ n_regions int := 0;
257
+ n_localities int;
258
+ n_members bigint;
259
+ code text;
260
+ BEGIN
261
+ IF hdr IS NULL OR coalesce(hdr->>'slug', '') = '' OR coalesce(hdr->>'version', '') = '' THEN
262
+ RAISE EXCEPTION 'Pack header must have slug and version' USING ERRCODE = 'invalid_parameter_value';
263
+ END IF;
264
+ IF coalesce((hdr->>'schema_version')::int, 0) > geo.schema_version() THEN
265
+ RAISE EXCEPTION 'Pack "%" needs schema version % but this database has %',
266
+ hdr->>'slug', hdr->>'schema_version', geo.schema_version()
267
+ USING ERRCODE = 'invalid_parameter_value';
268
+ END IF;
269
+ -- no key leaves the pack's localities alone; a key must be a usable section,
270
+ -- since an empty one removes them all
271
+ IF p_pack ? 'localities' AND (jsonb_typeof(p_pack->'localities') IS DISTINCT FROM 'object'
272
+ OR jsonb_typeof(p_pack->'localities'->'features') IS DISTINCT FROM 'array') THEN
273
+ RAISE EXCEPTION 'localities must be a FeatureCollection with a features array'
274
+ USING ERRCODE = 'invalid_parameter_value';
275
+ END IF;
276
+
277
+ INSERT INTO geo.packs (slug, version, schema_version)
278
+ VALUES (hdr->>'slug', hdr->>'version', coalesce((hdr->>'schema_version')::int, geo.schema_version()))
279
+ ON CONFLICT (slug) DO UPDATE
280
+ SET version = EXCLUDED.version, schema_version = EXCLUDED.schema_version, installed_at = now()
281
+ RETURNING id INTO pid;
282
+
283
+ PERFORM set_config('geo.skip_membership_triggers', 'on', true);
284
+
285
+ FOR f IN SELECT * FROM jsonb_array_elements(coalesce(p_pack->'countries', '[]'::jsonb)) LOOP
286
+ PERFORM geo.upsert_country(f->>'code', f->>'name',
287
+ CASE WHEN jsonb_typeof(f->'geometry') = 'object' THEN f->'geometry' END);
288
+ n_countries := n_countries + 1;
289
+ END LOOP;
290
+
291
+ FOR f IN SELECT * FROM jsonb_array_elements(feats) LOOP
292
+ PERFORM geo.upsert_region(
293
+ f->'properties'->>'country',
294
+ f->'properties'->>'slug',
295
+ f->'properties'->>'name',
296
+ (f->'properties'->>'type')::geo.region_type,
297
+ (f->'properties'->>'level')::int,
298
+ NULL,
299
+ f->'properties'->>'code',
300
+ ARRAY(SELECT jsonb_array_elements_text(coalesce(f->'properties'->'tags', '[]'::jsonb))),
301
+ coalesce(f->'properties'->'properties', '{}'::jsonb),
302
+ f->'geometry',
303
+ true,
304
+ pid);
305
+ n_regions := n_regions + 1;
306
+ END LOOP;
307
+
308
+ -- regions that left the pack
309
+ DELETE FROM geo.regions r
310
+ USING geo.countries c
311
+ WHERE r.pack_id = pid AND c.id = r.country_id
312
+ AND NOT EXISTS (
313
+ SELECT 1 FROM jsonb_array_elements(feats) ff
314
+ WHERE upper(ff->'properties'->>'country') = c.code AND ff->'properties'->>'slug' = r.slug);
315
+
316
+ -- the parent each region declares (a slug, in its parent_country or its own)
317
+ UPDATE geo.regions r
318
+ SET declared_parent = nullif(ff->'properties'->>'parent', ''),
319
+ declared_parent_country = CASE WHEN nullif(ff->'properties'->>'parent', '') IS NULL THEN NULL
320
+ ELSE upper(coalesce(ff->'properties'->>'parent_country', ff->'properties'->>'country')) END
321
+ FROM jsonb_array_elements(feats) ff
322
+ JOIN geo.countries c ON c.code = upper(ff->'properties'->>'country')
323
+ WHERE r.pack_id = pid AND r.country_id = c.id AND r.slug = ff->'properties'->>'slug';
324
+
325
+ -- parents among pack regions, this pack's and every other's
326
+ PERFORM geo.link_pack_parents();
327
+
328
+ IF p_pack ? 'localities' THEN
329
+ n_localities := geo.install_pack_localities(pid, p_pack->'localities'->'features',
330
+ format('Pack "%s" %s', hdr->>'slug', hdr->>'version'));
331
+ END IF;
332
+ PERFORM geo.link_pack_locality_parents();
333
+
334
+ FOR code IN SELECT c.code FROM geo.countries c WHERE c.geom IS NULL LOOP
335
+ PERFORM geo.derive_country_geom(code);
336
+ END LOOP;
337
+
338
+ n_members := geo.rebuild_all_memberships();
339
+ PERFORM set_config('geo.skip_membership_triggers', 'off', true);
340
+ RETURN jsonb_build_object(
341
+ 'pack', hdr->>'slug', 'version', hdr->>'version',
342
+ 'countries', n_countries, 'regions', n_regions, 'memberships', n_members)
343
+ || CASE WHEN n_localities IS NULL THEN '{}'::jsonb ELSE jsonb_build_object('localities', n_localities) END;
344
+ END $$;
345
+
346
+
347
+ CREATE OR REPLACE FUNCTION geo.uninstall_pack(p_slug text) RETURNS int
348
+ LANGUAGE plpgsql AS $$
349
+ DECLARE
350
+ pid int;
351
+ n int;
352
+ BEGIN
353
+ SELECT id INTO pid FROM geo.packs WHERE slug = p_slug;
354
+ IF pid IS NULL THEN
355
+ RAISE EXCEPTION 'Unknown pack "%"', p_slug USING ERRCODE = 'invalid_parameter_value';
356
+ END IF;
357
+ PERFORM geo.delete_pack_localities(pid, NULL,
358
+ format('Pack "%s" %s', p_slug, (SELECT version FROM geo.packs WHERE id = pid)));
359
+ DELETE FROM geo.regions WHERE pack_id = pid; -- memberships cascade
360
+ GET DIAGNOSTICS n = ROW_COUNT;
361
+ DELETE FROM geo.packs WHERE id = pid;
362
+ RETURN n;
363
+ END $$;
364
+
365
+
366
+ CREATE OR REPLACE FUNCTION geo.localities_by_code(p_codes text[], p_country text DEFAULT NULL) RETURNS SETOF json
367
+ LANGUAGE sql STABLE AS $$
368
+ -- a code given twice answers once, at its first position
369
+ SELECT geo.locality_summary(l)
370
+ FROM (SELECT u.code, min(u.ord) AS ord FROM unnest(p_codes) WITH ORDINALITY AS u(code, ord) GROUP BY u.code) c
371
+ JOIN geo.localities l ON l.code = c.code AND (p_country IS NULL OR l.country = upper(p_country))
372
+ ORDER BY c.ord, l.country
373
+ $$;
374
+
375
+ -- The region ids and every region below them.
376
+ CREATE OR REPLACE FUNCTION geo.region_tree(p_ids int[]) RETURNS SETOF int
377
+ LANGUAGE sql STABLE AS $$
378
+ WITH RECURSIVE tree AS (
379
+ SELECT unnest(p_ids) AS id
380
+ UNION
381
+ SELECT r.id FROM geo.regions r JOIN tree t ON r.parent_id = t.id
382
+ )
383
+ SELECT id FROM tree
384
+ $$;
385
+
386
+ CREATE OR REPLACE FUNCTION geo.localities_in(p_regions text[], p_via text DEFAULT 'parent') RETURNS SETOF json
387
+ LANGUAGE plpgsql STABLE AS $$
388
+ DECLARE
389
+ ids int[];
390
+ BEGIN
391
+ IF p_via IS NULL OR p_via NOT IN ('parent', 'point') THEN
392
+ RAISE EXCEPTION 'via must be parent or point, got %', p_via USING ERRCODE = 'invalid_parameter_value';
393
+ END IF;
394
+ IF p_regions IS NULL OR cardinality(p_regions) = 0 THEN
395
+ RAISE EXCEPTION 'name at least one region' USING ERRCODE = 'invalid_parameter_value';
396
+ END IF;
397
+ ids := geo.resolve_regions(p_regions);
398
+ IF p_via = 'parent' THEN
399
+ RETURN QUERY
400
+ SELECT geo.locality_summary(l) FROM geo.localities l
401
+ WHERE l.parent_id IN (SELECT geo.region_tree(ids))
402
+ ORDER BY l.name_key, l.name, l.country, l.code;
403
+ ELSE
404
+ RETURN QUERY
405
+ SELECT geo.locality_summary(l) FROM geo.localities l
406
+ WHERE l.point IS NOT NULL
407
+ AND EXISTS (SELECT 1 FROM geo.regions r WHERE r.id = ANY (ids) AND ST_Intersects(r.geom, l.point))
408
+ ORDER BY l.name_key, l.name, l.country, l.code;
409
+ END IF;
410
+ END $$;
411
+
412
+ CREATE OR REPLACE FUNCTION geo.check_limit(p_limit int) RETURNS void
413
+ LANGUAGE plpgsql IMMUTABLE AS $$
414
+ BEGIN
415
+ IF p_limit IS NULL OR p_limit < 1 OR p_limit > 1000 THEN
416
+ RAISE EXCEPTION 'limit must be 1 to 1000, got %', coalesce(p_limit::text, 'null')
417
+ USING ERRCODE = 'invalid_parameter_value';
418
+ END IF;
419
+ END $$;
420
+
421
+ CREATE OR REPLACE FUNCTION geo.search_localities(p_query text, p_within text[] DEFAULT NULL, p_limit int DEFAULT 20)
422
+ RETURNS SETOF json
423
+ LANGUAGE plpgsql STABLE AS $$
424
+ DECLARE
425
+ q text := coalesce(geo.fold(p_query), '');
426
+ within int[];
427
+ BEGIN
428
+ IF q = '' THEN
429
+ RAISE EXCEPTION 'query must not be empty' USING ERRCODE = 'invalid_parameter_value';
430
+ END IF;
431
+ IF p_within IS NOT NULL AND cardinality(p_within) = 0 THEN
432
+ RAISE EXCEPTION 'within must name at least one region, or be null' USING ERRCODE = 'invalid_parameter_value';
433
+ END IF;
434
+ PERFORM geo.check_limit(p_limit);
435
+ IF p_within IS NOT NULL THEN
436
+ within := ARRAY(SELECT geo.region_tree(geo.resolve_regions(p_within)));
437
+ END IF;
438
+ RETURN QUERY
439
+ SELECT geo.locality_summary(l) FROM geo.localities l
440
+ WHERE strpos(l.name_key, q) > 0
441
+ AND (within IS NULL OR l.parent_id = ANY (within))
442
+ ORDER BY CASE WHEN l.name_key = q THEN 0 WHEN starts_with(l.name_key, q) THEN 1 ELSE 2 END,
443
+ l.name_key, l.name, l.country, l.code
444
+ LIMIT p_limit;
445
+ END $$;
446
+
447
+ CREATE OR REPLACE FUNCTION geo.localities_near(p_lng float8, p_lat float8, p_limit int DEFAULT 10, p_max_m float8 DEFAULT NULL)
448
+ RETURNS SETOF json
449
+ LANGUAGE plpgsql STABLE AS $$
450
+ DECLARE
451
+ here geography;
452
+ BEGIN
453
+ IF p_lng IS NULL OR p_lat IS NULL OR p_lng NOT BETWEEN -180 AND 180 OR p_lat NOT BETWEEN -90 AND 90 THEN
454
+ RAISE EXCEPTION 'lng must be -180..180 and lat -90..90, got %, %', p_lng, p_lat
455
+ USING ERRCODE = 'invalid_parameter_value';
456
+ END IF;
457
+ PERFORM geo.check_limit(p_limit);
458
+ IF p_max_m IS NOT NULL AND p_max_m < 0 THEN
459
+ RAISE EXCEPTION 'max_m must be 0 or more, got %', p_max_m USING ERRCODE = 'invalid_parameter_value';
460
+ END IF;
461
+ here := ST_SetSRID(ST_MakePoint(p_lng, p_lat), 4326)::geography;
462
+ RETURN QUERY
463
+ SELECT geo.locality_summary(n.l, n.m)
464
+ FROM (
465
+ SELECT l, ST_Distance(l.point::geography, here) AS m
466
+ FROM geo.localities l
467
+ WHERE l.point IS NOT NULL
468
+ AND (p_max_m IS NULL OR ST_DWithin(l.point::geography, here, p_max_m))
469
+ ORDER BY l.point::geography <-> here
470
+ LIMIT p_limit
471
+ ) n
472
+ ORDER BY n.m, (n.l).country, (n.l).code;
473
+ END $$;
474
+
475
+ CREATE OR REPLACE FUNCTION geo.upsert_locality(
476
+ p_country text,
477
+ p_code text,
478
+ p_name text,
479
+ p_parent text DEFAULT NULL,
480
+ p_parent_country text DEFAULT NULL,
481
+ p_point jsonb DEFAULT NULL,
482
+ p_point_gap text DEFAULT NULL,
483
+ p_tags text[] DEFAULT '{}',
484
+ p_properties jsonb DEFAULT '{}'
485
+ ) RETURNS json
486
+ LANGUAGE plpgsql AS $$
487
+ DECLARE
488
+ cc text := upper(p_country);
489
+ owner_ text;
490
+ parent int;
491
+ saved geo.localities;
492
+ BEGIN
493
+ IF coalesce(p_code, '') = '' OR coalesce(p_name, '') = '' THEN
494
+ RAISE EXCEPTION 'A locality needs a code and a name' USING ERRCODE = 'invalid_parameter_value';
495
+ END IF;
496
+ SELECT p.slug INTO owner_ FROM geo.localities l JOIN geo.packs p ON p.id = l.pack_id
497
+ WHERE l.country = cc AND l.code = p_code;
498
+ IF owner_ IS NOT NULL THEN
499
+ RAISE EXCEPTION 'Locality % % belongs to pack "%"; update or uninstall the pack instead', cc, p_code, owner_
500
+ USING ERRCODE = 'unique_violation';
501
+ END IF;
502
+ IF NOT EXISTS (SELECT 1 FROM geo.countries WHERE code = cc) THEN
503
+ RAISE EXCEPTION 'Unknown country "%"', cc USING ERRCODE = 'invalid_parameter_value';
504
+ END IF;
505
+ IF (p_point IS NOT NULL AND jsonb_typeof(p_point) <> 'null') = (coalesce(p_point_gap, '') <> '') THEN
506
+ RAISE EXCEPTION 'locality % %: give a point or a point_gap, not both and not neither', cc, p_code
507
+ USING ERRCODE = 'invalid_parameter_value';
508
+ END IF;
509
+ p_parent := nullif(p_parent, ''); -- '' is no parent
510
+ IF p_parent IS NOT NULL THEN
511
+ SELECT r.id INTO parent FROM geo.regions r JOIN geo.countries c ON c.id = r.country_id
512
+ WHERE r.slug = p_parent AND c.code = upper(coalesce(p_parent_country, cc));
513
+ IF parent IS NULL THEN
514
+ RAISE EXCEPTION 'Unknown parent region "%" in %', p_parent, upper(coalesce(p_parent_country, cc))
515
+ USING ERRCODE = 'invalid_parameter_value';
516
+ END IF;
517
+ END IF;
518
+ INSERT INTO geo.localities AS l (country, code, name, parent_id, point, point_gap, tags, properties)
519
+ VALUES (cc, p_code, p_name, parent, geo.locality_point(p_point, format('locality %s %s', cc, p_code)),
520
+ nullif(p_point_gap, ''), coalesce(p_tags, '{}'), coalesce(p_properties, '{}'))
521
+ ON CONFLICT (country, code) DO UPDATE SET
522
+ name = EXCLUDED.name, parent_id = EXCLUDED.parent_id, point = EXCLUDED.point,
523
+ point_gap = EXCLUDED.point_gap, tags = EXCLUDED.tags, properties = EXCLUDED.properties
524
+ WHERE l.pack_id IS NULL
525
+ RETURNING * INTO saved;
526
+ -- a pack installed it since the check above: the WHERE skipped the update
527
+ IF NOT FOUND THEN
528
+ RAISE EXCEPTION 'Locality % % belongs to a pack; update or uninstall the pack instead', cc, p_code
529
+ USING ERRCODE = 'unique_violation';
530
+ END IF;
531
+ RETURN geo.locality_summary(saved);
532
+ END $$;
533
+
534
+ CREATE OR REPLACE FUNCTION geo.delete_locality(p_country text, p_code text) RETURNS boolean
535
+ LANGUAGE plpgsql AS $$
536
+ DECLARE
537
+ owner_ text;
538
+ n int;
539
+ d text;
540
+ BEGIN
541
+ SELECT p.slug INTO owner_ FROM geo.localities l JOIN geo.packs p ON p.id = l.pack_id
542
+ WHERE l.country = upper(p_country) AND l.code = p_code;
543
+ IF owner_ IS NOT NULL THEN
544
+ RAISE EXCEPTION 'Locality % % belongs to pack "%"; update or uninstall the pack instead', upper(p_country), p_code, owner_
545
+ USING ERRCODE = 'unique_violation';
546
+ END IF;
547
+ BEGIN
548
+ DELETE FROM geo.localities WHERE country = upper(p_country) AND code = p_code AND pack_id IS NULL;
549
+ GET DIAGNOSTICS n = ROW_COUNT;
550
+ EXCEPTION WHEN foreign_key_violation THEN
551
+ GET STACKED DIAGNOSTICS d = PG_EXCEPTION_DETAIL;
552
+ RAISE EXCEPTION 'Locality % % is still referenced: a host still references it', upper(p_country), p_code
553
+ USING ERRCODE = 'foreign_key_violation', DETAIL = d;
554
+ END;
555
+ RETURN n > 0;
556
+ END $$;
557
+
558
+ -- export_pack as in 007, plus p_include: 'all' (the default), 'regions' or
559
+ -- 'localities'. Under 'all' a localities section is written only when there are
560
+ -- localities to write, so a pack without them exports exactly as before; under
561
+ -- 'localities' it is always written, so installing an empty one empties the pack.
562
+ -- A slug prefix also selects the localities whose parent region it selects.
563
+ DROP FUNCTION geo.export_pack(text, text, text, text);
564
+ CREATE OR REPLACE FUNCTION geo.export_pack(
565
+ p_slug text,
566
+ p_version text,
567
+ p_owner text DEFAULT NULL,
568
+ p_slug_prefix text DEFAULT NULL,
569
+ p_include text DEFAULT 'all'
570
+ ) RETURNS jsonb
571
+ LANGUAGE plpgsql STABLE AS $$
572
+ DECLARE
573
+ owner_id int;
574
+ scoped boolean := p_owner IS NOT NULL OR p_slug_prefix IS NOT NULL OR p_include <> 'all';
575
+ BEGIN
576
+ IF p_include IS NULL OR p_include NOT IN ('all', 'regions', 'localities') THEN
577
+ RAISE EXCEPTION 'include must be all, regions or localities, got %', p_include
578
+ USING ERRCODE = 'invalid_parameter_value';
579
+ END IF;
580
+ IF p_owner IS NOT NULL AND p_owner <> 'local' THEN
581
+ SELECT id INTO owner_id FROM geo.packs WHERE slug = p_owner;
582
+ IF owner_id IS NULL THEN
583
+ RAISE EXCEPTION 'Unknown pack "%"', p_owner USING ERRCODE = 'invalid_parameter_value';
584
+ END IF;
585
+ END IF;
586
+
587
+ RETURN (
588
+ WITH sel AS (
589
+ SELECT r.*
590
+ FROM geo.regions r
591
+ WHERE (p_owner IS NULL
592
+ OR (p_owner = 'local' AND r.pack_id IS NULL)
593
+ OR r.pack_id = owner_id)
594
+ AND (p_slug_prefix IS NULL OR starts_with(r.slug, p_slug_prefix))
595
+ AND p_include <> 'localities'
596
+ ),
597
+ feats AS (
598
+ SELECT c.code AS country, r.slug, jsonb_build_object(
599
+ 'type', 'Feature',
600
+ 'geometry', ST_AsGeoJSON(r.geom, 6)::jsonb,
601
+ 'properties', jsonb_build_object(
602
+ 'country', c.code, 'slug', r.slug, 'name', r.name, 'type', r.type, 'level', r.level,
603
+ 'parent', coalesce(p.slug, r.declared_parent),
604
+ 'parent_country', CASE WHEN p.id IS NOT NULL THEN pc.code ELSE r.declared_parent_country END,
605
+ 'code', r.code, 'tags', to_jsonb(r.tags), 'properties', r.properties)) AS feature,
606
+ coalesce(pc.code, r.declared_parent_country) AS parent_country
607
+ FROM sel r
608
+ JOIN geo.countries c ON c.id = r.country_id
609
+ LEFT JOIN geo.regions p ON p.id = r.parent_id
610
+ LEFT JOIN geo.countries pc ON pc.id = p.country_id
611
+ ),
612
+ -- with a slug prefix, only the localities whose parent region the prefix selects
613
+ lsel AS (
614
+ SELECT l.* FROM geo.localities l
615
+ WHERE p_include <> 'regions'
616
+ AND (p_owner IS NULL OR (p_owner = 'local' AND l.pack_id IS NULL) OR l.pack_id = owner_id)
617
+ AND (p_slug_prefix IS NULL OR l.parent_id IN (
618
+ SELECT r.id FROM geo.regions r WHERE starts_with(r.slug, p_slug_prefix)))
619
+ ),
620
+ lfeats AS (
621
+ SELECT l.country, l.code, jsonb_build_object(
622
+ 'type', 'Feature',
623
+ 'geometry', CASE WHEN l.point IS NULL THEN NULL ELSE ST_AsGeoJSON(l.point, 6)::jsonb END,
624
+ 'properties', jsonb_build_object(
625
+ 'country', l.country, 'code', l.code, 'name', l.name,
626
+ 'parent', coalesce(p.slug, l.declared_parent),
627
+ 'parent_country', CASE WHEN p.id IS NOT NULL THEN pc.code ELSE l.declared_parent_country END,
628
+ 'point_gap', l.point_gap, 'tags', to_jsonb(l.tags), 'properties', l.properties)) AS feature,
629
+ coalesce(pc.code, l.declared_parent_country) AS parent_country
630
+ FROM lsel l
631
+ LEFT JOIN geo.regions p ON p.id = l.parent_id
632
+ LEFT JOIN geo.countries pc ON pc.id = p.country_id
633
+ ),
634
+ needed AS (
635
+ SELECT country AS code FROM feats
636
+ UNION
637
+ SELECT parent_country FROM feats WHERE parent_country IS NOT NULL
638
+ UNION
639
+ SELECT country FROM lfeats
640
+ UNION
641
+ SELECT parent_country FROM lfeats WHERE parent_country IS NOT NULL
642
+ )
643
+ SELECT jsonb_build_object(
644
+ 'pack', jsonb_build_object(
645
+ 'slug', p_slug, 'version', p_version, 'schema_version', geo.schema_version(),
646
+ 'published_at', to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"')),
647
+ 'countries', coalesce((
648
+ SELECT jsonb_agg(jsonb_build_object(
649
+ 'code', c.code, 'name', c.name,
650
+ 'geometry', CASE WHEN scoped OR c.geom IS NULL THEN NULL ELSE ST_AsGeoJSON(c.geom, 6)::jsonb END
651
+ ) ORDER BY c.code)
652
+ FROM geo.countries c
653
+ WHERE NOT scoped OR c.code IN (SELECT code FROM needed)), '[]'::jsonb),
654
+ 'regions', jsonb_build_object(
655
+ 'type', 'FeatureCollection',
656
+ 'features', coalesce((SELECT jsonb_agg(feature ORDER BY country, slug) FROM feats), '[]'::jsonb)))
657
+ || CASE WHEN p_include = 'localities' OR EXISTS (SELECT 1 FROM lfeats) THEN
658
+ jsonb_build_object('localities', jsonb_build_object(
659
+ 'type', 'FeatureCollection',
660
+ 'features', coalesce((SELECT jsonb_agg(feature ORDER BY country, code) FROM lfeats), '[]'::jsonb)))
661
+ ELSE '{}'::jsonb END
662
+ );
663
+ END $$;
664
+
665
+ SELECT geo.pin_search_path();