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 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
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
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