sqlpush 0.3.0__tar.gz → 0.4.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {sqlpush-0.3.0 → sqlpush-0.4.1}/PKG-INFO +1 -1
- {sqlpush-0.3.0 → sqlpush-0.4.1}/pyproject.toml +1 -1
- {sqlpush-0.3.0 → sqlpush-0.4.1}/pyproject.toml.orig +1 -1
- sqlpush-0.4.1/src/sqlpush/annotations.py +103 -0
- {sqlpush-0.3.0 → sqlpush-0.4.1}/src/sqlpush/api.py +83 -0
- sqlpush-0.4.1/src/sqlpush/chain/__init__.py +6 -0
- sqlpush-0.4.1/src/sqlpush/chain/format.py +142 -0
- sqlpush-0.4.1/src/sqlpush/chain/migrate.py +175 -0
- {sqlpush-0.3.0 → sqlpush-0.4.1}/src/sqlpush/cli.py +82 -1
- {sqlpush-0.3.0 → sqlpush-0.4.1}/src/sqlpush/core/diff.py +140 -7
- {sqlpush-0.3.0 → sqlpush-0.4.1}/src/sqlpush/directives/timescale.py +5 -28
- {sqlpush-0.3.0 → sqlpush-0.4.1}/src/sqlpush/types.py +12 -0
- sqlpush-0.3.0/src/sqlpush/annotations.py +0 -37
- {sqlpush-0.3.0 → sqlpush-0.4.1}/README.md +0 -0
- {sqlpush-0.3.0 → sqlpush-0.4.1}/src/sqlpush/__init__.py +0 -0
- {sqlpush-0.3.0 → sqlpush-0.4.1}/src/sqlpush/apply/__init__.py +0 -0
- {sqlpush-0.3.0 → sqlpush-0.4.1}/src/sqlpush/apply/executor.py +0 -0
- {sqlpush-0.3.0 → sqlpush-0.4.1}/src/sqlpush/core/__init__.py +0 -0
- {sqlpush-0.3.0 → sqlpush-0.4.1}/src/sqlpush/core/classify.py +0 -0
- {sqlpush-0.3.0 → sqlpush-0.4.1}/src/sqlpush/core/render.py +0 -0
- {sqlpush-0.3.0 → sqlpush-0.4.1}/src/sqlpush/directives/__init__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sqlpush
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.1
|
|
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
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "sqlpush"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.4.1"
|
|
4
4
|
description = "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
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.10"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "sqlpush"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.4.1"
|
|
4
4
|
description = "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
|
readme = "README.md"
|
|
6
6
|
authors = [
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# src/sqlpush/annotations.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
HYPERTABLE_KEY = "sqlpush_hypertable"
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class HypertableInfo:
|
|
11
|
+
time_column: str
|
|
12
|
+
chunk_time_interval: str | None = None
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _lit(value: str) -> str:
|
|
16
|
+
# single-quoted SQL literal escaping: ' -> '' (defense in depth
|
|
17
|
+
# against quote break-out via table/column names from user metadata)
|
|
18
|
+
return value.replace("'", "''")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def create_hypertable_sql(table_name: str, schema: str | None, info: HypertableInfo) -> str:
|
|
22
|
+
"""Render the idempotent ``create_hypertable`` statement for an annotated table.
|
|
23
|
+
|
|
24
|
+
Single source of truth shared by the directive planner
|
|
25
|
+
(``directives/timescale.py`` — push/revision) and the decorator's
|
|
26
|
+
``after_create`` listener (``create_all``): both mechanisms must
|
|
27
|
+
always emit identical SQL.
|
|
28
|
+
"""
|
|
29
|
+
# Schema-qualified relation: create_hypertable resolves an
|
|
30
|
+
# unqualified name via the session search_path, so a table in a
|
|
31
|
+
# non-default schema MUST carry its schema or the op lands on
|
|
32
|
+
# public.<name> (UndefinedTable). Schema-less tables keep the
|
|
33
|
+
# bare name: they live in the default schema, which the
|
|
34
|
+
# search_path already resolves.
|
|
35
|
+
relation = table_name if schema is None else f"{schema}.{table_name}"
|
|
36
|
+
parts = [f"SELECT create_hypertable('{_lit(relation)}', '{_lit(info.time_column)}'"]
|
|
37
|
+
if info.chunk_time_interval:
|
|
38
|
+
parts.append(f", chunk_time_interval => INTERVAL '{_lit(info.chunk_time_interval)}'")
|
|
39
|
+
# if_not_exists: race insurance between state checks and apply —
|
|
40
|
+
# and what makes listener (create_all) + directive (push) coexistence
|
|
41
|
+
# duplicate-free. migrate_data: required when the directive runs on an
|
|
42
|
+
# existing populated table; a no-op on the empty table the listener
|
|
43
|
+
# just created. create_default_indexes=false: timescale's implicit
|
|
44
|
+
# time-column index is invisible to metadata, so the default would
|
|
45
|
+
# drift forever (destructive drop_index) on a fully synced schema;
|
|
46
|
+
# users declare wanted indexes in the Table instead (metadata is the
|
|
47
|
+
# source of truth).
|
|
48
|
+
parts.append(", migrate_data => true, if_not_exists => true, create_default_indexes => false)")
|
|
49
|
+
return "".join(parts)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _listen_after_create(table, info: HypertableInfo) -> None:
|
|
53
|
+
# Dual-mechanism (0.4.1): besides the info entry (planned from
|
|
54
|
+
# metadata on push/revision paths), register an after_create
|
|
55
|
+
# listener so create_all paths register the hypertable too —
|
|
56
|
+
# the same idempotent SQL the directive plans, executed via
|
|
57
|
+
# text() exactly like the executor runs planned ops. With a
|
|
58
|
+
# declared index on the time column, create_hypertable sees the
|
|
59
|
+
# column indexed and create_default_indexes => false adds nothing
|
|
60
|
+
# (see tests/test_annotations_createall.py).
|
|
61
|
+
# Imports stay inside the function: this module must remain free of
|
|
62
|
+
# heavy imports at module level (pinned by test_annotations.py).
|
|
63
|
+
from sqlalchemy import event, text
|
|
64
|
+
|
|
65
|
+
sql = create_hypertable_sql(table.name, table.schema, info)
|
|
66
|
+
|
|
67
|
+
def _after_create(target, connection, **kw):
|
|
68
|
+
connection.execute(text(sql))
|
|
69
|
+
|
|
70
|
+
event.listen(table, "after_create", _after_create)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def hypertable(*, time_column: str, chunk_time_interval: str | None = None):
|
|
74
|
+
"""Record hypertable intent on the model's Table (dual-mechanism).
|
|
75
|
+
|
|
76
|
+
Works with SQLModel, Flask-SQLAlchemy and plain declarative, anything
|
|
77
|
+
whose class already carries a built ``__table__`` when decorated.
|
|
78
|
+
|
|
79
|
+
Mechanism 1 (metadata): the annotation is recorded on ``Table.info``
|
|
80
|
+
and planned as a ``create_hypertable`` op by push/revision.
|
|
81
|
+
|
|
82
|
+
Mechanism 2 (create_all): an ``after_create`` listener runs the same
|
|
83
|
+
idempotent ``create_hypertable``, so ``MetaData.create_all`` paths
|
|
84
|
+
register the hypertable too; coexistence with a later push is
|
|
85
|
+
duplicate-free (``if_not_exists``) and ``check()`` stays clean.
|
|
86
|
+
|
|
87
|
+
The generated ``create_hypertable`` runs with
|
|
88
|
+
``create_default_indexes => false``: timescale's implicit time-column
|
|
89
|
+
index is invisible to metadata and would drift forever. Declare it
|
|
90
|
+
yourself (``Index(..., "<time_column>")`` on the model) if you rely
|
|
91
|
+
on it for time-range scans.
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
def decorator(cls):
|
|
95
|
+
table = getattr(cls, "__table__", None)
|
|
96
|
+
if table is None:
|
|
97
|
+
raise TypeError(f"{cls.__name__} has no __table__; decorate a mapped class")
|
|
98
|
+
info = HypertableInfo(time_column=time_column, chunk_time_interval=chunk_time_interval)
|
|
99
|
+
table.info[HYPERTABLE_KEY] = info
|
|
100
|
+
_listen_after_create(table, info)
|
|
101
|
+
return cls
|
|
102
|
+
|
|
103
|
+
return decorator
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
4
|
import asyncio
|
|
5
|
+
import re
|
|
6
|
+
from pathlib import Path
|
|
5
7
|
from typing import NoReturn
|
|
6
8
|
|
|
7
9
|
from sqlalchemy import MetaData
|
|
@@ -14,11 +16,18 @@ from sqlalchemy.exc import (
|
|
|
14
16
|
from sqlalchemy.ext.asyncio import AsyncEngine
|
|
15
17
|
|
|
16
18
|
from sqlpush.apply.executor import apply_plan, with_advisory_lock
|
|
19
|
+
from sqlpush.chain.format import (
|
|
20
|
+
RISK_RANK,
|
|
21
|
+
next_revision_id,
|
|
22
|
+
render_migration_file,
|
|
23
|
+
)
|
|
24
|
+
from sqlpush.chain.migrate import run_migrate, run_stamp
|
|
17
25
|
from sqlpush.core.diff import DiffEngine
|
|
18
26
|
from sqlpush.directives.timescale import hypertable_operations
|
|
19
27
|
from sqlpush.types import (
|
|
20
28
|
CheckResult,
|
|
21
29
|
ConnectFailed,
|
|
30
|
+
MigrateReport,
|
|
22
31
|
Plan,
|
|
23
32
|
Report,
|
|
24
33
|
SqlpushError,
|
|
@@ -116,6 +125,80 @@ def check(metadata, engine, *, schemas=None, exclude=()) -> CheckResult:
|
|
|
116
125
|
)
|
|
117
126
|
|
|
118
127
|
|
|
128
|
+
def revision(
|
|
129
|
+
metadata, ref_engine, *, out_dir="migrations/versions", message=None, schemas=None, exclude=()
|
|
130
|
+
) -> Path:
|
|
131
|
+
"""Generate the next annotated-SQL migration file from models-vs-ref drift.
|
|
132
|
+
|
|
133
|
+
The reference DB must sit at the chain head (caller-provided — sqlpush
|
|
134
|
+
stays docker-free). Empty drift refuses loudly: no empty files.
|
|
135
|
+
"""
|
|
136
|
+
p = plan(metadata, ref_engine, schemas=schemas, exclude=exclude)
|
|
137
|
+
if not p.operations:
|
|
138
|
+
raise SqlpushError("no drift between models and reference DB — nothing to revise")
|
|
139
|
+
risk = max((op.risk for op in p.operations), key=lambda r: RISK_RANK[r])
|
|
140
|
+
ops = [(f"[{op.risk.name}] {op.type} {op.table or '?'}", op.sql) for op in p.operations]
|
|
141
|
+
out = Path(out_dir)
|
|
142
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
143
|
+
rev_id = next_revision_id(out)
|
|
144
|
+
slug = re.sub(r"[^a-z0-9_]+", "_", (message or "migration").lower())[:40]
|
|
145
|
+
path = out / f"{rev_id}_{slug}.sql"
|
|
146
|
+
if path.exists():
|
|
147
|
+
# NNNN+slug collision (e.g. same message re-run without the file being
|
|
148
|
+
# consumed) must be loud, never a silent overwrite of chain history
|
|
149
|
+
raise SqlpushError(f"refusing to overwrite existing migration file {path}")
|
|
150
|
+
prev_n = int(rev_id) - 1
|
|
151
|
+
try:
|
|
152
|
+
path.write_text(
|
|
153
|
+
render_migration_file(
|
|
154
|
+
ops=ops,
|
|
155
|
+
revision_id=rev_id,
|
|
156
|
+
risk=risk,
|
|
157
|
+
message=message,
|
|
158
|
+
parent=f"{prev_n:04d}" if prev_n >= 1 else None,
|
|
159
|
+
)
|
|
160
|
+
)
|
|
161
|
+
except OSError as exc:
|
|
162
|
+
raise SqlpushError(f"cannot write migration file {path}: {exc}") from exc
|
|
163
|
+
return path
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def migrate(target, *, chain_dir="migrations/versions", allow_destructive=False) -> MigrateReport:
|
|
167
|
+
"""Replay annotated-SQL chain files with gates + same-txn bookkeeping.
|
|
168
|
+
|
|
169
|
+
``target`` is a DSN string, sync ``Engine`` or ``AsyncEngine`` (resolved
|
|
170
|
+
via ``_sync_engine_from``; engines created here are disposed). See
|
|
171
|
+
``chain.migrate.run_migrate`` for the execution contract.
|
|
172
|
+
"""
|
|
173
|
+
engine, dispose = _sync_engine_from(target)
|
|
174
|
+
try:
|
|
175
|
+
return run_migrate(engine, chain_dir=chain_dir, allow_destructive=allow_destructive)
|
|
176
|
+
except SQLAlchemyError as exc:
|
|
177
|
+
# MigrationFileError/SqlpushError (typed) pass through untouched
|
|
178
|
+
_raise_typed(exc)
|
|
179
|
+
finally:
|
|
180
|
+
if dispose:
|
|
181
|
+
engine.dispose()
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def stamp(target, *, chain_dir="migrations/versions") -> MigrateReport:
|
|
185
|
+
"""Bootstrap: register chain files as applied WITHOUT executing SQL.
|
|
186
|
+
|
|
187
|
+
For adopting a DB whose schema already reflects the chain. ``target``
|
|
188
|
+
resolution and error typing match :func:`migrate`. See
|
|
189
|
+
``chain.migrate.run_stamp`` for the report convention.
|
|
190
|
+
"""
|
|
191
|
+
engine, dispose = _sync_engine_from(target)
|
|
192
|
+
try:
|
|
193
|
+
return run_stamp(engine, chain_dir=chain_dir)
|
|
194
|
+
except SQLAlchemyError as exc:
|
|
195
|
+
# MigrationFileError/SqlpushError (typed) pass through untouched
|
|
196
|
+
_raise_typed(exc)
|
|
197
|
+
finally:
|
|
198
|
+
if dispose:
|
|
199
|
+
engine.dispose()
|
|
200
|
+
|
|
201
|
+
|
|
119
202
|
def _sync_engine_from(target):
|
|
120
203
|
if isinstance(target, AsyncEngine):
|
|
121
204
|
from sqlalchemy import create_engine
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
# re-export for downstream consumers (stamp verb): canonical home is
|
|
2
|
+
# chain.format. NOT re-exported from sqlpush.types — types is imported BY
|
|
3
|
+
# chain.format, so a types-level re-export would be circular.
|
|
4
|
+
from sqlpush.chain.format import MigrationFileError
|
|
5
|
+
|
|
6
|
+
__all__ = ["MigrationFileError"]
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Annotated-SQL migration files: the chain format (spec 2026-09-02 §4).
|
|
2
|
+
|
|
3
|
+
Files are plain editable SQL with a structured header line that carries
|
|
4
|
+
the risk classification. Labels are legal SQL comments, so generated
|
|
5
|
+
files are directly psql-runnable. Parsing is FAIL-LOUD: a missing or
|
|
6
|
+
malformed header refuses the file — never assume-safe.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import re
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from sqlpush.types import RiskClass, SqlpushError
|
|
17
|
+
|
|
18
|
+
RISK_RANK: dict[RiskClass, int] = {
|
|
19
|
+
RiskClass.SAFE: 0,
|
|
20
|
+
RiskClass.RISKY: 1,
|
|
21
|
+
RiskClass.DESTRUCTIVE: 2,
|
|
22
|
+
}
|
|
23
|
+
"""Explicit ordering — RiskClass is a plain str-Enum with no < (spec P5)."""
|
|
24
|
+
|
|
25
|
+
# Header line: `-- sqlpush: revision=0007 risk=DESTRUCTIVE ops=3`. Keys other
|
|
26
|
+
# than revision/risk (e.g. ops=, parent=, generated=) are tolerated — the
|
|
27
|
+
# writer may add informative keys, the parser only requires the fail-loud two.
|
|
28
|
+
_HEADER_LINE_RE = re.compile(r"^--\s*sqlpush:\s*(?P<body>.*)$")
|
|
29
|
+
_OP_RE = re.compile(r"^--\s*op\s+\d+\s+\[(?P<label>[^\]]*)\]\s*(?P<desc>.*)$")
|
|
30
|
+
_REV_PREFIX_RE = re.compile(r"^(\d+)_")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class MigrationFileError(SqlpushError):
|
|
34
|
+
"""A chain file is missing/malformed — fail-loud, never assume-safe."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class MigrationFile:
|
|
39
|
+
name: str
|
|
40
|
+
revision_id: str
|
|
41
|
+
risk: RiskClass
|
|
42
|
+
ops: list[tuple[str, str]] = field(default_factory=list)
|
|
43
|
+
text: str = ""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def checksum(text: str) -> str:
|
|
47
|
+
normalized = text.replace("\r\n", "\n").rstrip()
|
|
48
|
+
return hashlib.sha256(normalized.encode("utf-8")).hexdigest()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _parse_header(text: str, name: str) -> tuple[str, RiskClass]:
|
|
52
|
+
for line in text.splitlines():
|
|
53
|
+
m = _HEADER_LINE_RE.match(line.strip())
|
|
54
|
+
if m:
|
|
55
|
+
break
|
|
56
|
+
else:
|
|
57
|
+
raise MigrationFileError(f"{name}: missing -- sqlpush: header")
|
|
58
|
+
body = m.group("body").strip()
|
|
59
|
+
if not body:
|
|
60
|
+
raise MigrationFileError(f"{name}: malformed -- sqlpush: header (empty)")
|
|
61
|
+
pairs: dict[str, str] = {}
|
|
62
|
+
for token in body.split():
|
|
63
|
+
key, sep, value = token.partition("=")
|
|
64
|
+
if not sep or not key or not value:
|
|
65
|
+
raise MigrationFileError(f"{name}: malformed -- sqlpush: header token {token!r}")
|
|
66
|
+
pairs[key] = value
|
|
67
|
+
revision = pairs.get("revision", "")
|
|
68
|
+
if not revision.isdigit():
|
|
69
|
+
raise MigrationFileError(f"{name}: malformed -- sqlpush: header: revision=NNNN required")
|
|
70
|
+
risk_name = pairs.get("risk")
|
|
71
|
+
if risk_name is None:
|
|
72
|
+
raise MigrationFileError(
|
|
73
|
+
f"{name}: malformed -- sqlpush: header: risk= missing (expected SAFE|RISKY|DESTRUCTIVE)"
|
|
74
|
+
)
|
|
75
|
+
try:
|
|
76
|
+
risk = RiskClass[risk_name]
|
|
77
|
+
except KeyError:
|
|
78
|
+
raise MigrationFileError(
|
|
79
|
+
f"{name}: malformed -- sqlpush: header: unknown risk={risk_name!r} "
|
|
80
|
+
f"(expected SAFE|RISKY|DESTRUCTIVE)"
|
|
81
|
+
) from None
|
|
82
|
+
return revision, risk
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def render_migration_file(
|
|
86
|
+
*,
|
|
87
|
+
ops: list[tuple[str, str]],
|
|
88
|
+
revision_id: str,
|
|
89
|
+
risk: RiskClass,
|
|
90
|
+
message: str | None = None,
|
|
91
|
+
parent: str | None = None,
|
|
92
|
+
) -> str:
|
|
93
|
+
lines = [f"-- sqlpush: revision={revision_id} risk={risk.name}"]
|
|
94
|
+
# parent= is informative-only: the parser never requires it (hand-written
|
|
95
|
+
# files omit it freely), but the writer always records the slot.
|
|
96
|
+
lines.append(f"-- parent={parent or ''}")
|
|
97
|
+
if message:
|
|
98
|
+
lines.append(f"-- {message}")
|
|
99
|
+
lines.append("")
|
|
100
|
+
for i, (label, sql) in enumerate(ops, start=1):
|
|
101
|
+
lines.append(f"-- op {i} {label}")
|
|
102
|
+
lines.append(sql.rstrip(";") + ";")
|
|
103
|
+
lines.append("")
|
|
104
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def parse_migration_file(text: str, *, name: str) -> MigrationFile:
|
|
108
|
+
revision, risk = _parse_header(text, name)
|
|
109
|
+
|
|
110
|
+
ops: list[tuple[str, str]] = []
|
|
111
|
+
sql_buf: list[str] = []
|
|
112
|
+
current_label = ""
|
|
113
|
+
for line in text.splitlines():
|
|
114
|
+
stripped = line.strip()
|
|
115
|
+
if stripped.startswith("-- sqlpush:"):
|
|
116
|
+
continue
|
|
117
|
+
op_m = _OP_RE.match(stripped)
|
|
118
|
+
if op_m:
|
|
119
|
+
if sql_buf:
|
|
120
|
+
ops.append((current_label, "\n".join(sql_buf).strip()))
|
|
121
|
+
sql_buf = []
|
|
122
|
+
current_label = f"[{op_m.group('label')}] {op_m.group('desc')}".strip()
|
|
123
|
+
continue
|
|
124
|
+
if stripped.startswith("--"):
|
|
125
|
+
continue # comentarios libres (message, parent, generated...) — ignorable
|
|
126
|
+
if stripped:
|
|
127
|
+
sql_buf.append(line)
|
|
128
|
+
if sql_buf:
|
|
129
|
+
ops.append((current_label, "\n".join(sql_buf).strip()))
|
|
130
|
+
if not ops:
|
|
131
|
+
raise MigrationFileError(f"{name}: file has no SQL")
|
|
132
|
+
|
|
133
|
+
return MigrationFile(name=name, revision_id=revision, risk=risk, ops=ops, text=text)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def next_revision_id(chain_dir: Path | str) -> str:
|
|
137
|
+
max_n = 0
|
|
138
|
+
for f in sorted(Path(chain_dir).glob("*.sql")):
|
|
139
|
+
m = _REV_PREFIX_RE.match(f.name)
|
|
140
|
+
if m:
|
|
141
|
+
max_n = max(max_n, int(m.group(1)))
|
|
142
|
+
return f"{max_n + 1:04d}"
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""migrate/stamp: chain replay + bootstrap with gates and bookkeeping.
|
|
2
|
+
|
|
3
|
+
migrate execution contract (spec 2026-09-02 §5): each file's WHOLE TEXT is
|
|
4
|
+
replayed in ONE ``exec_driver_sql()`` call inside a per-file transaction —
|
|
5
|
+
the parsed ``ops`` are for gating/display only, NEVER reconstructed for
|
|
6
|
+
execution (psycopg3 accepts multi-statement strings; nothing is tokenized,
|
|
7
|
+
so dollar-quoted bodies are safe). The checksum row is inserted INSIDE the
|
|
8
|
+
same transaction as the file's SQL — bookkeeping in a separate txn would
|
|
9
|
+
let a crash between apply and registry re-apply the file forever
|
|
10
|
+
(crash-loop over existing objects).
|
|
11
|
+
|
|
12
|
+
Fail-loud ordering: any blocked file (parse error, checksum mismatch,
|
|
13
|
+
destructive gate, SQL failure) stops the chain — nothing later runs (R4).
|
|
14
|
+
A hung migrate (stuck waiting on the advisory lock) is diagnosed via
|
|
15
|
+
pg_locks / pg_stat_activity.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import contextlib
|
|
21
|
+
from collections.abc import Iterator
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
from sqlalchemy import text
|
|
25
|
+
from sqlalchemy.engine import Connection, Engine
|
|
26
|
+
|
|
27
|
+
from sqlpush.apply.executor import advisory_key
|
|
28
|
+
from sqlpush.chain.format import MigrationFileError, checksum, parse_migration_file
|
|
29
|
+
from sqlpush.types import MigrateReport, RiskClass
|
|
30
|
+
|
|
31
|
+
_VERSIONS_DDL = (
|
|
32
|
+
"CREATE TABLE IF NOT EXISTS public.sqlpush_versions ("
|
|
33
|
+
"name TEXT PRIMARY KEY, sha256 TEXT NOT NULL, "
|
|
34
|
+
"applied_at timestamptz NOT NULL DEFAULT now())"
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _chain_files(chain_dir: str | Path) -> list[Path]:
|
|
39
|
+
chain_path = Path(chain_dir)
|
|
40
|
+
if not chain_path.is_dir():
|
|
41
|
+
# a typo'd --dir silently no-op'ing is the footgun; an empty-but-
|
|
42
|
+
# EXISTING dir is a legitimate idle run (versions table still ensured)
|
|
43
|
+
raise MigrationFileError(f"chain dir not found: {chain_path}")
|
|
44
|
+
return sorted(chain_path.glob("*.sql"))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@contextlib.contextmanager
|
|
48
|
+
def _chain_session(engine: Engine) -> Iterator[Connection]:
|
|
49
|
+
"""Session-scoped advisory lock + versions table, shared by every verb.
|
|
50
|
+
|
|
51
|
+
Same key derivation as push (fnv1a_32(b"sqlpush") ^ db oid): serializes
|
|
52
|
+
concurrent chain workers and excludes push on the same database. The
|
|
53
|
+
txn opened by the key query is committed right away — session advisory
|
|
54
|
+
locks survive COMMIT/ROLLBACK, and an idle-in-transaction session is
|
|
55
|
+
exposed to idle_in_transaction_session_timeout (executor.py note).
|
|
56
|
+
"""
|
|
57
|
+
with engine.connect() as conn:
|
|
58
|
+
key = advisory_key(conn)
|
|
59
|
+
conn.execute(text("SELECT pg_advisory_lock(:k)"), {"k": key})
|
|
60
|
+
conn.commit()
|
|
61
|
+
try:
|
|
62
|
+
conn.execute(text(_VERSIONS_DDL))
|
|
63
|
+
conn.commit()
|
|
64
|
+
yield conn
|
|
65
|
+
finally:
|
|
66
|
+
# best-effort unlock (executor pattern): a secondary failure here
|
|
67
|
+
# must never mask the primary outcome; the session lock dies with
|
|
68
|
+
# the connection even if this fails
|
|
69
|
+
if not conn.closed:
|
|
70
|
+
with contextlib.suppress(Exception):
|
|
71
|
+
conn.rollback()
|
|
72
|
+
conn.execute(text("SELECT pg_advisory_unlock(:k)"), {"k": key})
|
|
73
|
+
conn.commit()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def run_migrate(engine: Engine, *, chain_dir: str | Path, allow_destructive: bool) -> MigrateReport:
|
|
77
|
+
applied: list[str] = []
|
|
78
|
+
skipped: list[str] = []
|
|
79
|
+
blocked: list[str] = []
|
|
80
|
+
notes: list[str] = []
|
|
81
|
+
partial = False
|
|
82
|
+
chain = _chain_files(chain_dir)
|
|
83
|
+
with _chain_session(engine) as conn:
|
|
84
|
+
recorded = {
|
|
85
|
+
row[0]: row[1]
|
|
86
|
+
for row in conn.execute(text("SELECT name, sha256 FROM public.sqlpush_versions"))
|
|
87
|
+
}
|
|
88
|
+
conn.commit()
|
|
89
|
+
for f in chain:
|
|
90
|
+
raw = f.read_text()
|
|
91
|
+
try:
|
|
92
|
+
mf = parse_migration_file(raw, name=f.name)
|
|
93
|
+
except MigrationFileError as exc:
|
|
94
|
+
blocked.append(f.name)
|
|
95
|
+
notes.append(f"{f.name}: {exc}")
|
|
96
|
+
break # orden estricto: nada posterior corre
|
|
97
|
+
if f.name in recorded:
|
|
98
|
+
if recorded[f.name] != checksum(raw):
|
|
99
|
+
blocked.append(f.name)
|
|
100
|
+
notes.append(f"{f.name}: checksum mismatch (edited after apply?)")
|
|
101
|
+
break
|
|
102
|
+
skipped.append(f.name)
|
|
103
|
+
continue
|
|
104
|
+
if mf.risk is RiskClass.DESTRUCTIVE and not allow_destructive:
|
|
105
|
+
blocked.append(f.name)
|
|
106
|
+
notes.append(f"{f.name}: DESTRUCTIVE requires --allow-destructive")
|
|
107
|
+
break
|
|
108
|
+
try:
|
|
109
|
+
with conn.begin():
|
|
110
|
+
# whole-file replay: exec_driver_sql bypasses text()'s
|
|
111
|
+
# bind-param parsing entirely — ":casts" and ":=" in
|
|
112
|
+
# hand-edited SQL must reach the server verbatim
|
|
113
|
+
conn.exec_driver_sql(raw)
|
|
114
|
+
conn.execute(
|
|
115
|
+
text("INSERT INTO public.sqlpush_versions (name, sha256) VALUES (:n, :s)"),
|
|
116
|
+
{"n": f.name, "s": checksum(raw)},
|
|
117
|
+
)
|
|
118
|
+
applied.append(f.name)
|
|
119
|
+
except Exception as exc: # noqa: BLE001 — report, no mask
|
|
120
|
+
blocked.append(f.name)
|
|
121
|
+
notes.append(f"{f.name}: {exc}")
|
|
122
|
+
partial = True
|
|
123
|
+
break
|
|
124
|
+
return MigrateReport(
|
|
125
|
+
applied=tuple(applied),
|
|
126
|
+
skipped=tuple(skipped),
|
|
127
|
+
blocked=tuple(blocked),
|
|
128
|
+
partial_failure=partial,
|
|
129
|
+
notes=tuple(notes),
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def run_stamp(engine: Engine, *, chain_dir: str | Path) -> MigrateReport:
|
|
134
|
+
"""Register every parseable chain file WITHOUT executing any SQL.
|
|
135
|
+
|
|
136
|
+
Bootstrap seam (spec §5 R7): adopt a DB whose schema already reflects
|
|
137
|
+
the chain. Only the header must parse (fail-loud on THAT) — invalid SQL
|
|
138
|
+
in a body still registers, because stamp never executes anything. Each
|
|
139
|
+
registration is an idempotent upsert (``ON CONFLICT (name) DO UPDATE``),
|
|
140
|
+
so re-stamping refreshes checksums instead of failing.
|
|
141
|
+
|
|
142
|
+
Report convention: registered files are listed in ``skipped`` (stamp
|
|
143
|
+
never populates ``applied`` and never sets ``partial_failure``); a
|
|
144
|
+
header that fails to parse goes to ``blocked`` + ``notes`` and stops
|
|
145
|
+
the walk (strict order, same as migrate).
|
|
146
|
+
"""
|
|
147
|
+
skipped: list[str] = []
|
|
148
|
+
blocked: list[str] = []
|
|
149
|
+
notes: list[str] = []
|
|
150
|
+
chain = _chain_files(chain_dir)
|
|
151
|
+
with _chain_session(engine) as conn:
|
|
152
|
+
for f in chain:
|
|
153
|
+
raw = f.read_text()
|
|
154
|
+
try:
|
|
155
|
+
parse_migration_file(raw, name=f.name)
|
|
156
|
+
except MigrationFileError as exc:
|
|
157
|
+
blocked.append(f.name)
|
|
158
|
+
notes.append(f"{f.name}: {exc}")
|
|
159
|
+
break # orden estricto: nada posterior se registra
|
|
160
|
+
with conn.begin():
|
|
161
|
+
conn.execute(
|
|
162
|
+
text(
|
|
163
|
+
"INSERT INTO public.sqlpush_versions (name, sha256) VALUES (:n, :s) "
|
|
164
|
+
"ON CONFLICT (name) DO UPDATE SET sha256 = EXCLUDED.sha256"
|
|
165
|
+
),
|
|
166
|
+
{"n": f.name, "s": checksum(raw)},
|
|
167
|
+
)
|
|
168
|
+
skipped.append(f.name)
|
|
169
|
+
return MigrateReport(
|
|
170
|
+
applied=(),
|
|
171
|
+
skipped=tuple(skipped),
|
|
172
|
+
blocked=tuple(blocked),
|
|
173
|
+
partial_failure=False,
|
|
174
|
+
notes=tuple(notes),
|
|
175
|
+
)
|
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
"""sqlpush: diff/push/check PostgreSQL schema drift from SQLAlchemy models.
|
|
3
3
|
|
|
4
4
|
Exit codes: diff always 0; check 0 clean / 2 drift / 3 destructive drift;
|
|
5
|
-
push 0 applied / 1 destructive blocked / 2 error (incl. partial failure)
|
|
5
|
+
push 0 applied / 1 destructive blocked / 2 error (incl. partial failure);
|
|
6
|
+
revision 0 written / 1 error (empty drift refuses); migrate 0 clean /
|
|
7
|
+
1 blocked or partial failure.
|
|
6
8
|
"""
|
|
7
9
|
|
|
8
10
|
from __future__ import annotations
|
|
@@ -11,6 +13,7 @@ import importlib
|
|
|
11
13
|
import json
|
|
12
14
|
import os
|
|
13
15
|
import sys
|
|
16
|
+
from pathlib import Path
|
|
14
17
|
from typing import Annotated
|
|
15
18
|
|
|
16
19
|
import typer
|
|
@@ -33,6 +36,9 @@ app = typer.Typer(add_completion=False, help=__doc__ or "")
|
|
|
33
36
|
# default, no mutable default) while staying the idiomatic typer style.
|
|
34
37
|
SchemaOpt = Annotated[list[str] | None, typer.Option("--schema")]
|
|
35
38
|
ExcludeOpt = Annotated[list[str] | None, typer.Option("--exclude")]
|
|
39
|
+
# Path-typed options need the Annotated form: B008 (call in default) only
|
|
40
|
+
# exempts typer.Option for non-Path annotations
|
|
41
|
+
DirOpt = Annotated[Path, typer.Option("--dir")]
|
|
36
42
|
|
|
37
43
|
|
|
38
44
|
def _load_metadata(spec: str):
|
|
@@ -210,6 +216,81 @@ def push(
|
|
|
210
216
|
raise typer.Exit(code=0)
|
|
211
217
|
|
|
212
218
|
|
|
219
|
+
@app.command()
|
|
220
|
+
def revision(
|
|
221
|
+
metadata_spec: str = typer.Argument(..., help="module:metadata"),
|
|
222
|
+
ref_dsn: str = typer.Option(..., "--ref-dsn"),
|
|
223
|
+
message: str | None = typer.Option(None, "--message", "-m"),
|
|
224
|
+
out_dir: DirOpt = Path("migrations/versions"),
|
|
225
|
+
schema: SchemaOpt = None,
|
|
226
|
+
exclude: ExcludeOpt = None,
|
|
227
|
+
):
|
|
228
|
+
"""Generate the next migration file from models vs the reference DB."""
|
|
229
|
+
md = _load_metadata(metadata_spec)
|
|
230
|
+
# required --ref-dsn (no DATABASE_URL fallback): the reference DB is a
|
|
231
|
+
# different database from the push target — conflating them silently
|
|
232
|
+
# would chain against the wrong head
|
|
233
|
+
engine = _engine(ref_dsn)
|
|
234
|
+
try:
|
|
235
|
+
path = api.revision(
|
|
236
|
+
md,
|
|
237
|
+
engine,
|
|
238
|
+
out_dir=out_dir,
|
|
239
|
+
message=message,
|
|
240
|
+
schemas=schema,
|
|
241
|
+
exclude=exclude or (),
|
|
242
|
+
)
|
|
243
|
+
finally:
|
|
244
|
+
engine.dispose()
|
|
245
|
+
typer.echo(str(path))
|
|
246
|
+
raise typer.Exit(code=0)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
@app.command()
|
|
250
|
+
def migrate(
|
|
251
|
+
dsn: str | None = typer.Option(None),
|
|
252
|
+
allow_destructive: bool = typer.Option(False, "--allow-destructive"),
|
|
253
|
+
out_dir: DirOpt = Path("migrations/versions"),
|
|
254
|
+
):
|
|
255
|
+
"""Replay pending migration files (gates + checksum bookkeeping)."""
|
|
256
|
+
engine = _engine(dsn)
|
|
257
|
+
try:
|
|
258
|
+
report = api.migrate(engine, chain_dir=out_dir, allow_destructive=allow_destructive)
|
|
259
|
+
finally:
|
|
260
|
+
engine.dispose()
|
|
261
|
+
typer.echo(
|
|
262
|
+
f"applied: {len(report.applied)}, skipped: {len(report.skipped)}, "
|
|
263
|
+
f"blocked: {len(report.blocked)}, partial_failure: {report.partial_failure}"
|
|
264
|
+
)
|
|
265
|
+
for note in report.notes:
|
|
266
|
+
typer.secho(note, fg="yellow", err=True)
|
|
267
|
+
if report.blocked or report.partial_failure:
|
|
268
|
+
raise typer.Exit(code=1)
|
|
269
|
+
raise typer.Exit(code=0)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
@app.command()
|
|
273
|
+
def stamp(
|
|
274
|
+
dsn: str | None = typer.Option(None),
|
|
275
|
+
out_dir: DirOpt = Path("migrations/versions"),
|
|
276
|
+
):
|
|
277
|
+
"""Adopt an existing DB: register chain files without executing SQL."""
|
|
278
|
+
engine = _engine(dsn)
|
|
279
|
+
try:
|
|
280
|
+
report = api.stamp(engine, chain_dir=out_dir)
|
|
281
|
+
finally:
|
|
282
|
+
engine.dispose()
|
|
283
|
+
typer.echo(
|
|
284
|
+
f"applied: {len(report.applied)}, skipped (registered): {len(report.skipped)}, "
|
|
285
|
+
f"blocked: {len(report.blocked)}, partial_failure: {report.partial_failure}"
|
|
286
|
+
)
|
|
287
|
+
for note in report.notes:
|
|
288
|
+
typer.secho(note, fg="yellow", err=True)
|
|
289
|
+
if report.blocked or report.partial_failure:
|
|
290
|
+
raise typer.Exit(code=1)
|
|
291
|
+
raise typer.Exit(code=0)
|
|
292
|
+
|
|
293
|
+
|
|
213
294
|
def main() -> None: # [project.scripts] entry point
|
|
214
295
|
try:
|
|
215
296
|
app()
|
|
@@ -17,7 +17,10 @@ from sqlpush.core.classify import classify
|
|
|
17
17
|
from sqlpush.types import Plan, PlannedOperation
|
|
18
18
|
|
|
19
19
|
_SYSTEM_SCHEMAS = ("_timescaledb%", "information_schema", "pg_%")
|
|
20
|
-
|
|
20
|
+
# sqlpush_versions: the chain engine's own bookkeeping (migrate/stamp) —
|
|
21
|
+
# same treatment as alembic_version (C1): a public-scoped post-migrate
|
|
22
|
+
# check must be clean, not report its own registry as destructive drift
|
|
23
|
+
_SYSTEM_TABLES = ("alembic_version", "spatial_ref_sys", "sqlpush_versions")
|
|
21
24
|
|
|
22
25
|
# Leaf op class -> sqlpush op type. Class names follow alembic 1.19.1
|
|
23
26
|
# autogen output as observed in docs/notes/alembic-notes.md and probes:
|
|
@@ -104,6 +107,12 @@ def _timescale_auto_indexes(conn) -> dict[str, set[str]]:
|
|
|
104
107
|
``a_b`` dim ``c`` both yield ``a_b_c_idx``) — a str value would
|
|
105
108
|
last-win and leak the other as false drift. Qualified keys carry
|
|
106
109
|
cross-schema hypertables. Empty when the extension is absent.
|
|
110
|
+
|
|
111
|
+
Consumed twice in ``_make_include``: DB-only prunes (the reflected
|
|
112
|
+
auto index with no metadata counterpart) and the S1 both-present
|
|
113
|
+
sibling (a metadata-declared index with the same qualified name and
|
|
114
|
+
column sequence compares EQUAL — the default's born-DESC ordering is
|
|
115
|
+
extension state, not drift).
|
|
107
116
|
"""
|
|
108
117
|
installed = conn.execute(
|
|
109
118
|
text("SELECT 1 FROM pg_extension WHERE extname = 'timescaledb'")
|
|
@@ -219,6 +228,24 @@ def _make_include_name(schemas: frozenset[str], default_schema: str):
|
|
|
219
228
|
return include_name
|
|
220
229
|
|
|
221
230
|
|
|
231
|
+
def _expr_name(e) -> str | None:
|
|
232
|
+
"""Best-effort column name of an Index expression, order-insensitive.
|
|
233
|
+
|
|
234
|
+
Metadata-side expressions may be bare strings (``Index(..., "col")``)
|
|
235
|
+
or Column objects; reflected-side ones arrive wrapped in
|
|
236
|
+
``UnaryExpression`` (the born-DESC/ASC ordering modifier). Unwrap
|
|
237
|
+
wrapper elements (UnaryExpression/Label carry ``.element``), resolve
|
|
238
|
+
strings and Columns to their names. Functions fall back to ``.name``
|
|
239
|
+
— a sufficient equality proxy for the suppression check below.
|
|
240
|
+
"""
|
|
241
|
+
if isinstance(e, str):
|
|
242
|
+
return e
|
|
243
|
+
inner = getattr(e, "element", None)
|
|
244
|
+
if inner is not None:
|
|
245
|
+
return _expr_name(inner)
|
|
246
|
+
return getattr(e, "name", None)
|
|
247
|
+
|
|
248
|
+
|
|
222
249
|
def _make_include(
|
|
223
250
|
schemas: frozenset[str],
|
|
224
251
|
default_schema: str,
|
|
@@ -238,6 +265,11 @@ def _make_include(
|
|
|
238
265
|
)
|
|
239
266
|
if schema not in schemas or _is_system_schema(schema):
|
|
240
267
|
return False
|
|
268
|
+
parent = getattr(obj, "table", None)
|
|
269
|
+
parent_table = getattr(parent, "name", None)
|
|
270
|
+
parent_schema = getattr(parent, "schema", None) or default_schema
|
|
271
|
+
qualified_index = f"{parent_schema}.{name}"
|
|
272
|
+
qualified_parent = f"{parent_schema}.{parent_table}"
|
|
241
273
|
if reflected and compare_to is None:
|
|
242
274
|
# DB-only object: skip system tables. NB: alembic also
|
|
243
275
|
# auto-excludes its own version table from autogen; the
|
|
@@ -248,16 +280,29 @@ def _make_include(
|
|
|
248
280
|
# state no model declares. Qualified name AND owner set must
|
|
249
281
|
# both match, so a same-named index on another table stays
|
|
250
282
|
# real drift, as does one the metadata actually declares
|
|
251
|
-
# (that arrives in the both-present
|
|
252
|
-
parent = getattr(obj, "table", None)
|
|
253
|
-
parent_table = getattr(parent, "name", None)
|
|
254
|
-
parent_schema = getattr(parent, "schema", None) or default_schema
|
|
255
|
-
qualified_index = f"{parent_schema}.{name}"
|
|
256
|
-
qualified_parent = f"{parent_schema}.{parent_table}"
|
|
283
|
+
# (that arrives in the both-present branches below).
|
|
257
284
|
if type_ == "index" and qualified_index in spatial_auto_indexes:
|
|
258
285
|
return False
|
|
259
286
|
owners = ts_auto_indexes.get(qualified_index, frozenset())
|
|
260
287
|
return not (type_ == "index" and qualified_parent in owners)
|
|
288
|
+
if type_ == "index" and not reflected and compare_to is not None:
|
|
289
|
+
# S1 both-present sibling (atlas cycle-6): alembic gates the
|
|
290
|
+
# drop+add PAIR for a "changed" index behind exactly ONE
|
|
291
|
+
# include_object call (metadata index, reflected=False,
|
|
292
|
+
# compare_to=reflected index). TimescaleDB's default time
|
|
293
|
+
# index is born DESC (pg_index.indoption DESC bit); a
|
|
294
|
+
# metadata-declared index with the same name, same owning
|
|
295
|
+
# hypertable and the SAME column sequence is its metadata
|
|
296
|
+
# stand-in — the sort-order difference is extension-birth
|
|
297
|
+
# state, not drift. Column names compared ORDER-SENSITIVELY:
|
|
298
|
+
# a genuinely different declaration (reorder, extra column)
|
|
299
|
+
# still reports the pair.
|
|
300
|
+
owners = ts_auto_indexes.get(qualified_index, frozenset())
|
|
301
|
+
if qualified_parent in owners:
|
|
302
|
+
m_cols = [_expr_name(e) for e in obj.expressions]
|
|
303
|
+
r_cols = [_expr_name(e) for e in compare_to.expressions]
|
|
304
|
+
if m_cols == r_cols:
|
|
305
|
+
return False
|
|
261
306
|
return True
|
|
262
307
|
|
|
263
308
|
return include_object
|
|
@@ -313,6 +358,93 @@ def _dedup_embedded_indexes(ops: list[PlannedOperation]) -> list[PlannedOperatio
|
|
|
313
358
|
]
|
|
314
359
|
|
|
315
360
|
|
|
361
|
+
def _split_statements(sql: str) -> list[str]:
|
|
362
|
+
"""Split a multi-statement op render on top-level ``;`` only.
|
|
363
|
+
|
|
364
|
+
Quote-aware: ``;`` inside single-quoted SQL literals (enum labels
|
|
365
|
+
can carry them) never splits; ``''`` escapes are consumed in pairs.
|
|
366
|
+
"""
|
|
367
|
+
parts: list[str] = []
|
|
368
|
+
buf: list[str] = []
|
|
369
|
+
in_quote = False
|
|
370
|
+
i = 0
|
|
371
|
+
while i < len(sql):
|
|
372
|
+
ch = sql[i]
|
|
373
|
+
if ch == "'":
|
|
374
|
+
if in_quote and i + 1 < len(sql) and sql[i + 1] == "'":
|
|
375
|
+
buf.append("''")
|
|
376
|
+
i += 2
|
|
377
|
+
continue
|
|
378
|
+
in_quote = not in_quote
|
|
379
|
+
buf.append(ch)
|
|
380
|
+
elif ch == ";" and not in_quote:
|
|
381
|
+
parts.append("".join(buf))
|
|
382
|
+
buf = []
|
|
383
|
+
else:
|
|
384
|
+
buf.append(ch)
|
|
385
|
+
i += 1
|
|
386
|
+
if "".join(buf).strip():
|
|
387
|
+
parts.append("".join(buf))
|
|
388
|
+
return parts
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _dedup_enum_types(ops: list[PlannedOperation]) -> list[PlannedOperation]:
|
|
392
|
+
"""Emit each native enum's ``CREATE/DROP TYPE`` once across the plan (S2).
|
|
393
|
+
|
|
394
|
+
SQLAlchemy's offline render embeds a native enum's DDL inside EVERY
|
|
395
|
+
table op that references it (per-invoke create/drop memos — unlike
|
|
396
|
+
metadata-level ``create_all``, which memoizes per metadata), so two
|
|
397
|
+
tables sharing a Python enum each carry a verbatim copy of the
|
|
398
|
+
``CREATE TYPE`` in their add_table render; executing both is a
|
|
399
|
+
guaranteed DuplicateObject (atlas cycle-6 finding S2 — the enum
|
|
400
|
+
sibling of F1/F2's embedded-index dedup, but statement-level and
|
|
401
|
+
cross-table).
|
|
402
|
+
|
|
403
|
+
Statement-level: only VERBATIM duplicates (whitespace-normalized)
|
|
404
|
+
are dropped — later byte-identical copies leave their op, the first
|
|
405
|
+
copy stays embedded in its original add_table render. A metadata
|
|
406
|
+
that defines one type name two DIFFERENT ways keeps both statements
|
|
407
|
+
and still fails loudly at apply time: silent first-win would mask a
|
|
408
|
+
genuine contradiction.
|
|
409
|
+
"""
|
|
410
|
+
seen: set[str] = set()
|
|
411
|
+
|
|
412
|
+
def _is_type_stmt(norm: str) -> bool:
|
|
413
|
+
low = norm.lower()
|
|
414
|
+
return low.startswith(("create type ", "drop type "))
|
|
415
|
+
|
|
416
|
+
out: list[PlannedOperation] = []
|
|
417
|
+
for op in ops:
|
|
418
|
+
if "type" not in op.sql.lower() or ";" not in op.sql:
|
|
419
|
+
out.append(op)
|
|
420
|
+
continue
|
|
421
|
+
stmts = _split_statements(op.sql)
|
|
422
|
+
kept: list[str] = []
|
|
423
|
+
for stmt in stmts:
|
|
424
|
+
norm = " ".join(stmt.split())
|
|
425
|
+
if _is_type_stmt(norm):
|
|
426
|
+
if norm in seen:
|
|
427
|
+
continue
|
|
428
|
+
seen.add(norm)
|
|
429
|
+
if norm:
|
|
430
|
+
kept.append(stmt)
|
|
431
|
+
if not kept:
|
|
432
|
+
# unreachable for table ops (their CREATE/DROP TABLE always
|
|
433
|
+
# survives); guard anyway — an empty op.sql is worse than
|
|
434
|
+
# the duplicate it replaced
|
|
435
|
+
continue
|
|
436
|
+
out.append(
|
|
437
|
+
PlannedOperation(
|
|
438
|
+
type=op.type,
|
|
439
|
+
risk=op.risk,
|
|
440
|
+
sql=";\n\n".join(stmt.strip() for stmt in kept if stmt.strip()),
|
|
441
|
+
table=op.table,
|
|
442
|
+
column=op.column,
|
|
443
|
+
)
|
|
444
|
+
)
|
|
445
|
+
return out
|
|
446
|
+
|
|
447
|
+
|
|
316
448
|
def _render_op_sql(op, engine: Engine) -> str:
|
|
317
449
|
buf = io.StringIO()
|
|
318
450
|
offline = MigrationContext.configure(
|
|
@@ -376,6 +508,7 @@ class DiffEngine:
|
|
|
376
508
|
for op in _flatten(script.upgrade_ops.ops):
|
|
377
509
|
ops.extend(self._translate(op, engine, exclude))
|
|
378
510
|
ops = _dedup_embedded_indexes(ops)
|
|
511
|
+
ops = _dedup_enum_types(ops)
|
|
379
512
|
return Plan(operations=tuple(ops))
|
|
380
513
|
|
|
381
514
|
def _translate(self, op, engine: Engine, exclude: tuple[str, ...]) -> list[PlannedOperation]:
|
|
@@ -5,7 +5,7 @@ from sqlalchemy import MetaData, text
|
|
|
5
5
|
from sqlalchemy.engine import Connection, Engine
|
|
6
6
|
from sqlalchemy.exc import ProgrammingError
|
|
7
7
|
|
|
8
|
-
from sqlpush.annotations import HYPERTABLE_KEY
|
|
8
|
+
from sqlpush.annotations import HYPERTABLE_KEY, create_hypertable_sql
|
|
9
9
|
from sqlpush.types import PlannedOperation, RiskClass
|
|
10
10
|
|
|
11
11
|
|
|
@@ -29,12 +29,6 @@ def _is_hypertable(conn: Connection, schema: str, table_name: str) -> bool:
|
|
|
29
29
|
return False
|
|
30
30
|
|
|
31
31
|
|
|
32
|
-
def _lit(value: str) -> str:
|
|
33
|
-
# single-quoted SQL literal escaping: ' -> '' (defense in depth
|
|
34
|
-
# against quote break-out via table/column names from user metadata)
|
|
35
|
-
return value.replace("'", "''")
|
|
36
|
-
|
|
37
|
-
|
|
38
32
|
def hypertable_operations(
|
|
39
33
|
metadata: MetaData, engine: Engine | None = None
|
|
40
34
|
) -> list[PlannedOperation]:
|
|
@@ -58,31 +52,14 @@ def hypertable_operations(
|
|
|
58
52
|
ops: list[PlannedOperation] = []
|
|
59
53
|
for table in pending:
|
|
60
54
|
info = table.info[HYPERTABLE_KEY]
|
|
61
|
-
#
|
|
62
|
-
#
|
|
63
|
-
#
|
|
64
|
-
# public.<name> (UndefinedTable). Schema-less tables keep the
|
|
65
|
-
# bare name: they live in the default schema, which the
|
|
66
|
-
# search_path already resolves.
|
|
67
|
-
relation = table.name if table.schema is None else f"{table.schema}.{table.name}"
|
|
68
|
-
name = _lit(relation)
|
|
69
|
-
time_column = _lit(info.time_column)
|
|
70
|
-
parts = [f"SELECT create_hypertable('{name}', '{time_column}'"]
|
|
71
|
-
if info.chunk_time_interval:
|
|
72
|
-
parts.append(f", chunk_time_interval => INTERVAL '{_lit(info.chunk_time_interval)}'")
|
|
73
|
-
parts.append(", migrate_data => true")
|
|
74
|
-
# if_not_exists: race insurance between the state check above
|
|
75
|
-
# and the apply. create_default_indexes=false: timescale's
|
|
76
|
-
# implicit time-column index is invisible to metadata, so the
|
|
77
|
-
# default would drift forever (destructive drop_index) on a
|
|
78
|
-
# fully synced schema; users declare wanted indexes in the
|
|
79
|
-
# Table instead (metadata is the source of truth).
|
|
80
|
-
parts.append(", if_not_exists => true, create_default_indexes => false)")
|
|
55
|
+
# SQL parity with the decorator's after_create listener is by
|
|
56
|
+
# construction: both render through create_hypertable_sql (the
|
|
57
|
+
# directive's render adds the statement terminator).
|
|
81
58
|
ops.append(
|
|
82
59
|
PlannedOperation(
|
|
83
60
|
type="create_hypertable",
|
|
84
61
|
risk=RiskClass.SAFE,
|
|
85
|
-
sql=
|
|
62
|
+
sql=create_hypertable_sql(table.name, table.schema, info) + ";",
|
|
86
63
|
table=table.name,
|
|
87
64
|
)
|
|
88
65
|
)
|
|
@@ -83,6 +83,18 @@ class Report:
|
|
|
83
83
|
duration: float = 0.0
|
|
84
84
|
|
|
85
85
|
|
|
86
|
+
@dataclass(frozen=True)
|
|
87
|
+
class MigrateReport:
|
|
88
|
+
# applied/skipped/blocked carry BARE filenames so callers (tests, CI)
|
|
89
|
+
# can do exact membership checks; the human-readable why (gate reason,
|
|
90
|
+
# checksum mismatch, SQL error) lives in `notes` aligned by prefix.
|
|
91
|
+
applied: tuple[str, ...] = ()
|
|
92
|
+
skipped: tuple[str, ...] = ()
|
|
93
|
+
blocked: tuple[str, ...] = ()
|
|
94
|
+
partial_failure: bool = False
|
|
95
|
+
notes: tuple[str, ...] = ()
|
|
96
|
+
|
|
97
|
+
|
|
86
98
|
class SqlpushError(Exception):
|
|
87
99
|
pass
|
|
88
100
|
|
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
# src/sqlpush/annotations.py
|
|
2
|
-
from __future__ import annotations
|
|
3
|
-
|
|
4
|
-
from dataclasses import dataclass
|
|
5
|
-
|
|
6
|
-
HYPERTABLE_KEY = "sqlpush_hypertable"
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
@dataclass(frozen=True)
|
|
10
|
-
class HypertableInfo:
|
|
11
|
-
time_column: str
|
|
12
|
-
chunk_time_interval: str | None = None
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
def hypertable(*, time_column: str, chunk_time_interval: str | None = None):
|
|
16
|
-
"""Record hypertable intent on the model's Table (MetaData level).
|
|
17
|
-
|
|
18
|
-
Works with SQLModel, Flask-SQLAlchemy and plain declarative, anything
|
|
19
|
-
whose class already carries a built ``__table__`` when decorated.
|
|
20
|
-
|
|
21
|
-
The generated ``create_hypertable`` runs with
|
|
22
|
-
``create_default_indexes => false``: timescale's implicit time-column
|
|
23
|
-
index is invisible to metadata and would drift forever. Declare it
|
|
24
|
-
yourself (``Index(..., "<time_column>")`` on the model) if you rely
|
|
25
|
-
on it for time-range scans.
|
|
26
|
-
"""
|
|
27
|
-
|
|
28
|
-
def decorator(cls):
|
|
29
|
-
table = getattr(cls, "__table__", None)
|
|
30
|
-
if table is None:
|
|
31
|
-
raise TypeError(f"{cls.__name__} has no __table__; decorate a mapped class")
|
|
32
|
-
table.info[HYPERTABLE_KEY] = HypertableInfo(
|
|
33
|
-
time_column=time_column, chunk_time_interval=chunk_time_interval
|
|
34
|
-
)
|
|
35
|
-
return cls
|
|
36
|
-
|
|
37
|
-
return decorator
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|