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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sqlpush
3
- Version: 0.3.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.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.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
- _SYSTEM_TABLES = ("alembic_version", "spatial_ref_sys")
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 branch above).
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
- # Schema-qualified relation: create_hypertable resolves an
62
- # unqualified name via the session search_path, so a table in a
63
- # non-default schema MUST carry its schema or the op lands on
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="".join(parts) + ";",
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