confluent-sql 0.5.1__tar.gz → 0.5.3__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.1 → confluent_sql-0.5.3}/CHANGELOG.md +12 -1
  2. {confluent_sql-0.5.1 → confluent_sql-0.5.3}/PKG-INFO +1 -1
  3. {confluent_sql-0.5.1 → confluent_sql-0.5.3}/pyproject.toml +1 -1
  4. {confluent_sql-0.5.1 → confluent_sql-0.5.3}/src/confluent_sql/cursor.py +3 -2
  5. {confluent_sql-0.5.1 → confluent_sql-0.5.3}/src/confluent_sql/statement.py +44 -6
  6. {confluent_sql-0.5.1 → confluent_sql-0.5.3}/tests/integration/test_fetch.py +128 -1
  7. {confluent_sql-0.5.1 → confluent_sql-0.5.3}/tests/unit/test_cursor_unit.py +75 -1
  8. {confluent_sql-0.5.1 → confluent_sql-0.5.3}/tests/unit/test_statement_unit.py +73 -3
  9. {confluent_sql-0.5.1 → confluent_sql-0.5.3}/uv.lock +1 -1
  10. {confluent_sql-0.5.1 → confluent_sql-0.5.3}/.github/CODEOWNERS +0 -0
  11. {confluent_sql-0.5.1 → confluent_sql-0.5.3}/.gitignore +0 -0
  12. {confluent_sql-0.5.1 → confluent_sql-0.5.3}/.semaphore/publish_to_pypi.yml +0 -0
  13. {confluent_sql-0.5.1 → confluent_sql-0.5.3}/.semaphore/semaphore.yml +0 -0
  14. {confluent_sql-0.5.1 → confluent_sql-0.5.3}/ARCHITECTURE.md +0 -0
  15. {confluent_sql-0.5.1 → confluent_sql-0.5.3}/CLAUDE.md +0 -0
  16. {confluent_sql-0.5.1 → confluent_sql-0.5.3}/DBAPI_EXTENSIONS.md +0 -0
  17. {confluent_sql-0.5.1 → confluent_sql-0.5.3}/LICENSE.txt +0 -0
  18. {confluent_sql-0.5.1 → confluent_sql-0.5.3}/Makefile +0 -0
  19. {confluent_sql-0.5.1 → confluent_sql-0.5.3}/README.md +0 -0
  20. {confluent_sql-0.5.1 → confluent_sql-0.5.3}/STREAMING.md +0 -0
  21. {confluent_sql-0.5.1 → confluent_sql-0.5.3}/TYPES.md +0 -0
  22. {confluent_sql-0.5.1 → confluent_sql-0.5.3}/examples/byoidc_bearer_token_example.py +0 -0
  23. {confluent_sql-0.5.1 → confluent_sql-0.5.3}/examples/errors.py +0 -0
  24. {confluent_sql-0.5.1 → confluent_sql-0.5.3}/examples/simple_append_only_streaming_query_example.py +0 -0
  25. {confluent_sql-0.5.1 → confluent_sql-0.5.3}/examples/snapshot_mode_tuple_cursor_simple_example.py +0 -0
  26. {confluent_sql-0.5.1 → confluent_sql-0.5.3}/examples/statement_properties_example.py +0 -0
  27. {confluent_sql-0.5.1 → confluent_sql-0.5.3}/examples/tableflow_lifecycle_example.py +0 -0
  28. {confluent_sql-0.5.1 → confluent_sql-0.5.3}/service.yml +0 -0
  29. {confluent_sql-0.5.1 → confluent_sql-0.5.3}/src/confluent_sql/__init__.py +0 -0
  30. {confluent_sql-0.5.1 → confluent_sql-0.5.3}/src/confluent_sql/__version__.py +0 -0
  31. {confluent_sql-0.5.1 → confluent_sql-0.5.3}/src/confluent_sql/auth.py +0 -0
  32. {confluent_sql-0.5.1 → confluent_sql-0.5.3}/src/confluent_sql/changelog_compressor.py +0 -0
  33. {confluent_sql-0.5.1 → confluent_sql-0.5.3}/src/confluent_sql/connection.py +0 -0
  34. {confluent_sql-0.5.1 → confluent_sql-0.5.3}/src/confluent_sql/connectors.py +0 -0
  35. {confluent_sql-0.5.1 → confluent_sql-0.5.3}/src/confluent_sql/exceptions.py +0 -0
  36. {confluent_sql-0.5.1 → confluent_sql-0.5.3}/src/confluent_sql/execution_mode.py +0 -0
  37. {confluent_sql-0.5.1 → confluent_sql-0.5.3}/src/confluent_sql/polling.py +0 -0
  38. {confluent_sql-0.5.1 → confluent_sql-0.5.3}/src/confluent_sql/result_readers.py +0 -0
  39. {confluent_sql-0.5.1 → confluent_sql-0.5.3}/src/confluent_sql/retry.py +0 -0
  40. {confluent_sql-0.5.1 → confluent_sql-0.5.3}/src/confluent_sql/statement_properties.py +0 -0
  41. {confluent_sql-0.5.1 → confluent_sql-0.5.3}/src/confluent_sql/tableflow.py +0 -0
  42. {confluent_sql-0.5.1 → confluent_sql-0.5.3}/src/confluent_sql/types.py +0 -0
  43. {confluent_sql-0.5.1 → confluent_sql-0.5.3}/tests/__init__.py +0 -0
  44. {confluent_sql-0.5.1 → confluent_sql-0.5.3}/tests/conftest.py +0 -0
  45. {confluent_sql-0.5.1 → confluent_sql-0.5.3}/tests/integration/conftest.py +0 -0
  46. {confluent_sql-0.5.1 → confluent_sql-0.5.3}/tests/integration/test_connection.py +0 -0
  47. {confluent_sql-0.5.1 → confluent_sql-0.5.3}/tests/integration/test_connector.py +0 -0
  48. {confluent_sql-0.5.1 → confluent_sql-0.5.3}/tests/integration/test_cursor.py +0 -0
  49. {confluent_sql-0.5.1 → confluent_sql-0.5.3}/tests/integration/test_tableflow.py +0 -0
  50. {confluent_sql-0.5.1 → confluent_sql-0.5.3}/tests/unit/conftest.py +0 -0
  51. {confluent_sql-0.5.1 → confluent_sql-0.5.3}/tests/unit/test_auth_unit.py +0 -0
  52. {confluent_sql-0.5.1 → confluent_sql-0.5.3}/tests/unit/test_changelog_compressor_unit.py +0 -0
  53. {confluent_sql-0.5.1 → confluent_sql-0.5.3}/tests/unit/test_changelog_unit.py +0 -0
  54. {confluent_sql-0.5.1 → confluent_sql-0.5.3}/tests/unit/test_connection_byoidc_unit.py +0 -0
  55. {confluent_sql-0.5.1 → confluent_sql-0.5.3}/tests/unit/test_connection_connector_unit.py +0 -0
  56. {confluent_sql-0.5.1 → confluent_sql-0.5.3}/tests/unit/test_connection_unit.py +0 -0
  57. {confluent_sql-0.5.1 → confluent_sql-0.5.3}/tests/unit/test_connection_unit_properties.py +0 -0
  58. {confluent_sql-0.5.1 → confluent_sql-0.5.3}/tests/unit/test_connectors_connection_unit.py +0 -0
  59. {confluent_sql-0.5.1 → confluent_sql-0.5.3}/tests/unit/test_connectors_unit.py +0 -0
  60. {confluent_sql-0.5.1 → confluent_sql-0.5.3}/tests/unit/test_execution_mode_unit.py +0 -0
  61. {confluent_sql-0.5.1 → confluent_sql-0.5.3}/tests/unit/test_polling_unit.py +0 -0
  62. {confluent_sql-0.5.1 → confluent_sql-0.5.3}/tests/unit/test_result_readers_unit.py +0 -0
  63. {confluent_sql-0.5.1 → confluent_sql-0.5.3}/tests/unit/test_retry_unit.py +0 -0
  64. {confluent_sql-0.5.1 → confluent_sql-0.5.3}/tests/unit/test_statement_properties_unit.py +0 -0
  65. {confluent_sql-0.5.1 → confluent_sql-0.5.3}/tests/unit/test_tableflow_connection_unit.py +0 -0
  66. {confluent_sql-0.5.1 → confluent_sql-0.5.3}/tests/unit/test_tableflow_unit.py +0 -0
  67. {confluent_sql-0.5.1 → confluent_sql-0.5.3}/tests/unit/test_types_unit.py +0 -0
@@ -2,7 +2,18 @@
2
2
 
3
3
  All notable changes to this dbapi driver will be documented in this file.
4
4
 
5
- ## Unreleased
5
+ ## 0.5.3, 2026-08-27
6
+
7
+ ### Fixed
8
+
9
+ - `Statement.from_response()` no longer raises `OperationalError` for a `PENDING` statement with no `status.traits`, which can possibly happen. (#194)
10
+
11
+ ## 0.5.2, 2026-08-26
12
+
13
+ ### Fixed
14
+
15
+ - `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`.
16
+ - 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`.
6
17
 
7
18
  ## 0.5.1, 2026-08-25
8
19
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: confluent-sql
3
- Version: 0.5.1
3
+ Version: 0.5.3
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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "confluent-sql"
7
- version = "0.5.1"
7
+ version = "0.5.3"
8
8
  description = "DB-API v2 compliant driver for Confluent Cloud Flink SQL"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -259,8 +259,9 @@ class Cursor:
259
259
 
260
260
  if self._statement.is_failed:
261
261
  raise OperationalError(
262
- f"Statement submission failed: {self._statement.status.get('detail', '')}"
263
- ) # pragma: no cover
262
+ f"Statement '{self._statement.name}' submission failed:"
263
+ f" {self._statement.status.get('detail', '')}"
264
+ )
264
265
 
265
266
  # ... and wait for it to be "ready" (either in a terminal state or running) based on
266
267
  # execution mode and statement type.
@@ -170,12 +170,30 @@ class Statement:
170
170
  """Represents a Confluent SQL statement, including its metadata, spec, status,
171
171
  and parsed traits such as schema, sql kind, etc."""
172
172
 
173
- # 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.
174
175
  _PURE_DDL_KINDS = frozenset(
175
- {"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
+ }
176
194
  )
177
195
 
178
- # 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)
179
197
  _IMPURE_DDL_KINDS = frozenset({"CREATE_TABLE_AS"})
180
198
 
181
199
  # From the cursor that created this statement ...
@@ -225,6 +243,12 @@ class Statement:
225
243
  if self.phase.is_terminal:
226
244
  return True
227
245
 
246
+ # Traits aren't sent on the initial PENDING response (see #194) -- without them, the
247
+ # trait-dependent checks below (is_pure_ddl, is_bounded, is_append_only) can't run yet,
248
+ # and a non-terminal statement with no traits can't be ready to fetch results anyway.
249
+ if self.traits is None:
250
+ return False
251
+
228
252
  if execution_mode.is_streaming:
229
253
  # In streaming mode, readiness depends on statement type.
230
254
  if self.is_pure_ddl:
@@ -336,9 +360,22 @@ class Statement:
336
360
  Pure DDL statements need to complete fully before the created/modified objects
337
361
  are ready for use, unlike streaming queries or CTAS which are ready when RUNNING.
338
362
 
363
+ "Pure" is stretched a little to also cover CREATE_MATERIALIZED_TABLE,
364
+ CREATE_OR_ALTER_MATERIALIZED_TABLE, and ALTER_MATERIALIZED_TABLE (the query-evolving
365
+ form -- see CREATE_OR_ALTER_MATERIALIZED_TABLE): their completion additionally kicks off
366
+ (or redeploys) a persistent background refresh job that keeps running long after the
367
+ statement settles, which isn't true of the other members. They earn the label anyway
368
+ because they satisfy the one thing this property actually gates: the statement's own
369
+ phase reliably reaches a terminal phase before the created/altered object is usable,
370
+ rather than lingering in RUNNING the way CTAS does (see _PURE_DDL_KINDS for the
371
+ confirming detail). DROP_MATERIALIZED_TABLE needs no such stretch -- like the other
372
+ DROP_* kinds, it's a one-shot action with no background job of its own.
373
+
339
374
  Returns:
340
375
  True if the statement is one of: CREATE_TABLE, DROP_TABLE, CREATE_VIEW,
341
- DROP_VIEW, ALTER_TABLE. False otherwise.
376
+ DROP_VIEW, ALTER_TABLE, CREATE_MATERIALIZED_TABLE,
377
+ CREATE_OR_ALTER_MATERIALIZED_TABLE, ALTER_MATERIALIZED_TABLE,
378
+ DROP_MATERIALIZED_TABLE. False otherwise.
342
379
  """
343
380
  return self.sql_kind in self._PURE_DDL_KINDS
344
381
 
@@ -482,8 +519,9 @@ class Statement:
482
519
  else None
483
520
  )
484
521
 
485
- # Defensive check: non-failed statements should have traits
486
- if traits is None and phase != Phase.FAILED:
522
+ # Defensive check: non-failed, non-pending statements should have traits. FAILED
523
+ # statements never get traits, and PENDING may or may not have traits. (see #194).
524
+ if traits is None and phase not in (Phase.FAILED, Phase.PENDING):
487
525
  raise OperationalError(
488
526
  f"Received statement '{name}' in phase {phase} without traits. "
489
527
  "This is unexpected and likely indicates a server API change or bug."
@@ -9,7 +9,7 @@ from typing import NamedTuple
9
9
 
10
10
  import pytest
11
11
 
12
- from confluent_sql import Connection, SqlNone, YearMonthInterval
12
+ from confluent_sql import Connection, OperationalError, SqlNone, YearMonthInterval
13
13
  from confluent_sql.execution_mode import ExecutionMode
14
14
 
15
15
 
@@ -678,6 +678,133 @@ class TestExecuteDDL:
678
678
  assert statement.schema is None, "CTAS schema should be None"
679
679
  assert statement.is_deleted, "Snapshot DDL statement should be auto-deleted"
680
680
 
681
+ def test_execute_streaming_ddl_create_materialized_table_completes(
682
+ self, connection: Connection, auto_dropped_table_name: str
683
+ ):
684
+ """Prove that CREATE MATERIALIZED TABLE, submitted via execute_streaming_ddl(), waits
685
+ for the statement to reach COMPLETED rather than returning as soon as it's RUNNING --
686
+ unlike CTAS, whose backing job runs forever, a materialized table's own CREATE statement
687
+ settles once its background refresh job is started (see statement.py's is_pure_ddl)."""
688
+ try:
689
+ statement_text = f"""
690
+ CREATE MATERIALIZED TABLE `{auto_dropped_table_name}`
691
+ AS SELECT * FROM `sample_data_stock_trades` WHERE quantity > 100
692
+ """
693
+
694
+ statement = connection.execute_streaming_ddl(statement_text)
695
+
696
+ # execute_streaming_ddl's closing_cursor auto-deletes a terminal statement on exit,
697
+ # which flips Statement.phase to DELETED (it overrides the underlying phase once
698
+ # deleted -- see Statement.phase) -- so we can't read back "COMPLETED" directly here.
699
+ # is_deleted only follows from a *deletable* terminal phase (COMPLETED/FAILED/
700
+ # STOPPED); FAILED would have raised OperationalError out of execute_streaming_ddl
701
+ # instead of returning, and nothing here ever requested a STOPPED, so reaching this
702
+ # line with is_deleted true and no exception raised is exactly the proof that the
703
+ # statement settled to COMPLETED.
704
+ assert not statement.is_running
705
+ assert statement.is_deleted
706
+
707
+ # The table should be immediately queryable.
708
+ with connection.closing_cursor(as_dict=True) as cursor:
709
+ cursor.execute(f"SELECT COUNT(*) AS row_count FROM `{auto_dropped_table_name}`")
710
+ results = cursor.fetchone()
711
+ assert results is not None
712
+ finally:
713
+ with suppress(Exception):
714
+ connection.execute_snapshot_ddl(
715
+ f"DROP MATERIALIZED TABLE IF EXISTS `{auto_dropped_table_name}`"
716
+ )
717
+
718
+ def test_execute_streaming_ddl_create_or_alter_materialized_table_completes(
719
+ self, connection: Connection, auto_dropped_table_name: str
720
+ ):
721
+ """Prove that CREATE OR ALTER MATERIALIZED TABLE, and a bare (query-evolving) ALTER
722
+ MATERIALIZED TABLE, both also wait for COMPLETED, same as a first-time CREATE
723
+ MATERIALIZED TABLE. Each step respells the filter predicate (>100, then >=100, then >50)
724
+ to exercise a genuine redefinition of an already-existing materialized table."""
725
+
726
+ def show_create_ddl_text(table_name: str) -> str:
727
+ # SHOW CREATE MATERIALIZED TABLE is a plain bounded single-row query, so a default
728
+ # (snapshot mode) cursor is all it needs.
729
+ with connection.closing_cursor(as_dict=True) as cursor:
730
+ cursor.execute(f"SHOW CREATE MATERIALIZED TABLE `{table_name}`")
731
+ row = cursor.fetchone()
732
+ assert row is not None
733
+ return row["SHOW CREATE MATERIALIZED TABLE"] # type: ignore[index]
734
+
735
+ try:
736
+ create_statement_text = f"""
737
+ CREATE MATERIALIZED TABLE `{auto_dropped_table_name}`
738
+ AS SELECT * FROM `sample_data_stock_trades` WHERE quantity > 100
739
+ """
740
+ created = connection.execute_streaming_ddl(create_statement_text)
741
+ assert not created.is_running
742
+ assert created.is_deleted # see the sibling CREATE test for why this implies COMPLETED
743
+
744
+ create_or_alter_statement_text = f"""
745
+ CREATE OR ALTER MATERIALIZED TABLE `{auto_dropped_table_name}`
746
+ AS SELECT * FROM `sample_data_stock_trades` WHERE quantity >= 100
747
+ """
748
+ create_or_altered = connection.execute_streaming_ddl(create_or_alter_statement_text)
749
+
750
+ assert not create_or_altered.is_running
751
+ assert create_or_altered.is_deleted
752
+
753
+ # Prove the redefinition actually took effect.
754
+ assert ">= 100" in show_create_ddl_text(auto_dropped_table_name)
755
+
756
+ # A bare ALTER (no CREATE OR prefix) with a new AS SELECT is query evolution too --
757
+ # same completion semantics, since ALTER_MATERIALIZED_TABLE is also pure DDL.
758
+ bare_alter_statement_text = f"""
759
+ ALTER MATERIALIZED TABLE `{auto_dropped_table_name}`
760
+ AS SELECT * FROM `sample_data_stock_trades` WHERE quantity > 50
761
+ """
762
+ bare_altered = connection.execute_streaming_ddl(bare_alter_statement_text)
763
+
764
+ assert not bare_altered.is_running
765
+ assert bare_altered.is_deleted
766
+
767
+ assert "> 50" in show_create_ddl_text(auto_dropped_table_name)
768
+ finally:
769
+ with suppress(Exception):
770
+ connection.execute_snapshot_ddl(
771
+ f"DROP MATERIALIZED TABLE IF EXISTS `{auto_dropped_table_name}`"
772
+ )
773
+
774
+ def test_execute_snapshot_ddl_drop_materialized_table_completes(
775
+ self, connection: Connection, auto_dropped_table_name: str
776
+ ):
777
+ """Prove that DROP MATERIALIZED TABLE, submitted via execute_snapshot_ddl(), completes
778
+ (it's pure DDL, like the other DROP_* kinds -- see statement.py's _PURE_DDL_KINDS)."""
779
+ try:
780
+ create_statement_text = f"""
781
+ CREATE MATERIALIZED TABLE `{auto_dropped_table_name}`
782
+ AS SELECT * FROM `sample_data_stock_trades` WHERE quantity > 100
783
+ """
784
+ connection.execute_streaming_ddl(create_statement_text)
785
+
786
+ drop_statement = connection.execute_snapshot_ddl(
787
+ f"DROP MATERIALIZED TABLE `{auto_dropped_table_name}`"
788
+ )
789
+
790
+ # is_deleted implies COMPLETED here, not just some other deletable terminal phase --
791
+ # see the CREATE test above for why.
792
+ assert not drop_statement.is_running
793
+ assert drop_statement.is_deleted
794
+
795
+ # The table should be gone: selecting from it should now fail with the backend's
796
+ # missing-object diagnostic, not just some unrelated failure.
797
+ with (
798
+ pytest.raises(OperationalError, match="does not exist"),
799
+ connection.closing_cursor() as cursor,
800
+ ):
801
+ cursor.execute(f"SELECT * FROM `{auto_dropped_table_name}`")
802
+ finally:
803
+ with suppress(Exception):
804
+ connection.execute_snapshot_ddl(
805
+ f"DROP MATERIALIZED TABLE IF EXISTS `{auto_dropped_table_name}`"
806
+ )
807
+
681
808
 
682
809
  @pytest.mark.integration
683
810
  class TestArrayStatements:
@@ -13,7 +13,7 @@ from confluent_sql.exceptions import (
13
13
  )
14
14
  from confluent_sql.execution_mode import ExecutionMode
15
15
  from confluent_sql.result_readers import ChangelogEventReader, ChangeloggedRow, FetchMetrics
16
- from confluent_sql.statement import ChangelogRow, Op, Statement
16
+ from confluent_sql.statement import ChangelogRow, Op, Phase, Statement
17
17
  from tests.unit.conftest import (
18
18
  CursorWithStatementFactory,
19
19
  MockConnectionFactory,
@@ -94,6 +94,33 @@ class TestExecute:
94
94
  # non-append-only statements.
95
95
  assert isinstance(mock_connection_cursor._result_reader, ChangelogEventReader)
96
96
 
97
+ def test_execute_raises_with_actual_statement_name_on_immediate_submission_failure(
98
+ self,
99
+ mock_connection_cursor: Cursor,
100
+ statement_response_factory: StatementResponseFactory,
101
+ ):
102
+ """Prove that when the *submission* response (not a later poll) already reports FAILED,
103
+ the raised OperationalError names the statement per the parsed response -- not the
104
+ caller's statement_name argument, which is None on the common path where the caller lets
105
+ the server generate a name. Regression test for the bug Copilot's review of #195 caught:
106
+ interpolating the (usually None) statement_name parameter instead of
107
+ self._statement.name."""
108
+ failed_submission = statement_response_factory(
109
+ phase="FAILED",
110
+ status_detail="Invalid SQL syntax",
111
+ name="dbapi-server-generated-name",
112
+ )
113
+ mock_connection_cursor._connection._execute_statement.return_value = ( # type: ignore
114
+ failed_submission
115
+ )
116
+
117
+ with pytest.raises(
118
+ OperationalError,
119
+ match=r"Statement 'dbapi-server-generated-name' submission failed: Invalid SQL syntax",
120
+ ):
121
+ # statement_name intentionally omitted, as most callers do -- the server assigns one.
122
+ mock_connection_cursor.execute("SELECT 1 AS col")
123
+
97
124
  def test_execute_calls_raise_if_statement_is_broken_for_failed_statement(
98
125
  self,
99
126
  mock_connection_cursor: Cursor,
@@ -253,6 +280,53 @@ class TestExecute:
253
280
 
254
281
  backoff.assert_called_once_with(30, started_at=0.0)
255
282
 
283
+ def test_becomes_ready_after_pending_without_traits_then_running_with_traits(
284
+ self,
285
+ mock_connection_cursor: Cursor,
286
+ statement_response_factory: StatementResponseFactory,
287
+ mocker,
288
+ ):
289
+ """End-to-end lifecycle for #194: a statement is submitted PENDING with no traits (the
290
+ documented shape of Confluent Cloud's initial response), stays PENDING with no traits for
291
+ two more polls, then transitions to RUNNING with traits -- at which point execute() must
292
+ return instead of raising or timing out."""
293
+ pending_no_traits = statement_response_factory(phase="PENDING")
294
+ pending_no_traits["status"]["traits"] = None
295
+
296
+ running_with_traits = statement_response_factory(
297
+ sql_statement="SELECT * FROM source_table",
298
+ sql_kind="SELECT",
299
+ phase="RUNNING",
300
+ is_bounded=True,
301
+ is_append_only=True,
302
+ )
303
+
304
+ mock_connection_cursor._execution_mode = ExecutionMode.STREAMING_QUERY
305
+
306
+ # The initial submission (POST) response.
307
+ mock_connection_cursor._connection._execute_statement.return_value = ( # type: ignore
308
+ pending_no_traits
309
+ )
310
+ # Subsequent polls (GET): two more PENDING-without-traits observations, then RUNNING
311
+ # with traits on the third.
312
+ mock_connection_cursor._connection._get_statement.side_effect = ( # type: ignore
313
+ [pending_no_traits, pending_no_traits, running_with_traits]
314
+ )
315
+
316
+ mocker.patch("time.sleep", return_value=None)
317
+ start_time = 1000000.0
318
+ time_mock = mocker.patch(
319
+ "time.monotonic", side_effect=lambda: start_time + time_mock.call_count
320
+ )
321
+
322
+ mock_connection_cursor.execute("SELECT * FROM source_table")
323
+
324
+ assert mock_connection_cursor._connection._get_statement.call_count == 3 # type: ignore
325
+ assert mock_connection_cursor._statement is not None
326
+ assert mock_connection_cursor._statement.phase is Phase.RUNNING
327
+ assert mock_connection_cursor._statement.traits is not None
328
+ assert mock_connection_cursor._statement.can_fetch_results(ExecutionMode.STREAMING_QUERY)
329
+
256
330
  @pytest.mark.parametrize(
257
331
  "streaming_mode",
258
332
  [ExecutionMode.STREAMING_QUERY, ExecutionMode.STREAMING_DDL],
@@ -440,6 +440,10 @@ class TestStatementProperties:
440
440
  ("CREATE_VIEW", True),
441
441
  ("DROP_VIEW", True),
442
442
  ("ALTER_TABLE", True),
443
+ ("CREATE_MATERIALIZED_TABLE", True),
444
+ ("CREATE_OR_ALTER_MATERIALIZED_TABLE", True),
445
+ ("ALTER_MATERIALIZED_TABLE", True),
446
+ ("DROP_MATERIALIZED_TABLE", True),
443
447
  ("SELECT", False),
444
448
  ("INSERT", False),
445
449
  ("UPDATE", False),
@@ -613,6 +617,22 @@ class TestStatementFromResponse:
613
617
  ):
614
618
  Statement.from_response(mock_connection, response)
615
619
 
620
+ def test_allows_pending_statement_without_traits(
621
+ self, mock_connection: Connection, statement_response_factory: StatementResponseFactory
622
+ ):
623
+ """Test that from_response accepts a PENDING statement without traits.
624
+
625
+ Confluent Cloud's documented initial response to a statement submission is a PENDING
626
+ phase with no status.traits at all -- see #194. Traits only become available once the
627
+ statement has been polled at least once.
628
+ """
629
+ response = statement_response_factory(phase="PENDING")
630
+ response["status"]["traits"] = None
631
+
632
+ statement = Statement.from_response(mock_connection, response)
633
+ assert statement.traits is None
634
+ assert statement.phase == Phase.PENDING
635
+
616
636
  def test_parses_row_result_schema(
617
637
  self, mock_connection: Connection, statement_response_factory: StatementResponseFactory
618
638
  ):
@@ -980,32 +1000,63 @@ class TestStatementCanFetchResults:
980
1000
  statement = Statement.from_response(mock_connection, statement_json)
981
1001
  assert statement.can_fetch_results(ExecutionMode.STREAMING_QUERY)
982
1002
 
1003
+ @pytest.mark.parametrize(
1004
+ "sql_kind",
1005
+ [
1006
+ "CREATE_TABLE",
1007
+ "CREATE_MATERIALIZED_TABLE",
1008
+ "CREATE_OR_ALTER_MATERIALIZED_TABLE",
1009
+ "ALTER_MATERIALIZED_TABLE",
1010
+ "DROP_MATERIALIZED_TABLE",
1011
+ ],
1012
+ )
983
1013
  @pytest.mark.parametrize("phase", ["PENDING", "RUNNING"])
984
1014
  def test_streaming_ddl_pure_ddl_not_ready(
985
1015
  self,
986
1016
  mock_connection: Connection,
987
1017
  statement_response_factory: StatementResponseFactory,
988
1018
  phase: str,
1019
+ sql_kind: str,
989
1020
  ):
990
- """In streaming DDL mode, pure DDL must wait for terminal state."""
1021
+ """In streaming DDL mode, pure DDL must wait for terminal state.
1022
+
1023
+ The three MATERIALIZED_TABLE kinds are included alongside CREATE_TABLE here: even though
1024
+ CREATE_MATERIALIZED_TABLE, CREATE_OR_ALTER_MATERIALIZED_TABLE, and
1025
+ ALTER_MATERIALIZED_TABLE all kick off (or redeploy) a persistent background refresh job
1026
+ on completion, each statement's own phase reliably reaches a terminal phase once that job
1027
+ is started (confirmed against a real environment -- unlike CTAS, which the Flink API
1028
+ traits currently mis-report as perpetually RUNNING, see is_bounded's Jan 2026 bug note),
1029
+ so they belong in the same "must wait for terminal" bucket as the other pure DDL kinds.
1030
+ """
991
1031
  statement_json = statement_response_factory(
992
1032
  phase=phase,
993
- sql_kind="CREATE_TABLE",
1033
+ sql_kind=sql_kind,
994
1034
  )
995
1035
  statement = Statement.from_response(mock_connection, statement_json)
996
1036
  assert not statement.can_fetch_results(ExecutionMode.STREAMING_DDL)
997
1037
 
1038
+ @pytest.mark.parametrize(
1039
+ "sql_kind",
1040
+ [
1041
+ "CREATE_TABLE",
1042
+ "CREATE_MATERIALIZED_TABLE",
1043
+ "CREATE_OR_ALTER_MATERIALIZED_TABLE",
1044
+ "ALTER_MATERIALIZED_TABLE",
1045
+ "DROP_MATERIALIZED_TABLE",
1046
+ ],
1047
+ )
998
1048
  @pytest.mark.parametrize("phase", ["COMPLETED", "STOPPED", "FAILED"])
999
1049
  def test_streaming_ddl_pure_ddl_ready(
1000
1050
  self,
1001
1051
  mock_connection: Connection,
1002
1052
  statement_response_factory: StatementResponseFactory,
1003
1053
  phase: str,
1054
+ sql_kind: str,
1004
1055
  ):
1005
1056
  """In streaming DDL mode, pure DDL is ready in terminal states."""
1006
1057
  statement_json = statement_response_factory(
1007
1058
  phase=phase,
1008
- sql_kind="CREATE_TABLE",
1059
+ sql_kind=sql_kind,
1009
1060
  )
1010
1061
  statement = Statement.from_response(mock_connection, statement_json)
1011
1062
  assert statement.can_fetch_results(ExecutionMode.STREAMING_DDL)
@@ -1075,3 +1126,22 @@ class TestStatementCanFetchResults:
1075
1126
  )
1076
1127
  statement = Statement.from_response(mock_connection, statement_json)
1077
1128
  assert statement.can_fetch_results(ExecutionMode.STREAMING_DDL)
1129
+
1130
+ @pytest.mark.parametrize(
1131
+ "execution_mode",
1132
+ [ExecutionMode.SNAPSHOT, ExecutionMode.STREAMING_QUERY, ExecutionMode.STREAMING_DDL],
1133
+ )
1134
+ def test_pending_without_traits_not_ready(
1135
+ self,
1136
+ mock_connection: Connection,
1137
+ statement_response_factory: StatementResponseFactory,
1138
+ execution_mode: ExecutionMode,
1139
+ ):
1140
+ """A freshly-submitted PENDING statement has no traits yet (see #194) -- it must be
1141
+ reported as not-yet-ready rather than raising while probing trait-dependent properties
1142
+ like is_pure_ddl."""
1143
+ response = statement_response_factory(phase="PENDING")
1144
+ response["status"]["traits"] = None
1145
+
1146
+ statement = Statement.from_response(mock_connection, response)
1147
+ assert not statement.can_fetch_results(execution_mode)
@@ -36,7 +36,7 @@ wheels = [
36
36
 
37
37
  [[package]]
38
38
  name = "confluent-sql"
39
- version = "0.5.1"
39
+ version = "0.5.3"
40
40
  source = { editable = "." }
41
41
  dependencies = [
42
42
  { name = "httpx" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes