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.
Files changed (106) hide show
  1. {alterdb-0.2.0 → alterdb-0.2.2}/CHANGELOG.md +53 -0
  2. {alterdb-0.2.0 → alterdb-0.2.2}/PKG-INFO +8 -4
  3. {alterdb-0.2.0 → alterdb-0.2.2}/README.md +7 -3
  4. {alterdb-0.2.0 → alterdb-0.2.2}/pyproject.toml +1 -1
  5. {alterdb-0.2.0 → alterdb-0.2.2}/src/alter/canvas/server.py +15 -5
  6. {alterdb-0.2.0 → alterdb-0.2.2}/src/alter/cli.py +56 -6
  7. {alterdb-0.2.0 → alterdb-0.2.2}/src/alter/exporters/mermaid.py +1 -1
  8. {alterdb-0.2.0 → alterdb-0.2.2}/src/alter/exporters/sql.py +3 -2
  9. {alterdb-0.2.0 → alterdb-0.2.2}/src/alter/generators/_surgical.py +23 -3
  10. {alterdb-0.2.0 → alterdb-0.2.2}/src/alter/generators/sqlalchemy.py +11 -4
  11. {alterdb-0.2.0 → alterdb-0.2.2}/src/alter/generators/sqlmodel.py +11 -7
  12. {alterdb-0.2.0 → alterdb-0.2.2}/src/alter/importers/database.py +22 -3
  13. {alterdb-0.2.0 → alterdb-0.2.2}/src/alter/importers/sql.py +14 -4
  14. {alterdb-0.2.0 → alterdb-0.2.2}/src/alter/mcp_server.py +68 -86
  15. {alterdb-0.2.0 → alterdb-0.2.2}/src/alter/merge_driver.py +25 -6
  16. {alterdb-0.2.0 → alterdb-0.2.2}/src/alter/parsers/base.py +44 -0
  17. {alterdb-0.2.0 → alterdb-0.2.2}/src/alter/parsers/sqlalchemy.py +3 -0
  18. {alterdb-0.2.0 → alterdb-0.2.2}/src/alter/parsers/sqlmodel.py +3 -0
  19. {alterdb-0.2.0 → alterdb-0.2.2}/src/alter/staging.py +15 -2
  20. {alterdb-0.2.0 → alterdb-0.2.2}/src/alter/validate.py +16 -0
  21. {alterdb-0.2.0 → alterdb-0.2.2}/tests/test_canvas_actions.py +157 -0
  22. {alterdb-0.2.0 → alterdb-0.2.2}/tests/test_canvas_cors.py +98 -20
  23. {alterdb-0.2.0 → alterdb-0.2.2}/tests/test_exporters.py +118 -0
  24. {alterdb-0.2.0 → alterdb-0.2.2}/tests/test_generator_sqlalchemy.py +54 -0
  25. {alterdb-0.2.0 → alterdb-0.2.2}/tests/test_generator_sqlmodel.py +53 -0
  26. {alterdb-0.2.0 → alterdb-0.2.2}/tests/test_importer_database.py +141 -0
  27. {alterdb-0.2.0 → alterdb-0.2.2}/tests/test_importers.py +40 -0
  28. {alterdb-0.2.0 → alterdb-0.2.2}/tests/test_mcp_server.py +67 -71
  29. {alterdb-0.2.0 → alterdb-0.2.2}/tests/test_merge_driver.py +103 -0
  30. {alterdb-0.2.0 → alterdb-0.2.2}/tests/test_staging.py +91 -0
  31. {alterdb-0.2.0 → alterdb-0.2.2}/tests/test_surgical.py +16 -3
  32. alterdb-0.2.2/tests/test_sync_from_code.py +244 -0
  33. {alterdb-0.2.0 → alterdb-0.2.2}/tests/test_validate.py +74 -0
  34. {alterdb-0.2.0 → alterdb-0.2.2}/.env.example +0 -0
  35. {alterdb-0.2.0 → alterdb-0.2.2}/.gitignore +0 -0
  36. {alterdb-0.2.0 → alterdb-0.2.2}/.python-version +0 -0
  37. {alterdb-0.2.0 → alterdb-0.2.2}/LICENSE +0 -0
  38. {alterdb-0.2.0 → alterdb-0.2.2}/docker-compose.yml +0 -0
  39. {alterdb-0.2.0 → alterdb-0.2.2}/docs/Canvas.png +0 -0
  40. {alterdb-0.2.0 → alterdb-0.2.2}/examples/saas-starter/alembic/alembic.ini +0 -0
  41. {alterdb-0.2.0 → alterdb-0.2.2}/examples/saas-starter/alembic/env.py +0 -0
  42. {alterdb-0.2.0 → alterdb-0.2.2}/examples/saas-starter/alembic/script.py.mako +0 -0
  43. {alterdb-0.2.0 → alterdb-0.2.2}/examples/saas-starter/app/__init__.py +0 -0
  44. {alterdb-0.2.0 → alterdb-0.2.2}/examples/saas-starter/app/database.py +0 -0
  45. {alterdb-0.2.0 → alterdb-0.2.2}/examples/saas-starter/app/enums.py +0 -0
  46. {alterdb-0.2.0 → alterdb-0.2.2}/examples/saas-starter/app/main.py +0 -0
  47. {alterdb-0.2.0 → alterdb-0.2.2}/examples/saas-starter/app/models/parents.py +0 -0
  48. {alterdb-0.2.0 → alterdb-0.2.2}/examples/saas-starter/app/models/starter.py +0 -0
  49. {alterdb-0.2.0 → alterdb-0.2.2}/examples/saas-starter/pyproject.toml +0 -0
  50. {alterdb-0.2.0 → alterdb-0.2.2}/examples/saas-starter/tests/__init__.py +0 -0
  51. {alterdb-0.2.0 → alterdb-0.2.2}/examples/saas-starter/tests/test_integration.py +0 -0
  52. {alterdb-0.2.0 → alterdb-0.2.2}/examples/saas-starter/tests/test_round_trip.py +0 -0
  53. {alterdb-0.2.0 → alterdb-0.2.2}/src/alter/__init__.py +0 -0
  54. {alterdb-0.2.0 → alterdb-0.2.2}/src/alter/canvas/__init__.py +0 -0
  55. {alterdb-0.2.0 → alterdb-0.2.2}/src/alter/canvas/static/canvas.js +0 -0
  56. {alterdb-0.2.0 → alterdb-0.2.2}/src/alter/canvas/static/index.html +0 -0
  57. {alterdb-0.2.0 → alterdb-0.2.2}/src/alter/canvas/static/style.css +0 -0
  58. {alterdb-0.2.0 → alterdb-0.2.2}/src/alter/data/demo_schema.alter +0 -0
  59. {alterdb-0.2.0 → alterdb-0.2.2}/src/alter/diff.py +0 -0
  60. {alterdb-0.2.0 → alterdb-0.2.2}/src/alter/diff_format.py +0 -0
  61. {alterdb-0.2.0 → alterdb-0.2.2}/src/alter/errors.py +0 -0
  62. {alterdb-0.2.0 → alterdb-0.2.2}/src/alter/exporters/__init__.py +0 -0
  63. {alterdb-0.2.0 → alterdb-0.2.2}/src/alter/exporters/alter_file.py +0 -0
  64. {alterdb-0.2.0 → alterdb-0.2.2}/src/alter/generators/__init__.py +0 -0
  65. {alterdb-0.2.0 → alterdb-0.2.2}/src/alter/generators/base.py +0 -0
  66. {alterdb-0.2.0 → alterdb-0.2.2}/src/alter/importers/__init__.py +0 -0
  67. {alterdb-0.2.0 → alterdb-0.2.2}/src/alter/importers/alter_file.py +0 -0
  68. {alterdb-0.2.0 → alterdb-0.2.2}/src/alter/layout.py +0 -0
  69. {alterdb-0.2.0 → alterdb-0.2.2}/src/alter/parsers/__init__.py +0 -0
  70. {alterdb-0.2.0 → alterdb-0.2.2}/src/alter/schema.py +0 -0
  71. {alterdb-0.2.0 → alterdb-0.2.2}/src/alter/templates/auth.alter +0 -0
  72. {alterdb-0.2.0 → alterdb-0.2.2}/src/alter/templates/cms.alter +0 -0
  73. {alterdb-0.2.0 → alterdb-0.2.2}/src/alter/templates/ecommerce.alter +0 -0
  74. {alterdb-0.2.0 → alterdb-0.2.2}/src/alter/templates/saas-base.alter +0 -0
  75. {alterdb-0.2.0 → alterdb-0.2.2}/src/alter/types.py +0 -0
  76. {alterdb-0.2.0 → alterdb-0.2.2}/src/alterdb/__init__.py +0 -0
  77. {alterdb-0.2.0 → alterdb-0.2.2}/templates/auth.alter +0 -0
  78. {alterdb-0.2.0 → alterdb-0.2.2}/templates/cms.alter +0 -0
  79. {alterdb-0.2.0 → alterdb-0.2.2}/templates/ecommerce.alter +0 -0
  80. {alterdb-0.2.0 → alterdb-0.2.2}/templates/saas-base.alter +0 -0
  81. {alterdb-0.2.0 → alterdb-0.2.2}/tests/__init__.py +0 -0
  82. {alterdb-0.2.0 → alterdb-0.2.2}/tests/conftest.py +0 -0
  83. {alterdb-0.2.0 → alterdb-0.2.2}/tests/fixtures/__init__.py +0 -0
  84. {alterdb-0.2.0 → alterdb-0.2.2}/tests/fixtures/sqlalchemy_models.py +0 -0
  85. {alterdb-0.2.0 → alterdb-0.2.2}/tests/test_alembic_wrapper.py +0 -0
  86. {alterdb-0.2.0 → alterdb-0.2.2}/tests/test_bug10_sa_column_type.py +0 -0
  87. {alterdb-0.2.0 → alterdb-0.2.2}/tests/test_bug17_apply_preserve.py +0 -0
  88. {alterdb-0.2.0 → alterdb-0.2.2}/tests/test_bug6_list_any_json.py +0 -0
  89. {alterdb-0.2.0 → alterdb-0.2.2}/tests/test_bug7_table_args.py +0 -0
  90. {alterdb-0.2.0 → alterdb-0.2.2}/tests/test_bug7_unreferenced_enums.py +0 -0
  91. {alterdb-0.2.0 → alterdb-0.2.2}/tests/test_bug8_surgical_preserve.py +0 -0
  92. {alterdb-0.2.0 → alterdb-0.2.2}/tests/test_bug_table_args_tuple.py +0 -0
  93. {alterdb-0.2.0 → alterdb-0.2.2}/tests/test_bugs_v013.py +0 -0
  94. {alterdb-0.2.0 → alterdb-0.2.2}/tests/test_cli.py +0 -0
  95. {alterdb-0.2.0 → alterdb-0.2.2}/tests/test_diff.py +0 -0
  96. {alterdb-0.2.0 → alterdb-0.2.2}/tests/test_e2e.py +0 -0
  97. {alterdb-0.2.0 → alterdb-0.2.2}/tests/test_enum_routing.py +0 -0
  98. {alterdb-0.2.0 → alterdb-0.2.2}/tests/test_layout.py +0 -0
  99. {alterdb-0.2.0 → alterdb-0.2.2}/tests/test_migration_sql.py +0 -0
  100. {alterdb-0.2.0 → alterdb-0.2.2}/tests/test_parser_sqlalchemy.py +0 -0
  101. {alterdb-0.2.0 → alterdb-0.2.2}/tests/test_parser_sqlmodel.py +0 -0
  102. {alterdb-0.2.0 → alterdb-0.2.2}/tests/test_round_trip.py +0 -0
  103. {alterdb-0.2.0 → alterdb-0.2.2}/tests/test_schema.py +0 -0
  104. {alterdb-0.2.0 → alterdb-0.2.2}/tests/test_smoke.py +0 -0
  105. {alterdb-0.2.0 → alterdb-0.2.2}/tests/test_types.py +0 -0
  106. {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.0
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 modifies the classes that changed. Your docstrings,
164
- `Relationship()` definitions, trailing inline comments, hand-written `Field()` kwarg order,
165
- and mutable defaults written as `default={}` or `default=[]` are all preserved verbatim.
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 modifies the classes that changed. Your docstrings,
134
- `Relationship()` definitions, trailing inline comments, hand-written `Field()` kwarg order,
135
- and mutable defaults written as `default={}` or `default=[]` are all preserved verbatim.
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`:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "alterdb"
3
- version = "0.2.0"
3
+ version = "0.2.2"
4
4
  description = "Visual schema design for SQLModel and SQLAlchemy. Edit your database as a diagram, write it back as code."
5
5
  license = "MIT"
6
6
  license-files = ["LICENSE"]
@@ -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 so cross-origin clients can access the API.
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
- The server already binds to 127.0.0.1 only, so ``*`` is safe here —
381
- it allows the canvas UI (which may be served from a different local
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 to scan."""
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
- dirs = _find_model_dirs(cwd)
262
- scan_dir = dirs[0] if dirs else cwd
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
- dirs = _find_model_dirs(cwd)
495
- scan_dir = dirs[0] if dirs else cwd
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)
@@ -94,6 +94,6 @@ def _col_attr(col: Column) -> str:
94
94
  attrs.append("PK")
95
95
  if col.foreign_key:
96
96
  attrs.append("FK")
97
- elif col.unique and not col.primary_key:
97
+ if col.unique and not col.primary_key:
98
98
  attrs.append("UK")
99
99
  return (" " + ",".join(attrs)) if attrs else ""
@@ -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
- return f"'{default}'"
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
- else:
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
- last_field_result_idx = len(result) - 1
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
- # Leave untouched destructive removal requires explicit
312
- # confirmation at the CLI/MCP layer, not inside the generator.
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 == "list":
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
- # Class exists in file but not in schema.
292
- # Leave it untouched destructive removal requires explicit
293
- # confirmation at the CLI/MCP layer, not inside the generator.
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="sqlmodel", tables=tables, relations=relations)
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+('(?:[^']|'')*'|\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(rest)
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"