@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
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
-- ============================================================================
|
|
2
|
+
-- 005 — region packs: versioned region sets moved between databases
|
|
3
|
+
-- ============================================================================
|
|
4
|
+
|
|
5
|
+
CREATE TABLE geo.packs (
|
|
6
|
+
id serial PRIMARY KEY,
|
|
7
|
+
slug text NOT NULL UNIQUE,
|
|
8
|
+
version text NOT NULL,
|
|
9
|
+
schema_version int NOT NULL,
|
|
10
|
+
installed_at timestamptz NOT NULL DEFAULT now()
|
|
11
|
+
);
|
|
12
|
+
|
|
13
|
+
ALTER TABLE geo.regions ADD COLUMN pack_id int REFERENCES geo.packs(id) ON DELETE SET NULL;
|
|
14
|
+
CREATE INDEX regions_pack_idx ON geo.regions (pack_id);
|
|
15
|
+
|
|
16
|
+
-- upsert_region now carries the pack it writes for. A region owned by a
|
|
17
|
+
-- different pack (or by nobody, i.e. a local region) is never overwritten.
|
|
18
|
+
DROP FUNCTION geo.upsert_region(text, text, text, geo.region_type, int, int, text, text[], jsonb, jsonb, boolean);
|
|
19
|
+
CREATE OR REPLACE FUNCTION geo.upsert_region(
|
|
20
|
+
p_country_code text,
|
|
21
|
+
p_slug text,
|
|
22
|
+
p_name text,
|
|
23
|
+
p_type geo.region_type,
|
|
24
|
+
p_level int,
|
|
25
|
+
p_parent_id int,
|
|
26
|
+
p_code text,
|
|
27
|
+
p_tags text[],
|
|
28
|
+
p_properties jsonb,
|
|
29
|
+
p_geojson jsonb,
|
|
30
|
+
p_upsert boolean DEFAULT true,
|
|
31
|
+
p_pack_id int DEFAULT NULL
|
|
32
|
+
) RETURNS geo.regions
|
|
33
|
+
LANGUAGE plpgsql AS $$
|
|
34
|
+
DECLARE
|
|
35
|
+
r geo.regions;
|
|
36
|
+
existing geo.regions;
|
|
37
|
+
cid int;
|
|
38
|
+
BEGIN
|
|
39
|
+
SELECT id INTO cid FROM geo.countries WHERE code = upper(p_country_code);
|
|
40
|
+
IF cid IS NULL THEN
|
|
41
|
+
RAISE EXCEPTION 'Unknown country code %', p_country_code USING ERRCODE = 'foreign_key_violation';
|
|
42
|
+
END IF;
|
|
43
|
+
SELECT * INTO existing FROM geo.regions WHERE country_id = cid AND slug = p_slug;
|
|
44
|
+
IF existing.id IS NOT NULL AND NOT p_upsert THEN
|
|
45
|
+
RAISE EXCEPTION 'A region with slug "%" already exists in %', p_slug, upper(p_country_code)
|
|
46
|
+
USING ERRCODE = 'unique_violation';
|
|
47
|
+
END IF;
|
|
48
|
+
IF existing.id IS NOT NULL AND existing.pack_id IS DISTINCT FROM p_pack_id THEN
|
|
49
|
+
RAISE EXCEPTION 'Region "%" in % belongs to % and cannot be changed here', p_slug, upper(p_country_code),
|
|
50
|
+
CASE WHEN existing.pack_id IS NULL THEN 'this database (local region)'
|
|
51
|
+
ELSE 'pack "' || (SELECT slug FROM geo.packs WHERE id = existing.pack_id) || '"' END
|
|
52
|
+
USING ERRCODE = 'unique_violation';
|
|
53
|
+
END IF;
|
|
54
|
+
|
|
55
|
+
INSERT INTO geo.regions (country_id, slug, name, type, level, parent_id, code, tags, properties, geom, pack_id)
|
|
56
|
+
VALUES (cid, p_slug, p_name, p_type, p_level, p_parent_id, p_code,
|
|
57
|
+
coalesce(p_tags, '{}'), coalesce(p_properties, '{}'), geo.geom_from_geojson_area(p_geojson), p_pack_id)
|
|
58
|
+
ON CONFLICT (country_id, slug) DO UPDATE
|
|
59
|
+
SET name = EXCLUDED.name,
|
|
60
|
+
type = EXCLUDED.type,
|
|
61
|
+
level = EXCLUDED.level,
|
|
62
|
+
parent_id = EXCLUDED.parent_id,
|
|
63
|
+
code = EXCLUDED.code,
|
|
64
|
+
tags = EXCLUDED.tags,
|
|
65
|
+
properties = EXCLUDED.properties,
|
|
66
|
+
geom = EXCLUDED.geom,
|
|
67
|
+
pack_id = EXCLUDED.pack_id
|
|
68
|
+
RETURNING * INTO r;
|
|
69
|
+
RETURN r;
|
|
70
|
+
END $$;
|
|
71
|
+
|
|
72
|
+
-- Everything in this database as one pack document (6-decimal coordinates).
|
|
73
|
+
CREATE OR REPLACE FUNCTION geo.export_pack(p_slug text, p_version text) RETURNS jsonb
|
|
74
|
+
LANGUAGE sql STABLE AS $$
|
|
75
|
+
SELECT jsonb_build_object(
|
|
76
|
+
'pack', jsonb_build_object(
|
|
77
|
+
'slug', p_slug, 'version', p_version, 'schema_version', geo.schema_version(),
|
|
78
|
+
'published_at', to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"')),
|
|
79
|
+
'countries', coalesce((
|
|
80
|
+
SELECT jsonb_agg(jsonb_build_object(
|
|
81
|
+
'code', c.code, 'name', c.name,
|
|
82
|
+
'geometry', CASE WHEN c.geom IS NULL THEN NULL ELSE ST_AsGeoJSON(c.geom, 6)::jsonb END
|
|
83
|
+
) ORDER BY c.code)
|
|
84
|
+
FROM geo.countries c), '[]'::jsonb),
|
|
85
|
+
'regions', jsonb_build_object(
|
|
86
|
+
'type', 'FeatureCollection',
|
|
87
|
+
'features', coalesce((
|
|
88
|
+
SELECT jsonb_agg(jsonb_build_object(
|
|
89
|
+
'type', 'Feature',
|
|
90
|
+
'geometry', ST_AsGeoJSON(r.geom, 6)::jsonb,
|
|
91
|
+
'properties', jsonb_build_object(
|
|
92
|
+
'country', c.code, 'slug', r.slug, 'name', r.name, 'type', r.type, 'level', r.level,
|
|
93
|
+
'parent', p.slug, 'parent_country', pc.code, 'code', r.code, 'tags', to_jsonb(r.tags), 'properties', r.properties)
|
|
94
|
+
) ORDER BY c.code, r.slug)
|
|
95
|
+
FROM geo.regions r
|
|
96
|
+
JOIN geo.countries c ON c.id = r.country_id
|
|
97
|
+
LEFT JOIN geo.regions p ON p.id = r.parent_id
|
|
98
|
+
LEFT JOIN geo.countries pc ON pc.id = p.country_id), '[]'::jsonb)));
|
|
99
|
+
$$;
|
|
100
|
+
|
|
101
|
+
-- Install or update a pack in one transaction. Membership triggers are
|
|
102
|
+
-- suspended for the bulk write and the cache is rebuilt once at the end.
|
|
103
|
+
CREATE OR REPLACE FUNCTION geo.install_pack(p_pack jsonb) RETURNS jsonb
|
|
104
|
+
LANGUAGE plpgsql AS $$
|
|
105
|
+
DECLARE
|
|
106
|
+
hdr jsonb := p_pack->'pack';
|
|
107
|
+
feats jsonb := coalesce(p_pack->'regions'->'features', '[]'::jsonb);
|
|
108
|
+
pid int;
|
|
109
|
+
f jsonb;
|
|
110
|
+
n_countries int := 0;
|
|
111
|
+
n_regions int := 0;
|
|
112
|
+
n_members bigint;
|
|
113
|
+
code text;
|
|
114
|
+
BEGIN
|
|
115
|
+
IF hdr IS NULL OR coalesce(hdr->>'slug', '') = '' OR coalesce(hdr->>'version', '') = '' THEN
|
|
116
|
+
RAISE EXCEPTION 'Pack header must have slug and version' USING ERRCODE = 'invalid_parameter_value';
|
|
117
|
+
END IF;
|
|
118
|
+
IF coalesce((hdr->>'schema_version')::int, 0) > geo.schema_version() THEN
|
|
119
|
+
RAISE EXCEPTION 'Pack "%" needs schema version % but this database has %',
|
|
120
|
+
hdr->>'slug', hdr->>'schema_version', geo.schema_version()
|
|
121
|
+
USING ERRCODE = 'invalid_parameter_value';
|
|
122
|
+
END IF;
|
|
123
|
+
|
|
124
|
+
INSERT INTO geo.packs (slug, version, schema_version)
|
|
125
|
+
VALUES (hdr->>'slug', hdr->>'version', coalesce((hdr->>'schema_version')::int, geo.schema_version()))
|
|
126
|
+
ON CONFLICT (slug) DO UPDATE
|
|
127
|
+
SET version = EXCLUDED.version, schema_version = EXCLUDED.schema_version, installed_at = now()
|
|
128
|
+
RETURNING id INTO pid;
|
|
129
|
+
|
|
130
|
+
PERFORM set_config('geo.skip_membership_triggers', 'on', true);
|
|
131
|
+
|
|
132
|
+
FOR f IN SELECT * FROM jsonb_array_elements(coalesce(p_pack->'countries', '[]'::jsonb)) LOOP
|
|
133
|
+
PERFORM geo.upsert_country(f->>'code', f->>'name',
|
|
134
|
+
CASE WHEN jsonb_typeof(f->'geometry') = 'object' THEN f->'geometry' END);
|
|
135
|
+
n_countries := n_countries + 1;
|
|
136
|
+
END LOOP;
|
|
137
|
+
|
|
138
|
+
FOR f IN SELECT * FROM jsonb_array_elements(feats) LOOP
|
|
139
|
+
PERFORM geo.upsert_region(
|
|
140
|
+
f->'properties'->>'country',
|
|
141
|
+
f->'properties'->>'slug',
|
|
142
|
+
f->'properties'->>'name',
|
|
143
|
+
(f->'properties'->>'type')::geo.region_type,
|
|
144
|
+
(f->'properties'->>'level')::int,
|
|
145
|
+
NULL,
|
|
146
|
+
f->'properties'->>'code',
|
|
147
|
+
ARRAY(SELECT jsonb_array_elements_text(coalesce(f->'properties'->'tags', '[]'::jsonb))),
|
|
148
|
+
coalesce(f->'properties'->'properties', '{}'::jsonb),
|
|
149
|
+
f->'geometry',
|
|
150
|
+
true,
|
|
151
|
+
pid);
|
|
152
|
+
n_regions := n_regions + 1;
|
|
153
|
+
END LOOP;
|
|
154
|
+
|
|
155
|
+
-- regions that left the pack
|
|
156
|
+
DELETE FROM geo.regions r
|
|
157
|
+
USING geo.countries c
|
|
158
|
+
WHERE r.pack_id = pid AND c.id = r.country_id
|
|
159
|
+
AND NOT EXISTS (
|
|
160
|
+
SELECT 1 FROM jsonb_array_elements(feats) ff
|
|
161
|
+
WHERE upper(ff->'properties'->>'country') = c.code AND ff->'properties'->>'slug' = r.slug);
|
|
162
|
+
|
|
163
|
+
-- parents by slug, now that every region of the pack exists. A parent may
|
|
164
|
+
-- live in another country (parent_country); it must belong to this same
|
|
165
|
+
-- pack, so a pack stays self-contained and never adopts a local region
|
|
166
|
+
-- that happens to share its parent's slug.
|
|
167
|
+
UPDATE geo.regions r
|
|
168
|
+
SET parent_id = par.id
|
|
169
|
+
FROM jsonb_array_elements(feats) ff
|
|
170
|
+
JOIN geo.countries c ON c.code = upper(ff->'properties'->>'country')
|
|
171
|
+
JOIN geo.countries pc ON pc.code = upper(coalesce(ff->'properties'->>'parent_country', ff->'properties'->>'country'))
|
|
172
|
+
JOIN geo.regions par ON par.country_id = pc.id AND par.slug = ff->'properties'->>'parent' AND par.pack_id = pid
|
|
173
|
+
WHERE r.pack_id = pid AND r.country_id = c.id AND r.slug = ff->'properties'->>'slug';
|
|
174
|
+
|
|
175
|
+
FOR code IN SELECT c.code FROM geo.countries c WHERE c.geom IS NULL LOOP
|
|
176
|
+
PERFORM geo.derive_country_geom(code);
|
|
177
|
+
END LOOP;
|
|
178
|
+
|
|
179
|
+
n_members := geo.rebuild_all_memberships();
|
|
180
|
+
PERFORM set_config('geo.skip_membership_triggers', 'off', true);
|
|
181
|
+
RETURN jsonb_build_object(
|
|
182
|
+
'pack', hdr->>'slug', 'version', hdr->>'version',
|
|
183
|
+
'countries', n_countries, 'regions', n_regions, 'memberships', n_members);
|
|
184
|
+
END $$;
|
|
185
|
+
|
|
186
|
+
CREATE OR REPLACE FUNCTION geo.uninstall_pack(p_slug text) RETURNS int
|
|
187
|
+
LANGUAGE plpgsql AS $$
|
|
188
|
+
DECLARE
|
|
189
|
+
pid int;
|
|
190
|
+
n int;
|
|
191
|
+
BEGIN
|
|
192
|
+
SELECT id INTO pid FROM geo.packs WHERE slug = p_slug;
|
|
193
|
+
IF pid IS NULL THEN
|
|
194
|
+
RAISE EXCEPTION 'Unknown pack "%"', p_slug USING ERRCODE = 'invalid_parameter_value';
|
|
195
|
+
END IF;
|
|
196
|
+
DELETE FROM geo.regions WHERE pack_id = pid; -- memberships cascade
|
|
197
|
+
GET DIAGNOSTICS n = ROW_COUNT;
|
|
198
|
+
DELETE FROM geo.packs WHERE id = pid;
|
|
199
|
+
RETURN n;
|
|
200
|
+
END $$;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
-- ============================================================================
|
|
2
|
+
-- 006 — pin search_path on every function and procedure in schema geo
|
|
3
|
+
--
|
|
4
|
+
-- The engine calls PostGIS unqualified. Hosts may install PostGIS outside
|
|
5
|
+
-- public (Supabase uses `extensions`) or connect with a search_path of their
|
|
6
|
+
-- own, so each function carries its own path: geo first, then the schema
|
|
7
|
+
-- that owns the postgis extension, then pg_temp. geom_expr fragments given to
|
|
8
|
+
-- register_source therefore must qualify any non-PostGIS function they call.
|
|
9
|
+
--
|
|
10
|
+
-- Later migrations that CREATE OR REPLACE a function must end with
|
|
11
|
+
-- SELECT geo.pin_search_path();
|
|
12
|
+
--
|
|
13
|
+
-- Pinning can break a geom_expr registered before this migration if it called a
|
|
14
|
+
-- host function unqualified, so the migration ends by re-probing every stored
|
|
15
|
+
-- geom_expr through geo.check_sources(): one WARNING per source that no longer
|
|
16
|
+
-- compiles, and the count of them as the result. It never fails the migration —
|
|
17
|
+
-- run `SELECT geo.check_sources();` again at any time to re-check.
|
|
18
|
+
-- ============================================================================
|
|
19
|
+
|
|
20
|
+
CREATE OR REPLACE FUNCTION geo.pin_search_path() RETURNS int
|
|
21
|
+
LANGUAGE plpgsql AS $$
|
|
22
|
+
DECLARE
|
|
23
|
+
postgis_schema text;
|
|
24
|
+
f record;
|
|
25
|
+
n int := 0;
|
|
26
|
+
BEGIN
|
|
27
|
+
SELECT ns.nspname INTO postgis_schema
|
|
28
|
+
FROM pg_extension e JOIN pg_namespace ns ON ns.oid = e.extnamespace
|
|
29
|
+
WHERE e.extname = 'postgis';
|
|
30
|
+
IF postgis_schema IS NULL THEN
|
|
31
|
+
RAISE EXCEPTION 'The postgis extension is not installed' USING ERRCODE = 'undefined_object';
|
|
32
|
+
END IF;
|
|
33
|
+
FOR f IN
|
|
34
|
+
SELECT p.oid::regprocedure AS sig, p.prokind AS kind
|
|
35
|
+
FROM pg_proc p JOIN pg_namespace ns ON ns.oid = p.pronamespace
|
|
36
|
+
WHERE ns.nspname = 'geo' AND p.prokind IN ('f', 'p')
|
|
37
|
+
LOOP
|
|
38
|
+
IF f.kind = 'p' THEN
|
|
39
|
+
EXECUTE format('ALTER PROCEDURE %s SET search_path = geo, %I, pg_temp', f.sig, postgis_schema);
|
|
40
|
+
ELSE
|
|
41
|
+
EXECUTE format('ALTER FUNCTION %s SET search_path = geo, %I, pg_temp', f.sig, postgis_schema);
|
|
42
|
+
END IF;
|
|
43
|
+
n := n + 1;
|
|
44
|
+
END LOOP;
|
|
45
|
+
RETURN n;
|
|
46
|
+
END $$;
|
|
47
|
+
|
|
48
|
+
-- Re-run register_source's probe against every stored geom_expr. Pinned like every
|
|
49
|
+
-- other geo function (pin_search_path() below covers it), so the probe compiles the
|
|
50
|
+
-- expression under exactly the search_path the membership triggers will use.
|
|
51
|
+
-- Returns the number of sources that failed; each one also raises a WARNING.
|
|
52
|
+
CREATE OR REPLACE FUNCTION geo.check_sources() RETURNS int
|
|
53
|
+
LANGUAGE plpgsql AS $$
|
|
54
|
+
DECLARE
|
|
55
|
+
s record;
|
|
56
|
+
failed int := 0;
|
|
57
|
+
BEGIN
|
|
58
|
+
FOR s IN SELECT name, table_schema, table_name, geom_expr FROM geo.sources ORDER BY id
|
|
59
|
+
LOOP
|
|
60
|
+
BEGIN
|
|
61
|
+
EXECUTE format('SELECT (%s)::geometry FROM %I.%I r LIMIT 0', s.geom_expr, s.table_schema, s.table_name);
|
|
62
|
+
EXCEPTION WHEN OTHERS THEN
|
|
63
|
+
failed := failed + 1;
|
|
64
|
+
RAISE WARNING 'geo: source "%" geom_expr no longer compiles under the pinned search_path (%); qualify non-PostGIS functions and re-register it', s.name, SQLERRM;
|
|
65
|
+
END;
|
|
66
|
+
END LOOP;
|
|
67
|
+
RETURN failed;
|
|
68
|
+
END $$;
|
|
69
|
+
|
|
70
|
+
SELECT geo.pin_search_path();
|
|
71
|
+
SELECT geo.check_sources();
|