dbly 0.0.1__tar.gz → 0.2.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 (37) hide show
  1. {dbly-0.0.1 → dbly-0.2.0}/PKG-INFO +8 -1
  2. {dbly-0.0.1 → dbly-0.2.0}/README.md +7 -0
  3. {dbly-0.0.1 → dbly-0.2.0}/pyproject.toml +1 -1
  4. {dbly-0.0.1 → dbly-0.2.0}/src/dbly/__init__.py +1 -1
  5. {dbly-0.0.1 → dbly-0.2.0}/src/dbly/adapters/base.py +33 -2
  6. {dbly-0.0.1 → dbly-0.2.0}/src/dbly/adapters/mssql.py +63 -1
  7. {dbly-0.0.1 → dbly-0.2.0}/src/dbly/adapters/oracle.py +57 -1
  8. {dbly-0.0.1 → dbly-0.2.0}/src/dbly/adapters/postgres.py +63 -1
  9. {dbly-0.0.1 → dbly-0.2.0}/src/dbly/adapters/sqlite.py +33 -1
  10. {dbly-0.0.1 → dbly-0.2.0}/src/dbly/cli.py +47 -12
  11. dbly-0.2.0/src/dbly/drift.py +98 -0
  12. {dbly-0.0.1 → dbly-0.2.0}/src/dbly/model.py +40 -1
  13. {dbly-0.0.1 → dbly-0.2.0}/src/dbly/parsing.py +51 -0
  14. {dbly-0.0.1 → dbly-0.2.0}/src/dbly/planner.py +64 -7
  15. {dbly-0.0.1 → dbly-0.2.0}/src/dbly/repo.py +25 -9
  16. {dbly-0.0.1 → dbly-0.2.0}/src/dbly/report.py +53 -15
  17. dbly-0.2.0/tests/test_integration.py +270 -0
  18. {dbly-0.0.1 → dbly-0.2.0}/tests/test_parsing.py +21 -0
  19. {dbly-0.0.1 → dbly-0.2.0}/uv.lock +1 -1
  20. dbly-0.0.1/tests/test_integration.py +0 -108
  21. {dbly-0.0.1 → dbly-0.2.0}/.github/workflows/ci.yml +0 -0
  22. {dbly-0.0.1 → dbly-0.2.0}/.github/workflows/publish.yml +0 -0
  23. {dbly-0.0.1 → dbly-0.2.0}/.gitignore +0 -0
  24. {dbly-0.0.1 → dbly-0.2.0}/docs/dbly_head.png +0 -0
  25. {dbly-0.0.1 → dbly-0.2.0}/examples/ci/README.md +0 -0
  26. {dbly-0.0.1 → dbly-0.2.0}/examples/ci/bitbucket-pipelines.yml +0 -0
  27. {dbly-0.0.1 → dbly-0.2.0}/examples/ci/github-actions.yml +0 -0
  28. {dbly-0.0.1 → dbly-0.2.0}/src/dbly/adapters/__init__.py +0 -0
  29. {dbly-0.0.1 → dbly-0.2.0}/src/dbly/config.py +0 -0
  30. {dbly-0.0.1 → dbly-0.2.0}/src/dbly/engine.py +0 -0
  31. {dbly-0.0.1 → dbly-0.2.0}/src/dbly/hooks.py +0 -0
  32. {dbly-0.0.1 → dbly-0.2.0}/src/dbly/initializer.py +0 -0
  33. {dbly-0.0.1 → dbly-0.2.0}/src/dbly/py.typed +0 -0
  34. {dbly-0.0.1 → dbly-0.2.0}/tests/test_mssql.py +0 -0
  35. {dbly-0.0.1 → dbly-0.2.0}/tests/test_mssql_e2e.py +0 -0
  36. {dbly-0.0.1 → dbly-0.2.0}/tests/test_oracle.py +0 -0
  37. {dbly-0.0.1 → dbly-0.2.0}/tests/test_oracle_e2e.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dbly
3
- Version: 0.0.1
3
+ Version: 0.2.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
@@ -81,10 +81,17 @@ db/
81
81
  v_open_orders.vw
82
82
  grants.sql
83
83
  init/ # optional: privileged greenfield groundwork (CREATE DATABASE/ROLE…)
84
+ migrations/ # optional: ordered, run-once ALTERs for renames / data backfills
84
85
  hooks/pre/ hooks/post/ # optional: .sql or .py hooks (e.g. ArcGIS/ArcPy steps)
85
86
  .dbignore # files in the repo that should not be deployed
86
87
  ```
87
88
 
89
+ Most changes are declarative — edit the object file, dbly figures out the additive diff. For
90
+ changes the diff can't do safely (renaming a column, moving data), drop an ordered script in
91
+ `migrations/` (`0001_…sql`); it runs exactly once, is recorded in the ledger, and the table
92
+ it touches defers to it for that deploy. On a fresh database, migrations are *baselined*
93
+ (recorded, not run) since the object files already describe the end state.
94
+
88
95
  ## Connect
89
96
 
90
97
  A connection profile (reuses the familiar `connection.properties` format):
@@ -43,10 +43,17 @@ db/
43
43
  v_open_orders.vw
44
44
  grants.sql
45
45
  init/ # optional: privileged greenfield groundwork (CREATE DATABASE/ROLE…)
46
+ migrations/ # optional: ordered, run-once ALTERs for renames / data backfills
46
47
  hooks/pre/ hooks/post/ # optional: .sql or .py hooks (e.g. ArcGIS/ArcPy steps)
47
48
  .dbignore # files in the repo that should not be deployed
48
49
  ```
49
50
 
51
+ Most changes are declarative — edit the object file, dbly figures out the additive diff. For
52
+ changes the diff can't do safely (renaming a column, moving data), drop an ordered script in
53
+ `migrations/` (`0001_…sql`); it runs exactly once, is recorded in the ledger, and the table
54
+ it touches defers to it for that deploy. On a fresh database, migrations are *baselined*
55
+ (recorded, not run) since the object files already describe the end state.
56
+
50
57
  ## Connect
51
58
 
52
59
  A connection profile (reuses the familiar `connection.properties` format):
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "dbly"
3
- version = "0.0.1"
3
+ version = "0.2.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.0.1"
7
+ __version__ = "0.2.0"
@@ -13,11 +13,11 @@ from __future__ import annotations
13
13
 
14
14
  import abc
15
15
 
16
- from sqlalchemy import Engine
16
+ from sqlalchemy import Engine, text
17
17
 
18
18
  from dbly.config import ConnectionConfig
19
19
  from dbly.engine import make_engine
20
- from dbly.model import Column, ObjectId
20
+ from dbly.model import Column, LiveObject, ObjectId, ObjectKind
21
21
 
22
22
  __all__ = ["Adapter", "Column"]
23
23
 
@@ -28,6 +28,10 @@ class Adapter(abc.ABC):
28
28
  #: whether DDL participates in transactions (drives apply strategy)
29
29
  transactional_ddl: bool = False
30
30
 
31
+ #: the engine's implicit schema — an unqualified repo object maps here (used by drift
32
+ #: matching so repo `kunde` aligns with live `public.kunde` / `dbo.kunde`).
33
+ default_schema: str | None = None
34
+
31
35
  def __init__(self, cfg: ConnectionConfig):
32
36
  self.cfg = cfg
33
37
  self._engine: Engine | None = None
@@ -45,6 +49,19 @@ class Adapter(abc.ABC):
45
49
  @abc.abstractmethod
46
50
  def get_columns(self, schema: str | None, name: str) -> list[Column]: ...
47
51
 
52
+ @abc.abstractmethod
53
+ def has_object(self, kind: ObjectKind, schema: str | None, name: str) -> bool:
54
+ """Existence check for a non-table object (used for index/sequence create-if-missing).
55
+
56
+ Indexes and sequences have no ``CREATE OR REPLACE`` form on most engines, so they
57
+ are created only when absent.
58
+ """
59
+
60
+ @abc.abstractmethod
61
+ def inventory(self) -> list[LiveObject]:
62
+ """Read-only live inventory of user objects across kinds, for drift detection
63
+ (``dbly check``). Procedural/definitional objects carry a canonical source hash."""
64
+
48
65
  # --- dialect-specific DDL generation -----------------------------------------------
49
66
  @abc.abstractmethod
50
67
  def add_column_sql(self, table: ObjectId, col: Column) -> str:
@@ -78,6 +95,20 @@ class Adapter(abc.ABC):
78
95
  @abc.abstractmethod
79
96
  def record_deploy(self, ref: str, migration_ids: list[str]) -> None: ...
80
97
 
98
+ # --- explicit migrations (run-once, ledger-tracked) --------------------------------
99
+ def applied_migrations(self) -> set[str]:
100
+ """Migration ids already recorded in the ledger (standard SQL, all engines)."""
101
+ self.ensure_state_table()
102
+ with self.engine.connect() as conn:
103
+ rows = conn.execute(
104
+ text("SELECT DISTINCT migration_id FROM dbly_state WHERE migration_id IS NOT NULL")
105
+ )
106
+ return {r[0] for r in rows}
107
+
108
+ def record_migration(self, ref: str, migration_id: str) -> None:
109
+ """Mark a migration applied (reuses the per-engine record_deploy insert)."""
110
+ self.record_deploy(ref, [migration_id])
111
+
81
112
  # --- pure SQL builders (no connection — used by `plan --sql` export) ----------------
82
113
  @abc.abstractmethod
83
114
  def state_table_ddl(self) -> str:
@@ -19,7 +19,8 @@ import re
19
19
  from sqlalchemy import inspect, text
20
20
 
21
21
  from dbly.adapters.base import Adapter, Column
22
- from dbly.model import ObjectId
22
+ from dbly.model import LiveObject, ObjectId, ObjectKind
23
+ from dbly.parsing import canonical_hash
23
24
 
24
25
  _GO_RE = re.compile(r"^\s*GO\s*$", re.IGNORECASE | re.MULTILINE)
25
26
 
@@ -38,6 +39,7 @@ END
38
39
 
39
40
  class MssqlAdapter(Adapter):
40
41
  transactional_ddl = True
42
+ default_schema = "dbo"
41
43
 
42
44
  def table_exists(self, schema: str | None, name: str) -> bool:
43
45
  return inspect(self.engine).has_table(name, schema=schema)
@@ -54,6 +56,66 @@ class MssqlAdapter(Adapter):
54
56
  for c in cols
55
57
  ]
56
58
 
59
+ def has_object(self, kind: ObjectKind, schema: str | None, name: str) -> bool:
60
+ with self.engine.connect() as conn:
61
+ if kind is ObjectKind.INDEX:
62
+ # index names are unique per table, not globally — best-effort by name
63
+ return conn.execute(
64
+ text("SELECT TOP 1 1 FROM sys.indexes WHERE name = :n"), {"n": name}
65
+ ).first() is not None
66
+ if kind is ObjectKind.SEQUENCE:
67
+ return conn.execute(
68
+ text(
69
+ "SELECT 1 FROM sys.sequences s "
70
+ "JOIN sys.schemas c ON c.schema_id = s.schema_id "
71
+ "WHERE s.name = :n AND (:s IS NULL OR c.name = :s)"
72
+ ),
73
+ {"n": name, "s": schema},
74
+ ).first() is not None
75
+ qname = f"{schema}.{name}" if schema else name
76
+ return conn.execute(text("SELECT OBJECT_ID(:q)"), {"q": qname}).scalar() is not None
77
+
78
+ _OBJTYPE = {
79
+ "U": ObjectKind.TABLE, "V": ObjectKind.VIEW, "P": ObjectKind.PROCEDURE,
80
+ "FN": ObjectKind.FUNCTION, "IF": ObjectKind.FUNCTION, "TF": ObjectKind.FUNCTION,
81
+ "TR": ObjectKind.TRIGGER,
82
+ }
83
+
84
+ def inventory(self) -> list[LiveObject]:
85
+ objs = text(
86
+ "SELECT s.name, o.name, RTRIM(o.type), OBJECT_DEFINITION(o.object_id) "
87
+ "FROM sys.objects o JOIN sys.schemas s ON s.schema_id = o.schema_id "
88
+ "WHERE o.is_ms_shipped = 0 AND RTRIM(o.type) IN ('U','V','P','FN','IF','TF','TR')"
89
+ )
90
+ idx = text(
91
+ "SELECT s.name, i.name FROM sys.indexes i "
92
+ "JOIN sys.objects o ON o.object_id = i.object_id "
93
+ "JOIN sys.schemas s ON s.schema_id = o.schema_id "
94
+ "WHERE o.is_ms_shipped = 0 AND i.name IS NOT NULL "
95
+ " AND i.is_primary_key = 0 AND i.type > 0"
96
+ )
97
+ seqs = text(
98
+ "SELECT s.name, q.name FROM sys.sequences q "
99
+ "JOIN sys.schemas s ON s.schema_id = q.schema_id"
100
+ )
101
+ hashed = {ObjectKind.VIEW, ObjectKind.PROCEDURE, ObjectKind.FUNCTION, ObjectKind.TRIGGER}
102
+ found: dict[str, LiveObject] = {}
103
+ with self.engine.connect() as conn:
104
+ for schema, name, otype, src in conn.execute(objs):
105
+ kind = self._OBJTYPE.get(otype)
106
+ if kind is None:
107
+ continue
108
+ h = canonical_hash(src, dialect="tsql") if kind in hashed else None
109
+ obj = LiveObject(kind, ObjectId(schema, name), h)
110
+ found[obj.key()] = obj
111
+ for schema, name in conn.execute(idx):
112
+ obj = LiveObject(ObjectKind.INDEX, ObjectId(schema, name))
113
+ found[obj.key()] = obj
114
+ for schema, name in conn.execute(seqs):
115
+ obj = LiveObject(ObjectKind.SEQUENCE, ObjectId(schema, name))
116
+ found[obj.key()] = obj
117
+ return list(found.values())
118
+
57
119
  def add_column_sql(self, table: ObjectId, col: Column) -> str:
58
120
  # T-SQL: ADD, not ADD COLUMN
59
121
  parts = [f"ALTER TABLE {table} ADD {col.name} {col.type}"]
@@ -24,7 +24,8 @@ import re
24
24
  from sqlalchemy import inspect, text
25
25
 
26
26
  from dbly.adapters.base import Adapter, Column
27
- from dbly.model import ObjectId
27
+ from dbly.model import LiveObject, ObjectId, ObjectKind
28
+ from dbly.parsing import canonical_hash
28
29
 
29
30
  _SLASH_RE = re.compile(r"(?m)^\s*/\s*$")
30
31
  _PLSQL_RE = re.compile(
@@ -103,6 +104,61 @@ class OracleAdapter(Adapter):
103
104
  for c in cols
104
105
  ]
105
106
 
107
+ def has_object(self, kind: ObjectKind, schema: str | None, name: str) -> bool:
108
+ n = name.upper()
109
+ s = schema.upper() if schema else None
110
+ if kind is ObjectKind.INDEX:
111
+ q = "SELECT 1 FROM all_indexes WHERE index_name = :n AND (:s IS NULL OR owner = :s)"
112
+ elif kind is ObjectKind.SEQUENCE:
113
+ q = ("SELECT 1 FROM all_sequences "
114
+ "WHERE sequence_name = :n AND (:s IS NULL OR sequence_owner = :s)")
115
+ else:
116
+ q = "SELECT 1 FROM all_objects WHERE object_name = :n AND (:s IS NULL OR owner = :s)"
117
+ with self.engine.connect() as conn:
118
+ return conn.execute(text(q), {"n": n, "s": s}).first() is not None
119
+
120
+ _TYPEMAP = {
121
+ "TABLE": ObjectKind.TABLE, "VIEW": ObjectKind.VIEW, "INDEX": ObjectKind.INDEX,
122
+ "SEQUENCE": ObjectKind.SEQUENCE, "FUNCTION": ObjectKind.FUNCTION,
123
+ "PROCEDURE": ObjectKind.PROCEDURE, "TRIGGER": ObjectKind.TRIGGER,
124
+ "PACKAGE": ObjectKind.PACKAGE, "PACKAGE BODY": ObjectKind.PACKAGE,
125
+ "TYPE": ObjectKind.TYPE,
126
+ }
127
+
128
+ def inventory(self) -> list[LiveObject]:
129
+ existence = text(
130
+ "SELECT object_type, object_name FROM all_objects "
131
+ "WHERE owner = USER AND object_name NOT LIKE 'BIN$%' AND object_type IN "
132
+ "('TABLE','VIEW','INDEX','SEQUENCE','FUNCTION','PROCEDURE','TRIGGER',"
133
+ " 'PACKAGE','PACKAGE BODY','TYPE')"
134
+ )
135
+ # procedural source, one round-trip, grouped by object (line-ordered VARCHAR2 — no LONG)
136
+ sources = text(
137
+ "SELECT name, type, text FROM all_source "
138
+ "WHERE owner = USER AND type IN "
139
+ "('FUNCTION','PROCEDURE','TRIGGER','PACKAGE','PACKAGE BODY','TYPE') "
140
+ "ORDER BY name, type, line"
141
+ )
142
+ found: dict[str, LiveObject] = {}
143
+ src_text: dict[str, list[str]] = {}
144
+ with self.engine.connect() as conn:
145
+ for otype, name in conn.execute(existence):
146
+ kind = self._TYPEMAP.get(otype)
147
+ if kind is None:
148
+ continue
149
+ obj = LiveObject(kind, ObjectId(None, name))
150
+ found[obj.key()] = obj
151
+ for name, otype, line in conn.execute(sources):
152
+ kind = self._TYPEMAP.get(otype)
153
+ if kind is None:
154
+ continue
155
+ key = LiveObject(kind, ObjectId(None, name)).key()
156
+ src_text.setdefault(key, []).append(line or "")
157
+ for key, lines in src_text.items():
158
+ if key in found:
159
+ found[key].source_hash = canonical_hash("".join(lines), dialect="oracle")
160
+ return list(found.values())
161
+
106
162
  def add_column_sql(self, table: ObjectId, col: Column) -> str:
107
163
  parts = [f"ALTER TABLE {table} ADD {col.name} {col.type}"]
108
164
  if col.default is not None:
@@ -9,7 +9,8 @@ from __future__ import annotations
9
9
  from sqlalchemy import inspect, text
10
10
 
11
11
  from dbly.adapters.base import Adapter, Column
12
- from dbly.model import ObjectId
12
+ from dbly.model import LiveObject, ObjectId, ObjectKind
13
+ from dbly.parsing import canonical_hash
13
14
 
14
15
  _STATE_DDL = """
15
16
  CREATE TABLE IF NOT EXISTS dbly_state (
@@ -23,6 +24,7 @@ CREATE TABLE IF NOT EXISTS dbly_state (
23
24
 
24
25
  class PostgresAdapter(Adapter):
25
26
  transactional_ddl = True
27
+ default_schema = "public"
26
28
 
27
29
  def table_exists(self, schema: str | None, name: str) -> bool:
28
30
  insp = inspect(self.engine)
@@ -41,6 +43,66 @@ class PostgresAdapter(Adapter):
41
43
  for c in cols
42
44
  ]
43
45
 
46
+ def has_object(self, kind: ObjectKind, schema: str | None, name: str) -> bool:
47
+ qname = f"{schema}.{name}" if schema else name
48
+ with self.engine.connect() as conn:
49
+ if kind in (ObjectKind.INDEX, ObjectKind.SEQUENCE, ObjectKind.TABLE, ObjectKind.VIEW):
50
+ return conn.execute(
51
+ text("SELECT to_regclass(:q)"), {"q": qname}
52
+ ).scalar() is not None
53
+ return conn.execute(
54
+ text(
55
+ "SELECT 1 FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace "
56
+ "WHERE p.proname = :n AND (:s IS NULL OR n.nspname = :s) LIMIT 1"
57
+ ),
58
+ {"n": name, "s": schema},
59
+ ).first() is not None
60
+
61
+ _RELKIND = {
62
+ "r": ObjectKind.TABLE, "p": ObjectKind.TABLE, "v": ObjectKind.VIEW,
63
+ "m": ObjectKind.VIEW, "i": ObjectKind.INDEX, "S": ObjectKind.SEQUENCE,
64
+ }
65
+
66
+ def inventory(self) -> list[LiveObject]:
67
+ rels = text(
68
+ "SELECT n.nspname, c.relname, c.relkind, "
69
+ " CASE WHEN c.relkind IN ('v','m') THEN pg_get_viewdef(c.oid) END "
70
+ "FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace "
71
+ "WHERE n.nspname NOT IN ('pg_catalog','information_schema','pg_toast') "
72
+ " AND c.relkind IN ('r','p','v','m','i','S')"
73
+ )
74
+ routines = text(
75
+ "SELECT n.nspname, p.proname, p.prokind, pg_get_functiondef(p.oid) "
76
+ "FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace "
77
+ "WHERE n.nspname NOT IN ('pg_catalog','information_schema') "
78
+ " AND p.prokind IN ('f','p')"
79
+ )
80
+ triggers = text(
81
+ "SELECT n.nspname, t.tgname, pg_get_triggerdef(t.oid) "
82
+ "FROM pg_trigger t JOIN pg_class c ON c.oid = t.tgrelid "
83
+ "JOIN pg_namespace n ON n.oid = c.relnamespace "
84
+ "WHERE NOT t.tgisinternal "
85
+ " AND n.nspname NOT IN ('pg_catalog','information_schema')"
86
+ )
87
+ found: dict[str, LiveObject] = {}
88
+ with self.engine.connect() as conn:
89
+ for schema, name, relkind, src in conn.execute(rels):
90
+ kind = self._RELKIND.get(relkind)
91
+ if kind is None:
92
+ continue
93
+ h = canonical_hash(src, dialect="postgres") if kind is ObjectKind.VIEW else None
94
+ obj = LiveObject(kind, ObjectId(schema, name), h)
95
+ found[obj.key()] = obj
96
+ for schema, name, prokind, src in conn.execute(routines):
97
+ kind = ObjectKind.PROCEDURE if prokind == "p" else ObjectKind.FUNCTION
98
+ obj = LiveObject(kind, ObjectId(schema, name), canonical_hash(src, dialect="postgres"))
99
+ found[obj.key()] = obj
100
+ for schema, name, src in conn.execute(triggers):
101
+ obj = LiveObject(ObjectKind.TRIGGER, ObjectId(schema, name),
102
+ canonical_hash(src, dialect="postgres"))
103
+ found[obj.key()] = obj
104
+ return list(found.values())
105
+
44
106
  def add_column_sql(self, table: ObjectId, col: Column) -> str:
45
107
  parts = [f"ALTER TABLE {table} ADD COLUMN {col.name} {col.type}"]
46
108
  if not col.nullable:
@@ -9,7 +9,8 @@ from __future__ import annotations
9
9
  from sqlalchemy import inspect, text
10
10
 
11
11
  from dbly.adapters.base import Adapter, Column
12
- from dbly.model import ObjectId
12
+ from dbly.model import LiveObject, ObjectId, ObjectKind
13
+ from dbly.parsing import canonical_hash
13
14
 
14
15
  _STATE_DDL = """
15
16
  CREATE TABLE IF NOT EXISTS dbly_state (
@@ -39,6 +40,37 @@ class SqliteAdapter(Adapter):
39
40
  for c in cols
40
41
  ]
41
42
 
43
+ def has_object(self, kind: ObjectKind, schema: str | None, name: str) -> bool:
44
+ if kind is ObjectKind.SEQUENCE:
45
+ return False # SQLite has no sequences
46
+ if kind is ObjectKind.INDEX:
47
+ q = "SELECT 1 FROM sqlite_master WHERE type='index' AND name=:n"
48
+ else:
49
+ q = "SELECT 1 FROM sqlite_master WHERE name=:n"
50
+ with self.engine.connect() as conn:
51
+ return conn.execute(text(q), {"n": name}).first() is not None
52
+
53
+ _TYPEMAP = {
54
+ "table": ObjectKind.TABLE, "view": ObjectKind.VIEW,
55
+ "index": ObjectKind.INDEX, "trigger": ObjectKind.TRIGGER,
56
+ }
57
+
58
+ def inventory(self) -> list[LiveObject]:
59
+ out: dict[str, LiveObject] = {}
60
+ with self.engine.connect() as conn:
61
+ rows = conn.execute(
62
+ text("SELECT type, name, sql FROM sqlite_master WHERE name NOT LIKE 'sqlite_%'")
63
+ )
64
+ for typ, name, sql in rows:
65
+ kind = self._TYPEMAP.get(typ)
66
+ if kind is None:
67
+ continue
68
+ h = (canonical_hash(sql, dialect="sqlite")
69
+ if kind in (ObjectKind.VIEW, ObjectKind.TRIGGER) else None)
70
+ obj = LiveObject(kind, ObjectId(None, name), h)
71
+ out[obj.key()] = obj
72
+ return list(out.values())
73
+
42
74
  def add_column_sql(self, table: ObjectId, col: Column) -> str:
43
75
  # SQLite has no schemas; ignore the schema qualifier.
44
76
  parts = [f"ALTER TABLE {table.name} ADD COLUMN {col.name} {col.type}"]
@@ -14,7 +14,7 @@ from typing import Optional
14
14
  import typer
15
15
  from rich.console import Console
16
16
 
17
- from dbly import __version__, hooks, initializer, report
17
+ from dbly import __version__, drift, hooks, initializer, report
18
18
  from dbly.adapters import get_adapter
19
19
  from dbly.config import resolve_target
20
20
  from dbly.engine import detect_dialect
@@ -137,7 +137,7 @@ def apply(
137
137
  s.sql for s in plan_obj.steps
138
138
  if allow_destructive or s.severity is not Severity.DESTRUCTIVE
139
139
  ]
140
- if not statements:
140
+ if not statements and not plan_obj.migrations and not plan_obj.baselined:
141
141
  console.print("[green]nothing to apply.[/green]")
142
142
  return
143
143
 
@@ -147,13 +147,24 @@ def apply(
147
147
  try:
148
148
  _run_hooks(repo, "pre", py_interpreter)
149
149
  adapter.ensure_state_table()
150
- adapter.apply(statements)
150
+ # baseline (bootstrap): record migrations as applied without running them
151
+ for mid in plan_obj.baselined:
152
+ adapter.record_migration(plan_obj.to_ref, mid)
153
+ # explicit migrations run first — they reshape the schema before reconciliation
154
+ for m in plan_obj.migrations:
155
+ adapter.run_init_script(m.sql) # engine-aware multi-statement / PL-SQL runner
156
+ adapter.record_migration(plan_obj.to_ref, m.id)
157
+ console.print(f"[magenta]migration[/magenta] applied {m.id}")
158
+ if statements:
159
+ adapter.apply(statements)
151
160
  adapter.record_deploy(plan_obj.to_ref, migration_ids=[])
152
161
  _run_hooks(repo, "post", py_interpreter)
153
162
  finally:
154
163
  adapter.dispose()
155
- console.print(f"[green]applied[/green] {len(statements)} step(s); "
156
- f"deployed ref {plan_obj.to_ref}")
164
+ console.print(
165
+ f"[green]applied[/green] {len(statements)} step(s), "
166
+ f"{len(plan_obj.migrations)} migration(s); deployed ref → {plan_obj.to_ref}"
167
+ )
157
168
 
158
169
 
159
170
  @app.command()
@@ -216,15 +227,39 @@ def check(
216
227
  target: str = typer.Option(..., "--target"),
217
228
  to: str = typer.Option("HEAD", "--to"),
218
229
  repo_path: Path = typer.Option(Path("."), "--repo"),
230
+ orphans: bool = typer.Option(
231
+ False, "--orphans", help="also report objects in the DB but not in the repo."
232
+ ),
219
233
  ) -> None:
220
- """Detect drift: compare desired state at <to> against the live database."""
221
- plan_obj = _make_plan(repo_path, target, None, to)
222
- drift = [s for s in plan_obj.steps] + plan_obj.warnings
223
- if not drift:
224
- console.print("[green]no drift — database matches desired state.[/green]")
234
+ """Detect drift: compare the desired state at <to> against the live database."""
235
+ repo = Repo(repo_path)
236
+ cfg = resolve_target(target)
237
+ dialect = sqlglot_dialect(detect_dialect(cfg))
238
+ adapter = get_adapter(cfg)
239
+ try:
240
+ rep = drift.compute_drift(
241
+ repo, adapter,
242
+ to_ref=repo.resolve_ref(to), dialect=dialect, include_orphans=orphans,
243
+ )
244
+ finally:
245
+ adapter.dispose()
246
+
247
+ if rep.clean:
248
+ console.print("[green]no drift — database matches the desired state.[/green]")
225
249
  return
226
- report.render_plan(plan_obj, console)
227
- console.print("\n[yellow]drift detected (see steps/warnings above).[/yellow]")
250
+
251
+ for kind, oid in rep.missing:
252
+ console.print(f"[yellow]missing[/yellow] {kind.value} {oid} (in repo, not in DB)")
253
+ for cd in rep.columns:
254
+ if cd.added:
255
+ console.print(f"[yellow]columns[/yellow] {cd.table} to add: {', '.join(cd.added)}")
256
+ if cd.removed:
257
+ console.print(f"[red]columns[/red] {cd.table} only in DB: {', '.join(cd.removed)}")
258
+ for kind, oid in rep.definitions:
259
+ console.print(f"[yellow]changed[/yellow] {kind.value} {oid} (definition differs — advisory)")
260
+ for kind, oid in rep.orphaned:
261
+ console.print(f"[dim]orphaned[/dim] {kind.value} {oid} (in DB, not in repo)")
262
+ raise typer.Exit(code=1)
228
263
 
229
264
 
230
265
  def _run_hooks(repo: Repo, phase: str, py_interpreter: str) -> None:
@@ -0,0 +1,98 @@
1
+ """Drift detection for ``dbly check`` — desired (repo) vs. live (introspected) state.
2
+
3
+ Compares the full desired state at a git ref against the database's live inventory and
4
+ reports four kinds of drift:
5
+
6
+ * **missing** — in the repo, absent from the DB (would be created on the next apply)
7
+ * **orphaned** — in the DB, absent from the repo (opt-in via ``--orphans``; on a partially
8
+ managed database everything unmanaged shows up here, so it is off by default)
9
+ * **columns** — tables present in both whose column sets differ
10
+ * **definitions** — procedural objects (view/function/procedure/trigger) whose canonical
11
+ source hash differs (advisory — may have false positives, see parsing)
12
+ """
13
+ from __future__ import annotations
14
+
15
+ from dataclasses import dataclass, field
16
+
17
+ from dbly import parsing
18
+ from dbly.adapters.base import Adapter
19
+ from dbly.model import ObjectId, ObjectKind
20
+ from dbly.repo import Repo
21
+
22
+ _HASHED_KINDS = {ObjectKind.VIEW, ObjectKind.FUNCTION, ObjectKind.PROCEDURE, ObjectKind.TRIGGER}
23
+ _LEDGER_KEY = "table:dbly_state"
24
+
25
+
26
+ @dataclass(slots=True)
27
+ class ColumnDrift:
28
+ table: ObjectId
29
+ added: list[str] # in the desired CREATE TABLE, missing from the DB
30
+ removed: list[str] # in the DB, absent from the desired CREATE TABLE
31
+
32
+
33
+ @dataclass(slots=True)
34
+ class DriftReport:
35
+ missing: list[tuple[ObjectKind, ObjectId]] = field(default_factory=list)
36
+ orphaned: list[tuple[ObjectKind, ObjectId]] = field(default_factory=list)
37
+ columns: list[ColumnDrift] = field(default_factory=list)
38
+ definitions: list[tuple[ObjectKind, ObjectId]] = field(default_factory=list)
39
+
40
+ @property
41
+ def clean(self) -> bool:
42
+ return not (self.missing or self.orphaned or self.columns or self.definitions)
43
+
44
+
45
+ def _norm_key(kind: ObjectKind, oid: ObjectId, default_schema: str | None) -> str:
46
+ schema = oid.schema
47
+ if schema and default_schema and schema.lower() == default_schema.lower():
48
+ schema = None # an unqualified repo object maps to the engine's implicit schema
49
+ body = f"{schema.lower()}.{oid.name.lower()}" if schema else oid.name.lower()
50
+ return f"{kind.value}:{body}"
51
+
52
+
53
+ def compute_drift(
54
+ repo: Repo,
55
+ adapter: Adapter,
56
+ *,
57
+ to_ref: str,
58
+ dialect: str | None,
59
+ include_orphans: bool = False,
60
+ ) -> DriftReport:
61
+ ds = adapter.default_schema
62
+
63
+ desired = {} # normalized key -> ParsedObject
64
+ for rel in repo.list_files(to_ref):
65
+ sql = repo.read_at(to_ref, rel)
66
+ for obj in parsing.parse_file(
67
+ sql, rel, default_schema=repo.schema_for(rel), dialect=dialect
68
+ ):
69
+ desired[_norm_key(obj.kind, obj.id, ds)] = obj
70
+
71
+ live = {_norm_key(o.kind, o.id, ds): o for o in adapter.inventory()}
72
+ live.pop(_LEDGER_KEY, None) # dbly's own ledger is never "orphaned"
73
+
74
+ report = DriftReport()
75
+ for key, obj in desired.items():
76
+ if key not in live:
77
+ report.missing.append((obj.kind, obj.id))
78
+
79
+ if include_orphans:
80
+ for key, o in live.items():
81
+ if key not in desired:
82
+ report.orphaned.append((o.kind, o.id))
83
+
84
+ for key, obj in desired.items():
85
+ 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
+ want = {c.key() for c in parsing.desired_columns(obj.sql, dialect=dialect)}
88
+ added, removed = sorted(want - actual), sorted(actual - want)
89
+ if added or removed:
90
+ report.columns.append(ColumnDrift(obj.id, added, removed))
91
+
92
+ for key, obj in desired.items():
93
+ if obj.kind in _HASHED_KINDS and key in live and live[key].source_hash:
94
+ want = parsing.canonical_hash(obj.sql, dialect=dialect)
95
+ if want and want != live[key].source_hash:
96
+ report.definitions.append((obj.kind, obj.id))
97
+
98
+ return report