---
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 без комментария почему
