arm-core/api/tests/dbml_parity.py
2026-10-03 19:22:29 +04:00

264 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Сверка ORM-метаданных с DBML v5.13 для заданного набора таблиц (без БД); переиспользуется тестами частей 01–09.
Публичное API:
- `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 Callable, Collection
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 (
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 tests.dbml_helpers import load_dbml
SCHEMA = "dkb"
_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]:
"""Все расхождения ORM (`metadata`) с DBML для таблиц `tables`; пустой список — совпадение."""
dbml = 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)