Skip to content

Commit 2786891

Browse files
committed
fix: thread original_sql through execute pipeline so RETURNING emulation fires
Two root causes for INSERT...RETURNING still returning [] after v1.5.5: 1. Execute handler fast-batch bypass checked has_returning on translated_query (RETURNING stripped) → INSERT...RETURNING was batched, emulation skipped. Fix: use original_query (pre-translation) for has_returning check. 2. _prepare_sql built ReturningPlan from optimized_sql (RETURNING stripped by normalization pipeline) → plan.has_returning always False, emulation block never entered. Fix: add original_sql param to _prepare_sql, thread it from _execute_external_async, _execute_embedded_async, and execute_query. Both paths (embedded + external) fixed. Bumps version to 1.5.6.
1 parent eb5c174 commit 2786891

File tree

4 files changed

+53
-9
lines changed

4 files changed

+53
-9
lines changed

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [1.5.6] - 2026-03-09
11+
12+
### Fixed
13+
- **`INSERT ... RETURNING` returns empty array with Extended Query Protocol (Drizzle / Better Auth) — complete fix**: v1.5.5 fixed the Describe phase (NoData → RowDescription), but the Execute phase still returned zero rows. Two additional root causes:
14+
15+
1. **Fast-batch bypass used translated SQL for `has_returning` check** (`protocol.py` Execute handler): The batch fast-path (`is_dml and not has_returning`) inspected `translated_query` (RETURNING stripped) instead of `original_query`. This caused `has_returning=False`, so INSERT...RETURNING was incorrectly fast-batched — the INSERT executed and sent a synthetic `CommandComplete` immediately, but `_emulate_returning` was never reached and zero DataRows were returned.
16+
17+
2. **`_prepare_sql` used translated SQL for `ReturningPlan`** (`iris_executor.py`): Even when the fast-batch was bypassed, `_prepare_sql` built `ReturningPlan` from `optimized_sql` (which has RETURNING stripped by the normalization pipeline). `plan.has_returning` was therefore always `False`, so the RETURNING emulation block was never entered. Fixed by adding an `original_sql` parameter to `_prepare_sql`, `_execute_external_async`, `_execute_embedded_async`, and `execute_query`, threading the pre-translation SQL all the way through so `ReturningPlan` can detect RETURNING correctly.
18+
19+
- **Impact**: Affects both embedded and external IRIS connection modes. Better Auth "Failed to create session", Drizzle `.returning()` always `[]`, SQLAlchemy `.returning()` with psycopg3.
20+
1021
## [1.5.5] - 2026-03-09
1122

1223
### Fixed

src/iris_pgwire/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
caretdev/sqlalchemy-iris.
77
"""
88

9-
__version__ = "1.5.5"
9+
__version__ = "1.5.6"
1010
__author__ = "Thomas Dyar <thomas.dyar@intersystems.com>"
1111

1212
# Don't import server/protocol in __init__ to avoid sys.modules conflicts

src/iris_pgwire/iris_executor.py

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -627,6 +627,7 @@ def _prepare_sql(
627627
params: list | None,
628628
execution_path: str,
629629
session_id: str | None = None,
630+
original_sql: str | None = None,
630631
) -> tuple[str, list | None, "ReturningPlan", float]:
631632
"""
632633
Shared pre-execution pipeline (steps 1–7) used by both embedded and external paths.
@@ -678,8 +679,12 @@ def _prepare_sql(
678679
t_opt_elapsed = (time.perf_counter() - t_opt_start) * 1000
679680

680681
# 5. RETURNING / ON CONFLICT parsing
682+
# Use original_sql (pre-translation) for RETURNING detection when available.
683+
# translated sql (optimized_sql) has the RETURNING clause stripped because IRIS
684+
# doesn't support it natively — so ReturningPlan must inspect the original.
685+
_sql_for_plan = original_sql if original_sql else optimized_sql
681686
plan = ReturningPlan.from_sql(
682-
optimized_sql,
687+
_sql_for_plan,
683688
metadata_cache=self.metadata_cache,
684689
executor=self,
685690
)
@@ -998,10 +1003,14 @@ async def execute_query(
9981003
)
9991004
if self.embedded_mode:
10001005
logger.warning("🔍 DEBUG: Taking EMBEDDED path → _execute_embedded_async()")
1001-
result = await self._execute_embedded_async(sql, params, session_id)
1006+
result = await self._execute_embedded_async(
1007+
sql, params, session_id, original_sql=kwargs.get("original_sql")
1008+
)
10021009
else:
10031010
logger.warning("🔍 DEBUG: Taking EXTERNAL path → _execute_external_async()")
1004-
result = await self._execute_external_async(sql, params, session_id)
1011+
result = await self._execute_external_async(
1012+
sql, params, session_id, original_sql=kwargs.get("original_sql")
1013+
)
10051014

10061015
# Feature 026: Handle DDL idempotency (IF NOT EXISTS)
10071016
# Check both for raised exceptions and for success=False results
@@ -2292,7 +2301,11 @@ def _materialize_embedded_result(
22922301
return rows, columns
22932302

22942303
async def _execute_embedded_async(
2295-
self, sql: str, params: list | None = None, session_id: str | None = None
2304+
self,
2305+
sql: str,
2306+
params: list | None = None,
2307+
session_id: str | None = None,
2308+
original_sql: str | None = None,
22962309
) -> dict[str, Any]:
22972310
"""Execute query in IRIS embedded Python environment (async wrapper)"""
22982311

@@ -2354,7 +2367,11 @@ def _sync_execute(captured_sql, captured_params, captured_session_id):
23542367

23552368
# Steps 1-7: Shared pre-execution pipeline
23562369
optimized_sql, optimized_params, plan, t_opt_elapsed = self._prepare_sql(
2357-
sql, params, execution_path="direct", session_id=session_id
2370+
sql,
2371+
params,
2372+
execution_path="direct",
2373+
session_id=session_id,
2374+
original_sql=original_sql,
23582375
)
23592376
optimized_sql_upper = optimized_sql.upper()
23602377
returning_operation = plan.operation
@@ -2883,7 +2900,11 @@ def _materialize_external_result(
28832900
return rows, columns
28842901

28852902
async def _execute_external_async(
2886-
self, sql: str, params: list | None = None, session_id: str | None = None
2903+
self,
2904+
sql: str,
2905+
params: list | None = None,
2906+
session_id: str | None = None,
2907+
original_sql: str | None = None,
28872908
) -> dict[str, Any]:
28882909
"""
28892910
Execute SQL using external IRIS connection with proper async threading
@@ -2910,7 +2931,11 @@ def _sync_external_execute(captured_sql, captured_params, captured_session_id):
29102931

29112932
# Steps 1-7: Shared pre-execution pipeline
29122933
optimized_sql, optimized_params, plan, t_opt_elapsed = self._prepare_sql(
2913-
sql, params, execution_path="external", session_id=session_id
2934+
sql,
2935+
params,
2936+
execution_path="external",
2937+
session_id=session_id,
2938+
original_sql=original_sql,
29142939
)
29152940
optimized_sql_upper = optimized_sql.upper()
29162941

src/iris_pgwire/protocol.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3312,6 +3312,9 @@ async def handle_execute_message(self, body: bytes):
33123312

33133313
# Use translated query for execution
33143314
query = stmt.get("translated_query", stmt.get("query", stmt.get("original_query", "")))
3315+
# Use original_query for RETURNING detection: translated_query has RETURNING stripped.
3316+
# has_returning check below decides whether to fast-batch (DML without RETURNING).
3317+
original_query = stmt.get("original_query", query)
33153318

33163319
# Log execution of prepared statement with translation metadata
33173320
translation_metadata = stmt.get("translation_metadata", {})
@@ -3394,7 +3397,11 @@ async def handle_execute_message(self, body: bytes):
33943397
# Standard PostgreSQL clients (psycopg3) send Sync every 5 rows, which is slow.
33953398
# We buffer parameters and send synthetic CommandComplete to keep client pipe full.
33963399
is_dml = self.iris_executor.sql_parser.is_dml_statement(query)
3397-
has_returning = self.iris_executor.sql_parser.has_returning_clause(query)
3400+
# Use original_query for RETURNING detection: translated_query has RETURNING stripped.
3401+
# If we used query (translated) here, INSERT...RETURNING would be fast-batched
3402+
# (because has_returning=False on translated_query), INSERT would execute but
3403+
# zero DataRows would be returned to the client.
3404+
has_returning = self.iris_executor.sql_parser.has_returning_clause(original_query)
33983405

33993406
if is_dml and not has_returning:
34003407
# Store SQL if first row in batch
@@ -3432,6 +3439,7 @@ async def handle_execute_message(self, body: bytes):
34323439
session_id=self.connection_id,
34333440
in_transaction=in_txn,
34343441
autocommit=autocommit,
3442+
original_sql=original_query,
34353443
)
34363444

34373445
if result["success"]:

0 commit comments

Comments
 (0)