dbly 0.1.0__tar.gz → 0.2.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.1.0 → dbly-0.2.0}/PKG-INFO +8 -1
- {dbly-0.1.0 → dbly-0.2.0}/README.md +7 -0
- {dbly-0.1.0 → dbly-0.2.0}/pyproject.toml +1 -1
- {dbly-0.1.0 → dbly-0.2.0}/src/dbly/__init__.py +1 -1
- {dbly-0.1.0 → dbly-0.2.0}/src/dbly/adapters/base.py +15 -1
- {dbly-0.1.0 → dbly-0.2.0}/src/dbly/cli.py +15 -4
- {dbly-0.1.0 → dbly-0.2.0}/src/dbly/model.py +15 -0
- {dbly-0.1.0 → dbly-0.2.0}/src/dbly/parsing.py +18 -0
- {dbly-0.1.0 → dbly-0.2.0}/src/dbly/planner.py +25 -0
- {dbly-0.1.0 → dbly-0.2.0}/src/dbly/repo.py +25 -9
- {dbly-0.1.0 → dbly-0.2.0}/src/dbly/report.py +53 -15
- {dbly-0.1.0 → dbly-0.2.0}/tests/test_integration.py +93 -0
- {dbly-0.1.0 → dbly-0.2.0}/uv.lock +1 -1
- {dbly-0.1.0 → dbly-0.2.0}/.github/workflows/ci.yml +0 -0
- {dbly-0.1.0 → dbly-0.2.0}/.github/workflows/publish.yml +0 -0
- {dbly-0.1.0 → dbly-0.2.0}/.gitignore +0 -0
- {dbly-0.1.0 → dbly-0.2.0}/docs/dbly_head.png +0 -0
- {dbly-0.1.0 → dbly-0.2.0}/examples/ci/README.md +0 -0
- {dbly-0.1.0 → dbly-0.2.0}/examples/ci/bitbucket-pipelines.yml +0 -0
- {dbly-0.1.0 → dbly-0.2.0}/examples/ci/github-actions.yml +0 -0
- {dbly-0.1.0 → dbly-0.2.0}/src/dbly/adapters/__init__.py +0 -0
- {dbly-0.1.0 → dbly-0.2.0}/src/dbly/adapters/mssql.py +0 -0
- {dbly-0.1.0 → dbly-0.2.0}/src/dbly/adapters/oracle.py +0 -0
- {dbly-0.1.0 → dbly-0.2.0}/src/dbly/adapters/postgres.py +0 -0
- {dbly-0.1.0 → dbly-0.2.0}/src/dbly/adapters/sqlite.py +0 -0
- {dbly-0.1.0 → dbly-0.2.0}/src/dbly/config.py +0 -0
- {dbly-0.1.0 → dbly-0.2.0}/src/dbly/drift.py +0 -0
- {dbly-0.1.0 → dbly-0.2.0}/src/dbly/engine.py +0 -0
- {dbly-0.1.0 → dbly-0.2.0}/src/dbly/hooks.py +0 -0
- {dbly-0.1.0 → dbly-0.2.0}/src/dbly/initializer.py +0 -0
- {dbly-0.1.0 → dbly-0.2.0}/src/dbly/py.typed +0 -0
- {dbly-0.1.0 → dbly-0.2.0}/tests/test_mssql.py +0 -0
- {dbly-0.1.0 → dbly-0.2.0}/tests/test_mssql_e2e.py +0 -0
- {dbly-0.1.0 → dbly-0.2.0}/tests/test_oracle.py +0 -0
- {dbly-0.1.0 → dbly-0.2.0}/tests/test_oracle_e2e.py +0 -0
- {dbly-0.1.0 → dbly-0.2.0}/tests/test_parsing.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: dbly
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.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
|
|
@@ -81,10 +81,17 @@ db/
|
|
|
81
81
|
v_open_orders.vw
|
|
82
82
|
grants.sql
|
|
83
83
|
init/ # optional: privileged greenfield groundwork (CREATE DATABASE/ROLE…)
|
|
84
|
+
migrations/ # optional: ordered, run-once ALTERs for renames / data backfills
|
|
84
85
|
hooks/pre/ hooks/post/ # optional: .sql or .py hooks (e.g. ArcGIS/ArcPy steps)
|
|
85
86
|
.dbignore # files in the repo that should not be deployed
|
|
86
87
|
```
|
|
87
88
|
|
|
89
|
+
Most changes are declarative — edit the object file, dbly figures out the additive diff. For
|
|
90
|
+
changes the diff can't do safely (renaming a column, moving data), drop an ordered script in
|
|
91
|
+
`migrations/` (`0001_…sql`); it runs exactly once, is recorded in the ledger, and the table
|
|
92
|
+
it touches defers to it for that deploy. On a fresh database, migrations are *baselined*
|
|
93
|
+
(recorded, not run) since the object files already describe the end state.
|
|
94
|
+
|
|
88
95
|
## Connect
|
|
89
96
|
|
|
90
97
|
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):
|
|
@@ -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
|
|
@@ -95,6 +95,20 @@ class Adapter(abc.ABC):
|
|
|
95
95
|
@abc.abstractmethod
|
|
96
96
|
def record_deploy(self, ref: str, migration_ids: list[str]) -> None: ...
|
|
97
97
|
|
|
98
|
+
# --- explicit migrations (run-once, ledger-tracked) --------------------------------
|
|
99
|
+
def applied_migrations(self) -> set[str]:
|
|
100
|
+
"""Migration ids already recorded in the ledger (standard SQL, all engines)."""
|
|
101
|
+
self.ensure_state_table()
|
|
102
|
+
with self.engine.connect() as conn:
|
|
103
|
+
rows = conn.execute(
|
|
104
|
+
text("SELECT DISTINCT migration_id FROM dbly_state WHERE migration_id IS NOT NULL")
|
|
105
|
+
)
|
|
106
|
+
return {r[0] for r in rows}
|
|
107
|
+
|
|
108
|
+
def record_migration(self, ref: str, migration_id: str) -> None:
|
|
109
|
+
"""Mark a migration applied (reuses the per-engine record_deploy insert)."""
|
|
110
|
+
self.record_deploy(ref, [migration_id])
|
|
111
|
+
|
|
98
112
|
# --- pure SQL builders (no connection — used by `plan --sql` export) ----------------
|
|
99
113
|
@abc.abstractmethod
|
|
100
114
|
def state_table_ddl(self) -> str:
|
|
@@ -137,7 +137,7 @@ def apply(
|
|
|
137
137
|
s.sql for s in plan_obj.steps
|
|
138
138
|
if allow_destructive or s.severity is not Severity.DESTRUCTIVE
|
|
139
139
|
]
|
|
140
|
-
if not statements:
|
|
140
|
+
if not statements and not plan_obj.migrations and not plan_obj.baselined:
|
|
141
141
|
console.print("[green]nothing to apply.[/green]")
|
|
142
142
|
return
|
|
143
143
|
|
|
@@ -147,13 +147,24 @@ def apply(
|
|
|
147
147
|
try:
|
|
148
148
|
_run_hooks(repo, "pre", py_interpreter)
|
|
149
149
|
adapter.ensure_state_table()
|
|
150
|
-
|
|
150
|
+
# baseline (bootstrap): record migrations as applied without running them
|
|
151
|
+
for mid in plan_obj.baselined:
|
|
152
|
+
adapter.record_migration(plan_obj.to_ref, mid)
|
|
153
|
+
# explicit migrations run first — they reshape the schema before reconciliation
|
|
154
|
+
for m in plan_obj.migrations:
|
|
155
|
+
adapter.run_init_script(m.sql) # engine-aware multi-statement / PL-SQL runner
|
|
156
|
+
adapter.record_migration(plan_obj.to_ref, m.id)
|
|
157
|
+
console.print(f"[magenta]migration[/magenta] applied {m.id}")
|
|
158
|
+
if statements:
|
|
159
|
+
adapter.apply(statements)
|
|
151
160
|
adapter.record_deploy(plan_obj.to_ref, migration_ids=[])
|
|
152
161
|
_run_hooks(repo, "post", py_interpreter)
|
|
153
162
|
finally:
|
|
154
163
|
adapter.dispose()
|
|
155
|
-
console.print(
|
|
156
|
-
|
|
164
|
+
console.print(
|
|
165
|
+
f"[green]applied[/green] {len(statements)} step(s), "
|
|
166
|
+
f"{len(plan_obj.migrations)} migration(s); deployed ref → {plan_obj.to_ref}"
|
|
167
|
+
)
|
|
157
168
|
|
|
158
169
|
|
|
159
170
|
@app.command()
|
|
@@ -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
|
|
|
@@ -121,6 +121,24 @@ def parse_file(
|
|
|
121
121
|
return objects
|
|
122
122
|
|
|
123
123
|
|
|
124
|
+
def referenced_tables(sql: str, *, dialect: str | None = None) -> set[str]:
|
|
125
|
+
"""Lower-cased table names referenced anywhere in a SQL script (best effort).
|
|
126
|
+
|
|
127
|
+
Used to detect which tables a pending migration touches, so the additive diff defers to
|
|
128
|
+
the migration for those tables. Returns an empty set if sqlglot cannot parse the script.
|
|
129
|
+
"""
|
|
130
|
+
names: set[str] = set()
|
|
131
|
+
try:
|
|
132
|
+
for stmt in sqlglot.parse(sql, read=dialect):
|
|
133
|
+
if stmt is None:
|
|
134
|
+
continue
|
|
135
|
+
for tbl in stmt.find_all(exp.Table):
|
|
136
|
+
names.add(tbl.name.lower())
|
|
137
|
+
except Exception: # noqa: BLE001 — procedural/odd SQL; suppression is best-effort
|
|
138
|
+
pass
|
|
139
|
+
return names
|
|
140
|
+
|
|
141
|
+
|
|
124
142
|
def canonical_hash(sql: str | None, *, dialect: str | None = None) -> str | None:
|
|
125
143
|
"""A formatting-insensitive hash of a definition, for advisory drift detection.
|
|
126
144
|
|
|
@@ -12,6 +12,7 @@ from dbly import parsing
|
|
|
12
12
|
from dbly.adapters.base import Adapter
|
|
13
13
|
from dbly.model import (
|
|
14
14
|
ChangeType,
|
|
15
|
+
Migration,
|
|
15
16
|
ObjectKind,
|
|
16
17
|
ParsedObject,
|
|
17
18
|
Plan,
|
|
@@ -31,6 +32,19 @@ def build_plan(
|
|
|
31
32
|
dialect: str | None,
|
|
32
33
|
) -> Plan:
|
|
33
34
|
plan = Plan(target=target, from_ref=from_ref, to_ref=to_ref)
|
|
35
|
+
|
|
36
|
+
# Explicit migrations (run-once). On bootstrap (no baseline) the canonical objects
|
|
37
|
+
# already produce the end state, so pending migrations are *baselined* (recorded, not
|
|
38
|
+
# run); on upgrade they run — before the object steps — to reshape the schema.
|
|
39
|
+
applied = adapter.applied_migrations()
|
|
40
|
+
pending = [(mid, p) for mid, p in repo.migration_files(to_ref) if mid not in applied]
|
|
41
|
+
if from_ref is None:
|
|
42
|
+
plan.baselined = [mid for mid, _ in pending]
|
|
43
|
+
else:
|
|
44
|
+
plan.migrations = [
|
|
45
|
+
Migration(mid, repo.read_at(to_ref, p), p) for mid, p in pending
|
|
46
|
+
]
|
|
47
|
+
|
|
34
48
|
changes = repo.changed_files(from_ref, to_ref)
|
|
35
49
|
|
|
36
50
|
# Bucket by kind so the plan is emitted in dependency-safe order regardless of file
|
|
@@ -55,9 +69,20 @@ def build_plan(
|
|
|
55
69
|
else:
|
|
56
70
|
replaceable.append(obj)
|
|
57
71
|
|
|
72
|
+
# Tables touched by a pending migration are migration-managed for this deploy — the
|
|
73
|
+
# migration reshapes them at apply time, so the (plan-time) additive diff must defer.
|
|
74
|
+
migration_tables: set[str] = set()
|
|
75
|
+
for m in plan.migrations:
|
|
76
|
+
migration_tables |= parsing.referenced_tables(m.sql, dialect=dialect)
|
|
77
|
+
|
|
58
78
|
for obj in sequences:
|
|
59
79
|
_plan_create_if_missing(adapter, plan, obj)
|
|
60
80
|
for obj in tables:
|
|
81
|
+
if obj.id.name.lower() in migration_tables:
|
|
82
|
+
plan.warnings.append(
|
|
83
|
+
f"{obj.id}: managed by a pending migration — additive diff skipped this deploy"
|
|
84
|
+
)
|
|
85
|
+
continue
|
|
61
86
|
_plan_table(adapter, plan, obj, dialect)
|
|
62
87
|
for obj in indexes:
|
|
63
88
|
_plan_create_if_missing(adapter, plan, obj)
|
|
@@ -16,6 +16,7 @@ import pathspec
|
|
|
16
16
|
from dbly.model import ChangeType
|
|
17
17
|
|
|
18
18
|
_SQL_SUFFIXES = {".sql", ".tbl", ".vw", ".prc", ".fnc", ".pkg", ".trg", ".typ", ".ddl"}
|
|
19
|
+
MIGRATIONS_DIR = "migrations" # run-once scripts — not declarative objects
|
|
19
20
|
|
|
20
21
|
|
|
21
22
|
@dataclass(slots=True)
|
|
@@ -52,6 +53,14 @@ class Repo:
|
|
|
52
53
|
def _is_sql(rel: Path) -> bool:
|
|
53
54
|
return rel.suffix.lower() in _SQL_SUFFIXES
|
|
54
55
|
|
|
56
|
+
@staticmethod
|
|
57
|
+
def _is_migration(rel: Path) -> bool:
|
|
58
|
+
return len(rel.parts) > 0 and rel.parts[0] == MIGRATIONS_DIR
|
|
59
|
+
|
|
60
|
+
def _is_object(self, rel: Path) -> bool:
|
|
61
|
+
"""A deployable declarative object file (SQL, not a migration, not ignored)."""
|
|
62
|
+
return self._is_sql(rel) and not self._is_migration(rel) and not self.is_ignored(rel)
|
|
63
|
+
|
|
55
64
|
def changed_files(self, from_ref: str | None, to_ref: str) -> list[FileChange]:
|
|
56
65
|
"""Files changed between two refs (or the full tree at ``to_ref`` for bootstrap)."""
|
|
57
66
|
if from_ref is None:
|
|
@@ -60,14 +69,21 @@ class Repo:
|
|
|
60
69
|
return self._parse_name_status(raw)
|
|
61
70
|
|
|
62
71
|
def list_files(self, ref: str) -> list[Path]:
|
|
63
|
-
"""All deployable
|
|
72
|
+
"""All deployable declarative object files present at ``ref`` (excludes migrations)."""
|
|
73
|
+
raw = self._git("ls-tree", "-r", "--name-only", "-z", ref)
|
|
74
|
+
return [Path(n) for n in filter(None, raw.split("\0")) if self._is_object(Path(n))]
|
|
75
|
+
|
|
76
|
+
def migration_files(self, ref: str) -> list[tuple[str, Path]]:
|
|
77
|
+
"""Ordered (id, path) of migration scripts under ``migrations/`` at ``ref``.
|
|
78
|
+
|
|
79
|
+
Id is the filename; order is lexicographic, so prefix files ``0001_…`` / a timestamp.
|
|
80
|
+
"""
|
|
64
81
|
raw = self._git("ls-tree", "-r", "--name-only", "-z", ref)
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
return result
|
|
82
|
+
out = [
|
|
83
|
+
Path(n) for n in filter(None, raw.split("\0"))
|
|
84
|
+
if self._is_migration(Path(n)) and Path(n).suffix.lower() == ".sql"
|
|
85
|
+
]
|
|
86
|
+
return [(p.name, p) for p in sorted(out, key=lambda p: p.as_posix())]
|
|
71
87
|
|
|
72
88
|
def _parse_name_status(self, raw: str) -> list[FileChange]:
|
|
73
89
|
tokens = [t for t in raw.split("\0") if t]
|
|
@@ -79,12 +95,12 @@ class Repo:
|
|
|
79
95
|
if code == "R": # rename: status, old, new
|
|
80
96
|
new = Path(tokens[i + 2])
|
|
81
97
|
i += 3
|
|
82
|
-
if self.
|
|
98
|
+
if self._is_object(new):
|
|
83
99
|
changes.append(FileChange(new, ChangeType.MODIFIED))
|
|
84
100
|
continue
|
|
85
101
|
rel = Path(tokens[i + 1])
|
|
86
102
|
i += 2
|
|
87
|
-
if not self.
|
|
103
|
+
if not self._is_object(rel):
|
|
88
104
|
continue
|
|
89
105
|
mapping = {"A": ChangeType.ADDED, "M": ChangeType.MODIFIED,
|
|
90
106
|
"D": ChangeType.DELETED}
|
|
@@ -11,7 +11,7 @@ import yaml
|
|
|
11
11
|
from rich.console import Console
|
|
12
12
|
from rich.table import Table
|
|
13
13
|
|
|
14
|
-
from dbly.model import ObjectId, ObjectKind, Plan, Severity, Step
|
|
14
|
+
from dbly.model import Migration, ObjectId, ObjectKind, Plan, Severity, Step
|
|
15
15
|
|
|
16
16
|
|
|
17
17
|
def render_plan(plan: Plan, console: Console) -> None:
|
|
@@ -19,24 +19,32 @@ def render_plan(plan: Plan, console: Console) -> None:
|
|
|
19
19
|
f"[bold]Plan[/bold] for [cyan]{plan.target}[/cyan] "
|
|
20
20
|
f"{plan.from_ref or '∅'} → {plan.to_ref}"
|
|
21
21
|
)
|
|
22
|
-
if not plan.steps and not plan.warnings:
|
|
22
|
+
if not plan.steps and not plan.warnings and not plan.migrations and not plan.baselined:
|
|
23
23
|
console.print("[green]nothing to do — target is up to date[/green]")
|
|
24
24
|
return
|
|
25
25
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
for i, step in enumerate(plan.steps, 1):
|
|
32
|
-
sev_style = "red" if step.severity is Severity.DESTRUCTIVE else "green"
|
|
33
|
-
table.add_row(
|
|
34
|
-
str(i),
|
|
35
|
-
f"[{sev_style}]{step.severity.value}[/{sev_style}]",
|
|
36
|
-
step.kind.value,
|
|
37
|
-
step.title,
|
|
26
|
+
for m in plan.migrations:
|
|
27
|
+
console.print(f"[magenta]migration[/magenta] run {m.id}")
|
|
28
|
+
if plan.baselined:
|
|
29
|
+
console.print(
|
|
30
|
+
f"[dim]migration baseline (recorded, not run): {', '.join(plan.baselined)}[/dim]"
|
|
38
31
|
)
|
|
39
|
-
|
|
32
|
+
|
|
33
|
+
if plan.steps:
|
|
34
|
+
table = Table(show_header=True, header_style="bold")
|
|
35
|
+
table.add_column("#", justify="right", style="dim")
|
|
36
|
+
table.add_column("severity")
|
|
37
|
+
table.add_column("kind")
|
|
38
|
+
table.add_column("step")
|
|
39
|
+
for i, step in enumerate(plan.steps, 1):
|
|
40
|
+
sev_style = "red" if step.severity is Severity.DESTRUCTIVE else "green"
|
|
41
|
+
table.add_row(
|
|
42
|
+
str(i),
|
|
43
|
+
f"[{sev_style}]{step.severity.value}[/{sev_style}]",
|
|
44
|
+
step.kind.value,
|
|
45
|
+
step.title,
|
|
46
|
+
)
|
|
47
|
+
console.print(table)
|
|
40
48
|
|
|
41
49
|
if plan.warnings:
|
|
42
50
|
console.print("\n[bold yellow]Warnings[/bold yellow]")
|
|
@@ -73,6 +81,26 @@ def plan_to_sql(plan: Plan, *, state_ddl: str | None = None, record_sql: str | N
|
|
|
73
81
|
if state_ddl:
|
|
74
82
|
out += ["-- ledger table (no-op if it already exists)", state_ddl, ""]
|
|
75
83
|
|
|
84
|
+
safe_ref = plan.to_ref.replace("'", "''")
|
|
85
|
+
for m in plan.migrations:
|
|
86
|
+
mid = m.id.replace("'", "''")
|
|
87
|
+
out.append(f"-- migration (run-once): {m.id}")
|
|
88
|
+
body = m.sql.rstrip()
|
|
89
|
+
out.append(body if body.endswith(";") else body + ";")
|
|
90
|
+
out.append(
|
|
91
|
+
"INSERT INTO dbly_state (deployed_sha, migration_id) "
|
|
92
|
+
f"VALUES ('{safe_ref}', '{mid}');"
|
|
93
|
+
)
|
|
94
|
+
out.append("")
|
|
95
|
+
for mid in plan.baselined:
|
|
96
|
+
safe_mid = mid.replace("'", "''")
|
|
97
|
+
out.append(f"-- migration baseline (recorded, not run): {mid}")
|
|
98
|
+
out.append(
|
|
99
|
+
"INSERT INTO dbly_state (deployed_sha, migration_id) "
|
|
100
|
+
f"VALUES ('{safe_ref}', '{safe_mid}');"
|
|
101
|
+
)
|
|
102
|
+
out.append("")
|
|
103
|
+
|
|
76
104
|
for i, step in enumerate(plan.steps, 1):
|
|
77
105
|
mark = " !! DESTRUCTIVE" if step.severity is Severity.DESTRUCTIVE else ""
|
|
78
106
|
out.append(f"-- [{i}] {step.severity.value}{mark}: {step.title}")
|
|
@@ -95,6 +123,11 @@ def plan_to_yaml(plan: Plan) -> str:
|
|
|
95
123
|
"from_ref": plan.from_ref,
|
|
96
124
|
"to_ref": plan.to_ref,
|
|
97
125
|
"warnings": plan.warnings,
|
|
126
|
+
"migrations": [
|
|
127
|
+
{"id": m.id, "source_file": str(m.source_file), "sql": m.sql}
|
|
128
|
+
for m in plan.migrations
|
|
129
|
+
],
|
|
130
|
+
"baselined": plan.baselined,
|
|
98
131
|
"steps": [
|
|
99
132
|
{
|
|
100
133
|
"title": s.title,
|
|
@@ -115,6 +148,11 @@ def plan_from_yaml(text: str) -> Plan:
|
|
|
115
148
|
doc = yaml.safe_load(text)
|
|
116
149
|
plan = Plan(target=doc["target"], from_ref=doc.get("from_ref"), to_ref=doc["to_ref"])
|
|
117
150
|
plan.warnings = list(doc.get("warnings") or [])
|
|
151
|
+
plan.baselined = list(doc.get("baselined") or [])
|
|
152
|
+
plan.migrations = [
|
|
153
|
+
Migration(m["id"], m["sql"], Path(m["source_file"]))
|
|
154
|
+
for m in (doc.get("migrations") or [])
|
|
155
|
+
]
|
|
118
156
|
for s in doc.get("steps") or []:
|
|
119
157
|
obj = s.get("object")
|
|
120
158
|
oid = None
|
|
@@ -152,6 +152,99 @@ def test_check_drift_against_live_db(tmp_path: Path):
|
|
|
152
152
|
adapter.dispose()
|
|
153
153
|
|
|
154
154
|
|
|
155
|
+
def _apply(adapter, plan, to_ref):
|
|
156
|
+
"""Mirror cli.apply's order: baseline records, migrations run, then object steps."""
|
|
157
|
+
adapter.ensure_state_table()
|
|
158
|
+
for mid in plan.baselined:
|
|
159
|
+
adapter.record_migration(to_ref, mid)
|
|
160
|
+
for m in plan.migrations:
|
|
161
|
+
adapter.run_init_script(m.sql)
|
|
162
|
+
adapter.record_migration(to_ref, m.id)
|
|
163
|
+
if plan.steps:
|
|
164
|
+
adapter.apply([s.sql for s in plan.steps])
|
|
165
|
+
adapter.record_deploy(to_ref, [])
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def test_explicit_migration_runs_on_upgrade_once(tmp_path: Path):
|
|
169
|
+
repo_root = tmp_path / "db"
|
|
170
|
+
repo_root.mkdir()
|
|
171
|
+
_init_repo(repo_root)
|
|
172
|
+
(repo_root / "kunde.tbl").write_text(
|
|
173
|
+
"CREATE TABLE IF NOT EXISTS kunde (id INTEGER, name TEXT);", encoding="utf-8"
|
|
174
|
+
)
|
|
175
|
+
ref1 = _commit(repo_root, "v1")
|
|
176
|
+
|
|
177
|
+
db = tmp_path / "mig.db"
|
|
178
|
+
adapter = SqliteAdapter(ConnectionConfig(environment="sqlite", service=str(db)))
|
|
179
|
+
repo = Repo(repo_root)
|
|
180
|
+
_apply(adapter, build_plan(repo, adapter, from_ref=None, to_ref=ref1,
|
|
181
|
+
target="sqlite", dialect="sqlite"), ref1)
|
|
182
|
+
|
|
183
|
+
# v2: rename name -> full_name via an explicit migration; canonical table reflects it
|
|
184
|
+
(repo_root / "kunde.tbl").write_text(
|
|
185
|
+
"CREATE TABLE IF NOT EXISTS kunde (id INTEGER, full_name TEXT);", encoding="utf-8"
|
|
186
|
+
)
|
|
187
|
+
(repo_root / "migrations").mkdir()
|
|
188
|
+
(repo_root / "migrations" / "0001_rename.sql").write_text(
|
|
189
|
+
"ALTER TABLE kunde RENAME COLUMN name TO full_name;", encoding="utf-8"
|
|
190
|
+
)
|
|
191
|
+
ref2 = _commit(repo_root, "v2")
|
|
192
|
+
|
|
193
|
+
plan2 = build_plan(repo, adapter, from_ref=ref1, to_ref=ref2, target="sqlite", dialect="sqlite")
|
|
194
|
+
assert [m.id for m in plan2.migrations] == ["0001_rename.sql"] # pending, will run
|
|
195
|
+
_apply(adapter, plan2, ref2)
|
|
196
|
+
cols = {c.name.lower() for c in adapter.get_columns(None, "kunde")}
|
|
197
|
+
assert "full_name" in cols and "name" not in cols # rename happened
|
|
198
|
+
assert "0001_rename.sql" in adapter.applied_migrations()
|
|
199
|
+
|
|
200
|
+
# re-plan: migration already applied → not pending again
|
|
201
|
+
plan3 = build_plan(repo, adapter, from_ref=ref1, to_ref=ref2, target="sqlite", dialect="sqlite")
|
|
202
|
+
assert plan3.migrations == []
|
|
203
|
+
adapter.dispose()
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def test_bootstrap_baselines_migrations_without_running(tmp_path: Path):
|
|
207
|
+
repo_root = tmp_path / "db"
|
|
208
|
+
repo_root.mkdir()
|
|
209
|
+
_init_repo(repo_root)
|
|
210
|
+
(repo_root / "kunde.tbl").write_text(
|
|
211
|
+
"CREATE TABLE IF NOT EXISTS kunde (id INTEGER, full_name TEXT);", encoding="utf-8"
|
|
212
|
+
)
|
|
213
|
+
(repo_root / "migrations").mkdir()
|
|
214
|
+
# a rename that would FAIL on a fresh DB (column `name` never existed)
|
|
215
|
+
(repo_root / "migrations" / "0001_rename.sql").write_text(
|
|
216
|
+
"ALTER TABLE kunde RENAME COLUMN name TO full_name;", encoding="utf-8"
|
|
217
|
+
)
|
|
218
|
+
ref = _commit(repo_root, "v1")
|
|
219
|
+
|
|
220
|
+
db = tmp_path / "fresh.db"
|
|
221
|
+
adapter = SqliteAdapter(ConnectionConfig(environment="sqlite", service=str(db)))
|
|
222
|
+
repo = Repo(repo_root)
|
|
223
|
+
plan = build_plan(repo, adapter, from_ref=None, to_ref=ref, target="sqlite", dialect="sqlite")
|
|
224
|
+
assert plan.baselined == ["0001_rename.sql"] and plan.migrations == []
|
|
225
|
+
_apply(adapter, plan, ref) # must NOT run the rename (would error) — only record it
|
|
226
|
+
|
|
227
|
+
cols = {c.name.lower() for c in adapter.get_columns(None, "kunde")}
|
|
228
|
+
assert cols == {"id", "full_name"} # built from canonical CREATE
|
|
229
|
+
assert "0001_rename.sql" in adapter.applied_migrations() # recorded as baseline
|
|
230
|
+
adapter.dispose()
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def test_migration_files_excluded_from_objects(tmp_path: Path):
|
|
234
|
+
repo_root = tmp_path / "db"
|
|
235
|
+
repo_root.mkdir()
|
|
236
|
+
_init_repo(repo_root)
|
|
237
|
+
(repo_root / "kunde.tbl").write_text("CREATE TABLE kunde (id INTEGER);", encoding="utf-8")
|
|
238
|
+
(repo_root / "migrations").mkdir()
|
|
239
|
+
(repo_root / "migrations" / "0001_x.sql").write_text("ALTER TABLE kunde ADD c TEXT;",
|
|
240
|
+
encoding="utf-8")
|
|
241
|
+
ref = _commit(repo_root, "v1")
|
|
242
|
+
repo = Repo(repo_root)
|
|
243
|
+
objs = [p.name for p in repo.list_files(ref)]
|
|
244
|
+
assert "kunde.tbl" in objs and "0001_x.sql" not in objs # migrations not objects
|
|
245
|
+
assert [mid for mid, _ in repo.migration_files(ref)] == ["0001_x.sql"]
|
|
246
|
+
|
|
247
|
+
|
|
155
248
|
def test_init_runs_ordered_multistatement_scripts(tmp_path: Path):
|
|
156
249
|
repo_root = tmp_path / "db"
|
|
157
250
|
(repo_root / "init").mkdir(parents=True)
|
|
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
|
|
File without changes
|
|
File without changes
|