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.
- dbly-0.3.0/.github/FUNDING.yml +1 -0
- dbly-0.3.0/CHANGELOG.md +92 -0
- {dbly-0.1.0 → dbly-0.3.0}/PKG-INFO +9 -1
- {dbly-0.1.0 → dbly-0.3.0}/README.md +7 -0
- dbly-0.3.0/TODO.md +70 -0
- {dbly-0.1.0 → dbly-0.3.0}/pyproject.toml +2 -1
- {dbly-0.1.0 → dbly-0.3.0}/src/dbly/__init__.py +1 -1
- {dbly-0.1.0 → dbly-0.3.0}/src/dbly/adapters/base.py +24 -1
- {dbly-0.1.0 → dbly-0.3.0}/src/dbly/adapters/mssql.py +4 -0
- {dbly-0.1.0 → dbly-0.3.0}/src/dbly/adapters/oracle.py +22 -2
- {dbly-0.1.0 → dbly-0.3.0}/src/dbly/adapters/postgres.py +3 -0
- {dbly-0.1.0 → dbly-0.3.0}/src/dbly/adapters/sqlite.py +8 -0
- {dbly-0.1.0 → dbly-0.3.0}/src/dbly/cli.py +66 -16
- {dbly-0.1.0 → dbly-0.3.0}/src/dbly/model.py +15 -0
- {dbly-0.1.0 → dbly-0.3.0}/src/dbly/parsing.py +54 -0
- {dbly-0.1.0 → dbly-0.3.0}/src/dbly/planner.py +47 -0
- {dbly-0.1.0 → dbly-0.3.0}/src/dbly/repo.py +73 -10
- {dbly-0.1.0 → dbly-0.3.0}/src/dbly/report.py +71 -17
- dbly-0.3.0/tests/test_integration.py +370 -0
- {dbly-0.1.0 → dbly-0.3.0}/tests/test_parsing.py +36 -0
- {dbly-0.1.0 → dbly-0.3.0}/uv.lock +1 -1
- dbly-0.1.0/tests/test_integration.py +0 -177
- {dbly-0.1.0 → dbly-0.3.0}/.github/workflows/ci.yml +0 -0
- {dbly-0.1.0 → dbly-0.3.0}/.github/workflows/publish.yml +0 -0
- {dbly-0.1.0 → dbly-0.3.0}/.gitignore +0 -0
- {dbly-0.1.0 → dbly-0.3.0}/docs/dbly_head.png +0 -0
- {dbly-0.1.0 → dbly-0.3.0}/examples/ci/README.md +0 -0
- {dbly-0.1.0 → dbly-0.3.0}/examples/ci/bitbucket-pipelines.yml +0 -0
- {dbly-0.1.0 → dbly-0.3.0}/examples/ci/github-actions.yml +0 -0
- {dbly-0.1.0 → dbly-0.3.0}/src/dbly/adapters/__init__.py +0 -0
- {dbly-0.1.0 → dbly-0.3.0}/src/dbly/config.py +0 -0
- {dbly-0.1.0 → dbly-0.3.0}/src/dbly/drift.py +0 -0
- {dbly-0.1.0 → dbly-0.3.0}/src/dbly/engine.py +0 -0
- {dbly-0.1.0 → dbly-0.3.0}/src/dbly/hooks.py +0 -0
- {dbly-0.1.0 → dbly-0.3.0}/src/dbly/initializer.py +0 -0
- {dbly-0.1.0 → dbly-0.3.0}/src/dbly/py.typed +0 -0
- {dbly-0.1.0 → dbly-0.3.0}/tests/test_mssql.py +0 -0
- {dbly-0.1.0 → dbly-0.3.0}/tests/test_mssql_e2e.py +0 -0
- {dbly-0.1.0 → dbly-0.3.0}/tests/test_oracle.py +0 -0
- {dbly-0.1.0 → dbly-0.3.0}/tests/test_oracle_e2e.py +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ko_fi: angrydata
|
dbly-0.3.0/CHANGELOG.md
ADDED
|
@@ -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.
|
|
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.
|
|
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"
|
|
@@ -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(
|
|
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(
|
|
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
|
|
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
|
-
|
|
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(
|
|
156
|
-
|
|
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(
|
|
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),
|
|
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
|
|