sqlpush 0.1.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/__init__.py +78 -0
- sqlpush/annotations.py +37 -0
- sqlpush/api.py +172 -0
- sqlpush/apply/__init__.py +0 -0
- sqlpush/apply/executor.py +199 -0
- sqlpush/cli.py +218 -0
- sqlpush/core/__init__.py +0 -0
- sqlpush/core/classify.py +19 -0
- sqlpush/core/diff.py +205 -0
- sqlpush/core/render.py +21 -0
- sqlpush/directives/__init__.py +0 -0
- sqlpush/directives/timescale.py +79 -0
- sqlpush/types.py +100 -0
- sqlpush-0.1.0.dist-info/METADATA +215 -0
- sqlpush-0.1.0.dist-info/RECORD +17 -0
- sqlpush-0.1.0.dist-info/WHEEL +4 -0
- sqlpush-0.1.0.dist-info/entry_points.txt +3 -0
sqlpush/core/__init__.py
ADDED
|
File without changes
|
sqlpush/core/classify.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# src/sqlpush/core/classify.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from sqlpush.types import RiskClass
|
|
5
|
+
|
|
6
|
+
SAFE = frozenset({"add_table", "add_column", "add_constraint", "create_hypertable"})
|
|
7
|
+
DESTRUCTIVE = frozenset({"drop_column", "drop_table", "drop_index", "drop_constraint"})
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def classify(op_type: str) -> RiskClass:
|
|
11
|
+
"""Standalone add_index targets an EXISTING table
|
|
12
|
+
(indexes of new tables ride along with add_table), so it is risky:
|
|
13
|
+
a plain CREATE INDEX takes a SHARE lock that blocks writes."""
|
|
14
|
+
if op_type in DESTRUCTIVE:
|
|
15
|
+
return RiskClass.DESTRUCTIVE
|
|
16
|
+
if op_type in SAFE:
|
|
17
|
+
return RiskClass.SAFE
|
|
18
|
+
# modify_*, add_index, raw_sql, and anything unknown
|
|
19
|
+
return RiskClass.RISKY
|
sqlpush/core/diff.py
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""The ONLY module in sqlpush that imports alembic."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import fnmatch
|
|
6
|
+
import io
|
|
7
|
+
from collections.abc import Sequence
|
|
8
|
+
|
|
9
|
+
from alembic.autogenerate import produce_migrations
|
|
10
|
+
from alembic.migration import MigrationContext
|
|
11
|
+
from alembic.operations import Operations
|
|
12
|
+
from alembic.operations.ops import AlterColumnOp, OpContainer
|
|
13
|
+
from sqlalchemy import MetaData, text
|
|
14
|
+
from sqlalchemy.engine import Engine
|
|
15
|
+
|
|
16
|
+
from sqlpush.core.classify import classify
|
|
17
|
+
from sqlpush.types import Plan, PlannedOperation
|
|
18
|
+
|
|
19
|
+
_SYSTEM_SCHEMAS = ("_timescaledb%", "information_schema", "pg_%")
|
|
20
|
+
_SYSTEM_TABLES = ("alembic_version", "spatial_ref_sys")
|
|
21
|
+
|
|
22
|
+
# Leaf op class -> sqlpush op type. Class names follow alembic 1.19.1
|
|
23
|
+
# autogen output as observed in docs/notes/alembic-notes.md and probes:
|
|
24
|
+
# table create is CreateTableOp (not "AddTableOp"), column modify is
|
|
25
|
+
# AlterColumnOp (not "ModifyColumnOp"), index create is CreateIndexOp,
|
|
26
|
+
# and constraint creates are the concrete Create*ConstraintOp subclasses
|
|
27
|
+
# The AddConstraintOp base class is never emitted as a leaf op.
|
|
28
|
+
# AlterColumnOp is absent ON PURPOSE: its label is derived per-op (see
|
|
29
|
+
# _alter_column_label) because default-only, nullable-only and type
|
|
30
|
+
# changes all arrive as the same class.
|
|
31
|
+
_OP_TYPE = {
|
|
32
|
+
"CreateTableOp": "add_table",
|
|
33
|
+
"DropTableOp": "drop_table",
|
|
34
|
+
"AddColumnOp": "add_column",
|
|
35
|
+
"DropColumnOp": "drop_column",
|
|
36
|
+
"CreateIndexOp": "add_index",
|
|
37
|
+
"DropIndexOp": "drop_index",
|
|
38
|
+
"CreateUniqueConstraintOp": "add_constraint",
|
|
39
|
+
"CreateForeignKeyOp": "add_constraint",
|
|
40
|
+
"CreatePrimaryKeyOp": "add_constraint",
|
|
41
|
+
"CreateCheckConstraintOp": "add_constraint",
|
|
42
|
+
"DropConstraintOp": "drop_constraint",
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _alter_column_label(op: AlterColumnOp) -> str:
|
|
47
|
+
"""Precise op type for AlterColumnOp (alembic-notes Pattern C).
|
|
48
|
+
|
|
49
|
+
Sentinel semantics: ``False`` and ``None`` both mean "leave
|
|
50
|
+
unchanged"; any other value is the new setting. Default-only and
|
|
51
|
+
nullable-only drift arrive as AlterColumnOp just like type changes,
|
|
52
|
+
so disambiguate on the attributes.
|
|
53
|
+
"""
|
|
54
|
+
if op.modify_server_default not in (False, None):
|
|
55
|
+
return "modify_default"
|
|
56
|
+
if op.modify_nullable not in (False, None):
|
|
57
|
+
return "modify_nullable"
|
|
58
|
+
return "modify_type"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _is_system_schema(schema: str) -> bool:
|
|
62
|
+
return any(fnmatch.fnmatch(schema, pat) for pat in _SYSTEM_SCHEMAS)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _make_include_name(schemas: frozenset[str], default_schema: str):
|
|
66
|
+
def include_name(name, type_, parent_names):
|
|
67
|
+
# Prune schemas (and everything inside them) BEFORE reflection:
|
|
68
|
+
# system catalogs (timescale et al.) are then never reflected at
|
|
69
|
+
# all. Schema filtering routes through include_name: alembic
|
|
70
|
+
# 1.19 never calls include_object with type_ == "schema", and
|
|
71
|
+
# its return value must be a real bool (falsy = exclude).
|
|
72
|
+
# NB: alembic substitutes None for the default schema name in
|
|
73
|
+
# the schema pass (compare/schema.py), so map None back.
|
|
74
|
+
if type_ == "schema":
|
|
75
|
+
schema = default_schema if name is None else name
|
|
76
|
+
else:
|
|
77
|
+
schema = parent_names.get("schema_name") or default_schema
|
|
78
|
+
return schema in schemas and not _is_system_schema(schema)
|
|
79
|
+
|
|
80
|
+
return include_name
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _make_include(schemas: frozenset[str], default_schema: str):
|
|
84
|
+
def include_object(obj, name, type_, reflected, compare_to):
|
|
85
|
+
# Bound the diff to the target schemas on every branch: derive
|
|
86
|
+
# the object's effective schema and require membership, for
|
|
87
|
+
# reflected-only, metadata-only and both-present objects alike.
|
|
88
|
+
# Column/Index/Constraint objects have no `.schema` of their own
|
|
89
|
+
# (SQLAlchemy 2.0.52): they resolve it through the parent table.
|
|
90
|
+
schema = (
|
|
91
|
+
getattr(obj, "schema", None)
|
|
92
|
+
or getattr(getattr(obj, "table", None), "schema", None)
|
|
93
|
+
or default_schema
|
|
94
|
+
)
|
|
95
|
+
if schema not in schemas or _is_system_schema(schema):
|
|
96
|
+
return False
|
|
97
|
+
if reflected and compare_to is None:
|
|
98
|
+
# DB-only object: skip system tables. NB: alembic also
|
|
99
|
+
# auto-excludes its own version table from autogen; the
|
|
100
|
+
# name check here is defense in depth.
|
|
101
|
+
return name not in _SYSTEM_TABLES
|
|
102
|
+
return True
|
|
103
|
+
|
|
104
|
+
return include_object
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _flatten(ops):
|
|
108
|
+
# D1 (alembic-notes): ops targeting existing tables arrive wrapped in
|
|
109
|
+
# ModifyTableOps containers; Operations.invoke crashes on containers,
|
|
110
|
+
# so recurse into anything that is an OpContainer before invoking.
|
|
111
|
+
for op in ops:
|
|
112
|
+
if isinstance(op, OpContainer):
|
|
113
|
+
yield from _flatten(op.ops)
|
|
114
|
+
else:
|
|
115
|
+
yield op
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _render_op_sql(op, engine: Engine) -> str:
|
|
119
|
+
buf = io.StringIO()
|
|
120
|
+
offline = MigrationContext.configure(
|
|
121
|
+
dialect=engine.dialect, opts={"as_sql": True, "output_buffer": buf}
|
|
122
|
+
)
|
|
123
|
+
operations = Operations(offline)
|
|
124
|
+
operations.invoke(op)
|
|
125
|
+
# offline render terminates each op with ";\n\n"; normalize so each
|
|
126
|
+
# PlannedOperation.sql is a single clean statement
|
|
127
|
+
return buf.getvalue().strip().rstrip(";")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class DiffEngine:
|
|
131
|
+
def plan(
|
|
132
|
+
self,
|
|
133
|
+
metadata: MetaData,
|
|
134
|
+
engine: Engine,
|
|
135
|
+
*,
|
|
136
|
+
schemas: Sequence[str] | None = None,
|
|
137
|
+
exclude: Sequence[str] = (),
|
|
138
|
+
) -> Plan:
|
|
139
|
+
if schemas is None:
|
|
140
|
+
with engine.connect() as conn:
|
|
141
|
+
search_path = conn.execute(text("SHOW search_path")).scalar()
|
|
142
|
+
# a live PG session always reports a search_path
|
|
143
|
+
assert search_path is not None
|
|
144
|
+
schemas = [
|
|
145
|
+
s.strip()
|
|
146
|
+
for s in search_path.split(",")
|
|
147
|
+
if s.strip() and s.strip() != '"$user"'
|
|
148
|
+
]
|
|
149
|
+
# New binding rather than a param reassignment: frozenset is a
|
|
150
|
+
# Set, not a Sequence, and the helpers below declare the exact
|
|
151
|
+
# type they take.
|
|
152
|
+
schema_set = frozenset(schemas)
|
|
153
|
+
exclude = tuple(exclude)
|
|
154
|
+
# Typed `str | None` by SQLAlchemy; a dialect without a default
|
|
155
|
+
# schema is meaningless for this PostgreSQL-only tool; "public"
|
|
156
|
+
# matches every supported server config.
|
|
157
|
+
default_schema = engine.dialect.default_schema_name or "public"
|
|
158
|
+
|
|
159
|
+
opts = {
|
|
160
|
+
"compare_type": True,
|
|
161
|
+
"compare_server_default": True,
|
|
162
|
+
"include_name": _make_include_name(schema_set, default_schema),
|
|
163
|
+
"include_object": _make_include(schema_set, default_schema),
|
|
164
|
+
"include_schemas": True,
|
|
165
|
+
}
|
|
166
|
+
with engine.connect() as conn:
|
|
167
|
+
ctx = MigrationContext.configure(conn, opts=opts)
|
|
168
|
+
script = produce_migrations(ctx, metadata)
|
|
169
|
+
|
|
170
|
+
# produce_migrations always builds the upgrade bundle
|
|
171
|
+
assert script.upgrade_ops is not None
|
|
172
|
+
ops: list[PlannedOperation] = []
|
|
173
|
+
for op in _flatten(script.upgrade_ops.ops):
|
|
174
|
+
ops.extend(self._translate(op, engine, exclude))
|
|
175
|
+
return Plan(operations=tuple(ops))
|
|
176
|
+
|
|
177
|
+
def _translate(self, op, engine: Engine, exclude: tuple[str, ...]) -> list[PlannedOperation]:
|
|
178
|
+
op_type = _OP_TYPE.get(type(op).__name__, "raw_sql")
|
|
179
|
+
if isinstance(op, AlterColumnOp):
|
|
180
|
+
op_type = _alter_column_label(op)
|
|
181
|
+
sql_text = _render_op_sql(op, engine)
|
|
182
|
+
table = getattr(getattr(op, "table", None), "name", None) or getattr(op, "table_name", None)
|
|
183
|
+
# AddColumnOp carries a real Column in `.column`; DropColumnOp and
|
|
184
|
+
# AlterColumnOp only expose the plain string `column_name`
|
|
185
|
+
# (alembic-notes op reference).
|
|
186
|
+
column = getattr(getattr(op, "column", None), "name", None) or getattr(
|
|
187
|
+
op, "column_name", None
|
|
188
|
+
)
|
|
189
|
+
# table-level patterns reach column-level ops too; a qualified
|
|
190
|
+
# table.column pattern suppresses only that specific column op
|
|
191
|
+
if table and any(fnmatch.fnmatch(table, pat) for pat in exclude):
|
|
192
|
+
return []
|
|
193
|
+
if table and column:
|
|
194
|
+
full = f"{table}.{column}"
|
|
195
|
+
if any(fnmatch.fnmatch(full, pat) for pat in exclude):
|
|
196
|
+
return []
|
|
197
|
+
return [
|
|
198
|
+
PlannedOperation(
|
|
199
|
+
type=op_type,
|
|
200
|
+
risk=classify(op_type),
|
|
201
|
+
sql=sql_text,
|
|
202
|
+
table=table,
|
|
203
|
+
column=column,
|
|
204
|
+
)
|
|
205
|
+
]
|
sqlpush/core/render.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# src/sqlpush/core/render.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from sqlpush.types import Plan, RiskClass
|
|
5
|
+
|
|
6
|
+
_HEADERS = {
|
|
7
|
+
RiskClass.SAFE: "-- safe",
|
|
8
|
+
RiskClass.RISKY: "-- risky",
|
|
9
|
+
RiskClass.DESTRUCTIVE: "-- destructive",
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def render(plan: Plan) -> str:
|
|
14
|
+
if not plan.operations:
|
|
15
|
+
return ""
|
|
16
|
+
sections: list[str] = []
|
|
17
|
+
for risk in (RiskClass.SAFE, RiskClass.RISKY, RiskClass.DESTRUCTIVE):
|
|
18
|
+
sqls = [op.sql for op in plan.operations if op.risk is risk]
|
|
19
|
+
if sqls:
|
|
20
|
+
sections.append(_HEADERS[risk] + "\n" + ";\n".join(sqls) + ";")
|
|
21
|
+
return "\n\n".join(sections)
|
|
File without changes
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# src/sqlpush/directives/timescale.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from sqlalchemy import MetaData, text
|
|
5
|
+
from sqlalchemy.engine import Connection, Engine
|
|
6
|
+
from sqlalchemy.exc import ProgrammingError
|
|
7
|
+
|
|
8
|
+
from sqlpush.annotations import HYPERTABLE_KEY
|
|
9
|
+
from sqlpush.types import PlannedOperation, RiskClass
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _is_hypertable(conn: Connection, table_name: str) -> bool:
|
|
13
|
+
try:
|
|
14
|
+
return bool(
|
|
15
|
+
conn.execute(
|
|
16
|
+
text(
|
|
17
|
+
"SELECT 1 FROM timescaledb_information.hypertables "
|
|
18
|
+
"WHERE hypertable_name = :name"
|
|
19
|
+
),
|
|
20
|
+
{"name": table_name},
|
|
21
|
+
).scalar()
|
|
22
|
+
)
|
|
23
|
+
except ProgrammingError:
|
|
24
|
+
# timescaledb_information views exist only where the extension
|
|
25
|
+
# is installed: on a non-timescale DB nothing can be a
|
|
26
|
+
# hypertable. Emit the op and let apply surface the server's
|
|
27
|
+
# own error: annotated models on non-timescale DBs are user
|
|
28
|
+
# error, not a state probe failure.
|
|
29
|
+
return False
|
|
30
|
+
|
|
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
|
+
def hypertable_operations(
|
|
39
|
+
metadata: MetaData, engine: Engine | None = None
|
|
40
|
+
) -> list[PlannedOperation]:
|
|
41
|
+
"""Plan ``create_hypertable`` ops for ``@hypertable``-annotated tables.
|
|
42
|
+
|
|
43
|
+
``engine=None`` emits unconditionally (DB-free preview/tests keep
|
|
44
|
+
the previous behavior). With ``engine``, a table already registered
|
|
45
|
+
in ``timescaledb_information.hypertables`` emits nothing: push stays
|
|
46
|
+
idempotent and ``check()`` reports clean on a synced annotated
|
|
47
|
+
schema: directives are state-aware like the diff.
|
|
48
|
+
"""
|
|
49
|
+
pending = [
|
|
50
|
+
table for table in metadata.tables.values() if table.info.get(HYPERTABLE_KEY) is not None
|
|
51
|
+
]
|
|
52
|
+
if engine is not None and pending:
|
|
53
|
+
with engine.connect() as conn:
|
|
54
|
+
pending = [t for t in pending if not _is_hypertable(conn, t.name)]
|
|
55
|
+
ops: list[PlannedOperation] = []
|
|
56
|
+
for table in pending:
|
|
57
|
+
info = table.info[HYPERTABLE_KEY]
|
|
58
|
+
name = _lit(table.name)
|
|
59
|
+
time_column = _lit(info.time_column)
|
|
60
|
+
parts = [f"SELECT create_hypertable('{name}', '{time_column}'"]
|
|
61
|
+
if info.chunk_time_interval:
|
|
62
|
+
parts.append(f", chunk_time_interval => INTERVAL '{_lit(info.chunk_time_interval)}'")
|
|
63
|
+
parts.append(", migrate_data => true")
|
|
64
|
+
# if_not_exists: race insurance between the state check above
|
|
65
|
+
# and the apply. create_default_indexes=false: timescale's
|
|
66
|
+
# implicit time-column index is invisible to metadata, so the
|
|
67
|
+
# default would drift forever (destructive drop_index) on a
|
|
68
|
+
# fully synced schema; users declare wanted indexes in the
|
|
69
|
+
# Table instead (metadata is the source of truth).
|
|
70
|
+
parts.append(", if_not_exists => true, create_default_indexes => false)")
|
|
71
|
+
ops.append(
|
|
72
|
+
PlannedOperation(
|
|
73
|
+
type="create_hypertable",
|
|
74
|
+
risk=RiskClass.SAFE,
|
|
75
|
+
sql="".join(parts) + ";",
|
|
76
|
+
table=table.name,
|
|
77
|
+
)
|
|
78
|
+
)
|
|
79
|
+
return ops
|
sqlpush/types.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# src/sqlpush/types.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from enum import Enum
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class RiskClass(str, Enum):
|
|
10
|
+
SAFE = "safe"
|
|
11
|
+
RISKY = "risky"
|
|
12
|
+
DESTRUCTIVE = "destructive"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class PlannedOperation:
|
|
17
|
+
type: str
|
|
18
|
+
risk: RiskClass
|
|
19
|
+
sql: str
|
|
20
|
+
table: str | None = None
|
|
21
|
+
column: str | None = None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class Plan:
|
|
26
|
+
operations: tuple[PlannedOperation, ...] = ()
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def drift(self) -> bool:
|
|
30
|
+
return bool(self.operations)
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def has_destructive(self) -> bool:
|
|
34
|
+
return any(op.risk is RiskClass.DESTRUCTIVE for op in self.operations)
|
|
35
|
+
|
|
36
|
+
def to_json_dict(self) -> dict[str, Any]:
|
|
37
|
+
return {
|
|
38
|
+
"version": 1,
|
|
39
|
+
"drift": self.drift,
|
|
40
|
+
"operations": [
|
|
41
|
+
{
|
|
42
|
+
"type": op.type,
|
|
43
|
+
"risk": op.risk.value,
|
|
44
|
+
"table": op.table,
|
|
45
|
+
"column": op.column,
|
|
46
|
+
"sql": op.sql,
|
|
47
|
+
}
|
|
48
|
+
for op in self.operations
|
|
49
|
+
],
|
|
50
|
+
"sql": ";\n".join(op.sql for op in self.operations),
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True)
|
|
55
|
+
class CheckResult:
|
|
56
|
+
clean: bool
|
|
57
|
+
drift: bool
|
|
58
|
+
has_destructive: bool
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True)
|
|
62
|
+
class AppliedOperation:
|
|
63
|
+
type: str
|
|
64
|
+
# execution outcome only: "applied" | "failed". Blocked operations
|
|
65
|
+
# are refused by the gate before execution and live in
|
|
66
|
+
# Report.blocked instead.
|
|
67
|
+
status: str
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True)
|
|
71
|
+
class Report:
|
|
72
|
+
# `applied` is the EXECUTION TRAIL of the run: it may contain
|
|
73
|
+
# "failed" entries (a CONCURRENTLY op that errored, see
|
|
74
|
+
# partial_failure), not only successes.
|
|
75
|
+
applied: tuple[AppliedOperation, ...] = ()
|
|
76
|
+
# `blocked`: destructive ops refused by the gate (allow_destructive=False)
|
|
77
|
+
# Hard stop, CLI maps this to exit 1.
|
|
78
|
+
# `skipped`: ops declined by policy (safe_only), informational only,
|
|
79
|
+
# the run proceeds without them.
|
|
80
|
+
blocked: tuple[PlannedOperation, ...] = ()
|
|
81
|
+
skipped: tuple[PlannedOperation, ...] = ()
|
|
82
|
+
partial_failure: bool = False
|
|
83
|
+
duration: float = 0.0
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class SqlpushError(Exception):
|
|
87
|
+
pass
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class ConnectFailed(SqlpushError):
|
|
91
|
+
pass
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class MetadataImportError(SqlpushError):
|
|
95
|
+
pass
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class DestructiveBlocked(SqlpushError):
|
|
99
|
+
"""Reserved for future use: the destructive gate currently reports
|
|
100
|
+
through ``Report.blocked`` instead of raising."""
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sqlpush
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Schema lifecycle tool for production PostgreSQL/TimescaleDB on SQLAlchemy 2.0 (SQLModel included): diff, push and check schema drift straight from your models
|
|
5
|
+
Keywords: sqlalchemy,alembic,postgresql,timescaledb,prisma,schema,migrations,database,drift,cli
|
|
6
|
+
Author: Juan Miguel Contreras
|
|
7
|
+
Author-email: Juan Miguel Contreras <19253629+juanmicl@users.noreply.github.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Environment :: Console
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Topic :: Database
|
|
18
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
19
|
+
Requires-Dist: alembic>=1.18,<2
|
|
20
|
+
Requires-Dist: psycopg[binary]>=3.2
|
|
21
|
+
Requires-Dist: sqlalchemy>=2.0
|
|
22
|
+
Requires-Dist: typer>=0.12
|
|
23
|
+
Requires-Python: >=3.10
|
|
24
|
+
Project-URL: Homepage, https://github.com/juanmicl/sqlpush
|
|
25
|
+
Project-URL: Repository, https://github.com/juanmicl/sqlpush
|
|
26
|
+
Project-URL: Issues, https://github.com/juanmicl/sqlpush/issues
|
|
27
|
+
Project-URL: Changelog, https://github.com/juanmicl/sqlpush/blob/main/CHANGELOG.md
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# sqlpush
|
|
31
|
+
|
|
32
|
+
[](https://github.com/juanmicl/sqlpush/actions/workflows/ci.yml)
|
|
33
|
+
[](https://pypi.org/project/sqlpush/)
|
|
34
|
+
[](https://pypi.org/project/sqlpush/)
|
|
35
|
+
[](LICENSE)
|
|
36
|
+
|
|
37
|
+
**Prisma `db push` for SQLAlchemy.** Apply your models (SQLAlchemy,
|
|
38
|
+
SQLModel, anything built on `MetaData`) to a live PostgreSQL / TimescaleDB
|
|
39
|
+
database directly, no migration files. sqlpush
|
|
40
|
+
diffs your models against the real schema, classifies every operation by
|
|
41
|
+
risk (safe / risky / destructive), and applies the plan atomically. Drift
|
|
42
|
+
checks exit with codes your CI can gate on.
|
|
43
|
+
|
|
44
|
+
```console
|
|
45
|
+
sqlpush diff "myapp.models:metadata" # see the SQL, ordered by risk
|
|
46
|
+
sqlpush check "myapp.models:metadata" # CI gate: exit 0/2/3
|
|
47
|
+
sqlpush push "myapp.models:metadata" # apply (destructive gated)
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
If you've ever run `Base.metadata.create_all()` in production and known it
|
|
51
|
+
was wrong, then sighed at the migration-script treadmill when you reached
|
|
52
|
+
for alembic: sqlpush is for you.
|
|
53
|
+
|
|
54
|
+
## Why
|
|
55
|
+
|
|
56
|
+
Declarative models are already the source of truth. Migration files
|
|
57
|
+
re-encode what the models say, drift from them, and pile up forever.
|
|
58
|
+
sqlpush closes the loop the way Prisma's `db push` does for its schema
|
|
59
|
+
language, but for the SQLAlchemy ecosystem (SQLModel included):
|
|
60
|
+
|
|
61
|
+
- **No migration files, ever.** The diff *is* the migration: computed fresh
|
|
62
|
+
from models vs. live database on every run, via alembic's autogenerate
|
|
63
|
+
engine used as a library.
|
|
64
|
+
- **Risk-aware by default.** Every operation is classified `safe` /
|
|
65
|
+
`risky` / `destructive`. Destructive ops (drops) are **blocked until
|
|
66
|
+
`--allow-destructive`**: nothing executes at all while any is present.
|
|
67
|
+
- **Drift detection built for CI.** `check` plans once and exits `0` clean /
|
|
68
|
+
`2` drift / `3` destructive drift, scriptable without parsing output.
|
|
69
|
+
`--json` emits a stable versioned contract.
|
|
70
|
+
- **Safe under concurrency.** An advisory lock (keyed to the database, not
|
|
71
|
+
the DSN) coordinates workers: one pusher at a time, losers wait bounded
|
|
72
|
+
and re-verify, so deploy pipelines can race without corrupting anything.
|
|
73
|
+
- **Hypertables without hand-written SQL.** Decorate a model with
|
|
74
|
+
`@hypertable` and the `create_hypertable` directive is planned
|
|
75
|
+
state-aware: idempotent pushes, clean checks, no false drift.
|
|
76
|
+
|
|
77
|
+
PostgreSQL only, by design.
|
|
78
|
+
|
|
79
|
+
## Install
|
|
80
|
+
|
|
81
|
+
```console
|
|
82
|
+
pip install sqlpush
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Or from source:
|
|
86
|
+
|
|
87
|
+
```console
|
|
88
|
+
git clone https://github.com/juanmicl/sqlpush && cd sqlpush && uv sync
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## The 30-second tour
|
|
92
|
+
|
|
93
|
+
Point sqlpush at your metadata (`module:attribute`) and a database
|
|
94
|
+
(`--dsn` or `$DATABASE_URL`):
|
|
95
|
+
|
|
96
|
+
```console
|
|
97
|
+
$ export DATABASE_URL="postgresql+psycopg://user:pass@host:5432/db"
|
|
98
|
+
|
|
99
|
+
$ sqlpush diff "myapp.models:metadata"
|
|
100
|
+
-- safe
|
|
101
|
+
|
|
102
|
+
CREATE TABLE hero (
|
|
103
|
+
id SERIAL NOT NULL PRIMARY KEY,
|
|
104
|
+
name VARCHAR(50) NOT NULL
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
-- risky
|
|
108
|
+
|
|
109
|
+
CREATE INDEX ix_hero_name ON hero (name);
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Push it (the destructive gate is on by default):
|
|
113
|
+
|
|
114
|
+
```console
|
|
115
|
+
$ sqlpush push "myapp.models:metadata"
|
|
116
|
+
1 destructive operation(s) blocked; re-run with --allow-destructive
|
|
117
|
+
$ echo $?
|
|
118
|
+
1
|
|
119
|
+
|
|
120
|
+
$ sqlpush push "myapp.models:metadata" --allow-destructive
|
|
121
|
+
$ echo $?
|
|
122
|
+
0
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
In CI, check drift and fail loudly (see exit codes below). Limit scope with
|
|
126
|
+
repeated `--schema` / `--exclude` options.
|
|
127
|
+
|
|
128
|
+
## Exit codes
|
|
129
|
+
|
|
130
|
+
| verb | 0 | 1 | 2 | 3 |
|
|
131
|
+
| --- | --- | --- | --- | --- |
|
|
132
|
+
| `diff` | always | | | |
|
|
133
|
+
| `check` | clean | | drift | destructive drift |
|
|
134
|
+
| `push` | applied | destructive blocked | error (incl. partial failure) | |
|
|
135
|
+
|
|
136
|
+
`push --safe-only` runs only safe operations and skips the rest
|
|
137
|
+
informationally (exit `0`). A failed `CREATE INDEX CONCURRENTLY` marks the
|
|
138
|
+
run as partial failure (exit `2`) instead of silently half-applying.
|
|
139
|
+
|
|
140
|
+
## FastAPI / SQLModel: replace `create_all`
|
|
141
|
+
|
|
142
|
+
```python
|
|
143
|
+
from contextlib import asynccontextmanager
|
|
144
|
+
from sqlpush import aensure_schema
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@asynccontextmanager
|
|
148
|
+
async def lifespan(app):
|
|
149
|
+
await aensure_schema(SQLModel.metadata, engine, mode="check")
|
|
150
|
+
yield
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Push in the deploy pipeline, check at startup.
|
|
154
|
+
|
|
155
|
+
## How it works
|
|
156
|
+
|
|
157
|
+
```mermaid
|
|
158
|
+
flowchart LR
|
|
159
|
+
models["SQLAlchemy MetaData"] --> diff["diff<br>alembic autogenerate, scoped"]
|
|
160
|
+
db[("live PostgreSQL")] --> diff
|
|
161
|
+
diff --> risk["risk classification<br>safe / risky / destructive"]
|
|
162
|
+
risk --> plan["plan"]
|
|
163
|
+
plan --> render["render"]
|
|
164
|
+
render --> apply["apply<br>atomic txn · CONCURRENTLY split · advisory lock"]
|
|
165
|
+
apply --> report["report"]
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
- **Diff engine** scopes reflection to your target schemas (default: the
|
|
169
|
+
session's real `search_path`) and prunes system catalogs (TimescaleDB
|
|
170
|
+
internals included) before reflection even starts.
|
|
171
|
+
- **Classifier** maps each operation to a risk class; unknown operations
|
|
172
|
+
are `risky`, never silently safe.
|
|
173
|
+
- **Executor** splits the plan: `CONCURRENTLY` statements run one-per-
|
|
174
|
+
transaction on autocommit, everything else applies in a single atomic
|
|
175
|
+
transaction with a bounded `lock_timeout`.
|
|
176
|
+
- **Typed errors**: only `SqlpushError` / `ConnectFailed` /
|
|
177
|
+
`MetadataImportError` escape the API, never raw driver exceptions.
|
|
178
|
+
|
|
179
|
+
## Comparison
|
|
180
|
+
|
|
181
|
+
An honest view of the neighborhood (stars as of 2026-08):
|
|
182
|
+
|
|
183
|
+
| | migration files | source of truth | risk gate | CI drift exit codes | TimescaleDB |
|
|
184
|
+
| --- | --- | --- | --- | --- | --- |
|
|
185
|
+
| **sqlpush** | none (the diff is the migration) | SQLAlchemy `MetaData` | classified safe/risky/destructive, destructive blocked by default | `check` 0/2/3 | `@hypertable` directives |
|
|
186
|
+
| [alembic](https://github.com/sqlalchemy/alembic) (4.4k★) | yes | migration scripts (autogenerate assists) | no | no | no |
|
|
187
|
+
| [atlas](https://github.com/ariga/atlas) (8.7k★) | optional (HCL) | HCL / SQL (ORMs via providers) | lint policies | yes | no |
|
|
188
|
+
| [prisma `db push`](https://www.prisma.io/docs/orm/reference/prisma-cli-reference) (47k★) | none | Prisma schema (Node/TS) | no | no | no |
|
|
189
|
+
| [migra](https://github.com/djrobstep/migra) (3.1k★) | diff only | SQL | n/a | partial | no (*deprecated*) |
|
|
190
|
+
|
|
191
|
+
sqlpush is narrower than atlas and younger than alembic, deliberately.
|
|
192
|
+
It is one tool for one job: keep a PostgreSQL schema in lockstep with
|
|
193
|
+
SQLAlchemy models, safely enough to run from CI.
|
|
194
|
+
|
|
195
|
+
Coming from [migra](https://github.com/djrobstep/migra) (now
|
|
196
|
+
deprecated)? There is a [migration guide](docs/migrating-from-migra.md).
|
|
197
|
+
|
|
198
|
+
## Design notes
|
|
199
|
+
|
|
200
|
+
- `import sqlpush` stays light: the public API loads lazily, so the
|
|
201
|
+
annotations module carries none of alembic/typer/psycopg.
|
|
202
|
+
- The advisory-lock key derives from the database OID: two DSN spellings
|
|
203
|
+
of the same database contend for the same lock.
|
|
204
|
+
- `--json` output is a versioned contract (`"version": 1`) meant for
|
|
205
|
+
tooling; additive changes only within a version.
|
|
206
|
+
|
|
207
|
+
## Roadmap (0.1.x)
|
|
208
|
+
|
|
209
|
+
- `CREATE INDEX CONCURRENTLY` by default for indexes on existing tables
|
|
210
|
+
- asyncpg DSN translation in `ensure_schema(AsyncEngine)`
|
|
211
|
+
- jsonschema-validated `--json` output
|
|
212
|
+
|
|
213
|
+
## License
|
|
214
|
+
|
|
215
|
+
[MIT](LICENSE) · © 2026 Juan Miguel Contreras
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
sqlpush/__init__.py,sha256=Px0jJFHbwwM1-UHtDwD3xQlRifEMW4StSXQs5DD9z6w,1697
|
|
2
|
+
sqlpush/annotations.py,sha256=AKK4t30lRznTFUfDYaH9r8WslHzzVs57GNtZGHxS8LM,1222
|
|
3
|
+
sqlpush/api.py,sha256=n4NcrdmUWqu8TJluph61F2swEIJkZs0PQcFExzcebqU,5291
|
|
4
|
+
sqlpush/apply/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
sqlpush/apply/executor.py,sha256=-HgmscUO6M_xQ5HPBQhJkUo6XwEPPqBig7_G9aWQJAU,7242
|
|
6
|
+
sqlpush/cli.py,sha256=UeyBnixEz1s6nf4Wv4Y7zrmce8y02pqfHCy2_brjg_Q,7388
|
|
7
|
+
sqlpush/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
sqlpush/core/classify.py,sha256=3NgoaheRgO6AfjtXm9ndvvUq-Ei1BvYKX03o3kkdgZA,718
|
|
9
|
+
sqlpush/core/diff.py,sha256=g5e2kwKdu88k24S6lFNSPxxNDxeq55Yo22lLTPIXnvU,8381
|
|
10
|
+
sqlpush/core/render.py,sha256=9SNEyHOGmMxKUfIjvo3x0MNVQ6N2o2fl3noWT1EWO3M,609
|
|
11
|
+
sqlpush/directives/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
12
|
+
sqlpush/directives/timescale.py,sha256=Z1X7A6o8-wVTTe8aJLzXC_ko2OP_i8-LaEmgSzy7mYg,3210
|
|
13
|
+
sqlpush/types.py,sha256=D2FV3eJZt0znXpZLV8ZRFQr7mIoNKjai4C7zPL71fdE,2502
|
|
14
|
+
sqlpush-0.1.0.dist-info/WHEEL,sha256=ZFFp7t7R4RYQ5KYZkmiFWoQvHay7SrTmn-6ZYfoFZ3U,80
|
|
15
|
+
sqlpush-0.1.0.dist-info/entry_points.txt,sha256=VYha3PJSvklRgpcDAZYJdE66EXM79MSKQf0z8igyAOI,46
|
|
16
|
+
sqlpush-0.1.0.dist-info/METADATA,sha256=gqo6pjZ06bLySmE8MRXze-_5uiu2-ZYS8mpy7iJw7wI,8535
|
|
17
|
+
sqlpush-0.1.0.dist-info/RECORD,,
|