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.
- {dbly-0.0.1 → dbly-0.2.0}/PKG-INFO +8 -1
- {dbly-0.0.1 → dbly-0.2.0}/README.md +7 -0
- {dbly-0.0.1 → dbly-0.2.0}/pyproject.toml +1 -1
- {dbly-0.0.1 → dbly-0.2.0}/src/dbly/__init__.py +1 -1
- {dbly-0.0.1 → dbly-0.2.0}/src/dbly/adapters/base.py +33 -2
- {dbly-0.0.1 → dbly-0.2.0}/src/dbly/adapters/mssql.py +63 -1
- {dbly-0.0.1 → dbly-0.2.0}/src/dbly/adapters/oracle.py +57 -1
- {dbly-0.0.1 → dbly-0.2.0}/src/dbly/adapters/postgres.py +63 -1
- {dbly-0.0.1 → dbly-0.2.0}/src/dbly/adapters/sqlite.py +33 -1
- {dbly-0.0.1 → dbly-0.2.0}/src/dbly/cli.py +47 -12
- dbly-0.2.0/src/dbly/drift.py +98 -0
- {dbly-0.0.1 → dbly-0.2.0}/src/dbly/model.py +40 -1
- {dbly-0.0.1 → dbly-0.2.0}/src/dbly/parsing.py +51 -0
- {dbly-0.0.1 → dbly-0.2.0}/src/dbly/planner.py +64 -7
- {dbly-0.0.1 → dbly-0.2.0}/src/dbly/repo.py +25 -9
- {dbly-0.0.1 → dbly-0.2.0}/src/dbly/report.py +53 -15
- dbly-0.2.0/tests/test_integration.py +270 -0
- {dbly-0.0.1 → dbly-0.2.0}/tests/test_parsing.py +21 -0
- {dbly-0.0.1 → dbly-0.2.0}/uv.lock +1 -1
- dbly-0.0.1/tests/test_integration.py +0 -108
- {dbly-0.0.1 → dbly-0.2.0}/.github/workflows/ci.yml +0 -0
- {dbly-0.0.1 → dbly-0.2.0}/.github/workflows/publish.yml +0 -0
- {dbly-0.0.1 → dbly-0.2.0}/.gitignore +0 -0
- {dbly-0.0.1 → dbly-0.2.0}/docs/dbly_head.png +0 -0
- {dbly-0.0.1 → dbly-0.2.0}/examples/ci/README.md +0 -0
- {dbly-0.0.1 → dbly-0.2.0}/examples/ci/bitbucket-pipelines.yml +0 -0
- {dbly-0.0.1 → dbly-0.2.0}/examples/ci/github-actions.yml +0 -0
- {dbly-0.0.1 → dbly-0.2.0}/src/dbly/adapters/__init__.py +0 -0
- {dbly-0.0.1 → dbly-0.2.0}/src/dbly/config.py +0 -0
- {dbly-0.0.1 → dbly-0.2.0}/src/dbly/engine.py +0 -0
- {dbly-0.0.1 → dbly-0.2.0}/src/dbly/hooks.py +0 -0
- {dbly-0.0.1 → dbly-0.2.0}/src/dbly/initializer.py +0 -0
- {dbly-0.0.1 → dbly-0.2.0}/src/dbly/py.typed +0 -0
- {dbly-0.0.1 → dbly-0.2.0}/tests/test_mssql.py +0 -0
- {dbly-0.0.1 → dbly-0.2.0}/tests/test_mssql_e2e.py +0 -0
- {dbly-0.0.1 → dbly-0.2.0}/tests/test_oracle.py +0 -0
- {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
|
|
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):
|
|
@@ -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
|
-
|
|
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(
|
|
156
|
-
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
|
|
227
|
-
|
|
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
|