alterdb 0.2.0__tar.gz → 0.2.2__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.
- {alterdb-0.2.0 → alterdb-0.2.2}/CHANGELOG.md +53 -0
- {alterdb-0.2.0 → alterdb-0.2.2}/PKG-INFO +8 -4
- {alterdb-0.2.0 → alterdb-0.2.2}/README.md +7 -3
- {alterdb-0.2.0 → alterdb-0.2.2}/pyproject.toml +1 -1
- {alterdb-0.2.0 → alterdb-0.2.2}/src/alter/canvas/server.py +15 -5
- {alterdb-0.2.0 → alterdb-0.2.2}/src/alter/cli.py +56 -6
- {alterdb-0.2.0 → alterdb-0.2.2}/src/alter/exporters/mermaid.py +1 -1
- {alterdb-0.2.0 → alterdb-0.2.2}/src/alter/exporters/sql.py +3 -2
- {alterdb-0.2.0 → alterdb-0.2.2}/src/alter/generators/_surgical.py +23 -3
- {alterdb-0.2.0 → alterdb-0.2.2}/src/alter/generators/sqlalchemy.py +11 -4
- {alterdb-0.2.0 → alterdb-0.2.2}/src/alter/generators/sqlmodel.py +11 -7
- {alterdb-0.2.0 → alterdb-0.2.2}/src/alter/importers/database.py +22 -3
- {alterdb-0.2.0 → alterdb-0.2.2}/src/alter/importers/sql.py +14 -4
- {alterdb-0.2.0 → alterdb-0.2.2}/src/alter/mcp_server.py +68 -86
- {alterdb-0.2.0 → alterdb-0.2.2}/src/alter/merge_driver.py +25 -6
- {alterdb-0.2.0 → alterdb-0.2.2}/src/alter/parsers/base.py +44 -0
- {alterdb-0.2.0 → alterdb-0.2.2}/src/alter/parsers/sqlalchemy.py +3 -0
- {alterdb-0.2.0 → alterdb-0.2.2}/src/alter/parsers/sqlmodel.py +3 -0
- {alterdb-0.2.0 → alterdb-0.2.2}/src/alter/staging.py +15 -2
- {alterdb-0.2.0 → alterdb-0.2.2}/src/alter/validate.py +16 -0
- {alterdb-0.2.0 → alterdb-0.2.2}/tests/test_canvas_actions.py +157 -0
- {alterdb-0.2.0 → alterdb-0.2.2}/tests/test_canvas_cors.py +98 -20
- {alterdb-0.2.0 → alterdb-0.2.2}/tests/test_exporters.py +118 -0
- {alterdb-0.2.0 → alterdb-0.2.2}/tests/test_generator_sqlalchemy.py +54 -0
- {alterdb-0.2.0 → alterdb-0.2.2}/tests/test_generator_sqlmodel.py +53 -0
- {alterdb-0.2.0 → alterdb-0.2.2}/tests/test_importer_database.py +141 -0
- {alterdb-0.2.0 → alterdb-0.2.2}/tests/test_importers.py +40 -0
- {alterdb-0.2.0 → alterdb-0.2.2}/tests/test_mcp_server.py +67 -71
- {alterdb-0.2.0 → alterdb-0.2.2}/tests/test_merge_driver.py +103 -0
- {alterdb-0.2.0 → alterdb-0.2.2}/tests/test_staging.py +91 -0
- {alterdb-0.2.0 → alterdb-0.2.2}/tests/test_surgical.py +16 -3
- alterdb-0.2.2/tests/test_sync_from_code.py +244 -0
- {alterdb-0.2.0 → alterdb-0.2.2}/tests/test_validate.py +74 -0
- {alterdb-0.2.0 → alterdb-0.2.2}/.env.example +0 -0
- {alterdb-0.2.0 → alterdb-0.2.2}/.gitignore +0 -0
- {alterdb-0.2.0 → alterdb-0.2.2}/.python-version +0 -0
- {alterdb-0.2.0 → alterdb-0.2.2}/LICENSE +0 -0
- {alterdb-0.2.0 → alterdb-0.2.2}/docker-compose.yml +0 -0
- {alterdb-0.2.0 → alterdb-0.2.2}/docs/Canvas.png +0 -0
- {alterdb-0.2.0 → alterdb-0.2.2}/examples/saas-starter/alembic/alembic.ini +0 -0
- {alterdb-0.2.0 → alterdb-0.2.2}/examples/saas-starter/alembic/env.py +0 -0
- {alterdb-0.2.0 → alterdb-0.2.2}/examples/saas-starter/alembic/script.py.mako +0 -0
- {alterdb-0.2.0 → alterdb-0.2.2}/examples/saas-starter/app/__init__.py +0 -0
- {alterdb-0.2.0 → alterdb-0.2.2}/examples/saas-starter/app/database.py +0 -0
- {alterdb-0.2.0 → alterdb-0.2.2}/examples/saas-starter/app/enums.py +0 -0
- {alterdb-0.2.0 → alterdb-0.2.2}/examples/saas-starter/app/main.py +0 -0
- {alterdb-0.2.0 → alterdb-0.2.2}/examples/saas-starter/app/models/parents.py +0 -0
- {alterdb-0.2.0 → alterdb-0.2.2}/examples/saas-starter/app/models/starter.py +0 -0
- {alterdb-0.2.0 → alterdb-0.2.2}/examples/saas-starter/pyproject.toml +0 -0
- {alterdb-0.2.0 → alterdb-0.2.2}/examples/saas-starter/tests/__init__.py +0 -0
- {alterdb-0.2.0 → alterdb-0.2.2}/examples/saas-starter/tests/test_integration.py +0 -0
- {alterdb-0.2.0 → alterdb-0.2.2}/examples/saas-starter/tests/test_round_trip.py +0 -0
- {alterdb-0.2.0 → alterdb-0.2.2}/src/alter/__init__.py +0 -0
- {alterdb-0.2.0 → alterdb-0.2.2}/src/alter/canvas/__init__.py +0 -0
- {alterdb-0.2.0 → alterdb-0.2.2}/src/alter/canvas/static/canvas.js +0 -0
- {alterdb-0.2.0 → alterdb-0.2.2}/src/alter/canvas/static/index.html +0 -0
- {alterdb-0.2.0 → alterdb-0.2.2}/src/alter/canvas/static/style.css +0 -0
- {alterdb-0.2.0 → alterdb-0.2.2}/src/alter/data/demo_schema.alter +0 -0
- {alterdb-0.2.0 → alterdb-0.2.2}/src/alter/diff.py +0 -0
- {alterdb-0.2.0 → alterdb-0.2.2}/src/alter/diff_format.py +0 -0
- {alterdb-0.2.0 → alterdb-0.2.2}/src/alter/errors.py +0 -0
- {alterdb-0.2.0 → alterdb-0.2.2}/src/alter/exporters/__init__.py +0 -0
- {alterdb-0.2.0 → alterdb-0.2.2}/src/alter/exporters/alter_file.py +0 -0
- {alterdb-0.2.0 → alterdb-0.2.2}/src/alter/generators/__init__.py +0 -0
- {alterdb-0.2.0 → alterdb-0.2.2}/src/alter/generators/base.py +0 -0
- {alterdb-0.2.0 → alterdb-0.2.2}/src/alter/importers/__init__.py +0 -0
- {alterdb-0.2.0 → alterdb-0.2.2}/src/alter/importers/alter_file.py +0 -0
- {alterdb-0.2.0 → alterdb-0.2.2}/src/alter/layout.py +0 -0
- {alterdb-0.2.0 → alterdb-0.2.2}/src/alter/parsers/__init__.py +0 -0
- {alterdb-0.2.0 → alterdb-0.2.2}/src/alter/schema.py +0 -0
- {alterdb-0.2.0 → alterdb-0.2.2}/src/alter/templates/auth.alter +0 -0
- {alterdb-0.2.0 → alterdb-0.2.2}/src/alter/templates/cms.alter +0 -0
- {alterdb-0.2.0 → alterdb-0.2.2}/src/alter/templates/ecommerce.alter +0 -0
- {alterdb-0.2.0 → alterdb-0.2.2}/src/alter/templates/saas-base.alter +0 -0
- {alterdb-0.2.0 → alterdb-0.2.2}/src/alter/types.py +0 -0
- {alterdb-0.2.0 → alterdb-0.2.2}/src/alterdb/__init__.py +0 -0
- {alterdb-0.2.0 → alterdb-0.2.2}/templates/auth.alter +0 -0
- {alterdb-0.2.0 → alterdb-0.2.2}/templates/cms.alter +0 -0
- {alterdb-0.2.0 → alterdb-0.2.2}/templates/ecommerce.alter +0 -0
- {alterdb-0.2.0 → alterdb-0.2.2}/templates/saas-base.alter +0 -0
- {alterdb-0.2.0 → alterdb-0.2.2}/tests/__init__.py +0 -0
- {alterdb-0.2.0 → alterdb-0.2.2}/tests/conftest.py +0 -0
- {alterdb-0.2.0 → alterdb-0.2.2}/tests/fixtures/__init__.py +0 -0
- {alterdb-0.2.0 → alterdb-0.2.2}/tests/fixtures/sqlalchemy_models.py +0 -0
- {alterdb-0.2.0 → alterdb-0.2.2}/tests/test_alembic_wrapper.py +0 -0
- {alterdb-0.2.0 → alterdb-0.2.2}/tests/test_bug10_sa_column_type.py +0 -0
- {alterdb-0.2.0 → alterdb-0.2.2}/tests/test_bug17_apply_preserve.py +0 -0
- {alterdb-0.2.0 → alterdb-0.2.2}/tests/test_bug6_list_any_json.py +0 -0
- {alterdb-0.2.0 → alterdb-0.2.2}/tests/test_bug7_table_args.py +0 -0
- {alterdb-0.2.0 → alterdb-0.2.2}/tests/test_bug7_unreferenced_enums.py +0 -0
- {alterdb-0.2.0 → alterdb-0.2.2}/tests/test_bug8_surgical_preserve.py +0 -0
- {alterdb-0.2.0 → alterdb-0.2.2}/tests/test_bug_table_args_tuple.py +0 -0
- {alterdb-0.2.0 → alterdb-0.2.2}/tests/test_bugs_v013.py +0 -0
- {alterdb-0.2.0 → alterdb-0.2.2}/tests/test_cli.py +0 -0
- {alterdb-0.2.0 → alterdb-0.2.2}/tests/test_diff.py +0 -0
- {alterdb-0.2.0 → alterdb-0.2.2}/tests/test_e2e.py +0 -0
- {alterdb-0.2.0 → alterdb-0.2.2}/tests/test_enum_routing.py +0 -0
- {alterdb-0.2.0 → alterdb-0.2.2}/tests/test_layout.py +0 -0
- {alterdb-0.2.0 → alterdb-0.2.2}/tests/test_migration_sql.py +0 -0
- {alterdb-0.2.0 → alterdb-0.2.2}/tests/test_parser_sqlalchemy.py +0 -0
- {alterdb-0.2.0 → alterdb-0.2.2}/tests/test_parser_sqlmodel.py +0 -0
- {alterdb-0.2.0 → alterdb-0.2.2}/tests/test_round_trip.py +0 -0
- {alterdb-0.2.0 → alterdb-0.2.2}/tests/test_schema.py +0 -0
- {alterdb-0.2.0 → alterdb-0.2.2}/tests/test_smoke.py +0 -0
- {alterdb-0.2.0 → alterdb-0.2.2}/tests/test_types.py +0 -0
- {alterdb-0.2.0 → alterdb-0.2.2}/uv.lock +0 -0
|
@@ -2,6 +2,59 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to Alter are documented here.
|
|
4
4
|
|
|
5
|
+
## [0.2.2] — 2026-03-15
|
|
6
|
+
|
|
7
|
+
### Bug Fixes
|
|
8
|
+
|
|
9
|
+
#### `alter apply` now removes deleted tables and columns from code (Bug 09)
|
|
10
|
+
|
|
11
|
+
`alter apply` (and `apply_to_code` via MCP) previously ignored deletions — removing a table or column from the canvas and running `alter apply` would report "No changes — files are already up to date." while the Python class or field remained in the model file untouched. Four layers were involved:
|
|
12
|
+
|
|
13
|
+
1. **Column deletion — update detection** (`generators/_surgical.py` · `_class_needs_update`): the function only checked that schema columns were present in the file, never the reverse. A `Field()`-style column that existed in the file but had been removed from the schema was silently skipped. Added a reverse check: if any `Field()` / `mapped_column()` column in the file is absent from the schema, the class is flagged for update. Bare annotations (`name: str`) are intentionally excluded from this check — they may be non-ORM helper attributes and should never be auto-deleted.
|
|
14
|
+
|
|
15
|
+
2. **Column deletion — patch logic** (`generators/_surgical.py` · `_surgical_patch_class`): when walking source lines, a `Field()`-style column not found in `schema_map` fell into the "keep verbatim" branch — indistinguishable from an unchanged field. Restructured the branch: `col_name in schema_map` → keep or rebuild as before; `col_name not in schema_map` → omit from output (deleted). `last_field_result_idx` is now only updated when output is actually produced, so new-column insertion remains correctly positioned after the last kept field.
|
|
16
|
+
|
|
17
|
+
3. **Table deletion** (`generators/sqlmodel.py`, `generators/sqlalchemy.py` · `update_models`): classes present in the file but absent from the schema were unconditionally skipped with a "leave untouched" comment. Now uses `tablename_to_class` (which maps `__tablename__` string → class name) to identify genuine ORM table classes. If a class has `__tablename__` and its table was deleted from the schema, it is removed from the file. Mixins, base classes, and helper classes without `__tablename__` are always left untouched.
|
|
18
|
+
|
|
19
|
+
4. **File discovery** (`mcp_server.py` · `_apply_to_code_impl`, `cli.py` · `apply`): `file_groups` was built only from `schema.tables`, so a file whose last table was deleted was never visited at all. Both the CLI `apply` command and `_apply_to_code_impl` now scan the project root for ORM-containing `.py` files (using the parser's `detect_orm()`) and add any file not already in `file_groups` with an empty table list, ensuring `update_models()` is called and can remove the deleted class. Virtual environments, `__pycache__`, and other non-source directories are skipped.
|
|
20
|
+
|
|
21
|
+
`alter apply --preview` correctly shows deleted fields and classes as red lines in the unified diff before any files are written.
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
#### MCP `undo`/`redo` always reported "Nothing to undo/redo" even after changes (Bug 01)
|
|
26
|
+
|
|
27
|
+
`undo()` and `redo()` in `StagingManager` return `None` both when the stack is empty and when the operation succeeds but reverts the schema to its original state. The MCP tool handlers checked the return value to distinguish the two cases, which was unreliable. Added explicit `can_undo()` and `can_redo()` methods to `StagingManager` and changed the tool handlers to check those before calling `undo()`/`redo()`.
|
|
28
|
+
|
|
29
|
+
#### Duplicate relations when tables are defined across multiple files (Bug 02)
|
|
30
|
+
|
|
31
|
+
`parse_directory()` in both the SQLModel and SQLAlchemy parsers accumulated `Relation` objects across all scanned files without deduplication. When a table (and its relations) appeared in more than one file — e.g. a base model re-exported from a package `__init__.py` — the schema ended up with duplicate relation entries, which produced broken Mermaid diagrams and duplicate `FOREIGN KEY` clauses in SQL export. Added `deduplicate_relations()` to `parsers/base.py` (mirroring the existing `deduplicate_tables()`), which keeps the first definition of each relation name and also prunes orphaned relations whose source table was itself dropped by deduplication. Both parsers now call it immediately after `deduplicate_tables()`.
|
|
32
|
+
|
|
33
|
+
#### MCP tool errors returned `isError: false` (Bug 05)
|
|
34
|
+
|
|
35
|
+
All `except` branches in `mcp_server.py` tool handlers returned error strings (`return f"Error: {exc}"`) instead of raising. FastMCP's internal error chain only sets `isError: true` on the MCP response when the tool function raises an exception; returning a string always produces `isError: false`, so AI assistants had no reliable way to detect failures. All 17 return-error patterns replaced with `raise` (re-raise in `except` blocks) or `raise ValueError(...)` (inline guards). The 27 affected tests in `test_mcp_server.py` updated to use `pytest.raises`.
|
|
36
|
+
|
|
37
|
+
#### `alter sync`/`alter diff` scan the wrong directory on multi-file projects (Bug 07)
|
|
38
|
+
|
|
39
|
+
`_find_model_dirs()` in `cli.py` selected the first existing directory from a candidate list (`app/models`, `app/`, `src/`, `cwd`) without checking whether the directory contained any Python files. An empty `app/` directory would win over the project root even if all models were in `cwd`. Two-part fix: (1) `_find_model_dirs()` now requires at least one `.py` file (recursive, skipping venv/cache dirs) via a new `_has_py_files()` helper — empty directories are excluded. (2) `sync` and `diff` commands now use the `file_path` values recorded in `schema.tables` as the primary source of truth when tables are already tracked, falling back to the directory heuristic only for empty schemas. `alter init` also now records the most common model file path in `schema.metadata.sqlmodel_module` rather than using a hardcoded `"app/models.py"` default.
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
## [0.2.1] — 2026-03-14
|
|
44
|
+
|
|
45
|
+
### Bug Fixes
|
|
46
|
+
|
|
47
|
+
#### CSRF: canvas server replaced wildcard CORS with canvas-specific origin
|
|
48
|
+
`Access-Control-Allow-Origin: *` allowed any website visited in the user's browser to make cross-origin requests to the canvas server's mutating endpoints (e.g. `/api/commit`, `/api/apply-to-code`). The server now reflects its own origin (`http://127.0.0.1:{port}`) in the ACAO header and adds `Vary: Origin`, so browser preflight checks block requests from other origins.
|
|
49
|
+
|
|
50
|
+
#### Mermaid exporter: `FK,UK` now both emitted for FK+unique columns
|
|
51
|
+
An `elif` on the unique check meant columns that are both a foreign key and unique (one-to-one relationship pattern) only showed `FK` in the diagram. Changed to `if` so both `FK` and `UK` attributes are emitted.
|
|
52
|
+
|
|
53
|
+
#### SQL importer: parenthesized DEFAULT expressions with spaces now captured correctly
|
|
54
|
+
`DEFAULT (1 + 2)` and `DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC')` were truncated to just the first token because the `\S+` fallback stopped at the first space. Two-part fix: (1) `_DEFAULT_RE` now includes a parenthesized-expression branch `\((?:[^()]*|\([^()]*\))*\)` that handles one level of nesting; (2) the regex is now applied to the original `defn` string rather than the post-processed `rest`, because `_COL_DEF_RE`'s optional size group was silently stripping parentheses from DEFAULT expressions before `rest` was assembled.
|
|
55
|
+
|
|
56
|
+
---
|
|
57
|
+
|
|
5
58
|
## [0.2.0] — 2026-03-14
|
|
6
59
|
|
|
7
60
|
### New Features
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: alterdb
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Summary: Visual schema design for SQLModel and SQLAlchemy. Edit your database as a diagram, write it back as code.
|
|
5
5
|
Project-URL: Homepage, https://github.com/chimi-labs/alter
|
|
6
6
|
Project-URL: Repository, https://github.com/chimi-labs/alter
|
|
@@ -160,9 +160,13 @@ alter apply --preview # see exactly what will change (unified diff)
|
|
|
160
160
|
alter apply # write the changes to your model files
|
|
161
161
|
```
|
|
162
162
|
|
|
163
|
-
`alter apply` is surgical — it only
|
|
164
|
-
|
|
165
|
-
|
|
163
|
+
`alter apply` is surgical — it only touches what the schema says has changed:
|
|
164
|
+
|
|
165
|
+
- **Additions** — new tables are appended as new classes; new columns are inserted after the last existing field.
|
|
166
|
+
- **Modifications** — changed `Field()` kwargs are rebuilt in-place, preserving your original kwarg order, multi-line formatting, and inline comments.
|
|
167
|
+
- **Deletions** — tables removed from the canvas have their class deleted from the model file; columns removed from a table have their `Field()` line removed. If a file's last table is deleted, the file is still visited and the class is removed.
|
|
168
|
+
|
|
169
|
+
Your docstrings, `Relationship()` definitions, trailing inline comments, hand-written `Field()` kwarg order, and mutable defaults written as `default={}` or `default=[]` are all preserved verbatim.
|
|
166
170
|
|
|
167
171
|
For example, drawing a `Payment` table on the canvas and clicking **Commit** writes this to
|
|
168
172
|
`app/models.py`:
|
|
@@ -130,9 +130,13 @@ alter apply --preview # see exactly what will change (unified diff)
|
|
|
130
130
|
alter apply # write the changes to your model files
|
|
131
131
|
```
|
|
132
132
|
|
|
133
|
-
`alter apply` is surgical — it only
|
|
134
|
-
|
|
135
|
-
|
|
133
|
+
`alter apply` is surgical — it only touches what the schema says has changed:
|
|
134
|
+
|
|
135
|
+
- **Additions** — new tables are appended as new classes; new columns are inserted after the last existing field.
|
|
136
|
+
- **Modifications** — changed `Field()` kwargs are rebuilt in-place, preserving your original kwarg order, multi-line formatting, and inline comments.
|
|
137
|
+
- **Deletions** — tables removed from the canvas have their class deleted from the model file; columns removed from a table have their `Field()` line removed. If a file's last table is deleted, the file is still visited and the class is removed.
|
|
138
|
+
|
|
139
|
+
Your docstrings, `Relationship()` definitions, trailing inline comments, hand-written `Field()` kwarg order, and mutable defaults written as `default={}` or `default=[]` are all preserved verbatim.
|
|
136
140
|
|
|
137
141
|
For example, drawing a `Payment` table on the canvas and clicking **Commit** writes this to
|
|
138
142
|
`app/models.py`:
|
|
@@ -374,16 +374,26 @@ class _Handler(BaseHTTPRequestHandler):
|
|
|
374
374
|
|
|
375
375
|
# ── CORS ─────────────────────────────────────────────────────────────────
|
|
376
376
|
|
|
377
|
+
def _canvas_origin(self) -> str:
|
|
378
|
+
"""Return the canvas server's own origin, e.g. ``http://127.0.0.1:8080``."""
|
|
379
|
+
host, port = self.server.server_address
|
|
380
|
+
return f"http://{host}:{port}"
|
|
381
|
+
|
|
377
382
|
def _send_cors_headers(self) -> None:
|
|
378
|
-
"""Append CORS headers
|
|
383
|
+
"""Append CORS headers that restrict access to the canvas origin only.
|
|
384
|
+
|
|
385
|
+
Using ``Access-Control-Allow-Origin: *`` would allow any website the
|
|
386
|
+
user visits to make cross-origin requests to mutating endpoints (CSRF).
|
|
387
|
+
Reflecting the server's own origin means only requests that the canvas
|
|
388
|
+
page itself initiates will pass the browser's preflight check.
|
|
379
389
|
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
port during development) and browser extensions to reach the API.
|
|
390
|
+
``Vary: Origin`` is included so that caches do not serve a response
|
|
391
|
+
cached for one origin to a different origin.
|
|
383
392
|
"""
|
|
384
|
-
self.send_header("Access-Control-Allow-Origin",
|
|
393
|
+
self.send_header("Access-Control-Allow-Origin", self._canvas_origin())
|
|
385
394
|
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
|
386
395
|
self.send_header("Access-Control-Allow-Headers", "Content-Type")
|
|
396
|
+
self.send_header("Vary", "Origin")
|
|
387
397
|
|
|
388
398
|
def do_OPTIONS(self) -> None: # noqa: N802
|
|
389
399
|
"""Handle CORS preflight requests."""
|
|
@@ -102,15 +102,33 @@ def _detect_orm(cwd: Path) -> str:
|
|
|
102
102
|
return "sqlmodel" # safe default
|
|
103
103
|
|
|
104
104
|
|
|
105
|
+
def _has_py_files(directory: Path) -> bool:
|
|
106
|
+
"""Return True if *directory* contains at least one .py file (recursive).
|
|
107
|
+
|
|
108
|
+
Skips virtual-environment and cache directories so that an ``app/``
|
|
109
|
+
containing only a ``.venv`` subtree is not treated as a model directory.
|
|
110
|
+
Short-circuits on the first match for speed.
|
|
111
|
+
"""
|
|
112
|
+
for f in directory.rglob("*.py"):
|
|
113
|
+
if not any(part in _SKIP_DIRS for part in f.parts):
|
|
114
|
+
return True
|
|
115
|
+
return False
|
|
116
|
+
|
|
117
|
+
|
|
105
118
|
def _find_model_dirs(cwd: Path) -> list[Path]:
|
|
106
|
-
"""Return candidate model directories
|
|
119
|
+
"""Return candidate model directories that contain Python source files.
|
|
120
|
+
|
|
121
|
+
Checks only whether a directory exists AND has at least one ``.py`` file
|
|
122
|
+
(ignoring virtual-environment / cache subtrees). This prevents an empty
|
|
123
|
+
``app/`` directory from being chosen over the project root.
|
|
124
|
+
"""
|
|
107
125
|
candidates = [
|
|
108
126
|
cwd / "app" / "models",
|
|
109
127
|
cwd / "app",
|
|
110
128
|
cwd / "src",
|
|
111
129
|
cwd,
|
|
112
130
|
]
|
|
113
|
-
return [d for d in candidates if d.is_dir()]
|
|
131
|
+
return [d for d in candidates if d.is_dir() and _has_py_files(d)]
|
|
114
132
|
|
|
115
133
|
|
|
116
134
|
def _load_demo_schema() -> Path:
|
|
@@ -220,6 +238,15 @@ def init(orm_override: str | None, output: str | None, force: bool) -> None:
|
|
|
220
238
|
|
|
221
239
|
from alter.layout import auto_layout_tables
|
|
222
240
|
auto_layout_tables(result.schema.tables)
|
|
241
|
+
|
|
242
|
+
# Record the most-used model file as sqlmodel_module so that
|
|
243
|
+
# 'alter import' knows where to put new SQL-imported tables.
|
|
244
|
+
# Only override the default when we actually found model files.
|
|
245
|
+
file_paths = [t.file_path for t in result.schema.tables if t.file_path]
|
|
246
|
+
if file_paths:
|
|
247
|
+
from collections import Counter
|
|
248
|
+
result.schema.metadata.sqlmodel_module = Counter(file_paths).most_common(1)[0][0]
|
|
249
|
+
|
|
223
250
|
result.schema.save(out_path)
|
|
224
251
|
click.echo(
|
|
225
252
|
f" Created {out_path.name} — "
|
|
@@ -258,8 +285,16 @@ def sync(alter_file: str | None, model_dir: str | None) -> None:
|
|
|
258
285
|
cwd = path.parent
|
|
259
286
|
scan_dir = Path(model_dir) if model_dir else None
|
|
260
287
|
if scan_dir is None:
|
|
261
|
-
|
|
262
|
-
|
|
288
|
+
# If the schema already has tables with recorded file paths, scan from
|
|
289
|
+
# the project root so that all model files are found regardless of
|
|
290
|
+
# directory structure — and new files added outside the original
|
|
291
|
+
# location are picked up too. Fall back to the directory heuristic
|
|
292
|
+
# only for schemas with no tables (e.g. first sync on an empty schema).
|
|
293
|
+
if any(t.file_path for t in current.tables):
|
|
294
|
+
scan_dir = cwd
|
|
295
|
+
else:
|
|
296
|
+
dirs = _find_model_dirs(cwd)
|
|
297
|
+
scan_dir = dirs[0] if dirs else cwd
|
|
263
298
|
try:
|
|
264
299
|
from alter.parsers.base import get_parser
|
|
265
300
|
parser = get_parser(current.orm, project_root=cwd)
|
|
@@ -408,6 +443,18 @@ def apply(alter_file: str | None, preview: bool) -> None:
|
|
|
408
443
|
fp = t.file_path or _default_model_path(schema, project_root)
|
|
409
444
|
file_groups.setdefault(fp, []).append(t)
|
|
410
445
|
|
|
446
|
+
# Also discover model files on disk that are NOT already in file_groups.
|
|
447
|
+
# These may contain table classes whose schema entries were deleted — we
|
|
448
|
+
# must visit them so update_models() can remove the deleted classes.
|
|
449
|
+
from alter.parsers.base import get_parser as _get_parser # noqa: PLC0415
|
|
450
|
+
_file_parser = _get_parser(schema.orm, project_root=project_root)
|
|
451
|
+
for _py_file in sorted(project_root.rglob("*.py")):
|
|
452
|
+
if any(part in _SKIP_DIRS for part in _py_file.parts):
|
|
453
|
+
continue
|
|
454
|
+
_rel = str(_py_file.relative_to(project_root))
|
|
455
|
+
if _rel not in file_groups and _file_parser.detect_orm(_py_file):
|
|
456
|
+
file_groups[_rel] = []
|
|
457
|
+
|
|
411
458
|
changed = 0
|
|
412
459
|
default_path = _default_model_path(schema, project_root)
|
|
413
460
|
for rel_path, tables in sorted(file_groups.items()):
|
|
@@ -491,8 +538,11 @@ def diff(alter_file: str | None, fmt: str) -> None:
|
|
|
491
538
|
raise click.ClickException(str(exc)) from exc
|
|
492
539
|
|
|
493
540
|
cwd = path.parent
|
|
494
|
-
|
|
495
|
-
|
|
541
|
+
if any(t.file_path for t in current.tables):
|
|
542
|
+
scan_dir = cwd
|
|
543
|
+
else:
|
|
544
|
+
dirs = _find_model_dirs(cwd)
|
|
545
|
+
scan_dir = dirs[0] if dirs else cwd
|
|
496
546
|
try:
|
|
497
547
|
from alter.parsers.base import get_parser
|
|
498
548
|
parser = get_parser(current.orm, project_root=cwd)
|
|
@@ -148,5 +148,6 @@ def _format_default(default: str) -> str | None:
|
|
|
148
148
|
return default
|
|
149
149
|
except ValueError:
|
|
150
150
|
pass
|
|
151
|
-
# String literal — wrap in single quotes
|
|
152
|
-
|
|
151
|
+
# String literal — wrap in single quotes, doubling any internal quotes
|
|
152
|
+
# (standard SQL escaping: don't → 'don''t').
|
|
153
|
+
return "'{}'".format(default.replace("'", "''"))
|
|
@@ -562,6 +562,19 @@ def _class_needs_update(class_source: str, schema_field_lines: list[str]) -> boo
|
|
|
562
562
|
else:
|
|
563
563
|
return True # genuinely new column
|
|
564
564
|
|
|
565
|
+
# Reverse check: if any Field()-style column in the file is NOT in the
|
|
566
|
+
# schema, the class needs updating (that column was deleted from the schema).
|
|
567
|
+
# We intentionally do NOT check bare annotations here — those may be
|
|
568
|
+
# hand-written non-ORM helper attributes and should never be auto-deleted.
|
|
569
|
+
schema_col_names = {
|
|
570
|
+
c
|
|
571
|
+
for line in schema_field_lines
|
|
572
|
+
if (c := _col_name_from_generated(line)) is not None
|
|
573
|
+
}
|
|
574
|
+
for col_name, _, _ in stmts:
|
|
575
|
+
if col_name not in schema_col_names:
|
|
576
|
+
return True
|
|
577
|
+
|
|
565
578
|
return False
|
|
566
579
|
|
|
567
580
|
|
|
@@ -636,19 +649,26 @@ def _surgical_patch_class(
|
|
|
636
649
|
result.append(indent_str + new_line + "\n")
|
|
637
650
|
else:
|
|
638
651
|
# Bare field is equivalent or not in schema — keep verbatim.
|
|
652
|
+
# We never auto-delete bare annotations: they may be non-ORM
|
|
653
|
+
# helper attributes, not managed schema columns.
|
|
639
654
|
for ln in src_lines[i:end]:
|
|
640
655
|
result.append(ln)
|
|
656
|
+
# Bare branch always produces output — update insertion marker.
|
|
657
|
+
last_field_result_idx = len(result) - 1
|
|
641
658
|
else:
|
|
642
659
|
if col_name in schema_map and not _field_kwargs_equal(existing_text, schema_map[col_name]):
|
|
643
660
|
# Rebuild with original kwarg order / multi-line style / LHS preserved
|
|
644
661
|
rebuilt = _rebuild_field_line(existing_text, schema_map[col_name])
|
|
645
662
|
result.append(rebuilt.rstrip() + "\n")
|
|
646
|
-
|
|
663
|
+
last_field_result_idx = len(result) - 1
|
|
664
|
+
elif col_name in schema_map:
|
|
647
665
|
# Keep verbatim (unchanged field — preserves kwarg order and formatting)
|
|
648
666
|
for ln in src_lines[i:end]:
|
|
649
667
|
result.append(ln)
|
|
650
|
-
|
|
651
|
-
|
|
668
|
+
last_field_result_idx = len(result) - 1
|
|
669
|
+
# else: col_name not in schema_map → column deleted from schema;
|
|
670
|
+
# omit from output. Do NOT update last_field_result_idx so that
|
|
671
|
+
# any new columns are still inserted after the last kept field.
|
|
652
672
|
i = end
|
|
653
673
|
else:
|
|
654
674
|
result.append(src_lines[i])
|
|
@@ -103,9 +103,9 @@ def _mapped_column_args(col: Column, enum_names: set[str]) -> str:
|
|
|
103
103
|
args.append("default=lambda: datetime.now(timezone.utc)")
|
|
104
104
|
elif col.default == "now":
|
|
105
105
|
args.append("default=datetime.now")
|
|
106
|
-
elif col.default
|
|
106
|
+
elif col.default in ("dict", "{}"):
|
|
107
107
|
args.append("default=dict")
|
|
108
|
-
elif col.default
|
|
108
|
+
elif col.default in ("list", "[]"):
|
|
109
109
|
args.append("default=list")
|
|
110
110
|
elif col.default is not None:
|
|
111
111
|
raw = col.default
|
|
@@ -305,11 +305,18 @@ class SQLAlchemyGenerator(BaseGenerator):
|
|
|
305
305
|
}
|
|
306
306
|
schema_classes: set[str] = set(table_by_class) | set(enum_by_class)
|
|
307
307
|
|
|
308
|
+
# ORM table class names in this file: those with an explicit __tablename__.
|
|
309
|
+
# Only these are auto-removed when absent from the schema; mixins, base
|
|
310
|
+
# classes, and helper classes (no __tablename__) are always left untouched.
|
|
311
|
+
orm_class_names: set[str] = set(tablename_to_class.values())
|
|
312
|
+
|
|
308
313
|
replacements: list[tuple[int, int, list[str]]] = []
|
|
309
314
|
for cls_name, (start, end) in existing_classes.items():
|
|
310
315
|
if cls_name not in schema_classes:
|
|
311
|
-
#
|
|
312
|
-
#
|
|
316
|
+
# If the class is an ORM table (has __tablename__) and its table
|
|
317
|
+
# was deleted from the schema, remove it from the file.
|
|
318
|
+
if cls_name in orm_class_names:
|
|
319
|
+
replacements.append((start, end, []))
|
|
313
320
|
continue
|
|
314
321
|
if cls_name in table_by_class:
|
|
315
322
|
# Surgical update: only include non-inherited columns
|
|
@@ -78,12 +78,10 @@ def _field_args(col: Column, enum_names: set[str]) -> str:
|
|
|
78
78
|
args.append("default_factory=lambda: datetime.now(timezone.utc)")
|
|
79
79
|
elif col.default == "now":
|
|
80
80
|
args.append("default_factory=datetime.now")
|
|
81
|
-
elif col.default
|
|
81
|
+
elif col.default in ("list", "[]"):
|
|
82
82
|
args.append("default_factory=list")
|
|
83
|
-
elif col.default
|
|
83
|
+
elif col.default in ("dict", "{}"):
|
|
84
84
|
args.append("default_factory=dict")
|
|
85
|
-
elif col.default == "[]":
|
|
86
|
-
args.append("default_factory=list")
|
|
87
85
|
elif col.default is not None:
|
|
88
86
|
raw = col.default
|
|
89
87
|
if raw == "true":
|
|
@@ -285,12 +283,18 @@ class SQLModelGenerator(BaseGenerator):
|
|
|
285
283
|
schema_classes: set[str] = set(table_by_class) | set(enum_by_class)
|
|
286
284
|
|
|
287
285
|
# Build replacements: (start, end, patched_lines) — applied bottom-up
|
|
286
|
+
# ORM table class names in this file: those with an explicit __tablename__.
|
|
287
|
+
# Only these are auto-removed when absent from the schema; mixins, base
|
|
288
|
+
# classes, and helper classes (no __tablename__) are always left untouched.
|
|
289
|
+
orm_class_names: set[str] = set(tablename_to_class.values())
|
|
290
|
+
|
|
288
291
|
replacements: list[tuple[int, int, list[str]]] = []
|
|
289
292
|
for cls_name, (start, end) in existing_classes.items():
|
|
290
293
|
if cls_name not in schema_classes:
|
|
291
|
-
#
|
|
292
|
-
#
|
|
293
|
-
|
|
294
|
+
# If the class is an ORM table (has __tablename__) and its table
|
|
295
|
+
# was deleted from the schema, remove it from the file.
|
|
296
|
+
if cls_name in orm_class_names:
|
|
297
|
+
replacements.append((start, end, []))
|
|
294
298
|
continue
|
|
295
299
|
if cls_name in table_by_class:
|
|
296
300
|
# Surgical update: preserve docstrings, Relationship lines,
|
|
@@ -61,6 +61,7 @@ _PG_TYPE_MAP: dict[str, str] = {
|
|
|
61
61
|
def import_from_database(
|
|
62
62
|
connection_string: str,
|
|
63
63
|
schema: str = "public",
|
|
64
|
+
orm: str = "sqlmodel",
|
|
64
65
|
) -> AlterSchema:
|
|
65
66
|
"""Introspect a live PostgreSQL database and return an ``AlterSchema``.
|
|
66
67
|
|
|
@@ -78,6 +79,10 @@ def import_from_database(
|
|
|
78
79
|
have ``schema_name`` set on the resulting ``Table`` objects so
|
|
79
80
|
that generated SQL uses the fully-qualified ``schema.table``
|
|
80
81
|
reference.
|
|
82
|
+
orm: ORM backend to stamp on the returned ``AlterSchema`` —
|
|
83
|
+
``"sqlmodel"`` (default) or ``"sqlalchemy"``. Callers should
|
|
84
|
+
pass the current project's ORM so that ``alter apply`` generates
|
|
85
|
+
code in the correct style.
|
|
81
86
|
|
|
82
87
|
Returns:
|
|
83
88
|
An ``AlterSchema`` with grid-positioned tables and relations.
|
|
@@ -103,7 +108,7 @@ def import_from_database(
|
|
|
103
108
|
) from exc
|
|
104
109
|
|
|
105
110
|
try:
|
|
106
|
-
return _introspect(conn, schema=schema)
|
|
111
|
+
return _introspect(conn, schema=schema, orm=orm)
|
|
107
112
|
finally:
|
|
108
113
|
conn.close()
|
|
109
114
|
|
|
@@ -113,7 +118,7 @@ def import_from_database(
|
|
|
113
118
|
# ---------------------------------------------------------------------------
|
|
114
119
|
|
|
115
120
|
|
|
116
|
-
def _introspect(conn: object, schema: str = "public") -> AlterSchema:
|
|
121
|
+
def _introspect(conn: object, schema: str = "public", orm: str = "sqlmodel") -> AlterSchema:
|
|
117
122
|
"""Run all introspection queries and build the schema.
|
|
118
123
|
|
|
119
124
|
Args:
|
|
@@ -181,6 +186,14 @@ def _introspect(conn: object, schema: str = "public") -> AlterSchema:
|
|
|
181
186
|
uq_cols: set[tuple[str, str]] = {(r[0], r[1]) for r in cursor.fetchall()}
|
|
182
187
|
|
|
183
188
|
# ── Foreign keys ─────────────────────────────────────────────────────────
|
|
189
|
+
# Only single-column FK constraints are fetched. Joining kcu (the
|
|
190
|
+
# referencing side) with ccu (the referenced side) on constraint_name alone
|
|
191
|
+
# produces a cartesian product for composite FKs — e.g. a two-column FK
|
|
192
|
+
# (a, b) REFERENCES t(x, y) would yield four rows (a→x, a→y, b→x, b→y)
|
|
193
|
+
# instead of two. Since alter's data model stores FK references per column
|
|
194
|
+
# (Column.foreign_key is a single string), composite FK constraints cannot
|
|
195
|
+
# be represented accurately regardless; the subquery below skips them
|
|
196
|
+
# entirely to avoid producing wrong per-column mappings.
|
|
184
197
|
cursor.execute(
|
|
185
198
|
"""
|
|
186
199
|
SELECT tc.table_name, kcu.column_name,
|
|
@@ -198,6 +211,12 @@ def _introspect(conn: object, schema: str = "public") -> AlterSchema:
|
|
|
198
211
|
ON tc.constraint_name = rc.constraint_name
|
|
199
212
|
WHERE tc.table_schema = %s
|
|
200
213
|
AND tc.constraint_type = 'FOREIGN KEY'
|
|
214
|
+
AND (
|
|
215
|
+
SELECT count(*)
|
|
216
|
+
FROM information_schema.key_column_usage k2
|
|
217
|
+
WHERE k2.constraint_name = tc.constraint_name
|
|
218
|
+
AND k2.table_schema = tc.table_schema
|
|
219
|
+
) = 1
|
|
201
220
|
ORDER BY tc.table_name, kcu.column_name
|
|
202
221
|
""",
|
|
203
222
|
(schema,),
|
|
@@ -281,7 +300,7 @@ def _introspect(conn: object, schema: str = "public") -> AlterSchema:
|
|
|
281
300
|
)
|
|
282
301
|
)
|
|
283
302
|
|
|
284
|
-
schema = AlterSchema(orm=
|
|
303
|
+
schema = AlterSchema(orm=orm, tables=tables, relations=relations)
|
|
285
304
|
_auto_position(schema)
|
|
286
305
|
return schema
|
|
287
306
|
|
|
@@ -297,7 +297,14 @@ _CONSTRAINT_SPLIT_RE = re.compile(
|
|
|
297
297
|
)
|
|
298
298
|
|
|
299
299
|
_DEFAULT_RE = re.compile(
|
|
300
|
-
r"DEFAULT\s+
|
|
300
|
+
r"DEFAULT\s+"
|
|
301
|
+
r"("
|
|
302
|
+
r"'(?:[^']|'')*'" # single-quoted string (handles '' escapes)
|
|
303
|
+
r"|"
|
|
304
|
+
r"\((?:[^()]*|\([^()]*\))*\)" # parenthesized expression, one level of nesting
|
|
305
|
+
r"|"
|
|
306
|
+
r"\S+" # simple non-whitespace token
|
|
307
|
+
r")",
|
|
301
308
|
re.IGNORECASE,
|
|
302
309
|
)
|
|
303
310
|
|
|
@@ -353,12 +360,15 @@ def _parse_column_def(
|
|
|
353
360
|
unique = "UNIQUE" in rest_upper
|
|
354
361
|
|
|
355
362
|
# DEFAULT value
|
|
363
|
+
# Search `defn` (the original full column definition text) rather than the
|
|
364
|
+
# processed `rest`. _COL_DEF_RE's optional size group `(?:\s*\(([^)]*)\))?`
|
|
365
|
+
# can accidentally consume a parenthesised DEFAULT expression such as
|
|
366
|
+
# `DEFAULT (1 + 2)`, stripping the parens before `rest` is assembled.
|
|
367
|
+
# Searching the raw `defn` bypasses that mangling.
|
|
356
368
|
default: str | None = None
|
|
357
|
-
dm = _DEFAULT_RE.search(
|
|
369
|
+
dm = _DEFAULT_RE.search(defn)
|
|
358
370
|
if dm:
|
|
359
371
|
raw_default = dm.group(1).strip()
|
|
360
|
-
# Normalise common defaults. Strip trailing "()" before comparison
|
|
361
|
-
# because the regex may have consumed the parens as the "size" group.
|
|
362
372
|
raw_upper_bare = raw_default.upper().rstrip("()")
|
|
363
373
|
if raw_upper_bare in ("NOW", "CURRENT_TIMESTAMP"):
|
|
364
374
|
default = "utcnow"
|