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 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.32"
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.
@@ -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
- if params:
138
- cursor.execute(clean_sql, params)
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
- Args:
232
- sql: SQL query string (usually INSERT)
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 p_set
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": "dbapi_executemany",
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
- """Check if query has a RETURNING clause."""
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
- """Extract column names from RETURNING clause."""
408
- 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)
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()
@@ -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 row_params:
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
- # Parse column names from RETURNING clause
1624
- # Or just: col1, col2, ...
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
- for col in raw_cols:
1628
- # Extract just the column name (last part after dots)
1629
- # Handle aliased columns like "col AS alias"
1630
- 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
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
- col_match = re.search(r'"?(\w+)"?\s*$', col)
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
- 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 = "*"
1811
1824
  else:
1812
- 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)
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
- # Feature 036: Build proper metadata with actual IRIS types if needed
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, col, session_id=session_id
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], col)
1972
+ col_oid = self._infer_type_from_value(rows[0][i], col_name)
1961
1973
 
1962
1974
  new_meta.append(
1963
1975
  {
1964
- "name": col,
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
- 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)
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
- # INSERT INTO table_name ...
3565
- insert_match = re.search(r"INSERT\s+INTO\s+([^\s(]+)", sql, re.IGNORECASE)
3566
- if insert_match:
3567
- table_name = insert_match.group(1)
3568
- else:
3569
- # UPDATE table_name SET ...
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.32
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=Ja7xWzfwLgqZlu7YJqHrYksjf-LyPyvlNy_VQWkOeWg,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=U5hRrpYivWKIjeL1nTkJyVpGx1kzSDt_DABm4m0Ahlw,21076
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=gDBhhz-JE01hYLSTw4EGDoQJD7Kk_t4uFUJ18VH_MTM,187208
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.32.dist-info/METADATA,sha256=NXcxcTOddAeu48pVqOgkyPTHv5VU2OMx56bCTMUk5gY,14660
100
- iris_pgwire-1.2.32.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
101
- iris_pgwire-1.2.32.dist-info/entry_points.txt,sha256=pUOj368R5kSiRFbk4sqKUymPgBRNK3MZ9oYWAX5WZ3k,56
102
- iris_pgwire-1.2.32.dist-info/licenses/LICENSE,sha256=7C-kc5Ll8ZAWzCD882nr7ptzHdwhc-LXm-wpQ3AxRaU,1099
103
- iris_pgwire-1.2.32.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,,