dbly 0.2.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.2.0 → dbly-0.3.0}/PKG-INFO +2 -1
- dbly-0.3.0/TODO.md +70 -0
- {dbly-0.2.0 → dbly-0.3.0}/pyproject.toml +2 -1
- {dbly-0.2.0 → dbly-0.3.0}/src/dbly/__init__.py +1 -1
- {dbly-0.2.0 → dbly-0.3.0}/src/dbly/adapters/base.py +9 -0
- {dbly-0.2.0 → dbly-0.3.0}/src/dbly/adapters/mssql.py +4 -0
- {dbly-0.2.0 → dbly-0.3.0}/src/dbly/adapters/oracle.py +22 -2
- {dbly-0.2.0 → dbly-0.3.0}/src/dbly/adapters/postgres.py +3 -0
- {dbly-0.2.0 → dbly-0.3.0}/src/dbly/adapters/sqlite.py +8 -0
- {dbly-0.2.0 → dbly-0.3.0}/src/dbly/cli.py +51 -12
- {dbly-0.2.0 → dbly-0.3.0}/src/dbly/parsing.py +36 -0
- {dbly-0.2.0 → dbly-0.3.0}/src/dbly/planner.py +22 -0
- {dbly-0.2.0 → dbly-0.3.0}/src/dbly/repo.py +53 -6
- {dbly-0.2.0 → dbly-0.3.0}/src/dbly/report.py +19 -3
- {dbly-0.2.0 → dbly-0.3.0}/tests/test_integration.py +100 -0
- {dbly-0.2.0 → dbly-0.3.0}/tests/test_parsing.py +36 -0
- {dbly-0.2.0 → dbly-0.3.0}/.github/workflows/ci.yml +0 -0
- {dbly-0.2.0 → dbly-0.3.0}/.github/workflows/publish.yml +0 -0
- {dbly-0.2.0 → dbly-0.3.0}/.gitignore +0 -0
- {dbly-0.2.0 → dbly-0.3.0}/README.md +0 -0
- {dbly-0.2.0 → dbly-0.3.0}/docs/dbly_head.png +0 -0
- {dbly-0.2.0 → dbly-0.3.0}/examples/ci/README.md +0 -0
- {dbly-0.2.0 → dbly-0.3.0}/examples/ci/bitbucket-pipelines.yml +0 -0
- {dbly-0.2.0 → dbly-0.3.0}/examples/ci/github-actions.yml +0 -0
- {dbly-0.2.0 → dbly-0.3.0}/src/dbly/adapters/__init__.py +0 -0
- {dbly-0.2.0 → dbly-0.3.0}/src/dbly/config.py +0 -0
- {dbly-0.2.0 → dbly-0.3.0}/src/dbly/drift.py +0 -0
- {dbly-0.2.0 → dbly-0.3.0}/src/dbly/engine.py +0 -0
- {dbly-0.2.0 → dbly-0.3.0}/src/dbly/hooks.py +0 -0
- {dbly-0.2.0 → dbly-0.3.0}/src/dbly/initializer.py +0 -0
- {dbly-0.2.0 → dbly-0.3.0}/src/dbly/model.py +0 -0
- {dbly-0.2.0 → dbly-0.3.0}/src/dbly/py.typed +0 -0
- {dbly-0.2.0 → dbly-0.3.0}/tests/test_mssql.py +0 -0
- {dbly-0.2.0 → dbly-0.3.0}/tests/test_mssql_e2e.py +0 -0
- {dbly-0.2.0 → dbly-0.3.0}/tests/test_oracle.py +0 -0
- {dbly-0.2.0 → dbly-0.3.0}/tests/test_oracle_e2e.py +0 -0
- {dbly-0.2.0 → dbly-0.3.0}/uv.lock +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
|
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"
|
|
@@ -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:
|
|
@@ -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:
|
|
@@ -203,12 +227,15 @@ def bootstrap(
|
|
|
203
227
|
) -> None:
|
|
204
228
|
"""Install into an empty database (no baseline — full apply)."""
|
|
205
229
|
plan_obj = _make_plan(repo_path, target, None, to)
|
|
206
|
-
report.render_plan(plan_obj, console)
|
|
230
|
+
report.render_plan(plan_obj, console, ref_names=_decorations(repo_path, plan_obj))
|
|
207
231
|
console.print("\n[dim]review, then run `dbly apply` to execute.[/dim]")
|
|
208
232
|
|
|
209
233
|
|
|
210
234
|
@app.command()
|
|
211
|
-
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:
|
|
212
239
|
"""Show the deployed ref recorded on the target."""
|
|
213
240
|
cfg = resolve_target(target)
|
|
214
241
|
adapter = get_adapter(cfg)
|
|
@@ -216,10 +243,17 @@ def status(target: str = typer.Option(..., "--target")) -> None:
|
|
|
216
243
|
ref = adapter.get_deployed_ref()
|
|
217
244
|
finally:
|
|
218
245
|
adapter.dispose()
|
|
219
|
-
if ref:
|
|
220
|
-
console.print(f"deployed ref: [cyan]{ref}[/cyan]")
|
|
221
|
-
else:
|
|
246
|
+
if not ref:
|
|
222
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]")
|
|
223
257
|
|
|
224
258
|
|
|
225
259
|
@app.command()
|
|
@@ -230,6 +264,10 @@ def check(
|
|
|
230
264
|
orphans: bool = typer.Option(
|
|
231
265
|
False, "--orphans", help="also report objects in the DB but not in the repo."
|
|
232
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
|
+
),
|
|
233
271
|
) -> None:
|
|
234
272
|
"""Detect drift: compare the desired state at <to> against the live database."""
|
|
235
273
|
repo = Repo(repo_path)
|
|
@@ -239,7 +277,8 @@ def check(
|
|
|
239
277
|
try:
|
|
240
278
|
rep = drift.compute_drift(
|
|
241
279
|
repo, adapter,
|
|
242
|
-
to_ref=repo.resolve_ref(to),
|
|
280
|
+
to_ref=repo.resolve_ref(WORKTREE if worktree else to),
|
|
281
|
+
dialect=dialect, include_orphans=orphans,
|
|
243
282
|
)
|
|
244
283
|
finally:
|
|
245
284
|
adapter.dispose()
|
|
@@ -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
|
|
@@ -193,6 +194,41 @@ def desired_columns(sql: str, *, dialect: str | None = None) -> list[Column]:
|
|
|
193
194
|
return columns
|
|
194
195
|
|
|
195
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
|
+
|
|
196
232
|
def topological_order(objects: list[ParsedObject]) -> list[ParsedObject]:
|
|
197
233
|
"""Order replaceable objects so dependencies come first (CONCEPT.md §8).
|
|
198
234
|
|
|
@@ -174,6 +174,28 @@ def _plan_table(adapter: Adapter, plan: Plan, obj: ParsedObject, dialect: str |
|
|
|
174
174
|
)
|
|
175
175
|
)
|
|
176
176
|
|
|
177
|
+
# type changes: column present on both sides, but the declared type differs. Never
|
|
178
|
+
# auto-applied (a narrowing/incompatible change can truncate or fail) — flagged for review.
|
|
179
|
+
for col in desired:
|
|
180
|
+
actual_col = actual_by_key.get(col.key())
|
|
181
|
+
if actual_col is None or not parsing.types_differ(col.type, actual_col.type):
|
|
182
|
+
continue
|
|
183
|
+
plan.steps.append(
|
|
184
|
+
Step(
|
|
185
|
+
title=f"modify column {obj.id}.{col.name} type → {col.type}",
|
|
186
|
+
object_id=obj.id,
|
|
187
|
+
kind=ObjectKind.TABLE,
|
|
188
|
+
severity=Severity.DESTRUCTIVE,
|
|
189
|
+
sql=adapter.modify_column_sql(obj.id, col),
|
|
190
|
+
source_file=obj.source_file,
|
|
191
|
+
note=f"type change {actual_col.type} → {col.type} — may truncate/convert data",
|
|
192
|
+
)
|
|
193
|
+
)
|
|
194
|
+
plan.warnings.append(
|
|
195
|
+
f"{obj.id}.{col.name}: type change {actual_col.type} → {col.type} is not "
|
|
196
|
+
"auto-applied — review for data compatibility, then use --allow-destructive"
|
|
197
|
+
)
|
|
198
|
+
|
|
177
199
|
# destructive: columns present in actual, gone from desired
|
|
178
200
|
for col in actual:
|
|
179
201
|
if col.key() not in desired_by_key:
|
|
@@ -17,6 +17,7 @@ from dbly.model import ChangeType
|
|
|
17
17
|
|
|
18
18
|
_SQL_SUFFIXES = {".sql", ".tbl", ".vw", ".prc", ".fnc", ".pkg", ".trg", ".typ", ".ddl"}
|
|
19
19
|
MIGRATIONS_DIR = "migrations" # run-once scripts — not declarative objects
|
|
20
|
+
WORKTREE = "WORKTREE" # sentinel `to_ref`: plan/check against the working tree, not a git ref
|
|
20
21
|
|
|
21
22
|
|
|
22
23
|
@dataclass(slots=True)
|
|
@@ -61,15 +62,39 @@ class Repo:
|
|
|
61
62
|
"""A deployable declarative object file (SQL, not a migration, not ignored)."""
|
|
62
63
|
return self._is_sql(rel) and not self._is_migration(rel) and not self.is_ignored(rel)
|
|
63
64
|
|
|
65
|
+
def _untracked_files(self) -> list[Path]:
|
|
66
|
+
"""New files in the working tree not yet added to git (``git status`` "??")."""
|
|
67
|
+
raw = self._git("ls-files", "--others", "--exclude-standard", "-z")
|
|
68
|
+
return [Path(n) for n in filter(None, raw.split("\0"))]
|
|
69
|
+
|
|
70
|
+
def _worktree_paths(self) -> list[Path]:
|
|
71
|
+
"""Every file in the working tree: tracked (``ls-files``) + untracked new files."""
|
|
72
|
+
raw = self._git("ls-files", "-z")
|
|
73
|
+
tracked = [Path(n) for n in filter(None, raw.split("\0"))]
|
|
74
|
+
return tracked + self._untracked_files()
|
|
75
|
+
|
|
64
76
|
def changed_files(self, from_ref: str | None, to_ref: str) -> list[FileChange]:
|
|
65
77
|
"""Files changed between two refs (or the full tree at ``to_ref`` for bootstrap)."""
|
|
66
78
|
if from_ref is None:
|
|
67
79
|
return [FileChange(p, ChangeType.ADDED) for p in self.list_files(to_ref)]
|
|
80
|
+
if to_ref == WORKTREE:
|
|
81
|
+
# `git diff <from>` (no second ref) diffs the ref against the working tree
|
|
82
|
+
# (staged + unstaged tracked changes); untracked new files are added separately.
|
|
83
|
+
changes = self._parse_name_status(
|
|
84
|
+
self._git("diff", "--name-status", "-z", from_ref)
|
|
85
|
+
)
|
|
86
|
+
known = {c.path for c in changes}
|
|
87
|
+
for p in self._untracked_files():
|
|
88
|
+
if self._is_object(p) and p not in known:
|
|
89
|
+
changes.append(FileChange(p, ChangeType.ADDED))
|
|
90
|
+
return changes
|
|
68
91
|
raw = self._git("diff", "--name-status", "-z", f"{from_ref}..{to_ref}")
|
|
69
92
|
return self._parse_name_status(raw)
|
|
70
93
|
|
|
71
94
|
def list_files(self, ref: str) -> list[Path]:
|
|
72
95
|
"""All deployable declarative object files present at ``ref`` (excludes migrations)."""
|
|
96
|
+
if ref == WORKTREE:
|
|
97
|
+
return [p for p in self._worktree_paths() if self._is_object(p)]
|
|
73
98
|
raw = self._git("ls-tree", "-r", "--name-only", "-z", ref)
|
|
74
99
|
return [Path(n) for n in filter(None, raw.split("\0")) if self._is_object(Path(n))]
|
|
75
100
|
|
|
@@ -78,11 +103,12 @@ class Repo:
|
|
|
78
103
|
|
|
79
104
|
Id is the filename; order is lexicographic, so prefix files ``0001_…`` / a timestamp.
|
|
80
105
|
"""
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
106
|
+
if ref == WORKTREE:
|
|
107
|
+
names = self._worktree_paths()
|
|
108
|
+
else:
|
|
109
|
+
raw = self._git("ls-tree", "-r", "--name-only", "-z", ref)
|
|
110
|
+
names = [Path(n) for n in filter(None, raw.split("\0"))]
|
|
111
|
+
out = [p for p in names if self._is_migration(p) and p.suffix.lower() == ".sql"]
|
|
86
112
|
return [(p.name, p) for p in sorted(out, key=lambda p: p.as_posix())]
|
|
87
113
|
|
|
88
114
|
def _parse_name_status(self, raw: str) -> list[FileChange]:
|
|
@@ -111,12 +137,33 @@ class Repo:
|
|
|
111
137
|
"""Resolve a symbolic ref (HEAD, a tag, a branch) to its immutable commit SHA.
|
|
112
138
|
|
|
113
139
|
The ledger and plan headers store the SHA, not ``HEAD`` — so a later ``--from``
|
|
114
|
-
diff is stable regardless of where HEAD has since moved.
|
|
140
|
+
diff is stable regardless of where HEAD has since moved. The ``WORKTREE`` sentinel
|
|
141
|
+
has no SHA and is returned unchanged.
|
|
115
142
|
"""
|
|
143
|
+
if ref == WORKTREE:
|
|
144
|
+
return WORKTREE
|
|
116
145
|
return self._git("rev-parse", ref).strip()
|
|
117
146
|
|
|
147
|
+
def ref_names(self, sha: str) -> list[str]:
|
|
148
|
+
"""Tag and branch names pointing exactly at ``sha`` — for git-style plan/status
|
|
149
|
+
decorations (e.g. ``v0.1, main``). Display-only; the ledger still stores the SHA."""
|
|
150
|
+
names: list[str] = []
|
|
151
|
+
try:
|
|
152
|
+
names += [n for n in self._git("tag", "--points-at", sha).split() if n]
|
|
153
|
+
names += [
|
|
154
|
+
n for n in self._git(
|
|
155
|
+
"branch", "--points-at", sha, "--format=%(refname:short)"
|
|
156
|
+
).split() if n
|
|
157
|
+
]
|
|
158
|
+
except subprocess.CalledProcessError:
|
|
159
|
+
return []
|
|
160
|
+
seen: set[str] = set()
|
|
161
|
+
return [n for n in names if not (n in seen or seen.add(n))]
|
|
162
|
+
|
|
118
163
|
def read_at(self, ref: str, rel: Path) -> str:
|
|
119
164
|
"""File content at a given ref (the *desired* state)."""
|
|
165
|
+
if ref == WORKTREE:
|
|
166
|
+
return (self.root / rel).read_text(encoding="utf-8")
|
|
120
167
|
return self._git("show", f"{ref}:{rel.as_posix()}")
|
|
121
168
|
|
|
122
169
|
def schema_for(self, rel: Path) -> str | None:
|
|
@@ -14,10 +14,23 @@ from rich.table import Table
|
|
|
14
14
|
from dbly.model import Migration, ObjectId, ObjectKind, Plan, Severity, Step
|
|
15
15
|
|
|
16
16
|
|
|
17
|
-
def
|
|
17
|
+
def _decorate_ref(ref: str | None, ref_names: dict[str, str] | None) -> str:
|
|
18
|
+
"""Render a ref for the plan header: git-style ``<names> (<short-sha>)`` when known."""
|
|
19
|
+
if not ref:
|
|
20
|
+
return "∅"
|
|
21
|
+
if ref == "WORKTREE":
|
|
22
|
+
return "working tree"
|
|
23
|
+
if ref_names and ref_names.get(ref):
|
|
24
|
+
return f"{ref_names[ref]} ({ref[:8]})"
|
|
25
|
+
return ref
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def render_plan(
|
|
29
|
+
plan: Plan, console: Console, *, ref_names: dict[str, str] | None = None
|
|
30
|
+
) -> None:
|
|
18
31
|
console.print(
|
|
19
32
|
f"[bold]Plan[/bold] for [cyan]{plan.target}[/cyan] "
|
|
20
|
-
f"{plan.from_ref
|
|
33
|
+
f"{_decorate_ref(plan.from_ref, ref_names)} → {_decorate_ref(plan.to_ref, ref_names)}"
|
|
21
34
|
)
|
|
22
35
|
if not plan.steps and not plan.warnings and not plan.migrations and not plan.baselined:
|
|
23
36
|
console.print("[green]nothing to do — target is up to date[/green]")
|
|
@@ -38,11 +51,14 @@ def render_plan(plan: Plan, console: Console) -> None:
|
|
|
38
51
|
table.add_column("step")
|
|
39
52
|
for i, step in enumerate(plan.steps, 1):
|
|
40
53
|
sev_style = "red" if step.severity is Severity.DESTRUCTIVE else "green"
|
|
54
|
+
title = step.title
|
|
55
|
+
if step.note: # surface *why* a step is flagged (e.g. NOT NULL without default)
|
|
56
|
+
title += f"\n[dim]↳ {step.note}[/dim]"
|
|
41
57
|
table.add_row(
|
|
42
58
|
str(i),
|
|
43
59
|
f"[{sev_style}]{step.severity.value}[/{sev_style}]",
|
|
44
60
|
step.kind.value,
|
|
45
|
-
|
|
61
|
+
title,
|
|
46
62
|
)
|
|
47
63
|
console.print(table)
|
|
48
64
|
|
|
@@ -268,3 +268,103 @@ def test_init_runs_ordered_multistatement_scripts(tmp_path: Path):
|
|
|
268
268
|
assert adapter.table_exists(None, "meta")
|
|
269
269
|
assert adapter.table_exists(None, "audit")
|
|
270
270
|
adapter.dispose()
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def test_type_change_produces_destructive_modify_step(tmp_path: Path):
|
|
274
|
+
from dbly.repo import WORKTREE # noqa: F401 — import guard; used indirectly below
|
|
275
|
+
repo_root = tmp_path / "db"
|
|
276
|
+
repo_root.mkdir()
|
|
277
|
+
_init_repo(repo_root)
|
|
278
|
+
(repo_root / "kunde.tbl").write_text(
|
|
279
|
+
"CREATE TABLE IF NOT EXISTS kunde (id INTEGER, code VARCHAR(10));", encoding="utf-8"
|
|
280
|
+
)
|
|
281
|
+
ref1 = _commit(repo_root, "v1")
|
|
282
|
+
|
|
283
|
+
db = tmp_path / "types.db"
|
|
284
|
+
adapter = SqliteAdapter(ConnectionConfig(environment="sqlite", service=str(db)))
|
|
285
|
+
repo = Repo(repo_root)
|
|
286
|
+
plan1 = build_plan(repo, adapter, from_ref=None, to_ref=ref1,
|
|
287
|
+
target="sqlite", dialect="sqlite")
|
|
288
|
+
adapter.apply([s.sql for s in plan1.steps])
|
|
289
|
+
adapter.record_deploy(ref1, [])
|
|
290
|
+
|
|
291
|
+
# widen code VARCHAR(10) -> VARCHAR(20): a type change, must surface as a MODIFY step
|
|
292
|
+
(repo_root / "kunde.tbl").write_text(
|
|
293
|
+
"CREATE TABLE IF NOT EXISTS kunde (id INTEGER, code VARCHAR(20));", encoding="utf-8"
|
|
294
|
+
)
|
|
295
|
+
ref2 = _commit(repo_root, "v2")
|
|
296
|
+
plan2 = build_plan(repo, adapter, from_ref=ref1, to_ref=ref2,
|
|
297
|
+
target="sqlite", dialect="sqlite")
|
|
298
|
+
mod = [s for s in plan2.steps if "modify column" in s.title]
|
|
299
|
+
assert len(mod) == 1
|
|
300
|
+
assert mod[0].severity is Severity.DESTRUCTIVE # never auto-applied
|
|
301
|
+
assert "code" in mod[0].title
|
|
302
|
+
assert "→" in mod[0].note and "10" in mod[0].note and "20" in mod[0].note
|
|
303
|
+
adapter.dispose()
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def test_type_change_no_false_positive_when_unchanged(tmp_path: Path):
|
|
307
|
+
repo_root = tmp_path / "db"
|
|
308
|
+
repo_root.mkdir()
|
|
309
|
+
_init_repo(repo_root)
|
|
310
|
+
(repo_root / "kunde.tbl").write_text(
|
|
311
|
+
"CREATE TABLE IF NOT EXISTS kunde (id INTEGER, code VARCHAR(10));", encoding="utf-8"
|
|
312
|
+
)
|
|
313
|
+
ref1 = _commit(repo_root, "v1")
|
|
314
|
+
db = tmp_path / "stable.db"
|
|
315
|
+
adapter = SqliteAdapter(ConnectionConfig(environment="sqlite", service=str(db)))
|
|
316
|
+
repo = Repo(repo_root)
|
|
317
|
+
plan1 = build_plan(repo, adapter, from_ref=None, to_ref=ref1,
|
|
318
|
+
target="sqlite", dialect="sqlite")
|
|
319
|
+
adapter.apply([s.sql for s in plan1.steps])
|
|
320
|
+
adapter.record_deploy(ref1, [])
|
|
321
|
+
# re-plan the same schema against the live DB → no phantom modify step
|
|
322
|
+
plan2 = build_plan(repo, adapter, from_ref=None, to_ref=ref1,
|
|
323
|
+
target="sqlite", dialect="sqlite")
|
|
324
|
+
assert not [s for s in plan2.steps if "modify column" in s.title]
|
|
325
|
+
adapter.dispose()
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def test_plan_against_worktree_sees_uncommitted_and_untracked(tmp_path: Path):
|
|
329
|
+
from dbly.repo import WORKTREE
|
|
330
|
+
repo_root = tmp_path / "db"
|
|
331
|
+
repo_root.mkdir()
|
|
332
|
+
_init_repo(repo_root)
|
|
333
|
+
(repo_root / "kunde.tbl").write_text(
|
|
334
|
+
"CREATE TABLE IF NOT EXISTS kunde (id INTEGER, name TEXT);", encoding="utf-8"
|
|
335
|
+
)
|
|
336
|
+
ref1 = _commit(repo_root, "v1")
|
|
337
|
+
|
|
338
|
+
db = tmp_path / "wt.db"
|
|
339
|
+
adapter = SqliteAdapter(ConnectionConfig(environment="sqlite", service=str(db)))
|
|
340
|
+
repo = Repo(repo_root)
|
|
341
|
+
adapter.apply([s.sql for s in build_plan(
|
|
342
|
+
repo, adapter, from_ref=None, to_ref=ref1, target="sqlite", dialect="sqlite").steps])
|
|
343
|
+
adapter.record_deploy(ref1, [])
|
|
344
|
+
|
|
345
|
+
# edit an existing file (uncommitted) + add a brand-new untracked object file
|
|
346
|
+
(repo_root / "kunde.tbl").write_text(
|
|
347
|
+
"CREATE TABLE IF NOT EXISTS kunde (id INTEGER, name TEXT, email TEXT);", encoding="utf-8"
|
|
348
|
+
)
|
|
349
|
+
(repo_root / "v_kunde.vw").write_text(
|
|
350
|
+
"CREATE VIEW v_kunde AS SELECT id FROM kunde;", encoding="utf-8"
|
|
351
|
+
)
|
|
352
|
+
plan = build_plan(repo, adapter, from_ref=ref1, to_ref=WORKTREE,
|
|
353
|
+
target="sqlite", dialect="sqlite")
|
|
354
|
+
titles = " ".join(s.title for s in plan.steps)
|
|
355
|
+
assert "email" in titles # uncommitted column edit is planned
|
|
356
|
+
assert any(s.kind.value == "view" for s in plan.steps) # untracked new object is planned
|
|
357
|
+
adapter.dispose()
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def test_repo_ref_names_decorates_tag_and_branch(tmp_path: Path):
|
|
361
|
+
repo_root = tmp_path / "db"
|
|
362
|
+
repo_root.mkdir()
|
|
363
|
+
_init_repo(repo_root)
|
|
364
|
+
(repo_root / "kunde.tbl").write_text("CREATE TABLE kunde (id INTEGER);", encoding="utf-8")
|
|
365
|
+
sha = _commit(repo_root, "v1")
|
|
366
|
+
_git(repo_root, "tag", "v0.1")
|
|
367
|
+
repo = Repo(repo_root)
|
|
368
|
+
names = repo.ref_names(sha)
|
|
369
|
+
assert "v0.1" in names
|
|
370
|
+
assert any(n in ("main", "master") for n in names) # the current branch points here too
|
|
@@ -124,3 +124,39 @@ def test_plan_yaml_roundtrip():
|
|
|
124
124
|
assert back.to_ref == "HEAD"
|
|
125
125
|
assert back.steps[0].severity is Severity.ADDITIVE
|
|
126
126
|
assert back.warnings == ["something"]
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def test_canonical_type_normalizes_numeric_scale_and_collation():
|
|
130
|
+
# omitted scale ≡ scale 0 (Oracle NUMBER(p) == NUMBER(p,0)); case/space-insensitive
|
|
131
|
+
assert parsing.canonical_type("NUMBER(10)") == parsing.canonical_type("number(10, 0)")
|
|
132
|
+
# a genuine precision change is preserved (bug: NUMBER → NUMBER(10) was missed)
|
|
133
|
+
assert parsing.types_differ("NUMBER", "NUMBER(10)")
|
|
134
|
+
assert parsing.types_differ("NUMBER(10)", "NUMBER(12)")
|
|
135
|
+
# SQL Server appends a COLLATE clause on reflected string types — not a real change
|
|
136
|
+
assert not parsing.types_differ("NVARCHAR(100)", 'NVARCHAR(100) COLLATE "SQL_Latin1_CI_AS"')
|
|
137
|
+
assert not parsing.types_differ("VARCHAR2(200)", "varchar2(200)")
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def test_render_plan_shows_step_note(capsys):
|
|
141
|
+
from rich.console import Console
|
|
142
|
+
plan = Plan(target="t", from_ref="abc", to_ref="HEAD")
|
|
143
|
+
plan.steps.append(
|
|
144
|
+
Step(
|
|
145
|
+
title="add NOT NULL column app.kunde.flag",
|
|
146
|
+
object_id=None,
|
|
147
|
+
kind=ObjectKind.TABLE,
|
|
148
|
+
severity=Severity.DESTRUCTIVE,
|
|
149
|
+
sql="ALTER TABLE app.kunde ADD flag NUMBER(1) NOT NULL;",
|
|
150
|
+
note="NOT NULL without default on existing table — unsafe",
|
|
151
|
+
)
|
|
152
|
+
)
|
|
153
|
+
report.render_plan(plan, Console(force_terminal=False, width=200))
|
|
154
|
+
out = capsys.readouterr().out
|
|
155
|
+
assert "NOT NULL without default" in out # the hint is surfaced, not just stored
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def test_decorate_ref_git_style():
|
|
159
|
+
assert report._decorate_ref(None, None) == "∅"
|
|
160
|
+
assert report._decorate_ref("WORKTREE", None) == "working tree"
|
|
161
|
+
assert report._decorate_ref("9ff5e440abc", {"9ff5e440abc": "v0.1, main"}) == "v0.1, main (9ff5e440)"
|
|
162
|
+
assert report._decorate_ref("deadbeef", None) == "deadbeef" # no names → raw sha
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|