dbconform 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.
- dbconform/__init__.py +24 -0
- dbconform/adapters/__init__.py +13 -0
- dbconform/adapters/model_schema.py +219 -0
- dbconform/adapters/sa_to_neutral.py +90 -0
- dbconform/cli.py +416 -0
- dbconform/compare/__init__.py +16 -0
- dbconform/compare/db_schema.py +77 -0
- dbconform/compare/diff.py +249 -0
- dbconform/conform.py +302 -0
- dbconform/errors.py +28 -0
- dbconform/internal/__init__.py +38 -0
- dbconform/internal/objects.py +144 -0
- dbconform/internal/types.py +60 -0
- dbconform/plan/__init__.py +26 -0
- dbconform/plan/builder.py +241 -0
- dbconform/plan/steps.py +92 -0
- dbconform/schema/__init__.py +36 -0
- dbconform/schema/db_schema.py +7 -0
- dbconform/schema/diff.py +10 -0
- dbconform/schema/model_schema.py +8 -0
- dbconform/schema/objects.py +23 -0
- dbconform/schema/sa_to_neutral.py +7 -0
- dbconform/sql_dialect/__init__.py +16 -0
- dbconform/sql_dialect/base.py +239 -0
- dbconform/sql_dialect/postgresql.py +284 -0
- dbconform/sql_dialect/sqlite.py +142 -0
- dbconform-0.1.0.dist-info/METADATA +178 -0
- dbconform-0.1.0.dist-info/RECORD +32 -0
- dbconform-0.1.0.dist-info/WHEEL +5 -0
- dbconform-0.1.0.dist-info/entry_points.txt +2 -0
- dbconform-0.1.0.dist-info/licenses/LICENSE +21 -0
- dbconform-0.1.0.dist-info/top_level.txt +1 -0
dbconform/__init__.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""
|
|
2
|
+
dbconform — conform database schema to models.
|
|
3
|
+
|
|
4
|
+
Document-driven project; requirements live under docs/requirements/.
|
|
5
|
+
Library API: DbConform(connection=... | credentials=..., target_schema=...),
|
|
6
|
+
compare(models) -> ConformPlan | ConformError, apply_changes(models) to apply.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
10
|
+
|
|
11
|
+
try:
|
|
12
|
+
__version__ = version("dbconform")
|
|
13
|
+
except PackageNotFoundError:
|
|
14
|
+
__version__ = "0.0.0"
|
|
15
|
+
|
|
16
|
+
from dbconform.conform import DbConform
|
|
17
|
+
from dbconform.errors import ConformError
|
|
18
|
+
from dbconform.plan.steps import ConformPlan
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"DbConform",
|
|
22
|
+
"ConformError",
|
|
23
|
+
"ConformPlan",
|
|
24
|
+
]
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Adapters: third-party models (SQLAlchemy, SQLModel) → internal schema.
|
|
3
|
+
|
|
4
|
+
See docs/technical/02-architecture.md (Core functions, Adapters).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from dbconform.adapters.model_schema import ModelSchema
|
|
8
|
+
from dbconform.adapters.sa_to_neutral import sa_column_to_neutral_type
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"ModelSchema",
|
|
12
|
+
"sa_column_to_neutral_type",
|
|
13
|
+
]
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Build internal schema from SQLAlchemy or SQLModel model classes.
|
|
3
|
+
|
|
4
|
+
Callers pass a single model or sequence of models; we collect their Table
|
|
5
|
+
definitions and build a ModelSchema (name -> TableDef). See docs/requirements/01-functional.md
|
|
6
|
+
(Model discovery and API, Schema parity scope).
|
|
7
|
+
|
|
8
|
+
**Read-only contract:** Ingestion does not mutate caller models. We only read from
|
|
9
|
+
model.__table__ and its columns/constraints/indexes; we never assign to or modify
|
|
10
|
+
the caller's Table or column objects. ModelSchema stores only internal TableDef
|
|
11
|
+
instances, not references to the original tables.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from collections.abc import Sequence
|
|
17
|
+
from typing import Any, Protocol
|
|
18
|
+
|
|
19
|
+
from sqlalchemy import Table
|
|
20
|
+
from sqlalchemy.engine import Dialect
|
|
21
|
+
from sqlalchemy.schema import CheckConstraint, ForeignKeyConstraint, UniqueConstraint
|
|
22
|
+
|
|
23
|
+
from dbconform.adapters.sa_to_neutral import sa_column_to_neutral_type
|
|
24
|
+
from dbconform.internal.objects import (
|
|
25
|
+
CheckDef,
|
|
26
|
+
ColumnDef,
|
|
27
|
+
ForeignKeyDef,
|
|
28
|
+
IndexDef,
|
|
29
|
+
PrimaryKeyDef,
|
|
30
|
+
QualifiedName,
|
|
31
|
+
TableDef,
|
|
32
|
+
UniqueDef,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _get_table_from_model(model: type) -> Table:
|
|
37
|
+
"""Return the SQLAlchemy Table for a declarative or SQLModel class."""
|
|
38
|
+
table = getattr(model, "__table__", None)
|
|
39
|
+
if table is None:
|
|
40
|
+
raise TypeError(f"Model {model!r} has no __table__; not a mapped table class")
|
|
41
|
+
if not isinstance(table, Table):
|
|
42
|
+
raise TypeError(f"Model {model!r}.__table__ is not a Table: {type(table)}")
|
|
43
|
+
return table
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _column_type_str(column: Any, dialect: Dialect | None) -> str:
|
|
47
|
+
"""Return neutral type string for internal schema (dialect=None) or compiled type (dialect set)."""
|
|
48
|
+
if dialect is None:
|
|
49
|
+
return sa_column_to_neutral_type(column)
|
|
50
|
+
return column.type.compile(dialect=dialect)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _default_expr(column: Any, _dialect: Dialect) -> str | None:
|
|
54
|
+
"""Return server default expression as string, or None."""
|
|
55
|
+
default = getattr(column, "server_default", None) or getattr(column, "default", None)
|
|
56
|
+
if default is None:
|
|
57
|
+
return None
|
|
58
|
+
if hasattr(default, "arg") and default.arg is not None:
|
|
59
|
+
if callable(default.arg):
|
|
60
|
+
return None # Python-side default; no DDL expression
|
|
61
|
+
return str(default.arg)
|
|
62
|
+
if hasattr(default, "text") and default.text is not None:
|
|
63
|
+
return default.text
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _extract_table_def(
|
|
68
|
+
table: Table,
|
|
69
|
+
target_schema: str | None,
|
|
70
|
+
dialect: Dialect | None = None,
|
|
71
|
+
) -> TableDef:
|
|
72
|
+
"""Build a TableDef from a SQLAlchemy Table. When dialect is None, use neutral type names."""
|
|
73
|
+
schema = table.schema if table.schema is not None else target_schema
|
|
74
|
+
qualified_name = QualifiedName(schema=schema, name=table.name)
|
|
75
|
+
|
|
76
|
+
columns: list[ColumnDef] = []
|
|
77
|
+
# Only the single integer PK column should have autoincrement=True (SA uses "auto" on others too).
|
|
78
|
+
is_single_pk = (
|
|
79
|
+
table.primary_key
|
|
80
|
+
and len(table.primary_key.columns) == 1
|
|
81
|
+
)
|
|
82
|
+
pk_col_name = (
|
|
83
|
+
list(table.primary_key.columns)[0].name
|
|
84
|
+
if is_single_pk
|
|
85
|
+
else None
|
|
86
|
+
)
|
|
87
|
+
_integer_type_names = ("Integer", "INTEGER", "BigInteger", "BIGINT", "SmallInteger", "SMALLINT")
|
|
88
|
+
for col in table.c:
|
|
89
|
+
default = _default_expr(col, dialect)
|
|
90
|
+
type_str = _column_type_str(col, dialect)
|
|
91
|
+
comment = getattr(col, "comment", None)
|
|
92
|
+
sa_auto = getattr(col, "autoincrement", False)
|
|
93
|
+
if isinstance(sa_auto, str):
|
|
94
|
+
sa_auto = sa_auto == "auto"
|
|
95
|
+
is_pk_col = pk_col_name is not None and col.name == pk_col_name
|
|
96
|
+
is_integer = type(col.type).__name__ in _integer_type_names
|
|
97
|
+
autoincrement = bool(
|
|
98
|
+
is_single_pk and is_pk_col and is_integer and sa_auto
|
|
99
|
+
)
|
|
100
|
+
columns.append(
|
|
101
|
+
ColumnDef(
|
|
102
|
+
name=col.name,
|
|
103
|
+
data_type_name=type_str,
|
|
104
|
+
nullable=col.nullable,
|
|
105
|
+
default=default,
|
|
106
|
+
comment=comment,
|
|
107
|
+
autoincrement=autoincrement,
|
|
108
|
+
)
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
primary_key: PrimaryKeyDef | None = None
|
|
112
|
+
if table.primary_key and table.primary_key.columns:
|
|
113
|
+
primary_key = PrimaryKeyDef(column_names=tuple(c.name for c in table.primary_key.columns))
|
|
114
|
+
|
|
115
|
+
unique_constraints: list[UniqueDef] = []
|
|
116
|
+
foreign_keys: list[ForeignKeyDef] = []
|
|
117
|
+
check_constraints: list[CheckDef] = []
|
|
118
|
+
|
|
119
|
+
for constraint in table.constraints:
|
|
120
|
+
match constraint:
|
|
121
|
+
case UniqueConstraint() if constraint is not table.primary_key:
|
|
122
|
+
unique_constraints.append(
|
|
123
|
+
UniqueDef(
|
|
124
|
+
name=constraint.name,
|
|
125
|
+
column_names=tuple(c.name for c in constraint.columns),
|
|
126
|
+
)
|
|
127
|
+
)
|
|
128
|
+
case CheckConstraint():
|
|
129
|
+
expression = str(constraint.sqltext)
|
|
130
|
+
check_constraints.append(CheckDef(name=constraint.name, expression=expression))
|
|
131
|
+
case ForeignKeyConstraint():
|
|
132
|
+
ref_col = next(iter(constraint.elements)).column
|
|
133
|
+
ref_table = ref_col.table
|
|
134
|
+
ref_schema = ref_table.schema if ref_table.schema is not None else target_schema
|
|
135
|
+
ref_name = QualifiedName(schema=ref_schema, name=ref_table.name)
|
|
136
|
+
foreign_keys.append(
|
|
137
|
+
ForeignKeyDef(
|
|
138
|
+
name=constraint.name,
|
|
139
|
+
column_names=tuple(c.name for c in constraint.columns),
|
|
140
|
+
ref_table=ref_name,
|
|
141
|
+
ref_column_names=tuple(el.column.name for el in constraint.elements),
|
|
142
|
+
)
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
indexes: list[IndexDef] = []
|
|
146
|
+
for idx in table.indexes:
|
|
147
|
+
indexes.append(
|
|
148
|
+
IndexDef(
|
|
149
|
+
name=idx.name or f"ix_{table.name}_{'_'.join(c.name for c in idx.columns)}",
|
|
150
|
+
column_names=tuple(c.name for c in idx.columns),
|
|
151
|
+
unique=idx.unique or False,
|
|
152
|
+
)
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
comment = getattr(table, "comment", None)
|
|
156
|
+
|
|
157
|
+
return TableDef(
|
|
158
|
+
name=qualified_name,
|
|
159
|
+
columns=tuple(columns),
|
|
160
|
+
primary_key=primary_key,
|
|
161
|
+
unique_constraints=tuple(unique_constraints),
|
|
162
|
+
foreign_keys=tuple(foreign_keys),
|
|
163
|
+
check_constraints=tuple(check_constraints),
|
|
164
|
+
indexes=tuple(indexes),
|
|
165
|
+
comment=comment,
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class _SchemaNormalizer(Protocol):
|
|
170
|
+
"""Protocol for normalizing a TableDef so it compares equal across backends."""
|
|
171
|
+
|
|
172
|
+
def normalize_reflected_table(self, table_def: TableDef) -> TableDef: ...
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class ModelSchema:
|
|
176
|
+
"""
|
|
177
|
+
Internal schema derived from code models (SQLAlchemy/SQLModel).
|
|
178
|
+
|
|
179
|
+
Tables are keyed by QualifiedName. Built by from_models().
|
|
180
|
+
"""
|
|
181
|
+
|
|
182
|
+
def __init__(self) -> None:
|
|
183
|
+
self._tables: dict[QualifiedName, TableDef] = {}
|
|
184
|
+
|
|
185
|
+
@property
|
|
186
|
+
def tables(self) -> dict[QualifiedName, TableDef]:
|
|
187
|
+
"""Tables keyed by qualified name."""
|
|
188
|
+
return self._tables
|
|
189
|
+
|
|
190
|
+
@classmethod
|
|
191
|
+
def from_models(
|
|
192
|
+
cls,
|
|
193
|
+
models: type | Sequence[type],
|
|
194
|
+
target_schema: str | None = None,
|
|
195
|
+
*,
|
|
196
|
+
schema_normalizer: _SchemaNormalizer | None = None,
|
|
197
|
+
) -> ModelSchema:
|
|
198
|
+
"""
|
|
199
|
+
Build ModelSchema from one or more model classes.
|
|
200
|
+
|
|
201
|
+
Each model must have __table__ (SQLAlchemy Table). Column types are
|
|
202
|
+
mapped to neutral type names (no target database). target_schema is
|
|
203
|
+
used when table.schema is None (e.g. PostgreSQL default schema).
|
|
204
|
+
If schema_normalizer is provided (e.g. dbconform Dialect), its
|
|
205
|
+
normalize_reflected_table is applied so model-side internal schema
|
|
206
|
+
compares equal to database-side internal schema.
|
|
207
|
+
"""
|
|
208
|
+
if isinstance(models, type):
|
|
209
|
+
model_seq: Sequence[type] = (models,)
|
|
210
|
+
else:
|
|
211
|
+
model_seq = models
|
|
212
|
+
instance = cls()
|
|
213
|
+
for model in model_seq:
|
|
214
|
+
table = _get_table_from_model(model)
|
|
215
|
+
table_def = _extract_table_def(table, target_schema, dialect=None)
|
|
216
|
+
if schema_normalizer is not None:
|
|
217
|
+
table_def = schema_normalizer.normalize_reflected_table(table_def)
|
|
218
|
+
instance._tables[table_def.name] = table_def
|
|
219
|
+
return instance
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Map SQLAlchemy column types to neutral type names (no dialect).
|
|
3
|
+
|
|
4
|
+
Used when building internal schema from code models so that model-side schema
|
|
5
|
+
does not depend on a target database. See docs/technical/02-architecture.md
|
|
6
|
+
(Types, Internal schema: design goals) and the plan (Option B: neutral type vocabulary).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from dbconform.internal.types import (
|
|
15
|
+
CanonicalType,
|
|
16
|
+
canonical_char,
|
|
17
|
+
canonical_numeric,
|
|
18
|
+
canonical_varchar,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def sa_column_to_neutral_type(column: Any) -> str:
|
|
23
|
+
"""
|
|
24
|
+
Return the neutral data_type_name for a SQLAlchemy column (no dialect).
|
|
25
|
+
|
|
26
|
+
Maps common SQLAlchemy types to the neutral vocabulary (INTEGER, VARCHAR(n),
|
|
27
|
+
TEXT, FLOAT, BOOLEAN, DATE, TIMESTAMP, NUMERIC(p,s), etc.). Used when building
|
|
28
|
+
ModelSchema from models so that no target database is assumed.
|
|
29
|
+
"""
|
|
30
|
+
typ = column.type
|
|
31
|
+
type_cls = type(typ)
|
|
32
|
+
name = type_cls.__name__
|
|
33
|
+
|
|
34
|
+
if name in ("Integer", "INTEGER"):
|
|
35
|
+
return CanonicalType.INTEGER
|
|
36
|
+
if name in ("BigInteger", "BIGINT"):
|
|
37
|
+
return CanonicalType.BIGINT
|
|
38
|
+
if name in ("SmallInteger", "SMALLINT"):
|
|
39
|
+
return CanonicalType.SMALLINT
|
|
40
|
+
if name in ("Float", "FLOAT", "REAL"):
|
|
41
|
+
return CanonicalType.FLOAT
|
|
42
|
+
if name in ("Boolean", "BOOLEAN", "Bool"):
|
|
43
|
+
return CanonicalType.BOOLEAN
|
|
44
|
+
if name in ("Text", "TEXT", "CLOB"):
|
|
45
|
+
return CanonicalType.TEXT
|
|
46
|
+
if name in ("Date", "DATE", "Date"):
|
|
47
|
+
return CanonicalType.DATE
|
|
48
|
+
if name in ("DateTime", "DATETIME", "TIMESTAMP", "DateTime", "Timestamp"):
|
|
49
|
+
return CanonicalType.TIMESTAMP
|
|
50
|
+
if name in ("Numeric", "NUMERIC", "Decimal", "DECIMAL"):
|
|
51
|
+
precision = getattr(typ, "precision", None)
|
|
52
|
+
scale = getattr(typ, "scale", None)
|
|
53
|
+
return canonical_numeric(precision, scale)
|
|
54
|
+
if name in ("String", "VARCHAR", "CHAR", "Unicode"):
|
|
55
|
+
length = getattr(typ, "length", None)
|
|
56
|
+
if length is not None:
|
|
57
|
+
if name in ("CHAR", "Char"):
|
|
58
|
+
return canonical_char(length)
|
|
59
|
+
return canonical_varchar(length)
|
|
60
|
+
return canonical_varchar(255) # common default when no length
|
|
61
|
+
if name in ("LargeBinary", "BLOB", "BYTEA"):
|
|
62
|
+
return CanonicalType.BLOB
|
|
63
|
+
if name in ("JSON", "JSONB"):
|
|
64
|
+
return CanonicalType.JSON
|
|
65
|
+
|
|
66
|
+
# Fallback: try compile with a generic dialect to get a string, then normalize
|
|
67
|
+
# so we don't fail on custom or rare types. Use SQLite as a simple dialect.
|
|
68
|
+
try:
|
|
69
|
+
from sqlalchemy.dialects import sqlite
|
|
70
|
+
compiled = typ.compile(dialect=sqlite.dialect())
|
|
71
|
+
# Normalize common SQLite outputs to neutral
|
|
72
|
+
c = str(compiled).strip().upper()
|
|
73
|
+
if c == "INTEGER":
|
|
74
|
+
return CanonicalType.INTEGER
|
|
75
|
+
if c in ("REAL", "DOUBLE", "DOUBLE PRECISION"):
|
|
76
|
+
return CanonicalType.FLOAT
|
|
77
|
+
if c == "TEXT":
|
|
78
|
+
return CanonicalType.TEXT
|
|
79
|
+
if c == "BLOB":
|
|
80
|
+
return CanonicalType.BLOB
|
|
81
|
+
if c == "BOOLEAN":
|
|
82
|
+
return CanonicalType.BOOLEAN
|
|
83
|
+
if "VARCHAR" in c or "CHAR" in c:
|
|
84
|
+
m = re.search(r"(\d+)", c)
|
|
85
|
+
if m:
|
|
86
|
+
return canonical_varchar(int(m.group(1)))
|
|
87
|
+
return canonical_varchar(255)
|
|
88
|
+
return str(compiled)
|
|
89
|
+
except Exception:
|
|
90
|
+
return CanonicalType.TEXT # safe fallback
|