feat(connectors): add the sql connector (read-only sqlite, env-resolved)
TDD step 4: read_sql executes one read-only SELECT against the sqlite database whose path is resolved at run time from the env var named by connection_ref (credentials never in the manifest). Read-only enforced by the connection mode (file:...?mode=ro), single statement via Connection.execute, §5 cell text conversion, streaming max_rows cap, and every sqlite failure wrapped in SourceError. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QeqhJpYQyghASjiJo5EhGg
This commit is contained in:
parent
af1849f0b3
commit
c94ed6d525
2 changed files with 153 additions and 0 deletions
|
|
@ -8,9 +8,13 @@ from __future__ import annotations
|
|||
|
||||
import csv
|
||||
import os
|
||||
import sqlite3
|
||||
from contextlib import closing
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote
|
||||
|
||||
from .errors import SourceError
|
||||
from .render import sql_value_to_text
|
||||
|
||||
|
||||
def _safe_resolve(root: Path, relative: str) -> Path:
|
||||
|
|
@ -65,3 +69,50 @@ def read_csv(root: str | Path, query: str, *, max_rows: int) -> tuple[list[str],
|
|||
)
|
||||
rows.append(row)
|
||||
return header, rows
|
||||
|
||||
|
||||
def read_sql(
|
||||
connection_ref: str, query: str, *, max_rows: int
|
||||
) -> tuple[list[str], list[list[str]]]:
|
||||
"""Execute a `sql`-source extraction: one read-only SELECT against sqlite.
|
||||
|
||||
`connection_ref` names an environment variable whose value is the
|
||||
database path — resolved at run time, never from the manifest (§4).
|
||||
Opened read-only (mode=ro) so a write in `query` fails at the DB —
|
||||
§4's "SHOULD enforce read-only" honoured robustly, not by string
|
||||
parsing; Connection.execute runs exactly one statement. Cells are
|
||||
converted to their §5 text form; the streaming max_rows cap is an
|
||||
ERROR the moment it is exceeded (§8). Env-unset, missing file, and
|
||||
any sqlite3 error raise typed errors fail-fast.
|
||||
"""
|
||||
dsn = os.environ.get(connection_ref)
|
||||
if not dsn:
|
||||
raise SourceError(
|
||||
f"sql source connection_ref {connection_ref!r} is not set in the environment "
|
||||
"(paths/credentials resolve at run time, never from the manifest — §4)"
|
||||
)
|
||||
db_file = Path(dsn)
|
||||
if not db_file.is_file():
|
||||
raise SourceError(
|
||||
f"sql connection_ref {connection_ref!r} points at a missing database file: {db_file}"
|
||||
)
|
||||
# quote keeps '/' but encodes spaces/'?'/'#' so an odd path can't corrupt
|
||||
# the URI; file:{abs path}?mode=ro is sqlite's documented read-only open.
|
||||
uri = f"file:{quote(str(db_file))}?mode=ro"
|
||||
try:
|
||||
with closing(sqlite3.connect(uri, uri=True)) as conn:
|
||||
cursor = conn.execute(query)
|
||||
if cursor.description is None: # a SELECT always has columns; defensive
|
||||
raise SourceError(f"sql extraction returned no columns: {query!r}")
|
||||
header = [column[0] for column in cursor.description]
|
||||
rows: list[list[str]] = []
|
||||
for row in cursor:
|
||||
if len(rows) >= max_rows:
|
||||
raise SourceError(
|
||||
f"extraction {query!r} exceeds max_rows={max_rows} "
|
||||
"(error, never silent truncation — spec §8)"
|
||||
)
|
||||
rows.append([sql_value_to_text(value) for value in row])
|
||||
except (sqlite3.Error, sqlite3.Warning) as exc:
|
||||
raise SourceError(f"sql extraction failed for query {query!r}: {exc}") from exc
|
||||
return header, rows
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue