dbly 0.2.0__tar.gz → 0.4.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 (41) hide show
  1. dbly-0.4.0/.github/FUNDING.yml +1 -0
  2. dbly-0.4.0/CHANGELOG.md +120 -0
  3. {dbly-0.2.0 → dbly-0.4.0}/PKG-INFO +23 -1
  4. {dbly-0.2.0 → dbly-0.4.0}/README.md +21 -0
  5. dbly-0.4.0/TODO.md +70 -0
  6. {dbly-0.2.0 → dbly-0.4.0}/pyproject.toml +2 -1
  7. {dbly-0.2.0 → dbly-0.4.0}/src/dbly/__init__.py +1 -1
  8. {dbly-0.2.0 → dbly-0.4.0}/src/dbly/adapters/base.py +9 -0
  9. {dbly-0.2.0 → dbly-0.4.0}/src/dbly/adapters/mssql.py +4 -0
  10. {dbly-0.2.0 → dbly-0.4.0}/src/dbly/adapters/oracle.py +22 -2
  11. {dbly-0.2.0 → dbly-0.4.0}/src/dbly/adapters/postgres.py +42 -3
  12. {dbly-0.2.0 → dbly-0.4.0}/src/dbly/adapters/sqlite.py +8 -0
  13. {dbly-0.2.0 → dbly-0.4.0}/src/dbly/cli.py +89 -24
  14. {dbly-0.2.0 → dbly-0.4.0}/src/dbly/drift.py +10 -1
  15. {dbly-0.2.0 → dbly-0.4.0}/src/dbly/parsing.py +36 -0
  16. {dbly-0.2.0 → dbly-0.4.0}/src/dbly/planner.py +22 -0
  17. dbly-0.4.0/src/dbly/project.py +54 -0
  18. {dbly-0.2.0 → dbly-0.4.0}/src/dbly/repo.py +90 -15
  19. {dbly-0.2.0 → dbly-0.4.0}/src/dbly/report.py +19 -3
  20. {dbly-0.2.0 → dbly-0.4.0}/tests/test_integration.py +147 -0
  21. {dbly-0.2.0 → dbly-0.4.0}/tests/test_parsing.py +36 -0
  22. dbly-0.4.0/tests/test_project.py +35 -0
  23. {dbly-0.2.0 → dbly-0.4.0}/.github/workflows/ci.yml +0 -0
  24. {dbly-0.2.0 → dbly-0.4.0}/.github/workflows/publish.yml +0 -0
  25. {dbly-0.2.0 → dbly-0.4.0}/.gitignore +0 -0
  26. {dbly-0.2.0 → dbly-0.4.0}/docs/dbly_head.png +0 -0
  27. {dbly-0.2.0 → dbly-0.4.0}/examples/ci/README.md +0 -0
  28. {dbly-0.2.0 → dbly-0.4.0}/examples/ci/bitbucket-pipelines.yml +0 -0
  29. {dbly-0.2.0 → dbly-0.4.0}/examples/ci/github-actions.yml +0 -0
  30. {dbly-0.2.0 → dbly-0.4.0}/src/dbly/adapters/__init__.py +0 -0
  31. {dbly-0.2.0 → dbly-0.4.0}/src/dbly/config.py +0 -0
  32. {dbly-0.2.0 → dbly-0.4.0}/src/dbly/engine.py +0 -0
  33. {dbly-0.2.0 → dbly-0.4.0}/src/dbly/hooks.py +0 -0
  34. {dbly-0.2.0 → dbly-0.4.0}/src/dbly/initializer.py +0 -0
  35. {dbly-0.2.0 → dbly-0.4.0}/src/dbly/model.py +0 -0
  36. {dbly-0.2.0 → dbly-0.4.0}/src/dbly/py.typed +0 -0
  37. {dbly-0.2.0 → dbly-0.4.0}/tests/test_mssql.py +0 -0
  38. {dbly-0.2.0 → dbly-0.4.0}/tests/test_mssql_e2e.py +0 -0
  39. {dbly-0.2.0 → dbly-0.4.0}/tests/test_oracle.py +0 -0
  40. {dbly-0.2.0 → dbly-0.4.0}/tests/test_oracle_e2e.py +0 -0
  41. {dbly-0.2.0 → dbly-0.4.0}/uv.lock +0 -0
@@ -0,0 +1 @@
1
+ ko_fi: angrydata
@@ -0,0 +1,120 @@
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.4.0] — 2026-07-23
11
+
12
+ ### Added
13
+
14
+ - **Project configuration (`dbly.toml`).** An optional file at the repo root, for repos that
15
+ need more than a single connection profile:
16
+ - `object_root` — the subtree the declarative object files live under. The schema hint is
17
+ taken from the first path segment *below* this root, so a layout like
18
+ `pgsql/schema/<schema>/<obj>` maps to the right schema instead of the literal top folder.
19
+ - `environment` — default engine/dialect when a profile omits `environment=`.
20
+ - `[targets]` — named connection profiles, so `dbly plan --target dev` resolves to a
21
+ profile path.
22
+ - `ignore` — extra ignore patterns (gitwildmatch), merged with `.dbignore` (e.g. to skip a
23
+ handful of files a parser cannot read).
24
+
25
+ Absent config → unchanged behaviour (`object_root="."`, `--target` is a profile path).
26
+
27
+ ### Fixed
28
+
29
+ - **Case-insensitive identifier matching on PostgreSQL.** Postgres folds unquoted identifiers
30
+ to lower case, so a DDL `CREATE TABLE FOO` lives as `foo`. `plan` (`table_exists`/
31
+ `get_columns`) and `check` looked the object up by the *declared* name and either planned a
32
+ spurious full `CREATE` or crashed with `NoSuchTableError`. Both now resolve the object
33
+ case-insensitively and reflect it by its real, live identity.
34
+ - **`check` no longer aborts on a single unreflectable table.** A table whose columns cannot
35
+ be introspected is reported as advisory `unreadable` and the drift scan continues, instead
36
+ of failing the whole run.
37
+
38
+ ## [0.3.0] — 2026-07-21
39
+
40
+ ### Added
41
+
42
+ - **Plan against the working tree.** `dbly plan --worktree` (alias `--dirty`) and
43
+ `dbly check --worktree` diff the *working directory* — including uncommitted edits and
44
+ untracked new object files — instead of a git ref, for the fast edit→plan loop. Preview
45
+ only; `apply` still requires a real committed ref for the ledger.
46
+ - **Git-style ref decoration.** `dbly status` and the plan header now show the tag/branch
47
+ names pointing at a SHA next to it — e.g. `deployed ref: v0.1, main (a712b631)`. The
48
+ ledger still stores only the SHA (for stable diffs); the names are resolved at display time.
49
+
50
+ ### Fixed
51
+
52
+ - **Column type changes are now detected.** A changed column type (e.g. Oracle `NUMBER` →
53
+ `NUMBER(10)`) previously produced "nothing to do" — the diff matched columns by name only.
54
+ It now emits an `ALTER TABLE … MODIFY`/`ALTER COLUMN` step (flagged destructive, never
55
+ auto-applied) with a data-compatibility warning. Comparison is precision/scale-aware and
56
+ normalizes introspection noise (e.g. SQL Server `COLLATE`) to avoid false positives.
57
+ - **`check` no longer reports live Oracle objects as "missing".** The Oracle adapter now
58
+ resolves its `default_schema` to the connected user and carries the owner on introspected
59
+ objects, so drift keys align with the desired side (previously the owner was dropped on the
60
+ live side, so every object looked missing even right after a successful `apply`).
61
+
62
+ ### Changed
63
+
64
+ - The plan output now surfaces each step's **note inline** (e.g. *"NOT NULL without default
65
+ on existing table — unsafe"*), so *why* a step is flagged is visible without cross-checking
66
+ the warnings block.
67
+
68
+ ## [0.2.0] — 2026-06-28
69
+
70
+ ### Added
71
+
72
+ - **Explicit, run-once migrations.** Drop ordered SQL scripts in `migrations/` (`0001_…sql`)
73
+ for changes the additive diff cannot do safely — renaming a column, type changes with data
74
+ transformation, backfills. Each is tracked by id in the `dbly_state` ledger and runs
75
+ exactly once.
76
+ - On **upgrade**, pending migrations run *before* the object reconciliation, so they reshape
77
+ the schema first. On a **fresh database** they are *baselined* (recorded, not run) since the
78
+ canonical object files already describe the end state.
79
+
80
+ ### Changed
81
+
82
+ - A table touched by a pending migration **defers its additive diff** for that deploy, so an
83
+ explicit rename no longer collides with an auto-generated `ADD COLUMN`.
84
+ - `migrations/` files are excluded from object and drift discovery (they are imperative
85
+ scripts, not declarative objects).
86
+
87
+ ## [0.1.0] — 2026-06-27
88
+
89
+ ### Added
90
+
91
+ - **Indexes and sequences as first-class managed objects.** Correct identity (index name +
92
+ indexed-table schema), dependency-safe ordering (sequences → tables → indexes), and
93
+ create-if-missing handling (no more erroneous re-apply of non-idempotent
94
+ `CREATE INDEX`/`CREATE SEQUENCE`).
95
+ - **Live inventory introspection** across tables, views, functions, procedures, triggers,
96
+ indexes and sequences for all four engines, with a canonical source hash for procedural
97
+ objects.
98
+ - **Real drift detection in `dbly check`** — reports missing (in repo, not in DB), orphaned
99
+ (`--orphans`), table column drift, and advisory definition drift; schema-normalized so an
100
+ unqualified repo object matches the engine's implicit schema.
101
+
102
+ ## [0.0.1] — 2026-06-27
103
+
104
+ ### Added
105
+
106
+ - Initial release. **State-based, cross-engine database deployment** (PostgreSQL, SQL Server,
107
+ Oracle, SQLite), git-driven and parser-assisted (sqlglot).
108
+ - `plan` / `apply` workflow with additive table diffs generated from
109
+ `CREATE TABLE IF NOT EXISTS` (destructive changes flagged, never auto-applied).
110
+ - `plan --sql` — exports a self-contained SQL script for a hand/offline deploy.
111
+ - `dbly init` — privileged greenfield groundwork (`CREATE DATABASE`/roles/extensions).
112
+ - Pre-/post-deploy hooks accepting `.sql` and `.py` (configurable interpreter, e.g. ArcPy).
113
+ - Connection profiles (DBFit-compatible `connection.properties`) with `${ENV}` placeholders
114
+ for CI/CD.
115
+ - CI and PyPI publish workflows (trusted publishing).
116
+
117
+ [Unreleased]: https://github.com/angrydat/dbly/compare/v0.2.0...HEAD
118
+ [0.2.0]: https://github.com/angrydat/dbly/compare/v0.1.0...v0.2.0
119
+ [0.1.0]: https://github.com/angrydat/dbly/compare/v0.0.1...v0.1.0
120
+ [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.2.0
3
+ Version: 0.4.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
@@ -92,6 +93,27 @@ changes the diff can't do safely (renaming a column, moving data), drop an order
92
93
  it touches defers to it for that deploy. On a fresh database, migrations are *baselined*
93
94
  (recorded, not run) since the object files already describe the end state.
94
95
 
96
+ ## Project configuration (optional)
97
+
98
+ For anything beyond the defaults, drop a `dbly.toml` at the repo root. It's optional — without
99
+ it, `--target` is a profile path and the schema is the first folder segment.
100
+
101
+ ```toml
102
+ object_root = "pgsql/schema" # objects live here; schema = first segment BELOW this root
103
+ environment = "postgres" # default engine when a profile omits environment=
104
+ ignore = ["**/*_1252.sql"] # extra ignore patterns, merged with .dbignore
105
+
106
+ [targets] # dbly plan --target dev → resolves to the profile path
107
+ dev = "conn/dev.properties"
108
+ beta = "conn/beta.properties"
109
+ prod = "conn/prod.properties"
110
+ ```
111
+
112
+ `object_root` is the key that lets dbly sit on an existing repo whose objects live deeper than
113
+ the top level (e.g. `pgsql/schema/<schema>/<object>`): the schema is derived *relative to the
114
+ root*, so `pgsql/schema/sales/customer.tbl` maps to schema `sales`, not `pgsql`. Put root-level
115
+ keys (`object_root`, `environment`, `ignore`) **before** the `[targets]` table (TOML rule).
116
+
95
117
  ## Connect
96
118
 
97
119
  A connection profile (reuses the familiar `connection.properties` format):
@@ -54,6 +54,27 @@ changes the diff can't do safely (renaming a column, moving data), drop an order
54
54
  it touches defers to it for that deploy. On a fresh database, migrations are *baselined*
55
55
  (recorded, not run) since the object files already describe the end state.
56
56
 
57
+ ## Project configuration (optional)
58
+
59
+ For anything beyond the defaults, drop a `dbly.toml` at the repo root. It's optional — without
60
+ it, `--target` is a profile path and the schema is the first folder segment.
61
+
62
+ ```toml
63
+ object_root = "pgsql/schema" # objects live here; schema = first segment BELOW this root
64
+ environment = "postgres" # default engine when a profile omits environment=
65
+ ignore = ["**/*_1252.sql"] # extra ignore patterns, merged with .dbignore
66
+
67
+ [targets] # dbly plan --target dev → resolves to the profile path
68
+ dev = "conn/dev.properties"
69
+ beta = "conn/beta.properties"
70
+ prod = "conn/prod.properties"
71
+ ```
72
+
73
+ `object_root` is the key that lets dbly sit on an existing repo whose objects live deeper than
74
+ the top level (e.g. `pgsql/schema/<schema>/<object>`): the schema is derived *relative to the
75
+ root*, so `pgsql/schema/sales/customer.tbl` maps to schema `sales`, not `pgsql`. Put root-level
76
+ keys (`object_root`, `environment`, `ignore`) **before** the `[targets]` table (TOML rule).
77
+
57
78
  ## Connect
58
79
 
59
80
  A connection profile (reuses the familiar `connection.properties` format):
dbly-0.4.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.2.0"
3
+ version = "0.4.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.2.0"
7
+ __version__ = "0.4.0"
@@ -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:
@@ -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:
@@ -26,13 +26,49 @@ class PostgresAdapter(Adapter):
26
26
  transactional_ddl = True
27
27
  default_schema = "public"
28
28
 
29
+ def _resolve(self, schema: str | None, name: str) -> tuple[str, str] | None:
30
+ """Find a relation case-insensitively, returning its real (schema, name).
31
+
32
+ Postgres folds unquoted identifiers to lower case, so a DDL ``CREATE TABLE FOO`` lives
33
+ as ``foo``; the desired name (as written) need not match verbatim. Exact matches are
34
+ preferred over case-folded ones when both exist.
35
+ """
36
+ params = {"n": name}
37
+ schema_pred = ""
38
+ if schema is not None:
39
+ schema_pred = "AND lower(n.nspname) = lower(:s) "
40
+ params["s"] = schema
41
+ q = text(
42
+ "SELECT n.nspname, c.relname FROM pg_class c "
43
+ "JOIN pg_namespace n ON n.oid = c.relnamespace "
44
+ f"WHERE lower(c.relname) = lower(:n) {schema_pred}"
45
+ "AND c.relkind IN ('r','p','v','m') "
46
+ "ORDER BY (c.relname = :n) DESC LIMIT 1"
47
+ )
48
+ with self.engine.connect() as conn:
49
+ row = conn.execute(q, params).first()
50
+ return (row[0], row[1]) if row else None
51
+
29
52
  def table_exists(self, schema: str | None, name: str) -> bool:
30
- insp = inspect(self.engine)
31
- return insp.has_table(name, schema=schema)
53
+ params = {"n": name}
54
+ schema_pred = ""
55
+ if schema is not None:
56
+ schema_pred = "AND lower(n.nspname) = lower(:s) "
57
+ params["s"] = schema
58
+ with self.engine.connect() as conn:
59
+ return conn.execute(
60
+ text(
61
+ "SELECT 1 FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace "
62
+ f"WHERE lower(c.relname) = lower(:n) {schema_pred}"
63
+ "AND c.relkind IN ('r','p') LIMIT 1"
64
+ ),
65
+ params,
66
+ ).first() is not None
32
67
 
33
68
  def get_columns(self, schema: str | None, name: str) -> list[Column]:
34
69
  insp = inspect(self.engine)
35
- cols = insp.get_columns(name, schema=schema)
70
+ rs, rn = self._resolve(schema, name) or (schema, name)
71
+ cols = insp.get_columns(rn, schema=rs)
36
72
  return [
37
73
  Column(
38
74
  name=c["name"],
@@ -111,6 +147,9 @@ class PostgresAdapter(Adapter):
111
147
  parts.append(f"DEFAULT {col.default}")
112
148
  return " ".join(parts) + ";"
113
149
 
150
+ def modify_column_sql(self, table: ObjectId, col: Column) -> str:
151
+ return f"ALTER TABLE {table} ALTER COLUMN {col.name} TYPE {col.type};"
152
+
114
153
  def apply(self, statements: list[str]) -> None:
115
154
  # transactional DDL → one atomic transaction
116
155
  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:
@@ -16,12 +16,13 @@ from rich.console import Console
16
16
 
17
17
  from dbly import __version__, drift, hooks, initializer, report
18
18
  from dbly.adapters import get_adapter
19
- from dbly.config import resolve_target
19
+ from dbly.config import ConnectionConfig, load_profile, resolve_target
20
20
  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.project import ProjectConfig, load_project
25
+ from dbly.repo import WORKTREE, Repo
25
26
 
26
27
  app = typer.Typer(
27
28
  name="dbly",
@@ -48,13 +49,51 @@ def _main(
48
49
  pass
49
50
 
50
51
 
51
- def _make_plan(repo_path: Path, target: str, from_ref: Optional[str], to_ref: str) -> Plan:
52
- repo = Repo(repo_path)
53
- cfg = resolve_target(target)
52
+ def _open_repo(repo_path: Path, project: ProjectConfig) -> Repo:
53
+ return Repo(repo_path, object_root=project.object_root, extra_ignore=project.ignore)
54
+
55
+
56
+ def _resolve_target(project: ProjectConfig, repo_path: Path, target: str) -> ConnectionConfig:
57
+ """Resolve ``--target``: a named target from ``dbly.toml`` [targets], else a profile path.
58
+
59
+ The project's ``environment`` fills in when the profile omits ``environment=``.
60
+ """
61
+ if target in project.targets:
62
+ cfg = load_profile(repo_path / project.targets[target])
63
+ else:
64
+ cfg = resolve_target(target)
65
+ if cfg.environment is None and project.environment:
66
+ cfg.environment = project.environment
67
+ return cfg
68
+
69
+
70
+ def _decorations(repo_path: Path, plan: Plan) -> dict[str, str]:
71
+ """Map each real ref in the plan to its git tag/branch names (for the plan header)."""
72
+ try:
73
+ repo = Repo(repo_path)
74
+ except ValueError:
75
+ return {}
76
+ out: dict[str, str] = {}
77
+ for ref in {plan.from_ref, plan.to_ref}:
78
+ if not ref or ref == WORKTREE:
79
+ continue
80
+ names = repo.ref_names(ref)
81
+ if names:
82
+ out[ref] = ", ".join(names)
83
+ return out
84
+
85
+
86
+ def _make_plan(
87
+ repo_path: Path, target: str, from_ref: Optional[str], to_ref: str,
88
+ *, worktree: bool = False,
89
+ ) -> Plan:
90
+ project = load_project(repo_path)
91
+ repo = _open_repo(repo_path, project)
92
+ cfg = _resolve_target(project, repo_path, target)
54
93
  dialect = sqlglot_dialect(detect_dialect(cfg))
55
94
  adapter = get_adapter(cfg)
56
95
  try:
57
- resolved_to = repo.resolve_ref(to_ref)
96
+ resolved_to = repo.resolve_ref(WORKTREE if worktree else to_ref)
58
97
  if from_ref is not None:
59
98
  resolved_from = repo.resolve_ref(from_ref)
60
99
  else:
@@ -80,16 +119,21 @@ def plan(
80
119
  sql: Optional[Path] = typer.Option(
81
120
  None, "--sql", help="write an executable SQL script for a hand/offline deploy."
82
121
  ),
122
+ worktree: bool = typer.Option(
123
+ False, "--worktree", "--dirty",
124
+ help="plan against the working tree (uncommitted + untracked object files), "
125
+ "not a git ref — for the fast edit→plan loop.",
126
+ ),
83
127
  ) -> None:
84
128
  """Compute and show the deployment plan."""
85
- plan_obj = _make_plan(repo_path, target, from_ref, to)
86
- report.render_plan(plan_obj, console)
129
+ plan_obj = _make_plan(repo_path, target, from_ref, to, worktree=worktree)
130
+ report.render_plan(plan_obj, console, ref_names=_decorations(repo_path, plan_obj))
87
131
  if out:
88
132
  out.write_text(report.plan_to_yaml(plan_obj), encoding="utf-8")
89
133
  console.print(f"\n[dim]plan (YAML) written to {out}[/dim]")
90
134
  if sql:
91
135
  # state_table_ddl / record_deploy_sql are pure string builders — no DB connection.
92
- adapter = get_adapter(resolve_target(target))
136
+ adapter = get_adapter(_resolve_target(load_project(repo_path), repo_path, target))
93
137
  try:
94
138
  script = report.plan_to_sql(
95
139
  plan_obj,
@@ -123,7 +167,7 @@ def apply(
123
167
  else:
124
168
  plan_obj = _make_plan(repo_path, target, from_ref, to)
125
169
 
126
- report.render_plan(plan_obj, console)
170
+ report.render_plan(plan_obj, console, ref_names=_decorations(repo_path, plan_obj))
127
171
 
128
172
  destructive = [s for s in plan_obj.steps if s.severity is Severity.DESTRUCTIVE]
129
173
  if destructive and not allow_destructive:
@@ -141,8 +185,9 @@ def apply(
141
185
  console.print("[green]nothing to apply.[/green]")
142
186
  return
143
187
 
144
- repo = Repo(repo_path)
145
- cfg = resolve_target(target)
188
+ project = load_project(repo_path)
189
+ repo = _open_repo(repo_path, project)
190
+ cfg = _resolve_target(project, repo_path, target)
146
191
  adapter = get_adapter(cfg)
147
192
  try:
148
193
  _run_hooks(repo, "pre", py_interpreter)
@@ -180,12 +225,13 @@ def init(
180
225
 
181
226
  Greenfield only — brownfield (a handed-over database) skips this entirely.
182
227
  """
183
- repo = Repo(repo_path)
228
+ project = load_project(repo_path)
229
+ repo = _open_repo(repo_path, project)
184
230
  scripts = initializer.discover_init_scripts(repo.root, init_dir)
185
231
  if not scripts:
186
232
  console.print(f"[yellow]no init scripts in {init_dir}/ — nothing to do.[/yellow]")
187
233
  return
188
- adapter = get_adapter(resolve_target(init_target))
234
+ adapter = get_adapter(_resolve_target(project, repo_path, init_target))
189
235
  try:
190
236
  for s in scripts:
191
237
  adapter.run_init_script(s.read_text(encoding="utf-8"))
@@ -203,12 +249,15 @@ def bootstrap(
203
249
  ) -> None:
204
250
  """Install into an empty database (no baseline — full apply)."""
205
251
  plan_obj = _make_plan(repo_path, target, None, to)
206
- report.render_plan(plan_obj, console)
252
+ report.render_plan(plan_obj, console, ref_names=_decorations(repo_path, plan_obj))
207
253
  console.print("\n[dim]review, then run `dbly apply` to execute.[/dim]")
208
254
 
209
255
 
210
256
  @app.command()
211
- def status(target: str = typer.Option(..., "--target")) -> None:
257
+ def status(
258
+ target: str = typer.Option(..., "--target"),
259
+ repo_path: Path = typer.Option(Path("."), "--repo", help="repository root (for ref names)."),
260
+ ) -> None:
212
261
  """Show the deployed ref recorded on the target."""
213
262
  cfg = resolve_target(target)
214
263
  adapter = get_adapter(cfg)
@@ -216,10 +265,17 @@ def status(target: str = typer.Option(..., "--target")) -> None:
216
265
  ref = adapter.get_deployed_ref()
217
266
  finally:
218
267
  adapter.dispose()
219
- if ref:
220
- console.print(f"deployed ref: [cyan]{ref}[/cyan]")
221
- else:
268
+ if not ref:
222
269
  console.print("[yellow]no deploy recorded — database is unmanaged or empty.[/yellow]")
270
+ return
271
+ try:
272
+ names = Repo(repo_path).ref_names(ref)
273
+ except ValueError:
274
+ names = [] # not a git repo — SHA only
275
+ if names:
276
+ console.print(f"deployed ref: [cyan]{', '.join(names)}[/cyan] [dim]({ref[:8]})[/dim]")
277
+ else:
278
+ console.print(f"deployed ref: [cyan]{ref}[/cyan]")
223
279
 
224
280
 
225
281
  @app.command()
@@ -230,21 +286,27 @@ def check(
230
286
  orphans: bool = typer.Option(
231
287
  False, "--orphans", help="also report objects in the DB but not in the repo."
232
288
  ),
289
+ worktree: bool = typer.Option(
290
+ False, "--worktree", "--dirty",
291
+ help="compare the working tree (uncommitted + untracked) against the DB, not a git ref.",
292
+ ),
233
293
  ) -> None:
234
294
  """Detect drift: compare the desired state at <to> against the live database."""
235
- repo = Repo(repo_path)
236
- cfg = resolve_target(target)
295
+ project = load_project(repo_path)
296
+ repo = _open_repo(repo_path, project)
297
+ cfg = _resolve_target(project, repo_path, target)
237
298
  dialect = sqlglot_dialect(detect_dialect(cfg))
238
299
  adapter = get_adapter(cfg)
239
300
  try:
240
301
  rep = drift.compute_drift(
241
302
  repo, adapter,
242
- to_ref=repo.resolve_ref(to), dialect=dialect, include_orphans=orphans,
303
+ to_ref=repo.resolve_ref(WORKTREE if worktree else to),
304
+ dialect=dialect, include_orphans=orphans,
243
305
  )
244
306
  finally:
245
307
  adapter.dispose()
246
308
 
247
- if rep.clean:
309
+ if rep.clean and not rep.unreadable:
248
310
  console.print("[green]no drift — database matches the desired state.[/green]")
249
311
  return
250
312
 
@@ -259,7 +321,10 @@ def check(
259
321
  console.print(f"[yellow]changed[/yellow] {kind.value} {oid} (definition differs — advisory)")
260
322
  for kind, oid in rep.orphaned:
261
323
  console.print(f"[dim]orphaned[/dim] {kind.value} {oid} (in DB, not in repo)")
262
- raise typer.Exit(code=1)
324
+ for kind, oid in rep.unreadable:
325
+ console.print(f"[dim]unreadable[/dim] {kind.value} {oid} (columns could not be reflected — advisory)")
326
+ if not rep.clean:
327
+ raise typer.Exit(code=1)
263
328
 
264
329
 
265
330
  def _run_hooks(repo: Repo, phase: str, py_interpreter: str) -> None: