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 ADDED
@@ -0,0 +1,7 @@
1
+ """dbly — state-based, cross-engine database deployment.
2
+
3
+ git (what changed) + sqlglot (what it is) + live introspection (what's really there).
4
+ See CONCEPT.md for the design rationale.
5
+ """
6
+
7
+ __version__ = "0.0.1"
@@ -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
+ )
@@ -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
+ )
@@ -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
+ )