3333logger = logging .getLogger (__name__ )
3434
3535
36+ # ---------------------------------------------------------------------------
37+ # SQL query strings (shared between sync and async methods)
38+ # ---------------------------------------------------------------------------
39+
40+ _SQL_TABLE_NAMES = """
41+ SELECT table_name
42+ FROM information_schema.tables
43+ WHERE table_schema = current_schema()
44+ AND table_type = 'BASE TABLE'
45+ ORDER BY table_name
46+ """
47+
48+ _SQL_PK_COLUMNS = """
49+ SELECT kcu.column_name
50+ FROM information_schema.key_column_usage kcu
51+ JOIN information_schema.table_constraints tc
52+ ON kcu.constraint_name = tc.constraint_name
53+ AND kcu.table_schema = tc.table_schema
54+ AND kcu.table_name = tc.table_name
55+ WHERE tc.constraint_type = 'PRIMARY KEY'
56+ AND kcu.table_schema = current_schema()
57+ AND kcu.table_name = %s
58+ ORDER BY kcu.ordinal_position
59+ """
60+
61+ _SQL_COLUMN_INFO = """
62+ SELECT column_name, data_type, udt_name, is_nullable
63+ FROM information_schema.columns
64+ WHERE table_schema = current_schema()
65+ AND table_name = %s
66+ ORDER BY ordinal_position
67+ """
68+
69+
3670# ---------------------------------------------------------------------------
3771# Module-level helpers (pure functions, no I/O)
3872# These are kept module-level so AsyncPostgreSQLConnector (future) can reuse
@@ -151,31 +185,22 @@ def _arrow_type_to_pg_sql(arrow_type: pa.DataType) -> str:
151185 return "TEXT"
152186
153187
154- def _resolve_column_type_lookup (
155- query : str ,
156- connector : "PostgreSQLConnector" ,
157- ) -> dict [str , pa .DataType ]:
158- """Parse the FROM clause of query to find the source table, then return
159- a column-name → Arrow-type dict from get_column_info.
188+ def _parse_table_from_query (query : str ) -> str | None :
189+ """Extract the single unambiguous source table name from a SELECT query.
160190
161- Returns an empty dict if no single unambiguous table can be identified,
162- causing iter_batches to fall back to pa.large_string() for all columns.
191+ Returns ``None`` when the query references more than one table (JOINs,
192+ comma-separated tables, subqueries, CTEs) so callers fall back to treating
193+ all columns as ``pa.large_string()``.
163194
164195 Args:
165196 query: SQL query string.
166- connector: The connector to call get_column_info on.
167197
168198 Returns:
169- Dict mapping column name to Arrow DataType .
199+ The table name string, or ``None`` if no single table can be identified .
170200 """
171- # Be conservative: only resolve types when we can unambiguously identify
172- # a single source table. For multi-table queries (JOINs, comma-separated
173- # tables, multiple FROM clauses, subqueries, etc.) return {} so callers
174- # fall back to treating all columns as pa.large_string().
175-
176201 # Fast path: any JOIN keyword means multi-table.
177202 if re .search (r"\bJOIN\b" , query , re .IGNORECASE ):
178- return {}
203+ return None
179204
180205 # Find all FROM <table> occurrences. We only proceed when there is
181206 # exactly one (multiple FROMs indicate subqueries or CTEs).
@@ -185,7 +210,7 @@ def _resolve_column_type_lookup(
185210 )
186211 from_matches = list (from_pattern .finditer (query ))
187212 if len (from_matches ) != 1 :
188- return {}
213+ return None
189214
190215 match = from_matches [0 ]
191216 table_name = match .group (1 ) or match .group (2 )
@@ -202,57 +227,54 @@ def _resolve_column_type_lookup(
202227 from_tail = from_tail [: clause_boundary .start ()]
203228
204229 if "," in from_tail :
205- return {}
230+ return None
206231
207- return { ci . name : ci . arrow_type for ci in connector . get_column_info ( table_name )}
232+ return table_name
208233
209234
210- async def _async_resolve_column_type_lookup (
235+ def _resolve_column_type_lookup (
211236 query : str ,
212237 connector : "PostgreSQLConnector" ,
213238) -> dict [str , pa .DataType ]:
214- """Async version of ``_resolve_column_type_lookup``.
215-
216- Parses the FROM clause of query to find the source table, then returns a
217- column-name → Arrow-type dict by calling ``async_get_column_info``.
239+ """Return a column-name → Arrow-type dict for the source table of ``query``.
218240
219241 Returns an empty dict if no single unambiguous table can be identified,
220- causing ``async_iter_batches `` to fall back to ``pa.large_string()`` for
221- all columns.
242+ causing ``iter_batches `` to fall back to ``pa.large_string()`` for all
243+ columns.
222244
223245 Args:
224246 query: SQL query string.
225- connector: The connector to call ``async_get_column_info `` on.
247+ connector: The connector to call ``get_column_info `` on.
226248
227249 Returns:
228250 Dict mapping column name to Arrow DataType.
229251 """
230- if re .search (r"\bJOIN\b" , query , re .IGNORECASE ):
252+ table_name = _parse_table_from_query (query )
253+ if table_name is None :
231254 return {}
255+ return {ci .name : ci .arrow_type for ci in connector .get_column_info (table_name )}
232256
233- from_pattern = re .compile (
234- r'\bFROM\b\s+(?:"([^"]+)"|(\w+))' ,
235- re .IGNORECASE ,
236- )
237- from_matches = list (from_pattern .finditer (query ))
238- if len (from_matches ) != 1 :
239- return {}
240257
241- match = from_matches [0 ]
242- table_name = match .group (1 ) or match .group (2 )
258+ async def _async_resolve_column_type_lookup (
259+ query : str ,
260+ connector : "PostgreSQLConnector" ,
261+ ) -> dict [str , pa .DataType ]:
262+ """Async counterpart to ``_resolve_column_type_lookup``.
243263
244- from_tail = query [match .end ():]
245- clause_boundary = re .search (
246- r"\b(WHERE|GROUP\s+BY|ORDER\s+BY|LIMIT|OFFSET|HAVING|UNION|EXCEPT|INTERSECT)\b" ,
247- from_tail ,
248- re .IGNORECASE ,
249- )
250- if clause_boundary :
251- from_tail = from_tail [: clause_boundary .start ()]
264+ Returns an empty dict if no single unambiguous table can be identified,
265+ causing ``async_iter_batches`` to fall back to ``pa.large_string()`` for
266+ all columns.
252267
253- if "," in from_tail :
254- return {}
268+ Args:
269+ query: SQL query string.
270+ connector: The connector to call ``async_get_column_info`` on.
255271
272+ Returns:
273+ Dict mapping column name to Arrow DataType.
274+ """
275+ table_name = _parse_table_from_query (query )
276+ if table_name is None :
277+ return {}
256278 return {ci .name : ci .arrow_type for ci in await connector .async_get_column_info (table_name )}
257279
258280
@@ -324,15 +346,7 @@ def get_table_names(self) -> list[str]:
324346 with self ._lock :
325347 conn = self ._require_open ()
326348 with conn .cursor () as cur :
327- cur .execute (
328- """
329- SELECT table_name
330- FROM information_schema.tables
331- WHERE table_schema = current_schema()
332- AND table_type = 'BASE TABLE'
333- ORDER BY table_name
334- """
335- )
349+ cur .execute (_SQL_TABLE_NAMES )
336350 return [row [0 ] for row in cur .fetchall ()]
337351
338352 def get_pk_columns (self , table_name : str ) -> list [str ]:
@@ -341,21 +355,7 @@ def get_pk_columns(self, table_name: str) -> list[str]:
341355 conn = self ._require_open ()
342356 self ._validate_table_name (table_name )
343357 with conn .cursor () as cur :
344- cur .execute (
345- """
346- SELECT kcu.column_name
347- FROM information_schema.key_column_usage kcu
348- JOIN information_schema.table_constraints tc
349- ON kcu.constraint_name = tc.constraint_name
350- AND kcu.table_schema = tc.table_schema
351- AND kcu.table_name = tc.table_name
352- WHERE tc.constraint_type = 'PRIMARY KEY'
353- AND kcu.table_schema = current_schema()
354- AND kcu.table_name = %s
355- ORDER BY kcu.ordinal_position
356- """ ,
357- (table_name ,),
358- )
358+ cur .execute (_SQL_PK_COLUMNS , (table_name ,))
359359 return [row [0 ] for row in cur .fetchall ()]
360360
361361 def get_column_info (self , table_name : str ) -> list [ColumnInfo ]:
@@ -364,16 +364,7 @@ def get_column_info(self, table_name: str) -> list[ColumnInfo]:
364364 conn = self ._require_open ()
365365 self ._validate_table_name (table_name )
366366 with conn .cursor () as cur :
367- cur .execute (
368- """
369- SELECT column_name, data_type, udt_name, is_nullable
370- FROM information_schema.columns
371- WHERE table_schema = current_schema()
372- AND table_name = %s
373- ORDER BY ordinal_position
374- """ ,
375- (table_name ,),
376- )
367+ cur .execute (_SQL_COLUMN_INFO , (table_name ,))
377368 return [
378369 ColumnInfo (
379370 name = row [0 ],
@@ -571,15 +562,7 @@ async def async_get_table_names(self) -> list[str]:
571562 """Return all user table names in this database (sorted, excludes views)."""
572563 conn = self ._require_async_open ()
573564 async with conn .cursor () as cur :
574- await cur .execute (
575- """
576- SELECT table_name
577- FROM information_schema.tables
578- WHERE table_schema = current_schema()
579- AND table_type = 'BASE TABLE'
580- ORDER BY table_name
581- """
582- )
565+ await cur .execute (_SQL_TABLE_NAMES )
583566 return [row [0 ] for row in await cur .fetchall ()]
584567
585568 async def async_get_pk_columns (self , table_name : str ) -> list [str ]:
@@ -591,38 +574,15 @@ async def async_get_pk_columns(self, table_name: str) -> list[str]:
591574 conn = self ._require_async_open ()
592575 self ._validate_table_name (table_name )
593576 async with conn .cursor () as cur :
594- await cur .execute (
595- """
596- SELECT kcu.column_name
597- FROM information_schema.key_column_usage kcu
598- JOIN information_schema.table_constraints tc
599- ON kcu.constraint_name = tc.constraint_name
600- AND kcu.table_schema = tc.table_schema
601- AND kcu.table_name = tc.table_name
602- WHERE tc.constraint_type = 'PRIMARY KEY'
603- AND kcu.table_schema = current_schema()
604- AND kcu.table_name = %s
605- ORDER BY kcu.ordinal_position
606- """ ,
607- (table_name ,),
608- )
577+ await cur .execute (_SQL_PK_COLUMNS , (table_name ,))
609578 return [row [0 ] for row in await cur .fetchall ()]
610579
611580 async def async_get_column_info (self , table_name : str ) -> list [ColumnInfo ]:
612581 """Return column metadata with Arrow-mapped types."""
613582 conn = self ._require_async_open ()
614583 self ._validate_table_name (table_name )
615584 async with conn .cursor () as cur :
616- await cur .execute (
617- """
618- SELECT column_name, data_type, udt_name, is_nullable
619- FROM information_schema.columns
620- WHERE table_schema = current_schema()
621- AND table_name = %s
622- ORDER BY ordinal_position
623- """ ,
624- (table_name ,),
625- )
585+ await cur .execute (_SQL_COLUMN_INFO , (table_name ,))
626586 return [
627587 ColumnInfo (
628588 name = row [0 ],
@@ -690,12 +650,21 @@ async def async_iter_batches(
690650 yield _pa .RecordBatch .from_arrays (arrays , schema = schema )
691651 rows = await cur .fetchmany (batch_size )
692652 finally :
693- await cur .close ()
653+ # Each step runs independently so one failure doesn't block the
654+ # others. Note: CancelledError (BaseException subclass) can still
655+ # interrupt an individual await; full shield() protection is deferred.
656+ try :
657+ await cur .close ()
658+ except Exception :
659+ pass
694660 try :
695661 await read_conn .rollback ()
696662 except Exception :
697663 pass
698- await read_conn .close ()
664+ try :
665+ await read_conn .close ()
666+ except Exception :
667+ pass
699668
700669 # ── Serialization ─────────────────────────────────────────────────────────
701670
0 commit comments