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 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