@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,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();