sqlpush 0.2.0__tar.gz → 0.3.0__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.2.0 → sqlpush-0.3.0}/PKG-INFO +1 -1
- {sqlpush-0.2.0 → sqlpush-0.3.0}/pyproject.toml +1 -1
- {sqlpush-0.2.0 → sqlpush-0.3.0}/pyproject.toml.orig +1 -1
- {sqlpush-0.2.0 → sqlpush-0.3.0}/src/sqlpush/core/classify.py +7 -3
- {sqlpush-0.2.0 → sqlpush-0.3.0}/src/sqlpush/core/diff.py +40 -0
- {sqlpush-0.2.0 → sqlpush-0.3.0}/src/sqlpush/directives/timescale.py +15 -5
- {sqlpush-0.2.0 → sqlpush-0.3.0}/README.md +0 -0
- {sqlpush-0.2.0 → sqlpush-0.3.0}/src/sqlpush/__init__.py +0 -0
- {sqlpush-0.2.0 → sqlpush-0.3.0}/src/sqlpush/annotations.py +0 -0
- {sqlpush-0.2.0 → sqlpush-0.3.0}/src/sqlpush/api.py +0 -0
- {sqlpush-0.2.0 → sqlpush-0.3.0}/src/sqlpush/apply/__init__.py +0 -0
- {sqlpush-0.2.0 → sqlpush-0.3.0}/src/sqlpush/apply/executor.py +0 -0
- {sqlpush-0.2.0 → sqlpush-0.3.0}/src/sqlpush/cli.py +0 -0
- {sqlpush-0.2.0 → sqlpush-0.3.0}/src/sqlpush/core/__init__.py +0 -0
- {sqlpush-0.2.0 → sqlpush-0.3.0}/src/sqlpush/core/render.py +0 -0
- {sqlpush-0.2.0 → sqlpush-0.3.0}/src/sqlpush/directives/__init__.py +0 -0
- {sqlpush-0.2.0 → sqlpush-0.3.0}/src/sqlpush/types.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sqlpush
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Schema lifecycle tool for production PostgreSQL/TimescaleDB on SQLAlchemy 2.0 (SQLModel included): diff, push and check schema drift straight from your models
|
|
5
5
|
Keywords: sqlalchemy,alembic,postgresql,timescaledb,prisma,schema,migrations,database,drift,cli
|
|
6
6
|
Author: Juan Miguel Contreras
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "sqlpush"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.3.0"
|
|
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.3.0"
|
|
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 = [
|
|
@@ -8,9 +8,13 @@ DESTRUCTIVE = frozenset({"drop_column", "drop_table", "drop_index", "drop_constr
|
|
|
8
8
|
|
|
9
9
|
|
|
10
10
|
def classify(op_type: str) -> RiskClass:
|
|
11
|
-
"""
|
|
12
|
-
|
|
13
|
-
|
|
11
|
+
"""add_index renders standalone: on alembic 1.19.1 even plain
|
|
12
|
+
declared indexes of NEW tables arrive standalone
|
|
13
|
+
(CreateTableOp.from_table captures columns+constraints, not
|
|
14
|
+
indexes; only instrumentation-embedded ones ride inside the
|
|
15
|
+
add_table render, and the diff dedups those away). What survives
|
|
16
|
+
runs CREATE INDEX alone — a SHARE lock that blocks writes, hence
|
|
17
|
+
risky."""
|
|
14
18
|
if op_type in DESTRUCTIVE:
|
|
15
19
|
return RiskClass.DESTRUCTIVE
|
|
16
20
|
if op_type in SAFE:
|
|
@@ -274,6 +274,45 @@ def _flatten(ops):
|
|
|
274
274
|
yield op
|
|
275
275
|
|
|
276
276
|
|
|
277
|
+
def _dedup_embedded_indexes(ops: list[PlannedOperation]) -> list[PlannedOperation]:
|
|
278
|
+
"""Drop standalone ``add_index`` ops already embedded in an ``add_table`` render.
|
|
279
|
+
|
|
280
|
+
On alembic 1.19.1 ``CreateTableOp.from_table`` captures columns and
|
|
281
|
+
constraints only, NOT indexes: a plain declared ``Index(...)`` on a
|
|
282
|
+
new table never reaches the create render — it arrives
|
|
283
|
+
standalone-only and is untouched here. The embedding this dedup
|
|
284
|
+
targets happens when the table carries instrumentation-appended
|
|
285
|
+
indexes (geoalchemy2-style listeners attaching at Table
|
|
286
|
+
construction): ``to_table()`` reconstruction re-fires the
|
|
287
|
+
attachment, the rebuilt table carries the index again, the offline
|
|
288
|
+
create render embeds it, and autogen ALSO emits the standalone
|
|
289
|
+
CreateIndexOp — executing both is a guaranteed duplicate-object
|
|
290
|
+
failure (push fire-test F1/F2: the renders are byte-identical and
|
|
291
|
+
the second execution collides with 42P07). Suppression side: the
|
|
292
|
+
standalone op is the redundant one — its statement already runs
|
|
293
|
+
inside the add_table op, whose render embeds it verbatim; the
|
|
294
|
+
embedded copy has no other carrier op. Exact-statement containment
|
|
295
|
+
is safe: both renders come from the same renderer over the same
|
|
296
|
+
Index objects, so an embedded index matches its standalone op
|
|
297
|
+
byte-for-byte while a different index's statement cannot be a
|
|
298
|
+
substring of the create-table render (statement text runs to its
|
|
299
|
+
own terminator). Known limitation: the keys are bare table names,
|
|
300
|
+
so two NEW tables sharing a bare name across schemas under-dedup
|
|
301
|
+
(last-wins in the dict) — containment is SQL-qualified either way,
|
|
302
|
+
so no wrong suppression is possible.
|
|
303
|
+
"""
|
|
304
|
+
table_renders = {op.table: op.sql for op in ops if op.type == "add_table"}
|
|
305
|
+
return [
|
|
306
|
+
op
|
|
307
|
+
for op in ops
|
|
308
|
+
if not (
|
|
309
|
+
op.type == "add_index"
|
|
310
|
+
and op.table in table_renders
|
|
311
|
+
and op.sql.strip() in table_renders[op.table]
|
|
312
|
+
)
|
|
313
|
+
]
|
|
314
|
+
|
|
315
|
+
|
|
277
316
|
def _render_op_sql(op, engine: Engine) -> str:
|
|
278
317
|
buf = io.StringIO()
|
|
279
318
|
offline = MigrationContext.configure(
|
|
@@ -336,6 +375,7 @@ class DiffEngine:
|
|
|
336
375
|
ops: list[PlannedOperation] = []
|
|
337
376
|
for op in _flatten(script.upgrade_ops.ops):
|
|
338
377
|
ops.extend(self._translate(op, engine, exclude))
|
|
378
|
+
ops = _dedup_embedded_indexes(ops)
|
|
339
379
|
return Plan(operations=tuple(ops))
|
|
340
380
|
|
|
341
381
|
def _translate(self, op, engine: Engine, exclude: tuple[str, ...]) -> list[PlannedOperation]:
|
|
@@ -9,15 +9,15 @@ from sqlpush.annotations import HYPERTABLE_KEY
|
|
|
9
9
|
from sqlpush.types import PlannedOperation, RiskClass
|
|
10
10
|
|
|
11
11
|
|
|
12
|
-
def _is_hypertable(conn: Connection, table_name: str) -> bool:
|
|
12
|
+
def _is_hypertable(conn: Connection, schema: str, table_name: str) -> bool:
|
|
13
13
|
try:
|
|
14
14
|
return bool(
|
|
15
15
|
conn.execute(
|
|
16
16
|
text(
|
|
17
17
|
"SELECT 1 FROM timescaledb_information.hypertables "
|
|
18
|
-
"WHERE hypertable_name = :name"
|
|
18
|
+
"WHERE hypertable_schema = :schema AND hypertable_name = :name"
|
|
19
19
|
),
|
|
20
|
-
{"name": table_name},
|
|
20
|
+
{"schema": schema, "name": table_name},
|
|
21
21
|
).scalar()
|
|
22
22
|
)
|
|
23
23
|
except ProgrammingError:
|
|
@@ -50,12 +50,22 @@ def hypertable_operations(
|
|
|
50
50
|
table for table in metadata.tables.values() if table.info.get(HYPERTABLE_KEY) is not None
|
|
51
51
|
]
|
|
52
52
|
if engine is not None and pending:
|
|
53
|
+
default_schema = engine.dialect.default_schema_name or "public"
|
|
53
54
|
with engine.connect() as conn:
|
|
54
|
-
pending = [
|
|
55
|
+
pending = [
|
|
56
|
+
t for t in pending if not _is_hypertable(conn, t.schema or default_schema, t.name)
|
|
57
|
+
]
|
|
55
58
|
ops: list[PlannedOperation] = []
|
|
56
59
|
for table in pending:
|
|
57
60
|
info = table.info[HYPERTABLE_KEY]
|
|
58
|
-
|
|
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)
|
|
59
69
|
time_column = _lit(info.time_column)
|
|
60
70
|
parts = [f"SELECT create_hypertable('{name}', '{time_column}'"]
|
|
61
71
|
if info.chunk_time_interval:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|