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:
Kjell Tore Guttormsen 2026-07-16 19:56:41 +02:00
commit c94ed6d525
2 changed files with 153 additions and 0 deletions

View file

@ -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