@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.
- package/LICENSE +21 -0
- package/README.md +190 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +137 -0
- package/dist/errors.d.ts +17 -0
- package/dist/errors.js +41 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +7 -0
- package/dist/migrate.d.ts +14 -0
- package/dist/migrate.js +128 -0
- package/dist/packs.d.ts +14 -0
- package/dist/packs.js +73 -0
- package/dist/paths.d.ts +4 -0
- package/dist/paths.js +7 -0
- package/dist/query.d.ts +43 -0
- package/dist/query.js +52 -0
- package/dist/sources.d.ts +30 -0
- package/dist/sources.js +32 -0
- package/dist/types.d.ts +323 -0
- package/dist/types.js +58 -0
- package/package.json +45 -0
- package/sql/001_init.sql +188 -0
- package/sql/002_geojson_null_geometry.sql +26 -0
- package/sql/003_geo_schema.sql +364 -0
- package/sql/004_query_functions.sql +95 -0
- package/sql/005_packs.sql +200 -0
- package/sql/006_search_path.sql +71 -0
package/sql/001_init.sql
ADDED
|
@@ -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 $$;
|