Skip to content

Commit 8a1fdae

Browse files
kurodo3[bot]claude
andcommitted
refactor(connectors): address PR review — consolidate SQL/query logic, fix exports
- Extract _SQL_TABLE_NAMES, _SQL_PK_COLUMNS, _SQL_COLUMN_INFO module-level constants; sync and async schema methods now share a single copy of each SQL string (no duplication) - Extract _parse_table_from_query() helper from the two _resolve_column_type_lookup functions, which were identical except for the final DB call; each lookup function is now ~3 lines - Remove AsyncDBConnectorProtocol from orcapod.databases — protocols belong in orcapod.protocols (eywalker review) - Add DBConnectorProtocol to orcapod.protocols.__init__ alongside AsyncDBConnectorProtocol for a consistent export surface (Copilot review) - Wrap each async_iter_batches cleanup step (cur.close, rollback, read_conn.close) in its own try/except so one failure does not prevent the others from running Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 6734c26 commit 8a1fdae

3 files changed

Lines changed: 88 additions & 119 deletions

File tree

src/orcapod/databases/__init__.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,8 @@
55
from .spiraldb_connector import SpiralDBConnector
66
from .sqlite_connector import SQLiteConnector
77
from .postgresql_connector import PostgreSQLConnector
8-
from orcapod.protocols.async_db_connector_protocol import AsyncDBConnectorProtocol
98

109
__all__ = [
11-
"AsyncDBConnectorProtocol",
1210
"ConnectorArrowDatabase",
1311
"DeltaTableDatabase",
1412
"InMemoryArrowDatabase",

src/orcapod/databases/postgresql_connector.py

Lines changed: 86 additions & 117 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,40 @@
3333
logger = 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

src/orcapod/protocols/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@
33
DataExecutionLoggerProtocol,
44
)
55
from orcapod.protocols.async_db_connector_protocol import AsyncDBConnectorProtocol
6+
from orcapod.protocols.db_connector_protocol import DBConnectorProtocol
67

78
__all__ = [
89
"AsyncDBConnectorProtocol",
910
"DataExecutionLoggerProtocol",
11+
"DBConnectorProtocol",
1012
"ExecutionObserverProtocol",
1113
]

0 commit comments

Comments
 (0)