sqlpush 0.4.2__py3-none-any.whl → 0.5.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.
- sqlpush/api.py +35 -10
- sqlpush/apply/executor.py +69 -12
- sqlpush/chain/migrate.py +228 -50
- sqlpush/cli.py +8 -0
- sqlpush/core/classify.py +12 -7
- sqlpush/core/diff.py +49 -0
- sqlpush/types.py +6 -0
- sqlpush-0.5.1.dist-info/METADATA +299 -0
- sqlpush-0.5.1.dist-info/RECORD +20 -0
- sqlpush-0.4.2.dist-info/METADATA +0 -214
- sqlpush-0.4.2.dist-info/RECORD +0 -20
- {sqlpush-0.4.2.dist-info → sqlpush-0.5.1.dist-info}/WHEEL +0 -0
- {sqlpush-0.4.2.dist-info → sqlpush-0.5.1.dist-info}/entry_points.txt +0 -0
sqlpush/api.py
CHANGED
|
@@ -48,8 +48,10 @@ def _raise_typed(exc: SQLAlchemyError) -> NoReturn:
|
|
|
48
48
|
raise SqlpushError(f"database error: {exc}") from exc
|
|
49
49
|
|
|
50
50
|
|
|
51
|
-
def _build_plan(
|
|
52
|
-
|
|
51
|
+
def _build_plan(
|
|
52
|
+
metadata: MetaData, engine: Engine, schemas, exclude, concurrently: bool = True
|
|
53
|
+
) -> Plan:
|
|
54
|
+
p = _engine.plan(metadata, engine, schemas=schemas, exclude=exclude, concurrently=concurrently)
|
|
53
55
|
return Plan(operations=p.operations + tuple(hypertable_operations(metadata, engine)))
|
|
54
56
|
|
|
55
57
|
|
|
@@ -65,13 +67,13 @@ class _PlannerWithDirectives(DiffEngine):
|
|
|
65
67
|
def __init__(self, engine: DiffEngine) -> None:
|
|
66
68
|
self._engine = engine
|
|
67
69
|
|
|
68
|
-
def plan(self, metadata, engine, *, schemas=None, exclude=()) -> Plan:
|
|
69
|
-
return _build_plan(metadata, engine, schemas, exclude)
|
|
70
|
+
def plan(self, metadata, engine, *, schemas=None, exclude=(), concurrently=True) -> Plan:
|
|
71
|
+
return _build_plan(metadata, engine, schemas, exclude, concurrently)
|
|
70
72
|
|
|
71
73
|
|
|
72
|
-
def plan(metadata, engine, *, schemas=None, exclude=()) -> Plan:
|
|
74
|
+
def plan(metadata, engine, *, schemas=None, exclude=(), concurrently: bool = True) -> Plan:
|
|
73
75
|
try:
|
|
74
|
-
return _build_plan(metadata, engine, schemas, exclude)
|
|
76
|
+
return _build_plan(metadata, engine, schemas, exclude, concurrently)
|
|
75
77
|
except SQLAlchemyError as exc:
|
|
76
78
|
_raise_typed(exc)
|
|
77
79
|
|
|
@@ -84,9 +86,11 @@ def push(
|
|
|
84
86
|
allow_destructive=False,
|
|
85
87
|
lock=True,
|
|
86
88
|
lock_timeout=5.0,
|
|
89
|
+
statement_timeout=None,
|
|
87
90
|
advisory_wait=30.0,
|
|
88
91
|
schemas=None,
|
|
89
92
|
exclude=(),
|
|
93
|
+
concurrently=True,
|
|
90
94
|
) -> Report:
|
|
91
95
|
try:
|
|
92
96
|
if lock:
|
|
@@ -100,14 +104,21 @@ def push(
|
|
|
100
104
|
reverify=_PlannerWithDirectives(_engine),
|
|
101
105
|
schemas=schemas,
|
|
102
106
|
exclude=exclude,
|
|
107
|
+
# THREADING (pinned): the lock winner re-plans via
|
|
108
|
+
# reverify.plan(...) — both knobs must reach it and the
|
|
109
|
+
# final apply_plan, or the winner renders differently
|
|
110
|
+
# than requested
|
|
111
|
+
concurrently=concurrently,
|
|
112
|
+
statement_timeout=statement_timeout,
|
|
103
113
|
)
|
|
104
|
-
p = _build_plan(metadata, engine, schemas, exclude)
|
|
114
|
+
p = _build_plan(metadata, engine, schemas, exclude, concurrently)
|
|
105
115
|
return apply_plan(
|
|
106
116
|
engine,
|
|
107
117
|
p,
|
|
108
118
|
allow_destructive=allow_destructive,
|
|
109
119
|
safe_only=safe_only,
|
|
110
120
|
lock_timeout=lock_timeout,
|
|
121
|
+
statement_timeout=statement_timeout,
|
|
111
122
|
)
|
|
112
123
|
except SQLAlchemyError as exc:
|
|
113
124
|
_raise_typed(exc)
|
|
@@ -126,14 +137,23 @@ def check(metadata, engine, *, schemas=None, exclude=()) -> CheckResult:
|
|
|
126
137
|
|
|
127
138
|
|
|
128
139
|
def revision(
|
|
129
|
-
metadata,
|
|
140
|
+
metadata,
|
|
141
|
+
ref_engine,
|
|
142
|
+
*,
|
|
143
|
+
out_dir="migrations/versions",
|
|
144
|
+
message=None,
|
|
145
|
+
schemas=None,
|
|
146
|
+
exclude=(),
|
|
147
|
+
concurrently=True,
|
|
130
148
|
) -> Path:
|
|
131
149
|
"""Generate the next annotated-SQL migration file from models-vs-ref drift.
|
|
132
150
|
|
|
133
151
|
The reference DB must sit at the chain head (caller-provided — sqlpush
|
|
134
152
|
stays docker-free). Empty drift refuses loudly: no empty files.
|
|
153
|
+
``concurrently`` follows :func:`plan` (default True: existing-table
|
|
154
|
+
indexes render CONCURRENTLY in the generated file).
|
|
135
155
|
"""
|
|
136
|
-
p = plan(metadata, ref_engine, schemas=schemas, exclude=exclude)
|
|
156
|
+
p = plan(metadata, ref_engine, schemas=schemas, exclude=exclude, concurrently=concurrently)
|
|
137
157
|
if not p.operations:
|
|
138
158
|
raise SqlpushError("no drift between models and reference DB — nothing to revise")
|
|
139
159
|
risk = max((op.risk for op in p.operations), key=lambda r: RISK_RANK[r])
|
|
@@ -175,6 +195,7 @@ def migrate(
|
|
|
175
195
|
allow_destructive=False,
|
|
176
196
|
advisory_wait=30.0,
|
|
177
197
|
lock_timeout=5.0,
|
|
198
|
+
statement_timeout=None,
|
|
178
199
|
) -> MigrateReport:
|
|
179
200
|
"""Replay annotated-SQL chain files with gates + same-txn bookkeeping.
|
|
180
201
|
|
|
@@ -182,7 +203,10 @@ def migrate(
|
|
|
182
203
|
via ``_sync_engine_from``; engines created here are disposed).
|
|
183
204
|
``advisory_wait`` bounds the advisory-lock wait and ``lock_timeout``
|
|
184
205
|
bounds each per-file transaction's lock wait (seconds; 0 = fail
|
|
185
|
-
immediately — same contract as ``push``).
|
|
206
|
+
immediately — same contract as ``push``). ``statement_timeout``
|
|
207
|
+
(seconds, ``None`` = set nothing) bounds each statement's runtime —
|
|
208
|
+
``SET LOCAL`` in per-file transactions, session-level on the
|
|
209
|
+
CONCURRENTLY autocommit connection. See
|
|
186
210
|
``chain.migrate.run_migrate`` for the execution contract.
|
|
187
211
|
"""
|
|
188
212
|
engine, dispose = _sync_engine_from(target)
|
|
@@ -193,6 +217,7 @@ def migrate(
|
|
|
193
217
|
allow_destructive=allow_destructive,
|
|
194
218
|
advisory_wait=advisory_wait,
|
|
195
219
|
lock_timeout=lock_timeout,
|
|
220
|
+
statement_timeout=statement_timeout,
|
|
196
221
|
)
|
|
197
222
|
except SQLAlchemyError as exc:
|
|
198
223
|
# MigrationFileError/SqlpushError (typed) pass through untouched
|
sqlpush/apply/executor.py
CHANGED
|
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|
|
3
3
|
|
|
4
4
|
import contextlib
|
|
5
5
|
import time
|
|
6
|
+
from collections.abc import Iterator
|
|
6
7
|
from typing import TYPE_CHECKING
|
|
7
8
|
|
|
8
9
|
from sqlalchemy import text
|
|
@@ -26,7 +27,37 @@ if TYPE_CHECKING:
|
|
|
26
27
|
|
|
27
28
|
|
|
28
29
|
def _is_concurrent(op: PlannedOperation) -> bool:
|
|
29
|
-
|
|
30
|
+
# Union (A5): the `concurrent` flag is authoritative for generated
|
|
31
|
+
# plans (set by the diff's injection); the SQL substring keeps
|
|
32
|
+
# hand-built Plans splitting to the autocommit segment.
|
|
33
|
+
return op.concurrent or "CONCURRENTLY" in op.sql.upper()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@contextlib.contextmanager
|
|
37
|
+
def _session_gucs(conn, **gucs: str | int) -> Iterator[None]:
|
|
38
|
+
"""Session-level ``SET``/``RESET`` with pooled-connection hygiene.
|
|
39
|
+
|
|
40
|
+
The autocommit (CONCURRENTLY) segment cannot use ``SET LOCAL`` —
|
|
41
|
+
there is no surrounding transaction — so its GUCs are session-level,
|
|
42
|
+
and a session ``SET`` survives segment end (0.4.2 lesson). RESET runs
|
|
43
|
+
per GUC in ``finally``; a reset failure is suppressed (it must never
|
|
44
|
+
mask the segment's own outcome) and the connection is invalidated so
|
|
45
|
+
the pool discards it instead of handing the next borrower a session
|
|
46
|
+
still carrying the shrunken budget.
|
|
47
|
+
"""
|
|
48
|
+
try:
|
|
49
|
+
for name, value in gucs.items():
|
|
50
|
+
# names come from sqlpush code, values are ints inlined in
|
|
51
|
+
# the same style as the txn segment's SET LOCAL (PostgreSQL
|
|
52
|
+
# does not accept bind parameters for SET)
|
|
53
|
+
conn.execute(text(f"SET {name} = '{value}'"))
|
|
54
|
+
yield
|
|
55
|
+
finally:
|
|
56
|
+
for name in gucs:
|
|
57
|
+
try:
|
|
58
|
+
conn.execute(text(f"RESET {name}"))
|
|
59
|
+
except Exception: # noqa: BLE001 # never mask the segment's outcome
|
|
60
|
+
conn.invalidate()
|
|
30
61
|
|
|
31
62
|
|
|
32
63
|
def apply_plan(
|
|
@@ -36,8 +67,11 @@ def apply_plan(
|
|
|
36
67
|
allow_destructive: bool = False,
|
|
37
68
|
safe_only: bool = False,
|
|
38
69
|
lock_timeout: float = 5.0,
|
|
70
|
+
statement_timeout: float | None = None,
|
|
39
71
|
) -> Report:
|
|
40
72
|
start = time.monotonic()
|
|
73
|
+
if statement_timeout is not None and statement_timeout < 0:
|
|
74
|
+
raise SqlpushError(f"statement_timeout must be >= 0, got {statement_timeout}")
|
|
41
75
|
|
|
42
76
|
blocked = tuple(op for op in plan.operations if op.risk is RiskClass.DESTRUCTIVE)
|
|
43
77
|
if blocked and not allow_destructive:
|
|
@@ -69,13 +103,21 @@ def apply_plan(
|
|
|
69
103
|
concurrent = [op for op in runnable if _is_concurrent(op)]
|
|
70
104
|
if concurrent:
|
|
71
105
|
with engine.connect().execution_options(isolation_level="AUTOCOMMIT") as conn:
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
106
|
+
# A7: the concurrent segment gets the SAME lock budget as the
|
|
107
|
+
# txn segment (session-level: autocommit has no surrounding
|
|
108
|
+
# transaction for SET LOCAL), plus the optional statement
|
|
109
|
+
# budget; both RESET before the connection returns to the pool
|
|
110
|
+
session_gucs: dict[str, int] = {"lock_timeout": int(lock_timeout * 1000)}
|
|
111
|
+
if statement_timeout is not None:
|
|
112
|
+
session_gucs["statement_timeout"] = int(statement_timeout * 1000)
|
|
113
|
+
with _session_gucs(conn, **session_gucs):
|
|
114
|
+
for op in concurrent:
|
|
115
|
+
try:
|
|
116
|
+
conn.execute(text(op.sql))
|
|
117
|
+
applied.append(AppliedOperation(op.type, "applied"))
|
|
118
|
+
except Exception: # noqa: BLE001 # concurrent ops record any DB failure and continue
|
|
119
|
+
applied.append(AppliedOperation(op.type, "failed"))
|
|
120
|
+
partial_failure = True
|
|
79
121
|
|
|
80
122
|
# --- transactional segment: atomic ----------------------------------
|
|
81
123
|
plain = [op for op in runnable if not _is_concurrent(op)]
|
|
@@ -83,9 +125,13 @@ def apply_plan(
|
|
|
83
125
|
try:
|
|
84
126
|
with engine.begin() as conn:
|
|
85
127
|
# NOTE: PostgreSQL does not accept bind parameters for SET
|
|
86
|
-
# (utility statement), so the int is inlined;
|
|
87
|
-
#
|
|
128
|
+
# (utility statement), so the int is inlined; both timeouts
|
|
129
|
+
# are typed float parameters, not user input.
|
|
88
130
|
conn.execute(text(f"SET LOCAL lock_timeout = {int(lock_timeout * 1000)}"))
|
|
131
|
+
if statement_timeout is not None:
|
|
132
|
+
conn.execute(
|
|
133
|
+
text(f"SET LOCAL statement_timeout = {int(statement_timeout * 1000)}")
|
|
134
|
+
)
|
|
89
135
|
for op in plain:
|
|
90
136
|
conn.execute(text(op.sql))
|
|
91
137
|
applied.extend(AppliedOperation(op.type, "applied") for op in plain)
|
|
@@ -146,6 +192,8 @@ def with_advisory_lock(
|
|
|
146
192
|
reverify: DiffEngine | None = None,
|
|
147
193
|
schemas: Sequence[str] | None = None,
|
|
148
194
|
exclude: Sequence[str] = (),
|
|
195
|
+
concurrently: bool = True,
|
|
196
|
+
statement_timeout: float | None = None,
|
|
149
197
|
) -> Report:
|
|
150
198
|
"""Winner migrates; losers block (bounded), then re-verify.
|
|
151
199
|
|
|
@@ -153,7 +201,9 @@ def with_advisory_lock(
|
|
|
153
201
|
``time.monotonic()`` deadline; once the lock is acquired the winner
|
|
154
202
|
path re-plans (covering the case where the previous winner died
|
|
155
203
|
mid-push). Raises :class:`SqlpushError` if the wait budget is
|
|
156
|
-
exhausted.
|
|
204
|
+
exhausted. ``concurrently`` and ``statement_timeout`` thread into
|
|
205
|
+
BOTH the winner's re-plan and its apply — the re-plan must render
|
|
206
|
+
exactly what the caller asked for.
|
|
157
207
|
"""
|
|
158
208
|
if reverify is None:
|
|
159
209
|
raise SqlpushError(
|
|
@@ -179,7 +229,13 @@ def with_advisory_lock(
|
|
|
179
229
|
# (killed mid-push = lock silently released)
|
|
180
230
|
conn.rollback()
|
|
181
231
|
try:
|
|
182
|
-
plan = reverify.plan(
|
|
232
|
+
plan = reverify.plan(
|
|
233
|
+
metadata,
|
|
234
|
+
engine,
|
|
235
|
+
schemas=schemas,
|
|
236
|
+
exclude=exclude,
|
|
237
|
+
concurrently=concurrently,
|
|
238
|
+
)
|
|
183
239
|
if not plan.drift:
|
|
184
240
|
return Report()
|
|
185
241
|
return apply_plan(
|
|
@@ -188,6 +244,7 @@ def with_advisory_lock(
|
|
|
188
244
|
allow_destructive=allow_destructive,
|
|
189
245
|
safe_only=safe_only,
|
|
190
246
|
lock_timeout=timeout,
|
|
247
|
+
statement_timeout=statement_timeout,
|
|
191
248
|
)
|
|
192
249
|
finally:
|
|
193
250
|
# best-effort unlock: a secondary failure here (e.g. the
|
sqlpush/chain/migrate.py
CHANGED
|
@@ -1,13 +1,26 @@
|
|
|
1
1
|
"""migrate/stamp: chain replay + bootstrap with gates and bookkeeping.
|
|
2
2
|
|
|
3
|
-
migrate execution contract (spec 2026-09-02 §5):
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
3
|
+
migrate execution contract (spec 2026-09-02 §5, 0.5.0 hybrid): a file
|
|
4
|
+
whose text contains no CONCURRENTLY replays its WHOLE TEXT in ONE
|
|
5
|
+
``exec_driver_sql()`` call inside a per-file transaction — nothing is
|
|
6
|
+
tokenized, so dollar-quoted bodies (and their internal ``--`` lines)
|
|
7
|
+
are safe; that is the 0.4.2 fast path, byte-identical. A file that
|
|
8
|
+
DOES contain CONCURRENTLY replays per-op on the op-label delimiters
|
|
9
|
+
(``mf.ops``): the plain segment runs FIRST in one transaction (chain
|
|
10
|
+
files can create→index within one file — a table made by a plain op
|
|
11
|
+
must exist before a concurrent index on it; push's concurrent-first
|
|
12
|
+
order does NOT apply here), then concurrent ops run per-op on a
|
|
13
|
+
dedicated autocommit connection, and only after all of them succeed
|
|
14
|
+
is the versions row written. Known cost (chain spec §7): hand-edits
|
|
15
|
+
bypass the label mechanism — a label-less body containing
|
|
16
|
+
CONCURRENTLY routes the whole body to the autocommit lane (executed
|
|
17
|
+
statement-by-statement: the server runs a multi-statement string as
|
|
18
|
+
one implicit transaction, which CONCURRENTLY refuses), and lines
|
|
19
|
+
starting ``--`` are stripped from per-op parsing (they exist inside
|
|
20
|
+
dollar-quoted bodies at the author's risk). Concurrent-free files
|
|
21
|
+
are immune: they take the raw fast path. The crash window (plain
|
|
22
|
+
committed, concurrent applied, no row yet) re-runs loud on existing
|
|
23
|
+
objects — the documented chain-side cost of per-op replay.
|
|
11
24
|
|
|
12
25
|
Fail-loud ordering: any blocked file (parse error, checksum mismatch,
|
|
13
26
|
destructive gate, SQL failure) stops the chain — nothing later runs (R4).
|
|
@@ -19,6 +32,7 @@ holder — diagnose the holder via pg_locks / pg_stat_activity.
|
|
|
19
32
|
from __future__ import annotations
|
|
20
33
|
|
|
21
34
|
import contextlib
|
|
35
|
+
import re
|
|
22
36
|
import time
|
|
23
37
|
from collections.abc import Iterator
|
|
24
38
|
from pathlib import Path
|
|
@@ -26,7 +40,7 @@ from pathlib import Path
|
|
|
26
40
|
from sqlalchemy import text
|
|
27
41
|
from sqlalchemy.engine import Connection, Engine
|
|
28
42
|
|
|
29
|
-
from sqlpush.apply.executor import advisory_key
|
|
43
|
+
from sqlpush.apply.executor import _session_gucs, advisory_key
|
|
30
44
|
from sqlpush.chain.format import MigrationFileError, checksum, parse_migration_file
|
|
31
45
|
from sqlpush.types import MigrateReport, RiskClass, SqlpushError
|
|
32
46
|
|
|
@@ -46,6 +60,101 @@ def _chain_files(chain_dir: str | Path) -> list[Path]:
|
|
|
46
60
|
return sorted(chain_path.glob("*.sql"))
|
|
47
61
|
|
|
48
62
|
|
|
63
|
+
def _set_local_gucs(
|
|
64
|
+
conn: Connection, *, lock_timeout: float, statement_timeout: float | None
|
|
65
|
+
) -> None:
|
|
66
|
+
# txn-scoped: SET LOCAL dies with the surrounding transaction (style:
|
|
67
|
+
# push's transactional segment, executor.py). NOTE: PostgreSQL does
|
|
68
|
+
# not accept bind parameters for SET (utility statement), so ints
|
|
69
|
+
# are inlined; both timeouts are typed float parameters, not user
|
|
70
|
+
# input. A chain file blocked behind another transaction's lock
|
|
71
|
+
# fails fast instead of queuing.
|
|
72
|
+
conn.execute(text(f"SET LOCAL lock_timeout = {int(lock_timeout * 1000)}"))
|
|
73
|
+
if statement_timeout is not None:
|
|
74
|
+
conn.execute(text(f"SET LOCAL statement_timeout = {int(statement_timeout * 1000)}"))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _record_version(conn: Connection, name: str, sha: str) -> None:
|
|
78
|
+
conn.execute(
|
|
79
|
+
text("INSERT INTO public.sqlpush_versions (name, sha256) VALUES (:n, :s)"),
|
|
80
|
+
{"n": name, "s": sha},
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
_DOLLAR_TAG_RE = re.compile(r"\$[A-Za-z_][A-Za-z0-9_]*\$|\$\$")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _split_statements(sql: str) -> list[str]:
|
|
88
|
+
"""Split a concurrent-lane op into single statements.
|
|
89
|
+
|
|
90
|
+
Physical constraint (live-verified): the server executes a
|
|
91
|
+
multi-statement simple-protocol string inside ONE implicit
|
|
92
|
+
transaction block, even on an autocommit connection — so
|
|
93
|
+
``CREATE INDEX CONCURRENTLY`` cannot ride a multi-statement
|
|
94
|
+
``exec_driver_sql`` call; the autocommit lane needs one statement
|
|
95
|
+
per execute. Split on top-level ``;`` only, treating single-quoted
|
|
96
|
+
strings (``''`` doubling), double-quoted identifiers, dollar-quoted
|
|
97
|
+
bodies (``$tag$...$tag$``) and ``--`` / ``/* */`` comments as
|
|
98
|
+
opaque (psql-equivalent boundaries). Generated ops are single
|
|
99
|
+
statements — the splitter is a no-op for them; only hand-edited
|
|
100
|
+
label-less bodies exercise it (chain spec §7: at the author's
|
|
101
|
+
risk). The plain segment and the fast path NEVER split.
|
|
102
|
+
"""
|
|
103
|
+
statements: list[str] = []
|
|
104
|
+
buf: list[str] = []
|
|
105
|
+
i, n = 0, len(sql)
|
|
106
|
+
while i < n:
|
|
107
|
+
ch = sql[i]
|
|
108
|
+
nxt = sql[i + 1] if i + 1 < n else ""
|
|
109
|
+
if ch == "-" and nxt == "-": # line comment
|
|
110
|
+
j = sql.find("\n", i)
|
|
111
|
+
j = n if j == -1 else j + 1
|
|
112
|
+
buf.append(sql[i:j])
|
|
113
|
+
i = j
|
|
114
|
+
elif ch == "/" and nxt == "*": # block comment
|
|
115
|
+
j = sql.find("*/", i + 2)
|
|
116
|
+
j = n if j == -1 else j + 2
|
|
117
|
+
buf.append(sql[i:j])
|
|
118
|
+
i = j
|
|
119
|
+
elif ch == "'": # string literal, '' doubling
|
|
120
|
+
j = i + 1
|
|
121
|
+
while j < n:
|
|
122
|
+
if sql[j] == "'" and not (j + 1 < n and sql[j + 1] == "'"):
|
|
123
|
+
break
|
|
124
|
+
j += 2 if sql[j] == "'" else 1
|
|
125
|
+
j = min(j + 1, n)
|
|
126
|
+
buf.append(sql[i:j])
|
|
127
|
+
i = j
|
|
128
|
+
elif ch == '"': # quoted identifier
|
|
129
|
+
j = sql.find('"', i + 1)
|
|
130
|
+
j = n if j == -1 else j + 1
|
|
131
|
+
buf.append(sql[i:j])
|
|
132
|
+
i = j
|
|
133
|
+
elif ch == "$":
|
|
134
|
+
m = _DOLLAR_TAG_RE.match(sql, i)
|
|
135
|
+
if m: # dollar-quoted body: opaque through the closing tag
|
|
136
|
+
end = sql.find(m.group(0), m.end())
|
|
137
|
+
j = n if end == -1 else end + len(m.group(0))
|
|
138
|
+
buf.append(sql[i:j])
|
|
139
|
+
i = j
|
|
140
|
+
else:
|
|
141
|
+
buf.append(ch)
|
|
142
|
+
i += 1
|
|
143
|
+
elif ch == ";":
|
|
144
|
+
stmt = "".join(buf).strip()
|
|
145
|
+
if stmt:
|
|
146
|
+
statements.append(stmt)
|
|
147
|
+
buf = []
|
|
148
|
+
i += 1
|
|
149
|
+
else:
|
|
150
|
+
buf.append(ch)
|
|
151
|
+
i += 1
|
|
152
|
+
tail = "".join(buf).strip()
|
|
153
|
+
if tail:
|
|
154
|
+
statements.append(tail)
|
|
155
|
+
return statements
|
|
156
|
+
|
|
157
|
+
|
|
49
158
|
@contextlib.contextmanager
|
|
50
159
|
def _chain_session(engine: Engine, *, advisory_wait: float = 30.0) -> Iterator[Connection]:
|
|
51
160
|
"""Session-scoped advisory lock + versions table, shared by every verb.
|
|
@@ -97,12 +206,15 @@ def run_migrate(
|
|
|
97
206
|
allow_destructive: bool,
|
|
98
207
|
advisory_wait: float = 30.0,
|
|
99
208
|
lock_timeout: float = 5.0,
|
|
209
|
+
statement_timeout: float | None = None,
|
|
100
210
|
) -> MigrateReport:
|
|
101
211
|
if lock_timeout < 0:
|
|
102
212
|
# same contract as push (executor.with_advisory_lock): budgets
|
|
103
213
|
# are typed floats, never user input, and a negative one must
|
|
104
214
|
# fail before any file or connection work
|
|
105
215
|
raise SqlpushError(f"lock_timeout must be >= 0, got {lock_timeout}")
|
|
216
|
+
if statement_timeout is not None and statement_timeout < 0:
|
|
217
|
+
raise SqlpushError(f"statement_timeout must be >= 0, got {statement_timeout}")
|
|
106
218
|
applied: list[str] = []
|
|
107
219
|
skipped: list[str] = []
|
|
108
220
|
blocked: list[str] = []
|
|
@@ -115,49 +227,115 @@ def run_migrate(
|
|
|
115
227
|
for row in conn.execute(text("SELECT name, sha256 FROM public.sqlpush_versions"))
|
|
116
228
|
}
|
|
117
229
|
conn.commit()
|
|
118
|
-
for
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
230
|
+
# B11: ONE lazily-created autocommit connection for the whole
|
|
231
|
+
# walk, GUC-pinned for its entire lifetime. ExitStack ordering:
|
|
232
|
+
# _session_gucs' finally (per-GUC RESET, suppress + invalidate
|
|
233
|
+
# on reset failure) unwinds BEFORE the connection close (LIFO).
|
|
234
|
+
concurrent_stack = contextlib.ExitStack()
|
|
235
|
+
concurrent_conn: Connection | None = None
|
|
236
|
+
try:
|
|
237
|
+
for f in chain:
|
|
238
|
+
raw = f.read_text()
|
|
239
|
+
try:
|
|
240
|
+
mf = parse_migration_file(raw, name=f.name)
|
|
241
|
+
except MigrationFileError as exc:
|
|
242
|
+
blocked.append(f.name)
|
|
243
|
+
notes.append(f"{f.name}: {exc}")
|
|
244
|
+
break # orden estricto: nada posterior corre
|
|
245
|
+
if f.name in recorded:
|
|
246
|
+
if recorded[f.name] != checksum(raw):
|
|
247
|
+
blocked.append(f.name)
|
|
248
|
+
notes.append(f"{f.name}: checksum mismatch (edited after apply?)")
|
|
249
|
+
break
|
|
250
|
+
skipped.append(f.name)
|
|
251
|
+
continue
|
|
252
|
+
if mf.risk is RiskClass.DESTRUCTIVE and not allow_destructive:
|
|
128
253
|
blocked.append(f.name)
|
|
129
|
-
notes.append(f"{f.name}:
|
|
254
|
+
notes.append(f"{f.name}: DESTRUCTIVE requires --allow-destructive")
|
|
130
255
|
break
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
#
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
256
|
+
if "CONCURRENTLY" not in raw.upper():
|
|
257
|
+
# fast path — byte-identical to 0.4.2: whole text,
|
|
258
|
+
# one txn, one exec_driver_sql call (":casts" and
|
|
259
|
+
# ":=" reach the server verbatim; dollar-quoted
|
|
260
|
+
# bodies and their internal `--` lines are safe —
|
|
261
|
+
# the parser's line-stripping quirks never execute
|
|
262
|
+
# on this path). Checksum row rides the SAME txn.
|
|
263
|
+
try:
|
|
264
|
+
with conn.begin():
|
|
265
|
+
_set_local_gucs(
|
|
266
|
+
conn,
|
|
267
|
+
lock_timeout=lock_timeout,
|
|
268
|
+
statement_timeout=statement_timeout,
|
|
269
|
+
)
|
|
270
|
+
conn.exec_driver_sql(raw)
|
|
271
|
+
_record_version(conn, f.name, checksum(raw))
|
|
272
|
+
applied.append(f.name)
|
|
273
|
+
except Exception as exc: # noqa: BLE001 — report, no mask
|
|
274
|
+
blocked.append(f.name)
|
|
275
|
+
notes.append(f"{f.name}: {exc}")
|
|
276
|
+
partial = True
|
|
277
|
+
break
|
|
278
|
+
continue
|
|
279
|
+
# mixed path — per-op replay on the op-label delimiters.
|
|
280
|
+
# PLAIN SEGMENT FIRST (create→index dependencies inside
|
|
281
|
+
# one file); concurrent ops afterwards, per-op on the
|
|
282
|
+
# dedicated autocommit connection; versions row LAST.
|
|
283
|
+
plain = [sql for _, sql in mf.ops if "CONCURRENTLY" not in sql.upper()]
|
|
284
|
+
conc = [sql for _, sql in mf.ops if "CONCURRENTLY" in sql.upper()]
|
|
285
|
+
plain_committed = False
|
|
286
|
+
try:
|
|
287
|
+
if plain:
|
|
288
|
+
with conn.begin():
|
|
289
|
+
_set_local_gucs(
|
|
290
|
+
conn,
|
|
291
|
+
lock_timeout=lock_timeout,
|
|
292
|
+
statement_timeout=statement_timeout,
|
|
293
|
+
)
|
|
294
|
+
for sql in plain:
|
|
295
|
+
conn.exec_driver_sql(sql)
|
|
296
|
+
plain_committed = True
|
|
297
|
+
for sql in conc:
|
|
298
|
+
if concurrent_conn is None:
|
|
299
|
+
concurrent_conn = engine.connect().execution_options(
|
|
300
|
+
isolation_level="AUTOCOMMIT"
|
|
301
|
+
)
|
|
302
|
+
concurrent_stack.callback(concurrent_conn.close)
|
|
303
|
+
session_gucs: dict[str, int] = {
|
|
304
|
+
"lock_timeout": int(lock_timeout * 1000)
|
|
305
|
+
}
|
|
306
|
+
if statement_timeout is not None:
|
|
307
|
+
session_gucs["statement_timeout"] = int(statement_timeout * 1000)
|
|
308
|
+
concurrent_stack.enter_context(
|
|
309
|
+
_session_gucs(concurrent_conn, **session_gucs)
|
|
310
|
+
)
|
|
311
|
+
# statement-per-execute: a multi-statement string
|
|
312
|
+
# is one implicit server txn (CONCURRENTLY refuses
|
|
313
|
+
# it) — generated ops are single statements, so
|
|
314
|
+
# this split only fires for hand-edited bodies
|
|
315
|
+
for stmt in _split_statements(sql):
|
|
316
|
+
concurrent_conn.exec_driver_sql(stmt)
|
|
317
|
+
# never record before the concurrent ops succeed: a
|
|
318
|
+
# row for a file whose concurrent op then failed would
|
|
319
|
+
# be silent divergence. The crash window this leaves
|
|
320
|
+
# (plain committed + concurrent applied + no row)
|
|
321
|
+
# re-runs loud on existing objects — documented cost.
|
|
322
|
+
with conn.begin():
|
|
323
|
+
_record_version(conn, f.name, checksum(raw))
|
|
324
|
+
applied.append(f.name)
|
|
325
|
+
except Exception as exc: # noqa: BLE001 — report, no mask
|
|
326
|
+
blocked.append(f.name)
|
|
327
|
+
notes.append(f"{f.name}: {exc}")
|
|
328
|
+
if plain_committed:
|
|
329
|
+
# honest report: the plain segment of THIS file is
|
|
330
|
+
# already committed (same class as push's partial
|
|
331
|
+
# failure) and no versions row was recorded
|
|
332
|
+
notes.append(
|
|
333
|
+
f"{f.name}: plain segment already committed; no versions row recorded"
|
|
334
|
+
)
|
|
335
|
+
partial = True
|
|
336
|
+
break
|
|
337
|
+
finally:
|
|
338
|
+
concurrent_stack.close()
|
|
161
339
|
return MigrateReport(
|
|
162
340
|
applied=tuple(applied),
|
|
163
341
|
skipped=tuple(skipped),
|
sqlpush/cli.py
CHANGED
|
@@ -148,6 +148,8 @@ def push(
|
|
|
148
148
|
no_lock: bool = typer.Option(False, "--no-lock"),
|
|
149
149
|
lock_timeout: float = typer.Option(5.0, "--lock-timeout"),
|
|
150
150
|
advisory_wait: float = typer.Option(30.0, "--advisory-wait"),
|
|
151
|
+
no_concurrently: bool = typer.Option(False, "--no-concurrently"),
|
|
152
|
+
statement_timeout: float | None = typer.Option(None, "--statement-timeout"),
|
|
151
153
|
verbose: bool = typer.Option(False, "--verbose"),
|
|
152
154
|
quiet: bool = typer.Option(False, "--quiet"),
|
|
153
155
|
schema: SchemaOpt = None,
|
|
@@ -165,6 +167,8 @@ def push(
|
|
|
165
167
|
lock=not no_lock,
|
|
166
168
|
lock_timeout=lock_timeout,
|
|
167
169
|
advisory_wait=advisory_wait,
|
|
170
|
+
concurrently=not no_concurrently,
|
|
171
|
+
statement_timeout=statement_timeout,
|
|
168
172
|
schemas=schema,
|
|
169
173
|
exclude=exclude or (),
|
|
170
174
|
)
|
|
@@ -223,6 +227,7 @@ def revision(
|
|
|
223
227
|
ref_dsn: str = typer.Option(..., "--ref-dsn"),
|
|
224
228
|
message: str | None = typer.Option(None, "--message", "-m"),
|
|
225
229
|
out_dir: DirOpt = Path("migrations/versions"),
|
|
230
|
+
no_concurrently: bool = typer.Option(False, "--no-concurrently"),
|
|
226
231
|
schema: SchemaOpt = None,
|
|
227
232
|
exclude: ExcludeOpt = None,
|
|
228
233
|
):
|
|
@@ -238,6 +243,7 @@ def revision(
|
|
|
238
243
|
engine,
|
|
239
244
|
out_dir=out_dir,
|
|
240
245
|
message=message,
|
|
246
|
+
concurrently=not no_concurrently,
|
|
241
247
|
schemas=schema,
|
|
242
248
|
exclude=exclude or (),
|
|
243
249
|
)
|
|
@@ -253,6 +259,7 @@ def migrate(
|
|
|
253
259
|
allow_destructive: bool = typer.Option(False, "--allow-destructive"),
|
|
254
260
|
advisory_wait: float = typer.Option(30.0, "--advisory-wait"),
|
|
255
261
|
lock_timeout: float = typer.Option(5.0, "--lock-timeout"),
|
|
262
|
+
statement_timeout: float | None = typer.Option(None, "--statement-timeout"),
|
|
256
263
|
out_dir: DirOpt = Path("migrations/versions"),
|
|
257
264
|
):
|
|
258
265
|
"""Replay pending migration files (gates + checksum bookkeeping)."""
|
|
@@ -264,6 +271,7 @@ def migrate(
|
|
|
264
271
|
allow_destructive=allow_destructive,
|
|
265
272
|
advisory_wait=advisory_wait,
|
|
266
273
|
lock_timeout=lock_timeout,
|
|
274
|
+
statement_timeout=statement_timeout,
|
|
267
275
|
)
|
|
268
276
|
finally:
|
|
269
277
|
engine.dispose()
|
sqlpush/core/classify.py
CHANGED
|
@@ -8,13 +8,18 @@ DESTRUCTIVE = frozenset({"drop_column", "drop_table", "drop_index", "drop_constr
|
|
|
8
8
|
|
|
9
9
|
|
|
10
10
|
def classify(op_type: str) -> RiskClass:
|
|
11
|
-
"""add_index
|
|
12
|
-
|
|
13
|
-
(
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
11
|
+
"""add_index stays RISKY in BOTH renderings. The plain form takes a
|
|
12
|
+
SHARE lock that blocks writes for the whole build. CONCURRENTLY
|
|
13
|
+
(0.5 default for existing-table indexes) trades that write-block
|
|
14
|
+
for a different risk profile: the build is concurrent and
|
|
15
|
+
non-transactional (no rollback — a failure leaves no index behind
|
|
16
|
+
to retry with, but an aborted build can leave an INVALID index),
|
|
17
|
+
so the op is still flagged, never silently safe. Standalone
|
|
18
|
+
rendering note: on alembic 1.19.1 even plain declared indexes of
|
|
19
|
+
NEW tables arrive standalone (CreateTableOp.from_table captures
|
|
20
|
+
columns+constraints, not indexes; only instrumentation-embedded
|
|
21
|
+
ones ride inside the add_table render, and the diff dedups those
|
|
22
|
+
away)."""
|
|
18
23
|
if op_type in DESTRUCTIVE:
|
|
19
24
|
return RiskClass.DESTRUCTIVE
|
|
20
25
|
if op_type in SAFE:
|
sqlpush/core/diff.py
CHANGED
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import dataclasses
|
|
5
6
|
import fnmatch
|
|
6
7
|
import io
|
|
8
|
+
import re
|
|
7
9
|
from collections.abc import Sequence
|
|
8
10
|
|
|
9
11
|
from alembic.autogenerate import produce_migrations
|
|
@@ -464,6 +466,43 @@ def _render_op_sql(op, engine: Engine) -> str:
|
|
|
464
466
|
return buf.getvalue().strip().rstrip(";")
|
|
465
467
|
|
|
466
468
|
|
|
469
|
+
# A1: match the CREATE [UNIQUE] INDEX head of an op's (single-statement)
|
|
470
|
+
# SQL. Anchored with no MULTILINE and sub'd with count=1: only the op's
|
|
471
|
+
# own leading statement is touched. The lookahead makes it idempotent —
|
|
472
|
+
# a second pass over an already-injected render finds CONCURRENTLY right
|
|
473
|
+
# after the consumed whitespace and declines. IF NOT EXISTS lands after
|
|
474
|
+
# the inserted keyword, which is the correct PostgreSQL order.
|
|
475
|
+
_CONCURRENTLY_RE = re.compile(
|
|
476
|
+
r"^(CREATE\s+(?:UNIQUE\s+)?INDEX)\s+(?!CONCURRENTLY\s)", re.IGNORECASE
|
|
477
|
+
)
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def _render_concurrent(ops: list[PlannedOperation]) -> list[PlannedOperation]:
|
|
481
|
+
"""Render standalone ``add_index`` ops on EXISTING tables CONCURRENTLY.
|
|
482
|
+
|
|
483
|
+
Plain ``CREATE INDEX`` takes a SHARE lock that blocks all writes on
|
|
484
|
+
the table for the whole build; ``CREATE INDEX CONCURRENTLY`` is the
|
|
485
|
+
production-safe form for tables that already carry traffic. Indexes
|
|
486
|
+
on tables CREATED in the same plan stay plain: a brand-new table has
|
|
487
|
+
no concurrent writers to protect, and CONCURRENTLY cannot run inside
|
|
488
|
+
the plan's transactional create. The new-table guard keys on bare
|
|
489
|
+
table names (``op.table``), which is conservative-correct: a false
|
|
490
|
+
"new" (cross-schema bare-name collision) yields a plain
|
|
491
|
+
``CREATE INDEX`` — always executable — while the dangerous direction
|
|
492
|
+
(injecting on a new table) cannot happen.
|
|
493
|
+
"""
|
|
494
|
+
new_tables = {op.table for op in ops if op.type == "add_table"}
|
|
495
|
+
out: list[PlannedOperation] = []
|
|
496
|
+
for op in ops:
|
|
497
|
+
if op.type == "add_index" and op.table not in new_tables:
|
|
498
|
+
sql = _CONCURRENTLY_RE.sub(r"\1 CONCURRENTLY ", op.sql, count=1)
|
|
499
|
+
if sql != op.sql:
|
|
500
|
+
out.append(dataclasses.replace(op, sql=sql, concurrent=True))
|
|
501
|
+
continue
|
|
502
|
+
out.append(op)
|
|
503
|
+
return out
|
|
504
|
+
|
|
505
|
+
|
|
467
506
|
class DiffEngine:
|
|
468
507
|
def plan(
|
|
469
508
|
self,
|
|
@@ -472,6 +511,7 @@ class DiffEngine:
|
|
|
472
511
|
*,
|
|
473
512
|
schemas: Sequence[str] | None = None,
|
|
474
513
|
exclude: Sequence[str] = (),
|
|
514
|
+
concurrently: bool = True,
|
|
475
515
|
) -> Plan:
|
|
476
516
|
exclude = tuple(exclude)
|
|
477
517
|
# Typed `str | None` by SQLAlchemy; a dialect without a default
|
|
@@ -516,6 +556,15 @@ class DiffEngine:
|
|
|
516
556
|
ops.extend(self._translate(op, engine, exclude))
|
|
517
557
|
ops = _dedup_embedded_indexes(ops)
|
|
518
558
|
ops = _dedup_enum_types(ops)
|
|
559
|
+
# ORDERING INVARIANT: CONCURRENTLY injection must run AFTER both
|
|
560
|
+
# dedups, as the final pipeline stage. _dedup_embedded_indexes
|
|
561
|
+
# matches standalone renders against the add_table render by
|
|
562
|
+
# byte containment — injecting first would rewrite only the
|
|
563
|
+
# standalone copy, break the byte-identity, and let the pair
|
|
564
|
+
# execute twice. By construction the dedup never sees a
|
|
565
|
+
# CONCURRENTLY render.
|
|
566
|
+
if concurrently:
|
|
567
|
+
ops = _render_concurrent(ops)
|
|
519
568
|
return Plan(operations=tuple(ops))
|
|
520
569
|
|
|
521
570
|
def _translate(self, op, engine: Engine, exclude: tuple[str, ...]) -> list[PlannedOperation]:
|
sqlpush/types.py
CHANGED
|
@@ -19,6 +19,11 @@ class PlannedOperation:
|
|
|
19
19
|
sql: str
|
|
20
20
|
table: str | None = None
|
|
21
21
|
column: str | None = None
|
|
22
|
+
# True when the op's SQL was rendered with CONCURRENTLY (the
|
|
23
|
+
# executor's authoritative routing signal for generated plans;
|
|
24
|
+
# hand-built plans keep the SQL-substring fallback). Defaults False
|
|
25
|
+
# so every pre-existing construction site is unchanged.
|
|
26
|
+
concurrent: bool = False
|
|
22
27
|
|
|
23
28
|
|
|
24
29
|
@dataclass(frozen=True)
|
|
@@ -44,6 +49,7 @@ class Plan:
|
|
|
44
49
|
"table": op.table,
|
|
45
50
|
"column": op.column,
|
|
46
51
|
"sql": op.sql,
|
|
52
|
+
"concurrent": op.concurrent,
|
|
47
53
|
}
|
|
48
54
|
for op in self.operations
|
|
49
55
|
],
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sqlpush
|
|
3
|
+
Version: 0.5.1
|
|
4
|
+
Summary: Schema lifecycle tool for production PostgreSQL/TimescaleDB on SQLAlchemy 2.0 (SQLModel included): diff, push and check schema drift straight from your models
|
|
5
|
+
Keywords: sqlalchemy,sqlmodel,fastapi,alembic,postgresql,timescaledb,prisma,schema,migrations,database,drift,cli
|
|
6
|
+
Author: Juan Miguel Contreras
|
|
7
|
+
Author-email: Juan Miguel Contreras <19253629+juanmicl@users.noreply.github.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Environment :: Console
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Topic :: Database
|
|
18
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
19
|
+
Requires-Dist: alembic>=1.18,<2
|
|
20
|
+
Requires-Dist: psycopg[binary]>=3.2
|
|
21
|
+
Requires-Dist: sqlalchemy>=2.0
|
|
22
|
+
Requires-Dist: typer>=0.12
|
|
23
|
+
Requires-Python: >=3.10
|
|
24
|
+
Project-URL: Homepage, https://github.com/juanmicl/sqlpush
|
|
25
|
+
Project-URL: Repository, https://github.com/juanmicl/sqlpush
|
|
26
|
+
Project-URL: Issues, https://github.com/juanmicl/sqlpush/issues
|
|
27
|
+
Project-URL: Changelog, https://github.com/juanmicl/sqlpush/blob/main/CHANGELOG.md
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# sqlpush
|
|
31
|
+
|
|
32
|
+
[](https://pypi.org/project/sqlpush/)
|
|
33
|
+
[](https://pypi.org/project/sqlpush/)
|
|
34
|
+
[](LICENSE)
|
|
35
|
+
|
|
36
|
+
**Prisma `db push` for SQLAlchemy.** Your models are the migration.
|
|
37
|
+
sqlpush diffs them (SQLAlchemy, SQLModel, anything built on `MetaData`)
|
|
38
|
+
against the live PostgreSQL / TimescaleDB database, classifies every
|
|
39
|
+
operation by risk (safe / risky / destructive), and applies the plan
|
|
40
|
+
atomically. No migration files to write, no `upgrade` step to forget.
|
|
41
|
+
Drift checks exit with codes your CI can gate on.
|
|
42
|
+
|
|
43
|
+
```console
|
|
44
|
+
sqlpush diff "myapp.models:metadata" # see the SQL, ordered by risk
|
|
45
|
+
sqlpush check "myapp.models:metadata" # CI gate: exit 0/2/3
|
|
46
|
+
sqlpush push "myapp.models:metadata" # apply (destructive gated)
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
If you've run `Base.metadata.create_all()` in production and known it
|
|
50
|
+
was wrong, sqlpush is for you.
|
|
51
|
+
|
|
52
|
+
## Install
|
|
53
|
+
|
|
54
|
+
```console
|
|
55
|
+
pip install sqlpush
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Or from source:
|
|
59
|
+
|
|
60
|
+
```console
|
|
61
|
+
git clone https://github.com/juanmicl/sqlpush && cd sqlpush && uv sync
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Python 3.10 or newer. PostgreSQL only.
|
|
65
|
+
|
|
66
|
+
## Why
|
|
67
|
+
|
|
68
|
+
Declarative models are already the source of truth. Migration files
|
|
69
|
+
re-encode what the models say, drift from them, and pile up forever.
|
|
70
|
+
sqlpush closes the loop the way Prisma's `db push` does for its schema
|
|
71
|
+
language, but for the SQLAlchemy ecosystem (SQLModel included):
|
|
72
|
+
|
|
73
|
+
- **No migration files required.** The diff *is* the migration: computed fresh
|
|
74
|
+
from models vs. live database on every run, via alembic's autogenerate
|
|
75
|
+
engine used as a library. Files exist as a second workflow when you want
|
|
76
|
+
them (see below).
|
|
77
|
+
- **Risk-aware by default.** Every operation is classified `safe` /
|
|
78
|
+
`risky` / `destructive`. Destructive ops (drops) are **blocked until
|
|
79
|
+
`--allow-destructive`**: nothing executes at all while any is present.
|
|
80
|
+
- **Drift detection built for CI.** `check` plans once and reports
|
|
81
|
+
through its exit code, no output parsing; `--json` emits a stable
|
|
82
|
+
versioned contract.
|
|
83
|
+
- **Safe under concurrency.** An advisory lock coordinates workers: one
|
|
84
|
+
pusher at a time, losers wait bounded
|
|
85
|
+
and re-verify, so deploy pipelines can race without corrupting anything.
|
|
86
|
+
- **Hypertables without hand-written SQL.** Decorate a model with
|
|
87
|
+
`@hypertable` and the `create_hypertable` directive is planned
|
|
88
|
+
state-aware: idempotent pushes, clean checks, no false drift.
|
|
89
|
+
|
|
90
|
+
If you know alembic: sqlpush is its autogenerate engine, productized
|
|
91
|
+
into apply and check verbs, with no revision scripts to maintain.
|
|
92
|
+
|
|
93
|
+
PostgreSQL only, by design.
|
|
94
|
+
|
|
95
|
+
## The 30-second tour
|
|
96
|
+
|
|
97
|
+
Point sqlpush at your metadata (`module:attribute`) and a database
|
|
98
|
+
(`--dsn` or `$DATABASE_URL`):
|
|
99
|
+
|
|
100
|
+
```console
|
|
101
|
+
$ export DATABASE_URL="postgresql+psycopg://user:pass@host:5432/db"
|
|
102
|
+
|
|
103
|
+
$ sqlpush diff "myapp.models:metadata"
|
|
104
|
+
-- safe
|
|
105
|
+
CREATE TABLE hero (
|
|
106
|
+
id SERIAL NOT NULL,
|
|
107
|
+
name VARCHAR(50) NOT NULL,
|
|
108
|
+
PRIMARY KEY (id)
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
-- risky
|
|
112
|
+
CREATE INDEX ix_hero_name ON hero (name);
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Push it (the destructive gate is on by default):
|
|
116
|
+
|
|
117
|
+
```console
|
|
118
|
+
$ sqlpush push "myapp.models:metadata"
|
|
119
|
+
1 destructive operation(s) blocked; re-run with --allow-destructive
|
|
120
|
+
$ echo $?
|
|
121
|
+
1
|
|
122
|
+
|
|
123
|
+
$ sqlpush push "myapp.models:metadata" --allow-destructive
|
|
124
|
+
$ echo $?
|
|
125
|
+
0
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
In CI, check drift and fail loudly (see exit codes below). Limit scope with
|
|
129
|
+
repeated `--schema` / `--exclude` options.
|
|
130
|
+
|
|
131
|
+
## FastAPI: retire `create_all()`
|
|
132
|
+
|
|
133
|
+
Most FastAPI + SQLModel apps ship the lifespan the tutorials teach:
|
|
134
|
+
|
|
135
|
+
```python
|
|
136
|
+
@asynccontextmanager
|
|
137
|
+
async def lifespan(app):
|
|
138
|
+
async with engine.begin() as conn:
|
|
139
|
+
await conn.run_sync(SQLModel.metadata.create_all)
|
|
140
|
+
yield
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
`create_all` creates tables that are missing. That is all it ever
|
|
144
|
+
does. Add a column to a model and the database never hears about it;
|
|
145
|
+
an index on an existing table, a type change, a drop: nothing.
|
|
146
|
+
Production drifts from the models in silence, so every real change
|
|
147
|
+
still rides the alembic treadmill: autogenerate, review, upgrade, and
|
|
148
|
+
two histories to keep in agreement forever.
|
|
149
|
+
|
|
150
|
+
The sqlpush lifespan is one line:
|
|
151
|
+
|
|
152
|
+
```python
|
|
153
|
+
from contextlib import asynccontextmanager
|
|
154
|
+
from sqlpush import aensure_schema
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@asynccontextmanager
|
|
158
|
+
async def lifespan(app):
|
|
159
|
+
await aensure_schema(SQLModel.metadata, engine, mode="check")
|
|
160
|
+
yield
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
`mode="check"` verifies the models against the database at startup and
|
|
164
|
+
raises when they disagree: the app refuses to boot against a schema it
|
|
165
|
+
does not match, which beats failing on the first query at 3am. The
|
|
166
|
+
schema change itself comes from wherever you put it: `sqlpush push` in
|
|
167
|
+
the deploy pipeline (destructive ops gated), or
|
|
168
|
+
`aensure_schema(..., mode="push")` when you want the API to apply it.
|
|
169
|
+
|
|
170
|
+
asyncpg URLs work too: a DSN or `AsyncEngine` spelling
|
|
171
|
+
`postgresql+asyncpg` is translated to the psycopg driver automatically,
|
|
172
|
+
and asyncpg is never required in the sqlpush process.
|
|
173
|
+
|
|
174
|
+
Push in the deploy pipeline, check at boot.
|
|
175
|
+
|
|
176
|
+
## An inherited database
|
|
177
|
+
|
|
178
|
+
The first `check` against a database with history often reports drift:
|
|
179
|
+
hand-built indexes, audit tables, a column someone added by hand. If
|
|
180
|
+
any of the drift looks destructive, `check` exits `3` and `push`
|
|
181
|
+
blocks. That is the tool refusing to silently drop your legacy
|
|
182
|
+
objects. Two escape hatches: `--exclude` accepts objects you choose to
|
|
183
|
+
keep (fnmatch patterns, repeatable), and `--allow-destructive` accepts
|
|
184
|
+
the drops when you really do want them.
|
|
185
|
+
|
|
186
|
+
## When you want files: the chain
|
|
187
|
+
|
|
188
|
+
Most changes never need a file. When one does, sqlpush has a second
|
|
189
|
+
workflow built on the same diff engine: the chain. `revision` writes
|
|
190
|
+
the next numbered SQL file from your models against a reference DB,
|
|
191
|
+
`migrate` replays pending files with gates and checksums, and `stamp`
|
|
192
|
+
adopts an existing database without executing anything.
|
|
193
|
+
|
|
194
|
+
The files are plain SQL you can review, edit before first apply, and
|
|
195
|
+
run under `psql`. Schema change and data backfill ship as one file.
|
|
196
|
+
The [chain guide](docs/the-chain.md) covers the format, the gates and
|
|
197
|
+
the workflows.
|
|
198
|
+
|
|
199
|
+
## Exit codes
|
|
200
|
+
|
|
201
|
+
| verb | 0 | 1 | 2 | 3 |
|
|
202
|
+
| --- | --- | --- | --- | --- |
|
|
203
|
+
| `diff` | always | | | |
|
|
204
|
+
| `check` | clean | | drift | destructive drift |
|
|
205
|
+
| `push` | applied | destructive blocked | error (incl. partial failure) | |
|
|
206
|
+
| `revision` | file written | error (empty drift refuses) | | |
|
|
207
|
+
| `migrate` | clean | blocked or partial failure | | |
|
|
208
|
+
| `stamp` | registered | blocked or refused | | |
|
|
209
|
+
|
|
210
|
+
Failures print a typed error on stderr, never a traceback.
|
|
211
|
+
|
|
212
|
+
`push --safe-only` runs only safe operations and skips the rest
|
|
213
|
+
informationally (exit `0`). Indexes on existing tables build
|
|
214
|
+
`CONCURRENTLY` by default (opt out with `--no-concurrently`); a failed
|
|
215
|
+
`CREATE INDEX CONCURRENTLY` marks the run as partial failure (exit `2`)
|
|
216
|
+
instead of silently half-applying, and leaves an INVALID index: drop it
|
|
217
|
+
(`DROP INDEX CONCURRENTLY`) and re-push. `stamp` refuses a file whose
|
|
218
|
+
checksum no longer matches the registry; `--force` accepts the new
|
|
219
|
+
content.
|
|
220
|
+
|
|
221
|
+
The knobs, per verb:
|
|
222
|
+
|
|
223
|
+
| verb | flags |
|
|
224
|
+
| --- | --- |
|
|
225
|
+
| `push` | `--allow-destructive` `--safe-only` `--no-lock` `--lock-timeout` `--advisory-wait` `--no-concurrently` `--statement-timeout` |
|
|
226
|
+
| `revision` | `--ref-dsn` (required) `-m/--message` `--no-concurrently` `--dir` |
|
|
227
|
+
| `migrate` | `--allow-destructive` `--advisory-wait` `--lock-timeout` `--statement-timeout` `--dir` |
|
|
228
|
+
| `stamp` | `--force` `--dir` |
|
|
229
|
+
|
|
230
|
+
Every verb except `revision` takes `--dsn` (or `$DATABASE_URL`).
|
|
231
|
+
`revision` requires `--ref-dsn`, with no env fallback: the reference
|
|
232
|
+
DB is a different database from the push target. `diff`, `check`,
|
|
233
|
+
`push` and `revision` also take repeatable `--schema` / `--exclude`.
|
|
234
|
+
Timeouts are seconds; a `lock_timeout` bounds how long a statement
|
|
235
|
+
waits on a lock before failing, `statement_timeout` bounds each
|
|
236
|
+
statement's runtime, and an exhausted `advisory-wait` raises instead
|
|
237
|
+
of hanging on a stuck lock holder.
|
|
238
|
+
|
|
239
|
+
## How it works
|
|
240
|
+
|
|
241
|
+
```mermaid
|
|
242
|
+
flowchart LR
|
|
243
|
+
models["SQLAlchemy MetaData"] --> diff["diff<br>alembic autogenerate, scoped"]
|
|
244
|
+
db[("live PostgreSQL")] --> diff
|
|
245
|
+
diff --> risk["risk classification<br>safe / risky / destructive"]
|
|
246
|
+
risk --> plan["plan"]
|
|
247
|
+
plan --> render["render"]
|
|
248
|
+
render --> apply["apply<br>atomic txn · CONCURRENTLY split · advisory lock"]
|
|
249
|
+
apply --> report["report"]
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
- **Diff engine** scopes reflection to your target schemas and prunes
|
|
253
|
+
system catalogs (TimescaleDB internals included) before reflection
|
|
254
|
+
even starts.
|
|
255
|
+
- **Classifier** maps each operation to a risk class; unknown operations
|
|
256
|
+
are `risky`, never silently safe.
|
|
257
|
+
- **Executor** splits the plan: concurrent index builds run one per
|
|
258
|
+
transaction on autocommit, everything else applies in a single atomic
|
|
259
|
+
transaction with a bounded `lock_timeout`.
|
|
260
|
+
- **Typed errors**: only `SqlpushError` / `ConnectFailed` /
|
|
261
|
+
`MetadataImportError` escape the API, never raw driver exceptions.
|
|
262
|
+
|
|
263
|
+
Scoping: `--schema` restricts the diff to named schemas (default: the
|
|
264
|
+
session's real `search_path`). Extension-owned schemas never enter
|
|
265
|
+
scope automatically, and schemas you pass explicitly are never
|
|
266
|
+
filtered. The chain's registry table (`public.sqlpush_versions`)
|
|
267
|
+
always lives in `public` and is pruned from every diff, so `check`
|
|
268
|
+
after `migrate` is clean. `alembic_version` gets the same treatment.
|
|
269
|
+
|
|
270
|
+
## Comparison
|
|
271
|
+
|
|
272
|
+
An honest view of the neighborhood (stars as of 2026-08):
|
|
273
|
+
|
|
274
|
+
| | migration files | source of truth | risk gate | CI drift exit codes | TimescaleDB |
|
|
275
|
+
| --- | --- | --- | --- | --- | --- |
|
|
276
|
+
| **sqlpush** | optional: push needs none; the chain has reviewable, checksummed files | SQLAlchemy `MetaData` | classified safe/risky/destructive, destructive blocked by default | `check` 0/2/3 | `@hypertable` directives |
|
|
277
|
+
| [alembic](https://github.com/sqlalchemy/alembic) (4.4k★) | yes | migration scripts (autogenerate assists) | no | no | no |
|
|
278
|
+
| [atlas](https://github.com/ariga/atlas) (8.7k★) | optional (HCL) | HCL / SQL (ORMs via providers) | lint policies | yes | no |
|
|
279
|
+
| [prisma `db push`](https://www.prisma.io/docs/orm/reference/prisma-cli-reference) (47k★) | none | Prisma schema (Node/TS) | no | no | no |
|
|
280
|
+
| [migra](https://github.com/djrobstep/migra) (3.1k★) | diff only | SQL | n/a | partial | no (*deprecated*) |
|
|
281
|
+
|
|
282
|
+
sqlpush is narrower than atlas and younger than alembic, deliberately.
|
|
283
|
+
It is one tool for one job: keep a PostgreSQL schema in lockstep with
|
|
284
|
+
SQLAlchemy models, safely enough to run from CI.
|
|
285
|
+
|
|
286
|
+
Guides: [the chain](docs/the-chain.md) (file format, gates, backfills),
|
|
287
|
+
[migrating from alembic](docs/migrating-from-alembic.md), and
|
|
288
|
+
[migrating from migra](docs/migrating-from-migra.md) (deprecated).
|
|
289
|
+
Changes land in the [CHANGELOG](CHANGELOG.md).
|
|
290
|
+
|
|
291
|
+
## Design notes
|
|
292
|
+
|
|
293
|
+
- `import sqlpush` stays light: the public API loads lazily, so the
|
|
294
|
+
annotations module carries none of alembic/typer/psycopg.
|
|
295
|
+
- The advisory-lock key derives from the database OID: two DSN spellings
|
|
296
|
+
of the same database contend for the same lock.
|
|
297
|
+
- `--json` output is a versioned contract (`"version": 1`) meant for
|
|
298
|
+
tooling; additive changes only within a version (operations now carry
|
|
299
|
+
a `concurrent` boolean).
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
sqlpush/__init__.py,sha256=Px0jJFHbwwM1-UHtDwD3xQlRifEMW4StSXQs5DD9z6w,1697
|
|
2
|
+
sqlpush/annotations.py,sha256=xe5EoYPvraLJy-WvMkFK_zOIKquB3-VsrAzy6HE_hiA,4652
|
|
3
|
+
sqlpush/api.py,sha256=JTIDn-O50j9ytVTMRXJVu6eZcGbCX_v-03zqJ23laig,11235
|
|
4
|
+
sqlpush/apply/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
sqlpush/apply/executor.py,sha256=qHvfxB1sWQAQ0oMPDGuEr-HRaJwOYoVSB_s-7x-CZTc,10087
|
|
6
|
+
sqlpush/chain/__init__.py,sha256=AJ96P3I63mX6-uMuK0paCpKtaRexufnCqjUuElbyAsM,293
|
|
7
|
+
sqlpush/chain/format.py,sha256=uj0iMQfFWa9HpKhrokfJu_5B_FesiD4qCZxAGDJ3bfQ,4938
|
|
8
|
+
sqlpush/chain/migrate.py,sha256=P2grgrg8dkLJCBhL6CzNwa4xG8-VuFLqgN7XuvjFhwQ,18575
|
|
9
|
+
sqlpush/cli.py,sha256=7bjfh_YcFs4yIJtTS44YWr2YWQwGNuccHirkdorJXm4,11099
|
|
10
|
+
sqlpush/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
11
|
+
sqlpush/core/classify.py,sha256=xjBFIPAV8lxMsZZRiOQ1Yxlpgz4r-ZpCtnNXA4KAw0o,1290
|
|
12
|
+
sqlpush/core/diff.py,sha256=OkH0fLl9NLOyWFnefknvBK8SWeN6bnJz4kQJfK3-wFA,26552
|
|
13
|
+
sqlpush/core/render.py,sha256=9SNEyHOGmMxKUfIjvo3x0MNVQ6N2o2fl3noWT1EWO3M,609
|
|
14
|
+
sqlpush/directives/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
15
|
+
sqlpush/directives/timescale.py,sha256=W_Cd_stdhKZlzlMum7Bdzq99VfXOulYxq0B-sukz-xE,2645
|
|
16
|
+
sqlpush/types.py,sha256=hXetdVqMzYcBQi0M_x3qmCIOa3QzhuwxYNJkZRWz4qM,3280
|
|
17
|
+
sqlpush-0.5.1.dist-info/WHEEL,sha256=y6e-a5KI2W-qDAJKfh9Xr81bin8NgUdfWgz3VSXXpe4,80
|
|
18
|
+
sqlpush-0.5.1.dist-info/entry_points.txt,sha256=VYha3PJSvklRgpcDAZYJdE66EXM79MSKQf0z8igyAOI,46
|
|
19
|
+
sqlpush-0.5.1.dist-info/METADATA,sha256=ZxtbwPr8QWKh40NMrRpn87B_uGoMUleqYHYC-UzIZUM,12518
|
|
20
|
+
sqlpush-0.5.1.dist-info/RECORD,,
|
sqlpush-0.4.2.dist-info/METADATA
DELETED
|
@@ -1,214 +0,0 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: sqlpush
|
|
3
|
-
Version: 0.4.2
|
|
4
|
-
Summary: Schema lifecycle tool for production PostgreSQL/TimescaleDB on SQLAlchemy 2.0 (SQLModel included): diff, push and check schema drift straight from your models
|
|
5
|
-
Keywords: sqlalchemy,alembic,postgresql,timescaledb,prisma,schema,migrations,database,drift,cli
|
|
6
|
-
Author: Juan Miguel Contreras
|
|
7
|
-
Author-email: Juan Miguel Contreras <19253629+juanmicl@users.noreply.github.com>
|
|
8
|
-
License-Expression: MIT
|
|
9
|
-
Classifier: Development Status :: 4 - Beta
|
|
10
|
-
Classifier: Environment :: Console
|
|
11
|
-
Classifier: Intended Audience :: Developers
|
|
12
|
-
Classifier: Programming Language :: Python :: 3
|
|
13
|
-
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
-
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
-
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
-
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
-
Classifier: Topic :: Database
|
|
18
|
-
Classifier: Topic :: Software Development :: Quality Assurance
|
|
19
|
-
Requires-Dist: alembic>=1.18,<2
|
|
20
|
-
Requires-Dist: psycopg[binary]>=3.2
|
|
21
|
-
Requires-Dist: sqlalchemy>=2.0
|
|
22
|
-
Requires-Dist: typer>=0.12
|
|
23
|
-
Requires-Python: >=3.10
|
|
24
|
-
Project-URL: Homepage, https://github.com/juanmicl/sqlpush
|
|
25
|
-
Project-URL: Repository, https://github.com/juanmicl/sqlpush
|
|
26
|
-
Project-URL: Issues, https://github.com/juanmicl/sqlpush/issues
|
|
27
|
-
Project-URL: Changelog, https://github.com/juanmicl/sqlpush/blob/main/CHANGELOG.md
|
|
28
|
-
Description-Content-Type: text/markdown
|
|
29
|
-
|
|
30
|
-
# sqlpush
|
|
31
|
-
|
|
32
|
-
[](https://pypi.org/project/sqlpush/)
|
|
33
|
-
[](https://pypi.org/project/sqlpush/)
|
|
34
|
-
[](LICENSE)
|
|
35
|
-
|
|
36
|
-
**Prisma `db push` for SQLAlchemy.** Apply your models (SQLAlchemy,
|
|
37
|
-
SQLModel, anything built on `MetaData`) to a live PostgreSQL / TimescaleDB
|
|
38
|
-
database directly, no migration files. sqlpush
|
|
39
|
-
diffs your models against the real schema, classifies every operation by
|
|
40
|
-
risk (safe / risky / destructive), and applies the plan atomically. Drift
|
|
41
|
-
checks exit with codes your CI can gate on.
|
|
42
|
-
|
|
43
|
-
```console
|
|
44
|
-
sqlpush diff "myapp.models:metadata" # see the SQL, ordered by risk
|
|
45
|
-
sqlpush check "myapp.models:metadata" # CI gate: exit 0/2/3
|
|
46
|
-
sqlpush push "myapp.models:metadata" # apply (destructive gated)
|
|
47
|
-
```
|
|
48
|
-
|
|
49
|
-
If you've ever run `Base.metadata.create_all()` in production and known it
|
|
50
|
-
was wrong, then sighed at the migration-script treadmill when you reached
|
|
51
|
-
for alembic: sqlpush is for you.
|
|
52
|
-
|
|
53
|
-
## Why
|
|
54
|
-
|
|
55
|
-
Declarative models are already the source of truth. Migration files
|
|
56
|
-
re-encode what the models say, drift from them, and pile up forever.
|
|
57
|
-
sqlpush closes the loop the way Prisma's `db push` does for its schema
|
|
58
|
-
language, but for the SQLAlchemy ecosystem (SQLModel included):
|
|
59
|
-
|
|
60
|
-
- **No migration files, ever.** The diff *is* the migration: computed fresh
|
|
61
|
-
from models vs. live database on every run, via alembic's autogenerate
|
|
62
|
-
engine used as a library.
|
|
63
|
-
- **Risk-aware by default.** Every operation is classified `safe` /
|
|
64
|
-
`risky` / `destructive`. Destructive ops (drops) are **blocked until
|
|
65
|
-
`--allow-destructive`**: nothing executes at all while any is present.
|
|
66
|
-
- **Drift detection built for CI.** `check` plans once and exits `0` clean /
|
|
67
|
-
`2` drift / `3` destructive drift, scriptable without parsing output.
|
|
68
|
-
`--json` emits a stable versioned contract.
|
|
69
|
-
- **Safe under concurrency.** An advisory lock (keyed to the database, not
|
|
70
|
-
the DSN) coordinates workers: one pusher at a time, losers wait bounded
|
|
71
|
-
and re-verify, so deploy pipelines can race without corrupting anything.
|
|
72
|
-
- **Hypertables without hand-written SQL.** Decorate a model with
|
|
73
|
-
`@hypertable` and the `create_hypertable` directive is planned
|
|
74
|
-
state-aware: idempotent pushes, clean checks, no false drift.
|
|
75
|
-
|
|
76
|
-
PostgreSQL only, by design.
|
|
77
|
-
|
|
78
|
-
## Install
|
|
79
|
-
|
|
80
|
-
```console
|
|
81
|
-
pip install sqlpush
|
|
82
|
-
```
|
|
83
|
-
|
|
84
|
-
Or from source:
|
|
85
|
-
|
|
86
|
-
```console
|
|
87
|
-
git clone https://github.com/juanmicl/sqlpush && cd sqlpush && uv sync
|
|
88
|
-
```
|
|
89
|
-
|
|
90
|
-
## The 30-second tour
|
|
91
|
-
|
|
92
|
-
Point sqlpush at your metadata (`module:attribute`) and a database
|
|
93
|
-
(`--dsn` or `$DATABASE_URL`):
|
|
94
|
-
|
|
95
|
-
```console
|
|
96
|
-
$ export DATABASE_URL="postgresql+psycopg://user:pass@host:5432/db"
|
|
97
|
-
|
|
98
|
-
$ sqlpush diff "myapp.models:metadata"
|
|
99
|
-
-- safe
|
|
100
|
-
|
|
101
|
-
CREATE TABLE hero (
|
|
102
|
-
id SERIAL NOT NULL PRIMARY KEY,
|
|
103
|
-
name VARCHAR(50) NOT NULL
|
|
104
|
-
);
|
|
105
|
-
|
|
106
|
-
-- risky
|
|
107
|
-
|
|
108
|
-
CREATE INDEX ix_hero_name ON hero (name);
|
|
109
|
-
```
|
|
110
|
-
|
|
111
|
-
Push it (the destructive gate is on by default):
|
|
112
|
-
|
|
113
|
-
```console
|
|
114
|
-
$ sqlpush push "myapp.models:metadata"
|
|
115
|
-
1 destructive operation(s) blocked; re-run with --allow-destructive
|
|
116
|
-
$ echo $?
|
|
117
|
-
1
|
|
118
|
-
|
|
119
|
-
$ sqlpush push "myapp.models:metadata" --allow-destructive
|
|
120
|
-
$ echo $?
|
|
121
|
-
0
|
|
122
|
-
```
|
|
123
|
-
|
|
124
|
-
In CI, check drift and fail loudly (see exit codes below). Limit scope with
|
|
125
|
-
repeated `--schema` / `--exclude` options.
|
|
126
|
-
|
|
127
|
-
## Exit codes
|
|
128
|
-
|
|
129
|
-
| verb | 0 | 1 | 2 | 3 |
|
|
130
|
-
| --- | --- | --- | --- | --- |
|
|
131
|
-
| `diff` | always | | | |
|
|
132
|
-
| `check` | clean | | drift | destructive drift |
|
|
133
|
-
| `push` | applied | destructive blocked | error (incl. partial failure) | |
|
|
134
|
-
|
|
135
|
-
`push --safe-only` runs only safe operations and skips the rest
|
|
136
|
-
informationally (exit `0`). A failed `CREATE INDEX CONCURRENTLY` marks the
|
|
137
|
-
run as partial failure (exit `2`) instead of silently half-applying.
|
|
138
|
-
|
|
139
|
-
## FastAPI / SQLModel: replace `create_all`
|
|
140
|
-
|
|
141
|
-
```python
|
|
142
|
-
from contextlib import asynccontextmanager
|
|
143
|
-
from sqlpush import aensure_schema
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
@asynccontextmanager
|
|
147
|
-
async def lifespan(app):
|
|
148
|
-
await aensure_schema(SQLModel.metadata, engine, mode="check")
|
|
149
|
-
yield
|
|
150
|
-
```
|
|
151
|
-
|
|
152
|
-
Push in the deploy pipeline, check at startup.
|
|
153
|
-
|
|
154
|
-
## How it works
|
|
155
|
-
|
|
156
|
-
```mermaid
|
|
157
|
-
flowchart LR
|
|
158
|
-
models["SQLAlchemy MetaData"] --> diff["diff<br>alembic autogenerate, scoped"]
|
|
159
|
-
db[("live PostgreSQL")] --> diff
|
|
160
|
-
diff --> risk["risk classification<br>safe / risky / destructive"]
|
|
161
|
-
risk --> plan["plan"]
|
|
162
|
-
plan --> render["render"]
|
|
163
|
-
render --> apply["apply<br>atomic txn · CONCURRENTLY split · advisory lock"]
|
|
164
|
-
apply --> report["report"]
|
|
165
|
-
```
|
|
166
|
-
|
|
167
|
-
- **Diff engine** scopes reflection to your target schemas (default: the
|
|
168
|
-
session's real `search_path`) and prunes system catalogs (TimescaleDB
|
|
169
|
-
internals included) before reflection even starts.
|
|
170
|
-
- **Classifier** maps each operation to a risk class; unknown operations
|
|
171
|
-
are `risky`, never silently safe.
|
|
172
|
-
- **Executor** splits the plan: `CONCURRENTLY` statements run one-per-
|
|
173
|
-
transaction on autocommit, everything else applies in a single atomic
|
|
174
|
-
transaction with a bounded `lock_timeout`.
|
|
175
|
-
- **Typed errors**: only `SqlpushError` / `ConnectFailed` /
|
|
176
|
-
`MetadataImportError` escape the API, never raw driver exceptions.
|
|
177
|
-
|
|
178
|
-
## Comparison
|
|
179
|
-
|
|
180
|
-
An honest view of the neighborhood (stars as of 2026-08):
|
|
181
|
-
|
|
182
|
-
| | migration files | source of truth | risk gate | CI drift exit codes | TimescaleDB |
|
|
183
|
-
| --- | --- | --- | --- | --- | --- |
|
|
184
|
-
| **sqlpush** | none (the diff is the migration) | SQLAlchemy `MetaData` | classified safe/risky/destructive, destructive blocked by default | `check` 0/2/3 | `@hypertable` directives |
|
|
185
|
-
| [alembic](https://github.com/sqlalchemy/alembic) (4.4k★) | yes | migration scripts (autogenerate assists) | no | no | no |
|
|
186
|
-
| [atlas](https://github.com/ariga/atlas) (8.7k★) | optional (HCL) | HCL / SQL (ORMs via providers) | lint policies | yes | no |
|
|
187
|
-
| [prisma `db push`](https://www.prisma.io/docs/orm/reference/prisma-cli-reference) (47k★) | none | Prisma schema (Node/TS) | no | no | no |
|
|
188
|
-
| [migra](https://github.com/djrobstep/migra) (3.1k★) | diff only | SQL | n/a | partial | no (*deprecated*) |
|
|
189
|
-
|
|
190
|
-
sqlpush is narrower than atlas and younger than alembic, deliberately.
|
|
191
|
-
It is one tool for one job: keep a PostgreSQL schema in lockstep with
|
|
192
|
-
SQLAlchemy models, safely enough to run from CI.
|
|
193
|
-
|
|
194
|
-
Coming from [migra](https://github.com/djrobstep/migra) (now
|
|
195
|
-
deprecated)? There is a [migration guide](docs/migrating-from-migra.md).
|
|
196
|
-
|
|
197
|
-
## Design notes
|
|
198
|
-
|
|
199
|
-
- `import sqlpush` stays light: the public API loads lazily, so the
|
|
200
|
-
annotations module carries none of alembic/typer/psycopg.
|
|
201
|
-
- The advisory-lock key derives from the database OID: two DSN spellings
|
|
202
|
-
of the same database contend for the same lock.
|
|
203
|
-
- `--json` output is a versioned contract (`"version": 1`) meant for
|
|
204
|
-
tooling; additive changes only within a version.
|
|
205
|
-
|
|
206
|
-
## Roadmap (0.1.x)
|
|
207
|
-
|
|
208
|
-
- `CREATE INDEX CONCURRENTLY` by default for indexes on existing tables
|
|
209
|
-
- asyncpg DSN translation in `ensure_schema(AsyncEngine)`
|
|
210
|
-
- jsonschema-validated `--json` output
|
|
211
|
-
|
|
212
|
-
## License
|
|
213
|
-
|
|
214
|
-
[MIT](LICENSE) · © 2026 Juan Miguel Contreras
|
sqlpush-0.4.2.dist-info/RECORD
DELETED
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
sqlpush/__init__.py,sha256=Px0jJFHbwwM1-UHtDwD3xQlRifEMW4StSXQs5DD9z6w,1697
|
|
2
|
-
sqlpush/annotations.py,sha256=xe5EoYPvraLJy-WvMkFK_zOIKquB3-VsrAzy6HE_hiA,4652
|
|
3
|
-
sqlpush/api.py,sha256=7fHsy1AHd2ffca-wtQDowfpt70P7c6L0_s720k8gRcU,10166
|
|
4
|
-
sqlpush/apply/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
-
sqlpush/apply/executor.py,sha256=-HgmscUO6M_xQ5HPBQhJkUo6XwEPPqBig7_G9aWQJAU,7242
|
|
6
|
-
sqlpush/chain/__init__.py,sha256=AJ96P3I63mX6-uMuK0paCpKtaRexufnCqjUuElbyAsM,293
|
|
7
|
-
sqlpush/chain/format.py,sha256=uj0iMQfFWa9HpKhrokfJu_5B_FesiD4qCZxAGDJ3bfQ,4938
|
|
8
|
-
sqlpush/chain/migrate.py,sha256=P7I07Zv3xRmRYgFTTLAqkrUdbjsGOGNQZG2j-eP5Hgk,10055
|
|
9
|
-
sqlpush/cli.py,sha256=7TBKMxmvkpwSH6gyqs2fDQ5Tyec1yMOLGJCUJb-2Af0,10599
|
|
10
|
-
sqlpush/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
11
|
-
sqlpush/core/classify.py,sha256=-HNPQ1Kfnr0MMYLoP26_VC14JjcQHravrbT7lPGqlIU,927
|
|
12
|
-
sqlpush/core/diff.py,sha256=q0f7NtiKQ8N33UbI40ttxgiNbsAsBpqa0XJ15oTLSBY,24147
|
|
13
|
-
sqlpush/core/render.py,sha256=9SNEyHOGmMxKUfIjvo3x0MNVQ6N2o2fl3noWT1EWO3M,609
|
|
14
|
-
sqlpush/directives/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
15
|
-
sqlpush/directives/timescale.py,sha256=W_Cd_stdhKZlzlMum7Bdzq99VfXOulYxq0B-sukz-xE,2645
|
|
16
|
-
sqlpush/types.py,sha256=IMA1HRLIumat7tpt8Yscv-9g2U5U_--xxnfcf_pfkyo,2938
|
|
17
|
-
sqlpush-0.4.2.dist-info/WHEEL,sha256=y6e-a5KI2W-qDAJKfh9Xr81bin8NgUdfWgz3VSXXpe4,80
|
|
18
|
-
sqlpush-0.4.2.dist-info/entry_points.txt,sha256=VYha3PJSvklRgpcDAZYJdE66EXM79MSKQf0z8igyAOI,46
|
|
19
|
-
sqlpush-0.4.2.dist-info/METADATA,sha256=KwaHMTxd1Upr_Mf2GbulnbnY3MY58NfBLNBacSNQjWE,8373
|
|
20
|
-
sqlpush-0.4.2.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|