dbly 0.1.0__tar.gz → 0.3.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 (40) hide show
  1. dbly-0.3.0/.github/FUNDING.yml +1 -0
  2. dbly-0.3.0/CHANGELOG.md +92 -0
  3. {dbly-0.1.0 → dbly-0.3.0}/PKG-INFO +9 -1
  4. {dbly-0.1.0 → dbly-0.3.0}/README.md +7 -0
  5. dbly-0.3.0/TODO.md +70 -0
  6. {dbly-0.1.0 → dbly-0.3.0}/pyproject.toml +2 -1
  7. {dbly-0.1.0 → dbly-0.3.0}/src/dbly/__init__.py +1 -1
  8. {dbly-0.1.0 → dbly-0.3.0}/src/dbly/adapters/base.py +24 -1
  9. {dbly-0.1.0 → dbly-0.3.0}/src/dbly/adapters/mssql.py +4 -0
  10. {dbly-0.1.0 → dbly-0.3.0}/src/dbly/adapters/oracle.py +22 -2
  11. {dbly-0.1.0 → dbly-0.3.0}/src/dbly/adapters/postgres.py +3 -0
  12. {dbly-0.1.0 → dbly-0.3.0}/src/dbly/adapters/sqlite.py +8 -0
  13. {dbly-0.1.0 → dbly-0.3.0}/src/dbly/cli.py +66 -16
  14. {dbly-0.1.0 → dbly-0.3.0}/src/dbly/model.py +15 -0
  15. {dbly-0.1.0 → dbly-0.3.0}/src/dbly/parsing.py +54 -0
  16. {dbly-0.1.0 → dbly-0.3.0}/src/dbly/planner.py +47 -0
  17. {dbly-0.1.0 → dbly-0.3.0}/src/dbly/repo.py +73 -10
  18. {dbly-0.1.0 → dbly-0.3.0}/src/dbly/report.py +71 -17
  19. dbly-0.3.0/tests/test_integration.py +370 -0
  20. {dbly-0.1.0 → dbly-0.3.0}/tests/test_parsing.py +36 -0
  21. {dbly-0.1.0 → dbly-0.3.0}/uv.lock +1 -1
  22. dbly-0.1.0/tests/test_integration.py +0 -177
  23. {dbly-0.1.0 → dbly-0.3.0}/.github/workflows/ci.yml +0 -0
  24. {dbly-0.1.0 → dbly-0.3.0}/.github/workflows/publish.yml +0 -0
  25. {dbly-0.1.0 → dbly-0.3.0}/.gitignore +0 -0
  26. {dbly-0.1.0 → dbly-0.3.0}/docs/dbly_head.png +0 -0
  27. {dbly-0.1.0 → dbly-0.3.0}/examples/ci/README.md +0 -0
  28. {dbly-0.1.0 → dbly-0.3.0}/examples/ci/bitbucket-pipelines.yml +0 -0
  29. {dbly-0.1.0 → dbly-0.3.0}/examples/ci/github-actions.yml +0 -0
  30. {dbly-0.1.0 → dbly-0.3.0}/src/dbly/adapters/__init__.py +0 -0
  31. {dbly-0.1.0 → dbly-0.3.0}/src/dbly/config.py +0 -0
  32. {dbly-0.1.0 → dbly-0.3.0}/src/dbly/drift.py +0 -0
  33. {dbly-0.1.0 → dbly-0.3.0}/src/dbly/engine.py +0 -0
  34. {dbly-0.1.0 → dbly-0.3.0}/src/dbly/hooks.py +0 -0
  35. {dbly-0.1.0 → dbly-0.3.0}/src/dbly/initializer.py +0 -0
  36. {dbly-0.1.0 → dbly-0.3.0}/src/dbly/py.typed +0 -0
  37. {dbly-0.1.0 → dbly-0.3.0}/tests/test_mssql.py +0 -0
  38. {dbly-0.1.0 → dbly-0.3.0}/tests/test_mssql_e2e.py +0 -0
  39. {dbly-0.1.0 → dbly-0.3.0}/tests/test_oracle.py +0 -0
  40. {dbly-0.1.0 → dbly-0.3.0}/tests/test_oracle_e2e.py +0 -0
@@ -0,0 +1 @@
1
+ ko_fi: angrydata
@@ -0,0 +1,92 @@
1
+ # Changelog
2
+
3
+ All notable changes to `dbly` are documented here.
4
+
5
+ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project
6
+ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.3.0] — 2026-07-21
11
+
12
+ ### Added
13
+
14
+ - **Plan against the working tree.** `dbly plan --worktree` (alias `--dirty`) and
15
+ `dbly check --worktree` diff the *working directory* — including uncommitted edits and
16
+ untracked new object files — instead of a git ref, for the fast edit→plan loop. Preview
17
+ only; `apply` still requires a real committed ref for the ledger.
18
+ - **Git-style ref decoration.** `dbly status` and the plan header now show the tag/branch
19
+ names pointing at a SHA next to it — e.g. `deployed ref: v0.1, main (a712b631)`. The
20
+ ledger still stores only the SHA (for stable diffs); the names are resolved at display time.
21
+
22
+ ### Fixed
23
+
24
+ - **Column type changes are now detected.** A changed column type (e.g. Oracle `NUMBER` →
25
+ `NUMBER(10)`) previously produced "nothing to do" — the diff matched columns by name only.
26
+ It now emits an `ALTER TABLE … MODIFY`/`ALTER COLUMN` step (flagged destructive, never
27
+ auto-applied) with a data-compatibility warning. Comparison is precision/scale-aware and
28
+ normalizes introspection noise (e.g. SQL Server `COLLATE`) to avoid false positives.
29
+ - **`check` no longer reports live Oracle objects as "missing".** The Oracle adapter now
30
+ resolves its `default_schema` to the connected user and carries the owner on introspected
31
+ objects, so drift keys align with the desired side (previously the owner was dropped on the
32
+ live side, so every object looked missing even right after a successful `apply`).
33
+
34
+ ### Changed
35
+
36
+ - The plan output now surfaces each step's **note inline** (e.g. *"NOT NULL without default
37
+ on existing table — unsafe"*), so *why* a step is flagged is visible without cross-checking
38
+ the warnings block.
39
+
40
+ ## [0.2.0] — 2026-06-28
41
+
42
+ ### Added
43
+
44
+ - **Explicit, run-once migrations.** Drop ordered SQL scripts in `migrations/` (`0001_…sql`)
45
+ for changes the additive diff cannot do safely — renaming a column, type changes with data
46
+ transformation, backfills. Each is tracked by id in the `dbly_state` ledger and runs
47
+ exactly once.
48
+ - On **upgrade**, pending migrations run *before* the object reconciliation, so they reshape
49
+ the schema first. On a **fresh database** they are *baselined* (recorded, not run) since the
50
+ canonical object files already describe the end state.
51
+
52
+ ### Changed
53
+
54
+ - A table touched by a pending migration **defers its additive diff** for that deploy, so an
55
+ explicit rename no longer collides with an auto-generated `ADD COLUMN`.
56
+ - `migrations/` files are excluded from object and drift discovery (they are imperative
57
+ scripts, not declarative objects).
58
+
59
+ ## [0.1.0] — 2026-06-27
60
+
61
+ ### Added
62
+
63
+ - **Indexes and sequences as first-class managed objects.** Correct identity (index name +
64
+ indexed-table schema), dependency-safe ordering (sequences → tables → indexes), and
65
+ create-if-missing handling (no more erroneous re-apply of non-idempotent
66
+ `CREATE INDEX`/`CREATE SEQUENCE`).
67
+ - **Live inventory introspection** across tables, views, functions, procedures, triggers,
68
+ indexes and sequences for all four engines, with a canonical source hash for procedural
69
+ objects.
70
+ - **Real drift detection in `dbly check`** — reports missing (in repo, not in DB), orphaned
71
+ (`--orphans`), table column drift, and advisory definition drift; schema-normalized so an
72
+ unqualified repo object matches the engine's implicit schema.
73
+
74
+ ## [0.0.1] — 2026-06-27
75
+
76
+ ### Added
77
+
78
+ - Initial release. **State-based, cross-engine database deployment** (PostgreSQL, SQL Server,
79
+ Oracle, SQLite), git-driven and parser-assisted (sqlglot).
80
+ - `plan` / `apply` workflow with additive table diffs generated from
81
+ `CREATE TABLE IF NOT EXISTS` (destructive changes flagged, never auto-applied).
82
+ - `plan --sql` — exports a self-contained SQL script for a hand/offline deploy.
83
+ - `dbly init` — privileged greenfield groundwork (`CREATE DATABASE`/roles/extensions).
84
+ - Pre-/post-deploy hooks accepting `.sql` and `.py` (configurable interpreter, e.g. ArcPy).
85
+ - Connection profiles (DBFit-compatible `connection.properties`) with `${ENV}` placeholders
86
+ for CI/CD.
87
+ - CI and PyPI publish workflows (trusted publishing).
88
+
89
+ [Unreleased]: https://github.com/angrydat/dbly/compare/v0.2.0...HEAD
90
+ [0.2.0]: https://github.com/angrydat/dbly/compare/v0.1.0...v0.2.0
91
+ [0.1.0]: https://github.com/angrydat/dbly/compare/v0.0.1...v0.1.0
92
+ [0.0.1]: https://github.com/angrydat/dbly/releases/tag/v0.0.1
@@ -1,10 +1,11 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dbly
3
- Version: 0.1.0
3
+ Version: 0.3.0
4
4
  Summary: State-based, cross-engine database deployment — git-driven, parser-assisted, SQL-first.
5
5
  Project-URL: Homepage, https://angrydata.info/dbly
6
6
  Project-URL: Repository, https://github.com/angrydat/dbly
7
7
  Project-URL: Issues, https://github.com/angrydat/dbly/issues
8
+ Project-URL: Changelog, https://github.com/angrydat/dbly/blob/main/CHANGELOG.md
8
9
  Author-email: Jürgen Zornig <info@angrydata.info>
9
10
  License: MIT
10
11
  Keywords: ci-cd,database,deployment,git,migration,oracle,postgres,schema,sql,sqlite,sqlserver,state-based
@@ -81,10 +82,17 @@ db/
81
82
  v_open_orders.vw
82
83
  grants.sql
83
84
  init/ # optional: privileged greenfield groundwork (CREATE DATABASE/ROLE…)
85
+ migrations/ # optional: ordered, run-once ALTERs for renames / data backfills
84
86
  hooks/pre/ hooks/post/ # optional: .sql or .py hooks (e.g. ArcGIS/ArcPy steps)
85
87
  .dbignore # files in the repo that should not be deployed
86
88
  ```
87
89
 
90
+ Most changes are declarative — edit the object file, dbly figures out the additive diff. For
91
+ changes the diff can't do safely (renaming a column, moving data), drop an ordered script in
92
+ `migrations/` (`0001_…sql`); it runs exactly once, is recorded in the ledger, and the table
93
+ it touches defers to it for that deploy. On a fresh database, migrations are *baselined*
94
+ (recorded, not run) since the object files already describe the end state.
95
+
88
96
  ## Connect
89
97
 
90
98
  A connection profile (reuses the familiar `connection.properties` format):
@@ -43,10 +43,17 @@ db/
43
43
  v_open_orders.vw
44
44
  grants.sql
45
45
  init/ # optional: privileged greenfield groundwork (CREATE DATABASE/ROLE…)
46
+ migrations/ # optional: ordered, run-once ALTERs for renames / data backfills
46
47
  hooks/pre/ hooks/post/ # optional: .sql or .py hooks (e.g. ArcGIS/ArcPy steps)
47
48
  .dbignore # files in the repo that should not be deployed
48
49
  ```
49
50
 
51
+ Most changes are declarative — edit the object file, dbly figures out the additive diff. For
52
+ changes the diff can't do safely (renaming a column, moving data), drop an ordered script in
53
+ `migrations/` (`0001_…sql`); it runs exactly once, is recorded in the ledger, and the table
54
+ it touches defers to it for that deploy. On a fresh database, migrations are *baselined*
55
+ (recorded, not run) since the object files already describe the end state.
56
+
50
57
  ## Connect
51
58
 
52
59
  A connection profile (reuses the familiar `connection.properties` format):
dbly-0.3.0/TODO.md ADDED
@@ -0,0 +1,70 @@
1
+ # dbly — TODO / Ideen-Backlog
2
+
3
+ Kurzliste möglicher Verbesserungen, entstanden aus dem Vergleich mit **Atlas**
4
+ (atlasgo.io). Das sind Funktionen, die Atlas (teils nur in der kommerziellen
5
+ Pro-Edition) bietet und die dbly aktuell **nicht** hat — als Anregung, nicht als
6
+ Roadmap. dbly-Stärken (frei, MIT, Oracle/MSSQL nativ, `.py`-Hooks, `plan --sql`)
7
+ bleiben der Ausgangspunkt.
8
+
9
+ ## Migrations- & Sicherheits-Analyse
10
+ - **Migration-Linting / Analyzer**: destruktive/riskante Schritte erkennen und
11
+ warnen bzw. blocken (Atlas `migrate lint`). dbly kennt bisher nur additiv vs.
12
+ destruktiv + `--allow-destructive`.
13
+ - **Custom Schema Rules / Policies** (z. B. „jede Tabelle braucht PK", Namens-
14
+ konventionen, Boolean = NUMBER(1) CHECK (0,-1) erzwingen).
15
+ - **Pre-Migration-Checks** (Vorbedingungen prüfen, z. B. Spalte leer vor NOT NULL).
16
+ - **PII-/Security-as-Code**: Spalten als sensibel markieren; RLS deklarativ.
17
+
18
+ ## Sichtbarkeit
19
+ - **ERD / Schema-Visualisierung** + Auto-Doku aus dem Objektmodell.
20
+ - **Schema-Registry** / zentrale „source of truth" mit Versionsvergleich über
21
+ Umgebungen hinweg.
22
+ - **Kontinuierliches Drift-Monitoring**: dbly hat `check` on-demand; es fehlt ein
23
+ laufendes/alarmierendes Monitoring.
24
+ - ~~**Ref-Dekoration in `status` / Plan-Header** (Komfort)~~ ✅ **erledigt (0.3.0)**:
25
+ `status` und der Plan-Header zeigen jetzt Tag-/Branch-Namen neben dem SHA
26
+ (`deployed ref: v0.1, main (9ff5e440)`), aufgelöst via `git tag/branch --points-at`.
27
+ Der Ledger speichert weiterhin nur den SHA.
28
+
29
+ ## Autoren-Erlebnis
30
+ - Zusätzliche **Schema-Sprache (HCL o. ä.)** und/oder **ORM-Loader** — DB-agnostische
31
+ Definition neben plain SQL.
32
+ - **Testing-Framework** für Schema/Objekte (Atlas `atlas test`).
33
+ - **Interaktive Migrations / Checkpoints** für lange Migrationsketten.
34
+ - ~~**Plan gegen den Working-Tree** (uncommittete Änderungen)~~ ✅ **erledigt (0.3.0)**:
35
+ `dbly plan --worktree` (Alias `--dirty`) und `dbly check --worktree` planen/prüfen
36
+ den Arbeitsstand inkl. ungetrackter neuer Objektdateien. Preview-only — `apply`
37
+ braucht weiterhin einen committeten Ref für den Ledger.
38
+
39
+ ## Integration
40
+ - Breitere fertige **CI-Rezepte** (GitLab, Azure DevOps, CircleCI) — aktuell nur
41
+ GH-Actions/Bitbucket-Beispiele.
42
+ - **Terraform-Provider / K8s-Operator** (GitOps).
43
+ - **Rollout-/Approval-Flow** (Freigabe vor Apply in Prod).
44
+
45
+ ## Bekannte Design-Frage (aus dem MA31-PoC)
46
+ - Im Planner werden **Tabellen in Changeset-Reihenfolge** emittiert;
47
+ `topological_order` wird nur auf *replaceable* (Views/Functions) angewandt. Bei
48
+ Inline-FKs zwischen Tabellen ist auf einem frischen Ziel die Anlege-Reihenfolge
49
+ dann nicht FK-sicher — prüfen, ob Tabellen ebenfalls topologisch sortiert werden
50
+ sollten (oder ob der Adapter das per retry-until-stable abfängt).
51
+
52
+ ## Bugs (aus MA31-PoC, Oracle)
53
+
54
+ - ~~**Spalten-Typänderung wird nicht erkannt.**~~ ✅ **erledigt (0.3.0)**: Der
55
+ Spalten-Diff verglich nur den Namen; `NUMBER` → `NUMBER(10)` blieb unbemerkt. Jetzt
56
+ wird bei geändertem Typ ein `ALTER TABLE … MODIFY`/`ALTER COLUMN`-Schritt erzeugt
57
+ (als `destructive` markiert, nie auto-appliziert, mit Datenkompatibilitäts-Warnung).
58
+ Der Vergleich ist Precision/Scale-bewusst (`NUMBER(10)` ≡ `NUMBER(10,0)`) und
59
+ normalisiert Introspektions-Rauschen (z. B. SQL-Server `COLLATE`) gegen False Positives.
60
+ - ~~**ADD COLUMN fälschlich als „destructive" klassifiziert.**~~ ✅ **geklärt (0.3.0)**:
61
+ `TESTFELD` war `NOT NULL` **ohne** Default — die `destructive`-Einstufung ist damit
62
+ korrekt (kann auf einer befüllten Tabelle nicht sicher hinzugefügt werden). Verbessert:
63
+ der Grund (*„NOT NULL without default … — unsafe"*) wird jetzt als Hinweis direkt am
64
+ Plan-Schritt angezeigt, nicht nur im Warnungsblock.
65
+ - ~~**`check` meldet vorhandene Objekte als „missing".**~~ ✅ **erledigt (0.3.0)**:
66
+ Ursache war eine asymmetrische Key-Normalisierung — der Oracle-Adapter setzte kein
67
+ `default_schema` und ließ den Owner in `inventory()` weg, sodass die Live-Keys
68
+ (`table:<name>`) nicht zu den Desired-Keys (`table:dbb.<name>`) passten. Der Adapter
69
+ löst `default_schema` jetzt zum verbundenen `USER` auf und führt den Owner mit; beide
70
+ Seiten normalisieren identisch.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "dbly"
3
- version = "0.1.0"
3
+ version = "0.3.0"
4
4
  description = "State-based, cross-engine database deployment — git-driven, parser-assisted, SQL-first."
5
5
  readme = "README.md"
6
6
  license = { text = "MIT" }
@@ -54,6 +54,7 @@ all = ["oracledb>=2.0", "pymssql>=2.3"]
54
54
  Homepage = "https://angrydata.info/dbly"
55
55
  Repository = "https://github.com/angrydat/dbly"
56
56
  Issues = "https://github.com/angrydat/dbly/issues"
57
+ Changelog = "https://github.com/angrydat/dbly/blob/main/CHANGELOG.md"
57
58
 
58
59
  [project.scripts]
59
60
  dbly = "dbly.cli:main"
@@ -4,4 +4,4 @@ git (what changed) + sqlglot (what it is) + live introspection (what's really th
4
4
  See CONCEPT.md for the design rationale.
5
5
  """
6
6
 
7
- __version__ = "0.1.0"
7
+ __version__ = "0.3.0"
@@ -13,7 +13,7 @@ from __future__ import annotations
13
13
 
14
14
  import abc
15
15
 
16
- from sqlalchemy import Engine
16
+ from sqlalchemy import Engine, text
17
17
 
18
18
  from dbly.config import ConnectionConfig
19
19
  from dbly.engine import make_engine
@@ -71,6 +71,15 @@ class Adapter(abc.ABC):
71
71
  dialect-agnostic and delegates the rendering here.
72
72
  """
73
73
 
74
+ @abc.abstractmethod
75
+ def modify_column_sql(self, table: ObjectId, col: Column) -> str:
76
+ """Generate the ``ALTER TABLE … <MODIFY|ALTER COLUMN>`` that changes a column's type.
77
+
78
+ Oracle uses ``MODIFY``; Postgres uses ``ALTER COLUMN … TYPE``; T-SQL uses
79
+ ``ALTER COLUMN``. SQLite cannot change a column's type in place and returns a
80
+ commented no-op (a type change there needs a manual table rebuild).
81
+ """
82
+
74
83
  # --- execution ---------------------------------------------------------------------
75
84
  @abc.abstractmethod
76
85
  def apply(self, statements: list[str]) -> None:
@@ -95,6 +104,20 @@ class Adapter(abc.ABC):
95
104
  @abc.abstractmethod
96
105
  def record_deploy(self, ref: str, migration_ids: list[str]) -> None: ...
97
106
 
107
+ # --- explicit migrations (run-once, ledger-tracked) --------------------------------
108
+ def applied_migrations(self) -> set[str]:
109
+ """Migration ids already recorded in the ledger (standard SQL, all engines)."""
110
+ self.ensure_state_table()
111
+ with self.engine.connect() as conn:
112
+ rows = conn.execute(
113
+ text("SELECT DISTINCT migration_id FROM dbly_state WHERE migration_id IS NOT NULL")
114
+ )
115
+ return {r[0] for r in rows}
116
+
117
+ def record_migration(self, ref: str, migration_id: str) -> None:
118
+ """Mark a migration applied (reuses the per-engine record_deploy insert)."""
119
+ self.record_deploy(ref, [migration_id])
120
+
98
121
  # --- pure SQL builders (no connection — used by `plan --sql` export) ----------------
99
122
  @abc.abstractmethod
100
123
  def state_table_ddl(self) -> str:
@@ -125,6 +125,10 @@ class MssqlAdapter(Adapter):
125
125
  parts.append(f"DEFAULT {col.default}")
126
126
  return " ".join(parts) + ";"
127
127
 
128
+ def modify_column_sql(self, table: ObjectId, col: Column) -> str:
129
+ # T-SQL ALTER COLUMN preserves nullability unless restated; type only here.
130
+ return f"ALTER TABLE {table} ALTER COLUMN {col.name} {col.type};"
131
+
128
132
  def apply(self, statements: list[str]) -> None:
129
133
  with self.engine.begin() as conn:
130
134
  for stmt in statements:
@@ -89,6 +89,22 @@ class OracleAdapter(Adapter):
89
89
  # unquoted Oracle identifiers are upper-cased on storage
90
90
  return ident.upper() if ident else ident
91
91
 
92
+ @property
93
+ def default_schema(self) -> str | None: # type: ignore[override]
94
+ """The connected user — Oracle's implicit schema for unqualified objects.
95
+
96
+ Resolved from the live session (``SELECT USER``) and cached. Drift matching uses this
97
+ so a repo object under folder ``dbb`` aligns with the live ``DBB``-owned object;
98
+ without it every object read back by ``inventory()`` looked "missing" (owner dropped
99
+ on one side of the key). Same source as ``inventory()``'s ``owner = USER`` filter.
100
+ """
101
+ cached = getattr(self, "_ds_cache", None)
102
+ if cached is None:
103
+ with self.engine.connect() as conn:
104
+ cached = conn.exec_driver_sql("SELECT USER FROM dual").scalar()
105
+ self._ds_cache = cached
106
+ return cached
107
+
92
108
  def table_exists(self, schema: str | None, name: str) -> bool:
93
109
  return inspect(self.engine).has_table(self._norm(name), schema=self._norm(schema))
94
110
 
@@ -139,6 +155,7 @@ class OracleAdapter(Adapter):
139
155
  "('FUNCTION','PROCEDURE','TRIGGER','PACKAGE','PACKAGE BODY','TYPE') "
140
156
  "ORDER BY name, type, line"
141
157
  )
158
+ owner = self.default_schema # objects are filtered by owner = USER — carry it on the id
142
159
  found: dict[str, LiveObject] = {}
143
160
  src_text: dict[str, list[str]] = {}
144
161
  with self.engine.connect() as conn:
@@ -146,13 +163,13 @@ class OracleAdapter(Adapter):
146
163
  kind = self._TYPEMAP.get(otype)
147
164
  if kind is None:
148
165
  continue
149
- obj = LiveObject(kind, ObjectId(None, name))
166
+ obj = LiveObject(kind, ObjectId(owner, name))
150
167
  found[obj.key()] = obj
151
168
  for name, otype, line in conn.execute(sources):
152
169
  kind = self._TYPEMAP.get(otype)
153
170
  if kind is None:
154
171
  continue
155
- key = LiveObject(kind, ObjectId(None, name)).key()
172
+ key = LiveObject(kind, ObjectId(owner, name)).key()
156
173
  src_text.setdefault(key, []).append(line or "")
157
174
  for key, lines in src_text.items():
158
175
  if key in found:
@@ -167,6 +184,9 @@ class OracleAdapter(Adapter):
167
184
  parts.append("NOT NULL")
168
185
  return " ".join(parts) + ";"
169
186
 
187
+ def modify_column_sql(self, table: ObjectId, col: Column) -> str:
188
+ return f"ALTER TABLE {table} MODIFY {col.name} {col.type};"
189
+
170
190
  def apply(self, statements: list[str]) -> None:
171
191
  # No transactional rollback (DDL auto-commits). Sequential; commit covers any DML.
172
192
  with self.engine.connect() as conn:
@@ -111,6 +111,9 @@ class PostgresAdapter(Adapter):
111
111
  parts.append(f"DEFAULT {col.default}")
112
112
  return " ".join(parts) + ";"
113
113
 
114
+ def modify_column_sql(self, table: ObjectId, col: Column) -> str:
115
+ return f"ALTER TABLE {table} ALTER COLUMN {col.name} TYPE {col.type};"
116
+
114
117
  def apply(self, statements: list[str]) -> None:
115
118
  # transactional DDL → one atomic transaction
116
119
  with self.engine.begin() as conn:
@@ -80,6 +80,14 @@ class SqliteAdapter(Adapter):
80
80
  parts.append(f"DEFAULT {col.default}")
81
81
  return " ".join(parts) + ";"
82
82
 
83
+ def modify_column_sql(self, table: ObjectId, col: Column) -> str:
84
+ # SQLite cannot change a column's type in place — a rebuild is required. Emit a
85
+ # commented no-op so the (destructive, never auto-applied) step documents the limit.
86
+ return (
87
+ f"-- SQLite cannot ALTER COLUMN {table.name}.{col.name} to {col.type} "
88
+ "in place; rebuild the table manually."
89
+ )
90
+
83
91
  def apply(self, statements: list[str]) -> None:
84
92
  with self.engine.begin() as conn:
85
93
  for stmt in statements:
@@ -21,7 +21,7 @@ from dbly.engine import detect_dialect
21
21
  from dbly.parsing import sqlglot_dialect
22
22
  from dbly.planner import build_plan
23
23
  from dbly.model import Plan, Severity
24
- from dbly.repo import Repo
24
+ from dbly.repo import WORKTREE, Repo
25
25
 
26
26
  app = typer.Typer(
27
27
  name="dbly",
@@ -48,13 +48,32 @@ def _main(
48
48
  pass
49
49
 
50
50
 
51
- def _make_plan(repo_path: Path, target: str, from_ref: Optional[str], to_ref: str) -> Plan:
51
+ def _decorations(repo_path: Path, plan: Plan) -> dict[str, str]:
52
+ """Map each real ref in the plan to its git tag/branch names (for the plan header)."""
53
+ try:
54
+ repo = Repo(repo_path)
55
+ except ValueError:
56
+ return {}
57
+ out: dict[str, str] = {}
58
+ for ref in {plan.from_ref, plan.to_ref}:
59
+ if not ref or ref == WORKTREE:
60
+ continue
61
+ names = repo.ref_names(ref)
62
+ if names:
63
+ out[ref] = ", ".join(names)
64
+ return out
65
+
66
+
67
+ def _make_plan(
68
+ repo_path: Path, target: str, from_ref: Optional[str], to_ref: str,
69
+ *, worktree: bool = False,
70
+ ) -> Plan:
52
71
  repo = Repo(repo_path)
53
72
  cfg = resolve_target(target)
54
73
  dialect = sqlglot_dialect(detect_dialect(cfg))
55
74
  adapter = get_adapter(cfg)
56
75
  try:
57
- resolved_to = repo.resolve_ref(to_ref)
76
+ resolved_to = repo.resolve_ref(WORKTREE if worktree else to_ref)
58
77
  if from_ref is not None:
59
78
  resolved_from = repo.resolve_ref(from_ref)
60
79
  else:
@@ -80,10 +99,15 @@ def plan(
80
99
  sql: Optional[Path] = typer.Option(
81
100
  None, "--sql", help="write an executable SQL script for a hand/offline deploy."
82
101
  ),
102
+ worktree: bool = typer.Option(
103
+ False, "--worktree", "--dirty",
104
+ help="plan against the working tree (uncommitted + untracked object files), "
105
+ "not a git ref — for the fast edit→plan loop.",
106
+ ),
83
107
  ) -> None:
84
108
  """Compute and show the deployment plan."""
85
- plan_obj = _make_plan(repo_path, target, from_ref, to)
86
- report.render_plan(plan_obj, console)
109
+ plan_obj = _make_plan(repo_path, target, from_ref, to, worktree=worktree)
110
+ report.render_plan(plan_obj, console, ref_names=_decorations(repo_path, plan_obj))
87
111
  if out:
88
112
  out.write_text(report.plan_to_yaml(plan_obj), encoding="utf-8")
89
113
  console.print(f"\n[dim]plan (YAML) written to {out}[/dim]")
@@ -123,7 +147,7 @@ def apply(
123
147
  else:
124
148
  plan_obj = _make_plan(repo_path, target, from_ref, to)
125
149
 
126
- report.render_plan(plan_obj, console)
150
+ report.render_plan(plan_obj, console, ref_names=_decorations(repo_path, plan_obj))
127
151
 
128
152
  destructive = [s for s in plan_obj.steps if s.severity is Severity.DESTRUCTIVE]
129
153
  if destructive and not allow_destructive:
@@ -137,7 +161,7 @@ def apply(
137
161
  s.sql for s in plan_obj.steps
138
162
  if allow_destructive or s.severity is not Severity.DESTRUCTIVE
139
163
  ]
140
- if not statements:
164
+ if not statements and not plan_obj.migrations and not plan_obj.baselined:
141
165
  console.print("[green]nothing to apply.[/green]")
142
166
  return
143
167
 
@@ -147,13 +171,24 @@ def apply(
147
171
  try:
148
172
  _run_hooks(repo, "pre", py_interpreter)
149
173
  adapter.ensure_state_table()
150
- adapter.apply(statements)
174
+ # baseline (bootstrap): record migrations as applied without running them
175
+ for mid in plan_obj.baselined:
176
+ adapter.record_migration(plan_obj.to_ref, mid)
177
+ # explicit migrations run first — they reshape the schema before reconciliation
178
+ for m in plan_obj.migrations:
179
+ adapter.run_init_script(m.sql) # engine-aware multi-statement / PL-SQL runner
180
+ adapter.record_migration(plan_obj.to_ref, m.id)
181
+ console.print(f"[magenta]migration[/magenta] applied {m.id}")
182
+ if statements:
183
+ adapter.apply(statements)
151
184
  adapter.record_deploy(plan_obj.to_ref, migration_ids=[])
152
185
  _run_hooks(repo, "post", py_interpreter)
153
186
  finally:
154
187
  adapter.dispose()
155
- console.print(f"[green]applied[/green] {len(statements)} step(s); "
156
- f"deployed ref {plan_obj.to_ref}")
188
+ console.print(
189
+ f"[green]applied[/green] {len(statements)} step(s), "
190
+ f"{len(plan_obj.migrations)} migration(s); deployed ref → {plan_obj.to_ref}"
191
+ )
157
192
 
158
193
 
159
194
  @app.command()
@@ -192,12 +227,15 @@ def bootstrap(
192
227
  ) -> None:
193
228
  """Install into an empty database (no baseline — full apply)."""
194
229
  plan_obj = _make_plan(repo_path, target, None, to)
195
- report.render_plan(plan_obj, console)
230
+ report.render_plan(plan_obj, console, ref_names=_decorations(repo_path, plan_obj))
196
231
  console.print("\n[dim]review, then run `dbly apply` to execute.[/dim]")
197
232
 
198
233
 
199
234
  @app.command()
200
- def status(target: str = typer.Option(..., "--target")) -> None:
235
+ def status(
236
+ target: str = typer.Option(..., "--target"),
237
+ repo_path: Path = typer.Option(Path("."), "--repo", help="repository root (for ref names)."),
238
+ ) -> None:
201
239
  """Show the deployed ref recorded on the target."""
202
240
  cfg = resolve_target(target)
203
241
  adapter = get_adapter(cfg)
@@ -205,10 +243,17 @@ def status(target: str = typer.Option(..., "--target")) -> None:
205
243
  ref = adapter.get_deployed_ref()
206
244
  finally:
207
245
  adapter.dispose()
208
- if ref:
209
- console.print(f"deployed ref: [cyan]{ref}[/cyan]")
210
- else:
246
+ if not ref:
211
247
  console.print("[yellow]no deploy recorded — database is unmanaged or empty.[/yellow]")
248
+ return
249
+ try:
250
+ names = Repo(repo_path).ref_names(ref)
251
+ except ValueError:
252
+ names = [] # not a git repo — SHA only
253
+ if names:
254
+ console.print(f"deployed ref: [cyan]{', '.join(names)}[/cyan] [dim]({ref[:8]})[/dim]")
255
+ else:
256
+ console.print(f"deployed ref: [cyan]{ref}[/cyan]")
212
257
 
213
258
 
214
259
  @app.command()
@@ -219,6 +264,10 @@ def check(
219
264
  orphans: bool = typer.Option(
220
265
  False, "--orphans", help="also report objects in the DB but not in the repo."
221
266
  ),
267
+ worktree: bool = typer.Option(
268
+ False, "--worktree", "--dirty",
269
+ help="compare the working tree (uncommitted + untracked) against the DB, not a git ref.",
270
+ ),
222
271
  ) -> None:
223
272
  """Detect drift: compare the desired state at <to> against the live database."""
224
273
  repo = Repo(repo_path)
@@ -228,7 +277,8 @@ def check(
228
277
  try:
229
278
  rep = drift.compute_drift(
230
279
  repo, adapter,
231
- to_ref=repo.resolve_ref(to), dialect=dialect, include_orphans=orphans,
280
+ to_ref=repo.resolve_ref(WORKTREE if worktree else to),
281
+ dialect=dialect, include_orphans=orphans,
232
282
  )
233
283
  finally:
234
284
  adapter.dispose()
@@ -128,6 +128,19 @@ class Step:
128
128
  note: str | None = None
129
129
 
130
130
 
131
+ @dataclass(slots=True)
132
+ class Migration:
133
+ """An explicit, run-once, hand-written migration script (CONCEPT.md §5).
134
+
135
+ For changes the additive diff cannot do safely — renames, type changes with data
136
+ transformation, backfills. Tracked by ``id`` in the ledger so it runs exactly once.
137
+ """
138
+
139
+ id: str
140
+ sql: str
141
+ source_file: Path
142
+
143
+
131
144
  @dataclass(slots=True)
132
145
  class Plan:
133
146
  """An ordered, reviewable set of steps — the artifact of ``dbly plan``."""
@@ -135,6 +148,8 @@ class Plan:
135
148
  target: str
136
149
  from_ref: str | None
137
150
  to_ref: str
151
+ migrations: list[Migration] = field(default_factory=list) # pending — run before steps
152
+ baselined: list[str] = field(default_factory=list) # recorded, not run (bootstrap)
138
153
  steps: list[Step] = field(default_factory=list)
139
154
  warnings: list[str] = field(default_factory=list)
140
155
 
@@ -11,6 +11,7 @@ applied verbatim per dialect by the adapter.
11
11
  from __future__ import annotations
12
12
 
13
13
  import hashlib
14
+ import re
14
15
  from pathlib import Path
15
16
 
16
17
  import sqlglot
@@ -121,6 +122,24 @@ def parse_file(
121
122
  return objects
122
123
 
123
124
 
125
+ def referenced_tables(sql: str, *, dialect: str | None = None) -> set[str]:
126
+ """Lower-cased table names referenced anywhere in a SQL script (best effort).
127
+
128
+ Used to detect which tables a pending migration touches, so the additive diff defers to
129
+ the migration for those tables. Returns an empty set if sqlglot cannot parse the script.
130
+ """
131
+ names: set[str] = set()
132
+ try:
133
+ for stmt in sqlglot.parse(sql, read=dialect):
134
+ if stmt is None:
135
+ continue
136
+ for tbl in stmt.find_all(exp.Table):
137
+ names.add(tbl.name.lower())
138
+ except Exception: # noqa: BLE001 — procedural/odd SQL; suppression is best-effort
139
+ pass
140
+ return names
141
+
142
+
124
143
  def canonical_hash(sql: str | None, *, dialect: str | None = None) -> str | None:
125
144
  """A formatting-insensitive hash of a definition, for advisory drift detection.
126
145
 
@@ -175,6 +194,41 @@ def desired_columns(sql: str, *, dialect: str | None = None) -> list[Column]:
175
194
  return columns
176
195
 
177
196
 
197
+ _NUMERIC_TYPES = {"NUMBER", "NUMERIC", "DECIMAL", "DEC"}
198
+
199
+
200
+ def canonical_type(type_str: str) -> str:
201
+ """Normalize a column type for cross-boundary comparison (desired vs. introspected).
202
+
203
+ The desired side is rendered by sqlglot (e.g. ``NUMBER(10)``); the actual side comes from
204
+ SQLAlchemy introspection (e.g. ``NUMBER(10, 0)``). Whitespace/case and an *omitted numeric
205
+ scale* are normalized so semantically-equal types compare equal — while a genuine
206
+ precision/scale change (``NUMBER`` → ``NUMBER(10)``) is preserved.
207
+
208
+ Honest scope: this is a string-level normalizer, not a type system. Exotic spellings that
209
+ differ across the sqlglot↔SQLAlchemy boundary may still compare unequal; a resulting
210
+ MODIFY step is flagged destructive (never auto-applied) and carries a warning, so a false
211
+ positive surfaces as a reviewable note rather than silent data loss.
212
+ """
213
+ s = " ".join(type_str.strip().upper().split())
214
+ # Drop introspection decorations that carry no shape info (e.g. SQL Server appends
215
+ # `COLLATE "SQL_Latin1_General_CP1_CI_AS"` to reflected string types).
216
+ s = re.split(r"\s+COLLATE\s+", s, maxsplit=1)[0].strip()
217
+ m = re.match(r"^([A-Z0-9_ ]+?)\s*\(([^)]*)\)\s*$", s)
218
+ if not m:
219
+ return s.replace(" ", "")
220
+ base = m.group(1).strip().replace(" ", "")
221
+ params = [p.strip() for p in m.group(2).split(",") if p.strip()]
222
+ if base in _NUMERIC_TYPES and len(params) == 1:
223
+ params.append("0") # NUMBER(p) ≡ NUMBER(p,0) — scale defaults to 0
224
+ return f"{base}({','.join(params)})"
225
+
226
+
227
+ def types_differ(desired: str, actual: str) -> bool:
228
+ """Whether a declared (desired) column type differs from the live (actual) one."""
229
+ return canonical_type(desired) != canonical_type(actual)
230
+
231
+
178
232
  def topological_order(objects: list[ParsedObject]) -> list[ParsedObject]:
179
233
  """Order replaceable objects so dependencies come first (CONCEPT.md §8).
180
234