@nspot/geo-engine 0.1.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,188 @@
1
+ -- ============================================================================
2
+ -- geo-engine — core schema
3
+ --
4
+ -- Design:
5
+ -- * Geometry is the single source of truth. A location "belongs" to a region
6
+ -- iff the region polygon covers the location point. Nothing is stored about
7
+ -- that relationship at write time by the application.
8
+ -- * Regions of any type (admin / cultural / natural / custom) may overlap
9
+ -- freely; a point can be in many regions at once.
10
+ -- ============================================================================
11
+
12
+ CREATE EXTENSION IF NOT EXISTS postgis;
13
+
14
+ DO $$ BEGIN
15
+ CREATE TYPE region_type AS ENUM ('admin', 'cultural', 'natural', 'custom');
16
+ EXCEPTION WHEN duplicate_object THEN NULL; END $$;
17
+
18
+ -- ----------------------------------------------------------------------------
19
+ -- countries
20
+ -- ----------------------------------------------------------------------------
21
+ CREATE TABLE IF NOT EXISTS countries (
22
+ id serial PRIMARY KEY,
23
+ code char(2) NOT NULL UNIQUE, -- ISO 3166-1 alpha-2, e.g. 'RO'
24
+ name text NOT NULL,
25
+ geom geometry(MultiPolygon, 4326), -- optional; derived from admin regions if missing
26
+ created_at timestamptz NOT NULL DEFAULT now(),
27
+ updated_at timestamptz NOT NULL DEFAULT now()
28
+ );
29
+ CREATE INDEX IF NOT EXISTS countries_geom_gix ON countries USING GIST (geom);
30
+
31
+ -- ----------------------------------------------------------------------------
32
+ -- regions
33
+ -- ----------------------------------------------------------------------------
34
+ CREATE TABLE IF NOT EXISTS regions (
35
+ id serial PRIMARY KEY,
36
+ country_id int NOT NULL REFERENCES countries(id) ON DELETE CASCADE,
37
+ parent_id int REFERENCES regions(id) ON DELETE SET NULL,
38
+ type region_type NOT NULL DEFAULT 'custom',
39
+ level smallint, -- admin level (1 = județ); NULL for non-admin
40
+ slug text NOT NULL, -- unique per country, URL-safe
41
+ name text NOT NULL,
42
+ code text, -- e.g. ISO 3166-2 'RO-CJ'
43
+ tags text[] NOT NULL DEFAULT '{}',
44
+ properties jsonb NOT NULL DEFAULT '{}',
45
+ geom geometry(MultiPolygon, 4326) NOT NULL,
46
+ created_at timestamptz NOT NULL DEFAULT now(),
47
+ updated_at timestamptz NOT NULL DEFAULT now(),
48
+ UNIQUE (country_id, slug)
49
+ );
50
+ CREATE INDEX IF NOT EXISTS regions_geom_gix ON regions USING GIST (geom);
51
+ CREATE INDEX IF NOT EXISTS regions_country_type ON regions (country_id, type);
52
+ CREATE INDEX IF NOT EXISTS regions_parent_idx ON regions (parent_id);
53
+ CREATE INDEX IF NOT EXISTS regions_tags_gin ON regions USING GIN (tags);
54
+
55
+ -- ----------------------------------------------------------------------------
56
+ -- helpers
57
+ -- ----------------------------------------------------------------------------
58
+
59
+ -- Normalise any GeoJSON area geometry (Polygon / MultiPolygon, possibly invalid,
60
+ -- possibly wrapped in a Feature) into a valid MultiPolygon in EPSG:4326.
61
+ CREATE OR REPLACE FUNCTION geom_from_geojson_area(gj jsonb)
62
+ RETURNS geometry(MultiPolygon, 4326)
63
+ LANGUAGE plpgsql IMMUTABLE STRICT AS $$
64
+ DECLARE
65
+ g geometry;
66
+ BEGIN
67
+ IF gj->>'type' = 'Feature' THEN
68
+ gj := gj->'geometry';
69
+ END IF;
70
+ IF gj IS NULL OR gj->>'type' NOT IN ('Polygon', 'MultiPolygon', 'GeometryCollection') THEN
71
+ RAISE EXCEPTION 'Expected a Polygon or MultiPolygon GeoJSON geometry, got %', coalesce(gj->>'type', 'null')
72
+ USING ERRCODE = 'invalid_parameter_value';
73
+ END IF;
74
+ g := ST_SetSRID(ST_GeomFromGeoJSON(gj::text), 4326);
75
+ g := ST_CollectionExtract(ST_MakeValid(g), 3); -- keep only polygonal parts
76
+ IF g IS NULL OR ST_IsEmpty(g) THEN
77
+ RAISE EXCEPTION 'Geometry has no polygonal area' USING ERRCODE = 'invalid_parameter_value';
78
+ END IF;
79
+ RETURN ST_Multi(g)::geometry(MultiPolygon, 4326);
80
+ END $$;
81
+
82
+ CREATE OR REPLACE FUNCTION set_updated_at() RETURNS trigger
83
+ LANGUAGE plpgsql AS $$
84
+ BEGIN
85
+ NEW.updated_at := now();
86
+ RETURN NEW;
87
+ END $$;
88
+
89
+ -- ----------------------------------------------------------------------------
90
+ -- triggers
91
+ -- ----------------------------------------------------------------------------
92
+
93
+ DROP TRIGGER IF EXISTS regions_updated_at_trg ON regions;
94
+ CREATE TRIGGER regions_updated_at_trg
95
+ BEFORE UPDATE ON regions
96
+ FOR EACH ROW EXECUTE FUNCTION set_updated_at();
97
+
98
+ DROP TRIGGER IF EXISTS countries_updated_at_trg ON countries;
99
+ CREATE TRIGGER countries_updated_at_trg
100
+ BEFORE UPDATE ON countries
101
+ FOR EACH ROW EXECUTE FUNCTION set_updated_at();
102
+
103
+ -- ----------------------------------------------------------------------------
104
+ -- write helpers used by the API, the seed and the import CLI
105
+ -- ----------------------------------------------------------------------------
106
+
107
+ CREATE OR REPLACE FUNCTION upsert_country(p_code text, p_name text, p_geojson jsonb)
108
+ RETURNS countries
109
+ LANGUAGE plpgsql AS $$
110
+ DECLARE
111
+ c countries;
112
+ BEGIN
113
+ INSERT INTO countries (code, name, geom)
114
+ VALUES (upper(p_code), p_name, CASE WHEN p_geojson IS NULL THEN NULL ELSE geom_from_geojson_area(p_geojson) END)
115
+ ON CONFLICT (code) DO UPDATE
116
+ SET name = EXCLUDED.name,
117
+ geom = coalesce(EXCLUDED.geom, countries.geom)
118
+ RETURNING * INTO c;
119
+ RETURN c;
120
+ END $$;
121
+
122
+ CREATE OR REPLACE FUNCTION upsert_region(
123
+ p_country_code text,
124
+ p_slug text,
125
+ p_name text,
126
+ p_type region_type,
127
+ p_level int,
128
+ p_parent_id int,
129
+ p_code text,
130
+ p_tags text[],
131
+ p_properties jsonb,
132
+ p_geojson jsonb,
133
+ p_upsert boolean DEFAULT true -- false: fail with unique_violation if the slug exists
134
+ ) RETURNS regions
135
+ LANGUAGE plpgsql AS $$
136
+ DECLARE
137
+ r regions;
138
+ cid int;
139
+ BEGIN
140
+ SELECT id INTO cid FROM countries WHERE code = upper(p_country_code);
141
+ IF cid IS NULL THEN
142
+ RAISE EXCEPTION 'Unknown country code %', p_country_code USING ERRCODE = 'foreign_key_violation';
143
+ END IF;
144
+ IF NOT p_upsert AND EXISTS (SELECT 1 FROM regions WHERE country_id = cid AND slug = p_slug) THEN
145
+ RAISE EXCEPTION 'A region with slug "%" already exists in %', p_slug, upper(p_country_code)
146
+ USING ERRCODE = 'unique_violation';
147
+ END IF;
148
+
149
+ INSERT INTO regions (country_id, slug, name, type, level, parent_id, code, tags, properties, geom)
150
+ VALUES (cid, p_slug, p_name, p_type, p_level, p_parent_id, p_code,
151
+ coalesce(p_tags, '{}'), coalesce(p_properties, '{}'), geom_from_geojson_area(p_geojson))
152
+ ON CONFLICT (country_id, slug) DO UPDATE
153
+ SET name = EXCLUDED.name,
154
+ type = EXCLUDED.type,
155
+ level = EXCLUDED.level,
156
+ parent_id = EXCLUDED.parent_id,
157
+ code = EXCLUDED.code,
158
+ tags = EXCLUDED.tags,
159
+ properties = EXCLUDED.properties,
160
+ geom = EXCLUDED.geom
161
+ RETURNING * INTO r;
162
+ RETURN r;
163
+ END $$;
164
+
165
+ -- If a country has no outline, build one as the union of its admin regions.
166
+ CREATE OR REPLACE FUNCTION derive_country_geom(p_code text) RETURNS void
167
+ LANGUAGE plpgsql AS $$
168
+ BEGIN
169
+ UPDATE countries c
170
+ SET geom = sub.g
171
+ FROM (
172
+ SELECT r.country_id, ST_Multi(ST_UnaryUnion(ST_Collect(r.geom)))::geometry(MultiPolygon,4326) AS g
173
+ FROM regions r JOIN countries cc ON cc.id = r.country_id
174
+ WHERE cc.code = upper(p_code) AND r.type = 'admin'
175
+ GROUP BY r.country_id
176
+ ) sub
177
+ WHERE c.id = sub.country_id;
178
+ END $$;
179
+
180
+ -- Live (uncached) lookup: every region covering an arbitrary point.
181
+ CREATE OR REPLACE FUNCTION regions_at_point(p_lng double precision, p_lat double precision)
182
+ RETURNS SETOF regions
183
+ LANGUAGE sql STABLE AS $$
184
+ SELECT r.*
185
+ FROM regions r
186
+ WHERE ST_Intersects(r.geom, ST_SetSRID(ST_MakePoint(p_lng, p_lat), 4326))
187
+ ORDER BY r.type, r.level NULLS LAST, r.name;
188
+ $$;
@@ -0,0 +1,26 @@
1
+ -- geom_from_geojson_area: a Feature with "geometry": null unwraps to a JSON null
2
+ -- (not SQL NULL), and `NULL NOT IN (...)` is not true, so it slipped past the
3
+ -- type check and failed later with PostGIS' generic "invalid GeoJSON
4
+ -- representation". Check the JSON value's type explicitly instead.
5
+ CREATE OR REPLACE FUNCTION geom_from_geojson_area(gj jsonb)
6
+ RETURNS geometry(MultiPolygon, 4326)
7
+ LANGUAGE plpgsql IMMUTABLE STRICT AS $$
8
+ DECLARE
9
+ g geometry;
10
+ BEGIN
11
+ IF gj->>'type' = 'Feature' THEN
12
+ gj := gj->'geometry';
13
+ END IF;
14
+ IF gj IS NULL OR jsonb_typeof(gj) <> 'object'
15
+ OR coalesce(gj->>'type', '') NOT IN ('Polygon', 'MultiPolygon', 'GeometryCollection') THEN
16
+ RAISE EXCEPTION 'Expected a Polygon or MultiPolygon GeoJSON geometry, got %',
17
+ CASE WHEN gj IS NULL OR jsonb_typeof(gj) <> 'object' THEN 'null' ELSE coalesce(gj->>'type', 'null') END
18
+ USING ERRCODE = 'invalid_parameter_value';
19
+ END IF;
20
+ g := ST_SetSRID(ST_GeomFromGeoJSON(gj::text), 4326);
21
+ g := ST_CollectionExtract(ST_MakeValid(g), 3); -- keep only polygonal parts
22
+ IF g IS NULL OR ST_IsEmpty(g) THEN
23
+ RAISE EXCEPTION 'Geometry has no polygonal area' USING ERRCODE = 'invalid_parameter_value';
24
+ END IF;
25
+ RETURN ST_Multi(g)::geometry(MultiPolygon, 4326);
26
+ END $$;
@@ -0,0 +1,364 @@
1
+ -- ============================================================================
2
+ -- 003 — move the engine into its own schema and generalise memberships
3
+ --
4
+ -- * every table, type and function now lives in schema `geo`, so the engine
5
+ -- can be installed next to a host application's own tables
6
+ -- * membership means ST_Intersects, generalised to any geometry type
7
+ -- * memberships are kept for any *registered source table*. The demo table
8
+ -- (geo.locations) is no longer part of this core set: it is applied
9
+ -- separately by @geo/db from packages/db/sql-demo, after this migration.
10
+ -- ============================================================================
11
+
12
+ CREATE SCHEMA IF NOT EXISTS geo;
13
+
14
+ -- ---------------------------------------------------------------- move objects
15
+ ALTER TYPE public.region_type SET SCHEMA geo;
16
+ ALTER TABLE public.countries SET SCHEMA geo;
17
+ ALTER TABLE public.regions SET SCHEMA geo;
18
+
19
+ DROP TRIGGER IF EXISTS regions_refresh_memberships_trg ON geo.regions;
20
+ DROP TRIGGER IF EXISTS regions_updated_at_trg ON geo.regions;
21
+ DROP TRIGGER IF EXISTS countries_updated_at_trg ON geo.countries;
22
+
23
+ -- every signature is spelled out so a host function that happens to share a name
24
+ -- but not an argument list is never dropped (region_type has just moved to geo,
25
+ -- so upsert_region's fourth argument is already geo.region_type here)
26
+ DROP FUNCTION IF EXISTS public.set_updated_at();
27
+ DROP FUNCTION IF EXISTS public.upsert_country(text, text, jsonb);
28
+ DROP FUNCTION IF EXISTS public.upsert_region(text, text, text, geo.region_type, int, int, text, text[], jsonb, jsonb, boolean);
29
+ DROP FUNCTION IF EXISTS public.derive_country_geom(text);
30
+ DROP FUNCTION IF EXISTS public.regions_at_point(double precision, double precision);
31
+ DROP FUNCTION IF EXISTS public.geom_from_geojson_area(jsonb);
32
+
33
+ -- ---------------------------------------------------------------- new tables
34
+ CREATE TABLE geo.sources (
35
+ id serial PRIMARY KEY,
36
+ name text NOT NULL UNIQUE,
37
+ table_schema text NOT NULL,
38
+ table_name text NOT NULL,
39
+ id_column text NOT NULL,
40
+ id_type text NOT NULL, -- SQL type of the id column, e.g. 'integer', 'uuid'
41
+ geom_expr text NOT NULL, -- expression over a row alias `r` yielding geometry(…, 4326)
42
+ registered_at timestamptz NOT NULL DEFAULT now(),
43
+ UNIQUE (table_schema, table_name)
44
+ );
45
+
46
+ CREATE TABLE geo.memberships (
47
+ source_id int NOT NULL REFERENCES geo.sources(id) ON DELETE CASCADE,
48
+ external_id text NOT NULL,
49
+ region_id int NOT NULL REFERENCES geo.regions(id) ON DELETE CASCADE,
50
+ PRIMARY KEY (source_id, external_id, region_id)
51
+ );
52
+ CREATE INDEX memberships_region_idx ON geo.memberships (region_id);
53
+
54
+ CREATE OR REPLACE FUNCTION geo.schema_version() RETURNS int
55
+ LANGUAGE sql STABLE AS $$
56
+ SELECT coalesce(max(split_part(name, '_', 1)::int), 0) FROM geo.schema_migrations;
57
+ $$;
58
+
59
+ -- ---------------------------------------------------------------- helpers
60
+ CREATE OR REPLACE FUNCTION geo.geom_from_geojson_area(gj jsonb)
61
+ RETURNS geometry(MultiPolygon, 4326)
62
+ LANGUAGE plpgsql IMMUTABLE STRICT AS $$
63
+ DECLARE
64
+ g geometry;
65
+ BEGIN
66
+ IF gj->>'type' = 'Feature' THEN
67
+ gj := gj->'geometry';
68
+ END IF;
69
+ IF gj IS NULL OR jsonb_typeof(gj) <> 'object'
70
+ OR coalesce(gj->>'type', '') NOT IN ('Polygon', 'MultiPolygon', 'GeometryCollection') THEN
71
+ RAISE EXCEPTION 'Expected a Polygon or MultiPolygon GeoJSON geometry, got %',
72
+ CASE WHEN gj IS NULL OR jsonb_typeof(gj) <> 'object' THEN 'null' ELSE coalesce(gj->>'type', 'null') END
73
+ USING ERRCODE = 'invalid_parameter_value';
74
+ END IF;
75
+ g := ST_SetSRID(ST_GeomFromGeoJSON(gj::text), 4326);
76
+ g := ST_CollectionExtract(ST_MakeValid(g), 3);
77
+ IF g IS NULL OR ST_IsEmpty(g) THEN
78
+ RAISE EXCEPTION 'Geometry has no polygonal area' USING ERRCODE = 'invalid_parameter_value';
79
+ END IF;
80
+ RETURN ST_Multi(g)::geometry(MultiPolygon, 4326);
81
+ END $$;
82
+
83
+ CREATE OR REPLACE FUNCTION geo.set_updated_at() RETURNS trigger
84
+ LANGUAGE plpgsql AS $$
85
+ BEGIN
86
+ NEW.updated_at := now();
87
+ RETURN NEW;
88
+ END $$;
89
+
90
+ CREATE TRIGGER regions_updated_at_trg
91
+ BEFORE UPDATE ON geo.regions FOR EACH ROW EXECUTE FUNCTION geo.set_updated_at();
92
+ CREATE TRIGGER countries_updated_at_trg
93
+ BEFORE UPDATE ON geo.countries FOR EACH ROW EXECUTE FUNCTION geo.set_updated_at();
94
+
95
+ -- ---------------------------------------------------------------- memberships
96
+ -- Row trigger installed on every registered source table. TG_ARGV[0] is the
97
+ -- geo.sources id. The host's id column and geometry expression are read from
98
+ -- geo.sources and evaluated dynamically against the row.
99
+ CREATE OR REPLACE FUNCTION geo.source_row_changed() RETURNS trigger
100
+ LANGUAGE plpgsql AS $$
101
+ DECLARE
102
+ s geo.sources;
103
+ new_id text;
104
+ old_id text;
105
+ new_g geometry;
106
+ old_g geometry;
107
+ q text;
108
+ BEGIN
109
+ IF current_setting('geo.skip_membership_triggers', true) = 'on' THEN
110
+ RETURN NULL;
111
+ END IF;
112
+ SELECT * INTO s FROM geo.sources WHERE id = TG_ARGV[0]::int;
113
+ IF s.id IS NULL THEN
114
+ RETURN NULL; -- source was unregistered; nothing to maintain
115
+ END IF;
116
+ q := format('SELECT r.%I::text, (%s)::geometry FROM (SELECT ($1::%I.%I).*) AS r',
117
+ s.id_column, s.geom_expr, s.table_schema, s.table_name);
118
+
119
+ IF TG_OP IN ('UPDATE', 'DELETE') THEN
120
+ EXECUTE q INTO old_id, old_g USING OLD;
121
+ END IF;
122
+ IF TG_OP IN ('INSERT', 'UPDATE') THEN
123
+ EXECUTE q INTO new_id, new_g USING NEW;
124
+ END IF;
125
+
126
+ IF TG_OP = 'UPDATE' AND new_id = old_id
127
+ AND ((new_g IS NULL AND old_g IS NULL) OR (new_g IS NOT NULL AND old_g IS NOT NULL AND ST_Equals(new_g, old_g))) THEN
128
+ RETURN NULL; -- neither the id nor the geometry changed
129
+ END IF;
130
+
131
+ IF old_id IS NOT NULL THEN
132
+ DELETE FROM geo.memberships WHERE source_id = s.id AND external_id = old_id;
133
+ END IF;
134
+ IF new_id IS NOT NULL THEN
135
+ IF new_id IS DISTINCT FROM old_id THEN -- otherwise the DELETE above already removed it
136
+ DELETE FROM geo.memberships WHERE source_id = s.id AND external_id = new_id;
137
+ END IF;
138
+ IF new_g IS NOT NULL THEN
139
+ INSERT INTO geo.memberships (source_id, external_id, region_id)
140
+ SELECT s.id, new_id, r.id FROM geo.regions r WHERE ST_Intersects(r.geom, new_g);
141
+ END IF;
142
+ END IF;
143
+ RETURN NULL;
144
+ END $$;
145
+
146
+ -- A region was inserted or reshaped: recompute its members in every source.
147
+ CREATE OR REPLACE FUNCTION geo.regions_refresh_memberships() RETURNS trigger
148
+ LANGUAGE plpgsql AS $$
149
+ DECLARE
150
+ s geo.sources;
151
+ BEGIN
152
+ IF current_setting('geo.skip_membership_triggers', true) = 'on' THEN
153
+ RETURN NULL;
154
+ END IF;
155
+ IF TG_OP = 'UPDATE' AND ST_Equals(NEW.geom, OLD.geom) THEN
156
+ RETURN NULL;
157
+ END IF;
158
+ DELETE FROM geo.memberships WHERE region_id = NEW.id;
159
+ FOR s IN SELECT * FROM geo.sources LOOP
160
+ -- a registered table that was dropped or renamed must not break region writes
161
+ IF to_regclass(format('%I.%I', s.table_schema, s.table_name)) IS NULL THEN
162
+ RAISE WARNING 'geo: source "%" points at %.% which no longer exists; run geo.unregister_source(%L)',
163
+ s.name, s.table_schema, s.table_name, s.name;
164
+ CONTINUE;
165
+ END IF;
166
+ EXECUTE format(
167
+ 'INSERT INTO geo.memberships (source_id, external_id, region_id)
168
+ SELECT %s, r.%I::text, %s FROM %I.%I r WHERE ST_Intersects($1, (%s)::geometry)',
169
+ s.id, s.id_column, NEW.id, s.table_schema, s.table_name, s.geom_expr)
170
+ USING NEW.geom;
171
+ END LOOP;
172
+ RETURN NULL;
173
+ END $$;
174
+
175
+ CREATE TRIGGER regions_refresh_memberships_trg
176
+ AFTER INSERT OR UPDATE OF geom ON geo.regions
177
+ FOR EACH ROW EXECUTE FUNCTION geo.regions_refresh_memberships();
178
+
179
+ CREATE OR REPLACE FUNCTION geo.rebuild_memberships(p_source text) RETURNS bigint
180
+ LANGUAGE plpgsql AS $$
181
+ DECLARE
182
+ s geo.sources;
183
+ n bigint;
184
+ BEGIN
185
+ SELECT * INTO s FROM geo.sources WHERE name = p_source;
186
+ IF s.id IS NULL THEN
187
+ RAISE EXCEPTION 'Unknown source "%"', p_source USING ERRCODE = 'invalid_parameter_value';
188
+ END IF;
189
+ DELETE FROM geo.memberships WHERE source_id = s.id;
190
+ IF to_regclass(format('%I.%I', s.table_schema, s.table_name)) IS NULL THEN
191
+ RAISE WARNING 'geo: source "%" points at %.% which no longer exists; run geo.unregister_source(%L)',
192
+ s.name, s.table_schema, s.table_name, s.name;
193
+ RETURN 0; -- the memberships we just dropped could not be valid any more
194
+ END IF;
195
+ -- the source row is resolved in a subquery (aliased r there) before joining to
196
+ -- geo.regions (aliased g): geom_expr may be a bare column such as "geom", which
197
+ -- would otherwise collide with geo.regions.geom once both aliases are in scope
198
+ EXECUTE format(
199
+ 'INSERT INTO geo.memberships (source_id, external_id, region_id)
200
+ SELECT %s, x.rid, g.id
201
+ FROM (SELECT r.%I::text AS rid, (%s)::geometry AS rgeom FROM %I.%I r) x
202
+ JOIN geo.regions g ON ST_Intersects(g.geom, x.rgeom)',
203
+ s.id, s.id_column, s.geom_expr, s.table_schema, s.table_name);
204
+ GET DIAGNOSTICS n = ROW_COUNT;
205
+ RETURN n;
206
+ END $$;
207
+
208
+ CREATE OR REPLACE FUNCTION geo.rebuild_all_memberships() RETURNS bigint
209
+ LANGUAGE plpgsql AS $$
210
+ DECLARE
211
+ total bigint := 0;
212
+ s geo.sources;
213
+ BEGIN
214
+ FOR s IN SELECT * FROM geo.sources ORDER BY id LOOP
215
+ total := total + geo.rebuild_memberships(s.name);
216
+ END LOOP;
217
+ RETURN total;
218
+ END $$;
219
+
220
+ -- Register a host table. Validates the table, the id column and the geometry
221
+ -- expression (compiled with LIMIT 0), installs the row trigger and builds the
222
+ -- initial memberships.
223
+ CREATE OR REPLACE FUNCTION geo.register_source(
224
+ p_name text, p_table_schema text, p_table_name text, p_id_column text, p_geom_expr text
225
+ ) RETURNS geo.sources
226
+ LANGUAGE plpgsql AS $$
227
+ DECLARE
228
+ s geo.sources;
229
+ t regclass;
230
+ idtype text;
231
+ BEGIN
232
+ t := to_regclass(format('%I.%I', p_table_schema, p_table_name));
233
+ IF t IS NULL THEN
234
+ RAISE EXCEPTION 'Table %.% does not exist', p_table_schema, p_table_name USING ERRCODE = 'undefined_table';
235
+ END IF;
236
+ SELECT format_type(a.atttypid, a.atttypmod) INTO idtype
237
+ FROM pg_attribute a
238
+ WHERE a.attrelid = t AND a.attname = p_id_column AND a.attnum > 0 AND NOT a.attisdropped;
239
+ IF idtype IS NULL THEN
240
+ RAISE EXCEPTION 'Column % does not exist on %.%', p_id_column, p_table_schema, p_table_name
241
+ USING ERRCODE = 'undefined_column';
242
+ END IF;
243
+ EXECUTE format('SELECT (%s)::geometry FROM %I.%I r LIMIT 0', p_geom_expr, p_table_schema, p_table_name);
244
+
245
+ INSERT INTO geo.sources (name, table_schema, table_name, id_column, id_type, geom_expr)
246
+ VALUES (p_name, p_table_schema, p_table_name, p_id_column, idtype, p_geom_expr)
247
+ RETURNING * INTO s;
248
+
249
+ EXECUTE format(
250
+ 'CREATE TRIGGER %I AFTER INSERT OR UPDATE OR DELETE ON %I.%I
251
+ FOR EACH ROW EXECUTE FUNCTION geo.source_row_changed(%L)',
252
+ 'geo_memberships_' || s.id, p_table_schema, p_table_name, s.id::text);
253
+
254
+ PERFORM geo.rebuild_memberships(p_name);
255
+ RETURN s;
256
+ END $$;
257
+
258
+ CREATE OR REPLACE FUNCTION geo.unregister_source(p_name text) RETURNS void
259
+ LANGUAGE plpgsql AS $$
260
+ DECLARE
261
+ s geo.sources;
262
+ BEGIN
263
+ SELECT * INTO s FROM geo.sources WHERE name = p_name;
264
+ IF s.id IS NULL THEN
265
+ RAISE EXCEPTION 'Unknown source "%"', p_name USING ERRCODE = 'invalid_parameter_value';
266
+ END IF;
267
+ IF to_regclass(format('%I.%I', s.table_schema, s.table_name)) IS NOT NULL THEN
268
+ EXECUTE format('DROP TRIGGER IF EXISTS %I ON %I.%I', 'geo_memberships_' || s.id, s.table_schema, s.table_name);
269
+ END IF;
270
+ DELETE FROM geo.sources WHERE id = s.id; -- memberships cascade
271
+ END $$;
272
+
273
+ -- ---------------------------------------------------------------- write helpers
274
+ CREATE OR REPLACE FUNCTION geo.upsert_country(p_code text, p_name text, p_geojson jsonb)
275
+ RETURNS geo.countries
276
+ LANGUAGE plpgsql AS $$
277
+ DECLARE
278
+ c geo.countries;
279
+ BEGIN
280
+ INSERT INTO geo.countries (code, name, geom)
281
+ VALUES (upper(p_code), p_name, CASE WHEN p_geojson IS NULL THEN NULL ELSE geo.geom_from_geojson_area(p_geojson) END)
282
+ ON CONFLICT (code) DO UPDATE
283
+ SET name = EXCLUDED.name,
284
+ geom = coalesce(EXCLUDED.geom, geo.countries.geom)
285
+ RETURNING * INTO c;
286
+ RETURN c;
287
+ END $$;
288
+
289
+ CREATE OR REPLACE FUNCTION geo.upsert_region(
290
+ p_country_code text,
291
+ p_slug text,
292
+ p_name text,
293
+ p_type geo.region_type,
294
+ p_level int,
295
+ p_parent_id int,
296
+ p_code text,
297
+ p_tags text[],
298
+ p_properties jsonb,
299
+ p_geojson jsonb,
300
+ p_upsert boolean DEFAULT true
301
+ ) RETURNS geo.regions
302
+ LANGUAGE plpgsql AS $$
303
+ DECLARE
304
+ r geo.regions;
305
+ cid int;
306
+ BEGIN
307
+ SELECT id INTO cid FROM geo.countries WHERE code = upper(p_country_code);
308
+ IF cid IS NULL THEN
309
+ RAISE EXCEPTION 'Unknown country code %', p_country_code USING ERRCODE = 'foreign_key_violation';
310
+ END IF;
311
+ IF NOT p_upsert AND EXISTS (SELECT 1 FROM geo.regions WHERE country_id = cid AND slug = p_slug) THEN
312
+ RAISE EXCEPTION 'A region with slug "%" already exists in %', p_slug, upper(p_country_code)
313
+ USING ERRCODE = 'unique_violation';
314
+ END IF;
315
+
316
+ INSERT INTO geo.regions (country_id, slug, name, type, level, parent_id, code, tags, properties, geom)
317
+ VALUES (cid, p_slug, p_name, p_type, p_level, p_parent_id, p_code,
318
+ coalesce(p_tags, '{}'), coalesce(p_properties, '{}'), geo.geom_from_geojson_area(p_geojson))
319
+ ON CONFLICT (country_id, slug) DO UPDATE
320
+ SET name = EXCLUDED.name,
321
+ type = EXCLUDED.type,
322
+ level = EXCLUDED.level,
323
+ parent_id = EXCLUDED.parent_id,
324
+ code = EXCLUDED.code,
325
+ tags = EXCLUDED.tags,
326
+ properties = EXCLUDED.properties,
327
+ geom = EXCLUDED.geom
328
+ RETURNING * INTO r;
329
+ RETURN r;
330
+ END $$;
331
+
332
+ CREATE OR REPLACE FUNCTION geo.derive_country_geom(p_code text) RETURNS void
333
+ LANGUAGE plpgsql AS $$
334
+ BEGIN
335
+ UPDATE geo.countries c
336
+ SET geom = sub.g
337
+ FROM (
338
+ SELECT r.country_id, ST_Multi(ST_UnaryUnion(ST_Collect(r.geom)))::geometry(MultiPolygon,4326) AS g
339
+ FROM geo.regions r JOIN geo.countries cc ON cc.id = r.country_id
340
+ WHERE cc.code = upper(p_code) AND r.type = 'admin'
341
+ GROUP BY r.country_id
342
+ ) sub
343
+ WHERE c.id = sub.country_id;
344
+ END $$;
345
+
346
+ CREATE OR REPLACE FUNCTION geo.regions_at_point(p_lng double precision, p_lat double precision)
347
+ RETURNS SETOF geo.regions
348
+ LANGUAGE sql STABLE AS $$
349
+ SELECT r.*
350
+ FROM geo.regions r
351
+ WHERE ST_Intersects(r.geom, ST_SetSRID(ST_MakePoint(p_lng, p_lat), 4326))
352
+ ORDER BY r.type, r.level NULLS LAST, r.name;
353
+ $$;
354
+
355
+ -- ---------------------------------------------------------------- privileges
356
+ -- geom_expr is *trusted SQL*: it is supplied by the database administrator when a
357
+ -- table is registered and is later evaluated inside the membership triggers and in
358
+ -- geo.rebuild_memberships, with the privileges of whichever role happens to be
359
+ -- writing rows or editing regions at that moment. Registering a source is therefore
360
+ -- as powerful as writing a trigger by hand, and must stay an administrative act:
361
+ -- both entry points are taken away from PUBLIC. Everything else in the geo schema
362
+ -- (the query contract, the write helpers) keeps its default grants.
363
+ REVOKE EXECUTE ON FUNCTION geo.register_source(text, text, text, text, text) FROM PUBLIC;
364
+ REVOKE EXECUTE ON FUNCTION geo.unregister_source(text) FROM PUBLIC;
@@ -0,0 +1,95 @@
1
+ -- ============================================================================
2
+ -- 004 — the query contract: ids_in / regions_of
3
+ -- ============================================================================
4
+
5
+ -- Slugs or numeric ids -> distinct region ids. Raises on any unknown token.
6
+ CREATE OR REPLACE FUNCTION geo.resolve_regions(p_tokens text[]) RETURNS int[]
7
+ LANGUAGE plpgsql STABLE AS $$
8
+ DECLARE
9
+ ids int[];
10
+ unknown text[];
11
+ BEGIN
12
+ IF p_tokens IS NULL OR cardinality(p_tokens) = 0 THEN
13
+ RETURN NULL;
14
+ END IF;
15
+ SELECT array_agg(t.tok ORDER BY t.n) INTO unknown
16
+ FROM unnest(p_tokens) WITH ORDINALITY AS t(tok, n)
17
+ WHERE NOT EXISTS (
18
+ SELECT 1 FROM geo.regions r
19
+ WHERE r.slug = t.tok OR r.id = (CASE WHEN t.tok ~ '^\d{1,9}$' THEN t.tok::int END));
20
+ IF unknown IS NOT NULL THEN
21
+ RAISE EXCEPTION 'Unknown region(s): %', array_to_string(unknown, ', ')
22
+ USING ERRCODE = 'invalid_parameter_value', DETAIL = array_to_string(unknown, ',');
23
+ END IF;
24
+ SELECT array_agg(DISTINCT r.id) INTO ids
25
+ FROM geo.regions r
26
+ JOIN unnest(p_tokens) AS t(tok)
27
+ ON r.slug = t.tok OR r.id = (CASE WHEN t.tok ~ '^\d{1,9}$' THEN t.tok::int END);
28
+ RETURN ids;
29
+ END $$;
30
+
31
+ -- External ids of the source's rows that are inside the given regions
32
+ -- (any of them, or all of them) and inside none of the excluded regions.
33
+ CREATE OR REPLACE FUNCTION geo.ids_in(
34
+ p_source text,
35
+ p_regions text[],
36
+ p_match text DEFAULT 'any',
37
+ p_exclude text[] DEFAULT NULL
38
+ ) RETURNS SETOF text
39
+ LANGUAGE plpgsql STABLE AS $$
40
+ DECLARE
41
+ sid int;
42
+ inc int[];
43
+ exc int[];
44
+ BEGIN
45
+ SELECT id INTO sid FROM geo.sources WHERE name = p_source;
46
+ IF sid IS NULL THEN
47
+ RAISE EXCEPTION 'Unknown source "%"', p_source USING ERRCODE = 'invalid_parameter_value';
48
+ END IF;
49
+ IF p_match IS NULL OR p_match NOT IN ('any', 'all') THEN
50
+ RAISE EXCEPTION 'match must be any or all, got "%"', p_match USING ERRCODE = 'invalid_parameter_value';
51
+ END IF;
52
+ inc := geo.resolve_regions(p_regions);
53
+ IF inc IS NULL THEN
54
+ RAISE EXCEPTION 'regions must name at least one region' USING ERRCODE = 'invalid_parameter_value';
55
+ END IF;
56
+ exc := geo.resolve_regions(p_exclude);
57
+
58
+ RETURN QUERY
59
+ SELECT m.external_id
60
+ FROM geo.memberships m
61
+ WHERE m.source_id = sid
62
+ AND m.region_id = ANY (inc)
63
+ AND (exc IS NULL OR NOT EXISTS (
64
+ SELECT 1 FROM geo.memberships x
65
+ WHERE x.source_id = sid AND x.external_id = m.external_id AND x.region_id = ANY (exc)))
66
+ GROUP BY m.external_id
67
+ HAVING p_match = 'any' OR count(DISTINCT m.region_id) = cardinality(inc);
68
+ END $$;
69
+
70
+ CREATE OR REPLACE FUNCTION geo.region_summary(r geo.regions) RETURNS json
71
+ LANGUAGE sql IMMUTABLE AS $$
72
+ SELECT json_build_object(
73
+ 'id', r.id, 'slug', r.slug, 'name', r.name, 'type', r.type,
74
+ 'level', r.level, 'parent_id', r.parent_id, 'code', r.code);
75
+ $$;
76
+
77
+ -- The regions one row of a source belongs to, admin first, as a JSON array.
78
+ -- An unknown source is caller error, not an empty result — same as geo.ids_in.
79
+ CREATE OR REPLACE FUNCTION geo.regions_of(p_source text, p_external_id text) RETURNS json
80
+ LANGUAGE plpgsql STABLE AS $$
81
+ DECLARE
82
+ sid int;
83
+ result json;
84
+ BEGIN
85
+ SELECT id INTO sid FROM geo.sources WHERE name = p_source;
86
+ IF sid IS NULL THEN
87
+ RAISE EXCEPTION 'Unknown source "%"', p_source USING ERRCODE = 'invalid_parameter_value';
88
+ END IF;
89
+ SELECT coalesce(json_agg(geo.region_summary(r) ORDER BY r.type, r.level NULLS LAST, r.name), '[]'::json)
90
+ INTO result
91
+ FROM geo.memberships m
92
+ JOIN geo.regions r ON r.id = m.region_id
93
+ WHERE m.source_id = sid AND m.external_id = p_external_id;
94
+ RETURN result;
95
+ END $$;