89 lines
3.6 KiB
Markdown
89 lines
3.6 KiB
Markdown
---
|
||
paths:
|
||
- data/sql/**/*.sql
|
||
- tradein-mvp/backend/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 <dependent>;`
|
||
3. `ALTER COLUMN ...;`
|
||
4. `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:
|
||
```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 без комментария почему
|