dbly 0.3.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.3.0 → dbly-0.6.0}/CHANGELOG.md +66 -0
- {dbly-0.3.0 → dbly-0.6.0}/PKG-INFO +31 -1
- {dbly-0.3.0 → dbly-0.6.0}/README.md +30 -0
- {dbly-0.3.0 → dbly-0.6.0}/pyproject.toml +1 -1
- {dbly-0.3.0 → dbly-0.6.0}/src/dbly/__init__.py +1 -1
- {dbly-0.3.0 → dbly-0.6.0}/src/dbly/adapters/mssql.py +2 -1
- {dbly-0.3.0 → dbly-0.6.0}/src/dbly/adapters/oracle.py +9 -1
- {dbly-0.3.0 → dbly-0.6.0}/src/dbly/adapters/postgres.py +46 -7
- {dbly-0.3.0 → dbly-0.6.0}/src/dbly/adapters/sqlite.py +1 -1
- {dbly-0.3.0 → dbly-0.6.0}/src/dbly/cli.py +147 -32
- {dbly-0.3.0 → dbly-0.6.0}/src/dbly/drift.py +16 -3
- dbly-0.6.0/src/dbly/export.py +127 -0
- {dbly-0.3.0 → dbly-0.6.0}/src/dbly/model.py +3 -0
- dbly-0.6.0/src/dbly/project.py +54 -0
- {dbly-0.3.0 → dbly-0.6.0}/src/dbly/repo.py +56 -9
- {dbly-0.3.0 → dbly-0.6.0}/src/dbly/report.py +76 -0
- {dbly-0.3.0 → dbly-0.6.0}/tests/test_integration.py +111 -0
- {dbly-0.3.0 → dbly-0.6.0}/tests/test_parsing.py +18 -0
- dbly-0.6.0/tests/test_project.py +35 -0
- {dbly-0.3.0 → dbly-0.6.0}/.github/FUNDING.yml +0 -0
- {dbly-0.3.0 → dbly-0.6.0}/.github/workflows/ci.yml +0 -0
- {dbly-0.3.0 → dbly-0.6.0}/.github/workflows/publish.yml +0 -0
- {dbly-0.3.0 → dbly-0.6.0}/.gitignore +0 -0
- {dbly-0.3.0 → dbly-0.6.0}/TODO.md +0 -0
- {dbly-0.3.0 → dbly-0.6.0}/docs/dbly_head.png +0 -0
- {dbly-0.3.0 → dbly-0.6.0}/examples/ci/README.md +0 -0
- {dbly-0.3.0 → dbly-0.6.0}/examples/ci/bitbucket-pipelines.yml +0 -0
- {dbly-0.3.0 → dbly-0.6.0}/examples/ci/github-actions.yml +0 -0
- {dbly-0.3.0 → dbly-0.6.0}/src/dbly/adapters/__init__.py +0 -0
- {dbly-0.3.0 → dbly-0.6.0}/src/dbly/adapters/base.py +0 -0
- {dbly-0.3.0 → dbly-0.6.0}/src/dbly/config.py +0 -0
- {dbly-0.3.0 → dbly-0.6.0}/src/dbly/engine.py +0 -0
- {dbly-0.3.0 → dbly-0.6.0}/src/dbly/hooks.py +0 -0
- {dbly-0.3.0 → dbly-0.6.0}/src/dbly/initializer.py +0 -0
- {dbly-0.3.0 → dbly-0.6.0}/src/dbly/parsing.py +0 -0
- {dbly-0.3.0 → dbly-0.6.0}/src/dbly/planner.py +0 -0
- {dbly-0.3.0 → dbly-0.6.0}/src/dbly/py.typed +0 -0
- {dbly-0.3.0 → dbly-0.6.0}/tests/test_mssql.py +0 -0
- {dbly-0.3.0 → dbly-0.6.0}/tests/test_mssql_e2e.py +0 -0
- {dbly-0.3.0 → dbly-0.6.0}/tests/test_oracle.py +0 -0
- {dbly-0.3.0 → dbly-0.6.0}/tests/test_oracle_e2e.py +0 -0
- {dbly-0.3.0 → dbly-0.6.0}/uv.lock +0 -0
|
@@ -7,6 +7,72 @@ 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
|
+
|
|
48
|
+
## [0.4.0] — 2026-07-23
|
|
49
|
+
|
|
50
|
+
### Added
|
|
51
|
+
|
|
52
|
+
- **Project configuration (`dbly.toml`).** An optional file at the repo root, for repos that
|
|
53
|
+
need more than a single connection profile:
|
|
54
|
+
- `object_root` — the subtree the declarative object files live under. The schema hint is
|
|
55
|
+
taken from the first path segment *below* this root, so a layout like
|
|
56
|
+
`pgsql/schema/<schema>/<obj>` maps to the right schema instead of the literal top folder.
|
|
57
|
+
- `environment` — default engine/dialect when a profile omits `environment=`.
|
|
58
|
+
- `[targets]` — named connection profiles, so `dbly plan --target dev` resolves to a
|
|
59
|
+
profile path.
|
|
60
|
+
- `ignore` — extra ignore patterns (gitwildmatch), merged with `.dbignore` (e.g. to skip a
|
|
61
|
+
handful of files a parser cannot read).
|
|
62
|
+
|
|
63
|
+
Absent config → unchanged behaviour (`object_root="."`, `--target` is a profile path).
|
|
64
|
+
|
|
65
|
+
### Fixed
|
|
66
|
+
|
|
67
|
+
- **Case-insensitive identifier matching on PostgreSQL.** Postgres folds unquoted identifiers
|
|
68
|
+
to lower case, so a DDL `CREATE TABLE FOO` lives as `foo`. `plan` (`table_exists`/
|
|
69
|
+
`get_columns`) and `check` looked the object up by the *declared* name and either planned a
|
|
70
|
+
spurious full `CREATE` or crashed with `NoSuchTableError`. Both now resolve the object
|
|
71
|
+
case-insensitively and reflect it by its real, live identity.
|
|
72
|
+
- **`check` no longer aborts on a single unreflectable table.** A table whose columns cannot
|
|
73
|
+
be introspected is reported as advisory `unreadable` and the drift scan continues, instead
|
|
74
|
+
of failing the whole run.
|
|
75
|
+
|
|
10
76
|
## [0.3.0] — 2026-07-21
|
|
11
77
|
|
|
12
78
|
### 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
|
|
@@ -93,6 +93,27 @@ changes the diff can't do safely (renaming a column, moving data), drop an order
|
|
|
93
93
|
it touches defers to it for that deploy. On a fresh database, migrations are *baselined*
|
|
94
94
|
(recorded, not run) since the object files already describe the end state.
|
|
95
95
|
|
|
96
|
+
## Project configuration (optional)
|
|
97
|
+
|
|
98
|
+
For anything beyond the defaults, drop a `dbly.toml` at the repo root. It's optional — without
|
|
99
|
+
it, `--target` is a profile path and the schema is the first folder segment.
|
|
100
|
+
|
|
101
|
+
```toml
|
|
102
|
+
object_root = "pgsql/schema" # objects live here; schema = first segment BELOW this root
|
|
103
|
+
environment = "postgres" # default engine when a profile omits environment=
|
|
104
|
+
ignore = ["**/*_1252.sql"] # extra ignore patterns, merged with .dbignore
|
|
105
|
+
|
|
106
|
+
[targets] # dbly plan --target dev → resolves to the profile path
|
|
107
|
+
dev = "conn/dev.properties"
|
|
108
|
+
beta = "conn/beta.properties"
|
|
109
|
+
prod = "conn/prod.properties"
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
`object_root` is the key that lets dbly sit on an existing repo whose objects live deeper than
|
|
113
|
+
the top level (e.g. `pgsql/schema/<schema>/<object>`): the schema is derived *relative to the
|
|
114
|
+
root*, so `pgsql/schema/sales/customer.tbl` maps to schema `sales`, not `pgsql`. Put root-level
|
|
115
|
+
keys (`object_root`, `environment`, `ignore`) **before** the `[targets]` table (TOML rule).
|
|
116
|
+
|
|
96
117
|
## Connect
|
|
97
118
|
|
|
98
119
|
A connection profile (reuses the familiar `connection.properties` format):
|
|
@@ -125,8 +146,17 @@ dbly check --target prod.connection.properties
|
|
|
125
146
|
|
|
126
147
|
# greenfield only: run privileged groundwork once under a superuser profile
|
|
127
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
|
|
128
153
|
```
|
|
129
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
|
+
|
|
130
160
|
**Typical workflow:** edit your object files → commit → `dbly plan` to review → `dbly apply`.
|
|
131
161
|
|
|
132
162
|
Deploying a *subset* of features is just choosing the git ref you deploy (a release tag or
|
|
@@ -54,6 +54,27 @@ changes the diff can't do safely (renaming a column, moving data), drop an order
|
|
|
54
54
|
it touches defers to it for that deploy. On a fresh database, migrations are *baselined*
|
|
55
55
|
(recorded, not run) since the object files already describe the end state.
|
|
56
56
|
|
|
57
|
+
## Project configuration (optional)
|
|
58
|
+
|
|
59
|
+
For anything beyond the defaults, drop a `dbly.toml` at the repo root. It's optional — without
|
|
60
|
+
it, `--target` is a profile path and the schema is the first folder segment.
|
|
61
|
+
|
|
62
|
+
```toml
|
|
63
|
+
object_root = "pgsql/schema" # objects live here; schema = first segment BELOW this root
|
|
64
|
+
environment = "postgres" # default engine when a profile omits environment=
|
|
65
|
+
ignore = ["**/*_1252.sql"] # extra ignore patterns, merged with .dbignore
|
|
66
|
+
|
|
67
|
+
[targets] # dbly plan --target dev → resolves to the profile path
|
|
68
|
+
dev = "conn/dev.properties"
|
|
69
|
+
beta = "conn/beta.properties"
|
|
70
|
+
prod = "conn/prod.properties"
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
`object_root` is the key that lets dbly sit on an existing repo whose objects live deeper than
|
|
74
|
+
the top level (e.g. `pgsql/schema/<schema>/<object>`): the schema is derived *relative to the
|
|
75
|
+
root*, so `pgsql/schema/sales/customer.tbl` maps to schema `sales`, not `pgsql`. Put root-level
|
|
76
|
+
keys (`object_root`, `environment`, `ignore`) **before** the `[targets]` table (TOML rule).
|
|
77
|
+
|
|
57
78
|
## Connect
|
|
58
79
|
|
|
59
80
|
A connection profile (reuses the familiar `connection.properties` format):
|
|
@@ -86,8 +107,17 @@ dbly check --target prod.connection.properties
|
|
|
86
107
|
|
|
87
108
|
# greenfield only: run privileged groundwork once under a superuser profile
|
|
88
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
|
|
89
114
|
```
|
|
90
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
|
+
|
|
91
121
|
**Typical workflow:** edit your object files → commit → `dbly plan` to review → `dbly apply`.
|
|
92
122
|
|
|
93
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:
|
|
@@ -26,13 +26,49 @@ class PostgresAdapter(Adapter):
|
|
|
26
26
|
transactional_ddl = True
|
|
27
27
|
default_schema = "public"
|
|
28
28
|
|
|
29
|
+
def _resolve(self, schema: str | None, name: str) -> tuple[str, str] | None:
|
|
30
|
+
"""Find a relation case-insensitively, returning its real (schema, name).
|
|
31
|
+
|
|
32
|
+
Postgres folds unquoted identifiers to lower case, so a DDL ``CREATE TABLE FOO`` lives
|
|
33
|
+
as ``foo``; the desired name (as written) need not match verbatim. Exact matches are
|
|
34
|
+
preferred over case-folded ones when both exist.
|
|
35
|
+
"""
|
|
36
|
+
params = {"n": name}
|
|
37
|
+
schema_pred = ""
|
|
38
|
+
if schema is not None:
|
|
39
|
+
schema_pred = "AND lower(n.nspname) = lower(:s) "
|
|
40
|
+
params["s"] = schema
|
|
41
|
+
q = text(
|
|
42
|
+
"SELECT n.nspname, c.relname FROM pg_class c "
|
|
43
|
+
"JOIN pg_namespace n ON n.oid = c.relnamespace "
|
|
44
|
+
f"WHERE lower(c.relname) = lower(:n) {schema_pred}"
|
|
45
|
+
"AND c.relkind IN ('r','p','v','m') "
|
|
46
|
+
"ORDER BY (c.relname = :n) DESC LIMIT 1"
|
|
47
|
+
)
|
|
48
|
+
with self.engine.connect() as conn:
|
|
49
|
+
row = conn.execute(q, params).first()
|
|
50
|
+
return (row[0], row[1]) if row else None
|
|
51
|
+
|
|
29
52
|
def table_exists(self, schema: str | None, name: str) -> bool:
|
|
30
|
-
|
|
31
|
-
|
|
53
|
+
params = {"n": name}
|
|
54
|
+
schema_pred = ""
|
|
55
|
+
if schema is not None:
|
|
56
|
+
schema_pred = "AND lower(n.nspname) = lower(:s) "
|
|
57
|
+
params["s"] = schema
|
|
58
|
+
with self.engine.connect() as conn:
|
|
59
|
+
return conn.execute(
|
|
60
|
+
text(
|
|
61
|
+
"SELECT 1 FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace "
|
|
62
|
+
f"WHERE lower(c.relname) = lower(:n) {schema_pred}"
|
|
63
|
+
"AND c.relkind IN ('r','p') LIMIT 1"
|
|
64
|
+
),
|
|
65
|
+
params,
|
|
66
|
+
).first() is not None
|
|
32
67
|
|
|
33
68
|
def get_columns(self, schema: str | None, name: str) -> list[Column]:
|
|
34
69
|
insp = inspect(self.engine)
|
|
35
|
-
|
|
70
|
+
rs, rn = self._resolve(schema, name) or (schema, name)
|
|
71
|
+
cols = insp.get_columns(rn, schema=rs)
|
|
36
72
|
return [
|
|
37
73
|
Column(
|
|
38
74
|
name=c["name"],
|
|
@@ -90,16 +126,19 @@ class PostgresAdapter(Adapter):
|
|
|
90
126
|
kind = self._RELKIND.get(relkind)
|
|
91
127
|
if kind is None:
|
|
92
128
|
continue
|
|
93
|
-
|
|
94
|
-
|
|
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)
|
|
95
133
|
found[obj.key()] = obj
|
|
96
134
|
for schema, name, prokind, src in conn.execute(routines):
|
|
97
135
|
kind = ObjectKind.PROCEDURE if prokind == "p" else ObjectKind.FUNCTION
|
|
98
|
-
obj = LiveObject(kind, ObjectId(schema, name),
|
|
136
|
+
obj = LiveObject(kind, ObjectId(schema, name),
|
|
137
|
+
canonical_hash(src, dialect="postgres"), src)
|
|
99
138
|
found[obj.key()] = obj
|
|
100
139
|
for schema, name, src in conn.execute(triggers):
|
|
101
140
|
obj = LiveObject(ObjectKind.TRIGGER, ObjectId(schema, name),
|
|
102
|
-
canonical_hash(src, dialect="postgres"))
|
|
141
|
+
canonical_hash(src, dialect="postgres"), src)
|
|
103
142
|
found[obj.key()] = obj
|
|
104
143
|
return list(found.values())
|
|
105
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,19 +8,22 @@
|
|
|
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
|
-
from dbly.config import resolve_target
|
|
21
|
+
from dbly.config import ConnectionConfig, load_profile, resolve_target
|
|
20
22
|
from dbly.engine import detect_dialect
|
|
21
23
|
from dbly.parsing import sqlglot_dialect
|
|
22
24
|
from dbly.planner import build_plan
|
|
23
25
|
from dbly.model import Plan, Severity
|
|
26
|
+
from dbly.project import ProjectConfig, load_project
|
|
24
27
|
from dbly.repo import WORKTREE, Repo
|
|
25
28
|
|
|
26
29
|
app = typer.Typer(
|
|
@@ -39,13 +42,67 @@ def _version(value: bool) -> None:
|
|
|
39
42
|
raise typer.Exit()
|
|
40
43
|
|
|
41
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
|
+
|
|
42
60
|
@app.callback()
|
|
43
61
|
def _main(
|
|
44
62
|
version: bool = typer.Option( # noqa: ARG001
|
|
45
63
|
False, "--version", callback=_version, is_eager=True, help="Show version and exit."
|
|
46
64
|
),
|
|
65
|
+
debug: bool = typer.Option(
|
|
66
|
+
False, "--debug", help="show parser/introspection diagnostics (sqlglot, SQLAlchemy)."
|
|
67
|
+
),
|
|
47
68
|
) -> None:
|
|
48
|
-
|
|
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
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
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
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _resolve_target(project: ProjectConfig, repo_path: Path, target: str) -> ConnectionConfig:
|
|
95
|
+
"""Resolve ``--target``: a named target from ``dbly.toml`` [targets], else a profile path.
|
|
96
|
+
|
|
97
|
+
The project's ``environment`` fills in when the profile omits ``environment=``.
|
|
98
|
+
"""
|
|
99
|
+
if target in project.targets:
|
|
100
|
+
cfg = load_profile(repo_path / project.targets[target])
|
|
101
|
+
else:
|
|
102
|
+
cfg = resolve_target(target)
|
|
103
|
+
if cfg.environment is None and project.environment:
|
|
104
|
+
cfg.environment = project.environment
|
|
105
|
+
return cfg
|
|
49
106
|
|
|
50
107
|
|
|
51
108
|
def _decorations(repo_path: Path, plan: Plan) -> dict[str, str]:
|
|
@@ -67,9 +124,11 @@ def _decorations(repo_path: Path, plan: Plan) -> dict[str, str]:
|
|
|
67
124
|
def _make_plan(
|
|
68
125
|
repo_path: Path, target: str, from_ref: Optional[str], to_ref: str,
|
|
69
126
|
*, worktree: bool = False,
|
|
127
|
+
schemas: Optional[list[str]] = None, paths: Optional[list[str]] = None,
|
|
70
128
|
) -> Plan:
|
|
71
|
-
|
|
72
|
-
|
|
129
|
+
project = load_project(repo_path)
|
|
130
|
+
repo = _open_repo(repo_path, project, schemas=schemas, paths=paths)
|
|
131
|
+
cfg = _resolve_target(project, repo_path, target)
|
|
73
132
|
dialect = sqlglot_dialect(detect_dialect(cfg))
|
|
74
133
|
adapter = get_adapter(cfg)
|
|
75
134
|
try:
|
|
@@ -104,16 +163,23 @@ def plan(
|
|
|
104
163
|
help="plan against the working tree (uncommitted + untracked object files), "
|
|
105
164
|
"not a git ref — for the fast edit→plan loop.",
|
|
106
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
|
+
),
|
|
107
172
|
) -> None:
|
|
108
173
|
"""Compute and show the deployment plan."""
|
|
109
|
-
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)
|
|
110
176
|
report.render_plan(plan_obj, console, ref_names=_decorations(repo_path, plan_obj))
|
|
111
177
|
if out:
|
|
112
178
|
out.write_text(report.plan_to_yaml(plan_obj), encoding="utf-8")
|
|
113
179
|
console.print(f"\n[dim]plan (YAML) written to {out}[/dim]")
|
|
114
180
|
if sql:
|
|
115
181
|
# state_table_ddl / record_deploy_sql are pure string builders — no DB connection.
|
|
116
|
-
adapter = get_adapter(
|
|
182
|
+
adapter = get_adapter(_resolve_target(load_project(repo_path), repo_path, target))
|
|
117
183
|
try:
|
|
118
184
|
script = report.plan_to_sql(
|
|
119
185
|
plan_obj,
|
|
@@ -139,13 +205,19 @@ def apply(
|
|
|
139
205
|
py_interpreter: str = typer.Option(
|
|
140
206
|
"python", "--py-interpreter", help="interpreter for .py hooks (e.g. ArcGIS propy)."
|
|
141
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
|
+
),
|
|
142
214
|
) -> None:
|
|
143
215
|
"""Apply a plan to the target database (re-computes one unless a file is given)."""
|
|
144
216
|
if plan_file is not None:
|
|
145
217
|
plan_obj = report.plan_from_yaml(plan_file.read_text(encoding="utf-8"))
|
|
146
218
|
target = plan_obj.target
|
|
147
219
|
else:
|
|
148
|
-
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)
|
|
149
221
|
|
|
150
222
|
report.render_plan(plan_obj, console, ref_names=_decorations(repo_path, plan_obj))
|
|
151
223
|
|
|
@@ -165,8 +237,9 @@ def apply(
|
|
|
165
237
|
console.print("[green]nothing to apply.[/green]")
|
|
166
238
|
return
|
|
167
239
|
|
|
168
|
-
|
|
169
|
-
|
|
240
|
+
project = load_project(repo_path)
|
|
241
|
+
repo = _open_repo(repo_path, project)
|
|
242
|
+
cfg = _resolve_target(project, repo_path, target)
|
|
170
243
|
adapter = get_adapter(cfg)
|
|
171
244
|
try:
|
|
172
245
|
_run_hooks(repo, "pre", py_interpreter)
|
|
@@ -204,12 +277,13 @@ def init(
|
|
|
204
277
|
|
|
205
278
|
Greenfield only — brownfield (a handed-over database) skips this entirely.
|
|
206
279
|
"""
|
|
207
|
-
|
|
280
|
+
project = load_project(repo_path)
|
|
281
|
+
repo = _open_repo(repo_path, project)
|
|
208
282
|
scripts = initializer.discover_init_scripts(repo.root, init_dir)
|
|
209
283
|
if not scripts:
|
|
210
284
|
console.print(f"[yellow]no init scripts in {init_dir}/ — nothing to do.[/yellow]")
|
|
211
285
|
return
|
|
212
|
-
adapter = get_adapter(
|
|
286
|
+
adapter = get_adapter(_resolve_target(project, repo_path, init_target))
|
|
213
287
|
try:
|
|
214
288
|
for s in scripts:
|
|
215
289
|
adapter.run_init_script(s.read_text(encoding="utf-8"))
|
|
@@ -268,37 +342,78 @@ def check(
|
|
|
268
342
|
False, "--worktree", "--dirty",
|
|
269
343
|
help="compare the working tree (uncommitted + untracked) against the DB, not a git ref.",
|
|
270
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
|
+
),
|
|
271
351
|
) -> None:
|
|
272
352
|
"""Detect drift: compare the desired state at <to> against the live database."""
|
|
273
|
-
|
|
274
|
-
|
|
353
|
+
project = load_project(repo_path)
|
|
354
|
+
repo = _open_repo(repo_path, project, schemas=schema, paths=path)
|
|
355
|
+
cfg = _resolve_target(project, repo_path, target)
|
|
275
356
|
dialect = sqlglot_dialect(detect_dialect(cfg))
|
|
357
|
+
resolved_to = repo.resolve_ref(WORKTREE if worktree else to)
|
|
276
358
|
adapter = get_adapter(cfg)
|
|
277
359
|
try:
|
|
278
360
|
rep = drift.compute_drift(
|
|
279
|
-
repo, adapter,
|
|
280
|
-
to_ref=repo.resolve_ref(WORKTREE if worktree else to),
|
|
281
|
-
dialect=dialect, include_orphans=orphans,
|
|
361
|
+
repo, adapter, to_ref=resolved_to, dialect=dialect, include_orphans=orphans,
|
|
282
362
|
)
|
|
283
363
|
finally:
|
|
284
364
|
adapter.dispose()
|
|
285
365
|
|
|
286
|
-
|
|
287
|
-
console
|
|
288
|
-
|
|
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
|
+
)
|
|
371
|
+
if not rep.clean:
|
|
372
|
+
raise typer.Exit(code=1)
|
|
373
|
+
|
|
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.
|
|
289
390
|
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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}")
|
|
302
417
|
|
|
303
418
|
|
|
304
419
|
def _run_hooks(repo: Repo, phase: str, py_interpreter: str) -> None:
|
|
@@ -36,6 +36,7 @@ class DriftReport:
|
|
|
36
36
|
orphaned: list[tuple[ObjectKind, ObjectId]] = field(default_factory=list)
|
|
37
37
|
columns: list[ColumnDrift] = field(default_factory=list)
|
|
38
38
|
definitions: list[tuple[ObjectKind, ObjectId]] = field(default_factory=list)
|
|
39
|
+
unreadable: list[tuple[ObjectKind, ObjectId]] = field(default_factory=list) # reflection failed (advisory)
|
|
39
40
|
|
|
40
41
|
@property
|
|
41
42
|
def clean(self) -> bool:
|
|
@@ -77,13 +78,25 @@ def compute_drift(
|
|
|
77
78
|
report.missing.append((obj.kind, obj.id))
|
|
78
79
|
|
|
79
80
|
if include_orphans:
|
|
81
|
+
sel = repo.select_schemas # scope orphans to the same subset as desired discovery
|
|
80
82
|
for key, o in live.items():
|
|
81
|
-
if key
|
|
82
|
-
|
|
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))
|
|
83
88
|
|
|
84
89
|
for key, obj in desired.items():
|
|
85
90
|
if obj.kind is ObjectKind.TABLE and key in live:
|
|
86
|
-
|
|
91
|
+
# Reflect columns by the LIVE object's identity — the engine may have folded the
|
|
92
|
+
# declared name's case (unquoted `FOO` → `foo` on Postgres), so the desired name
|
|
93
|
+
# need not exist verbatim. A table that cannot be reflected is skipped, not fatal.
|
|
94
|
+
live_id = live[key].id
|
|
95
|
+
try:
|
|
96
|
+
actual = {c.key() for c in adapter.get_columns(live_id.schema, live_id.name)}
|
|
97
|
+
except Exception: # noqa: BLE001 — one odd table must not abort the whole check
|
|
98
|
+
report.unreadable.append((obj.kind, obj.id))
|
|
99
|
+
continue
|
|
87
100
|
want = {c.key() for c in parsing.desired_columns(obj.sql, dialect=dialect)}
|
|
88
101
|
added, removed = sorted(want - actual), sorted(actual - want)
|
|
89
102
|
if added or removed:
|
|
@@ -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()}"
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Project configuration — an optional ``dbly.toml`` at the repository root.
|
|
2
|
+
|
|
3
|
+
The flag-driven workflow (``--target <profile>`` + ``.dbignore``) stays the default; a
|
|
4
|
+
``dbly.toml`` adds the terraform-style project setup a real repo needs:
|
|
5
|
+
|
|
6
|
+
* **object_root** — the subtree the declarative object files live under. The schema hint is the
|
|
7
|
+
first path segment *below* this root, so a repo laid out as ``pgsql/schema/<schema>/<obj>``
|
|
8
|
+
maps to the right schema instead of the literal top folder.
|
|
9
|
+
* **environment** — the default engine/dialect when a profile omits ``environment=``.
|
|
10
|
+
* **[targets]** — named connection profiles, so ``dbly plan --target dev`` resolves to a
|
|
11
|
+
profile path instead of spelling it out each time.
|
|
12
|
+
* **ignore** — extra ignore patterns (gitwildmatch), merged with ``.dbignore`` — e.g. to
|
|
13
|
+
exclude a handful of files a parser cannot read.
|
|
14
|
+
|
|
15
|
+
Absent config → :class:`ProjectConfig` defaults (``object_root="."``, no named targets), i.e.
|
|
16
|
+
the previous behaviour unchanged.
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import tomllib
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
CONFIG_NAME = "dbly.toml"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(slots=True)
|
|
28
|
+
class ProjectConfig:
|
|
29
|
+
object_root: str = "."
|
|
30
|
+
environment: str | None = None
|
|
31
|
+
targets: dict[str, str] = field(default_factory=dict)
|
|
32
|
+
ignore: list[str] = field(default_factory=list)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def load_project(repo_root: Path) -> ProjectConfig:
|
|
36
|
+
"""Load ``<repo_root>/dbly.toml`` if present, else return defaults."""
|
|
37
|
+
path = repo_root / CONFIG_NAME
|
|
38
|
+
if not path.exists():
|
|
39
|
+
return ProjectConfig()
|
|
40
|
+
data = tomllib.loads(path.read_text(encoding="utf-8"))
|
|
41
|
+
|
|
42
|
+
targets = data.get("targets") or {}
|
|
43
|
+
if not isinstance(targets, dict):
|
|
44
|
+
raise ValueError(f"{CONFIG_NAME}: [targets] must be a table of name = \"profile-path\"")
|
|
45
|
+
ignore = data.get("ignore") or []
|
|
46
|
+
if not isinstance(ignore, list):
|
|
47
|
+
raise ValueError(f"{CONFIG_NAME}: ignore must be a list of patterns")
|
|
48
|
+
|
|
49
|
+
return ProjectConfig(
|
|
50
|
+
object_root=str(data.get("object_root", ".")),
|
|
51
|
+
environment=data.get("environment"),
|
|
52
|
+
targets={str(k): str(v) for k, v in targets.items()},
|
|
53
|
+
ignore=[str(p) for p in ignore],
|
|
54
|
+
)
|
|
@@ -27,11 +27,26 @@ class FileChange:
|
|
|
27
27
|
|
|
28
28
|
|
|
29
29
|
class Repo:
|
|
30
|
-
def __init__(
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
root: Path,
|
|
33
|
+
*,
|
|
34
|
+
object_root: str | None = None,
|
|
35
|
+
extra_ignore: list[str] | None = None,
|
|
36
|
+
select_schemas: list[str] | None = None,
|
|
37
|
+
select_paths: list[str] | None = None,
|
|
38
|
+
):
|
|
31
39
|
self.root = root.resolve()
|
|
32
40
|
if not (self.root / ".git").exists():
|
|
33
41
|
raise ValueError(f"not a git repository: {self.root}")
|
|
34
|
-
|
|
42
|
+
# the subtree object files live under; the schema hint is the first segment *below* it.
|
|
43
|
+
self.object_root = (
|
|
44
|
+
Path(object_root) if object_root and object_root not in (".", "") else None
|
|
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
|
|
49
|
+
self._ignore = self._load_dbignore(extra_ignore or [])
|
|
35
50
|
|
|
36
51
|
def _git(self, *args: str) -> str:
|
|
37
52
|
out = subprocess.run(
|
|
@@ -42,10 +57,10 @@ class Repo:
|
|
|
42
57
|
)
|
|
43
58
|
return out.stdout
|
|
44
59
|
|
|
45
|
-
def _load_dbignore(self) -> pathspec.PathSpec:
|
|
60
|
+
def _load_dbignore(self, extra: list[str]) -> pathspec.PathSpec:
|
|
46
61
|
f = self.root / ".dbignore"
|
|
47
62
|
lines = f.read_text(encoding="utf-8").splitlines() if f.exists() else []
|
|
48
|
-
return pathspec.PathSpec.from_lines("gitwildmatch", lines)
|
|
63
|
+
return pathspec.PathSpec.from_lines("gitwildmatch", [*lines, *extra])
|
|
49
64
|
|
|
50
65
|
def is_ignored(self, rel: Path) -> bool:
|
|
51
66
|
return self._ignore.match_file(rel.as_posix())
|
|
@@ -58,9 +73,33 @@ class Repo:
|
|
|
58
73
|
def _is_migration(rel: Path) -> bool:
|
|
59
74
|
return len(rel.parts) > 0 and rel.parts[0] == MIGRATIONS_DIR
|
|
60
75
|
|
|
76
|
+
def _under_object_root(self, rel: Path) -> bool:
|
|
77
|
+
if self.object_root is None:
|
|
78
|
+
return True
|
|
79
|
+
return rel == self.object_root or self.object_root in rel.parents
|
|
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
|
+
|
|
61
94
|
def _is_object(self, rel: Path) -> bool:
|
|
62
|
-
"""A deployable declarative object file (SQL,
|
|
63
|
-
return
|
|
95
|
+
"""A deployable declarative object file (SQL, under object_root, selected, not ignored)."""
|
|
96
|
+
return (
|
|
97
|
+
self._is_sql(rel)
|
|
98
|
+
and not self._is_migration(rel)
|
|
99
|
+
and self._under_object_root(rel)
|
|
100
|
+
and self._selected(rel)
|
|
101
|
+
and not self.is_ignored(rel)
|
|
102
|
+
)
|
|
64
103
|
|
|
65
104
|
def _untracked_files(self) -> list[Path]:
|
|
66
105
|
"""New files in the working tree not yet added to git (``git status`` "??")."""
|
|
@@ -169,8 +208,16 @@ class Repo:
|
|
|
169
208
|
def schema_for(self, rel: Path) -> str | None:
|
|
170
209
|
"""Best-practice convention: the first path segment names the DB schema.
|
|
171
210
|
|
|
172
|
-
|
|
173
|
-
|
|
211
|
+
With an ``object_root`` set, the segment is taken *relative to that root* — so
|
|
212
|
+
``pgsql/schema/bas/foo.tbl`` under root ``pgsql/schema`` yields schema ``bas``.
|
|
213
|
+
Only a *hint* — the parser overrides it when the DDL is schema-qualified. Returns
|
|
214
|
+
None when the file sits directly at the (object) root.
|
|
174
215
|
"""
|
|
175
|
-
|
|
216
|
+
r = rel
|
|
217
|
+
if self.object_root is not None:
|
|
218
|
+
try:
|
|
219
|
+
r = rel.relative_to(self.object_root)
|
|
220
|
+
except ValueError:
|
|
221
|
+
return None
|
|
222
|
+
parts = r.parts
|
|
176
223
|
return parts[0] if len(parts) > 1 else None
|
|
@@ -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
|
|
|
@@ -368,3 +368,114 @@ def test_repo_ref_names_decorates_tag_and_branch(tmp_path: Path):
|
|
|
368
368
|
names = repo.ref_names(sha)
|
|
369
369
|
assert "v0.1" in names
|
|
370
370
|
assert any(n in ("main", "master") for n in names) # the current branch points here too
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def test_object_root_scopes_discovery_and_schema(tmp_path: Path):
|
|
374
|
+
"""object_root: schema = first segment BELOW the root; files outside it are ignored."""
|
|
375
|
+
from dbly import parsing
|
|
376
|
+
repo_root = tmp_path / "db"
|
|
377
|
+
(repo_root / "pgsql" / "schema" / "bas").mkdir(parents=True)
|
|
378
|
+
(repo_root / "ora").mkdir(parents=True)
|
|
379
|
+
_init_repo(repo_root)
|
|
380
|
+
# unqualified CREATE (schema comes from the folder), under pgsql/schema/bas
|
|
381
|
+
(repo_root / "pgsql" / "schema" / "bas" / "kunde.tbl").write_text(
|
|
382
|
+
"CREATE TABLE IF NOT EXISTS kunde (id INTEGER, name TEXT);", encoding="utf-8"
|
|
383
|
+
)
|
|
384
|
+
# an Oracle file outside object_root must NOT be discovered
|
|
385
|
+
(repo_root / "ora" / "irrelevant.tbl").write_text(
|
|
386
|
+
"CREATE TABLE junk (id NUMBER);", encoding="utf-8"
|
|
387
|
+
)
|
|
388
|
+
_commit(repo_root, "v1")
|
|
389
|
+
|
|
390
|
+
repo = Repo(repo_root, object_root="pgsql/schema")
|
|
391
|
+
files = repo.list_files("HEAD")
|
|
392
|
+
assert files == [Path("pgsql/schema/bas/kunde.tbl")] # ora/ excluded
|
|
393
|
+
assert repo.schema_for(files[0]) == "bas" # segment below object_root
|
|
394
|
+
|
|
395
|
+
# parse with that hint → object id carries the real schema, not "pgsql"
|
|
396
|
+
sql = repo.read_at("HEAD", files[0])
|
|
397
|
+
obj = parsing.parse_file(sql, files[0], default_schema=repo.schema_for(files[0]),
|
|
398
|
+
dialect="postgres")[0]
|
|
399
|
+
assert str(obj.id) == "bas.kunde"
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def test_extra_ignore_excludes_unparseable_files(tmp_path: Path):
|
|
403
|
+
repo_root = tmp_path / "db"
|
|
404
|
+
(repo_root / "schema" / "app").mkdir(parents=True)
|
|
405
|
+
_init_repo(repo_root)
|
|
406
|
+
(repo_root / "schema" / "app" / "good.tbl").write_text(
|
|
407
|
+
"CREATE TABLE app.good (id INTEGER);", encoding="utf-8"
|
|
408
|
+
)
|
|
409
|
+
(repo_root / "schema" / "app" / "bad.vw").write_text(
|
|
410
|
+
"CREATE VIEW app.bad AS SELECT 1;", encoding="utf-8"
|
|
411
|
+
)
|
|
412
|
+
_commit(repo_root, "v1")
|
|
413
|
+
|
|
414
|
+
repo = Repo(repo_root, object_root="schema", extra_ignore=["schema/app/bad.vw"])
|
|
415
|
+
files = repo.list_files("HEAD")
|
|
416
|
+
assert Path("schema/app/good.tbl") in files
|
|
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
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Tests for dbly.toml project configuration and object_root scoping."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from dbly.project import ProjectConfig, load_project
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_load_project_absent_returns_defaults(tmp_path: Path):
|
|
10
|
+
cfg = load_project(tmp_path)
|
|
11
|
+
assert cfg == ProjectConfig()
|
|
12
|
+
assert cfg.object_root == "." and cfg.targets == {} and cfg.ignore == []
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_load_project_parses_toml(tmp_path: Path):
|
|
16
|
+
(tmp_path / "dbly.toml").write_text(
|
|
17
|
+
"""
|
|
18
|
+
object_root = "pgsql/schema"
|
|
19
|
+
environment = "postgres"
|
|
20
|
+
ignore = ["pgsql/schema/ggn/vw_bad.vw", "**/deploy-*.sql"]
|
|
21
|
+
|
|
22
|
+
[targets]
|
|
23
|
+
dev = "test/dbly.dev.properties"
|
|
24
|
+
prod = "test/dbly.prod.properties"
|
|
25
|
+
""",
|
|
26
|
+
encoding="utf-8",
|
|
27
|
+
)
|
|
28
|
+
cfg = load_project(tmp_path)
|
|
29
|
+
assert cfg.object_root == "pgsql/schema"
|
|
30
|
+
assert cfg.environment == "postgres"
|
|
31
|
+
assert cfg.targets == {
|
|
32
|
+
"dev": "test/dbly.dev.properties",
|
|
33
|
+
"prod": "test/dbly.prod.properties",
|
|
34
|
+
}
|
|
35
|
+
assert "**/deploy-*.sql" in cfg.ignore
|
|
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
|