fifteen/infra/schema.sql

169 lines
7.3 KiB
SQL

-- Enable PostGIS
CREATE EXTENSION IF NOT EXISTS postgis;
CREATE EXTENSION IF NOT EXISTS postgis_topology;
CREATE EXTENSION IF NOT EXISTS pg_trgm;
-- ─── Cities ──────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS cities (
slug TEXT PRIMARY KEY,
name TEXT NOT NULL,
country_code CHAR(2) NOT NULL DEFAULT '',
geofabrik_url TEXT NOT NULL,
bbox geometry(Polygon, 4326),
resolution_m INTEGER NOT NULL DEFAULT 200,
status TEXT NOT NULL DEFAULT 'empty'
CHECK (status IN ('empty','pending','processing','ready','error')),
error_message TEXT,
last_ingested TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Migration for existing databases
ALTER TABLE cities ADD COLUMN IF NOT EXISTS resolution_m INTEGER NOT NULL DEFAULT 200;
ALTER TABLE cities ADD COLUMN IF NOT EXISTS boundary geometry(MultiPolygon, 4326);
CREATE INDEX IF NOT EXISTS idx_cities_bbox ON cities USING GIST (bbox);
CREATE INDEX IF NOT EXISTS idx_cities_boundary ON cities USING GIST (boundary);
-- ─── Raw POIs (created and managed by osm2pgsql flex output) ─────────────────
-- osm2pgsql --drop recreates this table on each ingest using the Lua script.
-- Columns: osm_id (bigint), osm_type (char), city_slug, category, subcategory,
-- name, tags, geom — no auto-generated id column.
-- This CREATE TABLE IF NOT EXISTS is a no-op after the first osm2pgsql run.
CREATE TABLE IF NOT EXISTS raw_pois (
osm_id BIGINT NOT NULL,
osm_type CHAR(1) NOT NULL,
city_slug TEXT NOT NULL,
category TEXT NOT NULL,
subcategory TEXT NOT NULL,
name TEXT,
tags JSONB,
geom geometry(Point, 4326) NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_raw_pois_city_cat
ON raw_pois (city_slug, category);
CREATE INDEX IF NOT EXISTS idx_raw_pois_geom
ON raw_pois USING GIST (geom);
CREATE INDEX IF NOT EXISTS idx_raw_pois_name
ON raw_pois USING GIN (name gin_trgm_ops)
WHERE name IS NOT NULL;
-- ─── Grid points ─────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS grid_points (
id BIGSERIAL PRIMARY KEY,
city_slug TEXT NOT NULL REFERENCES cities(slug) ON DELETE CASCADE,
geom geometry(Point, 4326) NOT NULL,
grid_x INTEGER NOT NULL,
grid_y INTEGER NOT NULL,
hidden_gem_score FLOAT4,
UNIQUE (city_slug, grid_x, grid_y)
);
-- Migration for existing databases
ALTER TABLE grid_points ADD COLUMN IF NOT EXISTS hidden_gem_score FLOAT4;
CREATE INDEX IF NOT EXISTS idx_grid_city ON grid_points (city_slug);
CREATE INDEX IF NOT EXISTS idx_grid_geom ON grid_points USING GIST (geom);
CREATE INDEX IF NOT EXISTS idx_grid_hidden_gem
ON grid_points (city_slug, hidden_gem_score)
WHERE hidden_gem_score IS NOT NULL;
-- ─── Pre-computed accessibility scores ───────────────────────────────────────
CREATE TABLE IF NOT EXISTS grid_scores (
grid_point_id BIGINT NOT NULL REFERENCES grid_points(id) ON DELETE CASCADE,
category TEXT NOT NULL,
travel_mode TEXT NOT NULL CHECK (travel_mode IN ('walking','cycling','driving','transit','fifteen')),
threshold_min INTEGER NOT NULL,
profile TEXT NOT NULL DEFAULT 'universal',
nearest_poi_id BIGINT,
distance_m FLOAT,
travel_time_s FLOAT,
score FLOAT NOT NULL CHECK (score >= 0 AND score <= 1),
computed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (grid_point_id, category, travel_mode, threshold_min, profile)
);
-- Migration for existing databases (adds profile column and rebuilds PK)
ALTER TABLE grid_scores ADD COLUMN IF NOT EXISTS profile TEXT NOT NULL DEFAULT 'universal';
CREATE INDEX IF NOT EXISTS idx_grid_scores_lookup
ON grid_scores (grid_point_id, travel_mode, threshold_min, profile);
-- ─── Nearest POI per subcategory per grid point ───────────────────────────────
-- Populated by compute-scores job. Stores the nearest (by routing time) POI for
-- each subcategory at each grid point, for each travel mode. Threshold-independent.
CREATE TABLE IF NOT EXISTS grid_poi_details (
grid_point_id BIGINT NOT NULL REFERENCES grid_points(id) ON DELETE CASCADE,
category TEXT NOT NULL,
subcategory TEXT NOT NULL,
travel_mode TEXT NOT NULL,
nearest_poi_id BIGINT,
nearest_poi_name TEXT,
distance_m FLOAT,
travel_time_s FLOAT,
computed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (grid_point_id, category, subcategory, travel_mode)
);
CREATE INDEX IF NOT EXISTS idx_grid_poi_details_lookup
ON grid_poi_details (grid_point_id, travel_mode);
-- ─── Isochrone cache ──────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS isochrone_cache (
id BIGSERIAL PRIMARY KEY,
origin_geom geometry(Point, 4326) NOT NULL,
travel_mode TEXT NOT NULL,
contours_min INTEGER[] NOT NULL,
result JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_isochrone_origin
ON isochrone_cache USING GIST (origin_geom);
CREATE INDEX IF NOT EXISTS idx_isochrone_created
ON isochrone_cache (created_at);
-- Auto-expire isochrone cache entries older than 30 days
-- (handled by periodic cleanup or TTL logic in app)
-- ─── Estate value zones ───────────────────────────────────────────────────────
-- Populated by datasource-specific worker jobs (currently: ingest-boris-ni).
-- Only present for cities whose bbox intersects a supported region.
-- Migration: rename if upgrading from the previous schema version
DO $$ BEGIN
IF EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'boris_zones')
AND NOT EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'estate_value_zones')
THEN ALTER TABLE boris_zones RENAME TO estate_value_zones; END IF;
END $$;
CREATE TABLE IF NOT EXISTS estate_value_zones (
id BIGSERIAL PRIMARY KEY,
city_slug TEXT NOT NULL REFERENCES cities(slug) ON DELETE CASCADE,
geom geometry(GEOMETRY, 4326) NOT NULL,
value_eur_m2 NUMERIC,
zone_name TEXT,
usage_type TEXT,
usage_detail TEXT,
dev_state TEXT,
stichtag TEXT,
source TEXT NOT NULL DEFAULT 'boris-ni',
year SMALLINT,
ingested_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Migration for existing databases
ALTER TABLE estate_value_zones ADD COLUMN IF NOT EXISTS year SMALLINT;
CREATE INDEX IF NOT EXISTS idx_estate_value_zones_city ON estate_value_zones (city_slug);
CREATE INDEX IF NOT EXISTS idx_estate_value_zones_geom ON estate_value_zones USING GIST (geom);
CREATE INDEX IF NOT EXISTS idx_estate_value_zones_year
ON estate_value_zones (city_slug, year)
WHERE year IS NOT NULL;