iris-pgwire 1.2.32__py3-none-any.whl → 1.2.34__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- iris_pgwire/__init__.py +1 -1
- iris_pgwire/dbapi_executor.py +544 -24
- iris_pgwire/iris_executor.py +128 -88
- {iris_pgwire-1.2.32.dist-info → iris_pgwire-1.2.34.dist-info}/METADATA +1 -1
- {iris_pgwire-1.2.32.dist-info → iris_pgwire-1.2.34.dist-info}/RECORD +8 -8
- {iris_pgwire-1.2.32.dist-info → iris_pgwire-1.2.34.dist-info}/WHEEL +0 -0
- {iris_pgwire-1.2.32.dist-info → iris_pgwire-1.2.34.dist-info}/entry_points.txt +0 -0
- {iris_pgwire-1.2.32.dist-info → iris_pgwire-1.2.34.dist-info}/licenses/LICENSE +0 -0
iris_pgwire/__init__.py
CHANGED
|
@@ -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.
|
|
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
|
iris_pgwire/dbapi_executor.py
CHANGED
|
@@ -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.
|
|
@@ -87,6 +119,33 @@ class DBAPIExecutor:
|
|
|
87
119
|
"""
|
|
88
120
|
return re.sub(r"\$\d+", "?", sql)
|
|
89
121
|
|
|
122
|
+
def _convert_params_for_iris(self, params: Any) -> Any:
|
|
123
|
+
"""
|
|
124
|
+
Convert parameters to IRIS-compatible formats.
|
|
125
|
+
Specifically handles ISO 8601 timestamps.
|
|
126
|
+
"""
|
|
127
|
+
if params is None:
|
|
128
|
+
return None
|
|
129
|
+
|
|
130
|
+
if isinstance(params, (list, tuple)):
|
|
131
|
+
return [self._convert_value_for_iris(v) for v in params]
|
|
132
|
+
|
|
133
|
+
return self._convert_value_for_iris(params)
|
|
134
|
+
|
|
135
|
+
def _convert_value_for_iris(self, value: Any) -> Any:
|
|
136
|
+
"""Helper to convert a single value."""
|
|
137
|
+
if isinstance(value, str):
|
|
138
|
+
# Check for ISO 8601 timestamp: 2026-01-29T21:27:38.111Z
|
|
139
|
+
# or 2026-01-29T21:27:38.111+00:00
|
|
140
|
+
# IRIS rejects the 'T' and 'Z' or offset in %PosixTime/TIMESTAMP
|
|
141
|
+
ts_match = re.match(
|
|
142
|
+
r"^(\d{4}-\d{2}-\d{2})[T ](\d{2}:\d{2}:\d{2}(?:\.\d+)?)(?:Z|[+-]\d{2}:?(\d{2})?)?$",
|
|
143
|
+
value,
|
|
144
|
+
)
|
|
145
|
+
if ts_match:
|
|
146
|
+
return f"{ts_match.group(1)} {ts_match.group(2)}"
|
|
147
|
+
return value
|
|
148
|
+
|
|
90
149
|
async def execute_query(
|
|
91
150
|
self, sql: str, params: tuple | None = None, session_id: str | None = None, **kwargs
|
|
92
151
|
) -> dict[str, Any]:
|
|
@@ -116,26 +175,63 @@ class DBAPIExecutor:
|
|
|
116
175
|
# Translate placeholders ($1 -> ?)
|
|
117
176
|
sql = self._translate_placeholders(sql)
|
|
118
177
|
|
|
178
|
+
# Convert parameters for IRIS (e.g., ISO 8601 timestamps)
|
|
179
|
+
converted_params = self._convert_params_for_iris(params)
|
|
180
|
+
|
|
119
181
|
# Acquire connection from pool
|
|
120
182
|
conn_wrapper = await self.pool.acquire()
|
|
121
183
|
|
|
184
|
+
# Detect RETURNING clause
|
|
185
|
+
has_returning = self.has_returning_clause(sql)
|
|
186
|
+
|
|
122
187
|
# Execute query in thread pool (DBAPI is synchronous)
|
|
123
188
|
def execute_in_thread():
|
|
124
189
|
cursor = conn_wrapper.connection.cursor() # type: ignore
|
|
125
190
|
try:
|
|
126
|
-
# Strip trailing semicolon for IRIS compatibility
|
|
127
|
-
clean_sql = sql.strip().rstrip(";")
|
|
128
|
-
|
|
129
191
|
# Feature 034: Apply per-session namespace if set
|
|
130
192
|
if session_id and session_id in self.session_namespaces:
|
|
131
193
|
ns = self.session_namespaces[session_id]
|
|
132
|
-
# In DBAPI, we switch namespace by executing a command if supported,
|
|
133
|
-
# but IRIS DBAPI connection is usually fixed to a namespace.
|
|
134
|
-
# For now, we log it.
|
|
135
194
|
logger.debug(f"Session {session_id} using namespace {ns}")
|
|
136
195
|
|
|
137
|
-
|
|
138
|
-
|
|
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
|
+
|
|
233
|
+
if converted_params:
|
|
234
|
+
cursor.execute(clean_sql, converted_params)
|
|
139
235
|
else:
|
|
140
236
|
cursor.execute(clean_sql)
|
|
141
237
|
|
|
@@ -228,13 +324,8 @@ class DBAPIExecutor:
|
|
|
228
324
|
"""
|
|
229
325
|
Execute SQL with multiple parameter sets for batch operations.
|
|
230
326
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
params_list: List of parameter tuples/lists
|
|
234
|
-
session_id: Optional session identifier
|
|
235
|
-
|
|
236
|
-
Returns:
|
|
237
|
-
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.
|
|
238
329
|
"""
|
|
239
330
|
start_time = time.perf_counter()
|
|
240
331
|
conn_wrapper = None
|
|
@@ -243,6 +334,9 @@ class DBAPIExecutor:
|
|
|
243
334
|
# Translate placeholders ($1 -> ?)
|
|
244
335
|
sql = self._translate_placeholders(sql)
|
|
245
336
|
|
|
337
|
+
# Detect RETURNING clause
|
|
338
|
+
has_returning = self.has_returning_clause(sql)
|
|
339
|
+
|
|
246
340
|
# Acquire connection from pool
|
|
247
341
|
conn_wrapper = await self.pool.acquire()
|
|
248
342
|
|
|
@@ -253,12 +347,47 @@ class DBAPIExecutor:
|
|
|
253
347
|
# Strip trailing semicolon for IRIS compatibility
|
|
254
348
|
clean_sql = sql.strip().rstrip(";")
|
|
255
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
|
|
256
382
|
# Pre-process parameters (e.g. convert lists to IRIS vector strings)
|
|
257
383
|
final_params_list = []
|
|
258
384
|
for p_set in params_list:
|
|
385
|
+
# Convert ISO 8601 timestamps and other formats
|
|
386
|
+
converted_p_set = self._convert_params_for_iris(p_set)
|
|
387
|
+
|
|
259
388
|
processed_params = [
|
|
260
389
|
"[" + ",".join(map(str, p)) + "]" if isinstance(p, list) else p
|
|
261
|
-
for p in
|
|
390
|
+
for p in converted_p_set
|
|
262
391
|
]
|
|
263
392
|
final_params_list.append(tuple(processed_params))
|
|
264
393
|
|
|
@@ -272,11 +401,11 @@ class DBAPIExecutor:
|
|
|
272
401
|
rows_affected = (
|
|
273
402
|
cursor.rowcount if hasattr(cursor, "rowcount") else len(params_list)
|
|
274
403
|
)
|
|
275
|
-
return rows_affected
|
|
404
|
+
return [], [], rows_affected
|
|
276
405
|
finally:
|
|
277
406
|
cursor.close()
|
|
278
407
|
|
|
279
|
-
rows_affected = await asyncio.to_thread(execute_batch_in_thread)
|
|
408
|
+
rows, columns, rows_affected = await asyncio.to_thread(execute_batch_in_thread)
|
|
280
409
|
|
|
281
410
|
# Record metrics
|
|
282
411
|
elapsed_ms = (time.perf_counter() - start_time) * 1000
|
|
@@ -297,9 +426,11 @@ class DBAPIExecutor:
|
|
|
297
426
|
"rows_affected": rows_affected,
|
|
298
427
|
"execution_time_ms": elapsed_ms,
|
|
299
428
|
"batch_size": len(params_list),
|
|
300
|
-
"rows":
|
|
301
|
-
"columns":
|
|
302
|
-
"_execution_path":
|
|
429
|
+
"rows": rows,
|
|
430
|
+
"columns": columns,
|
|
431
|
+
"_execution_path": (
|
|
432
|
+
"dbapi_executemany_returning" if has_returning else "dbapi_executemany"
|
|
433
|
+
),
|
|
303
434
|
}
|
|
304
435
|
|
|
305
436
|
except Exception as e:
|
|
@@ -398,14 +529,18 @@ class DBAPIExecutor:
|
|
|
398
529
|
}
|
|
399
530
|
|
|
400
531
|
def has_returning_clause(self, query: str) -> bool:
|
|
401
|
-
"""
|
|
532
|
+
"""
|
|
533
|
+
Check if query has a RETURNING clause.
|
|
534
|
+
"""
|
|
402
535
|
if not query:
|
|
403
536
|
return False
|
|
404
537
|
return bool(re.search(r"\bRETURNING\b", query, re.IGNORECASE | re.DOTALL))
|
|
405
538
|
|
|
406
539
|
def get_returning_columns(self, query: str) -> list[str]:
|
|
407
|
-
"""
|
|
408
|
-
|
|
540
|
+
"""
|
|
541
|
+
Extract column names from RETURNING clause.
|
|
542
|
+
"""
|
|
543
|
+
match = re.search(r"RETURNING\s+(.+?)(?=$|;)", query, re.IGNORECASE | re.DOTALL)
|
|
409
544
|
if not match:
|
|
410
545
|
return []
|
|
411
546
|
cols_str = match.group(1).strip()
|
|
@@ -413,6 +548,356 @@ class DBAPIExecutor:
|
|
|
413
548
|
return ["*"]
|
|
414
549
|
return [c.strip() for c in cols_str.split(",")]
|
|
415
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
|
+
|
|
416
901
|
def _map_dbapi_type_to_oid(self, dbapi_type: Any) -> int:
|
|
417
902
|
"""Map DBAPI type to PostgreSQL OID."""
|
|
418
903
|
# Simple mapping for now, can be expanded
|
|
@@ -427,6 +912,41 @@ class DBAPIExecutor:
|
|
|
427
912
|
return 1114
|
|
428
913
|
return 1043 # Default to VARCHAR
|
|
429
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
|
+
|
|
430
950
|
def _determine_command_tag(self, sql: str, row_count: int) -> str:
|
|
431
951
|
"""Determine PostgreSQL command tag from SQL"""
|
|
432
952
|
sql_clean = sql.strip().upper()
|
iris_pgwire/iris_executor.py
CHANGED
|
@@ -1211,11 +1211,14 @@ class IRISExecutor:
|
|
|
1211
1211
|
|
|
1212
1212
|
rows_affected = 0
|
|
1213
1213
|
for row_params in params_list:
|
|
1214
|
+
# Normalize parameters for IRIS (e.g. ISO timestamps)
|
|
1215
|
+
normalized_row_params = self._normalize_parameters(row_params)
|
|
1216
|
+
|
|
1214
1217
|
inline_sql = "N/A"
|
|
1215
1218
|
try:
|
|
1216
1219
|
# Build inline SQL by replacing ? placeholders with actual values
|
|
1217
1220
|
inline_sql = normalized_sql
|
|
1218
|
-
for param_value in
|
|
1221
|
+
for param_value in normalized_row_params:
|
|
1219
1222
|
# Convert value to SQL literal
|
|
1220
1223
|
if param_value is None:
|
|
1221
1224
|
sql_literal = "NULL"
|
|
@@ -1620,41 +1623,49 @@ class IRISExecutor:
|
|
|
1620
1623
|
if returning_clause == "*":
|
|
1621
1624
|
returning_columns = "*"
|
|
1622
1625
|
else:
|
|
1623
|
-
#
|
|
1624
|
-
#
|
|
1625
|
-
raw_cols = [c.strip() for c in returning_clause.split(",")]
|
|
1626
|
+
# Better column parsing that preserves expressions and aliases
|
|
1627
|
+
# Split by commas but respect parentheses
|
|
1626
1628
|
returning_columns = []
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
if
|
|
1629
|
+
current_col = ""
|
|
1630
|
+
depth = 0
|
|
1631
|
+
for char in returning_clause:
|
|
1632
|
+
if char == "(":
|
|
1633
|
+
depth += 1
|
|
1634
|
+
elif char == ")":
|
|
1635
|
+
depth -= 1
|
|
1636
|
+
|
|
1637
|
+
if char == "," and depth == 0:
|
|
1638
|
+
col = current_col.strip()
|
|
1639
|
+
# Extract last part of identifier if it's schema-qualified
|
|
1640
|
+
# e.g. public.users.id -> id, or "public"."users"."id" -> id
|
|
1631
1641
|
col_match = re.search(r'"?(\w+)"?\s*$', col)
|
|
1642
|
+
if col_match:
|
|
1643
|
+
returning_columns.append(col_match.group(1).lower())
|
|
1644
|
+
else:
|
|
1645
|
+
returning_columns.append(col.lower())
|
|
1646
|
+
current_col = ""
|
|
1632
1647
|
else:
|
|
1633
|
-
|
|
1634
|
-
|
|
1648
|
+
current_col += char
|
|
1649
|
+
if current_col.strip():
|
|
1650
|
+
col = current_col.strip()
|
|
1651
|
+
col_match = re.search(r'"?(\w+)"?\s*$', col)
|
|
1635
1652
|
if col_match:
|
|
1636
1653
|
returning_columns.append(col_match.group(1).lower())
|
|
1654
|
+
else:
|
|
1655
|
+
returning_columns.append(col.lower())
|
|
1637
1656
|
|
|
1638
1657
|
# Determine operation type and extract table/where clause
|
|
1639
1658
|
sql_upper = sql.upper().strip()
|
|
1659
|
+
# Robust table extraction regex for all operations
|
|
1660
|
+
table_regex = r'(?:INSERT\s+INTO|UPDATE|DELETE\s+FROM)\s+(?:(?:"?\w+"?)\s*\.\s*)*"?(\w+)"?'
|
|
1661
|
+
table_match = re.search(table_regex, sql, re.IGNORECASE)
|
|
1662
|
+
if table_match:
|
|
1663
|
+
returning_table = table_match.group(1).upper()
|
|
1664
|
+
|
|
1640
1665
|
if sql_upper.startswith("INSERT"):
|
|
1641
1666
|
returning_operation = "INSERT"
|
|
1642
|
-
table_match = re.search(
|
|
1643
|
-
rf'INSERT\s+INTO\s+(?:{re.escape(IRIS_SCHEMA)}\s*\.\s*)?"?(\w+)"?',
|
|
1644
|
-
sql,
|
|
1645
|
-
re.IGNORECASE,
|
|
1646
|
-
)
|
|
1647
|
-
if table_match:
|
|
1648
|
-
returning_table = table_match.group(1)
|
|
1649
1667
|
elif sql_upper.startswith("UPDATE"):
|
|
1650
1668
|
returning_operation = "UPDATE"
|
|
1651
|
-
table_match = re.search(
|
|
1652
|
-
rf'UPDATE\s+(?:{re.escape(IRIS_SCHEMA)}\s*\.\s*)?"?(\w+)"?',
|
|
1653
|
-
sql,
|
|
1654
|
-
re.IGNORECASE,
|
|
1655
|
-
)
|
|
1656
|
-
if table_match:
|
|
1657
|
-
returning_table = table_match.group(1)
|
|
1658
1669
|
# Extract WHERE clause (everything between WHERE and RETURNING)
|
|
1659
1670
|
where_match = re.search(
|
|
1660
1671
|
r"\bWHERE\s+(.+?)\s+RETURNING\b",
|
|
@@ -1665,13 +1676,6 @@ class IRISExecutor:
|
|
|
1665
1676
|
returning_where_clause = where_match.group(1).strip()
|
|
1666
1677
|
elif sql_upper.startswith("DELETE"):
|
|
1667
1678
|
returning_operation = "DELETE"
|
|
1668
|
-
table_match = re.search(
|
|
1669
|
-
rf'DELETE\s+FROM\s+(?:{re.escape(IRIS_SCHEMA)}\s*\.\s*)?"?(\w+)"?',
|
|
1670
|
-
sql,
|
|
1671
|
-
re.IGNORECASE,
|
|
1672
|
-
)
|
|
1673
|
-
if table_match:
|
|
1674
|
-
returning_table = table_match.group(1)
|
|
1675
1679
|
# Extract WHERE clause
|
|
1676
1680
|
where_match = re.search(
|
|
1677
1681
|
r"\bWHERE\s+(.+?)\s+RETURNING\b",
|
|
@@ -1681,13 +1685,15 @@ class IRISExecutor:
|
|
|
1681
1685
|
if where_match:
|
|
1682
1686
|
returning_where_clause = where_match.group(1).strip()
|
|
1683
1687
|
|
|
1688
|
+
# Strip RETURNING clause from SQL for execution
|
|
1689
|
+
# Use a non-greedy match to avoid stripping important parts if multiple statements exist
|
|
1684
1690
|
stripped_sql = re.sub(
|
|
1685
1691
|
r"\s+RETURNING\s+.*?(?=$|;)",
|
|
1686
1692
|
"",
|
|
1687
1693
|
sql,
|
|
1688
1694
|
flags=re.IGNORECASE | re.DOTALL,
|
|
1689
1695
|
count=1,
|
|
1690
|
-
)
|
|
1696
|
+
).strip()
|
|
1691
1697
|
|
|
1692
1698
|
return (
|
|
1693
1699
|
returning_operation,
|
|
@@ -1802,14 +1808,31 @@ class IRISExecutor:
|
|
|
1802
1808
|
import re
|
|
1803
1809
|
|
|
1804
1810
|
# CRITICAL FIX: Normalize table name to UPPERCASE for IRIS compatibility
|
|
1805
|
-
# IRIS stores table names in uppercase in INFORMATION_SCHEMA
|
|
1806
1811
|
table_normalized = table.upper() if table else table
|
|
1807
1812
|
|
|
1808
1813
|
# Handle columns as list or '*'
|
|
1809
1814
|
if columns == "*":
|
|
1810
|
-
|
|
1815
|
+
# Expand * early to get real column names
|
|
1816
|
+
expanded_cols = self._expand_select_star(
|
|
1817
|
+
f"SELECT * FROM {IRIS_SCHEMA}.{table_normalized}", 0, session_id=session_id
|
|
1818
|
+
)
|
|
1819
|
+
if expanded_cols:
|
|
1820
|
+
columns = expanded_cols
|
|
1821
|
+
col_list = ", ".join([f'"{col}"' for col in columns])
|
|
1822
|
+
else:
|
|
1823
|
+
col_list = "*"
|
|
1811
1824
|
else:
|
|
1812
|
-
|
|
1825
|
+
# columns is a list of expressions/names. Preserve them but quote simple identifiers.
|
|
1826
|
+
processed_cols = []
|
|
1827
|
+
for col in columns:
|
|
1828
|
+
if re.match(r"^\"?\w+\"?$", col):
|
|
1829
|
+
# Simple identifier - quote it
|
|
1830
|
+
clean_col = col.strip('"')
|
|
1831
|
+
processed_cols.append(f'"{clean_col}"')
|
|
1832
|
+
else:
|
|
1833
|
+
# Expression - leave as is
|
|
1834
|
+
processed_cols.append(col)
|
|
1835
|
+
col_list = ", ".join(processed_cols)
|
|
1813
1836
|
|
|
1814
1837
|
rows = []
|
|
1815
1838
|
meta = None
|
|
@@ -1852,7 +1875,7 @@ class IRISExecutor:
|
|
|
1852
1875
|
f'SELECT {col_list} FROM {IRIS_SCHEMA}."{table_normalized}" WHERE %ID = ?',
|
|
1853
1876
|
[last_id],
|
|
1854
1877
|
)
|
|
1855
|
-
if not rows and columns
|
|
1878
|
+
if not rows and isinstance(columns, list):
|
|
1856
1879
|
id_cols = [
|
|
1857
1880
|
c
|
|
1858
1881
|
for c in columns
|
|
@@ -1926,42 +1949,31 @@ class IRISExecutor:
|
|
|
1926
1949
|
)
|
|
1927
1950
|
rows, meta = _fetch_results(select_sql, where_params)
|
|
1928
1951
|
|
|
1929
|
-
#
|
|
1952
|
+
# Build/Fix metadata
|
|
1930
1953
|
if meta is None or not any("type_oid" in c for c in meta if isinstance(c, dict)):
|
|
1931
|
-
if columns
|
|
1932
|
-
# For SELECT *, expand from schema
|
|
1933
|
-
col_names = self._expand_select_star(
|
|
1934
|
-
f"RETURNING * FROM {IRIS_SCHEMA}.{table}", 0, session_id=session_id
|
|
1935
|
-
)
|
|
1936
|
-
if col_names:
|
|
1937
|
-
new_meta = []
|
|
1938
|
-
for col in col_names:
|
|
1939
|
-
col_oid = self._get_column_type_from_schema(
|
|
1940
|
-
table, col, session_id=session_id
|
|
1941
|
-
)
|
|
1942
|
-
new_meta.append(
|
|
1943
|
-
{
|
|
1944
|
-
"name": col,
|
|
1945
|
-
"type_oid": col_oid or 1043,
|
|
1946
|
-
"type_size": -1,
|
|
1947
|
-
"type_modifier": -1,
|
|
1948
|
-
"format_code": 0,
|
|
1949
|
-
}
|
|
1950
|
-
)
|
|
1951
|
-
meta = new_meta
|
|
1952
|
-
else:
|
|
1954
|
+
if isinstance(columns, list):
|
|
1953
1955
|
new_meta = []
|
|
1954
1956
|
for i, col in enumerate(columns):
|
|
1957
|
+
# Extract alias or column name
|
|
1958
|
+
col_name = col
|
|
1959
|
+
alias_match = re.search(r"\s+AS\s+\"?(\w+)\"?$", col, re.IGNORECASE)
|
|
1960
|
+
if alias_match:
|
|
1961
|
+
col_name = alias_match.group(1)
|
|
1962
|
+
else:
|
|
1963
|
+
col_name = col_name.strip('"')
|
|
1964
|
+
if "." in col_name:
|
|
1965
|
+
col_name = col_name.split(".")[-1]
|
|
1966
|
+
|
|
1955
1967
|
col_oid = self._get_column_type_from_schema(
|
|
1956
|
-
table,
|
|
1968
|
+
table, col_name, session_id=session_id
|
|
1957
1969
|
)
|
|
1958
1970
|
if col_oid is None and rows:
|
|
1959
1971
|
# Fallback to inference from value
|
|
1960
|
-
col_oid = self._infer_type_from_value(rows[0][i],
|
|
1972
|
+
col_oid = self._infer_type_from_value(rows[0][i], col_name)
|
|
1961
1973
|
|
|
1962
1974
|
new_meta.append(
|
|
1963
1975
|
{
|
|
1964
|
-
"name":
|
|
1976
|
+
"name": col_name,
|
|
1965
1977
|
"type_oid": col_oid or 1043,
|
|
1966
1978
|
"type_size": -1,
|
|
1967
1979
|
"type_modifier": -1,
|
|
@@ -1969,6 +1981,9 @@ class IRISExecutor:
|
|
|
1969
1981
|
}
|
|
1970
1982
|
)
|
|
1971
1983
|
meta = new_meta
|
|
1984
|
+
else:
|
|
1985
|
+
# columns is '*' and expansion failed
|
|
1986
|
+
pass
|
|
1972
1987
|
|
|
1973
1988
|
except Exception as e:
|
|
1974
1989
|
logger.error(f"RETURNING emulation failed for {operation}", error=str(e))
|
|
@@ -2301,6 +2316,7 @@ class IRISExecutor:
|
|
|
2301
2316
|
returning_where_clause,
|
|
2302
2317
|
optimized_params,
|
|
2303
2318
|
is_embedded=True,
|
|
2319
|
+
session_id=session_id,
|
|
2304
2320
|
original_sql=sql, # Pass original SQL for UUID extraction
|
|
2305
2321
|
)
|
|
2306
2322
|
result = MockResult(rows, meta)
|
|
@@ -2321,25 +2337,18 @@ class IRISExecutor:
|
|
|
2321
2337
|
# Get original IRIS column name
|
|
2322
2338
|
iris_col_name = col_info.get("name", "")
|
|
2323
2339
|
iris_type = col_info.get("type", "VARCHAR")
|
|
2340
|
+
precomputed_oid = col_info.get("type_oid")
|
|
2324
2341
|
|
|
2325
2342
|
# CRITICAL: Normalize IRIS column names to PostgreSQL conventions
|
|
2326
|
-
# IRIS generates HostVar_1, Expression_1, Aggregate_1 for unnamed columns
|
|
2327
|
-
# PostgreSQL uses ?column?, type names (int4), or function names (count)
|
|
2328
2343
|
col_name = self._normalize_iris_column_name(
|
|
2329
2344
|
iris_col_name, optimized_sql, iris_type
|
|
2330
2345
|
)
|
|
2331
2346
|
|
|
2332
|
-
# DEBUG: Log IRIS type for arithmetic expressions
|
|
2333
|
-
logger.info(
|
|
2334
|
-
"🔍 IRIS metadata type discovery",
|
|
2335
|
-
original_column_name=iris_col_name,
|
|
2336
|
-
normalized_column_name=col_name,
|
|
2337
|
-
iris_type=iris_type,
|
|
2338
|
-
col_info=col_info,
|
|
2339
|
-
)
|
|
2340
|
-
|
|
2341
2347
|
# Get PostgreSQL type OID
|
|
2342
|
-
|
|
2348
|
+
if precomputed_oid is not None:
|
|
2349
|
+
type_oid = precomputed_oid
|
|
2350
|
+
else:
|
|
2351
|
+
type_oid = self._iris_type_to_pg_oid(iris_type)
|
|
2343
2352
|
|
|
2344
2353
|
# CRITICAL FIX: IRIS type code 2 means NUMERIC, but for decimal literals
|
|
2345
2354
|
|
|
@@ -3091,6 +3100,13 @@ class IRISExecutor:
|
|
|
3091
3100
|
connection=conn,
|
|
3092
3101
|
)
|
|
3093
3102
|
|
|
3103
|
+
# Commit for non-SELECT statements to ensure visibility for emulation and durability
|
|
3104
|
+
if not statements[-1].upper().strip().startswith("SELECT"):
|
|
3105
|
+
try:
|
|
3106
|
+
conn.commit()
|
|
3107
|
+
except Exception as commit_err:
|
|
3108
|
+
logger.warning(f"Failed to commit {statements[-1][:50]}: {commit_err}")
|
|
3109
|
+
|
|
3094
3110
|
# RETURNING emulation
|
|
3095
3111
|
if returning_operation and returning_columns:
|
|
3096
3112
|
if returning_operation == "DELETE":
|
|
@@ -3106,6 +3122,7 @@ class IRISExecutor:
|
|
|
3106
3122
|
optimized_params,
|
|
3107
3123
|
is_embedded=False,
|
|
3108
3124
|
connection=conn,
|
|
3125
|
+
session_id=session_id,
|
|
3109
3126
|
original_sql=sql, # Pass original SQL for UUID extraction
|
|
3110
3127
|
)
|
|
3111
3128
|
cursor = MockResult(rows, meta)
|
|
@@ -3561,20 +3578,12 @@ class IRISExecutor:
|
|
|
3561
3578
|
|
|
3562
3579
|
if "RETURNING" in sql_upper:
|
|
3563
3580
|
# For INSERT/UPDATE/DELETE ... RETURNING *, extract table from INTO/UPDATE/FROM
|
|
3564
|
-
|
|
3565
|
-
|
|
3566
|
-
|
|
3567
|
-
|
|
3568
|
-
|
|
3569
|
-
|
|
3570
|
-
update_match = re.search(r"UPDATE\s+([^\s]+)\s+SET", sql, re.IGNORECASE)
|
|
3571
|
-
if update_match:
|
|
3572
|
-
table_name = update_match.group(1)
|
|
3573
|
-
else:
|
|
3574
|
-
# DELETE FROM table_name ...
|
|
3575
|
-
delete_match = re.search(r"DELETE\s+FROM\s+([^\s]+)", sql, re.IGNORECASE)
|
|
3576
|
-
if delete_match:
|
|
3577
|
-
table_name = delete_match.group(1)
|
|
3581
|
+
table_regex = (
|
|
3582
|
+
r'(?:INSERT\s+INTO|UPDATE|DELETE\s+FROM)\s+(?:(?:"?\w+"?)\s*\.\s*)*"?(\w+)"?'
|
|
3583
|
+
)
|
|
3584
|
+
table_match = re.search(table_regex, sql, re.IGNORECASE)
|
|
3585
|
+
if table_match:
|
|
3586
|
+
table_name = table_match.group(1)
|
|
3578
3587
|
else:
|
|
3579
3588
|
# SELECT * FROM table_name ...
|
|
3580
3589
|
from_match = re.search(r"FROM\s+([^\s,;()]+)", sql, re.IGNORECASE)
|
|
@@ -4384,3 +4393,34 @@ class IRISExecutor:
|
|
|
4384
4393
|
except Exception as e:
|
|
4385
4394
|
logger.warning("Vector query translation failed", error=str(e), sql=sql[:100])
|
|
4386
4395
|
return sql # Return original if translation fails
|
|
4396
|
+
|
|
4397
|
+
def _convert_params_for_iris(self, params: Any) -> Any:
|
|
4398
|
+
"""
|
|
4399
|
+
Convert parameters to IRIS-compatible formats.
|
|
4400
|
+
Specifically handles ISO 8601 timestamps.
|
|
4401
|
+
"""
|
|
4402
|
+
if params is None:
|
|
4403
|
+
return None
|
|
4404
|
+
|
|
4405
|
+
if isinstance(params, (list, tuple)):
|
|
4406
|
+
return [self._convert_value_for_iris(v) for v in params]
|
|
4407
|
+
|
|
4408
|
+
return self._convert_value_for_iris(params)
|
|
4409
|
+
|
|
4410
|
+
def _convert_value_for_iris(self, value: Any) -> Any:
|
|
4411
|
+
"""Helper to convert a single value."""
|
|
4412
|
+
if isinstance(value, str):
|
|
4413
|
+
# Check for ISO 8601 timestamp: 2026-01-29T21:27:38.111Z
|
|
4414
|
+
# or 2026-01-29T21:27:38.111+00:00
|
|
4415
|
+
# IRIS rejects the 'T' and 'Z' or offset in %PosixTime/TIMESTAMP
|
|
4416
|
+
if len(value) >= 19 and value[10] == "T":
|
|
4417
|
+
# Replace 'T' with space
|
|
4418
|
+
converted = value.replace("T", " ")
|
|
4419
|
+
# Remove 'Z' if present
|
|
4420
|
+
if converted.endswith("Z"):
|
|
4421
|
+
converted = converted[:-1]
|
|
4422
|
+
# Remove timezone offset if present (e.g., +00:00)
|
|
4423
|
+
if "+" in converted:
|
|
4424
|
+
converted = converted.split("+")[0]
|
|
4425
|
+
return converted
|
|
4426
|
+
return value
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: iris-pgwire
|
|
3
|
-
Version: 1.2.
|
|
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
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
iris_pgwire/__init__.py,sha256=
|
|
1
|
+
iris_pgwire/__init__.py,sha256=XdG-aGnecQsLdu9Y3YON1Nnx8NGljGiYgAo1IC8WUaw,1097
|
|
2
2
|
iris_pgwire/backend_selector.py,sha256=e0DymV-TsGkvp2iItcxkSWSUM8Nf_Agw-LDo78itW04,9315
|
|
3
3
|
iris_pgwire/bulk_executor.py,sha256=nUp5e1ARUzRzFYmctqCS5Zh1gh09IV8H5-Sr2IbKbWg,12742
|
|
4
4
|
iris_pgwire/column_validator.py,sha256=0T2UlLzwH0mEsDkkPXqiUGz4t49o6ySBGtLRyeiGyQc,6806
|
|
@@ -7,12 +7,12 @@ iris_pgwire/constitutional.py,sha256=4rrl0DdZRzzcT8mppie93ajiZ_WI-TyBDxZ_Z_ZLGY8
|
|
|
7
7
|
iris_pgwire/copy_handler.py,sha256=uxvlOcdLiK91qASMcYDlY7rtDc2fmgDmZS9tjTE0WQw,15647
|
|
8
8
|
iris_pgwire/csv_processor.py,sha256=tUv-HOvrQ9KdlBk6MbYtOFvX5Ckz6WIWgJXOhDlky4I,8564
|
|
9
9
|
iris_pgwire/dbapi_connection_pool.py,sha256=84hsbGKscQoj3o2zmNKPDgQMftN7JK0ez_WQpx1P98o,17147
|
|
10
|
-
iris_pgwire/dbapi_executor.py,sha256=
|
|
10
|
+
iris_pgwire/dbapi_executor.py,sha256=y1gqHD_UJno8ryrVEv6jUJsT9IsUeiUyEy7V7zlaXEs,40917
|
|
11
11
|
iris_pgwire/debug_tracer.py,sha256=9w6Bve8cnzFPPa69ux3OcR5DMLtjI_NdEFw8TvuwlPM,11112
|
|
12
12
|
iris_pgwire/health_checker.py,sha256=5U7HHKmpzn_G9gcExbRTlJZdD47v4a2FqACVNMpURys,7094
|
|
13
13
|
iris_pgwire/integratedml.py,sha256=TjGX3Z8eXzszqABFrfcVnKp-vyzg5yOBQN8KkTws_o0,16031
|
|
14
14
|
iris_pgwire/iris_constructs.py,sha256=HJ3wNbZyxdlL_ZOganH0830AshPTbctxNTkt6GishfA,31739
|
|
15
|
-
iris_pgwire/iris_executor.py,sha256=
|
|
15
|
+
iris_pgwire/iris_executor.py,sha256=t91AYl3OOITbJVFgAJV_Wju8LzF3GeBtxFjMfQL84nA,188932
|
|
16
16
|
iris_pgwire/iris_log_handler.py,sha256=FrxCyXDwni7aVsmyAx8zlGGbPTAuRSH3j8FNY5OQdLU,3056
|
|
17
17
|
iris_pgwire/iris_user_management.py,sha256=eGN3CCJML-UVfgR4W0mvNHYQJmgMY5NZXT_SAljONdM,23780
|
|
18
18
|
iris_pgwire/observability.py,sha256=FElftKlwf4JNL3RAR1IiK7aQv2I1w_SCWk-uc6DggIk,3308
|
|
@@ -96,8 +96,8 @@ iris_pgwire/sql_translator/mappings/document_filters.py,sha256=gjkwxspizx9ObHUHJ
|
|
|
96
96
|
iris_pgwire/sql_translator/mappings/functions.py,sha256=9-lHfvGKbHlTMOdKtu0k6wH-mUth3alGZYQ5WXexWQI,19982
|
|
97
97
|
iris_pgwire/testing/__init__.py,sha256=_HBL11torW_QjZh2X3DusLlkmFp9YNb0fYoo_0_Jo3U,46
|
|
98
98
|
iris_pgwire/testing/base_fixture_builder.py,sha256=gVMGEXWjPkMPCpmvFzX9Yc5ZGLKtY8c1yj0YruHcwd8,11593
|
|
99
|
-
iris_pgwire-1.2.
|
|
100
|
-
iris_pgwire-1.2.
|
|
101
|
-
iris_pgwire-1.2.
|
|
102
|
-
iris_pgwire-1.2.
|
|
103
|
-
iris_pgwire-1.2.
|
|
99
|
+
iris_pgwire-1.2.34.dist-info/METADATA,sha256=FWOonpOtIoSbwOaLFND6rewkb1AmK45DZ1oZGSDJ0Gc,14660
|
|
100
|
+
iris_pgwire-1.2.34.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
101
|
+
iris_pgwire-1.2.34.dist-info/entry_points.txt,sha256=pUOj368R5kSiRFbk4sqKUymPgBRNK3MZ9oYWAX5WZ3k,56
|
|
102
|
+
iris_pgwire-1.2.34.dist-info/licenses/LICENSE,sha256=7C-kc5Ll8ZAWzCD882nr7ptzHdwhc-LXm-wpQ3AxRaU,1099
|
|
103
|
+
iris_pgwire-1.2.34.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|