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.
- {dbly-0.4.0 → dbly-0.6.0}/CHANGELOG.md +38 -0
- {dbly-0.4.0 → dbly-0.6.0}/PKG-INFO +10 -1
- {dbly-0.4.0 → dbly-0.6.0}/README.md +9 -0
- {dbly-0.4.0 → dbly-0.6.0}/pyproject.toml +1 -1
- {dbly-0.4.0 → dbly-0.6.0}/src/dbly/__init__.py +1 -1
- {dbly-0.4.0 → dbly-0.6.0}/src/dbly/adapters/mssql.py +2 -1
- {dbly-0.4.0 → dbly-0.6.0}/src/dbly/adapters/oracle.py +9 -1
- {dbly-0.4.0 → dbly-0.6.0}/src/dbly/adapters/postgres.py +7 -4
- {dbly-0.4.0 → dbly-0.6.0}/src/dbly/adapters/sqlite.py +1 -1
- {dbly-0.4.0 → dbly-0.6.0}/src/dbly/cli.py +117 -28
- {dbly-0.4.0 → dbly-0.6.0}/src/dbly/drift.py +6 -2
- dbly-0.6.0/src/dbly/export.py +127 -0
- {dbly-0.4.0 → dbly-0.6.0}/src/dbly/model.py +3 -0
- {dbly-0.4.0 → dbly-0.6.0}/src/dbly/repo.py +20 -1
- {dbly-0.4.0 → dbly-0.6.0}/src/dbly/report.py +76 -0
- {dbly-0.4.0 → dbly-0.6.0}/tests/test_integration.py +64 -0
- {dbly-0.4.0 → dbly-0.6.0}/tests/test_parsing.py +18 -0
- {dbly-0.4.0 → dbly-0.6.0}/.github/FUNDING.yml +0 -0
- {dbly-0.4.0 → dbly-0.6.0}/.github/workflows/ci.yml +0 -0
- {dbly-0.4.0 → dbly-0.6.0}/.github/workflows/publish.yml +0 -0
- {dbly-0.4.0 → dbly-0.6.0}/.gitignore +0 -0
- {dbly-0.4.0 → dbly-0.6.0}/TODO.md +0 -0
- {dbly-0.4.0 → dbly-0.6.0}/docs/dbly_head.png +0 -0
- {dbly-0.4.0 → dbly-0.6.0}/examples/ci/README.md +0 -0
- {dbly-0.4.0 → dbly-0.6.0}/examples/ci/bitbucket-pipelines.yml +0 -0
- {dbly-0.4.0 → dbly-0.6.0}/examples/ci/github-actions.yml +0 -0
- {dbly-0.4.0 → dbly-0.6.0}/src/dbly/adapters/__init__.py +0 -0
- {dbly-0.4.0 → dbly-0.6.0}/src/dbly/adapters/base.py +0 -0
- {dbly-0.4.0 → dbly-0.6.0}/src/dbly/config.py +0 -0
- {dbly-0.4.0 → dbly-0.6.0}/src/dbly/engine.py +0 -0
- {dbly-0.4.0 → dbly-0.6.0}/src/dbly/hooks.py +0 -0
- {dbly-0.4.0 → dbly-0.6.0}/src/dbly/initializer.py +0 -0
- {dbly-0.4.0 → dbly-0.6.0}/src/dbly/parsing.py +0 -0
- {dbly-0.4.0 → dbly-0.6.0}/src/dbly/planner.py +0 -0
- {dbly-0.4.0 → dbly-0.6.0}/src/dbly/project.py +0 -0
- {dbly-0.4.0 → dbly-0.6.0}/src/dbly/py.typed +0 -0
- {dbly-0.4.0 → dbly-0.6.0}/tests/test_mssql.py +0 -0
- {dbly-0.4.0 → dbly-0.6.0}/tests/test_mssql_e2e.py +0 -0
- {dbly-0.4.0 → dbly-0.6.0}/tests/test_oracle.py +0 -0
- {dbly-0.4.0 → dbly-0.6.0}/tests/test_oracle_e2e.py +0 -0
- {dbly-0.4.0 → dbly-0.6.0}/tests/test_project.py +0 -0
- {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.
|
|
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
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
130
|
-
|
|
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),
|
|
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
|
-
|
|
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
|
|
53
|
-
|
|
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
|
-
|
|
310
|
-
console
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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
|
|
83
|
-
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|