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.
Files changed (41) hide show
  1. {dbly-0.3.0 → dbly-0.4.0}/CHANGELOG.md +28 -0
  2. {dbly-0.3.0 → dbly-0.4.0}/PKG-INFO +22 -1
  3. {dbly-0.3.0 → dbly-0.4.0}/README.md +21 -0
  4. {dbly-0.3.0 → dbly-0.4.0}/pyproject.toml +1 -1
  5. {dbly-0.3.0 → dbly-0.4.0}/src/dbly/__init__.py +1 -1
  6. {dbly-0.3.0 → dbly-0.4.0}/src/dbly/adapters/postgres.py +39 -3
  7. {dbly-0.3.0 → dbly-0.4.0}/src/dbly/cli.py +38 -12
  8. {dbly-0.3.0 → dbly-0.4.0}/src/dbly/drift.py +10 -1
  9. dbly-0.4.0/src/dbly/project.py +54 -0
  10. {dbly-0.3.0 → dbly-0.4.0}/src/dbly/repo.py +37 -9
  11. {dbly-0.3.0 → dbly-0.4.0}/tests/test_integration.py +47 -0
  12. dbly-0.4.0/tests/test_project.py +35 -0
  13. {dbly-0.3.0 → dbly-0.4.0}/.github/FUNDING.yml +0 -0
  14. {dbly-0.3.0 → dbly-0.4.0}/.github/workflows/ci.yml +0 -0
  15. {dbly-0.3.0 → dbly-0.4.0}/.github/workflows/publish.yml +0 -0
  16. {dbly-0.3.0 → dbly-0.4.0}/.gitignore +0 -0
  17. {dbly-0.3.0 → dbly-0.4.0}/TODO.md +0 -0
  18. {dbly-0.3.0 → dbly-0.4.0}/docs/dbly_head.png +0 -0
  19. {dbly-0.3.0 → dbly-0.4.0}/examples/ci/README.md +0 -0
  20. {dbly-0.3.0 → dbly-0.4.0}/examples/ci/bitbucket-pipelines.yml +0 -0
  21. {dbly-0.3.0 → dbly-0.4.0}/examples/ci/github-actions.yml +0 -0
  22. {dbly-0.3.0 → dbly-0.4.0}/src/dbly/adapters/__init__.py +0 -0
  23. {dbly-0.3.0 → dbly-0.4.0}/src/dbly/adapters/base.py +0 -0
  24. {dbly-0.3.0 → dbly-0.4.0}/src/dbly/adapters/mssql.py +0 -0
  25. {dbly-0.3.0 → dbly-0.4.0}/src/dbly/adapters/oracle.py +0 -0
  26. {dbly-0.3.0 → dbly-0.4.0}/src/dbly/adapters/sqlite.py +0 -0
  27. {dbly-0.3.0 → dbly-0.4.0}/src/dbly/config.py +0 -0
  28. {dbly-0.3.0 → dbly-0.4.0}/src/dbly/engine.py +0 -0
  29. {dbly-0.3.0 → dbly-0.4.0}/src/dbly/hooks.py +0 -0
  30. {dbly-0.3.0 → dbly-0.4.0}/src/dbly/initializer.py +0 -0
  31. {dbly-0.3.0 → dbly-0.4.0}/src/dbly/model.py +0 -0
  32. {dbly-0.3.0 → dbly-0.4.0}/src/dbly/parsing.py +0 -0
  33. {dbly-0.3.0 → dbly-0.4.0}/src/dbly/planner.py +0 -0
  34. {dbly-0.3.0 → dbly-0.4.0}/src/dbly/py.typed +0 -0
  35. {dbly-0.3.0 → dbly-0.4.0}/src/dbly/report.py +0 -0
  36. {dbly-0.3.0 → dbly-0.4.0}/tests/test_mssql.py +0 -0
  37. {dbly-0.3.0 → dbly-0.4.0}/tests/test_mssql_e2e.py +0 -0
  38. {dbly-0.3.0 → dbly-0.4.0}/tests/test_oracle.py +0 -0
  39. {dbly-0.3.0 → dbly-0.4.0}/tests/test_oracle_e2e.py +0 -0
  40. {dbly-0.3.0 → dbly-0.4.0}/tests/test_parsing.py +0 -0
  41. {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.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):
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "dbly"
3
- version = "0.3.0"
3
+ version = "0.4.0"
4
4
  description = "State-based, cross-engine database deployment — git-driven, parser-assisted, SQL-first."
5
5
  readme = "README.md"
6
6
  license = { text = "MIT" }
@@ -4,4 +4,4 @@ git (what changed) + sqlglot (what it is) + live introspection (what's really th
4
4
  See CONCEPT.md for the design rationale.
5
5
  """
6
6
 
7
- __version__ = "0.3.0"
7
+ __version__ = "0.4.0"
@@ -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
- insp = inspect(self.engine)
31
- return insp.has_table(name, schema=schema)
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
- cols = insp.get_columns(name, schema=schema)
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
- repo = Repo(repo_path)
72
- cfg = resolve_target(target)
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(resolve_target(target))
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
- repo = Repo(repo_path)
169
- cfg = resolve_target(target)
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
- repo = Repo(repo_path)
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(resolve_target(init_target))
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
- repo = Repo(repo_path)
274
- cfg = resolve_target(target)
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
- raise typer.Exit(code=1)
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
- actual = {c.key() for c in adapter.get_columns(obj.id.schema, obj.id.name)}
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__(self, root: Path):
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
- self._ignore = self._load_dbignore()
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, not ignored)."""
63
- return self._is_sql(rel) and not self._is_migration(rel) and not self.is_ignored(rel)
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
- Only a *hint* the parser overrides it when the DDL is schema-qualified.
173
- Returns None when the file sits at the repo root.
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
- parts = rel.parts
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