arm-core/api/scripts/dbml_parity.py
Andrey Epifancev 2db0a66a86 feat(models): DBML v5.19 — колонки форм, document_issue, file_ordinal, crm_id ГСК (миграция 0007)
Модели и upgrade ревизии 0007_dbml_v5_19: пять enum'ов, колонки строк/значений/колонок/шапок форм,
document.file_ordinal (уникален в файле, уникальность типа только для не-форм), recognition_job.statements_version,
related_group.crm_id, таблица document_issue; заполнение по умолчанию (row_index, needs_review, header_source и др.).
delete_file_statements удаляет document_issue; make_document берёт следующий file_ordinal; маппер пока нумерует
документы по порядку создания. downgrade — заглушка (задача A2).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W
2026-10-18 19:04:40 +04:00

452 lines
24 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 (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: ожидалось родитель.колонка / фактически …`), у совпавших целей —
ON DELETE / ON UPDATE (нет в DBML = `NO ACTION`), DEFERRABLE и INITIALLY;
- индексы по набору колонок: уникальность, partial-условие (note `partial UNIQUE WHERE <expr>`), `NULLS NOT DISTINCT`;
табличный `UniqueConstraint` ORM считается уникальным индексом; составной `[pk]` в Indexes DBML — это первичный ключ,
а не индекс; CHECK-ограничений в DBML нет — любое CHECK в ORM лишнее;
- 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,
CheckConstraint,
Column,
Date,
DateTime,
Integer,
MetaData,
Numeric,
SmallInteger,
String,
Table,
Text,
UniqueConstraint,
)
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
#: Эталонная схема модели. Имя схемы в БД конфигурируемо (`DB_SCHEMA`), CLI сверяет ORM, загруженную с этим значением.
SCHEMA = "dkb"
DBML_FILE_NAME = "data-model-v5.19.dbml"
ORM = "ORM"
DBML = "DBML"
ABSENT = "нет"
NO_ACTION = "NO ACTION"
_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`; подкласс не должен «проходить» за базовый тип. Неизвестный класс отрисовывается как `repr` и всегда
расходится с DBML.
"""
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)
# неизвестный класс (в т.ч. `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:
"""Тип 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_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]:
"""FK по колонке-ссылке: множество целей `родитель.колонка`, затем у совпавших целей — ON DELETE/ON UPDATE и
DEFERRABLE/INITIALLY (в DBML их нет, значит в ORM их быть не должно)."""
expected: dict[tuple[str, str], dict[str, dict[str, 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:
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):
targets = expected.setdefault((child.name, child_col.name), {})
targets[f"{parent.name}.{parent_col.name}"] = props
actual: dict[tuple[str, str], dict[str, dict[str, str]]] = {}
for table in metadata.tables.values():
if table.name in names:
for fk in table.foreign_keys:
# DDL строится по ForeignKeyConstraint; ForeignKey(ondelete=...) копирует туда же — читаем оба
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 = []
for key in sorted(expected.keys() | actual.keys()):
want, have = expected.get(key, {}), actual.get(key, {})
label = f"{key[0]}.{key[1]}: FK"
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
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)
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 = []
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 _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)
]
# --- точки входа -----------------------------------------------------------------------------------------------
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])
problems += _check_problems(orm_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)