feat: scripts/check_dbml_vs_orm.py — CLI сверки ORM с DBML (версия, таблицы, колонки, типы, FK, индексы, enum'ы)
Ядро сверки перенесено из tests/dbml_parity.py в scripts/dbml_parity.py (единственная реализация, R-14); tests/dbml_parity.py и dbml_helpers.py — тонкие обёртки с пропуском теста без docs. Единый формат строк: `<объект>: ожидалось X / фактически Y`, `<объект>: отсутствует в ORM|DBML`; тип и nullability — одной строкой в нотации DBML. Версия из шапки DBML сверяется с DBML_VERSION до сравнения таблиц. Коды: 0/1/2. Без рабочего .env скрипт берёт api/.env.example. Makefile: dbml-check (в check-full), mypy проверяет scripts. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
218ce88a31
commit
e064830702
12
Makefile
12
Makefile
@ -2,8 +2,10 @@
|
|||||||
VENV := api/.venv
|
VENV := api/.venv
|
||||||
BIN := $(CURDIR)/$(VENV)/bin
|
BIN := $(CURDIR)/$(VENV)/bin
|
||||||
COMPOSE_FILE ?= ../docs/dev/docker-compose.yml
|
COMPOSE_FILE ?= ../docs/dev/docker-compose.yml
|
||||||
|
# DBML относительно api/: и в основном workspace, и в worktree .wt/<срез>/ это соседний docs/
|
||||||
|
DBML ?= ../../docs/model/data-model-v5.13.dbml
|
||||||
|
|
||||||
.PHONY: venv lint test test-integration check check-full up migrate seed seed-dev
|
.PHONY: venv lint test test-integration dbml-check check check-full up migrate seed seed-dev
|
||||||
|
|
||||||
# Python 3.11 (версия платформы); внутренние пакеты raisa_* вне банка недоступны — пропускаем их
|
# Python 3.11 (версия платформы); внутренние пакеты raisa_* вне банка недоступны — пропускаем их
|
||||||
venv:
|
venv:
|
||||||
@ -14,7 +16,7 @@ venv:
|
|||||||
lint:
|
lint:
|
||||||
cd api && $(BIN)/ruff check .
|
cd api && $(BIN)/ruff check .
|
||||||
cd api && $(BIN)/ruff format --check .
|
cd api && $(BIN)/ruff format --check .
|
||||||
cd api && $(BIN)/mypy src
|
cd api && $(BIN)/mypy src scripts
|
||||||
|
|
||||||
test:
|
test:
|
||||||
cd api && $(BIN)/pytest -m "not integration"
|
cd api && $(BIN)/pytest -m "not integration"
|
||||||
@ -22,10 +24,14 @@ test:
|
|||||||
test-integration:
|
test-integration:
|
||||||
cd api && $(BIN)/pytest -m integration
|
cd api && $(BIN)/pytest -m integration
|
||||||
|
|
||||||
|
# Сверка ORM с DBML (spec data-model): пустой вывод и код 0 — совпадение
|
||||||
|
dbml-check:
|
||||||
|
cd api && $(BIN)/python scripts/check_dbml_vs_orm.py $(DBML)
|
||||||
|
|
||||||
check: lint test
|
check: lint test
|
||||||
|
|
||||||
# TODO (задача 25): добавить фронт (lint/test/build web/), проверку миграций с нуля и откат, актуальность OpenAPI
|
# TODO (задача 25): добавить фронт (lint/test/build web/), проверку миграций с нуля и откат, актуальность OpenAPI
|
||||||
check-full: check test-integration
|
check-full: check dbml-check test-integration
|
||||||
|
|
||||||
up:
|
up:
|
||||||
docker compose -f $(COMPOSE_FILE) up -d
|
docker compose -f $(COMPOSE_FILE) up -d
|
||||||
|
|||||||
4
api/scripts/__init__.py
Normal file
4
api/scripts/__init__.py
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
"""Служебные скрипты arm-core (запуск из `api/`).
|
||||||
|
|
||||||
|
Сверка ORM с DBML — `check_dbml_vs_orm.py` (ядро — `dbml_parity.py`); seed — `python -m scripts.seed`.
|
||||||
|
"""
|
||||||
96
api/scripts/check_dbml_vs_orm.py
Normal file
96
api/scripts/check_dbml_vs_orm.py
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
"""Сверка ORM-моделей arm-core с DBML-моделью данных (spec data-model, design D-3).
|
||||||
|
|
||||||
|
Запуск из `api/`:
|
||||||
|
|
||||||
|
python scripts/check_dbml_vs_orm.py ../../docs/model/data-model-v5.13.dbml
|
||||||
|
|
||||||
|
Без аргумента DBML ищется в `$ARM_KA_DOCS/model/` или в соседнем `docs/` рядом с `arm-core`.
|
||||||
|
|
||||||
|
Вывод: пусто и код 0 — ORM совпадает с DBML; иначе по строке на расхождение и код 1, например:
|
||||||
|
|
||||||
|
osv_entry.extra_note: отсутствует в DBML
|
||||||
|
osv_entry.opening_debit: ожидалось numeric(18,2) NULL / фактически numeric(18,4) NOT NULL
|
||||||
|
|
||||||
|
Сначала сверяется версия из шапки DBML с `src.models.DBML_VERSION`: при расхождении — одна строка о версии, код 1,
|
||||||
|
таблицы не сравниваются. Код 2 — ошибка использования: нет файла, нет версии в шапке, DBML не разбирается
|
||||||
|
(сообщение — в stderr).
|
||||||
|
|
||||||
|
Окружение. Импорт `src.models` тянет `src/__init__` → приложение → `Settings`, которому нужны ключи `OPENBAO__*`.
|
||||||
|
Рабочий `.env` скрипту не нужен: если `ENV_FILE` не задан и в текущем каталоге нет `.env`, берётся `api/.env.example`
|
||||||
|
(все обязательные ключи там есть; соединений при импорте не открывается).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import enum
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from collections.abc import Mapping, Sequence
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
_API_DIR = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
# Запуск файлом (`python scripts/check_dbml_vs_orm.py`): в sys.path попадает только `api/scripts`, а нужен `api/`.
|
||||||
|
if str(_API_DIR) not in sys.path:
|
||||||
|
sys.path.insert(0, str(_API_DIR))
|
||||||
|
if "ENV_FILE" not in os.environ and not Path(".env").is_file():
|
||||||
|
os.environ["ENV_FILE"] = str(_API_DIR / ".env.example")
|
||||||
|
|
||||||
|
from sqlalchemy import MetaData # noqa: E402
|
||||||
|
|
||||||
|
from scripts.dbml_parity import default_dbml_path, find_all_problems, parse_dbml, read_dbml_version # noqa: E402
|
||||||
|
from src.models import DBML_VERSION, Base # noqa: E402
|
||||||
|
from src.models.enums import ENUMS # noqa: E402
|
||||||
|
|
||||||
|
EXIT_OK, EXIT_PROBLEMS, EXIT_USAGE = 0, 1, 2
|
||||||
|
USAGE = "использование: python scripts/check_dbml_vs_orm.py [путь к .dbml]"
|
||||||
|
|
||||||
|
|
||||||
|
def run_check(
|
||||||
|
dbml_path: Path,
|
||||||
|
*,
|
||||||
|
metadata: MetaData | None = None,
|
||||||
|
enums: Mapping[str, type[enum.Enum]] | None = None,
|
||||||
|
orm_version: str = DBML_VERSION,
|
||||||
|
) -> tuple[int, list[str]]:
|
||||||
|
"""Сверить ORM с DBML-файлом; вернуть (код возврата, строки отчёта).
|
||||||
|
|
||||||
|
По умолчанию сверяются реальные `Base.metadata` и `ENUMS`; тесты подставляют испорченные копии.
|
||||||
|
При коде 2 единственная строка — сообщение об ошибке использования.
|
||||||
|
"""
|
||||||
|
if not dbml_path.is_file():
|
||||||
|
return EXIT_USAGE, [f"файл DBML не найден: {dbml_path}"]
|
||||||
|
# версия — до разбора и сравнения таблиц: новая модель без осознанного обновления ORM не сверяется вовсе
|
||||||
|
dbml_version = read_dbml_version(dbml_path)
|
||||||
|
if dbml_version is None:
|
||||||
|
return EXIT_USAGE, [
|
||||||
|
f"в первой строке {dbml_path} не найдена версия DBML (ожидалось `... v<мажор>.<минор> ...`)"
|
||||||
|
]
|
||||||
|
if dbml_version != orm_version:
|
||||||
|
return EXIT_PROBLEMS, [f"версия DBML {dbml_version} не совпадает с версией ORM {orm_version}: обновите модели"]
|
||||||
|
try:
|
||||||
|
dbml = parse_dbml(dbml_path)
|
||||||
|
except Exception as error: # любая ошибка pydbml = непригодный файл
|
||||||
|
return EXIT_USAGE, [f"не удалось разобрать DBML {dbml_path}: {error}"]
|
||||||
|
problems = find_all_problems(
|
||||||
|
Base.metadata if metadata is None else metadata, ENUMS if enums is None else enums, dbml
|
||||||
|
)
|
||||||
|
return (EXIT_PROBLEMS if problems else EXIT_OK), problems
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: Sequence[str] | None = None) -> int:
|
||||||
|
"""CLI: расхождения — в stdout, ошибки использования — в stderr."""
|
||||||
|
args = list(sys.argv[1:] if argv is None else argv)
|
||||||
|
if args[:1] in (["-h"], ["--help"]):
|
||||||
|
print(__doc__)
|
||||||
|
return EXIT_OK
|
||||||
|
if len(args) > 1:
|
||||||
|
print(USAGE, file=sys.stderr)
|
||||||
|
return EXIT_USAGE
|
||||||
|
code, lines = run_check(Path(args[0]) if args else default_dbml_path())
|
||||||
|
stream = sys.stderr if code == EXIT_USAGE else sys.stdout
|
||||||
|
for line in lines:
|
||||||
|
print(line, file=stream)
|
||||||
|
return code
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
394
api/scripts/dbml_parity.py
Normal file
394
api/scripts/dbml_parity.py
Normal file
@ -0,0 +1,394 @@
|
|||||||
|
"""Сверка ORM-метаданных с DBML (design D-3) — единственная реализация: её вызывают CLI `check_dbml_vs_orm.py` и тесты.
|
||||||
|
|
||||||
|
Модуль не импортирует `src` (на вход приходят `MetaData` и реестр enum'ов) и не зависит от pytest.
|
||||||
|
|
||||||
|
Что сравнивается:
|
||||||
|
- множество таблиц (`таблица: отсутствует в ORM|DBML`) и схема таблиц ORM (`dkb`);
|
||||||
|
- колонки: состав (`таблица.колонка: отсутствует в ORM|DBML`), порядок общих колонок, тип + nullability одной строкой
|
||||||
|
в нотации DBML (`ожидалось numeric(18,2) NULL / фактически numeric(18,4) NOT NULL`), PK, unique, default;
|
||||||
|
- FK по колонке-ссылке (`таблица.колонка: FK: ожидалось родитель.колонка / фактически …`);
|
||||||
|
- индексы по набору колонок: уникальность, partial-условие (note `partial UNIQUE WHERE <expr>`), `NULLS NOT DISTINCT`;
|
||||||
|
составной `[pk]` в Indexes DBML — это первичный ключ, а не индекс;
|
||||||
|
- enum'ы: множество имён и упорядоченный список значений.
|
||||||
|
|
||||||
|
Формат каждой строки — `<объект>: ожидалось X / фактически Y` или `<объект>: отсутствует в ORM|DBML` (функции
|
||||||
|
`mismatch` / `missing`), чтобы тесты и человек читали один и тот же текст.
|
||||||
|
|
||||||
|
Нетривиальные места. DBML-default — python-литерал pydbml (bool/int/float/str) или `Expression` (обратные кавычки,
|
||||||
|
напр. `now()`); ORM server_default — `text(...)` или SQL-функция (`func.now()`). Обе стороны приводятся к строке:
|
||||||
|
bool -> `true`/`false`, число -> как есть, строка -> в одинарных кавычках, выражение -> текст в нижнем регистре.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import enum
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from collections.abc import Callable, Collection, Mapping
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pydbml import PyDBML
|
||||||
|
from pydbml.classes import Column as DbmlColumn
|
||||||
|
from pydbml.classes import Enum as DbmlEnum
|
||||||
|
from pydbml.classes import Expression
|
||||||
|
from pydbml.classes import Table as DbmlTable
|
||||||
|
from sqlalchemy import (
|
||||||
|
JSON,
|
||||||
|
BigInteger,
|
||||||
|
Boolean,
|
||||||
|
Column,
|
||||||
|
Date,
|
||||||
|
DateTime,
|
||||||
|
Integer,
|
||||||
|
MetaData,
|
||||||
|
Numeric,
|
||||||
|
SmallInteger,
|
||||||
|
String,
|
||||||
|
Table,
|
||||||
|
Text,
|
||||||
|
)
|
||||||
|
from sqlalchemy import Enum as SAEnum
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||||
|
from sqlalchemy.sql.elements import TextClause
|
||||||
|
from sqlalchemy.sql.functions import FunctionElement
|
||||||
|
|
||||||
|
SCHEMA = "dkb"
|
||||||
|
DBML_FILE_NAME = "data-model-v5.13.dbml"
|
||||||
|
ORM = "ORM"
|
||||||
|
DBML = "DBML"
|
||||||
|
ABSENT = "нет"
|
||||||
|
|
||||||
|
_NUMERIC_RE = re.compile(r"numeric\((\d+),\s*(\d+)\)")
|
||||||
|
_VERSION_RE = re.compile(r"v(\d+\.\d+)")
|
||||||
|
_PARTIAL_RE = re.compile(r"partial\s+UNIQUE\s+WHERE\s+(.+?)(?:\s+—.*)?$", re.IGNORECASE | re.DOTALL)
|
||||||
|
_NULLS_NOT_DISTINCT_RE = re.compile(r"NULLS\s+NOT\s+DISTINCT", re.IGNORECASE)
|
||||||
|
_SYNONYMS = {"current_timestamp": "now()", "current_timestamp()": "now()"}
|
||||||
|
#: Типы DBML, которые сверка умеет сопоставлять (плюс `numeric(p,s)` и enum'ы); прочие — явное расхождение.
|
||||||
|
SUPPORTED_DBML_TYPES = frozenset(
|
||||||
|
{"uuid", "varchar", "text", "smallint", "int", "bigint", "boolean", "date", "timestamptz", "jsonb"}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- формат строк отчёта ---------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def mismatch(obj: str, expected: object, actual: object) -> str:
|
||||||
|
"""Строка расхождения значения: `<объект>: ожидалось X / фактически Y`."""
|
||||||
|
return f"{obj}: ожидалось {expected} / фактически {actual}"
|
||||||
|
|
||||||
|
|
||||||
|
def missing(obj: str, where: str) -> str:
|
||||||
|
"""Строка отсутствия объекта: `<объект>: отсутствует в ORM|DBML`."""
|
||||||
|
return f"{obj}: отсутствует в {where}"
|
||||||
|
|
||||||
|
|
||||||
|
# --- поиск и чтение DBML ---------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def default_dbml_path() -> Path:
|
||||||
|
"""Путь к DBML по умолчанию: каталог docs из `ARM_KA_DOCS`, иначе соседний `docs` рядом с `arm-core`."""
|
||||||
|
docs = os.environ.get("ARM_KA_DOCS")
|
||||||
|
docs_dir = Path(docs) if docs else Path(__file__).resolve().parents[3] / "docs"
|
||||||
|
return docs_dir / "model" / DBML_FILE_NAME
|
||||||
|
|
||||||
|
|
||||||
|
def read_dbml_version(path: Path) -> str | None:
|
||||||
|
"""Версия из первой строки шапки DBML (`// ДКБ · Модель данных v5.13: ...`) или `None`, если её там нет."""
|
||||||
|
with path.open(encoding="utf-8") as file:
|
||||||
|
first_line = file.readline()
|
||||||
|
found = _VERSION_RE.search(first_line)
|
||||||
|
return found.group(1) if found else None
|
||||||
|
|
||||||
|
|
||||||
|
def parse_dbml(path: Path) -> PyDBML:
|
||||||
|
"""Разобрать DBML-файл через pydbml."""
|
||||||
|
return PyDBML.parse_file(path)
|
||||||
|
|
||||||
|
|
||||||
|
# --- типы ------------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
#: Типы без параметров: точный класс SQLAlchemy -> имя в DBML.
|
||||||
|
_SIMPLE_TYPE_NAMES: dict[type, str] = {
|
||||||
|
Text: "text",
|
||||||
|
SmallInteger: "smallint",
|
||||||
|
Integer: "int",
|
||||||
|
BigInteger: "bigint",
|
||||||
|
Boolean: "boolean",
|
||||||
|
Date: "date",
|
||||||
|
JSONB: "jsonb",
|
||||||
|
JSON: "json",
|
||||||
|
}
|
||||||
|
#: Типы с параметрами: точный класс -> отрисовка с параметрами (длина, точность, часовой пояс).
|
||||||
|
_PARAMETRIZED_TYPES: dict[type, Callable[[Any], str]] = {
|
||||||
|
Numeric: lambda t: f"numeric({t.precision},{t.scale})",
|
||||||
|
String: lambda t: "varchar" if t.length is None else f"varchar({t.length})",
|
||||||
|
DateTime: lambda t: "timestamptz" if t.timezone else "timestamp",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def render_orm_type(orm_type: object) -> str:
|
||||||
|
"""SQL-тип ORM-колонки в нотации DBML (`numeric(18,2)`, `varchar`, `timestamptz`, имя enum, ...).
|
||||||
|
|
||||||
|
Классы сравниваются точно: `SmallInteger` — подкласс `Integer`, `Text` — подкласс `String`, `Float` — подкласс
|
||||||
|
`Numeric`; подкласс не должен «проходить» за базовый тип.
|
||||||
|
"""
|
||||||
|
if isinstance(orm_type, SAEnum):
|
||||||
|
return str(orm_type.name) if orm_type.schema == SCHEMA else f"{orm_type.schema}.{orm_type.name}"
|
||||||
|
if isinstance(orm_type, UUID):
|
||||||
|
return "uuid" if orm_type.as_uuid else "uuid(as_uuid=False)"
|
||||||
|
cls = type(orm_type)
|
||||||
|
if cls in _PARAMETRIZED_TYPES:
|
||||||
|
return _PARAMETRIZED_TYPES[cls](orm_type)
|
||||||
|
return _SIMPLE_TYPE_NAMES.get(cls, cls.__name__.lower())
|
||||||
|
|
||||||
|
|
||||||
|
def render_dbml_type(dbml_type: object) -> str | None:
|
||||||
|
"""Тип DBML в канонической нотации; `None` — тип не поддержан сверкой."""
|
||||||
|
if isinstance(dbml_type, DbmlEnum):
|
||||||
|
return str(dbml_type.name)
|
||||||
|
name = str(dbml_type).strip().lower()
|
||||||
|
numeric = _NUMERIC_RE.fullmatch(name)
|
||||||
|
if numeric:
|
||||||
|
return f"numeric({numeric.group(1)},{numeric.group(2)})"
|
||||||
|
return name if name in SUPPORTED_DBML_TYPES else None
|
||||||
|
|
||||||
|
|
||||||
|
def type_pair(orm_type: object, dbml_type: object) -> tuple[str, str] | None:
|
||||||
|
"""(ожидаемый, фактический) тип в нотации DBML; совпадение — равенство строк. `None` — тип DBML не поддержан.
|
||||||
|
|
||||||
|
Для enum с верным именем и схемой сравниваются ещё и значения в порядке объявления: при расхождении к имени
|
||||||
|
приписываются списки значений, чтобы строка отчёта показывала разницу.
|
||||||
|
"""
|
||||||
|
expected = render_dbml_type(dbml_type)
|
||||||
|
if expected is None:
|
||||||
|
return None
|
||||||
|
actual = render_orm_type(orm_type)
|
||||||
|
if isinstance(dbml_type, DbmlEnum) and isinstance(orm_type, SAEnum) and actual == expected:
|
||||||
|
expected_values = [item.name for item in dbml_type.items]
|
||||||
|
if list(orm_type.enums) != expected_values:
|
||||||
|
return f"{expected}{expected_values}", f"{actual}{list(orm_type.enums)}"
|
||||||
|
return expected, actual
|
||||||
|
|
||||||
|
|
||||||
|
def type_problem(orm_type: object, dbml_type: object) -> str | None:
|
||||||
|
"""Расхождение только типа (`ожидалось X / фактически Y`), сообщение о неподдержанном типе DBML или `None`."""
|
||||||
|
pair = type_pair(orm_type, dbml_type)
|
||||||
|
if pair is None:
|
||||||
|
return f"тип DBML `{dbml_type}` не поддержан сверкой (добавьте в dbml_parity.SUPPORTED_DBML_TYPES)"
|
||||||
|
expected, actual = pair
|
||||||
|
return None if expected == actual else f"ожидалось {expected} / фактически {actual}"
|
||||||
|
|
||||||
|
|
||||||
|
# --- default ---------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _canon_expr(text: str) -> str:
|
||||||
|
text = " ".join(text.split())
|
||||||
|
if text.startswith("'"):
|
||||||
|
return text # строковый литерал — регистр значим
|
||||||
|
text = text.lower()
|
||||||
|
return _SYNONYMS.get(text, text)
|
||||||
|
|
||||||
|
|
||||||
|
def canonical_default_dbml(default: object) -> str:
|
||||||
|
"""Канонический вид DBML-default: bool/число/строка-литерал/`Expression`."""
|
||||||
|
if isinstance(default, Expression):
|
||||||
|
return _canon_expr(str(default.text))
|
||||||
|
if isinstance(default, bool):
|
||||||
|
return "true" if default else "false"
|
||||||
|
if isinstance(default, int | float):
|
||||||
|
return str(default)
|
||||||
|
return "'" + str(default).replace("'", "''") + "'"
|
||||||
|
|
||||||
|
|
||||||
|
def canonical_default_orm(server_default: object) -> str:
|
||||||
|
"""Канонический вид ORM `server_default` (`text(...)`, `func.now()`, строка)."""
|
||||||
|
arg = getattr(server_default, "arg", server_default)
|
||||||
|
if isinstance(arg, TextClause):
|
||||||
|
return _canon_expr(arg.text)
|
||||||
|
if isinstance(arg, FunctionElement):
|
||||||
|
return _canon_expr(f"{arg.name}()")
|
||||||
|
return _canon_expr(str(arg))
|
||||||
|
|
||||||
|
|
||||||
|
# --- колонки ---------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _nullability(nullable: bool) -> str:
|
||||||
|
return "NULL" if nullable else "NOT NULL"
|
||||||
|
|
||||||
|
|
||||||
|
def _yes_no(flag: bool) -> str:
|
||||||
|
return "да" if flag else "нет"
|
||||||
|
|
||||||
|
|
||||||
|
def _column_problems(orm_table: Table, dbml_table: DbmlTable) -> list[str]:
|
||||||
|
"""Состав, порядок и свойства колонок одной таблицы."""
|
||||||
|
name = dbml_table.name
|
||||||
|
dbml_names = [c.name for c in dbml_table.columns]
|
||||||
|
orm_names = [c.name for c in orm_table.columns]
|
||||||
|
problems = [missing(f"{name}.{col}", ORM) for col in dbml_names if col not in orm_names]
|
||||||
|
problems += [missing(f"{name}.{col}", DBML) for col in orm_names if col not in dbml_names]
|
||||||
|
# порядок сравнивается только по общим колонкам: пропавшая/лишняя колонка уже названа выше
|
||||||
|
common_dbml = [col for col in dbml_names if col in orm_names]
|
||||||
|
common_orm = [col for col in orm_names if col in dbml_names]
|
||||||
|
if common_dbml != common_orm:
|
||||||
|
problems.append(mismatch(f"{name}: порядок колонок", common_dbml, common_orm))
|
||||||
|
composite_pk = {c.name for i in dbml_table.indexes if i.pk for c in i.subjects}
|
||||||
|
for dbml_col in dbml_table.columns:
|
||||||
|
if dbml_col.name in orm_table.columns:
|
||||||
|
label = f"{name}.{dbml_col.name}"
|
||||||
|
problems += _one_column_problems(label, orm_table.columns[dbml_col.name], dbml_col, composite_pk)
|
||||||
|
return problems
|
||||||
|
|
||||||
|
|
||||||
|
def _one_column_problems(label: str, orm_col: Column[Any], dbml_col: DbmlColumn, composite_pk: set[str]) -> list[str]:
|
||||||
|
problems: list[str] = []
|
||||||
|
is_pk = bool(dbml_col.pk) or dbml_col.name in composite_pk
|
||||||
|
# тип и nullability — одной строкой, как в спеке: «ожидалось numeric(18,2) NULL / фактически numeric(18,4) NOT NULL»
|
||||||
|
pair = type_pair(orm_col.type, dbml_col.type)
|
||||||
|
if pair is None:
|
||||||
|
problems.append(f"{label}: тип DBML `{dbml_col.type}` не поддержан сверкой")
|
||||||
|
else:
|
||||||
|
expected_null = _nullability(not (dbml_col.not_null or is_pk))
|
||||||
|
actual_null = _nullability(bool(orm_col.nullable))
|
||||||
|
expected, actual = f"{pair[0]} {expected_null}", f"{pair[1]} {actual_null}"
|
||||||
|
if expected != actual:
|
||||||
|
problems.append(mismatch(label, expected, actual))
|
||||||
|
if orm_col.primary_key != is_pk:
|
||||||
|
problems.append(mismatch(f"{label}: первичный ключ", _yes_no(is_pk), _yes_no(orm_col.primary_key)))
|
||||||
|
if bool(orm_col.unique) != bool(dbml_col.unique):
|
||||||
|
problems.append(mismatch(f"{label}: unique", _yes_no(bool(dbml_col.unique)), _yes_no(bool(orm_col.unique))))
|
||||||
|
expected_default = ABSENT if dbml_col.default is None else canonical_default_dbml(dbml_col.default)
|
||||||
|
actual_default = ABSENT if orm_col.server_default is None else canonical_default_orm(orm_col.server_default)
|
||||||
|
if expected_default != actual_default:
|
||||||
|
problems.append(mismatch(f"{label}: default", expected_default, actual_default))
|
||||||
|
return problems
|
||||||
|
|
||||||
|
|
||||||
|
# --- FK --------------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _fk_problems(metadata: MetaData, dbml: PyDBML, names: Collection[str]) -> list[str]:
|
||||||
|
"""FK по колонке-ссылке: множество целей `родитель.колонка` в DBML против ORM."""
|
||||||
|
expected: dict[tuple[str, str], set[str]] = {}
|
||||||
|
for ref in dbml.refs:
|
||||||
|
# `>`: table1 ссылается на table2 (many-to-one); `<` — наоборот; `-` (one-to-one) читаем как `>`
|
||||||
|
child, child_cols, parent, parent_cols = ref.table1, ref.col1, ref.table2, ref.col2
|
||||||
|
if ref.type == "<":
|
||||||
|
child, child_cols, parent, parent_cols = parent, parent_cols, child, child_cols
|
||||||
|
if child.name in names:
|
||||||
|
for child_col, parent_col in zip(child_cols, parent_cols, strict=True):
|
||||||
|
expected.setdefault((child.name, child_col.name), set()).add(f"{parent.name}.{parent_col.name}")
|
||||||
|
actual: dict[tuple[str, str], set[str]] = {}
|
||||||
|
for table in metadata.tables.values():
|
||||||
|
if table.name in names:
|
||||||
|
for fk in table.foreign_keys:
|
||||||
|
target = f"{fk.column.table.name}.{fk.column.name}"
|
||||||
|
actual.setdefault((table.name, fk.parent.name), set()).add(target)
|
||||||
|
problems = []
|
||||||
|
for key in sorted(expected.keys() | actual.keys()):
|
||||||
|
want, have = expected.get(key, set()), actual.get(key, set())
|
||||||
|
if want != have:
|
||||||
|
problems.append(mismatch(f"{key[0]}.{key[1]}: FK", _targets(want), _targets(have)))
|
||||||
|
return problems
|
||||||
|
|
||||||
|
|
||||||
|
def _targets(targets: set[str]) -> str:
|
||||||
|
return ", ".join(sorted(targets)) or ABSENT
|
||||||
|
|
||||||
|
|
||||||
|
# --- индексы ---------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _norm_where(where: object) -> str | None:
|
||||||
|
if where is None:
|
||||||
|
return None
|
||||||
|
text = where.text if isinstance(where, TextClause) else str(where)
|
||||||
|
return " ".join(text.split()).lower()
|
||||||
|
|
||||||
|
|
||||||
|
def _index_props(unique: bool, where: str | None, nulls_not_distinct: bool) -> str:
|
||||||
|
"""Свойства индекса текстом: `unique WHERE is_current NULLS NOT DISTINCT` / `не unique`."""
|
||||||
|
parts = ["unique" if unique else "не unique"]
|
||||||
|
if where:
|
||||||
|
parts.append(f"WHERE {where}")
|
||||||
|
if nulls_not_distinct:
|
||||||
|
parts.append("NULLS NOT DISTINCT")
|
||||||
|
return " ".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def _index_problems(orm_table: Table, dbml_table: DbmlTable) -> list[str]:
|
||||||
|
"""Индексы, сгруппированные по набору колонок: состав и свойства (unique, WHERE, NULLS NOT DISTINCT)."""
|
||||||
|
expected: dict[tuple[str, ...], list[str]] = {}
|
||||||
|
for index in dbml_table.indexes:
|
||||||
|
if index.pk:
|
||||||
|
continue # составной [pk] — первичный ключ, сверяется в колонках
|
||||||
|
note = str(index.note) if index.note else ""
|
||||||
|
partial = _PARTIAL_RE.search(note)
|
||||||
|
props = _index_props(
|
||||||
|
bool(index.unique) or partial is not None,
|
||||||
|
_norm_where(partial.group(1)) if partial else None,
|
||||||
|
# note с `NULLS NOT DISTINCT` => уникальный индекс обязан иметь postgresql_nulls_not_distinct=True
|
||||||
|
_NULLS_NOT_DISTINCT_RE.search(note) is not None,
|
||||||
|
)
|
||||||
|
expected.setdefault(tuple(c.name for c in index.subjects), []).append(props)
|
||||||
|
actual: dict[tuple[str, ...], list[str]] = {}
|
||||||
|
for orm_index in orm_table.indexes:
|
||||||
|
options = orm_index.dialect_options["postgresql"]
|
||||||
|
props = _index_props(bool(orm_index.unique), _norm_where(options["where"]), bool(options["nulls_not_distinct"]))
|
||||||
|
actual.setdefault(tuple(c.name for c in orm_index.columns), []).append(props)
|
||||||
|
problems = []
|
||||||
|
for columns in sorted(expected.keys() | actual.keys()):
|
||||||
|
label = f"{dbml_table.name}: индекс ({', '.join(columns)})"
|
||||||
|
want, have = sorted(expected.get(columns, [])), sorted(actual.get(columns, []))
|
||||||
|
if not have:
|
||||||
|
problems.append(missing(label, ORM))
|
||||||
|
elif not want:
|
||||||
|
problems.append(missing(label, DBML))
|
||||||
|
elif want != have:
|
||||||
|
problems.append(mismatch(label, "; ".join(want), "; ".join(have)))
|
||||||
|
return problems
|
||||||
|
|
||||||
|
|
||||||
|
# --- точки входа -----------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def find_problems(metadata: MetaData, tables: Collection[str], dbml: PyDBML) -> list[str]:
|
||||||
|
"""Расхождения ORM (`metadata`) с DBML для таблиц `tables`; пустой список — совпадение."""
|
||||||
|
dbml_tables = {t.name: t for t in dbml.tables}
|
||||||
|
orm_tables = {t.name: t for t in metadata.tables.values()}
|
||||||
|
problems: list[str] = []
|
||||||
|
for name in sorted(tables):
|
||||||
|
if name not in dbml_tables:
|
||||||
|
problems.append(missing(name, DBML))
|
||||||
|
elif name not in orm_tables:
|
||||||
|
problems.append(missing(name, ORM))
|
||||||
|
else:
|
||||||
|
if orm_tables[name].schema != SCHEMA:
|
||||||
|
problems.append(mismatch(f"{name}: схема", SCHEMA, orm_tables[name].schema))
|
||||||
|
problems += _column_problems(orm_tables[name], dbml_tables[name])
|
||||||
|
problems += _index_problems(orm_tables[name], dbml_tables[name])
|
||||||
|
return problems + _fk_problems(metadata, dbml, {n for n in tables if n in dbml_tables})
|
||||||
|
|
||||||
|
|
||||||
|
def enum_problems(enums: Mapping[str, type[enum.Enum]], dbml: PyDBML) -> list[str]:
|
||||||
|
"""Enum'ы ORM-реестра (имя DBML → класс) против блоков `Enum {}` DBML: множество имён и значения по порядку."""
|
||||||
|
dbml_enums = {e.name: [item.name for item in e.items] for e in dbml.enums}
|
||||||
|
problems: list[str] = []
|
||||||
|
for name in sorted(dbml_enums.keys() | enums.keys()):
|
||||||
|
if name not in enums:
|
||||||
|
problems.append(missing(f"enum {name}", ORM))
|
||||||
|
elif name not in dbml_enums:
|
||||||
|
problems.append(missing(f"enum {name}", DBML))
|
||||||
|
else:
|
||||||
|
actual = [member.value for member in enums[name]]
|
||||||
|
if actual != dbml_enums[name]:
|
||||||
|
problems.append(mismatch(f"enum {name}", dbml_enums[name], actual))
|
||||||
|
return problems
|
||||||
|
|
||||||
|
|
||||||
|
def find_all_problems(metadata: MetaData, enums: Mapping[str, type[enum.Enum]], dbml: PyDBML) -> list[str]:
|
||||||
|
"""Полная сверка: все таблицы обеих сторон (количество — следствие сравнения множеств) и все enum'ы."""
|
||||||
|
names = {t.name for t in dbml.tables} | {t.name for t in metadata.tables.values()}
|
||||||
|
return find_problems(metadata, names, dbml) + enum_problems(enums, dbml)
|
||||||
@ -1,19 +1,18 @@
|
|||||||
"""Поиск и разбор DBML-модели для тестов (переиспользуется тестами чётности ORM и DBML)."""
|
"""Поиск и разбор DBML-модели для тестов: обёртка над `scripts.dbml_parity` с пропуском теста, если docs недоступен."""
|
||||||
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from pydbml import PyDBML
|
from pydbml import PyDBML
|
||||||
|
|
||||||
DBML_FILE_NAME = "data-model-v5.13.dbml"
|
from scripts.dbml_parity import DBML_FILE_NAME, default_dbml_path, parse_dbml
|
||||||
|
|
||||||
|
__all__ = ["DBML_FILE_NAME", "dbml_path", "load_dbml"]
|
||||||
|
|
||||||
|
|
||||||
def dbml_path() -> Path:
|
def dbml_path() -> Path:
|
||||||
"""Путь к DBML: каталог docs из `ARM_KA_DOCS`, иначе соседний `../../docs` относительно `arm-core/api`."""
|
"""Путь к DBML: каталог docs из `ARM_KA_DOCS`, иначе соседний `../../docs` относительно `arm-core/api`."""
|
||||||
docs = os.environ.get("ARM_KA_DOCS")
|
return default_dbml_path()
|
||||||
docs_dir = Path(docs) if docs else Path(__file__).resolve().parents[3] / "docs"
|
|
||||||
return docs_dir / "model" / DBML_FILE_NAME
|
|
||||||
|
|
||||||
|
|
||||||
def load_dbml() -> PyDBML:
|
def load_dbml() -> PyDBML:
|
||||||
@ -21,4 +20,4 @@ def load_dbml() -> PyDBML:
|
|||||||
path = dbml_path()
|
path = dbml_path()
|
||||||
if not path.is_file():
|
if not path.is_file():
|
||||||
pytest.skip(f"docs недоступен: нет {path} (задайте ARM_KA_DOCS или положите docs рядом с arm-core)")
|
pytest.skip(f"docs недоступен: нет {path} (задайте ARM_KA_DOCS или положите docs рядом с arm-core)")
|
||||||
return PyDBML.parse_file(path)
|
return parse_dbml(path)
|
||||||
|
|||||||
@ -1,263 +1,20 @@
|
|||||||
"""Сверка ORM-метаданных с DBML v5.13 для заданного набора таблиц (без БД); переиспользуется тестами частей 01–09.
|
"""Тонкая обёртка тестов над `scripts.dbml_parity` (единственная реализация сверки ORM с DBML, рулинг R-14).
|
||||||
|
|
||||||
Публичное API:
|
Отличие одно: `find_problems` без явного `dbml` берёт DBML через `load_dbml()` (пропуск теста, если docs недоступен).
|
||||||
- `find_problems(metadata, tables)` — список расхождений (пустой = ORM совпадает с DBML) по колонкам (имя, порядок,
|
|
||||||
тип, nullable, PK, unique, server_default), FK и индексам (состав, уникальность, partial-условие WHERE,
|
|
||||||
`NULLS NOT DISTINCT` по note DBML);
|
|
||||||
- `type_problem(orm_type, dbml_type)` — расхождение типа одной колонки (или `None`);
|
|
||||||
- `canonical_default_dbml` / `canonical_default_orm` — приведение default к сопоставимому виду.
|
|
||||||
|
|
||||||
Нетривиальные места. DBML-default — python-литерал pydbml (bool/int/float/str) или `Expression` (обратные кавычки,
|
|
||||||
напр. `now()`); ORM server_default — `text(...)` или SQL-функция (`func.now()`). Обе стороны приводятся к строке:
|
|
||||||
bool -> `true`/`false`, число -> как есть, строка -> в одинарных кавычках, выражение -> текст в нижнем регистре.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import re
|
from collections.abc import Collection
|
||||||
from collections.abc import Callable, Collection
|
|
||||||
|
|
||||||
from pydbml import PyDBML
|
from pydbml import PyDBML
|
||||||
from pydbml.classes import Column as DbmlColumn
|
from sqlalchemy import MetaData
|
||||||
from pydbml.classes import Enum as DbmlEnum
|
|
||||||
from pydbml.classes import Expression
|
|
||||||
from pydbml.classes import Table as DbmlTable
|
|
||||||
from sqlalchemy import (
|
|
||||||
BigInteger,
|
|
||||||
Boolean,
|
|
||||||
Date,
|
|
||||||
DateTime,
|
|
||||||
Integer,
|
|
||||||
MetaData,
|
|
||||||
Numeric,
|
|
||||||
SmallInteger,
|
|
||||||
String,
|
|
||||||
Table,
|
|
||||||
Text,
|
|
||||||
)
|
|
||||||
from sqlalchemy import Enum as SAEnum
|
|
||||||
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
|
||||||
from sqlalchemy.sql.elements import TextClause
|
|
||||||
from sqlalchemy.sql.functions import FunctionElement
|
|
||||||
|
|
||||||
|
from scripts import dbml_parity
|
||||||
|
from scripts.dbml_parity import canonical_default_dbml, canonical_default_orm, type_problem
|
||||||
from tests.dbml_helpers import load_dbml
|
from tests.dbml_helpers import load_dbml
|
||||||
|
|
||||||
SCHEMA = "dkb"
|
__all__ = ["canonical_default_dbml", "canonical_default_orm", "find_problems", "type_problem"]
|
||||||
_NUMERIC_RE = re.compile(r"numeric\((\d+),\s*(\d+)\)")
|
|
||||||
_PARTIAL_RE = re.compile(r"partial\s+UNIQUE\s+WHERE\s+(.+?)(?:\s+—.*)?$", re.IGNORECASE | re.DOTALL)
|
|
||||||
_NULLS_NOT_DISTINCT_RE = re.compile(r"NULLS\s+NOT\s+DISTINCT", re.IGNORECASE)
|
|
||||||
_SYNONYMS = {"current_timestamp": "now()", "current_timestamp()": "now()"}
|
|
||||||
|
|
||||||
|
|
||||||
# --- типы -------------------------------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def _is_exact(orm_type: object, cls: type) -> bool:
|
|
||||||
"""Точное совпадение класса (SmallInteger — подкласс Integer, Text — подкласс String, и т.д.)."""
|
|
||||||
return type(orm_type) is cls
|
|
||||||
|
|
||||||
|
|
||||||
_SIMPLE_TYPES: dict[str, Callable[[object], bool]] = {
|
|
||||||
"uuid": lambda t: isinstance(t, UUID) and t.as_uuid is True,
|
|
||||||
"varchar": lambda t: _is_exact(t, String) and t.length is None, # type: ignore[attr-defined]
|
|
||||||
"text": lambda t: _is_exact(t, Text),
|
|
||||||
"smallint": lambda t: _is_exact(t, SmallInteger),
|
|
||||||
"int": lambda t: _is_exact(t, Integer),
|
|
||||||
"bigint": lambda t: _is_exact(t, BigInteger),
|
|
||||||
"boolean": lambda t: _is_exact(t, Boolean),
|
|
||||||
"date": lambda t: _is_exact(t, Date),
|
|
||||||
"timestamptz": lambda t: _is_exact(t, DateTime) and t.timezone is True, # type: ignore[attr-defined]
|
|
||||||
"jsonb": lambda t: isinstance(t, JSONB),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def type_problem(orm_type: object, dbml_type: object) -> str | None:
|
|
||||||
"""Расхождение SQL-типа ORM-колонки с типом DBML; `dbml_type` — строка типа или объект enum pydbml."""
|
|
||||||
if isinstance(dbml_type, DbmlEnum):
|
|
||||||
return _enum_problem(orm_type, dbml_type)
|
|
||||||
name = str(dbml_type).strip().lower()
|
|
||||||
numeric = _NUMERIC_RE.fullmatch(name)
|
|
||||||
if numeric:
|
|
||||||
precision, scale = int(numeric.group(1)), int(numeric.group(2))
|
|
||||||
if _is_exact(orm_type, Numeric) and (orm_type.precision, orm_type.scale) == (precision, scale): # type: ignore[attr-defined]
|
|
||||||
return None
|
|
||||||
return f"ожидался Numeric({precision}, {scale}), фактически {_describe(orm_type)}"
|
|
||||||
check = _SIMPLE_TYPES.get(name)
|
|
||||||
if check is None:
|
|
||||||
return f"тип DBML `{dbml_type}` не поддержан сверкой (добавьте в dbml_parity._SIMPLE_TYPES)"
|
|
||||||
if check(orm_type):
|
|
||||||
return None
|
|
||||||
return f"ожидался тип для DBML `{name}`, фактически {_describe(orm_type)}"
|
|
||||||
|
|
||||||
|
|
||||||
def _enum_problem(orm_type: object, dbml_enum: DbmlEnum) -> str | None:
|
|
||||||
if not isinstance(orm_type, SAEnum):
|
|
||||||
return f"ожидался enum {dbml_enum.name}, фактически {_describe(orm_type)}"
|
|
||||||
expected_values = [item.name for item in dbml_enum.items]
|
|
||||||
if orm_type.name != dbml_enum.name:
|
|
||||||
return f"имя enum: ожидалось {dbml_enum.name}, фактически {orm_type.name}"
|
|
||||||
if orm_type.schema != SCHEMA:
|
|
||||||
return f"схема enum {dbml_enum.name}: ожидалась {SCHEMA}, фактически {orm_type.schema}"
|
|
||||||
if list(orm_type.enums) != expected_values:
|
|
||||||
return f"значения enum {dbml_enum.name}: ожидалось {expected_values}, фактически {list(orm_type.enums)}"
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _describe(orm_type: object) -> str:
|
|
||||||
extra = ""
|
|
||||||
if isinstance(orm_type, Numeric):
|
|
||||||
extra = f"({orm_type.precision}, {orm_type.scale})"
|
|
||||||
elif isinstance(orm_type, DateTime):
|
|
||||||
extra = f"(timezone={orm_type.timezone})"
|
|
||||||
elif isinstance(orm_type, String):
|
|
||||||
extra = f"(length={orm_type.length})"
|
|
||||||
elif isinstance(orm_type, SAEnum):
|
|
||||||
extra = f"({orm_type.name})"
|
|
||||||
return f"{type(orm_type).__name__}{extra}"
|
|
||||||
|
|
||||||
|
|
||||||
# --- default ----------------------------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def _canon_expr(text: str) -> str:
|
|
||||||
text = " ".join(text.split())
|
|
||||||
if text.startswith("'"):
|
|
||||||
return text # строковый литерал — регистр значим
|
|
||||||
text = text.lower()
|
|
||||||
return _SYNONYMS.get(text, text)
|
|
||||||
|
|
||||||
|
|
||||||
def canonical_default_dbml(default: object) -> str:
|
|
||||||
"""Канонический вид DBML-default: bool/число/строка-литерал/`Expression`."""
|
|
||||||
if isinstance(default, Expression):
|
|
||||||
return _canon_expr(str(default.text))
|
|
||||||
if isinstance(default, bool):
|
|
||||||
return "true" if default else "false"
|
|
||||||
if isinstance(default, int | float):
|
|
||||||
return str(default)
|
|
||||||
return "'" + str(default).replace("'", "''") + "'"
|
|
||||||
|
|
||||||
|
|
||||||
def canonical_default_orm(server_default: object) -> str:
|
|
||||||
"""Канонический вид ORM `server_default` (`text(...)`, `func.now()`, строка)."""
|
|
||||||
arg = getattr(server_default, "arg", server_default)
|
|
||||||
if isinstance(arg, TextClause):
|
|
||||||
return _canon_expr(arg.text)
|
|
||||||
if isinstance(arg, FunctionElement):
|
|
||||||
return _canon_expr(f"{arg.name}()")
|
|
||||||
return _canon_expr(str(arg))
|
|
||||||
|
|
||||||
|
|
||||||
# --- колонки, FK, индексы -------------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def _column_problems(orm_table: Table, dbml_table: DbmlTable) -> list[str]:
|
|
||||||
name = dbml_table.name
|
|
||||||
problems: list[str] = []
|
|
||||||
dbml_cols = {c.name: c for c in dbml_table.columns}
|
|
||||||
orm_names = [c.name for c in orm_table.columns]
|
|
||||||
if orm_names != list(dbml_cols):
|
|
||||||
problems.append(f"{name}: колонки/порядок: ожидалось {list(dbml_cols)}, фактически {orm_names}")
|
|
||||||
composite_pk = {c.name for i in dbml_table.indexes if i.pk for c in i.subjects}
|
|
||||||
for col_name, dbml_col in dbml_cols.items():
|
|
||||||
if col_name not in orm_table.columns:
|
|
||||||
continue
|
|
||||||
problems.extend(_one_column_problems(f"{name}.{col_name}", orm_table.columns[col_name], dbml_col, composite_pk))
|
|
||||||
return problems
|
|
||||||
|
|
||||||
|
|
||||||
def _one_column_problems(label: str, orm_col, dbml_col: DbmlColumn, composite_pk: set[str]) -> list[str]: # type: ignore[no-untyped-def]
|
|
||||||
problems: list[str] = []
|
|
||||||
is_pk = bool(dbml_col.pk) or dbml_col.name in composite_pk
|
|
||||||
if orm_col.primary_key != is_pk:
|
|
||||||
problems.append(f"{label}: primary_key ожидался {is_pk}")
|
|
||||||
if orm_col.nullable != (not (dbml_col.not_null or is_pk)):
|
|
||||||
problems.append(f"{label}: nullable фактически {orm_col.nullable}")
|
|
||||||
if bool(orm_col.unique) != bool(dbml_col.unique):
|
|
||||||
problems.append(f"{label}: unique ожидался {bool(dbml_col.unique)}")
|
|
||||||
type_diff = type_problem(orm_col.type, dbml_col.type)
|
|
||||||
if type_diff:
|
|
||||||
problems.append(f"{label}: тип: {type_diff}")
|
|
||||||
if dbml_col.default is None:
|
|
||||||
if orm_col.server_default is not None:
|
|
||||||
problems.append(f"{label}: лишний server_default {canonical_default_orm(orm_col.server_default)}")
|
|
||||||
elif orm_col.server_default is None:
|
|
||||||
problems.append(f"{label}: нет server_default (ожидался {canonical_default_dbml(dbml_col.default)})")
|
|
||||||
else:
|
|
||||||
expected, actual = canonical_default_dbml(dbml_col.default), canonical_default_orm(orm_col.server_default)
|
|
||||||
if expected != actual:
|
|
||||||
problems.append(f"{label}: server_default ожидался {expected}, фактически {actual}")
|
|
||||||
return problems
|
|
||||||
|
|
||||||
|
|
||||||
def _fk_problems(orm_tables: dict[str, Table], dbml: PyDBML, names: Collection[str]) -> list[str]:
|
|
||||||
expected: set[tuple[str, str, str, str]] = set()
|
|
||||||
for ref in dbml.refs:
|
|
||||||
child, child_col, parent, parent_col = ref.table1, ref.col1[0], ref.table2, ref.col2[0]
|
|
||||||
if ref.type == "<":
|
|
||||||
child, child_col, parent, parent_col = parent, parent_col, child, child_col
|
|
||||||
if child.name in names:
|
|
||||||
expected.add((child.name, child_col.name, parent.name, parent_col.name))
|
|
||||||
actual = {
|
|
||||||
(t.name, fk.parent.name, fk.column.table.name, fk.column.name)
|
|
||||||
for t in orm_tables.values()
|
|
||||||
for fk in t.foreign_keys
|
|
||||||
if t.name in names
|
|
||||||
}
|
|
||||||
problems = [f"FK отсутствует в ORM: {fk}" for fk in sorted(expected - actual)]
|
|
||||||
return problems + [f"FK лишний в ORM: {fk}" for fk in sorted(actual - expected)]
|
|
||||||
|
|
||||||
|
|
||||||
def _norm_where(where: object) -> str | None:
|
|
||||||
if where is None:
|
|
||||||
return None
|
|
||||||
text = where.text if isinstance(where, TextClause) else str(where)
|
|
||||||
return " ".join(text.split()).lower()
|
|
||||||
|
|
||||||
|
|
||||||
def _index_problems(orm_table: Table, dbml_table: DbmlTable) -> list[str]:
|
|
||||||
expected: list[tuple[tuple[str, ...], bool, str | None, bool]] = []
|
|
||||||
for i in dbml_table.indexes:
|
|
||||||
if i.pk:
|
|
||||||
continue
|
|
||||||
note = str(i.note) if i.note else ""
|
|
||||||
partial = _PARTIAL_RE.search(note)
|
|
||||||
where = _norm_where(partial.group(1)) if partial else None
|
|
||||||
# note с `NULLS NOT DISTINCT` => уникальный индекс обязан иметь postgresql_nulls_not_distinct=True
|
|
||||||
nulls_not_distinct = _NULLS_NOT_DISTINCT_RE.search(note) is not None
|
|
||||||
expected.append(
|
|
||||||
(tuple(c.name for c in i.subjects), bool(i.unique) or partial is not None, where, nulls_not_distinct)
|
|
||||||
)
|
|
||||||
actual = [
|
|
||||||
(
|
|
||||||
tuple(c.name for c in i.columns),
|
|
||||||
bool(i.unique),
|
|
||||||
_norm_where(i.dialect_options["postgresql"]["where"]),
|
|
||||||
bool(i.dialect_options["postgresql"]["nulls_not_distinct"]),
|
|
||||||
)
|
|
||||||
for i in orm_table.indexes
|
|
||||||
]
|
|
||||||
if sorted(actual, key=repr) == sorted(expected, key=repr):
|
|
||||||
return []
|
|
||||||
return [
|
|
||||||
f"{dbml_table.name}: индексы (колонки, unique, WHERE, NULLS NOT DISTINCT): "
|
|
||||||
f"ожидалось {sorted(expected, key=repr)}, фактически {sorted(actual, key=repr)}"
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
# --- точка входа ------------------------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def find_problems(metadata: MetaData, tables: Collection[str], dbml: PyDBML | None = None) -> list[str]:
|
def find_problems(metadata: MetaData, tables: Collection[str], dbml: PyDBML | None = None) -> list[str]:
|
||||||
"""Все расхождения ORM (`metadata`) с DBML для таблиц `tables`; пустой список — совпадение."""
|
"""Расхождения ORM с DBML для таблиц `tables` (см. `scripts.dbml_parity.find_problems`)."""
|
||||||
dbml = dbml or load_dbml()
|
return dbml_parity.find_problems(metadata, tables, dbml or load_dbml())
|
||||||
dbml_tables = {t.name: t for t in dbml.tables}
|
|
||||||
orm_tables = {t.name: t for t in metadata.tables.values()}
|
|
||||||
problems: list[str] = []
|
|
||||||
for name in sorted(tables):
|
|
||||||
if name not in dbml_tables:
|
|
||||||
problems.append(f"{name}: нет в DBML")
|
|
||||||
elif name not in orm_tables:
|
|
||||||
problems.append(f"{name}: нет в ORM-метаданных")
|
|
||||||
else:
|
|
||||||
problems.extend(_column_problems(orm_tables[name], dbml_tables[name]))
|
|
||||||
problems.extend(_index_problems(orm_tables[name], dbml_tables[name]))
|
|
||||||
return problems + _fk_problems(orm_tables, dbml, tables)
|
|
||||||
|
|||||||
162
api/tests/test_check_dbml_script.py
Normal file
162
api/tests/test_check_dbml_script.py
Normal file
@ -0,0 +1,162 @@
|
|||||||
|
"""CLI сверки ORM с DBML (`scripts/check_dbml_vs_orm.py`): сценарии спеки data-model «Сверка ORM с DBML»,
|
||||||
|
«Лишняя колонка в ORM», «Расхождение типа или nullability», «Новая версия DBML без обновления ORM».
|
||||||
|
|
||||||
|
Мутации ORM делаются на копии метаданных (`run_check(..., metadata=...)`), реальный запуск — через subprocess.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import enum
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import Column, MetaData, Numeric, String, Table
|
||||||
|
|
||||||
|
from scripts.check_dbml_vs_orm import EXIT_USAGE, run_check
|
||||||
|
from src.models import Base
|
||||||
|
from src.models.enums import ENUMS
|
||||||
|
from tests.dbml_helpers import dbml_path
|
||||||
|
|
||||||
|
API_DIR = Path(__file__).resolve().parents[1]
|
||||||
|
SCRIPT = "scripts/check_dbml_vs_orm.py"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def dbml_file() -> Path:
|
||||||
|
path = dbml_path()
|
||||||
|
if not path.is_file():
|
||||||
|
pytest.skip(f"docs недоступен: нет {path} (задайте ARM_KA_DOCS или положите docs рядом с arm-core)")
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def clone_metadata() -> MetaData:
|
||||||
|
"""Копия реальных метаданных ORM, которую можно портить, не затрагивая `Base.metadata`."""
|
||||||
|
clone = MetaData(schema=Base.metadata.schema, naming_convention=Base.metadata.naming_convention)
|
||||||
|
for table in Base.metadata.sorted_tables:
|
||||||
|
table.to_metadata(clone)
|
||||||
|
return clone
|
||||||
|
|
||||||
|
|
||||||
|
def _run_cli(*args: str) -> subprocess.CompletedProcess[str]:
|
||||||
|
"""Запуск скрипта как файла из `api/` без ENV_FILE: скрипт обязан обойтись без рабочего `.env`."""
|
||||||
|
env = {k: v for k, v in os.environ.items() if k not in {"ENV_FILE", "APP_ENV"}}
|
||||||
|
return subprocess.run(
|
||||||
|
[sys.executable, SCRIPT, *args], cwd=API_DIR, env=env, capture_output=True, text=True, check=False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- реальный запуск -------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_cli_on_real_models_prints_nothing_and_exits_0(dbml_file: Path) -> None:
|
||||||
|
result = _run_cli(str(dbml_file))
|
||||||
|
assert (result.returncode, result.stdout) == (0, ""), result.stdout + result.stderr
|
||||||
|
|
||||||
|
|
||||||
|
def test_cli_missing_file_exits_2_with_message(tmp_path: Path) -> None:
|
||||||
|
result = _run_cli(str(tmp_path / "no-such.dbml"))
|
||||||
|
assert result.returncode == EXIT_USAGE
|
||||||
|
assert result.stdout == ""
|
||||||
|
assert "файл DBML не найден" in result.stderr
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_check_on_real_models_is_clean(dbml_file: Path) -> None:
|
||||||
|
assert run_check(dbml_file) == (0, [])
|
||||||
|
|
||||||
|
|
||||||
|
# --- сценарий «Лишняя колонка в ORM» ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_extra_orm_column_is_reported_missing_in_dbml(dbml_file: Path) -> None:
|
||||||
|
metadata = clone_metadata()
|
||||||
|
metadata.tables["dkb.osv_entry"].append_column(Column("extra_note", String()))
|
||||||
|
assert run_check(dbml_file, metadata=metadata) == (1, ["osv_entry.extra_note: отсутствует в DBML"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_removed_orm_column_is_reported_missing_in_orm(dbml_file: Path) -> None:
|
||||||
|
metadata = clone_metadata()
|
||||||
|
osv_entry = metadata.tables["dkb.osv_entry"]
|
||||||
|
osv_entry._columns.remove(osv_entry.columns["opening_credit"]) # noqa: SLF001 — намеренная порча копии
|
||||||
|
assert run_check(dbml_file, metadata=metadata) == (1, ["osv_entry.opening_credit: отсутствует в ORM"])
|
||||||
|
|
||||||
|
|
||||||
|
# --- сценарий «Расхождение типа или nullability» --------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_type_and_nullability_mismatch_is_one_line_in_dbml_notation(dbml_file: Path) -> None:
|
||||||
|
metadata = clone_metadata()
|
||||||
|
column = metadata.tables["dkb.osv_entry"].columns["opening_debit"]
|
||||||
|
column.type = Numeric(18, 4)
|
||||||
|
column.nullable = False
|
||||||
|
assert run_check(dbml_file, metadata=metadata) == (
|
||||||
|
1,
|
||||||
|
["osv_entry.opening_debit: ожидалось numeric(18,2) NULL / фактически numeric(18,4) NOT NULL"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_nullability_only_mismatch(dbml_file: Path) -> None:
|
||||||
|
metadata = clone_metadata()
|
||||||
|
metadata.tables["dkb.osv_entry"].columns["opening_debit"].nullable = False
|
||||||
|
code, lines = run_check(dbml_file, metadata=metadata)
|
||||||
|
assert (code, lines) == (
|
||||||
|
1,
|
||||||
|
["osv_entry.opening_debit: ожидалось numeric(18,2) NULL / фактически numeric(18,2) NOT NULL"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- таблицы и enum'ы: сравнение множеств ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_and_extra_tables_are_reported(dbml_file: Path) -> None:
|
||||||
|
metadata = clone_metadata()
|
||||||
|
metadata.remove(metadata.tables["dkb.consolidation_lock"])
|
||||||
|
code, lines = run_check(dbml_file, metadata=metadata)
|
||||||
|
assert code == 1
|
||||||
|
assert "consolidation_lock: отсутствует в ORM" in lines
|
||||||
|
metadata = clone_metadata()
|
||||||
|
Table("orphan_table", metadata, Column("id", String(), primary_key=True))
|
||||||
|
assert run_check(dbml_file, metadata=metadata) == (1, ["orphan_table: отсутствует в DBML"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_enum_values_and_sets_are_compared(dbml_file: Path) -> None:
|
||||||
|
enums: dict[str, type[enum.Enum]] = dict(ENUMS)
|
||||||
|
enums["entity_type"] = enum.Enum("EntityType", {"ИП": "ИП", "ЮЛ": "ЮЛ"}) # порядок значений нарушен
|
||||||
|
del enums["audit_section"]
|
||||||
|
enums["no_such_enum"] = enum.Enum("NoSuch", {"A": "A"})
|
||||||
|
code, lines = run_check(dbml_file, enums=enums)
|
||||||
|
assert code == 1
|
||||||
|
assert sorted(lines) == sorted(
|
||||||
|
[
|
||||||
|
"enum entity_type: ожидалось ['ЮЛ', 'ИП'] / фактически ['ИП', 'ЮЛ']",
|
||||||
|
"enum audit_section: отсутствует в ORM",
|
||||||
|
"enum no_such_enum: отсутствует в DBML",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- сценарий «Новая версия DBML без обновления ORM» -----------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_newer_dbml_version_fails_before_comparing_tables(tmp_path: Path, dbml_file: Path) -> None:
|
||||||
|
lines = dbml_file.read_text(encoding="utf-8").splitlines()
|
||||||
|
newer = tmp_path / "data-model-v5.14.dbml"
|
||||||
|
# шапка 5.14 + заведомо сломанное тело: если бы таблицы сравнивались, были бы другие строки/ошибка разбора
|
||||||
|
newer.write_text(lines[0].replace("v5.13", "v5.14") + "\nTable broken {{{\n", encoding="utf-8")
|
||||||
|
assert run_check(newer) == (1, ["версия DBML 5.14 не совпадает с версией ORM 5.13: обновите модели"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_newer_dbml_version_via_cli(tmp_path: Path, dbml_file: Path) -> None:
|
||||||
|
newer = tmp_path / "data-model-v5.14.dbml"
|
||||||
|
newer.write_text(dbml_file.read_text(encoding="utf-8").replace("v5.13", "v5.14", 1), encoding="utf-8")
|
||||||
|
result = _run_cli(str(newer))
|
||||||
|
assert result.returncode == 1
|
||||||
|
assert result.stdout == "версия DBML 5.14 не совпадает с версией ORM 5.13: обновите модели\n"
|
||||||
|
|
||||||
|
|
||||||
|
def test_header_without_version_is_usage_error(tmp_path: Path) -> None:
|
||||||
|
bad = tmp_path / "model.dbml"
|
||||||
|
bad.write_text("// модель без версии\nTable t {\n id uuid [pk]\n}\n", encoding="utf-8")
|
||||||
|
code, lines = run_check(bad)
|
||||||
|
assert code == EXIT_USAGE
|
||||||
|
assert len(lines) == 1 and "версия" in lines[0]
|
||||||
@ -1,4 +1,5 @@
|
|||||||
"""Самопроверка сверки ORM с DBML (`dbml_parity`): компаратор обязан находить заведомо неверные расхождения."""
|
"""Самопроверка сверки ORM с DBML (`scripts.dbml_parity`): компаратор обязан находить заведомо неверные расхождения
|
||||||
|
и описывать их строками единого формата `<объект>: ожидалось X / фактически Y` / `<объект>: отсутствует в ORM|DBML`."""
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from pydbml.classes import Expression
|
from pydbml.classes import Expression
|
||||||
@ -41,22 +42,26 @@ def test_clone_is_clean_baseline() -> None:
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
("table", "column", "wrong_type", "needle"),
|
("table", "column", "wrong_type", "expected"),
|
||||||
[
|
[
|
||||||
("regional_office", "name", Text(), "name: тип"), # String -> Text
|
("regional_office", "name", Text(), "varchar NOT NULL / фактически text NOT NULL"), # String -> Text
|
||||||
("regional_office", "name", String(50), "name: тип"), # varchar с длиной
|
("regional_office", "name", String(50), "varchar NOT NULL / фактически varchar(50) NOT NULL"), # с длиной
|
||||||
("app_user", "created_at", DateTime(), "created_at: тип"), # без timezone
|
("app_user", "created_at", DateTime(), "timestamptz NULL / фактически timestamp NULL"), # без timezone
|
||||||
("elimination_rule", "rule_no", Integer(), "rule_no: тип"), # smallint -> int
|
("elimination_rule", "rule_no", Integer(), "smallint NOT NULL / фактически int NOT NULL"),
|
||||||
("elimination_rule", "valid_from", DateTime(timezone=True), "valid_from: тип"), # date -> timestamptz
|
("elimination_rule", "valid_from", DateTime(timezone=True), "date NOT NULL / фактически timestamptz NOT NULL"),
|
||||||
("permission", "action", pg_enum("permission_object"), "action: тип"), # не тот enum
|
( # не тот enum
|
||||||
("app_user", "regional_office_id", String(), "regional_office_id: тип"), # uuid -> varchar
|
"permission",
|
||||||
|
"action",
|
||||||
|
pg_enum("permission_object"),
|
||||||
|
"permission_action NOT NULL / фактически permission_object NOT NULL",
|
||||||
|
),
|
||||||
|
("app_user", "regional_office_id", String(), "uuid NOT NULL / фактически varchar NOT NULL"), # uuid -> varchar
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_wrong_column_type_is_detected(table: str, column: str, wrong_type: object, needle: str) -> None:
|
def test_wrong_column_type_is_detected(table: str, column: str, wrong_type: object, expected: str) -> None:
|
||||||
metadata = clone_metadata()
|
metadata = clone_metadata()
|
||||||
metadata.tables[f"dkb.{table}"].columns[column].type = wrong_type # type: ignore[assignment]
|
metadata.tables[f"dkb.{table}"].columns[column].type = wrong_type # type: ignore[assignment]
|
||||||
problems = find_problems(metadata, TABLES)
|
assert find_problems(metadata, TABLES) == [f"{table}.{column}: ожидалось {expected}"]
|
||||||
assert any(needle in p and p.startswith(f"{table}.") for p in problems), problems
|
|
||||||
|
|
||||||
|
|
||||||
def test_enum_with_wrong_schema_or_values_is_detected() -> None:
|
def test_enum_with_wrong_schema_or_values_is_detected() -> None:
|
||||||
@ -109,10 +114,11 @@ def test_missing_wrong_or_extra_server_default_is_detected() -> None:
|
|||||||
cols["is_active"].server_default = None
|
cols["is_active"].server_default = None
|
||||||
cols["is_head_office"].server_default = _default("true") # в DBML false
|
cols["is_head_office"].server_default = _default("true") # в DBML false
|
||||||
cols["name"].server_default = _default("'x'") # в DBML default нет
|
cols["name"].server_default = _default("'x'") # в DBML default нет
|
||||||
problems = find_problems(metadata, TABLES)
|
assert sorted(find_problems(metadata, TABLES)) == [
|
||||||
assert any("is_active: нет server_default" in p for p in problems), problems
|
"regional_office.is_active: default: ожидалось true / фактически нет",
|
||||||
assert any("is_head_office: server_default ожидался false, фактически true" in p for p in problems), problems
|
"regional_office.is_head_office: default: ожидалось false / фактически true",
|
||||||
assert any("name: лишний server_default" in p for p in problems), problems
|
"regional_office.name: default: ожидалось нет / фактически 'x'",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def _default(sql: str) -> object:
|
def _default(sql: str) -> object:
|
||||||
@ -124,12 +130,23 @@ def test_nullable_pk_unique_and_order_mismatches_are_detected() -> None:
|
|||||||
ro = metadata.tables["dkb.regional_office"]
|
ro = metadata.tables["dkb.regional_office"]
|
||||||
ro.columns["name"].nullable = True
|
ro.columns["name"].nullable = True
|
||||||
ro.columns["name"].unique = None
|
ro.columns["name"].unique = None
|
||||||
problems = find_problems(metadata, {"regional_office"})
|
assert find_problems(metadata, {"regional_office"}) == [
|
||||||
assert any("name: nullable" in p for p in problems), problems
|
"regional_office.name: ожидалось varchar NOT NULL / фактически varchar NULL",
|
||||||
assert any("name: unique" in p for p in problems), problems
|
"regional_office.name: unique: ожидалось да / фактически нет",
|
||||||
|
]
|
||||||
|
ro.columns["id"].primary_key = False
|
||||||
|
assert "regional_office.id: первичный ключ: ожидалось да / фактически нет" in find_problems(
|
||||||
|
metadata, {"regional_office"}
|
||||||
|
)
|
||||||
perm = metadata.tables["dkb.permission"]
|
perm = metadata.tables["dkb.permission"]
|
||||||
perm._columns.remove(perm.columns["name"]) # noqa: SLF001 — намеренная порча копии
|
name_col = perm.columns["name"]
|
||||||
assert any("колонки/порядок" in p for p in find_problems(metadata, {"permission"}))
|
perm._columns.remove(name_col) # noqa: SLF001 — намеренная порча копии
|
||||||
|
assert find_problems(metadata, {"permission"}) == ["permission.name: отсутствует в ORM"]
|
||||||
|
perm.append_column(name_col) # вернулась в конец: состав совпал, порядок — нет
|
||||||
|
[problem] = find_problems(metadata, {"permission"})
|
||||||
|
assert problem.startswith(
|
||||||
|
"permission: порядок колонок: ожидалось ['id', 'code', 'object_type', 'action', 'name'"
|
||||||
|
), problem
|
||||||
|
|
||||||
|
|
||||||
def test_missing_extra_and_wrong_where_indexes_are_detected() -> None:
|
def test_missing_extra_and_wrong_where_indexes_are_detected() -> None:
|
||||||
@ -137,15 +154,24 @@ def test_missing_extra_and_wrong_where_indexes_are_detected() -> None:
|
|||||||
member = metadata.tables["dkb.related_group_member"]
|
member = metadata.tables["dkb.related_group_member"]
|
||||||
index = next(i for i in member.indexes if i.name == "uq_related_group_member_entity_current")
|
index = next(i for i in member.indexes if i.name == "uq_related_group_member_entity_current")
|
||||||
index.dialect_options["postgresql"]["where"] = text("is_current AND valid_to IS NULL")
|
index.dialect_options["postgresql"]["where"] = text("is_current AND valid_to IS NULL")
|
||||||
assert any("индексы" in p for p in find_problems(metadata, {"related_group_member"}))
|
label = "related_group_member: индекс (legal_entity_id)"
|
||||||
|
assert find_problems(metadata, {"related_group_member"}) == [
|
||||||
|
f"{label}: ожидалось unique WHERE is_current / фактически unique WHERE is_current and valid_to is null"
|
||||||
|
]
|
||||||
index.dialect_options["postgresql"]["where"] = None # partial превращён в обычный unique
|
index.dialect_options["postgresql"]["where"] = None # partial превращён в обычный unique
|
||||||
assert any("индексы" in p for p in find_problems(metadata, {"related_group_member"}))
|
assert find_problems(metadata, {"related_group_member"}) == [
|
||||||
|
f"{label}: ожидалось unique WHERE is_current / фактически unique"
|
||||||
|
]
|
||||||
index.dialect_options["postgresql"]["where"] = text("is_current")
|
index.dialect_options["postgresql"]["where"] = text("is_current")
|
||||||
assert find_problems(metadata, {"related_group_member"}) == []
|
assert find_problems(metadata, {"related_group_member"}) == []
|
||||||
# лишний partial там, где DBML его не объявляет
|
# лишний partial там, где DBML его не объявляет
|
||||||
ro = metadata.tables["dkb.regional_office"]
|
ro = metadata.tables["dkb.regional_office"]
|
||||||
Index("ix_regional_office_extra", ro.columns["name"], postgresql_where=text("is_active"))
|
Index("ix_regional_office_extra", ro.columns["name"], postgresql_where=text("is_active"))
|
||||||
assert any("индексы" in p for p in find_problems(metadata, {"regional_office"}))
|
assert find_problems(metadata, {"regional_office"}) == ["regional_office: индекс (name): отсутствует в DBML"]
|
||||||
|
# пропавший индекс
|
||||||
|
index.dialect_options["postgresql"]["where"] = text("is_current")
|
||||||
|
member.indexes.discard(index)
|
||||||
|
assert find_problems(metadata, {"related_group_member"}) == [f"{label}: отсутствует в ORM"]
|
||||||
|
|
||||||
|
|
||||||
def test_nulls_not_distinct_flag_is_compared_both_ways() -> None:
|
def test_nulls_not_distinct_flag_is_compared_both_ways() -> None:
|
||||||
@ -171,12 +197,14 @@ def test_missing_and_extra_foreign_keys_are_detected() -> None:
|
|||||||
fk = next(iter(app_user.foreign_keys))
|
fk = next(iter(app_user.foreign_keys))
|
||||||
app_user.foreign_keys.remove(fk)
|
app_user.foreign_keys.remove(fk)
|
||||||
app_user.constraints.discard(fk.constraint) # type: ignore[arg-type]
|
app_user.constraints.discard(fk.constraint) # type: ignore[arg-type]
|
||||||
assert any("FK отсутствует" in p for p in find_problems(metadata, {"app_user"}))
|
assert find_problems(metadata, {"app_user"}) == [
|
||||||
|
f"app_user.{fk.parent.name}: FK: ожидалось {fk.column.table.name}.{fk.column.name} / фактически нет"
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_unknown_table_is_reported() -> None:
|
def test_unknown_table_is_reported() -> None:
|
||||||
assert find_problems(Base.metadata, {"no_such_table"}) == ["no_such_table: нет в DBML"]
|
assert find_problems(Base.metadata, {"no_such_table"}) == ["no_such_table: отсутствует в DBML"]
|
||||||
assert find_problems(MetaData(schema="dkb"), {"regional_office"}) == ["regional_office: нет в ORM-метаданных"]
|
assert find_problems(MetaData(schema="dkb"), {"regional_office"}) == ["regional_office: отсутствует в ORM"]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user