sqlpush 0.4.0__py3-none-any.whl → 0.4.1__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/annotations.py CHANGED
@@ -12,12 +12,78 @@ class HypertableInfo:
12
12
  chunk_time_interval: str | None = None
13
13
 
14
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
+
15
73
  def hypertable(*, time_column: str, chunk_time_interval: str | None = None):
16
- """Record hypertable intent on the model's Table (MetaData level).
74
+ """Record hypertable intent on the model's Table (dual-mechanism).
17
75
 
18
76
  Works with SQLModel, Flask-SQLAlchemy and plain declarative, anything
19
77
  whose class already carries a built ``__table__`` when decorated.
20
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
+
21
87
  The generated ``create_hypertable`` runs with
22
88
  ``create_default_indexes => false``: timescale's implicit time-column
23
89
  index is invisible to metadata and would drift forever. Declare it
@@ -29,9 +95,9 @@ def hypertable(*, time_column: str, chunk_time_interval: str | None = None):
29
95
  table = getattr(cls, "__table__", None)
30
96
  if table is None:
31
97
  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
- )
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)
35
101
  return cls
36
102
 
37
103
  return decorator
sqlpush/core/diff.py CHANGED
@@ -107,6 +107,12 @@ def _timescale_auto_indexes(conn) -> dict[str, set[str]]:
107
107
  ``a_b`` dim ``c`` both yield ``a_b_c_idx``) — a str value would
108
108
  last-win and leak the other as false drift. Qualified keys carry
109
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).
110
116
  """
111
117
  installed = conn.execute(
112
118
  text("SELECT 1 FROM pg_extension WHERE extname = 'timescaledb'")
@@ -222,6 +228,24 @@ def _make_include_name(schemas: frozenset[str], default_schema: str):
222
228
  return include_name
223
229
 
224
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
+
225
249
  def _make_include(
226
250
  schemas: frozenset[str],
227
251
  default_schema: str,
@@ -241,6 +265,11 @@ def _make_include(
241
265
  )
242
266
  if schema not in schemas or _is_system_schema(schema):
243
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}"
244
273
  if reflected and compare_to is None:
245
274
  # DB-only object: skip system tables. NB: alembic also
246
275
  # auto-excludes its own version table from autogen; the
@@ -251,16 +280,29 @@ def _make_include(
251
280
  # state no model declares. Qualified name AND owner set must
252
281
  # both match, so a same-named index on another table stays
253
282
  # real drift, as does one the metadata actually declares
254
- # (that arrives in the both-present branch above).
255
- parent = getattr(obj, "table", None)
256
- parent_table = getattr(parent, "name", None)
257
- parent_schema = getattr(parent, "schema", None) or default_schema
258
- qualified_index = f"{parent_schema}.{name}"
259
- qualified_parent = f"{parent_schema}.{parent_table}"
283
+ # (that arrives in the both-present branches below).
260
284
  if type_ == "index" and qualified_index in spatial_auto_indexes:
261
285
  return False
262
286
  owners = ts_auto_indexes.get(qualified_index, frozenset())
263
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
264
306
  return True
265
307
 
266
308
  return include_object
@@ -316,6 +358,93 @@ def _dedup_embedded_indexes(ops: list[PlannedOperation]) -> list[PlannedOperatio
316
358
  ]
317
359
 
318
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
+
319
448
  def _render_op_sql(op, engine: Engine) -> str:
320
449
  buf = io.StringIO()
321
450
  offline = MigrationContext.configure(
@@ -379,6 +508,7 @@ class DiffEngine:
379
508
  for op in _flatten(script.upgrade_ops.ops):
380
509
  ops.extend(self._translate(op, engine, exclude))
381
510
  ops = _dedup_embedded_indexes(ops)
511
+ ops = _dedup_enum_types(ops)
382
512
  return Plan(operations=tuple(ops))
383
513
 
384
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
  )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sqlpush
3
- Version: 0.4.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,5 +1,5 @@
1
1
  sqlpush/__init__.py,sha256=Px0jJFHbwwM1-UHtDwD3xQlRifEMW4StSXQs5DD9z6w,1697
2
- sqlpush/annotations.py,sha256=AKK4t30lRznTFUfDYaH9r8WslHzzVs57GNtZGHxS8LM,1222
2
+ sqlpush/annotations.py,sha256=xe5EoYPvraLJy-WvMkFK_zOIKquB3-VsrAzy6HE_hiA,4652
3
3
  sqlpush/api.py,sha256=Hm8YBiCEQ6GVI-p1HPe_2YJ0xoa4RnCtBucaxQgprII,8530
4
4
  sqlpush/apply/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
5
  sqlpush/apply/executor.py,sha256=-HgmscUO6M_xQ5HPBQhJkUo6XwEPPqBig7_G9aWQJAU,7242
@@ -9,12 +9,12 @@ sqlpush/chain/migrate.py,sha256=V2OG7C7BtPXcomTN7sDJSRrzKy0wN_eazq2u1-Ad41k,7313
9
9
  sqlpush/cli.py,sha256=xDyYqPgpkn2nh-SrCJ3iOL5UjCtW170UbRKa3X6xkvU,10175
10
10
  sqlpush/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
11
  sqlpush/core/classify.py,sha256=-HNPQ1Kfnr0MMYLoP26_VC14JjcQHravrbT7lPGqlIU,927
12
- sqlpush/core/diff.py,sha256=rJ_YjaxcCVYDb41Jd2tRizOyZ73eBUwFyNppYw9vgF8,18586
12
+ sqlpush/core/diff.py,sha256=bkaNBCitTzUBCGKonX-_u_CsUhFS9r2ivAiDJbeZtx8,23876
13
13
  sqlpush/core/render.py,sha256=9SNEyHOGmMxKUfIjvo3x0MNVQ6N2o2fl3noWT1EWO3M,609
14
14
  sqlpush/directives/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
15
- sqlpush/directives/timescale.py,sha256=o0WW9J32SYDLcGwzMAXpRcZ7XM0DXBvkoFz3MSJiAOI,3874
15
+ sqlpush/directives/timescale.py,sha256=W_Cd_stdhKZlzlMum7Bdzq99VfXOulYxq0B-sukz-xE,2645
16
16
  sqlpush/types.py,sha256=IMA1HRLIumat7tpt8Yscv-9g2U5U_--xxnfcf_pfkyo,2938
17
- sqlpush-0.4.0.dist-info/WHEEL,sha256=y6e-a5KI2W-qDAJKfh9Xr81bin8NgUdfWgz3VSXXpe4,80
18
- sqlpush-0.4.0.dist-info/entry_points.txt,sha256=VYha3PJSvklRgpcDAZYJdE66EXM79MSKQf0z8igyAOI,46
19
- sqlpush-0.4.0.dist-info/METADATA,sha256=zo2nJV5gBbPlAOCxvShKg_Xs4X87CD0ruTGOCH8Rzxs,8373
20
- sqlpush-0.4.0.dist-info/RECORD,,
17
+ sqlpush-0.4.1.dist-info/WHEEL,sha256=y6e-a5KI2W-qDAJKfh9Xr81bin8NgUdfWgz3VSXXpe4,80
18
+ sqlpush-0.4.1.dist-info/entry_points.txt,sha256=VYha3PJSvklRgpcDAZYJdE66EXM79MSKQf0z8igyAOI,46
19
+ sqlpush-0.4.1.dist-info/METADATA,sha256=sOurmy4zPSrgMqftJXcNLlDP7bS96_xEqbKSh3tT4fE,8373
20
+ sqlpush-0.4.1.dist-info/RECORD,,