264 lines
12 KiB
Python
264 lines
12 KiB
Python
"""Сверка 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)
|