dbly 0.0.1__py3-none-any.whl
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/__init__.py +7 -0
- dbly/adapters/__init__.py +35 -0
- dbly/adapters/base.py +93 -0
- dbly/adapters/mssql.py +112 -0
- dbly/adapters/oracle.py +160 -0
- dbly/adapters/postgres.py +103 -0
- dbly/adapters/sqlite.py +94 -0
- dbly/cli.py +245 -0
- dbly/config.py +101 -0
- dbly/engine.py +138 -0
- dbly/hooks.py +62 -0
- dbly/initializer.py +19 -0
- dbly/model.py +119 -0
- dbly/parsing.py +173 -0
- dbly/planner.py +143 -0
- dbly/py.typed +0 -0
- dbly/repo.py +113 -0
- dbly/report.py +135 -0
- dbly-0.0.1.dist-info/METADATA +149 -0
- dbly-0.0.1.dist-info/RECORD +22 -0
- dbly-0.0.1.dist-info/WHEEL +4 -0
- dbly-0.0.1.dist-info/entry_points.txt +2 -0
dbly/__init__.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Engine-specific adapters. Postgres is the Leitstern (CONCEPT.md §10, §16)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dbly.adapters.base import Adapter, Column
|
|
5
|
+
from dbly.config import ConnectionConfig
|
|
6
|
+
from dbly.engine import detect_dialect
|
|
7
|
+
|
|
8
|
+
_POSTGRES = {"postgres", "postgresql", "pg"}
|
|
9
|
+
_SQLITE = {"sqlite", "sqlite3"}
|
|
10
|
+
_MSSQL = {"sqlserver", "mssql", "ms-sql"}
|
|
11
|
+
_ORACLE = {"oracle"}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def get_adapter(cfg: ConnectionConfig) -> Adapter:
|
|
15
|
+
env = detect_dialect(cfg)
|
|
16
|
+
if env in _POSTGRES:
|
|
17
|
+
from dbly.adapters.postgres import PostgresAdapter
|
|
18
|
+
|
|
19
|
+
return PostgresAdapter(cfg)
|
|
20
|
+
if env in _SQLITE:
|
|
21
|
+
from dbly.adapters.sqlite import SqliteAdapter
|
|
22
|
+
|
|
23
|
+
return SqliteAdapter(cfg)
|
|
24
|
+
if env in _MSSQL:
|
|
25
|
+
from dbly.adapters.mssql import MssqlAdapter
|
|
26
|
+
|
|
27
|
+
return MssqlAdapter(cfg)
|
|
28
|
+
if env in _ORACLE:
|
|
29
|
+
from dbly.adapters.oracle import OracleAdapter
|
|
30
|
+
|
|
31
|
+
return OracleAdapter(cfg)
|
|
32
|
+
raise NotImplementedError(f"no adapter for environment {env!r}.")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
__all__ = ["Adapter", "Column", "get_adapter"]
|
dbly/adapters/base.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Adapter interface — the per-engine execution + introspection contract.
|
|
2
|
+
|
|
3
|
+
Each adapter encodes its engine's DDL transaction semantics (CONCEPT.md §10):
|
|
4
|
+
|
|
5
|
+
* Postgres — transactional DDL: wrap the whole apply in one transaction, clean rollback.
|
|
6
|
+
* Oracle — DDL auto-commits: per-statement, verify-driven, no rollback assumption.
|
|
7
|
+
* SQL Server — mixed: wrap where supported.
|
|
8
|
+
|
|
9
|
+
The adapter is also the *reality* layer (CONCEPT.md §2): it introspects the live schema so
|
|
10
|
+
the planner can diff desired vs. actual tables.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import abc
|
|
15
|
+
|
|
16
|
+
from sqlalchemy import Engine
|
|
17
|
+
|
|
18
|
+
from dbly.config import ConnectionConfig
|
|
19
|
+
from dbly.engine import make_engine
|
|
20
|
+
from dbly.model import Column, ObjectId
|
|
21
|
+
|
|
22
|
+
__all__ = ["Adapter", "Column"]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Adapter(abc.ABC):
|
|
26
|
+
"""Base adapter. Subclasses implement engine specifics."""
|
|
27
|
+
|
|
28
|
+
#: whether DDL participates in transactions (drives apply strategy)
|
|
29
|
+
transactional_ddl: bool = False
|
|
30
|
+
|
|
31
|
+
def __init__(self, cfg: ConnectionConfig):
|
|
32
|
+
self.cfg = cfg
|
|
33
|
+
self._engine: Engine | None = None
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def engine(self) -> Engine:
|
|
37
|
+
if self._engine is None:
|
|
38
|
+
self._engine = make_engine(self.cfg)
|
|
39
|
+
return self._engine
|
|
40
|
+
|
|
41
|
+
# --- introspection (reality layer) ------------------------------------------------
|
|
42
|
+
@abc.abstractmethod
|
|
43
|
+
def table_exists(self, schema: str | None, name: str) -> bool: ...
|
|
44
|
+
|
|
45
|
+
@abc.abstractmethod
|
|
46
|
+
def get_columns(self, schema: str | None, name: str) -> list[Column]: ...
|
|
47
|
+
|
|
48
|
+
# --- dialect-specific DDL generation -----------------------------------------------
|
|
49
|
+
@abc.abstractmethod
|
|
50
|
+
def add_column_sql(self, table: ObjectId, col: Column) -> str:
|
|
51
|
+
"""Generate the additive ``ALTER TABLE … ADD`` for this dialect.
|
|
52
|
+
|
|
53
|
+
Postgres/SQLite use ``ADD COLUMN``; T-SQL uses ``ADD``. The planner stays
|
|
54
|
+
dialect-agnostic and delegates the rendering here.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
# --- execution ---------------------------------------------------------------------
|
|
58
|
+
@abc.abstractmethod
|
|
59
|
+
def apply(self, statements: list[str]) -> None:
|
|
60
|
+
"""Execute statements with the engine's appropriate transaction strategy."""
|
|
61
|
+
|
|
62
|
+
@abc.abstractmethod
|
|
63
|
+
def run_init_script(self, script: str) -> None:
|
|
64
|
+
"""Run a privileged init script (CONCEPT.md §6) verbatim.
|
|
65
|
+
|
|
66
|
+
Init scripts are imperative groundwork — possibly multi-statement and containing
|
|
67
|
+
statements that cannot run inside a transaction (e.g. Postgres ``CREATE DATABASE``).
|
|
68
|
+
Implementations therefore run in **autocommit** and accept the whole script.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
# --- state ledger ------------------------------------------------------------------
|
|
72
|
+
@abc.abstractmethod
|
|
73
|
+
def ensure_state_table(self) -> None: ...
|
|
74
|
+
|
|
75
|
+
@abc.abstractmethod
|
|
76
|
+
def get_deployed_ref(self) -> str | None: ...
|
|
77
|
+
|
|
78
|
+
@abc.abstractmethod
|
|
79
|
+
def record_deploy(self, ref: str, migration_ids: list[str]) -> None: ...
|
|
80
|
+
|
|
81
|
+
# --- pure SQL builders (no connection — used by `plan --sql` export) ----------------
|
|
82
|
+
@abc.abstractmethod
|
|
83
|
+
def state_table_ddl(self) -> str:
|
|
84
|
+
"""``CREATE TABLE IF NOT EXISTS dbly_state …`` for this engine."""
|
|
85
|
+
|
|
86
|
+
@abc.abstractmethod
|
|
87
|
+
def record_deploy_sql(self, ref: str) -> str:
|
|
88
|
+
"""A standalone ``INSERT`` recording the deploy — for hand-run scripts."""
|
|
89
|
+
|
|
90
|
+
def dispose(self) -> None:
|
|
91
|
+
if self._engine is not None:
|
|
92
|
+
self._engine.dispose()
|
|
93
|
+
self._engine = None
|
dbly/adapters/mssql.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""SQL Server adapter (T-SQL via pymssql).
|
|
2
|
+
|
|
3
|
+
DDL in SQL Server is largely transactional — CREATE/ALTER/DROP of tables, views, procedures
|
|
4
|
+
roll back inside a transaction — so object deploys wrap in one transaction like Postgres.
|
|
5
|
+
T-SQL specifics handled here:
|
|
6
|
+
|
|
7
|
+
* no ``CREATE TABLE IF NOT EXISTS`` → guarded ``IF NOT EXISTS (…) BEGIN … END``;
|
|
8
|
+
* ``ALTER TABLE … ADD`` (not ``ADD COLUMN``);
|
|
9
|
+
* init scripts are split on ``GO`` batch separators (required so e.g. ``CREATE PROCEDURE``
|
|
10
|
+
is the first statement in its batch).
|
|
11
|
+
|
|
12
|
+
Requires the ``mssql`` extra (``pymssql``). End-to-end verification needs a reachable SQL
|
|
13
|
+
Server instance; unit-testable pieces (SQL string builders) work without one.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import re
|
|
18
|
+
|
|
19
|
+
from sqlalchemy import inspect, text
|
|
20
|
+
|
|
21
|
+
from dbly.adapters.base import Adapter, Column
|
|
22
|
+
from dbly.model import ObjectId
|
|
23
|
+
|
|
24
|
+
_GO_RE = re.compile(r"^\s*GO\s*$", re.IGNORECASE | re.MULTILINE)
|
|
25
|
+
|
|
26
|
+
_STATE_DDL = """
|
|
27
|
+
IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE name = 'dbly_state')
|
|
28
|
+
BEGIN
|
|
29
|
+
CREATE TABLE dbly_state (
|
|
30
|
+
id BIGINT IDENTITY(1,1) PRIMARY KEY,
|
|
31
|
+
deployed_sha NVARCHAR(64) NOT NULL,
|
|
32
|
+
migration_id NVARCHAR(200) NULL,
|
|
33
|
+
applied_at DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME()
|
|
34
|
+
)
|
|
35
|
+
END
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class MssqlAdapter(Adapter):
|
|
40
|
+
transactional_ddl = True
|
|
41
|
+
|
|
42
|
+
def table_exists(self, schema: str | None, name: str) -> bool:
|
|
43
|
+
return inspect(self.engine).has_table(name, schema=schema)
|
|
44
|
+
|
|
45
|
+
def get_columns(self, schema: str | None, name: str) -> list[Column]:
|
|
46
|
+
cols = inspect(self.engine).get_columns(name, schema=schema)
|
|
47
|
+
return [
|
|
48
|
+
Column(
|
|
49
|
+
name=c["name"],
|
|
50
|
+
type=str(c["type"]),
|
|
51
|
+
nullable=bool(c["nullable"]),
|
|
52
|
+
default=None if c.get("default") is None else str(c["default"]),
|
|
53
|
+
)
|
|
54
|
+
for c in cols
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
def add_column_sql(self, table: ObjectId, col: Column) -> str:
|
|
58
|
+
# T-SQL: ADD, not ADD COLUMN
|
|
59
|
+
parts = [f"ALTER TABLE {table} ADD {col.name} {col.type}"]
|
|
60
|
+
if not col.nullable:
|
|
61
|
+
parts.append("NOT NULL")
|
|
62
|
+
if col.default is not None:
|
|
63
|
+
parts.append(f"DEFAULT {col.default}")
|
|
64
|
+
return " ".join(parts) + ";"
|
|
65
|
+
|
|
66
|
+
def apply(self, statements: list[str]) -> None:
|
|
67
|
+
with self.engine.begin() as conn:
|
|
68
|
+
for stmt in statements:
|
|
69
|
+
if stmt.strip():
|
|
70
|
+
conn.execute(text(stmt))
|
|
71
|
+
|
|
72
|
+
def run_init_script(self, script: str) -> None:
|
|
73
|
+
# Split on GO batch separators and run each batch in autocommit.
|
|
74
|
+
batches = [b for b in _GO_RE.split(script) if b.strip()]
|
|
75
|
+
with self.engine.connect() as conn:
|
|
76
|
+
conn = conn.execution_options(isolation_level="AUTOCOMMIT")
|
|
77
|
+
for batch in batches:
|
|
78
|
+
conn.exec_driver_sql(batch)
|
|
79
|
+
|
|
80
|
+
def state_table_ddl(self) -> str:
|
|
81
|
+
return _STATE_DDL.strip()
|
|
82
|
+
|
|
83
|
+
def record_deploy_sql(self, ref: str) -> str:
|
|
84
|
+
safe = ref.replace("'", "''")
|
|
85
|
+
return f"INSERT INTO dbly_state (deployed_sha) VALUES ('{safe}');"
|
|
86
|
+
|
|
87
|
+
def ensure_state_table(self) -> None:
|
|
88
|
+
with self.engine.begin() as conn:
|
|
89
|
+
conn.execute(text(_STATE_DDL))
|
|
90
|
+
|
|
91
|
+
def get_deployed_ref(self) -> str | None:
|
|
92
|
+
self.ensure_state_table()
|
|
93
|
+
with self.engine.connect() as conn:
|
|
94
|
+
row = conn.execute(
|
|
95
|
+
text(
|
|
96
|
+
"SELECT TOP 1 deployed_sha FROM dbly_state "
|
|
97
|
+
"ORDER BY applied_at DESC, id DESC"
|
|
98
|
+
)
|
|
99
|
+
).first()
|
|
100
|
+
return row[0] if row else None
|
|
101
|
+
|
|
102
|
+
def record_deploy(self, ref: str, migration_ids: list[str]) -> None:
|
|
103
|
+
self.ensure_state_table()
|
|
104
|
+
with self.engine.begin() as conn:
|
|
105
|
+
for mid in (migration_ids or [None]):
|
|
106
|
+
conn.execute(
|
|
107
|
+
text(
|
|
108
|
+
"INSERT INTO dbly_state (deployed_sha, migration_id) "
|
|
109
|
+
"VALUES (:sha, :mid)"
|
|
110
|
+
),
|
|
111
|
+
{"sha": ref, "mid": mid},
|
|
112
|
+
)
|
dbly/adapters/oracle.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""Oracle adapter (oracledb) — the hardest semantics (CONCEPT.md §10).
|
|
2
|
+
|
|
3
|
+
Oracle commits DDL **implicitly** — there is no all-or-nothing rollback for a multi-statement
|
|
4
|
+
deploy. ``apply`` therefore runs statements sequentially (each self-commits); a mid-deploy
|
|
5
|
+
failure leaves earlier statements applied. Mitigate with verify steps and idempotent/guarded
|
|
6
|
+
DDL — never rely on rollback here.
|
|
7
|
+
|
|
8
|
+
Other Oracle specifics handled in this adapter:
|
|
9
|
+
|
|
10
|
+
* no ``CREATE TABLE IF NOT EXISTS`` → the ledger is created via a guarded PL/SQL block that
|
|
11
|
+
swallows ORA-00955 ("name already used");
|
|
12
|
+
* ``ALTER TABLE … ADD col`` with Oracle ordering (``DEFAULT`` before ``NOT NULL``);
|
|
13
|
+
* unquoted identifiers are stored upper-case → introspection normalizes case;
|
|
14
|
+
* python-oracledb rejects a trailing ``;`` on plain SQL but needs the ``;`` *inside* PL/SQL →
|
|
15
|
+
terminators are stripped for SQL and PL/SQL blocks are kept intact;
|
|
16
|
+
* init scripts are split on SQL*Plus ``/`` block terminators.
|
|
17
|
+
|
|
18
|
+
Requires the ``oracle`` extra (oracledb). Not yet e2e-verified against a live instance.
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import re
|
|
23
|
+
|
|
24
|
+
from sqlalchemy import inspect, text
|
|
25
|
+
|
|
26
|
+
from dbly.adapters.base import Adapter, Column
|
|
27
|
+
from dbly.model import ObjectId
|
|
28
|
+
|
|
29
|
+
_SLASH_RE = re.compile(r"(?m)^\s*/\s*$")
|
|
30
|
+
_PLSQL_RE = re.compile(
|
|
31
|
+
r"^\s*(CREATE\s+(OR\s+REPLACE\s+)?(EDITIONABLE\s+|NONEDITIONABLE\s+)?"
|
|
32
|
+
r"(PROCEDURE|FUNCTION|PACKAGE|TRIGGER|TYPE)\b|BEGIN\b|DECLARE\b)",
|
|
33
|
+
re.IGNORECASE,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
_STATE_BLOCK = """
|
|
37
|
+
BEGIN
|
|
38
|
+
EXECUTE IMMEDIATE '
|
|
39
|
+
CREATE TABLE dbly_state (
|
|
40
|
+
id NUMBER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
|
41
|
+
deployed_sha VARCHAR2(64) NOT NULL,
|
|
42
|
+
migration_id VARCHAR2(200),
|
|
43
|
+
applied_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL
|
|
44
|
+
)';
|
|
45
|
+
EXCEPTION
|
|
46
|
+
WHEN OTHERS THEN
|
|
47
|
+
IF SQLCODE != -955 THEN RAISE; END IF; -- ORA-00955: name already used
|
|
48
|
+
END;
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _is_plsql(stmt: str) -> bool:
|
|
53
|
+
return bool(_PLSQL_RE.match(stmt))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def strip_terminator(stmt: str) -> str:
|
|
57
|
+
"""Strip a trailing ``;`` from plain SQL; leave PL/SQL blocks (END;) intact."""
|
|
58
|
+
s = stmt.strip()
|
|
59
|
+
if not _is_plsql(s) and s.endswith(";"):
|
|
60
|
+
return s[:-1].rstrip()
|
|
61
|
+
return s
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def split_oracle_script(script: str) -> list[str]:
|
|
65
|
+
"""Split an init script into executable units.
|
|
66
|
+
|
|
67
|
+
PL/SQL blocks are terminated by a lone ``/`` (SQL*Plus convention) and run whole; plain
|
|
68
|
+
SQL between terminators is split on ``;``. Semicolons inside string literals are a known
|
|
69
|
+
edge case (rare in init scripts).
|
|
70
|
+
"""
|
|
71
|
+
out: list[str] = []
|
|
72
|
+
for chunk in _SLASH_RE.split(script):
|
|
73
|
+
s = chunk.strip()
|
|
74
|
+
if not s:
|
|
75
|
+
continue
|
|
76
|
+
if _is_plsql(s):
|
|
77
|
+
out.append(s)
|
|
78
|
+
else:
|
|
79
|
+
out.extend(st.strip() for st in s.split(";") if st.strip())
|
|
80
|
+
return out
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class OracleAdapter(Adapter):
|
|
84
|
+
transactional_ddl = False # DDL auto-commits — no rollback
|
|
85
|
+
|
|
86
|
+
@staticmethod
|
|
87
|
+
def _norm(ident: str | None) -> str | None:
|
|
88
|
+
# unquoted Oracle identifiers are upper-cased on storage
|
|
89
|
+
return ident.upper() if ident else ident
|
|
90
|
+
|
|
91
|
+
def table_exists(self, schema: str | None, name: str) -> bool:
|
|
92
|
+
return inspect(self.engine).has_table(self._norm(name), schema=self._norm(schema))
|
|
93
|
+
|
|
94
|
+
def get_columns(self, schema: str | None, name: str) -> list[Column]:
|
|
95
|
+
cols = inspect(self.engine).get_columns(self._norm(name), schema=self._norm(schema))
|
|
96
|
+
return [
|
|
97
|
+
Column(
|
|
98
|
+
name=c["name"],
|
|
99
|
+
type=str(c["type"]),
|
|
100
|
+
nullable=bool(c["nullable"]),
|
|
101
|
+
default=None if c.get("default") is None else str(c["default"]),
|
|
102
|
+
)
|
|
103
|
+
for c in cols
|
|
104
|
+
]
|
|
105
|
+
|
|
106
|
+
def add_column_sql(self, table: ObjectId, col: Column) -> str:
|
|
107
|
+
parts = [f"ALTER TABLE {table} ADD {col.name} {col.type}"]
|
|
108
|
+
if col.default is not None:
|
|
109
|
+
parts.append(f"DEFAULT {col.default}") # Oracle: DEFAULT before NOT NULL
|
|
110
|
+
if not col.nullable:
|
|
111
|
+
parts.append("NOT NULL")
|
|
112
|
+
return " ".join(parts) + ";"
|
|
113
|
+
|
|
114
|
+
def apply(self, statements: list[str]) -> None:
|
|
115
|
+
# No transactional rollback (DDL auto-commits). Sequential; commit covers any DML.
|
|
116
|
+
with self.engine.connect() as conn:
|
|
117
|
+
for stmt in statements:
|
|
118
|
+
s = strip_terminator(stmt)
|
|
119
|
+
if s:
|
|
120
|
+
conn.exec_driver_sql(s)
|
|
121
|
+
conn.commit()
|
|
122
|
+
|
|
123
|
+
def run_init_script(self, script: str) -> None:
|
|
124
|
+
with self.engine.connect() as conn:
|
|
125
|
+
for stmt in split_oracle_script(script):
|
|
126
|
+
conn.exec_driver_sql(stmt)
|
|
127
|
+
conn.commit()
|
|
128
|
+
|
|
129
|
+
def state_table_ddl(self) -> str:
|
|
130
|
+
return _STATE_BLOCK.strip() + "\n/" # '/' terminates the block for a SQL*Plus hand-run
|
|
131
|
+
|
|
132
|
+
def record_deploy_sql(self, ref: str) -> str:
|
|
133
|
+
return f"INSERT INTO dbly_state (deployed_sha) VALUES ('{ref.replace(chr(39), chr(39) * 2)}');"
|
|
134
|
+
|
|
135
|
+
def ensure_state_table(self) -> None:
|
|
136
|
+
with self.engine.connect() as conn:
|
|
137
|
+
conn.exec_driver_sql(_STATE_BLOCK.strip())
|
|
138
|
+
conn.commit()
|
|
139
|
+
|
|
140
|
+
def get_deployed_ref(self) -> str | None:
|
|
141
|
+
self.ensure_state_table()
|
|
142
|
+
with self.engine.connect() as conn:
|
|
143
|
+
row = conn.exec_driver_sql(
|
|
144
|
+
"SELECT deployed_sha FROM dbly_state "
|
|
145
|
+
"ORDER BY applied_at DESC, id DESC FETCH FIRST 1 ROW ONLY"
|
|
146
|
+
).first()
|
|
147
|
+
return row[0] if row else None
|
|
148
|
+
|
|
149
|
+
def record_deploy(self, ref: str, migration_ids: list[str]) -> None:
|
|
150
|
+
self.ensure_state_table()
|
|
151
|
+
with self.engine.connect() as conn:
|
|
152
|
+
for mid in (migration_ids or [None]):
|
|
153
|
+
conn.execute(
|
|
154
|
+
text(
|
|
155
|
+
"INSERT INTO dbly_state (deployed_sha, migration_id) "
|
|
156
|
+
"VALUES (:sha, :mid)"
|
|
157
|
+
),
|
|
158
|
+
{"sha": ref, "mid": mid},
|
|
159
|
+
)
|
|
160
|
+
conn.commit()
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""PostgreSQL adapter — the Leitstern (CONCEPT.md §16).
|
|
2
|
+
|
|
3
|
+
Postgres has transactional DDL, so the whole apply runs in a single transaction: on any
|
|
4
|
+
failure everything rolls back and the database is untouched. This is the clean reference
|
|
5
|
+
against which the trickier Oracle/SQL-Server semantics are later measured.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from sqlalchemy import inspect, text
|
|
10
|
+
|
|
11
|
+
from dbly.adapters.base import Adapter, Column
|
|
12
|
+
from dbly.model import ObjectId
|
|
13
|
+
|
|
14
|
+
_STATE_DDL = """
|
|
15
|
+
CREATE TABLE IF NOT EXISTS dbly_state (
|
|
16
|
+
id bigserial PRIMARY KEY,
|
|
17
|
+
deployed_sha text NOT NULL,
|
|
18
|
+
migration_id text,
|
|
19
|
+
applied_at timestamptz NOT NULL DEFAULT now()
|
|
20
|
+
)
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class PostgresAdapter(Adapter):
|
|
25
|
+
transactional_ddl = True
|
|
26
|
+
|
|
27
|
+
def table_exists(self, schema: str | None, name: str) -> bool:
|
|
28
|
+
insp = inspect(self.engine)
|
|
29
|
+
return insp.has_table(name, schema=schema)
|
|
30
|
+
|
|
31
|
+
def get_columns(self, schema: str | None, name: str) -> list[Column]:
|
|
32
|
+
insp = inspect(self.engine)
|
|
33
|
+
cols = insp.get_columns(name, schema=schema)
|
|
34
|
+
return [
|
|
35
|
+
Column(
|
|
36
|
+
name=c["name"],
|
|
37
|
+
type=str(c["type"]),
|
|
38
|
+
nullable=bool(c["nullable"]),
|
|
39
|
+
default=None if c.get("default") is None else str(c["default"]),
|
|
40
|
+
)
|
|
41
|
+
for c in cols
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
def add_column_sql(self, table: ObjectId, col: Column) -> str:
|
|
45
|
+
parts = [f"ALTER TABLE {table} ADD COLUMN {col.name} {col.type}"]
|
|
46
|
+
if not col.nullable:
|
|
47
|
+
parts.append("NOT NULL")
|
|
48
|
+
if col.default is not None:
|
|
49
|
+
parts.append(f"DEFAULT {col.default}")
|
|
50
|
+
return " ".join(parts) + ";"
|
|
51
|
+
|
|
52
|
+
def apply(self, statements: list[str]) -> None:
|
|
53
|
+
# transactional DDL → one atomic transaction
|
|
54
|
+
with self.engine.begin() as conn:
|
|
55
|
+
for stmt in statements:
|
|
56
|
+
if stmt.strip():
|
|
57
|
+
conn.execute(text(stmt))
|
|
58
|
+
|
|
59
|
+
def run_init_script(self, script: str) -> None:
|
|
60
|
+
# autocommit: CREATE DATABASE & friends cannot run inside a transaction block.
|
|
61
|
+
# psycopg3 executes a multi-statement string in a single exec_driver_sql call.
|
|
62
|
+
with self.engine.connect() as conn:
|
|
63
|
+
conn = conn.execution_options(isolation_level="AUTOCOMMIT")
|
|
64
|
+
conn.exec_driver_sql(script)
|
|
65
|
+
|
|
66
|
+
def state_table_ddl(self) -> str:
|
|
67
|
+
return _STATE_DDL.strip() + ";"
|
|
68
|
+
|
|
69
|
+
def record_deploy_sql(self, ref: str) -> str:
|
|
70
|
+
return f"INSERT INTO dbly_state (deployed_sha) VALUES ('{ref.replace(chr(39), chr(39) * 2)}');"
|
|
71
|
+
|
|
72
|
+
def ensure_state_table(self) -> None:
|
|
73
|
+
with self.engine.begin() as conn:
|
|
74
|
+
conn.execute(text(_STATE_DDL))
|
|
75
|
+
|
|
76
|
+
def get_deployed_ref(self) -> str | None:
|
|
77
|
+
self.ensure_state_table()
|
|
78
|
+
with self.engine.connect() as conn:
|
|
79
|
+
row = conn.execute(
|
|
80
|
+
text(
|
|
81
|
+
"SELECT deployed_sha FROM dbly_state "
|
|
82
|
+
"ORDER BY applied_at DESC, id DESC LIMIT 1"
|
|
83
|
+
)
|
|
84
|
+
).first()
|
|
85
|
+
return row[0] if row else None
|
|
86
|
+
|
|
87
|
+
def record_deploy(self, ref: str, migration_ids: list[str]) -> None:
|
|
88
|
+
self.ensure_state_table()
|
|
89
|
+
with self.engine.begin() as conn:
|
|
90
|
+
if migration_ids:
|
|
91
|
+
for mid in migration_ids:
|
|
92
|
+
conn.execute(
|
|
93
|
+
text(
|
|
94
|
+
"INSERT INTO dbly_state (deployed_sha, migration_id) "
|
|
95
|
+
"VALUES (:sha, :mid)"
|
|
96
|
+
),
|
|
97
|
+
{"sha": ref, "mid": mid},
|
|
98
|
+
)
|
|
99
|
+
else:
|
|
100
|
+
conn.execute(
|
|
101
|
+
text("INSERT INTO dbly_state (deployed_sha) VALUES (:sha)"),
|
|
102
|
+
{"sha": ref},
|
|
103
|
+
)
|
dbly/adapters/sqlite.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""SQLite adapter — transactional DDL, no native deps. Doubles as the test backend.
|
|
2
|
+
|
|
3
|
+
SQLite has no schemas; schema-qualified identities are treated as schemaless (the folder
|
|
4
|
+
hint should be omitted for SQLite repos). ALTER TABLE supports ADD COLUMN, which is all the
|
|
5
|
+
additive path needs.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from sqlalchemy import inspect, text
|
|
10
|
+
|
|
11
|
+
from dbly.adapters.base import Adapter, Column
|
|
12
|
+
from dbly.model import ObjectId
|
|
13
|
+
|
|
14
|
+
_STATE_DDL = """
|
|
15
|
+
CREATE TABLE IF NOT EXISTS dbly_state (
|
|
16
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
17
|
+
deployed_sha TEXT NOT NULL,
|
|
18
|
+
migration_id TEXT,
|
|
19
|
+
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
20
|
+
)
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class SqliteAdapter(Adapter):
|
|
25
|
+
transactional_ddl = True
|
|
26
|
+
|
|
27
|
+
def table_exists(self, schema: str | None, name: str) -> bool:
|
|
28
|
+
return inspect(self.engine).has_table(name)
|
|
29
|
+
|
|
30
|
+
def get_columns(self, schema: str | None, name: str) -> list[Column]:
|
|
31
|
+
cols = inspect(self.engine).get_columns(name)
|
|
32
|
+
return [
|
|
33
|
+
Column(
|
|
34
|
+
name=c["name"],
|
|
35
|
+
type=str(c["type"]),
|
|
36
|
+
nullable=bool(c["nullable"]),
|
|
37
|
+
default=None if c.get("default") is None else str(c["default"]),
|
|
38
|
+
)
|
|
39
|
+
for c in cols
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
def add_column_sql(self, table: ObjectId, col: Column) -> str:
|
|
43
|
+
# SQLite has no schemas; ignore the schema qualifier.
|
|
44
|
+
parts = [f"ALTER TABLE {table.name} ADD COLUMN {col.name} {col.type}"]
|
|
45
|
+
if not col.nullable:
|
|
46
|
+
parts.append("NOT NULL")
|
|
47
|
+
if col.default is not None:
|
|
48
|
+
parts.append(f"DEFAULT {col.default}")
|
|
49
|
+
return " ".join(parts) + ";"
|
|
50
|
+
|
|
51
|
+
def apply(self, statements: list[str]) -> None:
|
|
52
|
+
with self.engine.begin() as conn:
|
|
53
|
+
for stmt in statements:
|
|
54
|
+
if stmt.strip():
|
|
55
|
+
conn.execute(text(stmt))
|
|
56
|
+
|
|
57
|
+
def run_init_script(self, script: str) -> None:
|
|
58
|
+
# sqlite3's executescript handles multiple statements and auto-commits.
|
|
59
|
+
raw = self.engine.raw_connection()
|
|
60
|
+
try:
|
|
61
|
+
raw.driver_connection.executescript(script)
|
|
62
|
+
raw.commit()
|
|
63
|
+
finally:
|
|
64
|
+
raw.close()
|
|
65
|
+
|
|
66
|
+
def state_table_ddl(self) -> str:
|
|
67
|
+
return _STATE_DDL.strip() + ";"
|
|
68
|
+
|
|
69
|
+
def record_deploy_sql(self, ref: str) -> str:
|
|
70
|
+
return f"INSERT INTO dbly_state (deployed_sha) VALUES ('{ref.replace(chr(39), chr(39) * 2)}');"
|
|
71
|
+
|
|
72
|
+
def ensure_state_table(self) -> None:
|
|
73
|
+
with self.engine.begin() as conn:
|
|
74
|
+
conn.execute(text(_STATE_DDL))
|
|
75
|
+
|
|
76
|
+
def get_deployed_ref(self) -> str | None:
|
|
77
|
+
self.ensure_state_table()
|
|
78
|
+
with self.engine.connect() as conn:
|
|
79
|
+
row = conn.execute(
|
|
80
|
+
text("SELECT deployed_sha FROM dbly_state ORDER BY id DESC LIMIT 1")
|
|
81
|
+
).first()
|
|
82
|
+
return row[0] if row else None
|
|
83
|
+
|
|
84
|
+
def record_deploy(self, ref: str, migration_ids: list[str]) -> None:
|
|
85
|
+
self.ensure_state_table()
|
|
86
|
+
with self.engine.begin() as conn:
|
|
87
|
+
for mid in (migration_ids or [None]):
|
|
88
|
+
conn.execute(
|
|
89
|
+
text(
|
|
90
|
+
"INSERT INTO dbly_state (deployed_sha, migration_id) "
|
|
91
|
+
"VALUES (:sha, :mid)"
|
|
92
|
+
),
|
|
93
|
+
{"sha": ref, "mid": mid},
|
|
94
|
+
)
|