fix: сверка DBML — табличные UNIQUE и CHECK, действия FK, неизвестные классы типов

I-1: табличный UniqueConstraint ORM сверяется как уникальный индекс по колонкам
(флаг колонки unique=True — по-прежнему в свойствах колонки); любое CHECK в ORM —
«отсутствует в DBML».
I-2: у совпавших целей FK сравниваются ON DELETE / ON UPDATE (нет в DBML = NO ACTION),
DEFERRABLE и INITIALLY.
I-3: неизвестный класс типа отрисовывается repr с параметрами (VARCHAR(length=50),
Uuid(as_uuid=False)) и никогда не совпадает с именем типа DBML.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-03 20:11:25 +04:00
parent dd551faf17
commit 7ea1e67f4e
2 changed files with 122 additions and 13 deletions

View File

@ -6,9 +6,11 @@
- множество таблиц (`таблица: отсутствует в ORM|DBML`) и схема таблиц ORM (`dkb`); - множество таблиц (`таблица: отсутствует в ORM|DBML`) и схема таблиц ORM (`dkb`);
- колонки: состав (`таблица.колонка: отсутствует в ORM|DBML`), порядок общих колонок, тип + nullability одной строкой - колонки: состав (`таблица.колонка: отсутствует в ORM|DBML`), порядок общих колонок, тип + nullability одной строкой
в нотации DBML (`ожидалось numeric(18,2) NULL / фактически numeric(18,4) NOT NULL`), PK, unique, default; в нотации DBML (`ожидалось numeric(18,2) NULL / фактически numeric(18,4) NOT NULL`), PK, unique, default;
- FK по колонке-ссылке (`таблица.колонка: FK: ожидалось родитель.колонка / фактически …`); - FK по колонке-ссылке (`таблица.колонка: FK: ожидалось родитель.колонка / фактически …`), у совпавших целей —
ON DELETE / ON UPDATE (нет в DBML = `NO ACTION`), DEFERRABLE и INITIALLY;
- индексы по набору колонок: уникальность, partial-условие (note `partial UNIQUE WHERE <expr>`), `NULLS NOT DISTINCT`; - индексы по набору колонок: уникальность, partial-условие (note `partial UNIQUE WHERE <expr>`), `NULLS NOT DISTINCT`;
составной `[pk]` в Indexes DBML — это первичный ключ, а не индекс; табличный `UniqueConstraint` ORM считается уникальным индексом; составной `[pk]` в Indexes DBML — это первичный ключ,
а не индекс; CHECK-ограничений в DBML нет — любое CHECK в ORM лишнее;
- enum'ы: множество имён и упорядоченный список значений. - enum'ы: множество имён и упорядоченный список значений.
Формат каждой строки — `<объект>: ожидалось X / фактически Y` или `<объект>: отсутствует в ORM|DBML` (функции Формат каждой строки — `<объект>: ожидалось X / фактически Y` или `<объект>: отсутствует в ORM|DBML` (функции
@ -35,6 +37,7 @@ from sqlalchemy import (
JSON, JSON,
BigInteger, BigInteger,
Boolean, Boolean,
CheckConstraint,
Column, Column,
Date, Date,
DateTime, DateTime,
@ -45,6 +48,7 @@ from sqlalchemy import (
String, String,
Table, Table,
Text, Text,
UniqueConstraint,
) )
from sqlalchemy import Enum as SAEnum from sqlalchemy import Enum as SAEnum
from sqlalchemy.dialects.postgresql import JSONB, UUID from sqlalchemy.dialects.postgresql import JSONB, UUID
@ -56,6 +60,7 @@ DBML_FILE_NAME = "data-model-v5.13.dbml"
ORM = "ORM" ORM = "ORM"
DBML = "DBML" DBML = "DBML"
ABSENT = "нет" ABSENT = "нет"
NO_ACTION = "NO ACTION"
_NUMERIC_RE = re.compile(r"numeric\((\d+),\s*(\d+)\)") _NUMERIC_RE = re.compile(r"numeric\((\d+),\s*(\d+)\)")
_VERSION_RE = re.compile(r"v(\d+\.\d+)") _VERSION_RE = re.compile(r"v(\d+\.\d+)")
@ -130,7 +135,8 @@ def render_orm_type(orm_type: object) -> str:
"""SQL-тип ORM-колонки в нотации DBML (`numeric(18,2)`, `varchar`, `timestamptz`, имя enum, ...). """SQL-тип ORM-колонки в нотации DBML (`numeric(18,2)`, `varchar`, `timestamptz`, имя enum, ...).
Классы сравниваются точно: `SmallInteger` — подкласс `Integer`, `Text` — подкласс `String`, `Float` — подкласс Классы сравниваются точно: `SmallInteger` — подкласс `Integer`, `Text` — подкласс `String`, `Float` — подкласс
`Numeric`; подкласс не должен «проходить» за базовый тип. `Numeric`; подкласс не должен «проходить» за базовый тип. Неизвестный класс отрисовывается как `repr` и всегда
расходится с DBML.
""" """
if isinstance(orm_type, SAEnum): if isinstance(orm_type, SAEnum):
return str(orm_type.name) if orm_type.schema == SCHEMA else f"{orm_type.schema}.{orm_type.name}" return str(orm_type.name) if orm_type.schema == SCHEMA else f"{orm_type.schema}.{orm_type.name}"
@ -139,7 +145,9 @@ def render_orm_type(orm_type: object) -> str:
cls = type(orm_type) cls = type(orm_type)
if cls in _PARAMETRIZED_TYPES: if cls in _PARAMETRIZED_TYPES:
return _PARAMETRIZED_TYPES[cls](orm_type) return _PARAMETRIZED_TYPES[cls](orm_type)
return _SIMPLE_TYPE_NAMES.get(cls, cls.__name__.lower()) # неизвестный класс (в т.ч. `VARCHAR(50)`, `Uuid(as_uuid=False)`, подклассы) — repr с параметрами: он начинается
# с имени класса в CamelCase и потому никогда не совпадёт с именем типа DBML; длина/флаги не теряются молча
return _SIMPLE_TYPE_NAMES.get(cls) or repr(orm_type)
def render_dbml_type(dbml_type: object) -> str | None: def render_dbml_type(dbml_type: object) -> str | None:
@ -269,28 +277,59 @@ def _one_column_problems(label: str, orm_col: Column[Any], dbml_col: DbmlColumn,
# --- FK -------------------------------------------------------------------------------------------------------- # --- FK --------------------------------------------------------------------------------------------------------
def _fk_action(action: object) -> str:
"""Действие FK в каноническом виде; отсутствие действия в DBML и ORM — это `NO ACTION` PostgreSQL."""
return " ".join(str(action).split()).upper() if action else NO_ACTION
def _fk_initially(initially: object) -> str:
return str(initially).upper() if initially else ABSENT
def _fk_problems(metadata: MetaData, dbml: PyDBML, names: Collection[str]) -> list[str]: def _fk_problems(metadata: MetaData, dbml: PyDBML, names: Collection[str]) -> list[str]:
"""FK по колонке-ссылке: множество целей `родитель.колонка` в DBML против ORM.""" """FK по колонке-ссылке: множество целей `родитель.колонка`, затем у совпавших целей — ON DELETE/ON UPDATE и
expected: dict[tuple[str, str], set[str]] = {} DEFERRABLE/INITIALLY (в DBML их нет, значит в ORM их быть не должно)."""
expected: dict[tuple[str, str], dict[str, dict[str, str]]] = {}
for ref in dbml.refs: for ref in dbml.refs:
# `>`: table1 ссылается на table2 (many-to-one); `<` — наоборот; `-` (one-to-one) читаем как `>` # `>`: table1 ссылается на table2 (many-to-one); `<` — наоборот; `-` (one-to-one) читаем как `>`
child, child_cols, parent, parent_cols = ref.table1, ref.col1, ref.table2, ref.col2 child, child_cols, parent, parent_cols = ref.table1, ref.col1, ref.table2, ref.col2
if ref.type == "<": if ref.type == "<":
child, child_cols, parent, parent_cols = parent, parent_cols, child, child_cols child, child_cols, parent, parent_cols = parent, parent_cols, child, child_cols
if child.name in names: if child.name in names:
props = {
"ON DELETE": _fk_action(ref.on_delete),
"ON UPDATE": _fk_action(ref.on_update),
"DEFERRABLE": _yes_no(False),
"INITIALLY": ABSENT,
}
for child_col, parent_col in zip(child_cols, parent_cols, strict=True): 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}") targets = expected.setdefault((child.name, child_col.name), {})
actual: dict[tuple[str, str], set[str]] = {} targets[f"{parent.name}.{parent_col.name}"] = props
actual: dict[tuple[str, str], dict[str, dict[str, str]]] = {}
for table in metadata.tables.values(): for table in metadata.tables.values():
if table.name in names: if table.name in names:
for fk in table.foreign_keys: for fk in table.foreign_keys:
target = f"{fk.column.table.name}.{fk.column.name}" # DDL строится по ForeignKeyConstraint; ForeignKey(ondelete=...) копирует туда же — читаем оба
actual.setdefault((table.name, fk.parent.name), set()).add(target) constraint = fk.constraint
props = {
"ON DELETE": _fk_action(getattr(constraint, "ondelete", None) or fk.ondelete),
"ON UPDATE": _fk_action(getattr(constraint, "onupdate", None) or fk.onupdate),
"DEFERRABLE": _yes_no(bool(getattr(constraint, "deferrable", None) or fk.deferrable)),
"INITIALLY": _fk_initially(getattr(constraint, "initially", None) or fk.initially),
}
targets = actual.setdefault((table.name, fk.parent.name), {})
targets[f"{fk.column.table.name}.{fk.column.name}"] = props
problems = [] problems = []
for key in sorted(expected.keys() | actual.keys()): for key in sorted(expected.keys() | actual.keys()):
want, have = expected.get(key, set()), actual.get(key, set()) want, have = expected.get(key, {}), actual.get(key, {})
if want != have: label = f"{key[0]}.{key[1]}: FK"
problems.append(mismatch(f"{key[0]}.{key[1]}: FK", _targets(want), _targets(have))) if want.keys() != have.keys():
problems.append(mismatch(label, _targets(set(want)), _targets(set(have))))
continue
for target in sorted(want):
for prop, value in want[target].items():
if have[target][prop] != value:
problems.append(mismatch(f"{label} {prop}", value, have[target][prop]))
return problems return problems
@ -338,6 +377,13 @@ def _index_problems(orm_table: Table, dbml_table: DbmlTable) -> list[str]:
options = orm_index.dialect_options["postgresql"] options = orm_index.dialect_options["postgresql"]
props = _index_props(bool(orm_index.unique), _norm_where(options["where"]), bool(options["nulls_not_distinct"])) 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) actual.setdefault(tuple(c.name for c in orm_index.columns), []).append(props)
for constraint in orm_table.constraints:
# табличный UniqueConstraint сверяется как уникальный индекс по тем же колонкам; ограничение, созданное
# флагом колонки `unique=True` (`_column_flag`), уже сверено в свойствах колонки
if isinstance(constraint, UniqueConstraint) and not getattr(constraint, "_column_flag", False):
options = constraint.dialect_options["postgresql"]
props = _index_props(True, None, bool(options["nulls_not_distinct"]))
actual.setdefault(tuple(c.name for c in constraint.columns), []).append(props)
problems = [] problems = []
for columns in sorted(expected.keys() | actual.keys()): for columns in sorted(expected.keys() | actual.keys()):
label = f"{dbml_table.name}: индекс ({', '.join(columns)})" label = f"{dbml_table.name}: индекс ({', '.join(columns)})"
@ -351,6 +397,15 @@ def _index_problems(orm_table: Table, dbml_table: DbmlTable) -> list[str]:
return problems return problems
def _check_problems(orm_table: Table) -> list[str]:
"""CHECK-ограничения: в DBML их нет (типы и enum'ы — нативные), любое CHECK в ORM — лишнее."""
return [
missing(f"{orm_table.name}: CHECK {constraint.sqltext}", DBML)
for constraint in orm_table.constraints
if isinstance(constraint, CheckConstraint)
]
# --- точки входа ----------------------------------------------------------------------------------------------- # --- точки входа -----------------------------------------------------------------------------------------------
@ -369,6 +424,7 @@ def find_problems(metadata: MetaData, tables: Collection[str], dbml: PyDBML) ->
problems.append(mismatch(f"{name}: схема", SCHEMA, orm_tables[name].schema)) problems.append(mismatch(f"{name}: схема", SCHEMA, orm_tables[name].schema))
problems += _column_problems(orm_tables[name], dbml_tables[name]) problems += _column_problems(orm_tables[name], dbml_tables[name])
problems += _index_problems(orm_tables[name], dbml_tables[name]) problems += _index_problems(orm_tables[name], dbml_tables[name])
problems += _check_problems(orm_tables[name])
return problems + _fk_problems(metadata, dbml, {n for n in tables if n in dbml_tables}) return problems + _fk_problems(metadata, dbml, {n for n in tables if n in dbml_tables})

View File

@ -4,8 +4,10 @@
import pytest import pytest
from pydbml.classes import Expression from pydbml.classes import Expression
from sqlalchemy import ( from sqlalchemy import (
VARCHAR,
BigInteger, BigInteger,
Boolean, Boolean,
CheckConstraint,
Date, Date,
DateTime, DateTime,
DefaultClause, DefaultClause,
@ -16,10 +18,13 @@ from sqlalchemy import (
SmallInteger, SmallInteger,
String, String,
Text, Text,
UniqueConstraint,
Uuid,
func, func,
text, text,
) )
from sqlalchemy.dialects.postgresql import JSON, JSONB, UUID from sqlalchemy.dialects.postgresql import JSON, JSONB, UUID
from sqlalchemy.dialects.postgresql import VARCHAR as PG_VARCHAR
from src.models import Base from src.models import Base
from src.models.enums import pg_enum from src.models.enums import pg_enum
@ -98,12 +103,21 @@ def _dbml_enum(name: str) -> object:
(String(), "text", False), (String(), "text", False),
(String(), "varchar", True), (String(), "varchar", True),
(DateTime(timezone=True), "timestamptz", True), (DateTime(timezone=True), "timestamptz", True),
# неизвестные/производные классы не должны терять параметры и «проходить» за тип DBML
(VARCHAR(50), "varchar", False),
(PG_VARCHAR(10), "varchar", False),
(Uuid(as_uuid=False), "uuid", False),
], ],
) )
def test_type_mapper(orm_type: object, dbml_type: str, ok: bool) -> None: def test_type_mapper(orm_type: object, dbml_type: str, ok: bool) -> None:
assert (type_problem(orm_type, dbml_type) is None) is ok assert (type_problem(orm_type, dbml_type) is None) is ok
def test_unknown_orm_type_class_keeps_parameters_in_report() -> None:
assert type_problem(VARCHAR(50), "varchar") == "ожидалось varchar / фактически VARCHAR(length=50)"
assert type_problem(Uuid(as_uuid=False), "uuid") == "ожидалось uuid / фактически Uuid(as_uuid=False)"
def test_unknown_dbml_type_is_reported_not_ignored() -> None: def test_unknown_dbml_type_is_reported_not_ignored() -> None:
assert "не поддержан" in (type_problem(String(), "geometry") or "") assert "не поддержан" in (type_problem(String(), "geometry") or "")
@ -230,3 +244,42 @@ def test_now_default_forms_agree() -> None:
== canonical_default_orm(DefaultClause(text("CURRENT_TIMESTAMP"))) == canonical_default_orm(DefaultClause(text("CURRENT_TIMESTAMP")))
) )
assert expected != canonical_default_dbml(False) assert expected != canonical_default_dbml(False)
def test_table_level_unique_constraint_is_compared_as_unique_index() -> None:
"""Табличный UniqueConstraint ORM, которого нет в DBML, — расхождение (флаг колонки `unique=True` — не он)."""
metadata = clone_metadata()
metadata.tables["dkb.regional_office"].append_constraint(UniqueConstraint("name", "is_active"))
assert find_problems(metadata, {"regional_office"}) == [
"regional_office: индекс (name, is_active): отсутствует в DBML"
]
metadata.tables["dkb.permission"].append_constraint(UniqueConstraint("name"))
assert find_problems(metadata, {"permission"}) == ["permission: индекс (name): отсутствует в DBML"]
def test_check_constraint_is_reported_missing_in_dbml() -> None:
metadata = clone_metadata()
metadata.tables["dkb.elimination_rule"].append_constraint(CheckConstraint("rule_no > 0", name="rule_no_positive"))
assert find_problems(metadata, {"elimination_rule"}) == ["elimination_rule: CHECK rule_no > 0: отсутствует в DBML"]
def test_fk_actions_deferrable_and_initially_are_compared() -> None:
"""В DBML у ссылок нет действий (= NO ACTION) и DEFERRABLE: лишний CASCADE в ORM обязан всплыть."""
label = "app_user.regional_office_id: FK"
metadata = clone_metadata()
fk = next(f for f in metadata.tables["dkb.app_user"].foreign_keys if f.parent.name == "regional_office_id")
fk.ondelete = "CASCADE" # только на ForeignKey, ограничение не тронуто
assert find_problems(metadata, {"app_user"}) == [f"{label} ON DELETE: ожидалось NO ACTION / фактически CASCADE"]
fk.ondelete = None
constraint = fk.constraint
assert constraint is not None
constraint.onupdate = "set null"
constraint.deferrable = True
constraint.initially = "deferred"
assert find_problems(metadata, {"app_user"}) == [
f"{label} ON UPDATE: ожидалось NO ACTION / фактически SET NULL",
f"{label} DEFERRABLE: ожидалось нет / фактически да",
f"{label} INITIALLY: ожидалось нет / фактически DEFERRED",
]
constraint.onupdate, constraint.deferrable, constraint.initially = "NO ACTION", None, None
assert find_problems(metadata, {"app_user"}) == [] # явный NO ACTION равен отсутствию действия