3.6 KiB
| 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
-- Контекст: что делает файл, зачем, порядок применения, dependencies.
BEGIN;
-- DDL здесь (idempotent)
COMMIT;
Idempotency (обязательно)
CREATE TABLE IF NOT EXISTSALTER TABLE ... ADD COLUMN IF NOT EXISTSALTER TABLE ... DROP COLUMN IF EXISTSCREATE OR REPLACE VIEWCREATE INDEX IF NOT EXISTSON CONFLICT DO NOTHINGдля seed dataDROP 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
- SQL migration first (схема) — deploy first
- Backend code matching new schema — deploy second
- Никогда наоборот — 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'а:
- Найти зависимые view:
\d+ table_nameилиpg_get_viewdef('view_name'::regclass, true) DROP VIEW <dependent>;ALTER COLUMN ...;CREATE OR REPLACE VIEW <dependent> 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:
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 без комментария почему