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.
Files changed (42) hide show
  1. {dbly-0.3.0 → dbly-0.6.0}/CHANGELOG.md +66 -0
  2. {dbly-0.3.0 → dbly-0.6.0}/PKG-INFO +31 -1
  3. {dbly-0.3.0 → dbly-0.6.0}/README.md +30 -0
  4. {dbly-0.3.0 → dbly-0.6.0}/pyproject.toml +1 -1
  5. {dbly-0.3.0 → dbly-0.6.0}/src/dbly/__init__.py +1 -1
  6. {dbly-0.3.0 → dbly-0.6.0}/src/dbly/adapters/mssql.py +2 -1
  7. {dbly-0.3.0 → dbly-0.6.0}/src/dbly/adapters/oracle.py +9 -1
  8. {dbly-0.3.0 → dbly-0.6.0}/src/dbly/adapters/postgres.py +46 -7
  9. {dbly-0.3.0 → dbly-0.6.0}/src/dbly/adapters/sqlite.py +1 -1
  10. {dbly-0.3.0 → dbly-0.6.0}/src/dbly/cli.py +147 -32
  11. {dbly-0.3.0 → dbly-0.6.0}/src/dbly/drift.py +16 -3
  12. dbly-0.6.0/src/dbly/export.py +127 -0
  13. {dbly-0.3.0 → dbly-0.6.0}/src/dbly/model.py +3 -0
  14. dbly-0.6.0/src/dbly/project.py +54 -0
  15. {dbly-0.3.0 → dbly-0.6.0}/src/dbly/repo.py +56 -9
  16. {dbly-0.3.0 → dbly-0.6.0}/src/dbly/report.py +76 -0
  17. {dbly-0.3.0 → dbly-0.6.0}/tests/test_integration.py +111 -0
  18. {dbly-0.3.0 → dbly-0.6.0}/tests/test_parsing.py +18 -0
  19. dbly-0.6.0/tests/test_project.py +35 -0
  20. {dbly-0.3.0 → dbly-0.6.0}/.github/FUNDING.yml +0 -0
  21. {dbly-0.3.0 → dbly-0.6.0}/.github/workflows/ci.yml +0 -0
  22. {dbly-0.3.0 → dbly-0.6.0}/.github/workflows/publish.yml +0 -0
  23. {dbly-0.3.0 → dbly-0.6.0}/.gitignore +0 -0
  24. {dbly-0.3.0 → dbly-0.6.0}/TODO.md +0 -0
  25. {dbly-0.3.0 → dbly-0.6.0}/docs/dbly_head.png +0 -0
  26. {dbly-0.3.0 → dbly-0.6.0}/examples/ci/README.md +0 -0
  27. {dbly-0.3.0 → dbly-0.6.0}/examples/ci/bitbucket-pipelines.yml +0 -0
  28. {dbly-0.3.0 → dbly-0.6.0}/examples/ci/github-actions.yml +0 -0
  29. {dbly-0.3.0 → dbly-0.6.0}/src/dbly/adapters/__init__.py +0 -0
  30. {dbly-0.3.0 → dbly-0.6.0}/src/dbly/adapters/base.py +0 -0
  31. {dbly-0.3.0 → dbly-0.6.0}/src/dbly/config.py +0 -0
  32. {dbly-0.3.0 → dbly-0.6.0}/src/dbly/engine.py +0 -0
  33. {dbly-0.3.0 → dbly-0.6.0}/src/dbly/hooks.py +0 -0
  34. {dbly-0.3.0 → dbly-0.6.0}/src/dbly/initializer.py +0 -0
  35. {dbly-0.3.0 → dbly-0.6.0}/src/dbly/parsing.py +0 -0
  36. {dbly-0.3.0 → dbly-0.6.0}/src/dbly/planner.py +0 -0
  37. {dbly-0.3.0 → dbly-0.6.0}/src/dbly/py.typed +0 -0
  38. {dbly-0.3.0 → dbly-0.6.0}/tests/test_mssql.py +0 -0
  39. {dbly-0.3.0 → dbly-0.6.0}/tests/test_mssql_e2e.py +0 -0
  40. {dbly-0.3.0 → dbly-0.6.0}/tests/test_oracle.py +0 -0
  41. {dbly-0.3.0 → dbly-0.6.0}/tests/test_oracle_e2e.py +0 -0
  42. {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.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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "dbly"
3
- version = "0.3.0"
3
+ version = "0.6.0"
4
4
  description = "State-based, cross-engine database deployment — git-driven, parser-assisted, SQL-first."
5
5
  readme = "README.md"
6
6
  license = { text = "MIT" }
@@ -4,4 +4,4 @@ git (what changed) + sqlglot (what it is) + live introspection (what's really th
4
4
  See CONCEPT.md for the design rationale.
5
5
  """
6
6
 
7
- __version__ = "0.3.0"
7
+ __version__ = "0.6.0"
@@ -106,7 +106,8 @@ class MssqlAdapter(Adapter):
106
106
  if kind is None:
107
107
  continue
108
108
  h = canonical_hash(src, dialect="tsql") if kind in hashed else None
109
- obj = LiveObject(kind, ObjectId(schema, name), h)
109
+ # OBJECT_DEFINITION is NULL for tables (their DDL is rebuilt from columns).
110
+ obj = LiveObject(kind, ObjectId(schema, name), h, src)
110
111
  found[obj.key()] = obj
111
112
  for schema, name in conn.execute(idx):
112
113
  obj = LiveObject(ObjectKind.INDEX, ObjectId(schema, name))
@@ -155,6 +155,7 @@ class OracleAdapter(Adapter):
155
155
  "('FUNCTION','PROCEDURE','TRIGGER','PACKAGE','PACKAGE BODY','TYPE') "
156
156
  "ORDER BY name, type, line"
157
157
  )
158
+ views = text("SELECT view_name, text FROM all_views WHERE owner = USER")
158
159
  owner = self.default_schema # objects are filtered by owner = USER — carry it on the id
159
160
  found: dict[str, LiveObject] = {}
160
161
  src_text: dict[str, list[str]] = {}
@@ -171,9 +172,16 @@ class OracleAdapter(Adapter):
171
172
  continue
172
173
  key = LiveObject(kind, ObjectId(owner, name)).key()
173
174
  src_text.setdefault(key, []).append(line or "")
175
+ for name, vtext in conn.execute(views):
176
+ key = LiveObject(ObjectKind.VIEW, ObjectId(owner, name)).key()
177
+ if key in found and vtext is not None:
178
+ found[key].definition = f"CREATE VIEW {owner}.{name} AS\n{vtext}"
174
179
  for key, lines in src_text.items():
175
180
  if key in found:
176
- found[key].source_hash = canonical_hash("".join(lines), dialect="oracle")
181
+ body = "".join(lines)
182
+ found[key].source_hash = canonical_hash(body, dialect="oracle")
183
+ # all_source starts at "FUNCTION foo…"/"PROCEDURE…"; prefix CREATE OR REPLACE.
184
+ found[key].definition = "CREATE OR REPLACE " + body.lstrip()
177
185
  return list(found.values())
178
186
 
179
187
  def add_column_sql(self, table: ObjectId, col: Column) -> str:
@@ -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"],
@@ -90,16 +126,19 @@ class PostgresAdapter(Adapter):
90
126
  kind = self._RELKIND.get(relkind)
91
127
  if kind is None:
92
128
  continue
93
- h = canonical_hash(src, dialect="postgres") if kind is ObjectKind.VIEW else None
94
- obj = LiveObject(kind, ObjectId(schema, name), h)
129
+ is_view = kind is ObjectKind.VIEW
130
+ h = canonical_hash(src, dialect="postgres") if is_view else None
131
+ defn = f"CREATE VIEW {schema}.{name} AS\n{src}" if is_view and src else None
132
+ obj = LiveObject(kind, ObjectId(schema, name), h, defn)
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), canonical_hash(src, dialect="postgres"))
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
- pass
69
+ _quiet_parser_noise(debug)
70
+
71
+
72
+ def _open_repo(
73
+ repo_path: Path, project: ProjectConfig,
74
+ *, schemas: Optional[list[str]] = None, paths: Optional[list[str]] = None,
75
+ ) -> Repo:
76
+ return Repo(
77
+ repo_path,
78
+ object_root=project.object_root,
79
+ extra_ignore=project.ignore,
80
+ select_schemas=schemas or None,
81
+ select_paths=paths or None,
82
+ )
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
- repo = Repo(repo_path)
72
- cfg = resolve_target(target)
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(resolve_target(target))
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
- repo = Repo(repo_path)
169
- cfg = resolve_target(target)
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
- repo = Repo(repo_path)
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(resolve_target(init_target))
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
- repo = Repo(repo_path)
274
- cfg = resolve_target(target)
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
- if rep.clean:
287
- console.print("[green]no drift — database matches the desired state.[/green]")
288
- return
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
- for kind, oid in rep.missing:
291
- console.print(f"[yellow]missing[/yellow] {kind.value} {oid} (in repo, not in DB)")
292
- for cd in rep.columns:
293
- if cd.added:
294
- console.print(f"[yellow]columns[/yellow] {cd.table} to add: {', '.join(cd.added)}")
295
- if cd.removed:
296
- console.print(f"[red]columns[/red] {cd.table} only in DB: {', '.join(cd.removed)}")
297
- for kind, oid in rep.definitions:
298
- console.print(f"[yellow]changed[/yellow] {kind.value} {oid} (definition differs — advisory)")
299
- for kind, oid in rep.orphaned:
300
- console.print(f"[dim]orphaned[/dim] {kind.value} {oid} (in DB, not in repo)")
301
- raise typer.Exit(code=1)
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 not in desired:
82
- report.orphaned.append((o.kind, o.id))
83
+ if key in desired:
84
+ continue
85
+ if sel is not None and (o.id.schema is None or o.id.schema.lower() not in sel):
86
+ continue
87
+ report.orphaned.append((o.kind, o.id))
83
88
 
84
89
  for key, obj in desired.items():
85
90
  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)}
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__(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
+ 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
- self._ignore = self._load_dbignore()
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, not a migration, not ignored)."""
63
- return self._is_sql(rel) and not self._is_migration(rel) and not self.is_ignored(rel)
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
- Only a *hint* the parser overrides it when the DDL is schema-qualified.
173
- Returns None when the file sits at the repo root.
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
- parts = rel.parts
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