confluent-sql 0.5.0__tar.gz → 0.5.2__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.
Files changed (67) hide show
  1. {confluent_sql-0.5.0 → confluent_sql-0.5.2}/CHANGELOG.md +15 -0
  2. {confluent_sql-0.5.0 → confluent_sql-0.5.2}/PKG-INFO +2 -2
  3. {confluent_sql-0.5.0 → confluent_sql-0.5.2}/STREAMING.md +1 -1
  4. {confluent_sql-0.5.0 → confluent_sql-0.5.2}/pyproject.toml +1 -1
  5. {confluent_sql-0.5.0 → confluent_sql-0.5.2}/src/confluent_sql/changelog_compressor.py +58 -128
  6. {confluent_sql-0.5.0 → confluent_sql-0.5.2}/src/confluent_sql/statement.py +54 -4
  7. {confluent_sql-0.5.0 → confluent_sql-0.5.2}/tests/integration/test_cursor.py +362 -5
  8. {confluent_sql-0.5.0 → confluent_sql-0.5.2}/tests/integration/test_fetch.py +128 -1
  9. {confluent_sql-0.5.0 → confluent_sql-0.5.2}/tests/unit/test_changelog_compressor_unit.py +335 -330
  10. {confluent_sql-0.5.0 → confluent_sql-0.5.2}/tests/unit/test_statement_unit.py +52 -3
  11. {confluent_sql-0.5.0 → confluent_sql-0.5.2}/uv.lock +1 -1
  12. {confluent_sql-0.5.0 → confluent_sql-0.5.2}/.github/CODEOWNERS +0 -0
  13. {confluent_sql-0.5.0 → confluent_sql-0.5.2}/.gitignore +0 -0
  14. {confluent_sql-0.5.0 → confluent_sql-0.5.2}/.semaphore/publish_to_pypi.yml +0 -0
  15. {confluent_sql-0.5.0 → confluent_sql-0.5.2}/.semaphore/semaphore.yml +0 -0
  16. {confluent_sql-0.5.0 → confluent_sql-0.5.2}/ARCHITECTURE.md +0 -0
  17. {confluent_sql-0.5.0 → confluent_sql-0.5.2}/CLAUDE.md +0 -0
  18. {confluent_sql-0.5.0 → confluent_sql-0.5.2}/DBAPI_EXTENSIONS.md +0 -0
  19. {confluent_sql-0.5.0 → confluent_sql-0.5.2}/LICENSE.txt +0 -0
  20. {confluent_sql-0.5.0 → confluent_sql-0.5.2}/Makefile +0 -0
  21. {confluent_sql-0.5.0 → confluent_sql-0.5.2}/README.md +0 -0
  22. {confluent_sql-0.5.0 → confluent_sql-0.5.2}/TYPES.md +0 -0
  23. {confluent_sql-0.5.0 → confluent_sql-0.5.2}/examples/byoidc_bearer_token_example.py +0 -0
  24. {confluent_sql-0.5.0 → confluent_sql-0.5.2}/examples/errors.py +0 -0
  25. {confluent_sql-0.5.0 → confluent_sql-0.5.2}/examples/simple_append_only_streaming_query_example.py +0 -0
  26. {confluent_sql-0.5.0 → confluent_sql-0.5.2}/examples/snapshot_mode_tuple_cursor_simple_example.py +0 -0
  27. {confluent_sql-0.5.0 → confluent_sql-0.5.2}/examples/statement_properties_example.py +0 -0
  28. {confluent_sql-0.5.0 → confluent_sql-0.5.2}/examples/tableflow_lifecycle_example.py +0 -0
  29. {confluent_sql-0.5.0 → confluent_sql-0.5.2}/service.yml +0 -0
  30. {confluent_sql-0.5.0 → confluent_sql-0.5.2}/src/confluent_sql/__init__.py +0 -0
  31. {confluent_sql-0.5.0 → confluent_sql-0.5.2}/src/confluent_sql/__version__.py +0 -0
  32. {confluent_sql-0.5.0 → confluent_sql-0.5.2}/src/confluent_sql/auth.py +0 -0
  33. {confluent_sql-0.5.0 → confluent_sql-0.5.2}/src/confluent_sql/connection.py +0 -0
  34. {confluent_sql-0.5.0 → confluent_sql-0.5.2}/src/confluent_sql/connectors.py +0 -0
  35. {confluent_sql-0.5.0 → confluent_sql-0.5.2}/src/confluent_sql/cursor.py +0 -0
  36. {confluent_sql-0.5.0 → confluent_sql-0.5.2}/src/confluent_sql/exceptions.py +0 -0
  37. {confluent_sql-0.5.0 → confluent_sql-0.5.2}/src/confluent_sql/execution_mode.py +0 -0
  38. {confluent_sql-0.5.0 → confluent_sql-0.5.2}/src/confluent_sql/polling.py +0 -0
  39. {confluent_sql-0.5.0 → confluent_sql-0.5.2}/src/confluent_sql/result_readers.py +0 -0
  40. {confluent_sql-0.5.0 → confluent_sql-0.5.2}/src/confluent_sql/retry.py +0 -0
  41. {confluent_sql-0.5.0 → confluent_sql-0.5.2}/src/confluent_sql/statement_properties.py +0 -0
  42. {confluent_sql-0.5.0 → confluent_sql-0.5.2}/src/confluent_sql/tableflow.py +0 -0
  43. {confluent_sql-0.5.0 → confluent_sql-0.5.2}/src/confluent_sql/types.py +0 -0
  44. {confluent_sql-0.5.0 → confluent_sql-0.5.2}/tests/__init__.py +0 -0
  45. {confluent_sql-0.5.0 → confluent_sql-0.5.2}/tests/conftest.py +0 -0
  46. {confluent_sql-0.5.0 → confluent_sql-0.5.2}/tests/integration/conftest.py +0 -0
  47. {confluent_sql-0.5.0 → confluent_sql-0.5.2}/tests/integration/test_connection.py +0 -0
  48. {confluent_sql-0.5.0 → confluent_sql-0.5.2}/tests/integration/test_connector.py +0 -0
  49. {confluent_sql-0.5.0 → confluent_sql-0.5.2}/tests/integration/test_tableflow.py +0 -0
  50. {confluent_sql-0.5.0 → confluent_sql-0.5.2}/tests/unit/conftest.py +0 -0
  51. {confluent_sql-0.5.0 → confluent_sql-0.5.2}/tests/unit/test_auth_unit.py +0 -0
  52. {confluent_sql-0.5.0 → confluent_sql-0.5.2}/tests/unit/test_changelog_unit.py +0 -0
  53. {confluent_sql-0.5.0 → confluent_sql-0.5.2}/tests/unit/test_connection_byoidc_unit.py +0 -0
  54. {confluent_sql-0.5.0 → confluent_sql-0.5.2}/tests/unit/test_connection_connector_unit.py +0 -0
  55. {confluent_sql-0.5.0 → confluent_sql-0.5.2}/tests/unit/test_connection_unit.py +0 -0
  56. {confluent_sql-0.5.0 → confluent_sql-0.5.2}/tests/unit/test_connection_unit_properties.py +0 -0
  57. {confluent_sql-0.5.0 → confluent_sql-0.5.2}/tests/unit/test_connectors_connection_unit.py +0 -0
  58. {confluent_sql-0.5.0 → confluent_sql-0.5.2}/tests/unit/test_connectors_unit.py +0 -0
  59. {confluent_sql-0.5.0 → confluent_sql-0.5.2}/tests/unit/test_cursor_unit.py +0 -0
  60. {confluent_sql-0.5.0 → confluent_sql-0.5.2}/tests/unit/test_execution_mode_unit.py +0 -0
  61. {confluent_sql-0.5.0 → confluent_sql-0.5.2}/tests/unit/test_polling_unit.py +0 -0
  62. {confluent_sql-0.5.0 → confluent_sql-0.5.2}/tests/unit/test_result_readers_unit.py +0 -0
  63. {confluent_sql-0.5.0 → confluent_sql-0.5.2}/tests/unit/test_retry_unit.py +0 -0
  64. {confluent_sql-0.5.0 → confluent_sql-0.5.2}/tests/unit/test_statement_properties_unit.py +0 -0
  65. {confluent_sql-0.5.0 → confluent_sql-0.5.2}/tests/unit/test_tableflow_connection_unit.py +0 -0
  66. {confluent_sql-0.5.0 → confluent_sql-0.5.2}/tests/unit/test_tableflow_unit.py +0 -0
  67. {confluent_sql-0.5.0 → confluent_sql-0.5.2}/tests/unit/test_types_unit.py +0 -0
@@ -2,6 +2,21 @@
2
2
 
3
3
  All notable changes to this dbapi driver will be documented in this file.
4
4
 
5
+ ## 0.5.2, 2026-08-26
6
+
7
+ ### Fixed
8
+
9
+ - `Statement.is_pure_ddl` / `_PURE_DDL_KINDS` now include `CREATE_MATERIALIZED_TABLE`, `CREATE_OR_ALTER_MATERIALIZED_TABLE`, `ALTER_MATERIALIZED_TABLE` (the bare, query-evolving form), and `DROP_MATERIALIZED_TABLE`.
10
+ - Previously these kinds weren't classified as pure or impure DDL, so in **streaming** execution mode, `Statement.can_fetch_results` fell through to the "ready when RUNNING" branch used for CTAS -- meaning a streaming-mode `Cursor.execute()` (and therefore `Connection.execute_streaming_ddl()`) on one of these statements could in principle return control as soon as the statement was RUNNING rather than waiting for it to reach a terminal phase, the same way `execute_streaming_ddl()` correctly waits out a genuinely perpetual CTAS job's RUNNING phase. Snapshot execution mode (the default, and what `execute_snapshot_ddl()` always uses) was unaffected -- it already waits for a terminal phase regardless of `is_pure_ddl`.
11
+
12
+ ## 0.5.1, 2026-08-25
13
+
14
+ ### Fixed
15
+
16
+ - Streaming changelog compressor fixes for nontrivial changelog streams written to Kafka topics with multiple partitions:
17
+ - The changelog compressor for streaming non-append-only queries **without** upsert columns (`cursor.changelog_compressor()` on a keyless result, e.g. a global aggregation) no longer raises `InterfaceError` on legitimately out-of-order changelog events. When such a changelog is sinked to a multi-partition keyless topic, partitions are assigned by whole-row hash, so an updated row's `+U`/`-D` spelling can land on a different partition than its original `+I`/`-U` spelling; since Kafka only guarantees ordering within a partition, the events can be observed in a surprising order across spellings (a `+U` before its logical `-U`, or a `-D` before a later `+I`). The compressor previously assumed each `UPDATE_BEFORE` was immediately followed by its `UPDATE_AFTER` and failed on those sequences. It now treats the two additive ops (`+I`, `+U`) as inserts and the two retracting ops (`-U`, `-D`) as deletions, makes no ordering assumptions across different rows' spellings, and converges to the correct result set (intermediate snapshots may transiently show an extra row). Adds `Op.treat_as_insert` / `Op.treat_as_delete` helper properties. (#184)
18
+ - The changelog compressor for streaming non-append-only queries **with** upsert columns (`cursor.changelog_compressor()` on a keyed result, e.g. a `GROUP BY`) no longer raises `InterfaceError` when an unrelated key's changelog event arrives between one key's `UPDATE_BEFORE` and its own `UPDATE_AFTER`. A consumer reading a keyed upsert topic drains multiple partitions per poll; same-key events stay ordered (a key always hashes to the same partition), but a single fetch batch can still interleave *different* keys' events. The compressor previously modeled `UPDATE_BEFORE`/`UPDATE_AFTER` as a single-slot pending pair that had to be adjacent, and failed on that interleaving even though nothing was actually wrong. `UPDATE_BEFORE` is now treated as an unconditional no-op (it carries no information the matching insert/update doesn't already supply under key-based upsert semantics), and `UPDATE_AFTER` is handled exactly like `INSERT` (last write for the key wins); `DELETE` is unaffected and still validates that the key exists. As part of the same cleanup, the now-unused pending-update tracking hooks are removed from the shared compressor base class. (#185)
19
+
5
20
  ## 0.5.0, 2026-08-07
6
21
 
7
22
  ### Added
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: confluent-sql
3
- Version: 0.5.0
3
+ Version: 0.5.2
4
4
  Summary: DB-API v2 compliant driver for Confluent Cloud Flink SQL
5
5
  Project-URL: Repository, https://github.com/confluentinc/confluent-sql
6
6
  Project-URL: Documentation, https://github.com/confluentinc/confluent-sql?tab=readme-ov-file#confluent-sql
@@ -376,7 +376,7 @@ A **snapshot of results** is a self-consistent, complete result set showing the
376
376
 
377
377
  - **Current State of All Rows**: Built from all INSERT/UPDATE/DELETE operations processed so far
378
378
  - **Point-in-Time View**: Represents state after consuming all available changelog events at that moment
379
- - **Self-Consistent**: No pending operations awaiting completion (all UPDATE_BEFORE/UPDATE_AFTER pairs have been applied)
379
+ - **Self-Consistent**: Reflects every changelog event fetched so far -- there's nothing still in flight waiting on a match
380
380
  - **Complete Result Set, Not Incremental**: Each snapshot is the full accumulated result set at this moment in time, not just the changes since the last snapshot
381
381
 
382
382
  Don't think of snapshots of results as collections of individual changelog events. Instead, think of them as complete result sets:
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "confluent-sql"
7
- version = "0.5.0"
7
+ version = "0.5.2"
8
8
  description = "DB-API v2 compliant driver for Confluent Cloud Flink SQL"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -144,39 +144,11 @@ class ChangelogCompressor(abc.ABC):
144
144
  """
145
145
  ...
146
146
 
147
- @abc.abstractmethod
148
- def _has_pending_update(self) -> bool:
149
- """Check if there's a pending UPDATE_BEFORE awaiting UPDATE_AFTER.
150
-
151
- Returns:
152
- True if a pending update is in progress, False otherwise.
153
- """
154
- ...
155
-
156
147
  @abc.abstractmethod
157
148
  def _clear_storage(self) -> None:
158
149
  """Clear internal row storage."""
159
150
  ...
160
151
 
161
- @abc.abstractmethod
162
- def _clear_pending_update(self) -> None:
163
- """Clear pending update tracking state."""
164
- ...
165
-
166
- def _validate_no_pending_update(self, op: Op, row: ResultTupleOrDict) -> None:
167
- """Raise if we have a pending update and aren't processing UPDATE_AFTER.
168
-
169
- Args:
170
- op: The changelog operation being processed.
171
- row: The row data.
172
-
173
- Raises:
174
- InterfaceError: If a pending UPDATE_BEFORE exists while processing a
175
- non-UPDATE_AFTER operation.
176
- """
177
- if op != Op.UPDATE_AFTER and self._has_pending_update():
178
- raise InterfaceError(f"Received {op.name} while an UPDATE_BEFORE is pending: {row}")
179
-
180
152
  def _resolve_batchsize(self, fetch_batchsize: int | None) -> int:
181
153
  """Resolve and validate the batch size to use for fetching.
182
154
 
@@ -219,8 +191,7 @@ class ChangelogCompressor(abc.ABC):
219
191
 
220
192
  **Self-Consistency**: A snapshot is considered self-consistent when all currently
221
193
  available changelog events have been consumed and applied. This means the snapshot
222
- reflects a coherent state with no pending UPDATE_BEFORE operations awaiting their
223
- matching UPDATE_AFTER.
194
+ reflects every event fetched so far; nothing is retained awaiting a match.
224
195
 
225
196
  **No Guarantee of Logical Changes**: There is NO guarantee that consecutive snapshots
226
197
  will differ. If no new changelog events arrived since the prior yield, the snapshot
@@ -298,8 +269,8 @@ class ChangelogCompressor(abc.ABC):
298
269
  - Is non-blocking - returns immediately after consuming available events
299
270
 
300
271
  **Self-Consistency**: The returned snapshot is self-consistent, meaning all currently
301
- available changelog events have been consumed and applied. No pending UPDATE_BEFORE
302
- operations remain without their matching UPDATE_AFTER.
272
+ available changelog events have been consumed and applied. It reflects every event
273
+ fetched so far; nothing is retained awaiting a match.
303
274
 
304
275
  **Deep Copy**: The returned snapshot is a deep copy. Mutations will not affect the
305
276
  compressor's internal state.
@@ -348,7 +319,6 @@ class ChangelogCompressor(abc.ABC):
348
319
  After calling close(), the compressor should not be used anymore.
349
320
  """
350
321
  self._clear_storage()
351
- self._clear_pending_update()
352
322
  self._cursor.close()
353
323
 
354
324
 
@@ -357,8 +327,19 @@ class UpsertColumnsCompressor(ChangelogCompressor):
357
327
 
358
328
  Uses dict-based storage for fast O(1) key-based lookups.
359
329
 
360
- Expects UPDATE_BEFORE to be immediately followed by its matching UPDATE_AFTER event.
361
- Only one pending update is tracked at a time.
330
+ The Confluent-cloud-side Kafka consumer reading a keyed upsert topic may be draining multiple
331
+ partitions per poll. Same-key events stay ordered (a key always hashes to the same partition),
332
+ but a single fetchmany() batch can still interleave *different* keys' events, so a key's
333
+ UPDATE_BEFORE need not be immediately followed by that same key's UPDATE_AFTER -- an unrelated
334
+ key's event can land in between. See issue #185.
335
+
336
+ To stay correct under that interleaving, this compressor tracks no pending-update state:
337
+ UPDATE_BEFORE (-U) is treated as a pure no-op, since under key-based upsert semantics it
338
+ carries no information the matching INSERT/UPDATE_AFTER doesn't already supply. UPDATE_AFTER
339
+ (+U) is handled exactly like INSERT -- both simply upsert the row for its key, last write
340
+ wins. DELETE is unaffected by this and still validates that the key exists: same-key ops stay
341
+ ordered relative to each other, so a DELETE for an untracked key remains a genuine protocol
342
+ violation worth surfacing.
362
343
 
363
344
  Rows can be either tuples or dicts (as determined by cursor.as_dict). The row format
364
345
  decision is made by the result reader layer, and this compressor works transparently
@@ -374,9 +355,6 @@ class UpsertColumnsCompressor(ChangelogCompressor):
374
355
  _rows_by_key: dict[tuple, ResultTupleOrDict]
375
356
  """Dictionary mapping key tuples to row data. Dict maintains insertion order in Python 3.7+."""
376
357
 
377
- _expecting_update_after: bool
378
- """True when UPDATE_BEFORE has been received and UPDATE_AFTER is expected next."""
379
-
380
358
  def __init__(self, cursor: Cursor, statement: Statement):
381
359
  """Initialize the compressor with upsert column indices.
382
360
 
@@ -400,7 +378,6 @@ class UpsertColumnsCompressor(ChangelogCompressor):
400
378
  ]
401
379
 
402
380
  self._rows_by_key = {}
403
- self._expecting_update_after = False
404
381
 
405
382
  def _extract_key(self, row: ResultTupleOrDict) -> tuple:
406
383
  """Extract the key tuple from a row based on upsert columns.
@@ -421,22 +398,10 @@ class UpsertColumnsCompressor(ChangelogCompressor):
421
398
  # Tuple case: use direct index access
422
399
  return tuple(row[i] for i in self._upsert_column_indices)
423
400
 
424
- def _has_pending_update(self) -> bool:
425
- """Check if there's a pending UPDATE_BEFORE awaiting UPDATE_AFTER.
426
-
427
- Returns:
428
- True if a pending update is in progress, False otherwise.
429
- """
430
- return self._expecting_update_after
431
-
432
401
  def _clear_storage(self) -> None:
433
402
  """Clear internal row storage."""
434
403
  self._rows_by_key.clear()
435
404
 
436
- def _clear_pending_update(self) -> None:
437
- """Clear pending update tracking state."""
438
- self._expecting_update_after = False
439
-
440
405
  def _apply_operation(self, op: Op, row: ResultTupleOrDict) -> None:
441
406
  """Apply a changelog operation to the internal state.
442
407
 
@@ -444,42 +409,23 @@ class UpsertColumnsCompressor(ChangelogCompressor):
444
409
  op: The changelog operation.
445
410
  row: The row data.
446
411
  """
447
- self._validate_no_pending_update(op, row)
412
+ if op == Op.UPDATE_BEFORE:
413
+ # No-op: under key-based upsert semantics a retraction carries no information the
414
+ # matching INSERT/UPDATE_AFTER doesn't already supply. See issue #185.
415
+ return
448
416
 
449
417
  key = self._extract_key(row)
450
418
 
451
- if op == Op.INSERT:
452
- # When iterating _rows_by_keys in get_snapshot(), this newly inserted key will be
453
- # at the end of the dict, so insertion order will be maintained.
454
- self._rows_by_key[key] = row
455
-
456
- elif op == Op.UPDATE_BEFORE:
457
- # Raise if we receive an UPDATE_BEFORE for a key that doesn't exist in current state
458
- if key not in self._rows_by_key:
459
- raise InterfaceError(
460
- f"Received UPDATE_BEFORE for a key that does not exist in current state: {key}"
461
- )
462
- # Mark that we're expecting UPDATE_AFTER next
463
- self._expecting_update_after = True
464
-
465
- elif op == Op.UPDATE_AFTER:
466
- # May or may not have gotten a preceding UPDATE_BEFORE.
467
- # In either case, verify the key exists in current state
468
- if key not in self._rows_by_key:
469
- raise InterfaceError(
470
- f"Received UPDATE_AFTER for a key that does not exist in current state: {key}"
471
- )
472
-
473
- # Update the row
419
+ if op.treat_as_insert: # INSERT or UPDATE_AFTER: last write for this key wins
420
+ # When iterating _rows_by_key in get_snapshot(), an upsert for a brand-new key will
421
+ # be at the end of the dict, so insertion order is maintained.
474
422
  self._rows_by_key[key] = row
475
- self._expecting_update_after = False
476
423
 
477
- elif op == Op.DELETE:
478
- if key not in self._rows_by_key:
479
- raise InterfaceError(
480
- f"Received DELETE for a key that does not exist in current state: {key}"
481
- )
482
- del self._rows_by_key[key]
424
+ elif self._rows_by_key.pop(key, None) is None: # DELETE
425
+ # Wacky, the delete is for a key that doesn't exist in current state!
426
+ raise InterfaceError(
427
+ f"Received DELETE for a key that does not exist in current state: {key}"
428
+ )
483
429
 
484
430
  def _copy_accumulated_rows(self) -> list[ResultTupleOrDict]:
485
431
  """Return deep copy of rows from dict storage in insertion order.
@@ -495,19 +441,34 @@ class NoUpsertColumnsCompressor(ChangelogCompressor):
495
441
 
496
442
  Uses list-based storage with linear scan for row matching.
497
443
 
498
- Expects UPDATE_BEFORE to be immediately followed by its matching UPDATE_AFTER event.
499
- Only one pending update is tracked at a time.
444
+ Without an upsert key, a row can only be identified by its full-row spelling, and the
445
+ changelog reaches this client via a multi-partition keyless sink that partitions by
446
+ whole-row hash. An updated row's +U/-D spelling therefore hashes to a potentially
447
+ different partition than its original +I/-U spelling, and since Kafka only guarantees
448
+ order within a partition, events can be observed in a surprising order *across* spellings
449
+ (e.g. a +U before its logical -U, or a -D before a later +I). See issue #184.
450
+
451
+ To stay correct under that skew this compressor makes no ordering assumptions *across
452
+ different spellings* and holds no pending-update state (see below for the one ordering
453
+ guarantee it does rely on). It collapses the four ops to two: the additive ops (+I, +U;
454
+ Op.treat_as_insert) append a row spelling, and the retracting ops (-U, -D;
455
+ Op.treat_as_delete) remove one occurrence of a row spelling. The result set is eventually
456
+ consistent -- intermediate points in time may transiently show extra rows, but once every
457
+ event has arrived the set converges to the correct contents.
458
+
459
+ A retraction for a spelling never arrives before the matching insert for that same
460
+ spelling: identical whole-row values always hash to the same partition, which preserves
461
+ their relative order. `_find_row_position` therefore always finds its match on a
462
+ well-formed stream; its raise is retained only to surface a genuine protocol violation.
500
463
 
501
464
  Rows can be either tuples or dicts (as determined by cursor.as_dict). Row matching
502
465
  is performed by equality comparison, which works identically for both tuple and dict.
503
466
  """
504
467
 
505
468
  _rows: list[ResultTupleOrDict]
506
- """List of rows maintaining insertion order. Scanned linearly for matching."""
507
-
508
- _pending_update_position: int | None
509
- """Position of the row marked by the most recent UPDATE_BEFORE, awaiting UPDATE_AFTER.
510
- None when no UPDATE_BEFORE is pending."""
469
+ """List of row spellings, ordered by when each op was applied. Scanned linearly for
470
+ matching. Not a stable per-row order: an update's new spelling is appended anew, not
471
+ repositioned at its old spelling's spot."""
511
472
 
512
473
  def __init__(self, cursor: Cursor, statement: Statement):
513
474
  """Initialize the compressor.
@@ -518,24 +479,11 @@ class NoUpsertColumnsCompressor(ChangelogCompressor):
518
479
  """
519
480
  super().__init__(cursor, statement)
520
481
  self._rows = []
521
- self._pending_update_position = None
522
-
523
- def _has_pending_update(self) -> bool:
524
- """Check if there's a pending UPDATE_BEFORE awaiting UPDATE_AFTER.
525
-
526
- Returns:
527
- True if a pending update is in progress, False otherwise.
528
- """
529
- return self._pending_update_position is not None
530
482
 
531
483
  def _clear_storage(self) -> None:
532
484
  """Clear internal row storage."""
533
485
  self._rows.clear()
534
486
 
535
- def _clear_pending_update(self) -> None:
536
- """Clear pending update tracking state."""
537
- self._pending_update_position = None
538
-
539
487
  def _find_row_position(self, row: ResultTupleOrDict, operation: Op) -> int:
540
488
  """Find the position of a matching row by scanning backwards.
541
489
 
@@ -562,37 +510,19 @@ class NoUpsertColumnsCompressor(ChangelogCompressor):
562
510
  def _apply_operation(self, op: Op, row: ResultTupleOrDict) -> None:
563
511
  """Apply a changelog operation to the internal state.
564
512
 
513
+ Additive ops (+I, +U) append the row; retracting ops (-U, -D) remove the most recent
514
+ occurrence of a matching row. No ordering is assumed across *different* rows' ops --
515
+ but a row's own retracting op is assumed to arrive after its own matching additive op
516
+ (see class docstring); `_find_row_position` raises otherwise.
517
+
565
518
  Args:
566
519
  op: The changelog operation.
567
520
  row: The row data.
568
521
  """
569
- self._validate_no_pending_update(op, row)
570
-
571
- if op == Op.INSERT:
522
+ if op.treat_as_insert:
572
523
  self._rows.append(row)
573
-
574
- elif op == Op.UPDATE_BEFORE:
575
- # Find row position (raises InterfaceError if not found)
576
- pos = self._find_row_position(row, Op.UPDATE_BEFORE)
577
- # Record position for pending update (expecting matching UPDATE_AFTER next)
578
- self._pending_update_position = pos
579
-
580
- elif op == Op.UPDATE_AFTER:
581
- # MUST have gotten a preceding UPDATE_BEFORE.
582
- # (Without upsert columns, we can't identify the row to update from the
583
- # UPDATE_AFTER row alone, since the row content has changed.)
584
- if self._pending_update_position is None:
585
- raise InterfaceError(
586
- f"Received UPDATE_AFTER without a preceding UPDATE_BEFORE: {row}"
587
- )
588
-
589
- self._rows[self._pending_update_position] = row
590
- self._pending_update_position = None
591
-
592
- elif op == Op.DELETE:
593
- # Find row position (raises InterfaceError if not found)
594
- pos = self._find_row_position(row, Op.DELETE)
595
- del self._rows[pos]
524
+ else: # op.treat_as_delete
525
+ del self._rows[self._find_row_position(row, op)]
596
526
 
597
527
  def _copy_accumulated_rows(self) -> list[ResultTupleOrDict]:
598
528
  """Return deep copy of rows from list storage.
@@ -75,6 +75,25 @@ class Op(Enum):
75
75
  String representation: -D
76
76
  """
77
77
 
78
+ @property
79
+ def treat_as_insert(self) -> bool:
80
+ """True for the two additive changelog ops: INSERT (+I) and UPDATE_AFTER (+U).
81
+
82
+ Both add a row spelling to the result set. Consumers that only care whether an event
83
+ grows or shrinks the result set (e.g. a keyless compressor that cannot pair
84
+ UPDATE_BEFORE with UPDATE_AFTER) can collapse the four ops to this insert/delete
85
+ dichotomy. Every Op is exactly one of treat_as_insert or treat_as_delete.
86
+ """
87
+ return self in (Op.INSERT, Op.UPDATE_AFTER)
88
+
89
+ @property
90
+ def treat_as_delete(self) -> bool:
91
+ """True for the two retracting changelog ops: UPDATE_BEFORE (-U) and DELETE (-D).
92
+
93
+ Both remove a row spelling from the result set. See treat_as_insert for the rationale.
94
+ """
95
+ return self in (Op.UPDATE_BEFORE, Op.DELETE)
96
+
78
97
  def __str__(self):
79
98
  if self is self.INSERT:
80
99
  return "+I"
@@ -151,12 +170,30 @@ class Statement:
151
170
  """Represents a Confluent SQL statement, including its metadata, spec, status,
152
171
  and parsed traits such as schema, sql kind, etc."""
153
172
 
154
- # SQL kinds that represent pure DDL statements (create/modify schema objects)
173
+ # SQL kinds that represent pure DDL statements (create/modify schema objects) which should
174
+ # be waiting for a terminal state before the created/modified objects are usable.
155
175
  _PURE_DDL_KINDS = frozenset(
156
- {"CREATE_TABLE", "DROP_TABLE", "CREATE_VIEW", "DROP_VIEW", "ALTER_TABLE"}
176
+ {
177
+ "CREATE_TABLE",
178
+ "DROP_TABLE",
179
+ "CREATE_VIEW",
180
+ "DROP_VIEW",
181
+ "ALTER_TABLE",
182
+ # The four MATERIALIZED_TABLE kinds stretch "pure" a little: unlike the other
183
+ # members, CREATE_MATERIALIZED_TABLE, CREATE_OR_ALTER_MATERIALIZED_TABLE, and
184
+ # ALTER_MATERIALIZED_TABLE (a query-evolving ALTER, not just a metadata-only one)
185
+ # also kick off (or redeploy) a persistent background refresh job that keeps running
186
+ # long after this statement settles. But they still will reliably reach terminal
187
+ # COMPLETED (or FAILED) phase independent of the background job, which is the one
188
+ # thing this property actually gates.
189
+ "CREATE_MATERIALIZED_TABLE",
190
+ "CREATE_OR_ALTER_MATERIALIZED_TABLE",
191
+ "ALTER_MATERIALIZED_TABLE",
192
+ "DROP_MATERIALIZED_TABLE",
193
+ }
157
194
  )
158
195
 
159
- # SQL kinds that represent impure DDL (produce no result set but may stream)
196
+ # SQL kinds that represent impure DDL (produce no result set but will remain in RUNNING phase)
160
197
  _IMPURE_DDL_KINDS = frozenset({"CREATE_TABLE_AS"})
161
198
 
162
199
  # From the cursor that created this statement ...
@@ -317,9 +354,22 @@ class Statement:
317
354
  Pure DDL statements need to complete fully before the created/modified objects
318
355
  are ready for use, unlike streaming queries or CTAS which are ready when RUNNING.
319
356
 
357
+ "Pure" is stretched a little to also cover CREATE_MATERIALIZED_TABLE,
358
+ CREATE_OR_ALTER_MATERIALIZED_TABLE, and ALTER_MATERIALIZED_TABLE (the query-evolving
359
+ form -- see CREATE_OR_ALTER_MATERIALIZED_TABLE): their completion additionally kicks off
360
+ (or redeploys) a persistent background refresh job that keeps running long after the
361
+ statement settles, which isn't true of the other members. They earn the label anyway
362
+ because they satisfy the one thing this property actually gates: the statement's own
363
+ phase reliably reaches a terminal phase before the created/altered object is usable,
364
+ rather than lingering in RUNNING the way CTAS does (see _PURE_DDL_KINDS for the
365
+ confirming detail). DROP_MATERIALIZED_TABLE needs no such stretch -- like the other
366
+ DROP_* kinds, it's a one-shot action with no background job of its own.
367
+
320
368
  Returns:
321
369
  True if the statement is one of: CREATE_TABLE, DROP_TABLE, CREATE_VIEW,
322
- DROP_VIEW, ALTER_TABLE. False otherwise.
370
+ DROP_VIEW, ALTER_TABLE, CREATE_MATERIALIZED_TABLE,
371
+ CREATE_OR_ALTER_MATERIALIZED_TABLE, ALTER_MATERIALIZED_TABLE,
372
+ DROP_MATERIALIZED_TABLE. False otherwise.
323
373
  """
324
374
  return self.sql_kind in self._PURE_DDL_KINDS
325
375