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 csv
import os import os
import sqlite3
from contextlib import closing
from pathlib import Path from pathlib import Path
from urllib.parse import quote
from .errors import SourceError from .errors import SourceError
from .render import sql_value_to_text
def _safe_resolve(root: Path, relative: str) -> Path: 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) rows.append(row)
return header, rows 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

102
tests/test_sql_connector.py Normal file
View file

@ -0,0 +1,102 @@
"""The `sql` connector (ingest-spec §4, §8).
sqlite via connection_ref env-var resolution (the ref names the variable;
its value is the database path), read-only open, single statement,
streaming max_rows cap, typed errors for every sqlite failure.
"""
from __future__ import annotations
import sqlite3
from pathlib import Path
import pytest
from llm_ingestion_okf.connectors import read_sql
from llm_ingestion_okf.errors import IngestError, SourceError
REF = "OKF_TEST_DB"
@pytest.fixture
def db_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
path = tmp_path / "fixture.db"
with sqlite3.connect(path) as conn:
conn.execute("CREATE TABLE t (id INTEGER, name TEXT, score REAL, note TEXT)")
conn.executemany(
"INSERT INTO t VALUES (?, ?, ?, ?)",
[(1, "alpha", 1.5, None), (2, "beta", 0.1, "x|y")],
)
monkeypatch.setenv(REF, str(path))
return path
# --- happy path: §5 text form, source order ---
def test_reads_header_and_rows_as_text(db_path: Path) -> None:
header, rows = read_sql(REF, "SELECT id, name, score, note FROM t ORDER BY id", max_rows=10)
assert header == ["id", "name", "score", "note"]
# NULL -> empty string; int plain decimal; float shortest round-trip; text verbatim.
assert rows == [["1", "alpha", "1.5", ""], ["2", "beta", "0.1", "x|y"]]
# --- env-var resolution (credentials never in the manifest, §4) ---
def test_unset_connection_ref_rejected(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv(REF, raising=False)
with pytest.raises(SourceError):
read_sql(REF, "SELECT 1", max_rows=10)
def test_empty_connection_ref_rejected(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv(REF, "")
with pytest.raises(SourceError):
read_sql(REF, "SELECT 1", max_rows=10)
def test_missing_database_file_rejected(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv(REF, str(tmp_path / "nope.db"))
with pytest.raises(SourceError):
read_sql(REF, "SELECT 1", max_rows=10)
# --- read-only, single statement, typed sqlite errors ---
def test_write_statement_fails_at_the_database(db_path: Path) -> None:
"""Read-only is enforced by the connection mode, not string parsing."""
with pytest.raises(SourceError):
read_sql(REF, "DELETE FROM t", max_rows=10)
def test_multiple_statements_rejected(db_path: Path) -> None:
with pytest.raises(SourceError):
read_sql(REF, "SELECT 1; SELECT 2", max_rows=10)
def test_syntax_error_wrapped_in_typed_error(db_path: Path) -> None:
with pytest.raises(SourceError):
read_sql(REF, "SELEC nonsense", max_rows=10)
def test_blob_cell_fails_typed(db_path: Path, tmp_path: Path) -> None:
"""Never silent coercion (§5): a BLOB cell is a typed IngestError."""
with sqlite3.connect(tmp_path / "fixture.db") as conn:
conn.execute("INSERT INTO t VALUES (3, 'blob', 0.0, x'DEADBEEF')")
with pytest.raises(IngestError):
read_sql(REF, "SELECT note FROM t WHERE id = 3", max_rows=10)
# --- max_rows cap (§8): error, never silent truncation ---
def test_row_count_at_cap_is_allowed(db_path: Path) -> None:
_, rows = read_sql(REF, "SELECT id FROM t ORDER BY id", max_rows=2)
assert len(rows) == 2
def test_exceeding_cap_is_an_error(db_path: Path) -> None:
with pytest.raises(SourceError):
read_sql(REF, "SELECT id FROM t ORDER BY id", max_rows=1)