iris-pgwire 1.2.33__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 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.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()
@@ -1623,41 +1623,49 @@ class IRISExecutor:
1623
1623
  if returning_clause == "*":
1624
1624
  returning_columns = "*"
1625
1625
  else:
1626
- # Parse column names from RETURNING clause
1627
- # Or just: col1, col2, ...
1628
- 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
1629
1628
  returning_columns = []
1630
- for col in raw_cols:
1631
- # Extract just the column name (last part after dots)
1632
- # Handle aliased columns like "col AS alias"
1633
- if " AS " in col.upper():
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
1634
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 = ""
1635
1647
  else:
1636
- col_match = re.search(r'"?(\w+)"?\s*$', col)
1637
-
1648
+ current_col += char
1649
+ if current_col.strip():
1650
+ col = current_col.strip()
1651
+ col_match = re.search(r'"?(\w+)"?\s*$', col)
1638
1652
  if col_match:
1639
1653
  returning_columns.append(col_match.group(1).lower())
1654
+ else:
1655
+ returning_columns.append(col.lower())
1640
1656
 
1641
1657
  # Determine operation type and extract table/where clause
1642
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
+
1643
1665
  if sql_upper.startswith("INSERT"):
1644
1666
  returning_operation = "INSERT"
1645
- table_match = re.search(
1646
- rf'INSERT\s+INTO\s+(?:{re.escape(IRIS_SCHEMA)}\s*\.\s*)?"?(\w+)"?',
1647
- sql,
1648
- re.IGNORECASE,
1649
- )
1650
- if table_match:
1651
- returning_table = table_match.group(1)
1652
1667
  elif sql_upper.startswith("UPDATE"):
1653
1668
  returning_operation = "UPDATE"
1654
- table_match = re.search(
1655
- rf'UPDATE\s+(?:{re.escape(IRIS_SCHEMA)}\s*\.\s*)?"?(\w+)"?',
1656
- sql,
1657
- re.IGNORECASE,
1658
- )
1659
- if table_match:
1660
- returning_table = table_match.group(1)
1661
1669
  # Extract WHERE clause (everything between WHERE and RETURNING)
1662
1670
  where_match = re.search(
1663
1671
  r"\bWHERE\s+(.+?)\s+RETURNING\b",
@@ -1668,13 +1676,6 @@ class IRISExecutor:
1668
1676
  returning_where_clause = where_match.group(1).strip()
1669
1677
  elif sql_upper.startswith("DELETE"):
1670
1678
  returning_operation = "DELETE"
1671
- table_match = re.search(
1672
- rf'DELETE\s+FROM\s+(?:{re.escape(IRIS_SCHEMA)}\s*\.\s*)?"?(\w+)"?',
1673
- sql,
1674
- re.IGNORECASE,
1675
- )
1676
- if table_match:
1677
- returning_table = table_match.group(1)
1678
1679
  # Extract WHERE clause
1679
1680
  where_match = re.search(
1680
1681
  r"\bWHERE\s+(.+?)\s+RETURNING\b",
@@ -1684,13 +1685,15 @@ class IRISExecutor:
1684
1685
  if where_match:
1685
1686
  returning_where_clause = where_match.group(1).strip()
1686
1687
 
1688
+ # Strip RETURNING clause from SQL for execution
1689
+ # Use a non-greedy match to avoid stripping important parts if multiple statements exist
1687
1690
  stripped_sql = re.sub(
1688
1691
  r"\s+RETURNING\s+.*?(?=$|;)",
1689
1692
  "",
1690
1693
  sql,
1691
1694
  flags=re.IGNORECASE | re.DOTALL,
1692
1695
  count=1,
1693
- )
1696
+ ).strip()
1694
1697
 
1695
1698
  return (
1696
1699
  returning_operation,
@@ -1805,14 +1808,31 @@ class IRISExecutor:
1805
1808
  import re
1806
1809
 
1807
1810
  # CRITICAL FIX: Normalize table name to UPPERCASE for IRIS compatibility
1808
- # IRIS stores table names in uppercase in INFORMATION_SCHEMA
1809
1811
  table_normalized = table.upper() if table else table
1810
1812
 
1811
1813
  # Handle columns as list or '*'
1812
1814
  if columns == "*":
1813
- col_list = "*"
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 = "*"
1814
1824
  else:
1815
- col_list = ", ".join([f'"{col}"' for col in columns])
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)
1816
1836
 
1817
1837
  rows = []
1818
1838
  meta = None
@@ -1855,7 +1875,7 @@ class IRISExecutor:
1855
1875
  f'SELECT {col_list} FROM {IRIS_SCHEMA}."{table_normalized}" WHERE %ID = ?',
1856
1876
  [last_id],
1857
1877
  )
1858
- if not rows and columns != "*":
1878
+ if not rows and isinstance(columns, list):
1859
1879
  id_cols = [
1860
1880
  c
1861
1881
  for c in columns
@@ -1929,42 +1949,31 @@ class IRISExecutor:
1929
1949
  )
1930
1950
  rows, meta = _fetch_results(select_sql, where_params)
1931
1951
 
1932
- # Feature 036: Build proper metadata with actual IRIS types if needed
1952
+ # Build/Fix metadata
1933
1953
  if meta is None or not any("type_oid" in c for c in meta if isinstance(c, dict)):
1934
- if columns == "*":
1935
- # For SELECT *, expand from schema
1936
- col_names = self._expand_select_star(
1937
- f"RETURNING * FROM {IRIS_SCHEMA}.{table}", 0, session_id=session_id
1938
- )
1939
- if col_names:
1940
- new_meta = []
1941
- for col in col_names:
1942
- col_oid = self._get_column_type_from_schema(
1943
- table, col, session_id=session_id
1944
- )
1945
- new_meta.append(
1946
- {
1947
- "name": col,
1948
- "type_oid": col_oid or 1043,
1949
- "type_size": -1,
1950
- "type_modifier": -1,
1951
- "format_code": 0,
1952
- }
1953
- )
1954
- meta = new_meta
1955
- else:
1954
+ if isinstance(columns, list):
1956
1955
  new_meta = []
1957
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
+
1958
1967
  col_oid = self._get_column_type_from_schema(
1959
- table, col, session_id=session_id
1968
+ table, col_name, session_id=session_id
1960
1969
  )
1961
1970
  if col_oid is None and rows:
1962
1971
  # Fallback to inference from value
1963
- col_oid = self._infer_type_from_value(rows[0][i], col)
1972
+ col_oid = self._infer_type_from_value(rows[0][i], col_name)
1964
1973
 
1965
1974
  new_meta.append(
1966
1975
  {
1967
- "name": col,
1976
+ "name": col_name,
1968
1977
  "type_oid": col_oid or 1043,
1969
1978
  "type_size": -1,
1970
1979
  "type_modifier": -1,
@@ -1972,6 +1981,9 @@ class IRISExecutor:
1972
1981
  }
1973
1982
  )
1974
1983
  meta = new_meta
1984
+ else:
1985
+ # columns is '*' and expansion failed
1986
+ pass
1975
1987
 
1976
1988
  except Exception as e:
1977
1989
  logger.error(f"RETURNING emulation failed for {operation}", error=str(e))
@@ -2304,6 +2316,7 @@ class IRISExecutor:
2304
2316
  returning_where_clause,
2305
2317
  optimized_params,
2306
2318
  is_embedded=True,
2319
+ session_id=session_id,
2307
2320
  original_sql=sql, # Pass original SQL for UUID extraction
2308
2321
  )
2309
2322
  result = MockResult(rows, meta)
@@ -2324,25 +2337,18 @@ class IRISExecutor:
2324
2337
  # Get original IRIS column name
2325
2338
  iris_col_name = col_info.get("name", "")
2326
2339
  iris_type = col_info.get("type", "VARCHAR")
2340
+ precomputed_oid = col_info.get("type_oid")
2327
2341
 
2328
2342
  # CRITICAL: Normalize IRIS column names to PostgreSQL conventions
2329
- # IRIS generates HostVar_1, Expression_1, Aggregate_1 for unnamed columns
2330
- # PostgreSQL uses ?column?, type names (int4), or function names (count)
2331
2343
  col_name = self._normalize_iris_column_name(
2332
2344
  iris_col_name, optimized_sql, iris_type
2333
2345
  )
2334
2346
 
2335
- # DEBUG: Log IRIS type for arithmetic expressions
2336
- logger.info(
2337
- "🔍 IRIS metadata type discovery",
2338
- original_column_name=iris_col_name,
2339
- normalized_column_name=col_name,
2340
- iris_type=iris_type,
2341
- col_info=col_info,
2342
- )
2343
-
2344
2347
  # Get PostgreSQL type OID
2345
- type_oid = self._iris_type_to_pg_oid(iris_type)
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)
2346
2352
 
2347
2353
  # CRITICAL FIX: IRIS type code 2 means NUMERIC, but for decimal literals
2348
2354
 
@@ -3094,6 +3100,13 @@ class IRISExecutor:
3094
3100
  connection=conn,
3095
3101
  )
3096
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
+
3097
3110
  # RETURNING emulation
3098
3111
  if returning_operation and returning_columns:
3099
3112
  if returning_operation == "DELETE":
@@ -3109,6 +3122,7 @@ class IRISExecutor:
3109
3122
  optimized_params,
3110
3123
  is_embedded=False,
3111
3124
  connection=conn,
3125
+ session_id=session_id,
3112
3126
  original_sql=sql, # Pass original SQL for UUID extraction
3113
3127
  )
3114
3128
  cursor = MockResult(rows, meta)
@@ -3564,20 +3578,12 @@ class IRISExecutor:
3564
3578
 
3565
3579
  if "RETURNING" in sql_upper:
3566
3580
  # For INSERT/UPDATE/DELETE ... RETURNING *, extract table from INTO/UPDATE/FROM
3567
- # INSERT INTO table_name ...
3568
- insert_match = re.search(r"INSERT\s+INTO\s+([^\s(]+)", sql, re.IGNORECASE)
3569
- if insert_match:
3570
- table_name = insert_match.group(1)
3571
- else:
3572
- # UPDATE table_name SET ...
3573
- update_match = re.search(r"UPDATE\s+([^\s]+)\s+SET", sql, re.IGNORECASE)
3574
- if update_match:
3575
- table_name = update_match.group(1)
3576
- else:
3577
- # DELETE FROM table_name ...
3578
- delete_match = re.search(r"DELETE\s+FROM\s+([^\s]+)", sql, re.IGNORECASE)
3579
- if delete_match:
3580
- 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)
3581
3587
  else:
3582
3588
  # SELECT * FROM table_name ...
3583
3589
  from_match = re.search(r"FROM\s+([^\s,;()]+)", sql, re.IGNORECASE)
@@ -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
@@ -1,4 +1,4 @@
1
- iris_pgwire/__init__.py,sha256=Iad0ZJMoEwmN2gP0I685oKfsCTCxM3FV7k3BEzP9EF8,1097
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=XihdSX4VK2lTslQdd2rnOa63EE-gx04ae4qupCpX-3M,22466
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=7ZHt1C_UybCBX43SZRGmr6_QjBW1mx6NPUNoumALDRY,188614
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.33.dist-info/METADATA,sha256=4YRm0r5zWt9Z5-jfc4w4d0n1GtBWFkb1wsYsu8sDM5A,14660
100
- iris_pgwire-1.2.33.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
101
- iris_pgwire-1.2.33.dist-info/entry_points.txt,sha256=pUOj368R5kSiRFbk4sqKUymPgBRNK3MZ9oYWAX5WZ3k,56
102
- iris_pgwire-1.2.33.dist-info/licenses/LICENSE,sha256=7C-kc5Ll8ZAWzCD882nr7ptzHdwhc-LXm-wpQ3AxRaU,1099
103
- iris_pgwire-1.2.33.dist-info/RECORD,,
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,,