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.
- dbly-0.4.0/.github/FUNDING.yml +1 -0
- dbly-0.4.0/CHANGELOG.md +120 -0
- {dbly-0.2.0 → dbly-0.4.0}/PKG-INFO +23 -1
- {dbly-0.2.0 → dbly-0.4.0}/README.md +21 -0
- dbly-0.4.0/TODO.md +70 -0
- {dbly-0.2.0 → dbly-0.4.0}/pyproject.toml +2 -1
- {dbly-0.2.0 → dbly-0.4.0}/src/dbly/__init__.py +1 -1
- {dbly-0.2.0 → dbly-0.4.0}/src/dbly/adapters/base.py +9 -0
- {dbly-0.2.0 → dbly-0.4.0}/src/dbly/adapters/mssql.py +4 -0
- {dbly-0.2.0 → dbly-0.4.0}/src/dbly/adapters/oracle.py +22 -2
- {dbly-0.2.0 → dbly-0.4.0}/src/dbly/adapters/postgres.py +42 -3
- {dbly-0.2.0 → dbly-0.4.0}/src/dbly/adapters/sqlite.py +8 -0
- {dbly-0.2.0 → dbly-0.4.0}/src/dbly/cli.py +89 -24
- {dbly-0.2.0 → dbly-0.4.0}/src/dbly/drift.py +10 -1
- {dbly-0.2.0 → dbly-0.4.0}/src/dbly/parsing.py +36 -0
- {dbly-0.2.0 → dbly-0.4.0}/src/dbly/planner.py +22 -0
- dbly-0.4.0/src/dbly/project.py +54 -0
- {dbly-0.2.0 → dbly-0.4.0}/src/dbly/repo.py +90 -15
- {dbly-0.2.0 → dbly-0.4.0}/src/dbly/report.py +19 -3
- {dbly-0.2.0 → dbly-0.4.0}/tests/test_integration.py +147 -0
- {dbly-0.2.0 → dbly-0.4.0}/tests/test_parsing.py +36 -0
- dbly-0.4.0/tests/test_project.py +35 -0
- {dbly-0.2.0 → dbly-0.4.0}/.github/workflows/ci.yml +0 -0
- {dbly-0.2.0 → dbly-0.4.0}/.github/workflows/publish.yml +0 -0
- {dbly-0.2.0 → dbly-0.4.0}/.gitignore +0 -0
- {dbly-0.2.0 → dbly-0.4.0}/docs/dbly_head.png +0 -0
- {dbly-0.2.0 → dbly-0.4.0}/examples/ci/README.md +0 -0
- {dbly-0.2.0 → dbly-0.4.0}/examples/ci/bitbucket-pipelines.yml +0 -0
- {dbly-0.2.0 → dbly-0.4.0}/examples/ci/github-actions.yml +0 -0
- {dbly-0.2.0 → dbly-0.4.0}/src/dbly/adapters/__init__.py +0 -0
- {dbly-0.2.0 → dbly-0.4.0}/src/dbly/config.py +0 -0
- {dbly-0.2.0 → dbly-0.4.0}/src/dbly/engine.py +0 -0
- {dbly-0.2.0 → dbly-0.4.0}/src/dbly/hooks.py +0 -0
- {dbly-0.2.0 → dbly-0.4.0}/src/dbly/initializer.py +0 -0
- {dbly-0.2.0 → dbly-0.4.0}/src/dbly/model.py +0 -0
- {dbly-0.2.0 → dbly-0.4.0}/src/dbly/py.typed +0 -0
- {dbly-0.2.0 → dbly-0.4.0}/tests/test_mssql.py +0 -0
- {dbly-0.2.0 → dbly-0.4.0}/tests/test_mssql_e2e.py +0 -0
- {dbly-0.2.0 → dbly-0.4.0}/tests/test_oracle.py +0 -0
- {dbly-0.2.0 → dbly-0.4.0}/tests/test_oracle_e2e.py +0 -0
- {dbly-0.2.0 → dbly-0.4.0}/uv.lock +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ko_fi: angrydata
|
dbly-0.4.0/CHANGELOG.md
ADDED
|
@@ -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.
|
|
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.
|
|
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"
|
|
@@ -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(
|
|
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:
|
|
@@ -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
|
-
|
|
31
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
52
|
-
|
|
53
|
-
|
|
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(
|
|
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
|
-
|
|
145
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
236
|
-
|
|
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),
|
|
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
|
-
|
|
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:
|