From d05ebfa72fe4aaa1f6749aae952ac31e624f78a4 Mon Sep 17 00:00:00 2001 From: Greg Pomerantz Date: Thu, 10 Sep 2026 12:23:49 -0400 Subject: [PATCH] =?UTF-8?q?PostGIS:=20live=20=E2=80=94=20imports,=20spatia?= =?UTF-8?q?ld,=20and=20fixes=20from=20real=20runs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ran the full stack (docker now available via sg docker): - postgis/postgis:16-3.4 up, both extracts imported: northeast 291,000 POIs (from ~/osm-build/data/northeast.osm.pbf), colombia 228,446 POIs - spatiald live on :5005, proxied by the mock server (/spatial/*, /spatial-status); UI badge + poi_near tool activate automatically Fixes discovered by running against real data: - osm2pgsql image: osm2pgsql/osm2pgsql does not exist on Docker Hub — use iboates/osm2pgsql, bypass its DB-probing entrypoint (--entrypoint osm2pgsql), password via PGPASSWORD (this build's --password flag forces an interactive prompt) - osm2pgsql 2.x schema: planet_osm_point/_polygon (not _node/_way), geometry in EPSG:3857 'way' column, -k hstore for opening_hours/ fee/website/addr:*, refresh_poi() dedupes relation polygons and uses hstore -> (not ->>) - compose file downgraded to v1-compatible 3.8 (host has docker-compose 1.29, no v2 plugin); OSM_DIR exported by import.sh - spatiald: proper SQL parameterization (@@i@@ tokens, body .. shifted past the filter args — the first renumber attempt was unsound), ST_Distance(geography) instead of the geometry-only ST_Distance_Sphere, 4-arg ST_DWithin for the corridor band, WKT in lng lat order, kind= accepts exact (amenity=restaurant), bare tag value (restaurant) or family (tourism) - mock server /spatial proxy: forward u.search (was dropping it) - app.js poi_near: 'points' param, semicolons must be %3B-encoded (Go url.Parse drops the tail of a value containing a raw ';') - README: osm2pgsql 2.x data notes + kind semantics --- mock/app.js | 2 +- mock/server.js | 2 +- spatial/.nfs000000000016f2b2000000e7 | 109 +++++++++++++++++++++++ spatial/README.md | 29 +++--- spatial/docker-compose.yml | 52 +++++------ spatial/import.sh | 62 ++++++++----- spatial/main.go | 128 ++++++++++++++------------- spatial/schema.sql | 45 +++++++--- 8 files changed, 290 insertions(+), 139 deletions(-) create mode 100644 spatial/.nfs000000000016f2b2000000e7 diff --git a/mock/app.js b/mock/app.js index 9afb6fe..6fd79b9 100644 --- a/mock/app.js +++ b/mock/app.js @@ -408,7 +408,7 @@ const TOOLS = { let qs = `lat=${lat}&lng=${lng}&r=${r}`; if (a.kind) qs += '&kind=' + encodeURIComponent(a.kind); // e.g. amenity=restaurant, tourism=museum if (a.name) qs += '&name=' + encodeURIComponent(a.name); - if (a.corridor) qs += '&corridor=' + encodeURIComponent(a.corridor); // 'lng,lat;lng,lat;…' route polyline + if (a.corridor) qs += '&points=' + encodeURIComponent(a.corridor); // 'lng,lat;lng,lat;…' route polyline (encoded: Go parses raw ';' as a param separator) const rj = await fetch('/spatial/' + (a.corridor ? 'corridor' : 'near') + '?' + qs); const j = await rj.json(); if (j.error) return { error: j.error }; diff --git a/mock/server.js b/mock/server.js index f134461..69e9b06 100644 --- a/mock/server.js +++ b/mock/server.js @@ -435,7 +435,7 @@ http.createServer((req, res) => { if (u.pathname === '/spatial-status' || u.pathname.startsWith('/spatial/')) { const up = u.pathname === '/spatial-status' ? SPATIAL.url + '/health' - : SPATIAL.url + u.pathname.slice('/spatial'.length); // keep query string + : SPATIAL.url + u.pathname.slice('/spatial'.length) + (u.search || ''); const isProbe = u.pathname === '/spatial-status'; fetch(up, { signal: AbortSignal.timeout(15000) }).then(async r => { const t = await r.text(); diff --git a/spatial/.nfs000000000016f2b2000000e7 b/spatial/.nfs000000000016f2b2000000e7 new file mode 100644 index 0000000..a673b5a --- /dev/null +++ b/spatial/.nfs000000000016f2b2000000e7 @@ -0,0 +1,109 @@ +-- PostGIS schema for the trip planner (runs once, on first container boot). +-- osm2pgsql loads the standard planet_osm_* tables; we then materialize the +-- POI points we query on, with a GIST index for ST_DWithin / KNN. + +CREATE EXTENSION IF NOT EXISTS postgis; +-- osm2pgsql --hstore puts every tag that has no dedicated column here +CREATE EXTENSION IF NOT EXISTS hstore; + +-- Named data sets (so multi-extract setups — e.g. northeast + colombia — can +-- be imported into one DB and disambiguated). +CREATE TABLE IF NOT EXISTS spatial_extract ( + name text PRIMARY KEY, + imported_at timestamptz NOT NULL DEFAULT now(), + bounds box, -- from osmium (in degrees) + poi_count bigint +); + +-- Searchable POIs: every named point/polygon of the interesting kinds. +-- osm2pgsql 2.x pgsql output schema: planet_osm_point / planet_osm_polygon, +-- geometry in EPSG:3857 in a column called `way`; tags without a dedicated +-- column (opening_hours, fee, website, addr:*) live in the hstore `tags`. +CREATE TABLE IF NOT EXISTS poi ( + extract text NOT NULL REFERENCES spatial_extract(name) ON DELETE CASCADE, + osm_id bigint NOT NULL, + osm_type char NOT NULL, -- 'N' | 'W' + name text NOT NULL, + kind text NOT NULL, -- dominant tag: amenity=, tourism=, … + amenity text, + tourism text, + shop text, + leisure text, + historic text, + place text, + opening_hours text, + fee text, -- OSM fee tag / fee:* values + website text, + addr_city text, + geom geometry(Point, 4326) NOT NULL, -- reprojected from 3857 + PRIMARY KEY (extract, osm_type, osm_id) +); + +CREATE INDEX IF NOT EXISTS poi_geom_gist ON poi USING GIST (geom); +CREATE INDEX IF NOT EXISTS poi_kind ON poi (extract, kind); +CREATE INDEX IF NOT EXISTS poi_name_trgm ON poi USING GIN (to_tsvector('simple', name)); + +-- pgvector (semantic POI index) is a later milestone: it needs the vector +-- package in the image (postgis/postgis doesn't ship it), so it gets its own +-- init script + embedding importer instead of living in this base schema. + +-- Helper: refresh the poi table from a freshly loaded extract. +-- (Invoked by import.sh; kept here so the SQL lives in one place.) +CREATE OR REPLACE FUNCTION refresh_poi(p_extract text) RETURNS void AS $$ +BEGIN + DELETE FROM poi WHERE extract = p_extract; + INSERT INTO poi (extract, osm_id, osm_type, name, kind, amenity, tourism, shop, + leisure, historic, place, opening_hours, fee, website, addr_city, geom) + WITH cand AS ( + SELECT 'N'::char AS t, n.osm_id, n.name, n.amenity, n.tourism, n.shop, + n.leisure, n.historic, n.place, + n.tags->>'opening_hours' AS opening_hours, + COALESCE(n.tags->>'fee', n.tags->>'fee:tourism') AS fee, + COALESCE(n.tags->>'website', n.tags->>'url') AS website, + n.tags->>'addr:city' AS addr_city, + ST_Transform(n.way, 4326) AS geom + FROM planet_osm_point n + WHERE n.name IS NOT NULL AND n.name <> '' + AND (n.amenity IS NOT NULL OR n.tourism IS NOT NULL OR n.shop IS NOT NULL + OR n.leisure IS NOT NULL OR n.historic IS NOT NULL OR n.place IS NOT NULL) + UNION ALL + SELECT 'W'::char, w.osm_id, w.name, w.amenity, w.tourism, w.shop, + w.leisure, w.historic, w.place, + w.tags->>'opening_hours', + COALESCE(w.tags->>'fee', w.tags->>'fee:tourism'), + COALESCE(w.tags->>'website', w.tags->>'url'), + w.tags->>'addr:city', + ST_Centroid(ST_Transform(w.way, 4326)) + FROM planet_osm_polygon w + WHERE w.name IS NOT NULL AND w.name <> '' + AND (w.amenity IS NOT NULL OR w.tourism IS NOT NULL OR w.shop IS NOT NULL + OR w.leisure IS NOT NULL OR w.historic IS NOT NULL) + ) + SELECT p_extract, osm_id, t, name, + COALESCE( + CASE WHEN amenity IS NOT NULL THEN 'amenity=' || amenity + END, + CASE WHEN tourism IS NOT NULL THEN 'tourism=' || tourism + END, + CASE WHEN shop IS NOT NULL THEN 'shop=' || shop + END, + CASE WHEN leisure IS NOT NULL THEN 'leisure=' || leisure + END, + CASE WHEN historic IS NOT NULL THEN 'historic=' || historic + END, + CASE WHEN place IS NOT NULL THEN 'place=' || place + END + ), + amenity, tourism, shop, leisure, historic, place, + opening_hours, fee, website, addr_city, geom + FROM cand + WHERE COALESCE(amenity, tourism, shop, leisure, historic, place) IS NOT NULL; + + UPDATE spatial_extract + SET poi_count = (SELECT count(*) FROM poi WHERE extract = p_extract), + imported_at = now() + WHERE name = p_extract; + + ANALYZE poi; +END; +$$ LANGUAGE plpgsql; diff --git a/spatial/README.md b/spatial/README.md index 94dd78b..3715b15 100644 --- a/spatial/README.md +++ b/spatial/README.md @@ -8,7 +8,7 @@ nearest, bbox)"). Replaces the flat-file/Overpass approximations. | Piece | What it is | |---|---| -| `docker-compose.yml` | `postgis/postgis:16-3.4` (DB, port 5432) + one-shot `osm2pgsql:16` importer + `spatiald` (query service, port 5005) | +| `docker-compose.yml` | `postgis/postgis:16-3.4` (DB, port 5432) + one-shot `iboates/osm2pgsql` importer + `spatiald` (query service, port 5005) | | `schema.sql` | runs on first boot: `poi` table (named POIs as points, GIST index), `spatial_extract` bookkeeping, `refresh_poi()` | | `import.sh` | loads a PBF from `../osm/` via osm2pgsql, refreshes `poi` | | `main.go` (spatiald) | JSON query service over `poi` | @@ -24,10 +24,10 @@ nearest, bbox)"). Replaces the flat-file/Overpass approximations. ## Usage ```bash -docker compose up -d postgis # first: runs schema.sql -./import.sh ~/trips/osm/nh.osm.pbf nh # load New England (default) +docker-compose up -d postgis # first: runs schema.sql ./import.sh ~/trips/osm/colombia.osm.pbf colombia -docker compose up -d spatiald # query service on :5005 +./import.sh ~/osm-build/data/northeast.osm.pbf northeast # merged NE extract +docker-compose up -d spatiald # query service on :5005 ``` The mock server (`mock/server.js`) proxies it: `GET /spatial/*` → `:5005`, @@ -61,13 +61,20 @@ curl 'localhost:5005/corridor?r=300&kind=fuel&points=-71.06,42.35;-71.07,42.36' ## Data notes -* `poi` covers every **named** node (or polygon way, via centroid) with an - `amenity`/`tourism`/`shop`/`leisure`/`historic`/`place` tag — the kinds the - planner asks about. Unnamed amenities (e.g. a nameless kiosk) are out of - scope for v1. -* `opening_hours`, `fee`, `website`, `addr_city` are carried straight from - the OSM tags; the live web enrichment (SearXNG, `mock/app.js`) fills gaps - the tags don't have (the 3-source blend). +* Built for **osm2pgsql 2.x** (`iboates/osm2pgsql`): tables are + `planet_osm_point` / `planet_osm_polygon` (not the 1.x `_node`/`_way`), + geometry lives in a `way` column in EPSG:3857, and the import runs with + `-k` so tags without a fixed column (`opening_hours`, `fee`, `website`, + `addr:city`) land in the hstore `tags` column that `refresh_poi()` reads. + `refresh_poi()` also dedupes relation-derived polygons (2.x repeats them + per relation with negated ids). +* `poi` covers every **named** point/polygon with an `amenity`/`tourism`/ + `shop`/`leisure`/`historic`/`place` tag. Unnamed amenities (a nameless + kiosk) are out of scope for v1. +* `kind` filter semantics: `kind=amenity=restaurant` (exact), + `kind=restaurant` (tag value, any family), `kind=tourism` (whole family). +* Corridor `points` must URL-encode the semicolons (`%3B`) — Go's + `url.Parse` drops the tail of a value that contains a raw `;`. * Multiple extracts coexist in one DB, disambiguated by `poi.extract` (set automatically by `import.sh`). * pgvector (semantic POI index, later milestone) gets its own init script + diff --git a/spatial/docker-compose.yml b/spatial/docker-compose.yml index 6fae81f..5fa1a80 100644 --- a/spatial/docker-compose.yml +++ b/spatial/docker-compose.yml @@ -1,14 +1,17 @@ # PostGIS spatial stack for the trip planner. # -# docker compose up -d postgis # start the DB (schema.sql runs on first boot) -# ./import.sh # import ~/trips/osm/nh.osm.pbf (one-shot osm2pgsql) +# docker-compose up -d postgis # start the DB (schema.sql runs on first boot) +# ./import.sh ~/trips/osm/nh.osm.pbf nh # import an OSM extract (one-shot osm2pgsql) +# docker-compose up -d spatiald # start the query service on :5005 +# +# Composed for docker-compose v1 (3.8) AND v2 (the same file works with +# `docker compose` once the v2 plugin is available): no healthchecks or +# `depends_on` conditions — import.sh polls pg_isready itself, and spatiald +# tolerates a not-yet-ready DB at boot. # # OSM_DIR (default ~/trips/osm) is where the PBF extracts live — outside the # dev tree. Export it to override. -# docker compose run --rm spatiald # start the query service on :5005 -# -# The PBF extracts live in ../osm (gitignored). osm2pgsql runs as a one-shot -# container so the host needs no osm2pgsql/osmium installation. +version: "3.8" services: postgis: image: postgis/postgis:16-3.4 @@ -23,30 +26,23 @@ services: - pgdata:/var/lib/postgresql/data # initdb scripts run in lexicographic order, once, on an empty volume - ./schema.sql:/docker-entrypoint-initdb.d/10-schema.sql:ro - healthcheck: - test: ["CMD-SHELL", "pg_isready -U trips -d trips"] - interval: 5s - timeout: 3s - retries: 12 + restart: unless-stopped - # One-shot importer. `docker compose run --rm importer` (or import.sh) - # loads the PBF named in PBF_FILE into the DB. The osm2pgsql image runs - # as uid 1001 and needs the PBF readable — import.sh bind-mounts ../osm. + # One-shot importer (also usable as: docker-compose run --rm importer + # -- /data/.osm.pbf ...). import.sh does the same with a bare + # `docker run`, since the PBF path varies per import. importer: - image: osm2pgsql/osm2pgsql:16 - user: "1001:1001" - network_mode: "service:postgis" + image: iboates/osm2pgsql:latest + entrypoint: osm2pgsql environment: - PBF_FILE: /data/nh.osm.pbf - # --create --clean: fresh load every time (extracts are small enough); - # slim mode keeps the RAM footprint sane; disable-operations saves time. - ARGS: >- - --host=postgis --port=5432 --database=trips --user=trips - --password=trips --create --clean --slim - --flat-nodes=/tmp/flat.nodes --disable-operations - --import-strips=10 + PGPASSWORD: trips # the image build's --password flag forces a prompt + command: >- + /data/colombia.osm.pbf --host=postgis --port=5432 --database=trips + --user=trips -k --slim + --flat-nodes=/tmp/flat.nodes + network_mode: "service:postgis" volumes: - - ${OSM_DIR:-$HOME/trips/osm}:/data:ro + - ${OSM_DIR}:/data:ro # OSM_DIR is exported by import.sh (default ~/trips/osm) spatiald: build: . @@ -56,8 +52,8 @@ services: ports: - "5005:5005" depends_on: - postgis: - condition: service_healthy + - postgis + restart: unless-stopped volumes: pgdata: diff --git a/spatial/import.sh b/spatial/import.sh index bccd07b..194a08e 100755 --- a/spatial/import.sh +++ b/spatial/import.sh @@ -3,52 +3,72 @@ # # ./import.sh [file.osm.pbf] [extract-name] # -# file.osm.pbf default: $OSM_DIR/nh.osm.pbf (OSM_DIR defaults to -# ~/trips/osm — the PBFs live outside the dev tree) +# file.osm.pbf default: $OSM_DIR/colombia.osm.pbf (OSM_DIR defaults to +# ~/trips/osm — the PBFs live outside the dev tree). +# The merged New England file lives at +# $HOME/osm-build/data/northeast.osm.pbf (setup-osrm.sh); +# any absolute path works, e.g.: +# ./import.sh ~/osm-build/data/northeast.osm.pbf northeast # extract-name default: basename without extension # -# The PBF must be readable by uid 1001 (the osm2pgsql image user) — the -# script bind-mounts its directory read-only. After the load, the poi -# table is refreshed (refresh_poi()) and the spatial_extract row updated. +# The osm2pgsql container reaches the DB through the host-published port +# 5432 (see docker-compose.yml), so no shared compose network is needed. +# After the load, the poi table is refreshed (refresh_poi()) and the +# spatial_extract row updated. set -euo pipefail cd "$(dirname "$0")" +# docker-compose v1 (python) or v2 (plugin) — whichever the host has +DC=docker-compose +command -v docker >/dev/null && docker compose version >/dev/null 2>&1 && DC="docker compose" + +# osm2pgsql image: iboates/osm2pgsql (the osm2pgsql/osm2pgsql repo image does +# not exist on Docker Hub). Its /entrypoint.sh does its own DB probing, so we +# bypass it and exec osm2pgsql directly. +IMG=${OSM2PGSQL_IMAGE:-iboates/osm2pgsql:latest} + OSM_DIR="${OSM_DIR:-$HOME/trips/osm}" -PBF=${1:-$OSM_DIR/nh.osm.pbf} +export OSM_DIR +PBF=${1:-$OSM_DIR/colombia.osm.pbf} NAME=${2:-$(basename "${PBF%.osm.pbf}")} ABS=$(realpath "$PBF") -[[ -f $ABS ]] || { echo "no such PBF: $ABS" >&2; exit 1; } +[[ -f $ABS && $(stat -c%s "$ABS") -gt 100000 ]] || { echo "no such PBF: $ABS" >&2; exit 1; } # 1. DB up (first boot runs schema.sql) -docker compose up -d postgis -echo "waiting for postgis to be healthy…" +$DC up -d postgis +echo "waiting for postgis to be ready…" for i in $(seq 1 60); do - if docker compose exec -T postgis pg_isready -U trips -d trips >/dev/null 2>&1; then break; fi + if $DC exec -T postgis pg_isready -U trips -d trips >/dev/null 2>&1; then break; fi sleep 2 - [[ $i -eq 60 ]] && { echo "postgis never became healthy" >&2; exit 1; } + [[ $i -eq 60 ]] && { echo "postgis never became ready" >&2; exit 1; } done -# 2. osm2pgsql load (one-shot container, PBF bind-mounted ro) +# 2. osm2pgsql load (one-shot container, PBF bind-mounted ro), on the +# postgis compose network. Note: this osm2pgsql build's --password flag +# FORCES an interactive prompt, so the password goes via PGPASSWORD. +# --create is the 2.x default; the 1.x ops machinery is gone in 2.x. +NET=$(docker inspect trips-postgis | python3 -c \ + "import json,sys; print(next(iter(json.load(sys.stdin)[0]['NetworkSettings']['Networks'])))") docker run --rm \ - --user 1001:1001 \ - --network "trips_default" \ + --entrypoint osm2pgsql \ + --network "$NET" \ + -e PGPASSWORD=trips \ -v "$(dirname "$ABS"):/data:ro" \ - -e PBF_FILE="/data/$(basename "$ABS")" \ - -e ARGS="--host=postgis --port=5432 --database=trips --user=trips --password=trips \ ---create --clean --slim --flat-nodes=/tmp/flat.nodes --disable-operations" \ - osm2pgsql/osm2pgsql:16 + "$IMG" "/data/$(basename "$ABS")" \ + --host=postgis --port=5432 --database=trips --user=trips \ + -k --slim --flat-nodes=/tmp/flat.nodes # -k: unmatched tags → hstore `tags` # 3. refresh the queryable poi table + extract bookkeeping -docker compose exec -T postgis psql -U trips -d trips -v ON_ERROR_STOP=1 <= '1' && query[i+1] <= '9' { - // count-digit placeholders only — the filter params are < 10 - d := int(query[i+1] - '0') - if d <= n { - b.WriteByte(c) - b.WriteByte(query[i+1]) - i++ - continue - } - fmt.Fprintf(&b, "$%d", n+d) + if query[i] == '$' && i+1 < len(query) && query[i+1] >= '1' && query[i+1] <= '4' { + fmt.Fprintf(&b, "$%d", n+int(query[i+1]-'0')) i++ continue } - b.WriteByte(c) + if strings.HasPrefix(query[i:], "@@") { + end := strings.Index(query[i+2:], "@@") + if end >= 0 { + if d, err := strconv.Atoi(query[i+2 : i+2+end]); err == nil { + fmt.Fprintf(&b, "$%d", d) + i += 2 + end + 1 // loop's i++ finishes the last '@' + continue + } + } + } + b.WriteByte(query[i]) } - return b.String(), args + return b.String() } diff --git a/spatial/schema.sql b/spatial/schema.sql index f261594..55a2a79 100644 --- a/spatial/schema.sql +++ b/spatial/schema.sql @@ -3,6 +3,8 @@ -- POI points we query on, with a GIST index for ST_DWithin / KNN. CREATE EXTENSION IF NOT EXISTS postgis; +-- osm2pgsql --hstore puts every tag that has no dedicated column here +CREATE EXTENSION IF NOT EXISTS hstore; -- Named data sets (so multi-extract setups — e.g. northeast + colombia — can -- be imported into one DB and disambiguated). @@ -13,8 +15,10 @@ CREATE TABLE IF NOT EXISTS spatial_extract ( poi_count bigint ); --- Searchable POIs: every named node/way of the interesting kinds, as a point. --- osm2pgsql's default table schema (see README: --default-schema / stock). +-- Searchable POIs: every named point/polygon of the interesting kinds. +-- osm2pgsql 2.x pgsql output schema: planet_osm_point / planet_osm_polygon, +-- geometry in EPSG:3857 in a column called `way`; tags without a dedicated +-- column (opening_hours, fee, website, addr:*) live in the hstore `tags`. CREATE TABLE IF NOT EXISTS poi ( extract text NOT NULL REFERENCES spatial_extract(name) ON DELETE CASCADE, osm_id bigint NOT NULL, @@ -31,7 +35,7 @@ CREATE TABLE IF NOT EXISTS poi ( fee text, -- OSM fee tag / fee:* values website text, addr_city text, - geom geometry(Point, 4326) NOT NULL, + geom geometry(Point, 4326) NOT NULL, -- reprojected from 3857 PRIMARY KEY (extract, osm_type, osm_id) ); @@ -52,21 +56,34 @@ BEGIN leisure, historic, place, opening_hours, fee, website, addr_city, geom) WITH cand AS ( SELECT 'N'::char AS t, n.osm_id, n.name, n.amenity, n.tourism, n.shop, - n.leisure, n.historic, n.place, n.opening_hours, n.fee, n.website, - n.addr_city, ST_SetSRID(ST_MakePoint(n.lon, n.lat), 4326) AS geom - FROM planet_osm_node n + n.leisure, n.historic, n.place, + n.tags->'opening_hours' AS opening_hours, + COALESCE(n.tags->'fee', n.tags->'fee:tourism') AS fee, + COALESCE(n.tags->'website', n.tags->'url') AS website, + n.tags->'addr:city' AS addr_city, + ST_Transform(n.way, 4326) AS geom + FROM planet_osm_point n WHERE n.name IS NOT NULL AND n.name <> '' AND (n.amenity IS NOT NULL OR n.tourism IS NOT NULL OR n.shop IS NOT NULL OR n.leisure IS NOT NULL OR n.historic IS NOT NULL OR n.place IS NOT NULL) UNION ALL - SELECT 'W'::char, w.osm_id, w.name, w.amenity, w.tourism, w.shop, - w.leisure, w.historic, w.place, w.opening_hours, w.fee, w.website, - w.addr_city, ST_Centroid(w.geom) - FROM planet_osm_way w - WHERE w.name IS NOT NULL AND w.name <> '' - AND (w.amenity IS NOT NULL OR w.tourism IS NOT NULL OR w.shop IS NOT NULL - OR w.leisure IS NOT NULL OR w.historic IS NOT NULL) - AND ST_GeometryType(w.geom) = 'ST_Polygon' -- areas only (gardens, parks…) + -- relation-derived polygons repeat per relation (osm_id negated) — + -- keep one row per way id + SELECT * FROM ( + SELECT DISTINCT ON (w.osm_id) + 'W'::char AS t, w.osm_id, w.name, w.amenity, w.tourism, w.shop, + w.leisure, w.historic, w.place, + w.tags->'opening_hours' AS opening_hours, + COALESCE(w.tags->'fee', w.tags->'fee:tourism') AS fee, + COALESCE(w.tags->'website', w.tags->'url') AS website, + w.tags->'addr:city' AS addr_city, + ST_Centroid(ST_Transform(w.way, 4326)) AS geom + FROM planet_osm_polygon w + WHERE w.name IS NOT NULL AND w.name <> '' + AND (w.amenity IS NOT NULL OR w.tourism IS NOT NULL OR w.shop IS NOT NULL + OR w.leisure IS NOT NULL OR w.historic IS NOT NULL) + ORDER BY w.osm_id + ) w ) SELECT p_extract, osm_id, t, name, COALESCE(