dbly 0.4.0__tar.gz → 0.6.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 (42) hide show
  1. {dbly-0.4.0 → dbly-0.6.0}/CHANGELOG.md +38 -0
  2. {dbly-0.4.0 → dbly-0.6.0}/PKG-INFO +10 -1
  3. {dbly-0.4.0 → dbly-0.6.0}/README.md +9 -0
  4. {dbly-0.4.0 → dbly-0.6.0}/pyproject.toml +1 -1
  5. {dbly-0.4.0 → dbly-0.6.0}/src/dbly/__init__.py +1 -1
  6. {dbly-0.4.0 → dbly-0.6.0}/src/dbly/adapters/mssql.py +2 -1
  7. {dbly-0.4.0 → dbly-0.6.0}/src/dbly/adapters/oracle.py +9 -1
  8. {dbly-0.4.0 → dbly-0.6.0}/src/dbly/adapters/postgres.py +7 -4
  9. {dbly-0.4.0 → dbly-0.6.0}/src/dbly/adapters/sqlite.py +1 -1
  10. {dbly-0.4.0 → dbly-0.6.0}/src/dbly/cli.py +117 -28
  11. {dbly-0.4.0 → dbly-0.6.0}/src/dbly/drift.py +6 -2
  12. dbly-0.6.0/src/dbly/export.py +127 -0
  13. {dbly-0.4.0 → dbly-0.6.0}/src/dbly/model.py +3 -0
  14. {dbly-0.4.0 → dbly-0.6.0}/src/dbly/repo.py +20 -1
  15. {dbly-0.4.0 → dbly-0.6.0}/src/dbly/report.py +76 -0
  16. {dbly-0.4.0 → dbly-0.6.0}/tests/test_integration.py +64 -0
  17. {dbly-0.4.0 → dbly-0.6.0}/tests/test_parsing.py +18 -0
  18. {dbly-0.4.0 → dbly-0.6.0}/.github/FUNDING.yml +0 -0
  19. {dbly-0.4.0 → dbly-0.6.0}/.github/workflows/ci.yml +0 -0
  20. {dbly-0.4.0 → dbly-0.6.0}/.github/workflows/publish.yml +0 -0
  21. {dbly-0.4.0 → dbly-0.6.0}/.gitignore +0 -0
  22. {dbly-0.4.0 → dbly-0.6.0}/TODO.md +0 -0
  23. {dbly-0.4.0 → dbly-0.6.0}/docs/dbly_head.png +0 -0
  24. {dbly-0.4.0 → dbly-0.6.0}/examples/ci/README.md +0 -0
  25. {dbly-0.4.0 → dbly-0.6.0}/examples/ci/bitbucket-pipelines.yml +0 -0
  26. {dbly-0.4.0 → dbly-0.6.0}/examples/ci/github-actions.yml +0 -0
  27. {dbly-0.4.0 → dbly-0.6.0}/src/dbly/adapters/__init__.py +0 -0
  28. {dbly-0.4.0 → dbly-0.6.0}/src/dbly/adapters/base.py +0 -0
  29. {dbly-0.4.0 → dbly-0.6.0}/src/dbly/config.py +0 -0
  30. {dbly-0.4.0 → dbly-0.6.0}/src/dbly/engine.py +0 -0
  31. {dbly-0.4.0 → dbly-0.6.0}/src/dbly/hooks.py +0 -0
  32. {dbly-0.4.0 → dbly-0.6.0}/src/dbly/initializer.py +0 -0
  33. {dbly-0.4.0 → dbly-0.6.0}/src/dbly/parsing.py +0 -0
  34. {dbly-0.4.0 → dbly-0.6.0}/src/dbly/planner.py +0 -0
  35. {dbly-0.4.0 → dbly-0.6.0}/src/dbly/project.py +0 -0
  36. {dbly-0.4.0 → dbly-0.6.0}/src/dbly/py.typed +0 -0
  37. {dbly-0.4.0 → dbly-0.6.0}/tests/test_mssql.py +0 -0
  38. {dbly-0.4.0 → dbly-0.6.0}/tests/test_mssql_e2e.py +0 -0
  39. {dbly-0.4.0 → dbly-0.6.0}/tests/test_oracle.py +0 -0
  40. {dbly-0.4.0 → dbly-0.6.0}/tests/test_oracle_e2e.py +0 -0
  41. {dbly-0.4.0 → dbly-0.6.0}/tests/test_project.py +0 -0
  42. {dbly-0.4.0 → dbly-0.6.0}/uv.lock +0 -0
@@ -7,6 +7,44 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.6.0] — 2026-07-24
11
+
12
+ ### Added
13
+
14
+ - **`dbly export` — reverse direction.** Introspect a live database and emit its DDL as a SQL
15
+ script, optionally transpiled to another engine with `--dialect` (postgres | oracle |
16
+ sqlserver | sqlite). Tables and views are converted across dialects (tables rebuilt from the
17
+ live column set — constraints/indexes are not reconstructed, and a warning says so);
18
+ procedural objects (function/procedure/trigger/package/type) are emitted **verbatim** in the
19
+ source dialect. Scope with `--schema`; write to a file with `--out`. dbly's own `dbly_state`
20
+ ledger is never exported.
21
+
22
+ ## [0.5.0] — 2026-07-24
23
+
24
+ ### Added
25
+
26
+ - **Partial deploy / check.** `plan`, `apply` and `check` take `--schema NAME` (the folder
27
+ under `object_root`; repeatable, case-insensitive) and `--path SUBPATH` (any subtree under
28
+ `object_root`; repeatable) to scope the operation to part of the repo — e.g. deploy only
29
+ `bas/`. Both filters AND together; orphan reporting is scoped to the same selection.
30
+
31
+ ### Changed
32
+
33
+ - **Redesigned `check` output.** Drift is now grouped with an explicit direction —
34
+ *"Only in the repo — will be created on apply"* vs. *"Only in the database — not in the
35
+ repo"* — with per-group counts, a summary tally, and `+`/`−` markers for column drift
36
+ (`+` in the repo but missing from the DB, `−` in the DB but not the repo). The header shows
37
+ the ref (decorated) and the active scope.
38
+
39
+ ## [0.4.1] — 2026-07-23
40
+
41
+ ### Changed
42
+
43
+ - **Quiet by default.** sqlglot's per-statement fallback WARNINGs (it echoes any procedural /
44
+ engine-specific DDL it can't fully parse) and SQLAlchemy's "Did not recognize type" warnings
45
+ (e.g. PostGIS `geometry`) flooded the output on real repos. Both are now silenced; pass
46
+ `--debug` to see them again.
47
+
10
48
  ## [0.4.0] — 2026-07-23
11
49
 
12
50
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dbly
3
- Version: 0.4.0
3
+ Version: 0.6.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
@@ -146,8 +146,17 @@ dbly check --target prod.connection.properties
146
146
 
147
147
  # greenfield only: run privileged groundwork once under a superuser profile
148
148
  dbly init --init-target super.connection.properties
149
+
150
+ # reverse: export a live database as a DDL script — optionally in another engine's dialect
151
+ dbly export --target prod.connection.properties
152
+ dbly export --target prod.connection.properties --dialect postgres --out schema.sql
149
153
  ```
150
154
 
155
+ `export` is the inverse of deploy: tables and views transpile across dialects; procedural
156
+ objects (functions, procedures, triggers) are emitted verbatim in the source dialect. Scope it
157
+ with `--schema`. `plan`, `apply` and `check` likewise take `--schema NAME` / `--path SUBPATH`
158
+ to work on part of the repo (e.g. deploy only `bas/`).
159
+
151
160
  **Typical workflow:** edit your object files → commit → `dbly plan` to review → `dbly apply`.
152
161
 
153
162
  Deploying a *subset* of features is just choosing the git ref you deploy (a release tag or
@@ -107,8 +107,17 @@ dbly check --target prod.connection.properties
107
107
 
108
108
  # greenfield only: run privileged groundwork once under a superuser profile
109
109
  dbly init --init-target super.connection.properties
110
+
111
+ # reverse: export a live database as a DDL script — optionally in another engine's dialect
112
+ dbly export --target prod.connection.properties
113
+ dbly export --target prod.connection.properties --dialect postgres --out schema.sql
110
114
  ```
111
115
 
116
+ `export` is the inverse of deploy: tables and views transpile across dialects; procedural
117
+ objects (functions, procedures, triggers) are emitted verbatim in the source dialect. Scope it
118
+ with `--schema`. `plan`, `apply` and `check` likewise take `--schema NAME` / `--path SUBPATH`
119
+ to work on part of the repo (e.g. deploy only `bas/`).
120
+
112
121
  **Typical workflow:** edit your object files → commit → `dbly plan` to review → `dbly apply`.
113
122
 
114
123
  Deploying a *subset* of features is just choosing the git ref you deploy (a release tag or
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "dbly"
3
- version = "0.4.0"
3
+ version = "0.6.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.4.0"
7
+ __version__ = "0.6.0"
@@ -106,7 +106,8 @@ class MssqlAdapter(Adapter):
106
106
  if kind is None:
107
107
  continue
108
108
  h = canonical_hash(src, dialect="tsql") if kind in hashed else None
109
- obj = LiveObject(kind, ObjectId(schema, name), h)
109
+ # OBJECT_DEFINITION is NULL for tables (their DDL is rebuilt from columns).
110
+ obj = LiveObject(kind, ObjectId(schema, name), h, src)
110
111
  found[obj.key()] = obj
111
112
  for schema, name in conn.execute(idx):
112
113
  obj = LiveObject(ObjectKind.INDEX, ObjectId(schema, name))
@@ -155,6 +155,7 @@ class OracleAdapter(Adapter):
155
155
  "('FUNCTION','PROCEDURE','TRIGGER','PACKAGE','PACKAGE BODY','TYPE') "
156
156
  "ORDER BY name, type, line"
157
157
  )
158
+ views = text("SELECT view_name, text FROM all_views WHERE owner = USER")
158
159
  owner = self.default_schema # objects are filtered by owner = USER — carry it on the id
159
160
  found: dict[str, LiveObject] = {}
160
161
  src_text: dict[str, list[str]] = {}
@@ -171,9 +172,16 @@ class OracleAdapter(Adapter):
171
172
  continue
172
173
  key = LiveObject(kind, ObjectId(owner, name)).key()
173
174
  src_text.setdefault(key, []).append(line or "")
175
+ for name, vtext in conn.execute(views):
176
+ key = LiveObject(ObjectKind.VIEW, ObjectId(owner, name)).key()
177
+ if key in found and vtext is not None:
178
+ found[key].definition = f"CREATE VIEW {owner}.{name} AS\n{vtext}"
174
179
  for key, lines in src_text.items():
175
180
  if key in found:
176
- found[key].source_hash = canonical_hash("".join(lines), dialect="oracle")
181
+ body = "".join(lines)
182
+ found[key].source_hash = canonical_hash(body, dialect="oracle")
183
+ # all_source starts at "FUNCTION foo…"/"PROCEDURE…"; prefix CREATE OR REPLACE.
184
+ found[key].definition = "CREATE OR REPLACE " + body.lstrip()
177
185
  return list(found.values())
178
186
 
179
187
  def add_column_sql(self, table: ObjectId, col: Column) -> str:
@@ -126,16 +126,19 @@ class PostgresAdapter(Adapter):
126
126
  kind = self._RELKIND.get(relkind)
127
127
  if kind is None:
128
128
  continue
129
- h = canonical_hash(src, dialect="postgres") if kind is ObjectKind.VIEW else None
130
- obj = LiveObject(kind, ObjectId(schema, name), h)
129
+ is_view = kind is ObjectKind.VIEW
130
+ h = canonical_hash(src, dialect="postgres") if is_view else None
131
+ defn = f"CREATE VIEW {schema}.{name} AS\n{src}" if is_view and src else None
132
+ obj = LiveObject(kind, ObjectId(schema, name), h, defn)
131
133
  found[obj.key()] = obj
132
134
  for schema, name, prokind, src in conn.execute(routines):
133
135
  kind = ObjectKind.PROCEDURE if prokind == "p" else ObjectKind.FUNCTION
134
- obj = LiveObject(kind, ObjectId(schema, name), canonical_hash(src, dialect="postgres"))
136
+ obj = LiveObject(kind, ObjectId(schema, name),
137
+ canonical_hash(src, dialect="postgres"), src)
135
138
  found[obj.key()] = obj
136
139
  for schema, name, src in conn.execute(triggers):
137
140
  obj = LiveObject(ObjectKind.TRIGGER, ObjectId(schema, name),
138
- canonical_hash(src, dialect="postgres"))
141
+ canonical_hash(src, dialect="postgres"), src)
139
142
  found[obj.key()] = obj
140
143
  return list(found.values())
141
144
 
@@ -67,7 +67,7 @@ class SqliteAdapter(Adapter):
67
67
  continue
68
68
  h = (canonical_hash(sql, dialect="sqlite")
69
69
  if kind in (ObjectKind.VIEW, ObjectKind.TRIGGER) else None)
70
- obj = LiveObject(kind, ObjectId(None, name), h)
70
+ obj = LiveObject(kind, ObjectId(None, name), h, sql) # sqlite_master.sql = full DDL
71
71
  out[obj.key()] = obj
72
72
  return list(out.values())
73
73
 
@@ -8,13 +8,15 @@
8
8
  """
9
9
  from __future__ import annotations
10
10
 
11
+ import logging
12
+ import warnings
11
13
  from pathlib import Path
12
14
  from typing import Optional
13
15
 
14
16
  import typer
15
17
  from rich.console import Console
16
18
 
17
- from dbly import __version__, drift, hooks, initializer, report
19
+ from dbly import __version__, drift, export as export_mod, hooks, initializer, report
18
20
  from dbly.adapters import get_adapter
19
21
  from dbly.config import ConnectionConfig, load_profile, resolve_target
20
22
  from dbly.engine import detect_dialect
@@ -40,17 +42,53 @@ def _version(value: bool) -> None:
40
42
  raise typer.Exit()
41
43
 
42
44
 
45
+ def _quiet_parser_noise(debug: bool) -> None:
46
+ """Silence third-party parser/introspection chatter unless --debug.
47
+
48
+ sqlglot logs a WARNING (echoing the statement) every time it can't fully parse procedural
49
+ or engine-specific DDL and falls back to a raw command — expected and harmless for dbly,
50
+ but it floods real repos. SQLAlchemy likewise warns on types it doesn't model (e.g. PostGIS
51
+ ``geometry``), which dbly doesn't need for column identity.
52
+ """
53
+ if debug:
54
+ logging.getLogger("sqlglot").setLevel(logging.DEBUG)
55
+ return
56
+ logging.getLogger("sqlglot").setLevel(logging.ERROR)
57
+ warnings.filterwarnings("ignore", message="Did not recognize type")
58
+
59
+
43
60
  @app.callback()
44
61
  def _main(
45
62
  version: bool = typer.Option( # noqa: ARG001
46
63
  False, "--version", callback=_version, is_eager=True, help="Show version and exit."
47
64
  ),
65
+ debug: bool = typer.Option(
66
+ False, "--debug", help="show parser/introspection diagnostics (sqlglot, SQLAlchemy)."
67
+ ),
48
68
  ) -> None:
49
- pass
69
+ _quiet_parser_noise(debug)
70
+
71
+
72
+ def _open_repo(
73
+ repo_path: Path, project: ProjectConfig,
74
+ *, schemas: Optional[list[str]] = None, paths: Optional[list[str]] = None,
75
+ ) -> Repo:
76
+ return Repo(
77
+ repo_path,
78
+ object_root=project.object_root,
79
+ extra_ignore=project.ignore,
80
+ select_schemas=schemas or None,
81
+ select_paths=paths or None,
82
+ )
50
83
 
51
84
 
52
- def _open_repo(repo_path: Path, project: ProjectConfig) -> Repo:
53
- return Repo(repo_path, object_root=project.object_root, extra_ignore=project.ignore)
85
+ def _scope_label(schemas: Optional[list[str]], paths: Optional[list[str]]) -> Optional[str]:
86
+ bits = []
87
+ if schemas:
88
+ bits.append("schema=" + ",".join(schemas))
89
+ if paths:
90
+ bits.append("path=" + ",".join(paths))
91
+ return " ".join(bits) or None
54
92
 
55
93
 
56
94
  def _resolve_target(project: ProjectConfig, repo_path: Path, target: str) -> ConnectionConfig:
@@ -86,9 +124,10 @@ def _decorations(repo_path: Path, plan: Plan) -> dict[str, str]:
86
124
  def _make_plan(
87
125
  repo_path: Path, target: str, from_ref: Optional[str], to_ref: str,
88
126
  *, worktree: bool = False,
127
+ schemas: Optional[list[str]] = None, paths: Optional[list[str]] = None,
89
128
  ) -> Plan:
90
129
  project = load_project(repo_path)
91
- repo = _open_repo(repo_path, project)
130
+ repo = _open_repo(repo_path, project, schemas=schemas, paths=paths)
92
131
  cfg = _resolve_target(project, repo_path, target)
93
132
  dialect = sqlglot_dialect(detect_dialect(cfg))
94
133
  adapter = get_adapter(cfg)
@@ -124,9 +163,16 @@ def plan(
124
163
  help="plan against the working tree (uncommitted + untracked object files), "
125
164
  "not a git ref — for the fast edit→plan loop.",
126
165
  ),
166
+ schema: Optional[list[str]] = typer.Option(
167
+ None, "--schema", help="limit to these schemas (folder under object_root); repeatable."
168
+ ),
169
+ path: Optional[list[str]] = typer.Option(
170
+ None, "--path", help="limit to this subpath under object_root; repeatable."
171
+ ),
127
172
  ) -> None:
128
173
  """Compute and show the deployment plan."""
129
- plan_obj = _make_plan(repo_path, target, from_ref, to, worktree=worktree)
174
+ plan_obj = _make_plan(repo_path, target, from_ref, to, worktree=worktree,
175
+ schemas=schema, paths=path)
130
176
  report.render_plan(plan_obj, console, ref_names=_decorations(repo_path, plan_obj))
131
177
  if out:
132
178
  out.write_text(report.plan_to_yaml(plan_obj), encoding="utf-8")
@@ -159,13 +205,19 @@ def apply(
159
205
  py_interpreter: str = typer.Option(
160
206
  "python", "--py-interpreter", help="interpreter for .py hooks (e.g. ArcGIS propy)."
161
207
  ),
208
+ schema: Optional[list[str]] = typer.Option(
209
+ None, "--schema", help="limit to these schemas (folder under object_root); repeatable."
210
+ ),
211
+ path: Optional[list[str]] = typer.Option(
212
+ None, "--path", help="limit to this subpath under object_root; repeatable."
213
+ ),
162
214
  ) -> None:
163
215
  """Apply a plan to the target database (re-computes one unless a file is given)."""
164
216
  if plan_file is not None:
165
217
  plan_obj = report.plan_from_yaml(plan_file.read_text(encoding="utf-8"))
166
218
  target = plan_obj.target
167
219
  else:
168
- plan_obj = _make_plan(repo_path, target, from_ref, to)
220
+ plan_obj = _make_plan(repo_path, target, from_ref, to, schemas=schema, paths=path)
169
221
 
170
222
  report.render_plan(plan_obj, console, ref_names=_decorations(repo_path, plan_obj))
171
223
 
@@ -290,43 +342,80 @@ def check(
290
342
  False, "--worktree", "--dirty",
291
343
  help="compare the working tree (uncommitted + untracked) against the DB, not a git ref.",
292
344
  ),
345
+ schema: Optional[list[str]] = typer.Option(
346
+ None, "--schema", help="limit to these schemas (folder under object_root); repeatable."
347
+ ),
348
+ path: Optional[list[str]] = typer.Option(
349
+ None, "--path", help="limit to this subpath under object_root; repeatable."
350
+ ),
293
351
  ) -> None:
294
352
  """Detect drift: compare the desired state at <to> against the live database."""
295
353
  project = load_project(repo_path)
296
- repo = _open_repo(repo_path, project)
354
+ repo = _open_repo(repo_path, project, schemas=schema, paths=path)
297
355
  cfg = _resolve_target(project, repo_path, target)
298
356
  dialect = sqlglot_dialect(detect_dialect(cfg))
357
+ resolved_to = repo.resolve_ref(WORKTREE if worktree else to)
299
358
  adapter = get_adapter(cfg)
300
359
  try:
301
360
  rep = drift.compute_drift(
302
- repo, adapter,
303
- to_ref=repo.resolve_ref(WORKTREE if worktree else to),
304
- dialect=dialect, include_orphans=orphans,
361
+ repo, adapter, to_ref=resolved_to, dialect=dialect, include_orphans=orphans,
305
362
  )
306
363
  finally:
307
364
  adapter.dispose()
308
365
 
309
- if rep.clean and not rep.unreadable:
310
- console.print("[green]no drift — database matches the desired state.[/green]")
311
- return
312
-
313
- for kind, oid in rep.missing:
314
- console.print(f"[yellow]missing[/yellow] {kind.value} {oid} (in repo, not in DB)")
315
- for cd in rep.columns:
316
- if cd.added:
317
- console.print(f"[yellow]columns[/yellow] {cd.table} to add: {', '.join(cd.added)}")
318
- if cd.removed:
319
- console.print(f"[red]columns[/red] {cd.table} only in DB: {', '.join(cd.removed)}")
320
- for kind, oid in rep.definitions:
321
- console.print(f"[yellow]changed[/yellow] {kind.value} {oid} (definition differs — advisory)")
322
- for kind, oid in rep.orphaned:
323
- console.print(f"[dim]orphaned[/dim] {kind.value} {oid} (in DB, not in repo)")
324
- for kind, oid in rep.unreadable:
325
- console.print(f"[dim]unreadable[/dim] {kind.value} {oid} (columns could not be reflected — advisory)")
366
+ report.render_drift(
367
+ rep, console, target=target, ref=resolved_to,
368
+ ref_names=_decorations(repo_path, Plan(target=target, from_ref=None, to_ref=resolved_to)),
369
+ scope=_scope_label(schema, path),
370
+ )
326
371
  if not rep.clean:
327
372
  raise typer.Exit(code=1)
328
373
 
329
374
 
375
+ @app.command()
376
+ def export(
377
+ target: str = typer.Option(..., "--target", help="connection profile or named target."),
378
+ dialect: Optional[str] = typer.Option(
379
+ None, "--dialect",
380
+ help="transpile to another engine (postgres|oracle|sqlserver|sqlite). "
381
+ "Default: keep the source engine's dialect.",
382
+ ),
383
+ out: Optional[Path] = typer.Option(None, "--out", help="write the DDL script to a file."),
384
+ schema: Optional[list[str]] = typer.Option(
385
+ None, "--schema", help="limit to these live schemas; repeatable."
386
+ ),
387
+ repo_path: Path = typer.Option(Path("."), "--repo", help="repo root (for dbly.toml targets)."),
388
+ ) -> None:
389
+ """Export a live database as a DDL script — the reverse of deploy, optionally cross-dialect.
390
+
391
+ Tables/views transpile across dialects; procedural objects are emitted verbatim.
392
+ """
393
+ project = load_project(repo_path)
394
+ cfg = _resolve_target(project, repo_path, target)
395
+ source_dialect = sqlglot_dialect(detect_dialect(cfg))
396
+ target_dialect = sqlglot_dialect(dialect) if dialect else None
397
+ if dialect and target_dialect is None:
398
+ err.print(f"[red]unknown --dialect {dialect!r}[/red] (postgres|oracle|sqlserver|sqlite)")
399
+ raise typer.Exit(code=2)
400
+
401
+ adapter = get_adapter(cfg)
402
+ try:
403
+ result = export_mod.export_ddl(
404
+ adapter, source_dialect=source_dialect,
405
+ target_dialect=target_dialect, schemas=schema or None,
406
+ )
407
+ finally:
408
+ adapter.dispose()
409
+
410
+ if out:
411
+ out.write_text(result.ddl, encoding="utf-8")
412
+ console.print(f"[green]exported[/green] {result.object_count} object(s) → {out}")
413
+ else:
414
+ console.print(result.ddl)
415
+ for w in result.warnings:
416
+ err.print(f"[yellow]![/yellow] {w}")
417
+
418
+
330
419
  def _run_hooks(repo: Repo, phase: str, py_interpreter: str) -> None:
331
420
  for hook in hooks.discover_hooks(repo.root, phase):
332
421
  if hook.suffix.lower() == ".py":
@@ -78,9 +78,13 @@ def compute_drift(
78
78
  report.missing.append((obj.kind, obj.id))
79
79
 
80
80
  if include_orphans:
81
+ sel = repo.select_schemas # scope orphans to the same subset as desired discovery
81
82
  for key, o in live.items():
82
- if key not in desired:
83
- report.orphaned.append((o.kind, o.id))
83
+ if key in desired:
84
+ continue
85
+ if sel is not None and (o.id.schema is None or o.id.schema.lower() not in sel):
86
+ continue
87
+ report.orphaned.append((o.kind, o.id))
84
88
 
85
89
  for key, obj in desired.items():
86
90
  if obj.kind is ObjectKind.TABLE and key in live:
@@ -0,0 +1,127 @@
1
+ """Reverse direction — introspect a live database and emit its DDL (``dbly export``).
2
+
3
+ The inverse of the deploy path: read what actually exists and render it as a SQL script,
4
+ optionally transpiled to another engine's dialect. Honest scope (CONCEPT.md §10):
5
+
6
+ * **tables / views** are *structural* — sqlglot transpiles them across dialects reliably.
7
+ Tables are rebuilt from the live column set (name, type, nullability, default); constraints
8
+ and indexes are **not** reconstructed (a warning says so).
9
+ * **functions / procedures / triggers / packages / types** carry procedural bodies sqlglot
10
+ cannot transpile. They are emitted **verbatim** in the source dialect; requesting a
11
+ different target dialect keeps them as-is and adds a warning.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ from dataclasses import dataclass, field
16
+
17
+ import sqlglot
18
+
19
+ from dbly.adapters.base import Adapter
20
+ from dbly.model import LiveObject, ObjectId, ObjectKind
21
+
22
+ # emit order: providers before dependants (best-effort; FK ordering between tables is not solved)
23
+ _ORDER = {
24
+ ObjectKind.SEQUENCE: 0, ObjectKind.TYPE: 1, ObjectKind.TABLE: 2, ObjectKind.INDEX: 3,
25
+ ObjectKind.VIEW: 4, ObjectKind.FUNCTION: 5, ObjectKind.PROCEDURE: 6,
26
+ ObjectKind.PACKAGE: 7, ObjectKind.TRIGGER: 8, ObjectKind.GRANT: 9, ObjectKind.UNKNOWN: 10,
27
+ }
28
+ _STRUCTURAL = {ObjectKind.TABLE, ObjectKind.VIEW}
29
+ _PROCEDURAL = {
30
+ ObjectKind.FUNCTION, ObjectKind.PROCEDURE, ObjectKind.TRIGGER,
31
+ ObjectKind.PACKAGE, ObjectKind.TYPE,
32
+ }
33
+
34
+
35
+ @dataclass(slots=True)
36
+ class ExportResult:
37
+ ddl: str
38
+ warnings: list[str] = field(default_factory=list)
39
+ object_count: int = 0
40
+
41
+
42
+ def _create_table_sql(adapter: Adapter, oid: ObjectId, dialect: str | None) -> str:
43
+ cols = adapter.get_columns(oid.schema, oid.name)
44
+ lines = []
45
+ for c in cols:
46
+ piece = f" {c.name} {c.type}"
47
+ if c.default is not None:
48
+ piece += f" DEFAULT {c.default}"
49
+ if not c.nullable:
50
+ piece += " NOT NULL"
51
+ lines.append(piece)
52
+ body = ",\n".join(lines)
53
+ return f"CREATE TABLE {oid} (\n{body}\n);"
54
+
55
+
56
+ def _transpile(sql: str, *, read: str | None, write: str | None) -> str:
57
+ """Transpile one statement; on any failure the caller keeps the verbatim source."""
58
+ out = sqlglot.transpile(sql, read=read, write=write)
59
+ return ";\n".join(s.rstrip(";") for s in out) + ";"
60
+
61
+
62
+ def export_ddl(
63
+ adapter: Adapter,
64
+ *,
65
+ source_dialect: str | None,
66
+ target_dialect: str | None = None,
67
+ schemas: list[str] | None = None,
68
+ ) -> ExportResult:
69
+ """Render the live database as a DDL script (optionally transpiled to ``target_dialect``)."""
70
+ sel = {s.lower() for s in schemas} if schemas else None
71
+ live: list[LiveObject] = [
72
+ o for o in adapter.inventory()
73
+ if o.key() != "table:dbly_state" # never export dbly's own ledger
74
+ and (sel is None or (o.id.schema and o.id.schema.lower() in sel))
75
+ ]
76
+ live.sort(key=lambda o: (_ORDER.get(o.kind, 99), str(o.id).lower()))
77
+
78
+ cross = bool(target_dialect and target_dialect != source_dialect)
79
+ warnings: list[str] = []
80
+ if cross:
81
+ warnings.append(
82
+ f"transpiling {source_dialect} → {target_dialect}: tables/views are converted; "
83
+ "procedural objects (function/procedure/trigger/package/type) are emitted verbatim"
84
+ )
85
+ warned_table = False
86
+
87
+ parts: list[str] = [
88
+ "-- dbly export — DDL reconstructed from a live database. Review before running.",
89
+ f"-- source dialect: {source_dialect or '?'}"
90
+ + (f" target dialect: {target_dialect}" if cross else ""),
91
+ "",
92
+ ]
93
+ count = 0
94
+ for o in live:
95
+ if o.kind is ObjectKind.TABLE:
96
+ if o.definition: # engine gave us the real DDL (e.g. SQLite)
97
+ raw = o.definition.rstrip().rstrip(";") + ";"
98
+ else: # rebuilt from columns — flag the limits once
99
+ if not warned_table:
100
+ warnings.append(
101
+ "tables are rebuilt from columns only — primary keys, foreign keys, "
102
+ "checks and indexes are not reconstructed"
103
+ )
104
+ warned_table = True
105
+ raw = _create_table_sql(adapter, o.id, source_dialect)
106
+ elif o.definition:
107
+ raw = o.definition.rstrip().rstrip(";") + ";"
108
+ else:
109
+ warnings.append(f"{o.kind.value} {o.id}: no definition available — skipped")
110
+ continue
111
+
112
+ sql = raw
113
+ if cross:
114
+ if o.kind in _STRUCTURAL:
115
+ try:
116
+ sql = _transpile(raw, read=source_dialect, write=target_dialect)
117
+ except Exception as exc: # noqa: BLE001 — fall back to verbatim, flag it
118
+ warnings.append(f"{o.kind.value} {o.id}: could not transpile ({exc}); verbatim")
119
+ elif o.kind in _PROCEDURAL:
120
+ warnings.append(f"{o.kind.value} {o.id}: emitted verbatim ({source_dialect})")
121
+
122
+ parts.append(f"-- {o.kind.value} {o.id}")
123
+ parts.append(sql)
124
+ parts.append("")
125
+ count += 1
126
+
127
+ return ExportResult(ddl="\n".join(parts), warnings=warnings, object_count=count)
@@ -94,11 +94,14 @@ class LiveObject:
94
94
 
95
95
  ``source_hash`` is a canonicalized hash of the definition for procedural/definitional
96
96
  objects (views, functions, procedures, triggers) — used for advisory drift detection.
97
+ ``definition`` is the raw DDL/source as the engine reports it (``None`` for plain tables,
98
+ whose DDL is reconstructed from columns) — used by ``dbly export``.
97
99
  """
98
100
 
99
101
  kind: ObjectKind
100
102
  id: ObjectId
101
103
  source_hash: str | None = None
104
+ definition: str | None = None
102
105
 
103
106
  def key(self) -> str:
104
107
  return f"{self.kind.value}:{self.id.key()}"
@@ -33,6 +33,8 @@ class Repo:
33
33
  *,
34
34
  object_root: str | None = None,
35
35
  extra_ignore: list[str] | None = None,
36
+ select_schemas: list[str] | None = None,
37
+ select_paths: list[str] | None = None,
36
38
  ):
37
39
  self.root = root.resolve()
38
40
  if not (self.root / ".git").exists():
@@ -41,6 +43,9 @@ class Repo:
41
43
  self.object_root = (
42
44
  Path(object_root) if object_root and object_root not in (".", "") else None
43
45
  )
46
+ # optional subset selection (deploy/check only part of the tree)
47
+ self.select_schemas = {s.lower() for s in select_schemas} if select_schemas else None
48
+ self.select_paths = [Path(p) for p in select_paths] if select_paths else None
44
49
  self._ignore = self._load_dbignore(extra_ignore or [])
45
50
 
46
51
  def _git(self, *args: str) -> str:
@@ -73,12 +78,26 @@ class Repo:
73
78
  return True
74
79
  return rel == self.object_root or self.object_root in rel.parents
75
80
 
81
+ def _selected(self, rel: Path) -> bool:
82
+ """Honour an optional subset selection (``--schema`` / ``--path``). Both AND together."""
83
+ if self.select_paths is not None:
84
+ base = self.object_root or Path()
85
+ prefixes = [base / p for p in self.select_paths]
86
+ if not any(rel == pre or pre in rel.parents for pre in prefixes):
87
+ return False
88
+ if self.select_schemas is not None:
89
+ sch = self.schema_for(rel)
90
+ if sch is None or sch.lower() not in self.select_schemas:
91
+ return False
92
+ return True
93
+
76
94
  def _is_object(self, rel: Path) -> bool:
77
- """A deployable declarative object file (SQL, under object_root, not a migration/ignored)."""
95
+ """A deployable declarative object file (SQL, under object_root, selected, not ignored)."""
78
96
  return (
79
97
  self._is_sql(rel)
80
98
  and not self._is_migration(rel)
81
99
  and self._under_object_root(rel)
100
+ and self._selected(rel)
82
101
  and not self.is_ignored(rel)
83
102
  )
84
103
 
@@ -6,6 +6,7 @@ express: ordering, severity, source provenance and warnings.
6
6
  from __future__ import annotations
7
7
 
8
8
  from pathlib import Path
9
+ from typing import TYPE_CHECKING
9
10
 
10
11
  import yaml
11
12
  from rich.console import Console
@@ -13,6 +14,9 @@ from rich.table import Table
13
14
 
14
15
  from dbly.model import Migration, ObjectId, ObjectKind, Plan, Severity, Step
15
16
 
17
+ if TYPE_CHECKING:
18
+ from dbly.drift import DriftReport
19
+
16
20
 
17
21
  def _decorate_ref(ref: str | None, ref_names: dict[str, str] | None) -> str:
18
22
  """Render a ref for the plan header: git-style ``<names> (<short-sha>)`` when known."""
@@ -74,6 +78,78 @@ def render_plan(
74
78
  )
75
79
 
76
80
 
81
+ def render_drift(
82
+ rep: DriftReport,
83
+ console: Console,
84
+ *,
85
+ target: str,
86
+ ref: str,
87
+ ref_names: dict[str, str] | None = None,
88
+ scope: str | None = None,
89
+ ) -> None:
90
+ """Render a drift report with explicit direction, grouping and counts (CONCEPT.md §9).
91
+
92
+ The old output was one flat line per item and never made clear *which way* a difference
93
+ ran. Here each group states the direction in its heading, and column drift uses ``+`` (in
94
+ the repo, missing from the DB) vs. ``−`` (in the DB, not in the repo).
95
+ """
96
+ head = f"[bold]Drift[/bold] for [cyan]{target}[/cyan] {_decorate_ref(ref, ref_names)}"
97
+ if scope:
98
+ head += f" [dim](scope: {scope})[/dim]"
99
+ console.print(head)
100
+
101
+ if rep.clean and not rep.unreadable:
102
+ console.print("[green]✓ no drift — the database matches the desired state.[/green]")
103
+ return
104
+
105
+ n_add = sum(len(cd.added) for cd in rep.columns)
106
+ n_del = sum(len(cd.removed) for cd in rep.columns)
107
+ tally = [
108
+ f"[yellow]{len(rep.missing)}[/yellow] to create",
109
+ f"[red]{len(rep.orphaned)}[/red] orphaned" if rep.orphaned else None,
110
+ f"[yellow]{len(rep.columns)}[/yellow] tables with column drift"
111
+ f" ([green]+{n_add}[/green]/[red]−{n_del}[/red])" if rep.columns else None,
112
+ f"[yellow]{len(rep.definitions)}[/yellow] changed definitions" if rep.definitions else None,
113
+ f"[dim]{len(rep.unreadable)} unreadable[/dim]" if rep.unreadable else None,
114
+ ]
115
+ console.print(" " + " · ".join(t for t in tally if t) + "\n")
116
+
117
+ def _section(title: str, style: str, rows: list[str]) -> None:
118
+ if not rows:
119
+ return
120
+ console.print(f"[{style} bold]{title}[/{style} bold] [dim]({len(rows)})[/dim]")
121
+ for r in rows:
122
+ console.print(f" {r}")
123
+ console.print()
124
+
125
+ _section(
126
+ "Only in the repo — will be created on apply", "yellow",
127
+ [f"{k.value:9} {oid}" for k, oid in rep.missing],
128
+ )
129
+ _section(
130
+ "Only in the database — not in the repo", "red",
131
+ [f"{k.value:9} {oid}" for k, oid in rep.orphaned],
132
+ )
133
+ if rep.columns:
134
+ console.print(f"[yellow bold]Column drift[/yellow bold] [dim]({len(rep.columns)})[/dim]")
135
+ console.print(" [dim](+ in repo, missing from DB · − in DB, not in repo)[/dim]")
136
+ for cd in rep.columns:
137
+ console.print(f" {cd.table}")
138
+ if cd.added:
139
+ console.print(f" [green]+ {', '.join(cd.added)}[/green]")
140
+ if cd.removed:
141
+ console.print(f" [red]− {', '.join(cd.removed)}[/red]")
142
+ console.print()
143
+ _section(
144
+ "Definition differs — advisory", "yellow",
145
+ [f"{k.value:9} {oid}" for k, oid in rep.definitions],
146
+ )
147
+ _section(
148
+ "Could not introspect — advisory", "dim",
149
+ [f"{k.value:9} {oid}" for k, oid in rep.unreadable],
150
+ )
151
+
152
+
77
153
  def plan_to_sql(plan: Plan, *, state_ddl: str | None = None, record_sql: str | None = None) -> str:
78
154
  """Render the plan as a single, ordered vanilla-SQL script (CONCEPT.md §7).
79
155
 
@@ -415,3 +415,67 @@ def test_extra_ignore_excludes_unparseable_files(tmp_path: Path):
415
415
  files = repo.list_files("HEAD")
416
416
  assert Path("schema/app/good.tbl") in files
417
417
  assert Path("schema/app/bad.vw") not in files # ignored via extra_ignore
418
+
419
+
420
+ def test_select_schemas_and_paths_scope_discovery(tmp_path: Path):
421
+ repo_root = tmp_path / "db"
422
+ for sub in ("pgsql/schema/bas", "pgsql/schema/bas/domains", "pgsql/schema/gzp"):
423
+ (repo_root / sub).mkdir(parents=True)
424
+ _init_repo(repo_root)
425
+ (repo_root / "pgsql/schema/bas/kunde.tbl").write_text("CREATE TABLE bas.kunde (id int);", encoding="utf-8")
426
+ (repo_root / "pgsql/schema/bas/domains/d_typ.tbl").write_text("CREATE TABLE bas.d_typ (id int);", encoding="utf-8")
427
+ (repo_root / "pgsql/schema/gzp/plan.tbl").write_text("CREATE TABLE gzp.plan (id int);", encoding="utf-8")
428
+ _commit(repo_root, "v1")
429
+
430
+ # --schema bas → only the bas subtree (incl. bas/domains), not gzp
431
+ r_schema = Repo(repo_root, object_root="pgsql/schema", select_schemas=["bas"])
432
+ got = {p.as_posix() for p in r_schema.list_files("HEAD")}
433
+ assert got == {"pgsql/schema/bas/kunde.tbl", "pgsql/schema/bas/domains/d_typ.tbl"}
434
+
435
+ # --schema is case-insensitive
436
+ assert {p.as_posix() for p in Repo(repo_root, object_root="pgsql/schema",
437
+ select_schemas=["BAS"]).list_files("HEAD")} == got
438
+
439
+ # --path bas/domains → only that subpath
440
+ r_path = Repo(repo_root, object_root="pgsql/schema", select_paths=["bas/domains"])
441
+ assert {p.as_posix() for p in r_path.list_files("HEAD")} == {"pgsql/schema/bas/domains/d_typ.tbl"}
442
+
443
+ # no selection → everything under object_root
444
+ assert len(Repo(repo_root, object_root="pgsql/schema").list_files("HEAD")) == 3
445
+
446
+
447
+ def test_export_roundtrip_and_cross_dialect(tmp_path: Path):
448
+ from dbly.export import export_ddl
449
+ repo_root = tmp_path / "db"
450
+ repo_root.mkdir()
451
+ _init_repo(repo_root)
452
+ (repo_root / "kunde.tbl").write_text(
453
+ "CREATE TABLE IF NOT EXISTS kunde (id INTEGER, name TEXT NOT NULL);", encoding="utf-8"
454
+ )
455
+ (repo_root / "v_kunde.vw").write_text(
456
+ "CREATE VIEW v_kunde AS SELECT id, name FROM kunde;", encoding="utf-8"
457
+ )
458
+ ref = _commit(repo_root, "v1")
459
+ db = tmp_path / "exp.db"
460
+ adapter = SqliteAdapter(ConnectionConfig(environment="sqlite", service=str(db)))
461
+ repo = Repo(repo_root)
462
+ adapter.apply([s.sql for s in build_plan(repo, adapter, from_ref=None, to_ref=ref,
463
+ target="sqlite", dialect="sqlite").steps])
464
+
465
+ # same-dialect export: table + view present, table before view
466
+ res = export_ddl(adapter, source_dialect="sqlite")
467
+ assert res.object_count == 2
468
+ assert "CREATE TABLE" in res.ddl and "kunde" in res.ddl
469
+ assert res.ddl.index("kunde") < res.ddl.index("v_kunde") # dependency order
470
+ assert "NOT NULL" in res.ddl # SQLite keeps its stored DDL
471
+
472
+ # cross-dialect export to postgres: structural objects transpile, no crash
473
+ res_pg = export_ddl(adapter, source_dialect="sqlite", target_dialect="postgres")
474
+ assert res_pg.object_count == 2
475
+ assert "CREATE TABLE" in res_pg.ddl
476
+ assert any("transpiling sqlite → postgres" in w for w in res_pg.warnings)
477
+
478
+ # dbly_state ledger is never exported
479
+ adapter.ensure_state_table()
480
+ assert "dbly_state" not in export_ddl(adapter, source_dialect="sqlite").ddl
481
+ adapter.dispose()
@@ -160,3 +160,21 @@ def test_decorate_ref_git_style():
160
160
  assert report._decorate_ref("WORKTREE", None) == "working tree"
161
161
  assert report._decorate_ref("9ff5e440abc", {"9ff5e440abc": "v0.1, main"}) == "v0.1, main (9ff5e440)"
162
162
  assert report._decorate_ref("deadbeef", None) == "deadbeef" # no names → raw sha
163
+
164
+
165
+ def test_render_drift_shows_direction_and_counts(capsys):
166
+ from rich.console import Console
167
+ from dbly.drift import ColumnDrift, DriftReport
168
+ rep = DriftReport()
169
+ rep.missing.append((ObjectKind.TABLE, __import__("dbly.model", fromlist=["ObjectId"]).ObjectId("bas", "neu")))
170
+ rep.columns.append(ColumnDrift(
171
+ __import__("dbly.model", fromlist=["ObjectId"]).ObjectId("bas", "kunde"),
172
+ added=["email"], removed=["legacy"]))
173
+ report.render_drift(rep, Console(force_terminal=False, width=200),
174
+ target="dev", ref="abc1234", scope="schema=bas")
175
+ out = capsys.readouterr().out
176
+ assert "Only in the repo" in out # explicit direction, not just "missing"
177
+ assert "Column drift" in out
178
+ assert "+ email" in out and "− legacy" in out # + = add to DB, − = only in DB
179
+ assert "1 to create" in out # summary tally
180
+ assert "scope: schema=bas" in out # scope shown in header
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