modelsync 0.1.0__tar.gz

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.
Files changed (37) hide show
  1. modelsync-0.1.0/LICENSE +21 -0
  2. modelsync-0.1.0/PKG-INFO +178 -0
  3. modelsync-0.1.0/README.md +152 -0
  4. modelsync-0.1.0/pyproject.toml +52 -0
  5. modelsync-0.1.0/setup.cfg +4 -0
  6. modelsync-0.1.0/src/modelsync/__init__.py +24 -0
  7. modelsync-0.1.0/src/modelsync/adapters/__init__.py +13 -0
  8. modelsync-0.1.0/src/modelsync/adapters/model_schema.py +218 -0
  9. modelsync-0.1.0/src/modelsync/adapters/sa_to_neutral.py +90 -0
  10. modelsync-0.1.0/src/modelsync/cli.py +378 -0
  11. modelsync-0.1.0/src/modelsync/compare/__init__.py +16 -0
  12. modelsync-0.1.0/src/modelsync/compare/db_schema.py +77 -0
  13. modelsync-0.1.0/src/modelsync/compare/diff.py +249 -0
  14. modelsync-0.1.0/src/modelsync/errors.py +28 -0
  15. modelsync-0.1.0/src/modelsync/internal/__init__.py +38 -0
  16. modelsync-0.1.0/src/modelsync/internal/objects.py +144 -0
  17. modelsync-0.1.0/src/modelsync/internal/types.py +60 -0
  18. modelsync-0.1.0/src/modelsync/plan/__init__.py +26 -0
  19. modelsync-0.1.0/src/modelsync/plan/builder.py +241 -0
  20. modelsync-0.1.0/src/modelsync/plan/steps.py +92 -0
  21. modelsync-0.1.0/src/modelsync/schema/__init__.py +36 -0
  22. modelsync-0.1.0/src/modelsync/schema/db_schema.py +7 -0
  23. modelsync-0.1.0/src/modelsync/schema/diff.py +10 -0
  24. modelsync-0.1.0/src/modelsync/schema/model_schema.py +8 -0
  25. modelsync-0.1.0/src/modelsync/schema/objects.py +23 -0
  26. modelsync-0.1.0/src/modelsync/schema/sa_to_neutral.py +7 -0
  27. modelsync-0.1.0/src/modelsync/sql_dialect/__init__.py +16 -0
  28. modelsync-0.1.0/src/modelsync/sql_dialect/base.py +239 -0
  29. modelsync-0.1.0/src/modelsync/sql_dialect/postgresql.py +284 -0
  30. modelsync-0.1.0/src/modelsync/sql_dialect/sqlite.py +142 -0
  31. modelsync-0.1.0/src/modelsync/sync.py +302 -0
  32. modelsync-0.1.0/src/modelsync.egg-info/PKG-INFO +178 -0
  33. modelsync-0.1.0/src/modelsync.egg-info/SOURCES.txt +35 -0
  34. modelsync-0.1.0/src/modelsync.egg-info/dependency_links.txt +1 -0
  35. modelsync-0.1.0/src/modelsync.egg-info/entry_points.txt +2 -0
  36. modelsync-0.1.0/src/modelsync.egg-info/requires.txt +11 -0
  37. modelsync-0.1.0/src/modelsync.egg-info/top_level.txt +1 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Brian L. Pond
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,178 @@
1
+ Metadata-Version: 2.4
2
+ Name: modelsync
3
+ Version: 0.1.0
4
+ Summary: Synchronize database models with actuals — document-driven project.
5
+ Author: Brian L. Pond
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/brian-pond/modelsync
8
+ Project-URL: Repository, https://github.com/brian-pond/modelsync
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Requires-Python: >=3.11
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ Requires-Dist: sqlalchemy>=2.0
17
+ Provides-Extra: dev
18
+ Requires-Dist: build; extra == "dev"
19
+ Requires-Dist: pytest; extra == "dev"
20
+ Requires-Dist: ruff; extra == "dev"
21
+ Requires-Dist: sqlmodel; extra == "dev"
22
+ Requires-Dist: typer>=0.9; extra == "dev"
23
+ Provides-Extra: postgres
24
+ Requires-Dist: psycopg[binary]>=3; extra == "postgres"
25
+ Dynamic: license-file
26
+
27
+ # modelsync
28
+
29
+ A Python library for schema and model synchronization: keep your database schema in sync with your SQLAlchemy or SQLModel definitions.
30
+
31
+ ## Prerequisites
32
+
33
+ - Python 3.11+
34
+
35
+ ## Setup
36
+
37
+ Create a virtual environment and install the package in editable mode:
38
+
39
+ ```bash
40
+ python3 -m venv .venv
41
+ source .venv/bin/activate # Linux/macOS
42
+ # .venv\Scripts\activate # Windows
43
+ pip install -e ".[dev]"
44
+ ```
45
+
46
+ ## Installing in other projects
47
+
48
+ To use modelsync in another Python application:
49
+
50
+ - **Development (same machine):** From your other project, run `pip install -e /path/to/modelsync` or `uv pip install -e /path/to/modelsync`. Changes in the modelsync repo are reflected immediately.
51
+ - **Built wheel:** From the modelsync repo run `uv build` (requires `uv` and dev deps: `pip install -e ".[dev]"`). This produces a wheel in `dist/` (e.g. `dist/modelsync-0.1.0-py3-none-any.whl`). In your other project: `pip install /path/to/modelsync/dist/modelsync-0.1.0-py3-none-any.whl`.
52
+ - **Private index:** Upload the contents of `dist/` to your index; then `pip install modelsync --index-url https://your-index/simple/`.
53
+
54
+ ## Running tests
55
+
56
+ From the project root, with the virtual environment activated:
57
+
58
+ ```bash
59
+ pytest tests/
60
+ ```
61
+
62
+ Run only unit tests or only integration tests:
63
+
64
+ ```bash
65
+ pytest tests/unit/
66
+ pytest tests/integration/
67
+ ```
68
+
69
+ See `tests/TESTS_README.md` for how tests are organized.
70
+
71
+ ## Usage
72
+
73
+ Use modelsync as a library: define your models (e.g. SQLAlchemy or SQLModel), then compare them to the database. By default, only a plan is produced; apply only when you explicitly opt in.
74
+
75
+ ### ModelSync: the three main entry points
76
+
77
+ **1. `ModelSync(...)` — set up the connection**
78
+
79
+ Create a `ModelSync` instance by passing either:
80
+
81
+ - **`credentials={"url": "sqlite:///./mydb.sqlite"}`** — modelsync will open the database, run your call, then close it. No need to manage the connection yourself.
82
+ - **`connection=engine.connect()`** — you provide an open connection; you are responsible for closing it when done.
83
+
84
+ For databases that use schemas (e.g. PostgreSQL), also pass **`target_schema="public"`** (or your schema name). For SQLite you can omit it.
85
+
86
+ **2. `compare(models)` — see what would change (dry run)**
87
+
88
+ Pass one model class or a list of model classes. modelsync compares their combined schema to the live database and returns a **plan** of steps (create table, add column, add constraint, etc.) without executing anything. Use this to inspect changes, log them, or generate a DDL script with `plan.sql()`. Returns a `SyncPlan` or a `SyncError` if something went wrong.
89
+
90
+ **3. `do_sync(models)` — apply the changes**
91
+
92
+ Same comparison as `compare()`, but **runs** the plan against the database. By default all steps run in one transaction: if any step fails, everything is rolled back. Returns the applied `SyncPlan` on success or a `SyncError` on failure. Optional: `commit_per_step=True` to commit after each step (partial progress on failure), or `log_file="path"` to append applied steps to a file.
93
+
94
+ ---
95
+
96
+ Define one or more models and pass them (single class or a list) to `compare()` or `do_sync()`. The example below uses `compare()` to get a plan.
97
+
98
+ ```python
99
+ from sqlalchemy import Column, Float, ForeignKey, Integer, String
100
+ from sqlalchemy.orm import DeclarativeBase
101
+
102
+ from modelsync import ModelSync, SyncError
103
+
104
+ class Product(DeclarativeBase):
105
+ __tablename__ = "product"
106
+ id = Column(Integer, primary_key=True, autoincrement=True)
107
+ name = Column(String(255), nullable=False)
108
+ price = Column(Float, nullable=False)
109
+
110
+ class Cart(DeclarativeBase):
111
+ __tablename__ = "cart"
112
+ id = Column(Integer, primary_key=True, autoincrement=True)
113
+ product_id = Column(Integer, ForeignKey("product.id"), nullable=False)
114
+ quantity = Column(Integer, nullable=False)
115
+
116
+ sync = ModelSync(credentials={"url": "sqlite:///./mydb.sqlite"})
117
+ result_plan = sync.compare([Product, Cart])
118
+
119
+ if isinstance(result_plan, SyncError):
120
+ print("Compare failed:", result_plan.messages)
121
+ else:
122
+ if not result_plan.steps:
123
+ print("Your target database is up to date.")
124
+ else:
125
+ for step in result_plan.steps:
126
+ print(step)
127
+ # result_plan.sql() returns the full DDL script for inspection or manual execution
128
+ ```
129
+
130
+ **Using your own connection** — you open the connection and close it yourself:
131
+
132
+ ```python
133
+ from sqlalchemy import create_engine
134
+
135
+ engine = create_engine("sqlite:///./mydb.sqlite")
136
+ with engine.connect() as conn:
137
+ sync = ModelSync(connection=conn)
138
+ result_plan = sync.compare([Product, Cart])
139
+ engine.dispose()
140
+ ```
141
+
142
+ ### Possible Outcomes
143
+
144
+ #### Scenario 1
145
+ If the target database is empty, printing each step might show:
146
+
147
+ ```
148
+ Create table product
149
+ Create table cart
150
+ ```
151
+
152
+ #### Scenario 2
153
+ If the database already matches your models, you'll see *Your target database is up to date.* and no steps.
154
+
155
+ #### Scenario 3
156
+ What if `cart` already exists in the database but is missing the `quantity` column? Then you might see:
157
+
158
+ ```
159
+ Add column quantity to cart
160
+ ```
161
+
162
+ To get the full DDL script as a single string, use `result_plan.sql()`.
163
+
164
+ ### Applying the plan with do_sync()
165
+
166
+ To compare and apply the plan in one go (run the DDL against the database), use `do_sync()`. It uses the same comparison as `compare()` but executes the steps in a single transaction (all-or-nothing; rollback on failure). Returns the applied `SyncPlan` on success or `SyncError` on failure.
167
+
168
+ ```python
169
+ result_plan = sync.do_sync([Product, Cart])
170
+
171
+ if isinstance(result_plan, SyncError):
172
+ print("Sync failed:", result_plan.messages)
173
+ else:
174
+ print(f"Applied {len(result_plan.steps)} step(s). Schema is now in sync.")
175
+ ```
176
+
177
+ Optional: `do_sync(..., commit_per_step=True)` commits after each step so partial progress is kept if a later step fails. `do_sync(..., log_file="/path/to/sync.log")` appends applied steps as JSON lines to a file.
178
+
@@ -0,0 +1,152 @@
1
+ # modelsync
2
+
3
+ A Python library for schema and model synchronization: keep your database schema in sync with your SQLAlchemy or SQLModel definitions.
4
+
5
+ ## Prerequisites
6
+
7
+ - Python 3.11+
8
+
9
+ ## Setup
10
+
11
+ Create a virtual environment and install the package in editable mode:
12
+
13
+ ```bash
14
+ python3 -m venv .venv
15
+ source .venv/bin/activate # Linux/macOS
16
+ # .venv\Scripts\activate # Windows
17
+ pip install -e ".[dev]"
18
+ ```
19
+
20
+ ## Installing in other projects
21
+
22
+ To use modelsync in another Python application:
23
+
24
+ - **Development (same machine):** From your other project, run `pip install -e /path/to/modelsync` or `uv pip install -e /path/to/modelsync`. Changes in the modelsync repo are reflected immediately.
25
+ - **Built wheel:** From the modelsync repo run `uv build` (requires `uv` and dev deps: `pip install -e ".[dev]"`). This produces a wheel in `dist/` (e.g. `dist/modelsync-0.1.0-py3-none-any.whl`). In your other project: `pip install /path/to/modelsync/dist/modelsync-0.1.0-py3-none-any.whl`.
26
+ - **Private index:** Upload the contents of `dist/` to your index; then `pip install modelsync --index-url https://your-index/simple/`.
27
+
28
+ ## Running tests
29
+
30
+ From the project root, with the virtual environment activated:
31
+
32
+ ```bash
33
+ pytest tests/
34
+ ```
35
+
36
+ Run only unit tests or only integration tests:
37
+
38
+ ```bash
39
+ pytest tests/unit/
40
+ pytest tests/integration/
41
+ ```
42
+
43
+ See `tests/TESTS_README.md` for how tests are organized.
44
+
45
+ ## Usage
46
+
47
+ Use modelsync as a library: define your models (e.g. SQLAlchemy or SQLModel), then compare them to the database. By default, only a plan is produced; apply only when you explicitly opt in.
48
+
49
+ ### ModelSync: the three main entry points
50
+
51
+ **1. `ModelSync(...)` — set up the connection**
52
+
53
+ Create a `ModelSync` instance by passing either:
54
+
55
+ - **`credentials={"url": "sqlite:///./mydb.sqlite"}`** — modelsync will open the database, run your call, then close it. No need to manage the connection yourself.
56
+ - **`connection=engine.connect()`** — you provide an open connection; you are responsible for closing it when done.
57
+
58
+ For databases that use schemas (e.g. PostgreSQL), also pass **`target_schema="public"`** (or your schema name). For SQLite you can omit it.
59
+
60
+ **2. `compare(models)` — see what would change (dry run)**
61
+
62
+ Pass one model class or a list of model classes. modelsync compares their combined schema to the live database and returns a **plan** of steps (create table, add column, add constraint, etc.) without executing anything. Use this to inspect changes, log them, or generate a DDL script with `plan.sql()`. Returns a `SyncPlan` or a `SyncError` if something went wrong.
63
+
64
+ **3. `do_sync(models)` — apply the changes**
65
+
66
+ Same comparison as `compare()`, but **runs** the plan against the database. By default all steps run in one transaction: if any step fails, everything is rolled back. Returns the applied `SyncPlan` on success or a `SyncError` on failure. Optional: `commit_per_step=True` to commit after each step (partial progress on failure), or `log_file="path"` to append applied steps to a file.
67
+
68
+ ---
69
+
70
+ Define one or more models and pass them (single class or a list) to `compare()` or `do_sync()`. The example below uses `compare()` to get a plan.
71
+
72
+ ```python
73
+ from sqlalchemy import Column, Float, ForeignKey, Integer, String
74
+ from sqlalchemy.orm import DeclarativeBase
75
+
76
+ from modelsync import ModelSync, SyncError
77
+
78
+ class Product(DeclarativeBase):
79
+ __tablename__ = "product"
80
+ id = Column(Integer, primary_key=True, autoincrement=True)
81
+ name = Column(String(255), nullable=False)
82
+ price = Column(Float, nullable=False)
83
+
84
+ class Cart(DeclarativeBase):
85
+ __tablename__ = "cart"
86
+ id = Column(Integer, primary_key=True, autoincrement=True)
87
+ product_id = Column(Integer, ForeignKey("product.id"), nullable=False)
88
+ quantity = Column(Integer, nullable=False)
89
+
90
+ sync = ModelSync(credentials={"url": "sqlite:///./mydb.sqlite"})
91
+ result_plan = sync.compare([Product, Cart])
92
+
93
+ if isinstance(result_plan, SyncError):
94
+ print("Compare failed:", result_plan.messages)
95
+ else:
96
+ if not result_plan.steps:
97
+ print("Your target database is up to date.")
98
+ else:
99
+ for step in result_plan.steps:
100
+ print(step)
101
+ # result_plan.sql() returns the full DDL script for inspection or manual execution
102
+ ```
103
+
104
+ **Using your own connection** — you open the connection and close it yourself:
105
+
106
+ ```python
107
+ from sqlalchemy import create_engine
108
+
109
+ engine = create_engine("sqlite:///./mydb.sqlite")
110
+ with engine.connect() as conn:
111
+ sync = ModelSync(connection=conn)
112
+ result_plan = sync.compare([Product, Cart])
113
+ engine.dispose()
114
+ ```
115
+
116
+ ### Possible Outcomes
117
+
118
+ #### Scenario 1
119
+ If the target database is empty, printing each step might show:
120
+
121
+ ```
122
+ Create table product
123
+ Create table cart
124
+ ```
125
+
126
+ #### Scenario 2
127
+ If the database already matches your models, you'll see *Your target database is up to date.* and no steps.
128
+
129
+ #### Scenario 3
130
+ What if `cart` already exists in the database but is missing the `quantity` column? Then you might see:
131
+
132
+ ```
133
+ Add column quantity to cart
134
+ ```
135
+
136
+ To get the full DDL script as a single string, use `result_plan.sql()`.
137
+
138
+ ### Applying the plan with do_sync()
139
+
140
+ To compare and apply the plan in one go (run the DDL against the database), use `do_sync()`. It uses the same comparison as `compare()` but executes the steps in a single transaction (all-or-nothing; rollback on failure). Returns the applied `SyncPlan` on success or `SyncError` on failure.
141
+
142
+ ```python
143
+ result_plan = sync.do_sync([Product, Cart])
144
+
145
+ if isinstance(result_plan, SyncError):
146
+ print("Sync failed:", result_plan.messages)
147
+ else:
148
+ print(f"Applied {len(result_plan.steps)} step(s). Schema is now in sync.")
149
+ ```
150
+
151
+ Optional: `do_sync(..., commit_per_step=True)` commits after each step so partial progress is kept if a later step fails. `do_sync(..., log_file="/path/to/sync.log")` appends applied steps as JSON lines to a file.
152
+
@@ -0,0 +1,52 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "modelsync"
7
+ version = "0.1.0"
8
+ description = "Synchronize database models with actuals — document-driven project."
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ requires-python = ">=3.11"
12
+ authors = [{ name = "Brian L. Pond" }]
13
+ dependencies = [
14
+ "sqlalchemy>=2.0",
15
+ ]
16
+ classifiers = [
17
+ "Development Status :: 4 - Beta",
18
+ "License :: OSI Approved :: MIT License",
19
+ "Programming Language :: Python :: 3.11",
20
+ "Programming Language :: Python :: 3.12",
21
+ ]
22
+
23
+ [project.urls]
24
+ Homepage = "https://github.com/brian-pond/modelsync"
25
+ Repository = "https://github.com/brian-pond/modelsync"
26
+
27
+ [project.optional-dependencies]
28
+ dev = [
29
+ "build",
30
+ "pytest",
31
+ "ruff",
32
+ "sqlmodel",
33
+ "typer>=0.9",
34
+ ]
35
+ postgres = [
36
+ "psycopg[binary]>=3",
37
+ ]
38
+
39
+ [tool.ruff]
40
+ target-version = "py311"
41
+ line-length = 100
42
+ src = ["src"]
43
+
44
+ [tool.ruff.lint]
45
+ select = ["E", "F", "I", "B", "C4", "UP", "ARG", "SIM"]
46
+ ignore = []
47
+
48
+ [tool.setuptools.packages.find]
49
+ where = ["src"]
50
+
51
+ [project.scripts]
52
+ modelsync = "modelsync.cli:main"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,24 @@
1
+ """
2
+ modelsync — schema and model synchronization.
3
+
4
+ Document-driven project; requirements live under docs/requirements/.
5
+ Library API: ModelSync(connection=... | credentials=..., target_schema=...),
6
+ sync.compare(models) -> SyncPlan | SyncError.
7
+ """
8
+
9
+ from importlib.metadata import PackageNotFoundError, version
10
+
11
+ try:
12
+ __version__ = version("modelsync")
13
+ except PackageNotFoundError:
14
+ __version__ = "0.0.0"
15
+
16
+ from modelsync.errors import SyncError
17
+ from modelsync.plan.steps import SyncPlan
18
+ from modelsync.sync import ModelSync
19
+
20
+ __all__ = [
21
+ "ModelSync",
22
+ "SyncError",
23
+ "SyncPlan",
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 modelsync.adapters.model_schema import ModelSchema
8
+ from modelsync.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,218 @@
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 modelsync.adapters.sa_to_neutral import sa_column_to_neutral_type
24
+ from modelsync.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. modelsync Dialect), its
205
+ normalize_reflected_table is applied so model-side internal schema compares equal to database-side internal schema.
206
+ """
207
+ if isinstance(models, type):
208
+ model_seq: Sequence[type] = (models,)
209
+ else:
210
+ model_seq = models
211
+ instance = cls()
212
+ for model in model_seq:
213
+ table = _get_table_from_model(model)
214
+ table_def = _extract_table_def(table, target_schema, dialect=None)
215
+ if schema_normalizer is not None:
216
+ table_def = schema_normalizer.normalize_reflected_table(table_def)
217
+ instance._tables[table_def.name] = table_def
218
+ return instance