iris-pgwire 1.2.33__tar.gz → 1.2.34__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 (111) hide show
  1. {iris_pgwire-1.2.33 → iris_pgwire-1.2.34}/PKG-INFO +1 -1
  2. {iris_pgwire-1.2.33 → iris_pgwire-1.2.34}/src/iris_pgwire/__init__.py +1 -1
  3. {iris_pgwire-1.2.33 → iris_pgwire-1.2.34}/src/iris_pgwire/dbapi_executor.py +510 -25
  4. {iris_pgwire-1.2.33 → iris_pgwire-1.2.34}/src/iris_pgwire/iris_executor.py +93 -87
  5. {iris_pgwire-1.2.33 → iris_pgwire-1.2.34}/.gitignore +0 -0
  6. {iris_pgwire-1.2.33 → iris_pgwire-1.2.34}/CHANGELOG.md +0 -0
  7. {iris_pgwire-1.2.33 → iris_pgwire-1.2.34}/LICENSE +0 -0
  8. {iris_pgwire-1.2.33 → iris_pgwire-1.2.34}/README.md +0 -0
  9. {iris_pgwire-1.2.33 → iris_pgwire-1.2.34}/docs/VECTOR_PARAMETER_BINDING.md +0 -0
  10. {iris_pgwire-1.2.33 → iris_pgwire-1.2.34}/examples/BI_TOOLS_SETUP.md +0 -0
  11. {iris_pgwire-1.2.33 → iris_pgwire-1.2.34}/examples/async_sqlalchemy_demo.py +0 -0
  12. {iris_pgwire-1.2.33 → iris_pgwire-1.2.34}/examples/bi_tools_demo.py +0 -0
  13. {iris_pgwire-1.2.33 → iris_pgwire-1.2.34}/examples/client_demonstrations.py +0 -0
  14. {iris_pgwire-1.2.33 → iris_pgwire-1.2.34}/examples/client_demos.py +0 -0
  15. {iris_pgwire-1.2.33 → iris_pgwire-1.2.34}/examples/translation_api_demo.py +0 -0
  16. {iris_pgwire-1.2.33 → iris_pgwire-1.2.34}/pyproject.toml +0 -0
  17. {iris_pgwire-1.2.33 → iris_pgwire-1.2.34}/src/iris_pgwire/auth/__init__.py +0 -0
  18. {iris_pgwire-1.2.33 → iris_pgwire-1.2.34}/src/iris_pgwire/auth/auth_selector.py +0 -0
  19. {iris_pgwire-1.2.33 → iris_pgwire-1.2.34}/src/iris_pgwire/auth/gssapi_auth.py +0 -0
  20. {iris_pgwire-1.2.33 → iris_pgwire-1.2.34}/src/iris_pgwire/auth/oauth_bridge.py +0 -0
  21. {iris_pgwire-1.2.33 → iris_pgwire-1.2.34}/src/iris_pgwire/auth/scram.py +0 -0
  22. {iris_pgwire-1.2.33 → iris_pgwire-1.2.34}/src/iris_pgwire/auth/wallet_credentials.py +0 -0
  23. {iris_pgwire-1.2.33 → iris_pgwire-1.2.34}/src/iris_pgwire/backend_selector.py +0 -0
  24. {iris_pgwire-1.2.33 → iris_pgwire-1.2.34}/src/iris_pgwire/bulk_executor.py +0 -0
  25. {iris_pgwire-1.2.33 → iris_pgwire-1.2.34}/src/iris_pgwire/catalog/__init__.py +0 -0
  26. {iris_pgwire-1.2.33 → iris_pgwire-1.2.34}/src/iris_pgwire/catalog/catalog_functions.py +0 -0
  27. {iris_pgwire-1.2.33 → iris_pgwire-1.2.34}/src/iris_pgwire/catalog/catalog_router.py +0 -0
  28. {iris_pgwire-1.2.33 → iris_pgwire-1.2.34}/src/iris_pgwire/catalog/oid_generator.py +0 -0
  29. {iris_pgwire-1.2.33 → iris_pgwire-1.2.34}/src/iris_pgwire/catalog/pg_attrdef.py +0 -0
  30. {iris_pgwire-1.2.33 → iris_pgwire-1.2.34}/src/iris_pgwire/catalog/pg_attribute.py +0 -0
  31. {iris_pgwire-1.2.33 → iris_pgwire-1.2.34}/src/iris_pgwire/catalog/pg_class.py +0 -0
  32. {iris_pgwire-1.2.33 → iris_pgwire-1.2.34}/src/iris_pgwire/catalog/pg_constraint.py +0 -0
  33. {iris_pgwire-1.2.33 → iris_pgwire-1.2.34}/src/iris_pgwire/catalog/pg_index.py +0 -0
  34. {iris_pgwire-1.2.33 → iris_pgwire-1.2.34}/src/iris_pgwire/catalog/pg_namespace.py +0 -0
  35. {iris_pgwire-1.2.33 → iris_pgwire-1.2.34}/src/iris_pgwire/catalog/pg_type.py +0 -0
  36. {iris_pgwire-1.2.33 → iris_pgwire-1.2.34}/src/iris_pgwire/column_validator.py +0 -0
  37. {iris_pgwire-1.2.33 → iris_pgwire-1.2.34}/src/iris_pgwire/config_schema.py +0 -0
  38. {iris_pgwire-1.2.33 → iris_pgwire-1.2.34}/src/iris_pgwire/constitutional.py +0 -0
  39. {iris_pgwire-1.2.33 → iris_pgwire-1.2.34}/src/iris_pgwire/conversions/__init__.py +0 -0
  40. {iris_pgwire-1.2.33 → iris_pgwire-1.2.34}/src/iris_pgwire/conversions/bulk_insert.py +0 -0
  41. {iris_pgwire-1.2.33 → iris_pgwire-1.2.34}/src/iris_pgwire/conversions/date_horolog.py +0 -0
  42. {iris_pgwire-1.2.33 → iris_pgwire-1.2.34}/src/iris_pgwire/conversions/ddl_idempotency.py +0 -0
  43. {iris_pgwire-1.2.33 → iris_pgwire-1.2.34}/src/iris_pgwire/conversions/ddl_splitter.py +0 -0
  44. {iris_pgwire-1.2.33 → iris_pgwire-1.2.34}/src/iris_pgwire/conversions/json_path.py +0 -0
  45. {iris_pgwire-1.2.33 → iris_pgwire-1.2.34}/src/iris_pgwire/conversions/vector_syntax.py +0 -0
  46. {iris_pgwire-1.2.33 → iris_pgwire-1.2.34}/src/iris_pgwire/copy_handler.py +0 -0
  47. {iris_pgwire-1.2.33 → iris_pgwire-1.2.34}/src/iris_pgwire/csv_processor.py +0 -0
  48. {iris_pgwire-1.2.33 → iris_pgwire-1.2.34}/src/iris_pgwire/dbapi_connection_pool.py +0 -0
  49. {iris_pgwire-1.2.33 → iris_pgwire-1.2.34}/src/iris_pgwire/debug_tracer.py +0 -0
  50. {iris_pgwire-1.2.33 → iris_pgwire-1.2.34}/src/iris_pgwire/health_checker.py +0 -0
  51. {iris_pgwire-1.2.33 → iris_pgwire-1.2.34}/src/iris_pgwire/integratedml.py +0 -0
  52. {iris_pgwire-1.2.33 → iris_pgwire-1.2.34}/src/iris_pgwire/iris_constructs.py +0 -0
  53. {iris_pgwire-1.2.33 → iris_pgwire-1.2.34}/src/iris_pgwire/iris_log_handler.py +0 -0
  54. {iris_pgwire-1.2.33 → iris_pgwire-1.2.34}/src/iris_pgwire/iris_user_management.py +0 -0
  55. {iris_pgwire-1.2.33 → iris_pgwire-1.2.34}/src/iris_pgwire/models/__init__.py +0 -0
  56. {iris_pgwire-1.2.33 → iris_pgwire-1.2.34}/src/iris_pgwire/models/backend_config.py +0 -0
  57. {iris_pgwire-1.2.33 → iris_pgwire-1.2.34}/src/iris_pgwire/models/connection_pool_state.py +0 -0
  58. {iris_pgwire-1.2.33 → iris_pgwire-1.2.34}/src/iris_pgwire/models/dbapi_connection.py +0 -0
  59. {iris_pgwire-1.2.33 → iris_pgwire-1.2.34}/src/iris_pgwire/models/ipm_metadata.py +0 -0
  60. {iris_pgwire-1.2.33 → iris_pgwire-1.2.34}/src/iris_pgwire/models/vector_query_request.py +0 -0
  61. {iris_pgwire-1.2.33 → iris_pgwire-1.2.34}/src/iris_pgwire/observability.py +0 -0
  62. {iris_pgwire-1.2.33 → iris_pgwire-1.2.34}/src/iris_pgwire/performance_monitor.py +0 -0
  63. {iris_pgwire-1.2.33 → iris_pgwire-1.2.34}/src/iris_pgwire/protocol.py +0 -0
  64. {iris_pgwire-1.2.33 → iris_pgwire-1.2.34}/src/iris_pgwire/quality/__init__.py +0 -0
  65. {iris_pgwire-1.2.33 → iris_pgwire-1.2.34}/src/iris_pgwire/quality/__main__.py +0 -0
  66. {iris_pgwire-1.2.33 → iris_pgwire-1.2.34}/src/iris_pgwire/quality/code_quality_validator.py +0 -0
  67. {iris_pgwire-1.2.33 → iris_pgwire-1.2.34}/src/iris_pgwire/quality/documentation_validator.py +0 -0
  68. {iris_pgwire-1.2.33 → iris_pgwire-1.2.34}/src/iris_pgwire/quality/package_metadata_validator.py +0 -0
  69. {iris_pgwire-1.2.33 → iris_pgwire-1.2.34}/src/iris_pgwire/quality/security_validator.py +0 -0
  70. {iris_pgwire-1.2.33 → iris_pgwire-1.2.34}/src/iris_pgwire/quality/validator.py +0 -0
  71. {iris_pgwire-1.2.33 → iris_pgwire-1.2.34}/src/iris_pgwire/schema_mapper.py +0 -0
  72. {iris_pgwire-1.2.33 → iris_pgwire-1.2.34}/src/iris_pgwire/server.py +0 -0
  73. {iris_pgwire-1.2.33 → iris_pgwire-1.2.34}/src/iris_pgwire/sql_translator/__init__.py +0 -0
  74. {iris_pgwire-1.2.33 → iris_pgwire-1.2.34}/src/iris_pgwire/sql_translator/alias_extractor.py +0 -0
  75. {iris_pgwire-1.2.33 → iris_pgwire-1.2.34}/src/iris_pgwire/sql_translator/api.py +0 -0
  76. {iris_pgwire-1.2.33 → iris_pgwire-1.2.34}/src/iris_pgwire/sql_translator/boolean_translator.py +0 -0
  77. {iris_pgwire-1.2.33 → iris_pgwire-1.2.34}/src/iris_pgwire/sql_translator/cache.py +0 -0
  78. {iris_pgwire-1.2.33 → iris_pgwire-1.2.34}/src/iris_pgwire/sql_translator/confidence_analyzer.py +0 -0
  79. {iris_pgwire-1.2.33 → iris_pgwire-1.2.34}/src/iris_pgwire/sql_translator/config.py +0 -0
  80. {iris_pgwire-1.2.33 → iris_pgwire-1.2.34}/src/iris_pgwire/sql_translator/copy_parser.py +0 -0
  81. {iris_pgwire-1.2.33 → iris_pgwire-1.2.34}/src/iris_pgwire/sql_translator/date_translator.py +0 -0
  82. {iris_pgwire-1.2.33 → iris_pgwire-1.2.34}/src/iris_pgwire/sql_translator/debug.py +0 -0
  83. {iris_pgwire-1.2.33 → iris_pgwire-1.2.34}/src/iris_pgwire/sql_translator/default_values.py +0 -0
  84. {iris_pgwire-1.2.33 → iris_pgwire-1.2.34}/src/iris_pgwire/sql_translator/enum_registry.py +0 -0
  85. {iris_pgwire-1.2.33 → iris_pgwire-1.2.34}/src/iris_pgwire/sql_translator/enum_translator.py +0 -0
  86. {iris_pgwire-1.2.33 → iris_pgwire-1.2.34}/src/iris_pgwire/sql_translator/error_handler.py +0 -0
  87. {iris_pgwire-1.2.33 → iris_pgwire-1.2.34}/src/iris_pgwire/sql_translator/identifier_normalizer.py +0 -0
  88. {iris_pgwire-1.2.33 → iris_pgwire-1.2.34}/src/iris_pgwire/sql_translator/interceptor.py +0 -0
  89. {iris_pgwire-1.2.33 → iris_pgwire-1.2.34}/src/iris_pgwire/sql_translator/logging_config.py +0 -0
  90. {iris_pgwire-1.2.33 → iris_pgwire-1.2.34}/src/iris_pgwire/sql_translator/mappings/__init__.py +0 -0
  91. {iris_pgwire-1.2.33 → iris_pgwire-1.2.34}/src/iris_pgwire/sql_translator/mappings/constructs.py +0 -0
  92. {iris_pgwire-1.2.33 → iris_pgwire-1.2.34}/src/iris_pgwire/sql_translator/mappings/datatypes.py +0 -0
  93. {iris_pgwire-1.2.33 → iris_pgwire-1.2.34}/src/iris_pgwire/sql_translator/mappings/document_filters.py +0 -0
  94. {iris_pgwire-1.2.33 → iris_pgwire-1.2.34}/src/iris_pgwire/sql_translator/mappings/functions.py +0 -0
  95. {iris_pgwire-1.2.33 → iris_pgwire-1.2.34}/src/iris_pgwire/sql_translator/metrics.py +0 -0
  96. {iris_pgwire-1.2.33 → iris_pgwire-1.2.34}/src/iris_pgwire/sql_translator/models.py +0 -0
  97. {iris_pgwire-1.2.33 → iris_pgwire-1.2.34}/src/iris_pgwire/sql_translator/normalizer.py +0 -0
  98. {iris_pgwire-1.2.33 → iris_pgwire-1.2.34}/src/iris_pgwire/sql_translator/parser.py +0 -0
  99. {iris_pgwire-1.2.33 → iris_pgwire-1.2.34}/src/iris_pgwire/sql_translator/performance_monitor.py +0 -0
  100. {iris_pgwire-1.2.33 → iris_pgwire-1.2.34}/src/iris_pgwire/sql_translator/pipeline.py +0 -0
  101. {iris_pgwire-1.2.33 → iris_pgwire-1.2.34}/src/iris_pgwire/sql_translator/refiner.py +0 -0
  102. {iris_pgwire-1.2.33 → iris_pgwire-1.2.34}/src/iris_pgwire/sql_translator/skipped_table_set.py +0 -0
  103. {iris_pgwire-1.2.33 → iris_pgwire-1.2.34}/src/iris_pgwire/sql_translator/statement_filter.py +0 -0
  104. {iris_pgwire-1.2.33 → iris_pgwire-1.2.34}/src/iris_pgwire/sql_translator/transaction_translator.py +0 -0
  105. {iris_pgwire-1.2.33 → iris_pgwire-1.2.34}/src/iris_pgwire/sql_translator/translator.py +0 -0
  106. {iris_pgwire-1.2.33 → iris_pgwire-1.2.34}/src/iris_pgwire/sql_translator/validator.py +0 -0
  107. {iris_pgwire-1.2.33 → iris_pgwire-1.2.34}/src/iris_pgwire/testing/__init__.py +0 -0
  108. {iris_pgwire-1.2.33 → iris_pgwire-1.2.34}/src/iris_pgwire/testing/base_fixture_builder.py +0 -0
  109. {iris_pgwire-1.2.33 → iris_pgwire-1.2.34}/src/iris_pgwire/type_mapping.py +0 -0
  110. {iris_pgwire-1.2.33 → iris_pgwire-1.2.34}/src/iris_pgwire/vector_metrics.py +0 -0
  111. {iris_pgwire-1.2.33 → iris_pgwire-1.2.34}/src/iris_pgwire/vector_optimizer.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: iris-pgwire
3
- Version: 1.2.33
3
+ Version: 1.2.34
4
4
  Summary: PostgreSQL Wire Protocol Server for InterSystems IRIS - Connect BI tools, Python frameworks, and PostgreSQL clients to IRIS databases
5
5
  Project-URL: Homepage, https://github.com/intersystems-community/iris-pgwire
6
6
  Project-URL: Documentation, https://github.com/intersystems-community/iris-pgwire#readme
@@ -6,7 +6,7 @@ Based on the specification from docs/iris_pgwire_plan.md and proven patterns fro
6
6
  caretdev/sqlalchemy-iris.
7
7
  """
8
8
 
9
- __version__ = "1.2.33"
9
+ __version__ = "1.2.34"
10
10
  __author__ = "Thomas Dyar <thomas.dyar@intersystems.com>"
11
11
 
12
12
  # Don't import server/protocol in __init__ to avoid sys.modules conflicts
@@ -14,6 +14,7 @@ Contract: contracts/dbapi-executor-contract.md
14
14
  """
15
15
 
16
16
  import asyncio
17
+ import datetime as dt
17
18
  import re
18
19
  import time
19
20
  from typing import Any
@@ -25,12 +26,43 @@ from iris_pgwire.dbapi_connection_pool import IRISConnectionPool
25
26
  from iris_pgwire.models.backend_config import BackendConfig
26
27
  from iris_pgwire.models.connection_pool_state import ConnectionPoolState
27
28
  from iris_pgwire.models.vector_query_request import VectorQueryRequest
29
+ from iris_pgwire.schema_mapper import IRIS_SCHEMA
28
30
  from iris_pgwire.sql_translator import SQLPipeline
29
31
  from iris_pgwire.sql_translator.parser import get_parser
30
32
 
31
33
  logger = structlog.get_logger(__name__)
32
34
 
33
35
 
36
+ class MockResult:
37
+ """Mock result object for RETURNING emulation"""
38
+
39
+ def __init__(self, rows, meta=None):
40
+ self._rows = rows if rows is not None else []
41
+ self._meta = meta
42
+ self.description = meta
43
+ self.rowcount = len(self._rows)
44
+ self._index = 0
45
+
46
+ def __iter__(self):
47
+ return iter(self._rows)
48
+
49
+ def fetchall(self):
50
+ return self._rows
51
+
52
+ def fetchone(self):
53
+ if self._index < len(self._rows):
54
+ row = self._rows[self._index]
55
+ self._index += 1
56
+ return row
57
+ return None
58
+
59
+ def fetch(self):
60
+ return self._rows
61
+
62
+ def close(self):
63
+ pass
64
+
65
+
34
66
  class DBAPIExecutor:
35
67
  """
36
68
  Execute SQL queries against IRIS via DBAPI backend.
@@ -103,11 +135,9 @@ class DBAPIExecutor:
103
135
  def _convert_value_for_iris(self, value: Any) -> Any:
104
136
  """Helper to convert a single value."""
105
137
  if isinstance(value, str):
106
- # FR-004: Normalize ISO 8601 timestamp strings for IRIS
107
- # Handles: YYYY-MM-DD[T ]HH:MM:SS[.fff][Z|[+-]HH:MM]
138
+ # Check for ISO 8601 timestamp: 2026-01-29T21:27:38.111Z
139
+ # or 2026-01-29T21:27:38.111+00:00
108
140
  # IRIS rejects the 'T' and 'Z' or offset in %PosixTime/TIMESTAMP
109
- import re
110
-
111
141
  ts_match = re.match(
112
142
  r"^(\d{4}-\d{2}-\d{2})[T ](\d{2}:\d{2}:\d{2}(?:\.\d+)?)(?:Z|[+-]\d{2}:?(\d{2})?)?$",
113
143
  value,
@@ -151,21 +181,55 @@ class DBAPIExecutor:
151
181
  # Acquire connection from pool
152
182
  conn_wrapper = await self.pool.acquire()
153
183
 
184
+ # Detect RETURNING clause
185
+ has_returning = self.has_returning_clause(sql)
186
+
154
187
  # Execute query in thread pool (DBAPI is synchronous)
155
188
  def execute_in_thread():
156
189
  cursor = conn_wrapper.connection.cursor() # type: ignore
157
190
  try:
158
- # Strip trailing semicolon for IRIS compatibility
159
- clean_sql = sql.strip().rstrip(";")
160
-
161
191
  # Feature 034: Apply per-session namespace if set
162
192
  if session_id and session_id in self.session_namespaces:
163
193
  ns = self.session_namespaces[session_id]
164
- # In DBAPI, we switch namespace by executing a command if supported,
165
- # but IRIS DBAPI connection is usually fixed to a namespace.
166
- # For now, we log it.
167
194
  logger.debug(f"Session {session_id} using namespace {ns}")
168
195
 
196
+ # Handle RETURNING emulation
197
+ if has_returning:
198
+ op, table, cols, where, stripped_sql = self._parse_returning_clause(sql)
199
+ if op and table:
200
+ # Strip trailing semicolon
201
+ clean_sql = stripped_sql.strip().rstrip(";")
202
+
203
+ # For DELETE, we must fetch BEFORE deleting
204
+ delete_rows = []
205
+ delete_meta = None
206
+ if op == "DELETE":
207
+ delete_rows, delete_meta = self._emulate_returning_sync(
208
+ cursor, op, table, cols, where, converted_params, sql
209
+ )
210
+
211
+ # Execute the main statement
212
+ if converted_params:
213
+ cursor.execute(clean_sql, converted_params)
214
+ else:
215
+ cursor.execute(clean_sql)
216
+
217
+ # Emulate RETURNING result
218
+ if op == "DELETE":
219
+ rows = delete_rows
220
+ columns = delete_meta
221
+ else:
222
+ rows, columns = self._emulate_returning_sync(
223
+ cursor, op, table, cols, where, converted_params, sql
224
+ )
225
+
226
+ row_count = len(rows)
227
+ return rows, columns, row_count
228
+
229
+ # Standard execution path
230
+ # Strip trailing semicolon for IRIS compatibility
231
+ clean_sql = sql.strip().rstrip(";")
232
+
169
233
  if converted_params:
170
234
  cursor.execute(clean_sql, converted_params)
171
235
  else:
@@ -260,13 +324,8 @@ class DBAPIExecutor:
260
324
  """
261
325
  Execute SQL with multiple parameter sets for batch operations.
262
326
 
263
- Args:
264
- sql: SQL query string (usually INSERT)
265
- params_list: List of parameter tuples/lists
266
- session_id: Optional session identifier
267
-
268
- Returns:
269
- Dict with execution results (rows_affected, execution_time_ms, etc.)
327
+ RETURNING SUPPORT: When SQL contains RETURNING clause, executes each statement
328
+ individually and aggregates the returned rows.
270
329
  """
271
330
  start_time = time.perf_counter()
272
331
  conn_wrapper = None
@@ -275,6 +334,9 @@ class DBAPIExecutor:
275
334
  # Translate placeholders ($1 -> ?)
276
335
  sql = self._translate_placeholders(sql)
277
336
 
337
+ # Detect RETURNING clause
338
+ has_returning = self.has_returning_clause(sql)
339
+
278
340
  # Acquire connection from pool
279
341
  conn_wrapper = await self.pool.acquire()
280
342
 
@@ -285,6 +347,38 @@ class DBAPIExecutor:
285
347
  # Strip trailing semicolon for IRIS compatibility
286
348
  clean_sql = sql.strip().rstrip(";")
287
349
 
350
+ if has_returning:
351
+ op, table, cols, where, stripped_sql = self._parse_returning_clause(sql)
352
+ if op and table:
353
+ all_rows = []
354
+ all_meta = None
355
+
356
+ for params in params_list:
357
+ converted_params = self._convert_params_for_iris(params)
358
+ # For DELETE, capture before
359
+ if op == "DELETE":
360
+ rows, meta = self._emulate_returning_sync(
361
+ cursor, op, table, cols, where, converted_params, sql
362
+ )
363
+ all_rows.extend(rows)
364
+ if not all_meta:
365
+ all_meta = meta
366
+
367
+ # Execute statement
368
+ cursor.execute(stripped_sql.strip().rstrip(";"), converted_params)
369
+
370
+ # For INSERT/UPDATE, capture after
371
+ if op != "DELETE":
372
+ rows, meta = self._emulate_returning_sync(
373
+ cursor, op, table, cols, where, converted_params, sql
374
+ )
375
+ all_rows.extend(rows)
376
+ if not all_meta:
377
+ all_meta = meta
378
+
379
+ return all_rows, all_meta or [], len(params_list)
380
+
381
+ # Standard batch execution
288
382
  # Pre-process parameters (e.g. convert lists to IRIS vector strings)
289
383
  final_params_list = []
290
384
  for p_set in params_list:
@@ -307,11 +401,11 @@ class DBAPIExecutor:
307
401
  rows_affected = (
308
402
  cursor.rowcount if hasattr(cursor, "rowcount") else len(params_list)
309
403
  )
310
- return rows_affected
404
+ return [], [], rows_affected
311
405
  finally:
312
406
  cursor.close()
313
407
 
314
- rows_affected = await asyncio.to_thread(execute_batch_in_thread)
408
+ rows, columns, rows_affected = await asyncio.to_thread(execute_batch_in_thread)
315
409
 
316
410
  # Record metrics
317
411
  elapsed_ms = (time.perf_counter() - start_time) * 1000
@@ -332,9 +426,11 @@ class DBAPIExecutor:
332
426
  "rows_affected": rows_affected,
333
427
  "execution_time_ms": elapsed_ms,
334
428
  "batch_size": len(params_list),
335
- "rows": [],
336
- "columns": [],
337
- "_execution_path": "dbapi_executemany",
429
+ "rows": rows,
430
+ "columns": columns,
431
+ "_execution_path": (
432
+ "dbapi_executemany_returning" if has_returning else "dbapi_executemany"
433
+ ),
338
434
  }
339
435
 
340
436
  except Exception as e:
@@ -433,14 +529,18 @@ class DBAPIExecutor:
433
529
  }
434
530
 
435
531
  def has_returning_clause(self, query: str) -> bool:
436
- """Check if query has a RETURNING clause."""
532
+ """
533
+ Check if query has a RETURNING clause.
534
+ """
437
535
  if not query:
438
536
  return False
439
537
  return bool(re.search(r"\bRETURNING\b", query, re.IGNORECASE | re.DOTALL))
440
538
 
441
539
  def get_returning_columns(self, query: str) -> list[str]:
442
- """Extract column names from RETURNING clause."""
443
- match = re.search(r"RETURNING\s+(.+)$", query, re.IGNORECASE | re.DOTALL)
540
+ """
541
+ Extract column names from RETURNING clause.
542
+ """
543
+ match = re.search(r"RETURNING\s+(.+?)(?=$|;)", query, re.IGNORECASE | re.DOTALL)
444
544
  if not match:
445
545
  return []
446
546
  cols_str = match.group(1).strip()
@@ -448,6 +548,356 @@ class DBAPIExecutor:
448
548
  return ["*"]
449
549
  return [c.strip() for c in cols_str.split(",")]
450
550
 
551
+ def _get_table_columns_from_schema(self, table: str, cursor=None) -> list[str]:
552
+ """
553
+ Query INFORMATION_SCHEMA.COLUMNS for the given table.
554
+ Returns the list of column names in order.
555
+ """
556
+ if self.strict_single_connection or cursor is None:
557
+ return []
558
+ try:
559
+ table_clean = table.strip('"').strip("'")
560
+ metadata_sql = f"""
561
+ SELECT COLUMN_NAME
562
+ FROM INFORMATION_SCHEMA.COLUMNS
563
+ WHERE LOWER(TABLE_NAME) = LOWER('{table_clean}')
564
+ AND LOWER(TABLE_SCHEMA) = LOWER('{IRIS_SCHEMA}')
565
+ ORDER BY ORDINAL_POSITION
566
+ """
567
+ cursor.execute(metadata_sql)
568
+ rows = cursor.fetchall()
569
+ return [row[0] for row in rows]
570
+ except Exception as e:
571
+ logger.debug(f"Failed to get columns from schema for {table}: {e}")
572
+ return []
573
+
574
+ def _get_column_type_from_schema(self, table: str, column: str, cursor=None) -> int | None:
575
+ """
576
+ Query INFORMATION_SCHEMA.COLUMNS for the given table and column.
577
+ Returns the PostgreSQL type OID.
578
+ """
579
+ if self.strict_single_connection or cursor is None:
580
+ return None
581
+ try:
582
+ table_clean = table.strip('"').strip("'")
583
+ column_clean = column.strip('"').strip("'")
584
+ metadata_sql = f"""
585
+ SELECT DATA_TYPE
586
+ FROM INFORMATION_SCHEMA.COLUMNS
587
+ WHERE LOWER(TABLE_NAME) = LOWER('{table_clean}')
588
+ AND LOWER(COLUMN_NAME) = LOWER('{column_clean}')
589
+ AND LOWER(TABLE_SCHEMA) = LOWER('{IRIS_SCHEMA}')
590
+ """
591
+ cursor.execute(metadata_sql)
592
+ row = cursor.fetchone()
593
+ if row:
594
+ iris_type = row[0]
595
+ return self._map_iris_type_to_oid(iris_type)
596
+ except Exception as e:
597
+ logger.debug(f"Failed to get type from schema for {table}.{column}: {e}")
598
+ return None
599
+
600
+ def _infer_type_from_value(self, value, column_name: str | None = None) -> int:
601
+ """
602
+ Infer PostgreSQL type OID from Python value
603
+ """
604
+ from decimal import Decimal
605
+
606
+ if value is None:
607
+ return 1043 # VARCHAR
608
+ elif isinstance(value, bool):
609
+ return 16 # BOOL
610
+ elif isinstance(value, int):
611
+ if column_name and any(k in column_name.lower() for k in ("id", "key")):
612
+ return 20 # BIGINT
613
+ return 23 # INTEGER
614
+ elif isinstance(value, float):
615
+ return 701 # FLOAT8
616
+ elif isinstance(value, Decimal):
617
+ return 1700 # NUMERIC
618
+ elif isinstance(value, dt.datetime):
619
+ return 1114
620
+ elif isinstance(value, dt.date):
621
+ return 1082
622
+ elif isinstance(value, str):
623
+ return 1043 # VARCHAR
624
+ else:
625
+ return 1043
626
+
627
+ def _serialize_value(self, value: Any, type_oid: int) -> Any:
628
+ """
629
+ Robust value serialization for PostgreSQL wire protocol compatibility.
630
+ """
631
+ if value is None:
632
+ return None
633
+
634
+ if type_oid == 1114: # TIMESTAMP
635
+ if isinstance(value, dt.datetime):
636
+ return value.strftime("%Y-%m-%dT%H:%M:%S.%fZ")
637
+ elif isinstance(value, str):
638
+ return value # Already a string
639
+
640
+ return value
641
+
642
+ def _parse_returning_clause(
643
+ self, sql: str
644
+ ) -> tuple[str | None, str | None, Any, str | None, str]:
645
+ """
646
+ Parse RETURNING clause from SQL and return metadata.
647
+ Returns: (operation, table, columns, where_clause, stripped_sql)
648
+ """
649
+ returning_operation = None
650
+ returning_table = None
651
+ returning_columns = None
652
+ returning_where_clause = None
653
+
654
+ returning_pattern = r"\s+RETURNING\s+(.*?)($|;)"
655
+ returning_match = re.search(returning_pattern, sql, re.IGNORECASE | re.DOTALL)
656
+
657
+ if not returning_match:
658
+ return None, None, None, None, sql
659
+
660
+ returning_clause = returning_match.group(1).strip()
661
+
662
+ if returning_clause == "*":
663
+ returning_columns = "*"
664
+ else:
665
+ # Better column parsing that preserves expressions and aliases
666
+ # Split by commas but respect parentheses
667
+ returning_columns = []
668
+ current_col = ""
669
+ depth = 0
670
+ for char in returning_clause:
671
+ if char == "(":
672
+ depth += 1
673
+ elif char == ")":
674
+ depth -= 1
675
+
676
+ if char == "," and depth == 0:
677
+ col = current_col.strip()
678
+ # Extract last part of identifier if it's schema-qualified
679
+ # e.g. public.users.id -> id, or "public"."users"."id" -> id
680
+ col_match = re.search(r'"?(\w+)"?\s*$', col)
681
+ if col_match:
682
+ returning_columns.append(col_match.group(1).lower())
683
+ else:
684
+ returning_columns.append(col.lower())
685
+ current_col = ""
686
+ else:
687
+ current_col += char
688
+ if current_col.strip():
689
+ col = current_col.strip()
690
+ col_match = re.search(r'"?(\w+)"?\s*$', col)
691
+ if col_match:
692
+ returning_columns.append(col_match.group(1).lower())
693
+ else:
694
+ returning_columns.append(col.lower())
695
+
696
+ sql_upper = sql.upper().strip()
697
+ # Robust table extraction regex for all operations
698
+ table_regex = r'(?:INSERT\s+INTO|UPDATE|DELETE\s+FROM)\s+(?:(?:"?\w+"?)\s*\.\s*)*"?(\w+)"?'
699
+ table_match = re.search(table_regex, sql, re.IGNORECASE)
700
+ if table_match:
701
+ returning_table = table_match.group(1).upper()
702
+
703
+ if sql_upper.startswith("INSERT"):
704
+ returning_operation = "INSERT"
705
+ elif sql_upper.startswith("UPDATE"):
706
+ returning_operation = "UPDATE"
707
+ where_match = re.search(
708
+ r"\bWHERE\s+(.+?)\s+RETURNING\b",
709
+ sql,
710
+ re.IGNORECASE | re.DOTALL,
711
+ )
712
+ if where_match:
713
+ returning_where_clause = where_match.group(1).strip()
714
+ elif sql_upper.startswith("DELETE"):
715
+ returning_operation = "DELETE"
716
+ where_match = re.search(
717
+ r"\bWHERE\s+(.+?)\s+RETURNING\b",
718
+ sql,
719
+ re.IGNORECASE | re.DOTALL,
720
+ )
721
+ if where_match:
722
+ returning_where_clause = where_match.group(1).strip()
723
+
724
+ stripped_sql = re.sub(
725
+ r"\s+RETURNING\s+.*?(?=$|;)",
726
+ "",
727
+ sql,
728
+ flags=re.IGNORECASE | re.DOTALL,
729
+ count=1,
730
+ )
731
+
732
+ return (
733
+ returning_operation,
734
+ returning_table,
735
+ returning_columns,
736
+ returning_where_clause,
737
+ stripped_sql,
738
+ )
739
+
740
+ def _expand_select_star(self, sql: str, expected_columns: int, cursor=None) -> list[str] | None:
741
+ """
742
+ Expand SELECT * or RETURNING * into explicit column names using INFORMATION_SCHEMA.
743
+ """
744
+ try:
745
+ table_name = None
746
+ sql_upper = sql.upper()
747
+
748
+ if "RETURNING" in sql_upper:
749
+ table_regex = (
750
+ r'(?:INSERT\s+INTO|UPDATE|DELETE\s+FROM)\s+(?:(?:"?\w+"?)\s*\.\s*)*"?(\w+)"?'
751
+ )
752
+ table_match = re.search(table_regex, sql, re.IGNORECASE)
753
+ if table_match:
754
+ table_name = table_match.group(1)
755
+ else:
756
+ from_match = re.search(r"FROM\s+([^\s,;()]+)", sql, re.IGNORECASE)
757
+ if from_match:
758
+ table_name = from_match.group(1)
759
+
760
+ if table_name:
761
+ if "." in table_name:
762
+ table_name = table_name.split(".")[-1]
763
+ table_name = table_name.strip('"').strip("'")
764
+
765
+ schema_columns = self._get_table_columns_from_schema(table_name, cursor)
766
+ if schema_columns:
767
+ if expected_columns == 0 or len(schema_columns) == expected_columns:
768
+ return schema_columns
769
+ return None
770
+ except Exception as e:
771
+ logger.debug(f"Failed to expand SELECT *: {e}")
772
+ return None
773
+
774
+ def _extract_insert_id_from_sql(
775
+ self, sql: str, params: list | None, session_id: str | None = None
776
+ ) -> tuple[str | None, Any]:
777
+ """
778
+ Extract the ID value from an INSERT statement.
779
+ """
780
+ col_match = re.search(r"INSERT\s+INTO\s+[^\s(]+\s*\(\s*([^)]+)\s*\)", sql, re.IGNORECASE)
781
+ if not col_match:
782
+ return None, None
783
+
784
+ columns_str = col_match.group(1)
785
+ columns = [c.strip().strip('"').strip("'").lower() for c in columns_str.split(",")]
786
+
787
+ id_col_names = ["id", "uuid", "_id"]
788
+ id_col_idx = None
789
+ id_col_name = None
790
+ for i, col in enumerate(columns):
791
+ if col in id_col_names:
792
+ id_col_idx = i
793
+ id_col_name = col
794
+ break
795
+
796
+ if id_col_idx is None:
797
+ return None, None
798
+
799
+ if params and len(params) > id_col_idx:
800
+ return id_col_name, params[id_col_idx]
801
+
802
+ return None, None
803
+
804
+ def _emulate_returning_sync(
805
+ self,
806
+ cursor,
807
+ operation: str,
808
+ table: str,
809
+ columns: list[str] | str,
810
+ where_clause: str | None,
811
+ params: list | None,
812
+ original_sql: str | None = None,
813
+ ) -> tuple[list[Any], Any]:
814
+ """
815
+ Synchronous emulation of RETURNING clause.
816
+ """
817
+ table_normalized = table.upper() if table else table
818
+ if columns == "*":
819
+ # Expand * using table schema
820
+ expanded_cols = self._get_table_columns_from_schema(table_normalized, cursor)
821
+ if expanded_cols:
822
+ col_list = ", ".join([f'"{col}"' for col in expanded_cols])
823
+ columns = expanded_cols # Update columns for metadata generation
824
+ else:
825
+ col_list = "*"
826
+ else:
827
+ col_list = ", ".join([f'"{col}"' for col in columns])
828
+
829
+ rows = []
830
+ meta = None
831
+
832
+ try:
833
+ if operation == "INSERT":
834
+ # Method 1: LAST_IDENTITY()
835
+ cursor.execute("SELECT LAST_IDENTITY()")
836
+ id_row = cursor.fetchone()
837
+ last_id = id_row[0] if id_row else None
838
+
839
+ if last_id:
840
+ cursor.execute(
841
+ f'SELECT {col_list} FROM {IRIS_SCHEMA}."{table_normalized}" WHERE %ID = ?',
842
+ (last_id,),
843
+ )
844
+ rows = cursor.fetchall()
845
+ meta = cursor.description
846
+
847
+ # Method 2: Extract from SQL if still no rows
848
+ if not rows and original_sql:
849
+ id_col_name, id_value = self._extract_insert_id_from_sql(original_sql, params)
850
+ if id_col_name and id_value:
851
+ cursor.execute(
852
+ f'SELECT {col_list} FROM {IRIS_SCHEMA}."{table_normalized}" WHERE "{id_col_name}" = ?',
853
+ (id_value,),
854
+ )
855
+ rows = cursor.fetchall()
856
+ meta = cursor.description
857
+
858
+ elif operation in ("UPDATE", "DELETE"):
859
+ if where_clause:
860
+ # Translate schema references in WHERE clause
861
+ translated_where = re.sub(
862
+ r'"public"\s*\.\s*"(\w+)"',
863
+ rf'{IRIS_SCHEMA}."\1"',
864
+ where_clause,
865
+ flags=re.IGNORECASE,
866
+ )
867
+ translated_where = re.sub(
868
+ r'\bpublic\s*\.\s*"(\w+)"',
869
+ rf'{IRIS_SCHEMA}."\1"',
870
+ translated_where,
871
+ flags=re.IGNORECASE,
872
+ )
873
+
874
+ # Very basic where clause parameter extraction
875
+ where_param_count = translated_where.count("?")
876
+ where_params = (
877
+ params[-where_param_count:] if params and where_param_count > 0 else None
878
+ )
879
+
880
+ cursor.execute(
881
+ f'SELECT {col_list} FROM {IRIS_SCHEMA}."{table_normalized}" WHERE {translated_where}',
882
+ where_params or (),
883
+ )
884
+ rows = cursor.fetchall()
885
+ meta = cursor.description
886
+
887
+ # Build metadata if needed
888
+ if meta and not any(isinstance(m, dict) and "type_oid" in m for m in meta):
889
+ new_meta = []
890
+ for i, desc in enumerate(meta):
891
+ col_name = desc[0]
892
+ col_oid = self._map_dbapi_type_to_oid(desc[1])
893
+ new_meta.append({"name": col_name, "type_oid": col_oid, "format_code": 0})
894
+ meta = new_meta
895
+
896
+ except Exception as e:
897
+ logger.error(f"RETURNING emulation failed: {e}")
898
+
899
+ return rows, meta
900
+
451
901
  def _map_dbapi_type_to_oid(self, dbapi_type: Any) -> int:
452
902
  """Map DBAPI type to PostgreSQL OID."""
453
903
  # Simple mapping for now, can be expanded
@@ -462,6 +912,41 @@ class DBAPIExecutor:
462
912
  return 1114
463
913
  return 1043 # Default to VARCHAR
464
914
 
915
+ def _map_iris_type_to_oid(self, iris_type: str) -> int:
916
+ """
917
+ Map IRIS data type to PostgreSQL type OID.
918
+
919
+ Args:
920
+ iris_type: IRIS data type (e.g., 'INT', 'VARCHAR', 'DATE')
921
+
922
+ Returns:
923
+ PostgreSQL type OID
924
+ """
925
+ type_map = {
926
+ "INT": 23, # int4
927
+ "INTEGER": 23, # int4
928
+ "BIGINT": 20, # int8
929
+ "SMALLINT": 21, # int2
930
+ "VARCHAR": 1043, # varchar
931
+ "CHAR": 1042, # char
932
+ "TEXT": 25, # text
933
+ "DATE": 1082, # date
934
+ "TIME": 1083, # time
935
+ "TIMESTAMP": 1114, # timestamp
936
+ "DOUBLE": 701, # float8
937
+ "FLOAT": 701, # float8
938
+ "NUMERIC": 1700, # numeric
939
+ "DECIMAL": 1700, # numeric
940
+ "BIT": 1560, # bit
941
+ "BOOLEAN": 16, # bool
942
+ "VARBINARY": 17, # bytea
943
+ }
944
+
945
+ # Normalize type name (remove size, etc.)
946
+ normalized_type = iris_type.upper().split("(")[0].strip()
947
+
948
+ return type_map.get(normalized_type, 1043) # Default to VARCHAR (OID 1043)
949
+
465
950
  def _determine_command_tag(self, sql: str, row_count: int) -> str:
466
951
  """Determine PostgreSQL command tag from SQL"""
467
952
  sql_clean = sql.strip().upper()