dbly 0.3.0__tar.gz → 0.4.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.4.0}/CHANGELOG.md +28 -0
- {dbly-0.3.0 → dbly-0.4.0}/PKG-INFO +22 -1
- {dbly-0.3.0 → dbly-0.4.0}/README.md +21 -0
- {dbly-0.3.0 → dbly-0.4.0}/pyproject.toml +1 -1
- {dbly-0.3.0 → dbly-0.4.0}/src/dbly/__init__.py +1 -1
- {dbly-0.3.0 → dbly-0.4.0}/src/dbly/adapters/postgres.py +39 -3
- {dbly-0.3.0 → dbly-0.4.0}/src/dbly/cli.py +38 -12
- {dbly-0.3.0 → dbly-0.4.0}/src/dbly/drift.py +10 -1
- dbly-0.4.0/src/dbly/project.py +54 -0
- {dbly-0.3.0 → dbly-0.4.0}/src/dbly/repo.py +37 -9
- {dbly-0.3.0 → dbly-0.4.0}/tests/test_integration.py +47 -0
- dbly-0.4.0/tests/test_project.py +35 -0
- {dbly-0.3.0 → dbly-0.4.0}/.github/FUNDING.yml +0 -0
- {dbly-0.3.0 → dbly-0.4.0}/.github/workflows/ci.yml +0 -0
- {dbly-0.3.0 → dbly-0.4.0}/.github/workflows/publish.yml +0 -0
- {dbly-0.3.0 → dbly-0.4.0}/.gitignore +0 -0
- {dbly-0.3.0 → dbly-0.4.0}/TODO.md +0 -0
- {dbly-0.3.0 → dbly-0.4.0}/docs/dbly_head.png +0 -0
- {dbly-0.3.0 → dbly-0.4.0}/examples/ci/README.md +0 -0
- {dbly-0.3.0 → dbly-0.4.0}/examples/ci/bitbucket-pipelines.yml +0 -0
- {dbly-0.3.0 → dbly-0.4.0}/examples/ci/github-actions.yml +0 -0
- {dbly-0.3.0 → dbly-0.4.0}/src/dbly/adapters/__init__.py +0 -0
- {dbly-0.3.0 → dbly-0.4.0}/src/dbly/adapters/base.py +0 -0
- {dbly-0.3.0 → dbly-0.4.0}/src/dbly/adapters/mssql.py +0 -0
- {dbly-0.3.0 → dbly-0.4.0}/src/dbly/adapters/oracle.py +0 -0
- {dbly-0.3.0 → dbly-0.4.0}/src/dbly/adapters/sqlite.py +0 -0
- {dbly-0.3.0 → dbly-0.4.0}/src/dbly/config.py +0 -0
- {dbly-0.3.0 → dbly-0.4.0}/src/dbly/engine.py +0 -0
- {dbly-0.3.0 → dbly-0.4.0}/src/dbly/hooks.py +0 -0
- {dbly-0.3.0 → dbly-0.4.0}/src/dbly/initializer.py +0 -0
- {dbly-0.3.0 → dbly-0.4.0}/src/dbly/model.py +0 -0
- {dbly-0.3.0 → dbly-0.4.0}/src/dbly/parsing.py +0 -0
- {dbly-0.3.0 → dbly-0.4.0}/src/dbly/planner.py +0 -0
- {dbly-0.3.0 → dbly-0.4.0}/src/dbly/py.typed +0 -0
- {dbly-0.3.0 → dbly-0.4.0}/src/dbly/report.py +0 -0
- {dbly-0.3.0 → dbly-0.4.0}/tests/test_mssql.py +0 -0
- {dbly-0.3.0 → dbly-0.4.0}/tests/test_mssql_e2e.py +0 -0
- {dbly-0.3.0 → dbly-0.4.0}/tests/test_oracle.py +0 -0
- {dbly-0.3.0 → dbly-0.4.0}/tests/test_oracle_e2e.py +0 -0
- {dbly-0.3.0 → dbly-0.4.0}/tests/test_parsing.py +0 -0
- {dbly-0.3.0 → dbly-0.4.0}/uv.lock +0 -0
|
@@ -7,6 +7,34 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.4.0] — 2026-07-23
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **Project configuration (`dbly.toml`).** An optional file at the repo root, for repos that
|
|
15
|
+
need more than a single connection profile:
|
|
16
|
+
- `object_root` — the subtree the declarative object files live under. The schema hint is
|
|
17
|
+
taken from the first path segment *below* this root, so a layout like
|
|
18
|
+
`pgsql/schema/<schema>/<obj>` maps to the right schema instead of the literal top folder.
|
|
19
|
+
- `environment` — default engine/dialect when a profile omits `environment=`.
|
|
20
|
+
- `[targets]` — named connection profiles, so `dbly plan --target dev` resolves to a
|
|
21
|
+
profile path.
|
|
22
|
+
- `ignore` — extra ignore patterns (gitwildmatch), merged with `.dbignore` (e.g. to skip a
|
|
23
|
+
handful of files a parser cannot read).
|
|
24
|
+
|
|
25
|
+
Absent config → unchanged behaviour (`object_root="."`, `--target` is a profile path).
|
|
26
|
+
|
|
27
|
+
### Fixed
|
|
28
|
+
|
|
29
|
+
- **Case-insensitive identifier matching on PostgreSQL.** Postgres folds unquoted identifiers
|
|
30
|
+
to lower case, so a DDL `CREATE TABLE FOO` lives as `foo`. `plan` (`table_exists`/
|
|
31
|
+
`get_columns`) and `check` looked the object up by the *declared* name and either planned a
|
|
32
|
+
spurious full `CREATE` or crashed with `NoSuchTableError`. Both now resolve the object
|
|
33
|
+
case-insensitively and reflect it by its real, live identity.
|
|
34
|
+
- **`check` no longer aborts on a single unreflectable table.** A table whose columns cannot
|
|
35
|
+
be introspected is reported as advisory `unreadable` and the drift scan continues, instead
|
|
36
|
+
of failing the whole run.
|
|
37
|
+
|
|
10
38
|
## [0.3.0] — 2026-07-21
|
|
11
39
|
|
|
12
40
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: dbly
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.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):
|
|
@@ -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):
|
|
@@ -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"],
|
|
@@ -16,11 +16,12 @@ from rich.console import Console
|
|
|
16
16
|
|
|
17
17
|
from dbly import __version__, drift, hooks, initializer, report
|
|
18
18
|
from dbly.adapters import get_adapter
|
|
19
|
-
from dbly.config import resolve_target
|
|
19
|
+
from dbly.config import ConnectionConfig, load_profile, resolve_target
|
|
20
20
|
from dbly.engine import detect_dialect
|
|
21
21
|
from dbly.parsing import sqlglot_dialect
|
|
22
22
|
from dbly.planner import build_plan
|
|
23
23
|
from dbly.model import Plan, Severity
|
|
24
|
+
from dbly.project import ProjectConfig, load_project
|
|
24
25
|
from dbly.repo import WORKTREE, Repo
|
|
25
26
|
|
|
26
27
|
app = typer.Typer(
|
|
@@ -48,6 +49,24 @@ def _main(
|
|
|
48
49
|
pass
|
|
49
50
|
|
|
50
51
|
|
|
52
|
+
def _open_repo(repo_path: Path, project: ProjectConfig) -> Repo:
|
|
53
|
+
return Repo(repo_path, object_root=project.object_root, extra_ignore=project.ignore)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _resolve_target(project: ProjectConfig, repo_path: Path, target: str) -> ConnectionConfig:
|
|
57
|
+
"""Resolve ``--target``: a named target from ``dbly.toml`` [targets], else a profile path.
|
|
58
|
+
|
|
59
|
+
The project's ``environment`` fills in when the profile omits ``environment=``.
|
|
60
|
+
"""
|
|
61
|
+
if target in project.targets:
|
|
62
|
+
cfg = load_profile(repo_path / project.targets[target])
|
|
63
|
+
else:
|
|
64
|
+
cfg = resolve_target(target)
|
|
65
|
+
if cfg.environment is None and project.environment:
|
|
66
|
+
cfg.environment = project.environment
|
|
67
|
+
return cfg
|
|
68
|
+
|
|
69
|
+
|
|
51
70
|
def _decorations(repo_path: Path, plan: Plan) -> dict[str, str]:
|
|
52
71
|
"""Map each real ref in the plan to its git tag/branch names (for the plan header)."""
|
|
53
72
|
try:
|
|
@@ -68,8 +87,9 @@ def _make_plan(
|
|
|
68
87
|
repo_path: Path, target: str, from_ref: Optional[str], to_ref: str,
|
|
69
88
|
*, worktree: bool = False,
|
|
70
89
|
) -> Plan:
|
|
71
|
-
|
|
72
|
-
|
|
90
|
+
project = load_project(repo_path)
|
|
91
|
+
repo = _open_repo(repo_path, project)
|
|
92
|
+
cfg = _resolve_target(project, repo_path, target)
|
|
73
93
|
dialect = sqlglot_dialect(detect_dialect(cfg))
|
|
74
94
|
adapter = get_adapter(cfg)
|
|
75
95
|
try:
|
|
@@ -113,7 +133,7 @@ def plan(
|
|
|
113
133
|
console.print(f"\n[dim]plan (YAML) written to {out}[/dim]")
|
|
114
134
|
if sql:
|
|
115
135
|
# state_table_ddl / record_deploy_sql are pure string builders — no DB connection.
|
|
116
|
-
adapter = get_adapter(
|
|
136
|
+
adapter = get_adapter(_resolve_target(load_project(repo_path), repo_path, target))
|
|
117
137
|
try:
|
|
118
138
|
script = report.plan_to_sql(
|
|
119
139
|
plan_obj,
|
|
@@ -165,8 +185,9 @@ def apply(
|
|
|
165
185
|
console.print("[green]nothing to apply.[/green]")
|
|
166
186
|
return
|
|
167
187
|
|
|
168
|
-
|
|
169
|
-
|
|
188
|
+
project = load_project(repo_path)
|
|
189
|
+
repo = _open_repo(repo_path, project)
|
|
190
|
+
cfg = _resolve_target(project, repo_path, target)
|
|
170
191
|
adapter = get_adapter(cfg)
|
|
171
192
|
try:
|
|
172
193
|
_run_hooks(repo, "pre", py_interpreter)
|
|
@@ -204,12 +225,13 @@ def init(
|
|
|
204
225
|
|
|
205
226
|
Greenfield only — brownfield (a handed-over database) skips this entirely.
|
|
206
227
|
"""
|
|
207
|
-
|
|
228
|
+
project = load_project(repo_path)
|
|
229
|
+
repo = _open_repo(repo_path, project)
|
|
208
230
|
scripts = initializer.discover_init_scripts(repo.root, init_dir)
|
|
209
231
|
if not scripts:
|
|
210
232
|
console.print(f"[yellow]no init scripts in {init_dir}/ — nothing to do.[/yellow]")
|
|
211
233
|
return
|
|
212
|
-
adapter = get_adapter(
|
|
234
|
+
adapter = get_adapter(_resolve_target(project, repo_path, init_target))
|
|
213
235
|
try:
|
|
214
236
|
for s in scripts:
|
|
215
237
|
adapter.run_init_script(s.read_text(encoding="utf-8"))
|
|
@@ -270,8 +292,9 @@ def check(
|
|
|
270
292
|
),
|
|
271
293
|
) -> None:
|
|
272
294
|
"""Detect drift: compare the desired state at <to> against the live database."""
|
|
273
|
-
|
|
274
|
-
|
|
295
|
+
project = load_project(repo_path)
|
|
296
|
+
repo = _open_repo(repo_path, project)
|
|
297
|
+
cfg = _resolve_target(project, repo_path, target)
|
|
275
298
|
dialect = sqlglot_dialect(detect_dialect(cfg))
|
|
276
299
|
adapter = get_adapter(cfg)
|
|
277
300
|
try:
|
|
@@ -283,7 +306,7 @@ def check(
|
|
|
283
306
|
finally:
|
|
284
307
|
adapter.dispose()
|
|
285
308
|
|
|
286
|
-
if rep.clean:
|
|
309
|
+
if rep.clean and not rep.unreadable:
|
|
287
310
|
console.print("[green]no drift — database matches the desired state.[/green]")
|
|
288
311
|
return
|
|
289
312
|
|
|
@@ -298,7 +321,10 @@ def check(
|
|
|
298
321
|
console.print(f"[yellow]changed[/yellow] {kind.value} {oid} (definition differs — advisory)")
|
|
299
322
|
for kind, oid in rep.orphaned:
|
|
300
323
|
console.print(f"[dim]orphaned[/dim] {kind.value} {oid} (in DB, not in repo)")
|
|
301
|
-
|
|
324
|
+
for kind, oid in rep.unreadable:
|
|
325
|
+
console.print(f"[dim]unreadable[/dim] {kind.value} {oid} (columns could not be reflected — advisory)")
|
|
326
|
+
if not rep.clean:
|
|
327
|
+
raise typer.Exit(code=1)
|
|
302
328
|
|
|
303
329
|
|
|
304
330
|
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:
|
|
@@ -83,7 +84,15 @@ def compute_drift(
|
|
|
83
84
|
|
|
84
85
|
for key, obj in desired.items():
|
|
85
86
|
if obj.kind is ObjectKind.TABLE and key in live:
|
|
86
|
-
|
|
87
|
+
# Reflect columns by the LIVE object's identity — the engine may have folded the
|
|
88
|
+
# declared name's case (unquoted `FOO` → `foo` on Postgres), so the desired name
|
|
89
|
+
# need not exist verbatim. A table that cannot be reflected is skipped, not fatal.
|
|
90
|
+
live_id = live[key].id
|
|
91
|
+
try:
|
|
92
|
+
actual = {c.key() for c in adapter.get_columns(live_id.schema, live_id.name)}
|
|
93
|
+
except Exception: # noqa: BLE001 — one odd table must not abort the whole check
|
|
94
|
+
report.unreadable.append((obj.kind, obj.id))
|
|
95
|
+
continue
|
|
87
96
|
want = {c.key() for c in parsing.desired_columns(obj.sql, dialect=dialect)}
|
|
88
97
|
added, removed = sorted(want - actual), sorted(actual - want)
|
|
89
98
|
if added or removed:
|
|
@@ -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,21 @@ 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
|
+
):
|
|
31
37
|
self.root = root.resolve()
|
|
32
38
|
if not (self.root / ".git").exists():
|
|
33
39
|
raise ValueError(f"not a git repository: {self.root}")
|
|
34
|
-
|
|
40
|
+
# the subtree object files live under; the schema hint is the first segment *below* it.
|
|
41
|
+
self.object_root = (
|
|
42
|
+
Path(object_root) if object_root and object_root not in (".", "") else None
|
|
43
|
+
)
|
|
44
|
+
self._ignore = self._load_dbignore(extra_ignore or [])
|
|
35
45
|
|
|
36
46
|
def _git(self, *args: str) -> str:
|
|
37
47
|
out = subprocess.run(
|
|
@@ -42,10 +52,10 @@ class Repo:
|
|
|
42
52
|
)
|
|
43
53
|
return out.stdout
|
|
44
54
|
|
|
45
|
-
def _load_dbignore(self) -> pathspec.PathSpec:
|
|
55
|
+
def _load_dbignore(self, extra: list[str]) -> pathspec.PathSpec:
|
|
46
56
|
f = self.root / ".dbignore"
|
|
47
57
|
lines = f.read_text(encoding="utf-8").splitlines() if f.exists() else []
|
|
48
|
-
return pathspec.PathSpec.from_lines("gitwildmatch", lines)
|
|
58
|
+
return pathspec.PathSpec.from_lines("gitwildmatch", [*lines, *extra])
|
|
49
59
|
|
|
50
60
|
def is_ignored(self, rel: Path) -> bool:
|
|
51
61
|
return self._ignore.match_file(rel.as_posix())
|
|
@@ -58,9 +68,19 @@ class Repo:
|
|
|
58
68
|
def _is_migration(rel: Path) -> bool:
|
|
59
69
|
return len(rel.parts) > 0 and rel.parts[0] == MIGRATIONS_DIR
|
|
60
70
|
|
|
71
|
+
def _under_object_root(self, rel: Path) -> bool:
|
|
72
|
+
if self.object_root is None:
|
|
73
|
+
return True
|
|
74
|
+
return rel == self.object_root or self.object_root in rel.parents
|
|
75
|
+
|
|
61
76
|
def _is_object(self, rel: Path) -> bool:
|
|
62
|
-
"""A deployable declarative object file (SQL, not a migration
|
|
63
|
-
return
|
|
77
|
+
"""A deployable declarative object file (SQL, under object_root, not a migration/ignored)."""
|
|
78
|
+
return (
|
|
79
|
+
self._is_sql(rel)
|
|
80
|
+
and not self._is_migration(rel)
|
|
81
|
+
and self._under_object_root(rel)
|
|
82
|
+
and not self.is_ignored(rel)
|
|
83
|
+
)
|
|
64
84
|
|
|
65
85
|
def _untracked_files(self) -> list[Path]:
|
|
66
86
|
"""New files in the working tree not yet added to git (``git status`` "??")."""
|
|
@@ -169,8 +189,16 @@ class Repo:
|
|
|
169
189
|
def schema_for(self, rel: Path) -> str | None:
|
|
170
190
|
"""Best-practice convention: the first path segment names the DB schema.
|
|
171
191
|
|
|
172
|
-
|
|
173
|
-
|
|
192
|
+
With an ``object_root`` set, the segment is taken *relative to that root* — so
|
|
193
|
+
``pgsql/schema/bas/foo.tbl`` under root ``pgsql/schema`` yields schema ``bas``.
|
|
194
|
+
Only a *hint* — the parser overrides it when the DDL is schema-qualified. Returns
|
|
195
|
+
None when the file sits directly at the (object) root.
|
|
174
196
|
"""
|
|
175
|
-
|
|
197
|
+
r = rel
|
|
198
|
+
if self.object_root is not None:
|
|
199
|
+
try:
|
|
200
|
+
r = rel.relative_to(self.object_root)
|
|
201
|
+
except ValueError:
|
|
202
|
+
return None
|
|
203
|
+
parts = r.parts
|
|
176
204
|
return parts[0] if len(parts) > 1 else None
|
|
@@ -368,3 +368,50 @@ 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
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|