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.
Files changed (36) hide show
  1. {dbly-0.1.0 → dbly-0.2.0}/PKG-INFO +8 -1
  2. {dbly-0.1.0 → dbly-0.2.0}/README.md +7 -0
  3. {dbly-0.1.0 → dbly-0.2.0}/pyproject.toml +1 -1
  4. {dbly-0.1.0 → dbly-0.2.0}/src/dbly/__init__.py +1 -1
  5. {dbly-0.1.0 → dbly-0.2.0}/src/dbly/adapters/base.py +15 -1
  6. {dbly-0.1.0 → dbly-0.2.0}/src/dbly/cli.py +15 -4
  7. {dbly-0.1.0 → dbly-0.2.0}/src/dbly/model.py +15 -0
  8. {dbly-0.1.0 → dbly-0.2.0}/src/dbly/parsing.py +18 -0
  9. {dbly-0.1.0 → dbly-0.2.0}/src/dbly/planner.py +25 -0
  10. {dbly-0.1.0 → dbly-0.2.0}/src/dbly/repo.py +25 -9
  11. {dbly-0.1.0 → dbly-0.2.0}/src/dbly/report.py +53 -15
  12. {dbly-0.1.0 → dbly-0.2.0}/tests/test_integration.py +93 -0
  13. {dbly-0.1.0 → dbly-0.2.0}/uv.lock +1 -1
  14. {dbly-0.1.0 → dbly-0.2.0}/.github/workflows/ci.yml +0 -0
  15. {dbly-0.1.0 → dbly-0.2.0}/.github/workflows/publish.yml +0 -0
  16. {dbly-0.1.0 → dbly-0.2.0}/.gitignore +0 -0
  17. {dbly-0.1.0 → dbly-0.2.0}/docs/dbly_head.png +0 -0
  18. {dbly-0.1.0 → dbly-0.2.0}/examples/ci/README.md +0 -0
  19. {dbly-0.1.0 → dbly-0.2.0}/examples/ci/bitbucket-pipelines.yml +0 -0
  20. {dbly-0.1.0 → dbly-0.2.0}/examples/ci/github-actions.yml +0 -0
  21. {dbly-0.1.0 → dbly-0.2.0}/src/dbly/adapters/__init__.py +0 -0
  22. {dbly-0.1.0 → dbly-0.2.0}/src/dbly/adapters/mssql.py +0 -0
  23. {dbly-0.1.0 → dbly-0.2.0}/src/dbly/adapters/oracle.py +0 -0
  24. {dbly-0.1.0 → dbly-0.2.0}/src/dbly/adapters/postgres.py +0 -0
  25. {dbly-0.1.0 → dbly-0.2.0}/src/dbly/adapters/sqlite.py +0 -0
  26. {dbly-0.1.0 → dbly-0.2.0}/src/dbly/config.py +0 -0
  27. {dbly-0.1.0 → dbly-0.2.0}/src/dbly/drift.py +0 -0
  28. {dbly-0.1.0 → dbly-0.2.0}/src/dbly/engine.py +0 -0
  29. {dbly-0.1.0 → dbly-0.2.0}/src/dbly/hooks.py +0 -0
  30. {dbly-0.1.0 → dbly-0.2.0}/src/dbly/initializer.py +0 -0
  31. {dbly-0.1.0 → dbly-0.2.0}/src/dbly/py.typed +0 -0
  32. {dbly-0.1.0 → dbly-0.2.0}/tests/test_mssql.py +0 -0
  33. {dbly-0.1.0 → dbly-0.2.0}/tests/test_mssql_e2e.py +0 -0
  34. {dbly-0.1.0 → dbly-0.2.0}/tests/test_oracle.py +0 -0
  35. {dbly-0.1.0 → dbly-0.2.0}/tests/test_oracle_e2e.py +0 -0
  36. {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.1.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):
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "dbly"
3
- version = "0.1.0"
3
+ version = "0.2.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" }
@@ -4,4 +4,4 @@ git (what changed) + sqlglot (what it is) + live introspection (what's really th
4
4
  See CONCEPT.md for the design rationale.
5
5
  """
6
6
 
7
- __version__ = "0.1.0"
7
+ __version__ = "0.2.0"
@@ -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
- adapter.apply(statements)
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(f"[green]applied[/green] {len(statements)} step(s); "
156
- f"deployed ref {plan_obj.to_ref}")
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 SQL files present at ``ref``."""
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
- result = []
66
- for name in filter(None, raw.split("\0")):
67
- rel = Path(name)
68
- if self._is_sql(rel) and not self.is_ignored(rel):
69
- result.append(rel)
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._is_sql(new) and not self.is_ignored(new):
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._is_sql(rel) or self.is_ignored(rel):
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
- table = Table(show_header=True, header_style="bold")
27
- table.add_column("#", justify="right", style="dim")
28
- table.add_column("severity")
29
- table.add_column("kind")
30
- table.add_column("step")
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
- console.print(table)
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)
@@ -129,7 +129,7 @@ wheels = [
129
129
 
130
130
  [[package]]
131
131
  name = "dbly"
132
- version = "0.1.0"
132
+ version = "0.2.0"
133
133
  source = { editable = "." }
134
134
  dependencies = [
135
135
  { name = "pathspec" },
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