--- paths: data/sql/**/*.sql --- # SQL conventions — PostgreSQL 16 / PostGIS 3.4 ## File naming `NN_topic.sql` где NN — следующий sequential номер. Проверка: `ls data/sql/ | sort | tail -5`. ## Structure ```sql -- Контекст: что делает файл, зачем, порядок применения, dependencies. BEGIN; -- DDL здесь (idempotent) COMMIT; ``` ## Idempotency (обязательно) - `CREATE TABLE IF NOT EXISTS` - `ALTER TABLE ... ADD COLUMN IF NOT EXISTS` - `ALTER TABLE ... DROP COLUMN IF EXISTS` - `CREATE OR REPLACE VIEW` - `CREATE INDEX IF NOT EXISTS` - `ON CONFLICT DO NOTHING` для seed data - `DROP INDEX IF EXISTS` перед `CREATE INDEX` если меняется тип column ## Auto-apply на prod `data/sql/NN_*.sql` применяются **автоматически** через `deploy.yml` (tracking через `_schema_migrations`). Каждый файл — ровно один раз, по NN order. Idempotency критична. Если auto-apply падает → deploy exit 1, containers не обновляются. Diagnose: GHA log → "Apply DB migrations". Fix: `psql -f file.sql` через SSH tunnel + `INSERT INTO _schema_migrations (filename) VALUES ('NN_xxx.sql') ON CONFLICT DO NOTHING`. ## Migration order 1. **SQL migration first** (схема) — deploy first 2. **Backend code matching new schema** — deploy second 3. Никогда наоборот — deployed code напорется на несовместимую схему ## psycopg v3 CAST trap В Python через psycopg v3 / SQLAlchemy `text(...)`: - ❌ `:param::type` — игнорируется парсером - ✅ `CAST(:param AS type)` — canonical В чистом SQL (`.sql` файл, без bind params) — обычный `::type` работает. Reference: vault `Pattern_CAST_AS_Type`. ## VIEW dependencies Перед `ALTER COLUMN type` column'а: 1. Найти зависимые view: `\d+ table_name` или `pg_get_viewdef('view_name'::regclass, true)` 2. `DROP VIEW ;` 3. `ALTER COLUMN ...;` 4. `CREATE OR REPLACE VIEW AS SELECT ...;` Reference: `93_cad_parcels_geom_multipolygon.sql` (Polygon → MultiPolygon migration). ## Агрегация по pre-aggregated строкам (обязательно weighted AVG) Если источник содержит строки вида «одна строка = один период (месяц) + уже посчитанный `avg_value` + `count`» (например `objective_corpus_room_month`), то наивный `AVG(avg_value)` **неверен**: строки с нулевыми сделками занижают результат в 2-10x. Правильная формула — count-weighted AVG: ```sql SUM(avg_value * cnt) / NULLIF(SUM(cnt), 0) ``` - `NULLIF(..., 0)` обязателен — предотвращает `division by zero` при all-zero периодах и возвращает `NULL` вместо фейкового `0`. - Без весов: `AVG()` равноправно учитывает «пустые» месяцы → занижение. Reference: fix #295 (`100_fix_mv_layout_velocity_weighted_avg.sql`), тест `backend/tests/sql/test_mv_layout_velocity_weighted_avg.py`. ## Запреты - ❌ `DROP TABLE` / `TRUNCATE` без явного approval пользователя - ❌ Не-idempotent файлы (без `IF EXISTS`) — на случай ре-apply - ❌ Миграции без `BEGIN...COMMIT` обёртки - ❌ Hardcoded даты / IDs без комментария почему