sqlpush 0.4.2__py3-none-any.whl → 0.5.0__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.4.2.dist-info → sqlpush-0.5.0.dist-info}/METADATA +13 -9
- sqlpush-0.5.0.dist-info/RECORD +20 -0
- sqlpush-0.4.2.dist-info/RECORD +0 -20
- {sqlpush-0.4.2.dist-info → sqlpush-0.5.0.dist-info}/WHEEL +0 -0
- {sqlpush-0.4.2.dist-info → sqlpush-0.5.0.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
|
],
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sqlpush
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
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
5
|
Keywords: sqlalchemy,alembic,postgresql,timescaledb,prisma,schema,migrations,database,drift,cli
|
|
6
6
|
Author: Juan Miguel Contreras
|
|
@@ -133,8 +133,11 @@ repeated `--schema` / `--exclude` options.
|
|
|
133
133
|
| `push` | applied | destructive blocked | error (incl. partial failure) | |
|
|
134
134
|
|
|
135
135
|
`push --safe-only` runs only safe operations and skips the rest
|
|
136
|
-
informationally (exit `0`).
|
|
137
|
-
|
|
136
|
+
informationally (exit `0`). Indexes on existing tables build
|
|
137
|
+
`CONCURRENTLY` by default (opt out with `--no-concurrently`); a failed
|
|
138
|
+
`CREATE INDEX CONCURRENTLY` marks the run as partial failure (exit `2`)
|
|
139
|
+
instead of silently half-applying, and leaves an INVALID index — drop it
|
|
140
|
+
(`DROP INDEX CONCURRENTLY`) and re-push.
|
|
138
141
|
|
|
139
142
|
## FastAPI / SQLModel: replace `create_all`
|
|
140
143
|
|
|
@@ -169,9 +172,11 @@ flowchart LR
|
|
|
169
172
|
internals included) before reflection even starts.
|
|
170
173
|
- **Classifier** maps each operation to a risk class; unknown operations
|
|
171
174
|
are `risky`, never silently safe.
|
|
172
|
-
- **Executor** splits the plan:
|
|
173
|
-
|
|
174
|
-
|
|
175
|
+
- **Executor** splits the plan: existing-table indexes render
|
|
176
|
+
`CREATE INDEX CONCURRENTLY` and run one-per-transaction on autocommit
|
|
177
|
+
(`--no-concurrently` opts out; indexes on tables the same plan creates
|
|
178
|
+
stay in the atomic transaction), everything else applies in a single
|
|
179
|
+
atomic transaction with a bounded `lock_timeout`.
|
|
175
180
|
- **Typed errors**: only `SqlpushError` / `ConnectFailed` /
|
|
176
181
|
`MetadataImportError` escape the API, never raw driver exceptions.
|
|
177
182
|
|
|
@@ -201,12 +206,11 @@ deprecated)? There is a [migration guide](docs/migrating-from-migra.md).
|
|
|
201
206
|
- The advisory-lock key derives from the database OID: two DSN spellings
|
|
202
207
|
of the same database contend for the same lock.
|
|
203
208
|
- `--json` output is a versioned contract (`"version": 1`) meant for
|
|
204
|
-
tooling; additive changes only within a version
|
|
209
|
+
tooling; additive changes only within a version (operations now carry
|
|
210
|
+
a `concurrent` boolean).
|
|
205
211
|
|
|
206
212
|
## Roadmap (0.1.x)
|
|
207
213
|
|
|
208
|
-
- `CREATE INDEX CONCURRENTLY` by default for indexes on existing tables
|
|
209
|
-
- asyncpg DSN translation in `ensure_schema(AsyncEngine)`
|
|
210
214
|
- jsonschema-validated `--json` output
|
|
211
215
|
|
|
212
216
|
## License
|
|
@@ -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.0.dist-info/WHEEL,sha256=y6e-a5KI2W-qDAJKfh9Xr81bin8NgUdfWgz3VSXXpe4,80
|
|
18
|
+
sqlpush-0.5.0.dist-info/entry_points.txt,sha256=VYha3PJSvklRgpcDAZYJdE66EXM79MSKQf0z8igyAOI,46
|
|
19
|
+
sqlpush-0.5.0.dist-info/METADATA,sha256=7pDHwPL5eTzOHoS5rpp6259RlcEYU-y0OXcIw7HtcHY,8609
|
|
20
|
+
sqlpush-0.5.0.dist-info/RECORD,,
|
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
|