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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sqlpush
3
- Version: 0.2.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.2.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.2.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
- """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."""
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 = [t for t in pending if not _is_hypertable(conn, t.name)]
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
- name = _lit(table.name)
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