ormguard 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ormguard/__init__.py +42 -0
- ormguard/__main__.py +5 -0
- ormguard/_schema.py +75 -0
- ormguard/_version.py +24 -0
- ormguard/cli.py +120 -0
- ormguard/config.py +78 -0
- ormguard/core.py +71 -0
- ormguard/diff.py +256 -0
- ormguard/integrations/__init__.py +0 -0
- ormguard/integrations/fastapi.py +41 -0
- ormguard/model.py +111 -0
- ormguard/notify.py +44 -0
- ormguard/orm.py +80 -0
- ormguard/reflect.py +126 -0
- ormguard/selfcheck.py +63 -0
- ormguard/types.py +120 -0
- ormguard-0.1.0.dist-info/METADATA +197 -0
- ormguard-0.1.0.dist-info/RECORD +21 -0
- ormguard-0.1.0.dist-info/WHEEL +4 -0
- ormguard-0.1.0.dist-info/entry_points.txt +2 -0
- ormguard-0.1.0.dist-info/licenses/LICENSE +21 -0
ormguard/__init__.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""ormguard — fail-fast schema validation for SQLAlchemy.
|
|
2
|
+
|
|
3
|
+
Brings Hibernate's ``ddl-auto=validate`` to SQLAlchemy: at startup, reflect the
|
|
4
|
+
connected database and check that it matches your ORM entities. Catch
|
|
5
|
+
entity↔DB drift at boot instead of as a runtime ``column does not exist``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from importlib.metadata import PackageNotFoundError, version as _pkg_version
|
|
11
|
+
|
|
12
|
+
from .config import Config
|
|
13
|
+
from .core import (
|
|
14
|
+
assert_schema,
|
|
15
|
+
format_matrix,
|
|
16
|
+
validate,
|
|
17
|
+
validate_many,
|
|
18
|
+
)
|
|
19
|
+
from .model import (
|
|
20
|
+
Finding,
|
|
21
|
+
SchemaValidationError,
|
|
22
|
+
Severity,
|
|
23
|
+
ValidationReport,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
try:
|
|
27
|
+
__version__ = _pkg_version("ormguard")
|
|
28
|
+
except PackageNotFoundError: # source tree without installed metadata
|
|
29
|
+
__version__ = "0.0.0+unknown"
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"validate",
|
|
33
|
+
"assert_schema",
|
|
34
|
+
"validate_many",
|
|
35
|
+
"format_matrix",
|
|
36
|
+
"Config",
|
|
37
|
+
"Severity",
|
|
38
|
+
"Finding",
|
|
39
|
+
"ValidationReport",
|
|
40
|
+
"SchemaValidationError",
|
|
41
|
+
"__version__",
|
|
42
|
+
]
|
ormguard/__main__.py
ADDED
ormguard/_schema.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Normalized, dialect-light schema representation shared by both sides
|
|
2
|
+
of the comparison (ORM-expected and DB-reflected)."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class ColumnInfo:
|
|
11
|
+
name: str
|
|
12
|
+
type_str: str # normalized (dialect-compiled, upper-cased) type
|
|
13
|
+
nullable: bool
|
|
14
|
+
primary_key: bool = False
|
|
15
|
+
has_server_default: bool = False # a DB-side default exists (value not compared)
|
|
16
|
+
enum_values: tuple[str, ...] | None = None # allowed values if this is an enum, else None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class CheckConstraintInfo:
|
|
21
|
+
# CHECK constraints are compared by name only — the expression text is
|
|
22
|
+
# rewritten by each dialect on reflection, so comparing it produces noise.
|
|
23
|
+
name: str
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class IndexInfo:
|
|
28
|
+
name: str
|
|
29
|
+
columns: tuple[str, ...] # in declared order
|
|
30
|
+
unique: bool = False
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def key(self) -> tuple[tuple[str, ...], bool]:
|
|
34
|
+
# Indexes are compared by column set + uniqueness, not by name —
|
|
35
|
+
# names differ between the ORM and the database and across dialects.
|
|
36
|
+
return (self.columns, self.unique)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class ForeignKeyInfo:
|
|
41
|
+
columns: tuple[str, ...] # local columns, in order
|
|
42
|
+
referred_table: str
|
|
43
|
+
referred_columns: tuple[str, ...]
|
|
44
|
+
name: str = ""
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def key(self) -> tuple[tuple[str, ...], str, tuple[str, ...]]:
|
|
48
|
+
# Compared by (local columns, referred table, referred columns), not by
|
|
49
|
+
# name — constraint names differ between the ORM and dialects.
|
|
50
|
+
return (self.columns, self.referred_table, self.referred_columns)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class TableInfo:
|
|
55
|
+
name: str
|
|
56
|
+
schema: str | None
|
|
57
|
+
columns: dict[str, ColumnInfo] = field(default_factory=dict)
|
|
58
|
+
indexes: dict[tuple[tuple[str, ...], bool], IndexInfo] = field(default_factory=dict)
|
|
59
|
+
foreign_keys: dict[tuple[tuple[str, ...], str, tuple[str, ...]], ForeignKeyInfo] = field(
|
|
60
|
+
default_factory=dict
|
|
61
|
+
)
|
|
62
|
+
checks: dict[str, CheckConstraintInfo] = field(default_factory=dict) # keyed by constraint name
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def key(self) -> tuple[str | None, str]:
|
|
66
|
+
return (self.schema, self.name)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def type_to_string(type_engine, dialect) -> str:
|
|
70
|
+
"""Best-effort normalized type string. Falls back to repr when a type
|
|
71
|
+
cannot be compiled for the given dialect (common for reflected types)."""
|
|
72
|
+
try:
|
|
73
|
+
return type_engine.compile(dialect=dialect).upper()
|
|
74
|
+
except Exception:
|
|
75
|
+
return str(type_engine).upper()
|
ormguard/_version.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# file generated by vcs-versioning
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"__version__",
|
|
7
|
+
"__version_tuple__",
|
|
8
|
+
"version",
|
|
9
|
+
"version_tuple",
|
|
10
|
+
"__commit_id__",
|
|
11
|
+
"commit_id",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
version: str
|
|
15
|
+
__version__: str
|
|
16
|
+
__version_tuple__: tuple[int | str, ...]
|
|
17
|
+
version_tuple: tuple[int | str, ...]
|
|
18
|
+
commit_id: str | None
|
|
19
|
+
__commit_id__: str | None
|
|
20
|
+
|
|
21
|
+
__version__ = version = '0.1.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 1, 0)
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = None
|
ormguard/cli.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Command-line interface: ``python -m ormguard``.
|
|
2
|
+
|
|
3
|
+
Validate a database against ORM metadata in CI. Exit code 1 on ERROR findings.
|
|
4
|
+
|
|
5
|
+
python -m ormguard --url postgresql://u:p@host/db --metadata myapp.db:Base --schema aivelabs_sv
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import importlib
|
|
12
|
+
import sys
|
|
13
|
+
|
|
14
|
+
from sqlalchemy import create_engine
|
|
15
|
+
|
|
16
|
+
from .config import Config
|
|
17
|
+
from .core import validate
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _force_utf8_output() -> None:
|
|
21
|
+
"""Emit UTF-8 even on legacy consoles (e.g. Windows cp949).
|
|
22
|
+
|
|
23
|
+
Report output contains characters like ``—`` and ``→``; without this, a
|
|
24
|
+
``print`` on a cp949 terminal raises ``UnicodeEncodeError``.
|
|
25
|
+
"""
|
|
26
|
+
for stream in (sys.stdout, sys.stderr):
|
|
27
|
+
reconfigure = getattr(stream, "reconfigure", None)
|
|
28
|
+
if reconfigure is not None:
|
|
29
|
+
try:
|
|
30
|
+
reconfigure(encoding="utf-8")
|
|
31
|
+
except (ValueError, OSError): # pragma: no cover - stream may not support it
|
|
32
|
+
pass
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _load_metadata(spec: str):
|
|
36
|
+
"""Load 'package.module:attr' where attr is a declarative Base or MetaData."""
|
|
37
|
+
if ":" not in spec:
|
|
38
|
+
raise SystemExit("--metadata must be 'package.module:attr' (e.g. myapp.db:Base)")
|
|
39
|
+
module_name, attr = spec.split(":", 1)
|
|
40
|
+
module = importlib.import_module(module_name)
|
|
41
|
+
return getattr(module, attr)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def main(argv: list[str] | None = None) -> int:
|
|
45
|
+
_force_utf8_output()
|
|
46
|
+
parser = argparse.ArgumentParser(prog="ormguard", description="Validate DB schema against SQLAlchemy ORM.")
|
|
47
|
+
parser.add_argument(
|
|
48
|
+
"--selfcheck", action="store_true",
|
|
49
|
+
help="run a self-contained demo against in-memory SQLite (no --url/--metadata needed)",
|
|
50
|
+
)
|
|
51
|
+
parser.add_argument("--url", help="SQLAlchemy database URL")
|
|
52
|
+
parser.add_argument("--metadata", help="'package.module:attr' (Base or MetaData)")
|
|
53
|
+
parser.add_argument("--schema", action="append", default=None, help="restrict to schema (repeatable)")
|
|
54
|
+
parser.add_argument("--check-types", action="store_true", help="also compare column types")
|
|
55
|
+
parser.add_argument("--check-indexes", action="store_true", help="also compare indexes (opt-in)")
|
|
56
|
+
parser.add_argument(
|
|
57
|
+
"--check-foreign-keys", action="store_true", help="also compare foreign keys (opt-in)",
|
|
58
|
+
)
|
|
59
|
+
parser.add_argument(
|
|
60
|
+
"--check-defaults", action="store_true",
|
|
61
|
+
help="also compare server-default presence (opt-in)",
|
|
62
|
+
)
|
|
63
|
+
parser.add_argument("--no-nullable", action="store_true", help="skip nullable comparison")
|
|
64
|
+
parser.add_argument("--no-extra", action="store_true", help="do not flag DB-only columns")
|
|
65
|
+
parser.add_argument("--ignore-table", action="append", default=[], help="table to skip (repeatable)")
|
|
66
|
+
parser.add_argument(
|
|
67
|
+
"--warn-only", action="store_true",
|
|
68
|
+
help="exit 0 even on errors (report only)",
|
|
69
|
+
)
|
|
70
|
+
parser.add_argument(
|
|
71
|
+
"--notify-webhook",
|
|
72
|
+
help="POST the report to a Slack/Discord incoming webhook when drift is found",
|
|
73
|
+
)
|
|
74
|
+
parser.add_argument(
|
|
75
|
+
"--notify-on", choices=("error", "any"), default="error",
|
|
76
|
+
help="send the webhook on 'error' findings (default) or on 'any' finding",
|
|
77
|
+
)
|
|
78
|
+
args = parser.parse_args(argv)
|
|
79
|
+
|
|
80
|
+
if args.selfcheck:
|
|
81
|
+
from .selfcheck import run_selfcheck
|
|
82
|
+
|
|
83
|
+
report = run_selfcheck()
|
|
84
|
+
return 1 if (report.has_errors() and not args.warn_only) else 0
|
|
85
|
+
|
|
86
|
+
if not args.url or not args.metadata:
|
|
87
|
+
parser.error("--url and --metadata are required (or use --selfcheck)")
|
|
88
|
+
|
|
89
|
+
config = Config(
|
|
90
|
+
schemas=set(args.schema) if args.schema else None,
|
|
91
|
+
check_types=args.check_types,
|
|
92
|
+
check_indexes=args.check_indexes,
|
|
93
|
+
check_foreign_keys=args.check_foreign_keys,
|
|
94
|
+
check_server_defaults=args.check_defaults,
|
|
95
|
+
check_nullable=not args.no_nullable,
|
|
96
|
+
flag_extra_columns=not args.no_extra,
|
|
97
|
+
ignore_tables=set(args.ignore_table),
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
target = _load_metadata(args.metadata)
|
|
101
|
+
engine = create_engine(args.url)
|
|
102
|
+
report = validate(engine, target, config)
|
|
103
|
+
|
|
104
|
+
print(report.format_text())
|
|
105
|
+
|
|
106
|
+
if args.notify_webhook:
|
|
107
|
+
should_notify = report.has_errors() if args.notify_on == "error" else bool(report.findings)
|
|
108
|
+
if should_notify:
|
|
109
|
+
from .notify import notify_webhook
|
|
110
|
+
|
|
111
|
+
if not notify_webhook(args.notify_webhook, report):
|
|
112
|
+
print("ormguard: warning — webhook notification failed", file=sys.stderr)
|
|
113
|
+
|
|
114
|
+
if report.has_errors() and not args.warn_only:
|
|
115
|
+
return 1
|
|
116
|
+
return 0
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
if __name__ == "__main__": # pragma: no cover
|
|
120
|
+
sys.exit(main())
|
ormguard/config.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Validation configuration for ormguard."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
from .model import (
|
|
8
|
+
CHECK_EXTRA,
|
|
9
|
+
CHECK_MISSING,
|
|
10
|
+
COLUMN_EXTRA,
|
|
11
|
+
DEFAULT_EXTRA,
|
|
12
|
+
DEFAULT_MISSING,
|
|
13
|
+
ENUM_MISMATCH,
|
|
14
|
+
FK_EXTRA,
|
|
15
|
+
FK_MISSING,
|
|
16
|
+
INDEX_EXTRA,
|
|
17
|
+
INDEX_MISSING,
|
|
18
|
+
NULLABLE_MISMATCH,
|
|
19
|
+
TYPE_MISMATCH,
|
|
20
|
+
Severity,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class Config:
|
|
26
|
+
"""Controls what ormguard checks and how loud each finding is.
|
|
27
|
+
|
|
28
|
+
The defaults are tuned for low false positives: presence checks are
|
|
29
|
+
ERROR (these are the cases that crash at runtime), structural nuances
|
|
30
|
+
default to WARN, and type comparison is off until you opt in.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
# Restrict validation to these schemas. None = every schema present in the
|
|
34
|
+
# ORM metadata. Useful for multi-schema setups (e.g. {"aivelabs_sv"}).
|
|
35
|
+
schemas: set[str] | None = None
|
|
36
|
+
|
|
37
|
+
# Tables to skip entirely (unqualified name, e.g. "alembic_version").
|
|
38
|
+
ignore_tables: set[str] = field(default_factory=set)
|
|
39
|
+
|
|
40
|
+
# Columns to skip, as "table.column" (e.g. "users.legacy_flag").
|
|
41
|
+
ignore_columns: set[str] = field(default_factory=set)
|
|
42
|
+
|
|
43
|
+
# Toggles.
|
|
44
|
+
check_nullable: bool = True
|
|
45
|
+
check_types: bool = False # dialect-dependent; opt in once tuned for your DB.
|
|
46
|
+
check_indexes: bool = False # dialect-dependent; opt in. Compares by column set + uniqueness.
|
|
47
|
+
check_foreign_keys: bool = False # opt in. Compares by (columns, referred table, referred columns).
|
|
48
|
+
check_server_defaults: bool = False # opt in. Compares presence of a DB default, not its value.
|
|
49
|
+
check_constraints: bool = False # opt in. Compares named CHECK constraints by name (not expression).
|
|
50
|
+
check_enums: bool = False # opt in. Compares an enum column's allowed values.
|
|
51
|
+
flag_extra_columns: bool = True # DB columns not present on the entity.
|
|
52
|
+
|
|
53
|
+
# Severity per kind — override to make e.g. nullable mismatches fatal.
|
|
54
|
+
severity_overrides: dict[str, Severity] = field(
|
|
55
|
+
default_factory=lambda: {
|
|
56
|
+
COLUMN_EXTRA: Severity.WARN,
|
|
57
|
+
NULLABLE_MISMATCH: Severity.WARN,
|
|
58
|
+
TYPE_MISMATCH: Severity.WARN,
|
|
59
|
+
INDEX_MISSING: Severity.WARN,
|
|
60
|
+
INDEX_EXTRA: Severity.WARN,
|
|
61
|
+
FK_MISSING: Severity.WARN,
|
|
62
|
+
FK_EXTRA: Severity.WARN,
|
|
63
|
+
DEFAULT_MISSING: Severity.WARN,
|
|
64
|
+
DEFAULT_EXTRA: Severity.WARN,
|
|
65
|
+
CHECK_MISSING: Severity.WARN,
|
|
66
|
+
CHECK_EXTRA: Severity.WARN,
|
|
67
|
+
ENUM_MISMATCH: Severity.WARN,
|
|
68
|
+
}
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
def severity_for(self, kind: str, default: Severity) -> Severity:
|
|
72
|
+
return self.severity_overrides.get(kind, default)
|
|
73
|
+
|
|
74
|
+
def is_table_ignored(self, table: str) -> bool:
|
|
75
|
+
return table in self.ignore_tables
|
|
76
|
+
|
|
77
|
+
def is_column_ignored(self, table: str, column: str) -> bool:
|
|
78
|
+
return f"{table}.{column}" in self.ignore_columns
|
ormguard/core.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Public validation entry points."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .config import Config
|
|
6
|
+
from .diff import diff_schemas
|
|
7
|
+
from .model import SchemaValidationError, ValidationReport
|
|
8
|
+
from .orm import build_expected
|
|
9
|
+
from .reflect import reflect_actual
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _resolve_metadata(target):
|
|
13
|
+
"""Accept either a declarative Base (has ``.metadata``) or a MetaData."""
|
|
14
|
+
return getattr(target, "metadata", target)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def validate(engine, target, config: Config | None = None, *, label: str | None = None) -> ValidationReport:
|
|
18
|
+
"""Validate ORM ``target`` (declarative Base or MetaData) against the schema
|
|
19
|
+
of the database behind ``engine``. Never raises on drift — inspect the
|
|
20
|
+
returned :class:`ValidationReport` (or call ``.raise_if_errors()``)."""
|
|
21
|
+
config = config or Config()
|
|
22
|
+
metadata = _resolve_metadata(target)
|
|
23
|
+
dialect = engine.dialect
|
|
24
|
+
|
|
25
|
+
expected = build_expected(metadata, dialect, config)
|
|
26
|
+
actual = reflect_actual(engine, expected, config)
|
|
27
|
+
findings = diff_schemas(expected, actual, config, dialect_name=dialect.name)
|
|
28
|
+
|
|
29
|
+
if label is None:
|
|
30
|
+
label = getattr(getattr(engine, "url", None), "database", None)
|
|
31
|
+
return ValidationReport(findings=findings, label=label)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def assert_schema(engine, target, config: Config | None = None, *, strict: bool = True) -> ValidationReport:
|
|
35
|
+
"""Validate and, when ``strict``, raise :class:`SchemaValidationError` if any
|
|
36
|
+
ERROR-level drift is found. Returns the report either way."""
|
|
37
|
+
report = validate(engine, target, config)
|
|
38
|
+
if strict:
|
|
39
|
+
report.raise_if_errors()
|
|
40
|
+
return report
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def validate_many(
|
|
44
|
+
engines: dict[str, object],
|
|
45
|
+
target,
|
|
46
|
+
config: Config | None = None,
|
|
47
|
+
) -> dict[str, ValidationReport]:
|
|
48
|
+
"""Validate the same ORM metadata against several databases (multi-tenant).
|
|
49
|
+
Keys are tenant labels; values are engines."""
|
|
50
|
+
return {
|
|
51
|
+
label: validate(engine, target, config, label=label)
|
|
52
|
+
for label, engine in engines.items()
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def format_matrix(reports: dict[str, ValidationReport]) -> str:
|
|
57
|
+
"""One-line-per-tenant summary for multi-tenant runs."""
|
|
58
|
+
lines = []
|
|
59
|
+
for label, rep in reports.items():
|
|
60
|
+
status = "OK" if rep.ok else f"{len(rep.errors)}E/{len(rep.warnings)}W"
|
|
61
|
+
lines.append(f"{label:<24} {status}")
|
|
62
|
+
return "\n".join(lines)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
__all__ = [
|
|
66
|
+
"validate",
|
|
67
|
+
"assert_schema",
|
|
68
|
+
"validate_many",
|
|
69
|
+
"format_matrix",
|
|
70
|
+
"SchemaValidationError",
|
|
71
|
+
]
|
ormguard/diff.py
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
"""Compare expected (ORM) vs actual (DB) schema and emit findings."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ._schema import TableInfo
|
|
6
|
+
from .config import Config
|
|
7
|
+
from .model import (
|
|
8
|
+
CHECK_EXTRA,
|
|
9
|
+
CHECK_MISSING,
|
|
10
|
+
COLUMN_EXTRA,
|
|
11
|
+
COLUMN_MISSING,
|
|
12
|
+
DEFAULT_EXTRA,
|
|
13
|
+
DEFAULT_MISSING,
|
|
14
|
+
ENUM_MISMATCH,
|
|
15
|
+
FK_EXTRA,
|
|
16
|
+
FK_MISSING,
|
|
17
|
+
INDEX_EXTRA,
|
|
18
|
+
INDEX_MISSING,
|
|
19
|
+
NULLABLE_MISMATCH,
|
|
20
|
+
TABLE_MISSING,
|
|
21
|
+
TYPE_MISMATCH,
|
|
22
|
+
Finding,
|
|
23
|
+
Severity,
|
|
24
|
+
)
|
|
25
|
+
from .types import normalize_type
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def diff_schemas(
|
|
29
|
+
expected: dict[tuple[str | None, str], TableInfo],
|
|
30
|
+
actual: dict[tuple[str | None, str], TableInfo | None],
|
|
31
|
+
config: Config,
|
|
32
|
+
dialect_name: str = "",
|
|
33
|
+
) -> list[Finding]:
|
|
34
|
+
findings: list[Finding] = []
|
|
35
|
+
|
|
36
|
+
for key, exp in expected.items():
|
|
37
|
+
schema, table = key
|
|
38
|
+
act = actual.get(key)
|
|
39
|
+
|
|
40
|
+
if act is None:
|
|
41
|
+
findings.append(
|
|
42
|
+
Finding(
|
|
43
|
+
severity=config.severity_for(TABLE_MISSING, Severity.ERROR),
|
|
44
|
+
kind=TABLE_MISSING,
|
|
45
|
+
schema=schema,
|
|
46
|
+
table=table,
|
|
47
|
+
detail="ORM declares this table but it is absent from the database",
|
|
48
|
+
)
|
|
49
|
+
)
|
|
50
|
+
continue
|
|
51
|
+
|
|
52
|
+
# Columns the entity expects but the DB lacks -> runtime crash case.
|
|
53
|
+
for cname, ecol in exp.columns.items():
|
|
54
|
+
acol = act.columns.get(cname)
|
|
55
|
+
if acol is None:
|
|
56
|
+
findings.append(
|
|
57
|
+
Finding(
|
|
58
|
+
severity=config.severity_for(COLUMN_MISSING, Severity.ERROR),
|
|
59
|
+
kind=COLUMN_MISSING,
|
|
60
|
+
schema=schema,
|
|
61
|
+
table=table,
|
|
62
|
+
column=cname,
|
|
63
|
+
detail="entity maps this column but the database has no such column",
|
|
64
|
+
)
|
|
65
|
+
)
|
|
66
|
+
continue
|
|
67
|
+
|
|
68
|
+
# PK columns are always NOT NULL; some dialects (SQLite) misreport
|
|
69
|
+
# them as nullable on reflection, so skip the nullable check there.
|
|
70
|
+
if config.check_nullable and not ecol.primary_key and ecol.nullable != acol.nullable:
|
|
71
|
+
findings.append(
|
|
72
|
+
Finding(
|
|
73
|
+
severity=config.severity_for(NULLABLE_MISMATCH, Severity.WARN),
|
|
74
|
+
kind=NULLABLE_MISMATCH,
|
|
75
|
+
schema=schema,
|
|
76
|
+
table=table,
|
|
77
|
+
column=cname,
|
|
78
|
+
detail=(
|
|
79
|
+
f"entity nullable={ecol.nullable} but "
|
|
80
|
+
f"database nullable={acol.nullable}"
|
|
81
|
+
),
|
|
82
|
+
)
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
if config.check_types and normalize_type(
|
|
86
|
+
ecol.type_str, dialect_name
|
|
87
|
+
) != normalize_type(acol.type_str, dialect_name):
|
|
88
|
+
findings.append(
|
|
89
|
+
Finding(
|
|
90
|
+
severity=config.severity_for(TYPE_MISMATCH, Severity.WARN),
|
|
91
|
+
kind=TYPE_MISMATCH,
|
|
92
|
+
schema=schema,
|
|
93
|
+
table=table,
|
|
94
|
+
column=cname,
|
|
95
|
+
detail=f"entity type {ecol.type_str} != database type {acol.type_str}",
|
|
96
|
+
)
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
# Server-side default presence (opt-in) — value is not compared, only
|
|
100
|
+
# whether a DB default exists. PK identity defaults are skipped.
|
|
101
|
+
if (
|
|
102
|
+
config.check_server_defaults
|
|
103
|
+
and not ecol.primary_key
|
|
104
|
+
and ecol.has_server_default != acol.has_server_default
|
|
105
|
+
):
|
|
106
|
+
if ecol.has_server_default:
|
|
107
|
+
findings.append(
|
|
108
|
+
Finding(
|
|
109
|
+
severity=config.severity_for(DEFAULT_MISSING, Severity.WARN),
|
|
110
|
+
kind=DEFAULT_MISSING,
|
|
111
|
+
schema=schema,
|
|
112
|
+
table=table,
|
|
113
|
+
column=cname,
|
|
114
|
+
detail="entity sets a server_default but the database column has none",
|
|
115
|
+
)
|
|
116
|
+
)
|
|
117
|
+
else:
|
|
118
|
+
findings.append(
|
|
119
|
+
Finding(
|
|
120
|
+
severity=config.severity_for(DEFAULT_EXTRA, Severity.WARN),
|
|
121
|
+
kind=DEFAULT_EXTRA,
|
|
122
|
+
schema=schema,
|
|
123
|
+
table=table,
|
|
124
|
+
column=cname,
|
|
125
|
+
detail="database column has a default the ORM does not declare",
|
|
126
|
+
)
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
# Enum allowed-values comparison (opt-in). Only when both sides
|
|
130
|
+
# expose enum values (native enums on Postgres/MySQL).
|
|
131
|
+
if (
|
|
132
|
+
config.check_enums
|
|
133
|
+
and ecol.enum_values is not None
|
|
134
|
+
and acol.enum_values is not None
|
|
135
|
+
and set(ecol.enum_values) != set(acol.enum_values)
|
|
136
|
+
):
|
|
137
|
+
missing = sorted(set(ecol.enum_values) - set(acol.enum_values))
|
|
138
|
+
extra = sorted(set(acol.enum_values) - set(ecol.enum_values))
|
|
139
|
+
parts = []
|
|
140
|
+
if missing:
|
|
141
|
+
parts.append(f"missing in DB: {missing}")
|
|
142
|
+
if extra:
|
|
143
|
+
parts.append(f"only in DB: {extra}")
|
|
144
|
+
findings.append(
|
|
145
|
+
Finding(
|
|
146
|
+
severity=config.severity_for(ENUM_MISMATCH, Severity.WARN),
|
|
147
|
+
kind=ENUM_MISMATCH,
|
|
148
|
+
schema=schema,
|
|
149
|
+
table=table,
|
|
150
|
+
column=cname,
|
|
151
|
+
detail="enum values differ (" + "; ".join(parts) + ")",
|
|
152
|
+
)
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
# Columns in the DB that no entity maps.
|
|
156
|
+
if config.flag_extra_columns:
|
|
157
|
+
for cname in act.columns.keys() - exp.columns.keys():
|
|
158
|
+
findings.append(
|
|
159
|
+
Finding(
|
|
160
|
+
severity=config.severity_for(COLUMN_EXTRA, Severity.WARN),
|
|
161
|
+
kind=COLUMN_EXTRA,
|
|
162
|
+
schema=schema,
|
|
163
|
+
table=table,
|
|
164
|
+
column=cname,
|
|
165
|
+
detail="database column not mapped by any entity (silently unused)",
|
|
166
|
+
)
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
# Indexes (opt-in), compared by column set + uniqueness.
|
|
170
|
+
if config.check_indexes:
|
|
171
|
+
for k in exp.indexes.keys() - act.indexes.keys():
|
|
172
|
+
idx = exp.indexes[k]
|
|
173
|
+
findings.append(
|
|
174
|
+
Finding(
|
|
175
|
+
severity=config.severity_for(INDEX_MISSING, Severity.WARN),
|
|
176
|
+
kind=INDEX_MISSING,
|
|
177
|
+
schema=schema,
|
|
178
|
+
table=table,
|
|
179
|
+
detail=(
|
|
180
|
+
f"ORM declares an index on ({', '.join(idx.columns)})"
|
|
181
|
+
f"{' unique' if idx.unique else ''} but the database has none"
|
|
182
|
+
),
|
|
183
|
+
)
|
|
184
|
+
)
|
|
185
|
+
for k in act.indexes.keys() - exp.indexes.keys():
|
|
186
|
+
idx = act.indexes[k]
|
|
187
|
+
findings.append(
|
|
188
|
+
Finding(
|
|
189
|
+
severity=config.severity_for(INDEX_EXTRA, Severity.WARN),
|
|
190
|
+
kind=INDEX_EXTRA,
|
|
191
|
+
schema=schema,
|
|
192
|
+
table=table,
|
|
193
|
+
detail=(
|
|
194
|
+
f"database has an index on ({', '.join(idx.columns)})"
|
|
195
|
+
f"{' unique' if idx.unique else ''} not declared in the ORM"
|
|
196
|
+
),
|
|
197
|
+
)
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
# Foreign keys (opt-in), compared by (columns, referred table, referred columns).
|
|
201
|
+
if config.check_foreign_keys:
|
|
202
|
+
for k in exp.foreign_keys.keys() - act.foreign_keys.keys():
|
|
203
|
+
fk = exp.foreign_keys[k]
|
|
204
|
+
findings.append(
|
|
205
|
+
Finding(
|
|
206
|
+
severity=config.severity_for(FK_MISSING, Severity.WARN),
|
|
207
|
+
kind=FK_MISSING,
|
|
208
|
+
schema=schema,
|
|
209
|
+
table=table,
|
|
210
|
+
detail=(
|
|
211
|
+
f"ORM declares a foreign key ({', '.join(fk.columns)}) -> "
|
|
212
|
+
f"{fk.referred_table}({', '.join(fk.referred_columns)}) "
|
|
213
|
+
"but the database has none"
|
|
214
|
+
),
|
|
215
|
+
)
|
|
216
|
+
)
|
|
217
|
+
for k in act.foreign_keys.keys() - exp.foreign_keys.keys():
|
|
218
|
+
fk = act.foreign_keys[k]
|
|
219
|
+
findings.append(
|
|
220
|
+
Finding(
|
|
221
|
+
severity=config.severity_for(FK_EXTRA, Severity.WARN),
|
|
222
|
+
kind=FK_EXTRA,
|
|
223
|
+
schema=schema,
|
|
224
|
+
table=table,
|
|
225
|
+
detail=(
|
|
226
|
+
f"database has a foreign key ({', '.join(fk.columns)}) -> "
|
|
227
|
+
f"{fk.referred_table}({', '.join(fk.referred_columns)}) "
|
|
228
|
+
"not declared in the ORM"
|
|
229
|
+
),
|
|
230
|
+
)
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
# Named CHECK constraints (opt-in), compared by name only.
|
|
234
|
+
if config.check_constraints:
|
|
235
|
+
for cname in exp.checks.keys() - act.checks.keys():
|
|
236
|
+
findings.append(
|
|
237
|
+
Finding(
|
|
238
|
+
severity=config.severity_for(CHECK_MISSING, Severity.WARN),
|
|
239
|
+
kind=CHECK_MISSING,
|
|
240
|
+
schema=schema,
|
|
241
|
+
table=table,
|
|
242
|
+
detail=f"ORM declares CHECK constraint '{cname}' but the database has none",
|
|
243
|
+
)
|
|
244
|
+
)
|
|
245
|
+
for cname in act.checks.keys() - exp.checks.keys():
|
|
246
|
+
findings.append(
|
|
247
|
+
Finding(
|
|
248
|
+
severity=config.severity_for(CHECK_EXTRA, Severity.WARN),
|
|
249
|
+
kind=CHECK_EXTRA,
|
|
250
|
+
schema=schema,
|
|
251
|
+
table=table,
|
|
252
|
+
detail=f"database has CHECK constraint '{cname}' not declared in the ORM",
|
|
253
|
+
)
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
return findings
|
|
File without changes
|