portfolio-optimiser/tests/test_ingest_sql.py
Kjell Tore Guttormsen 4f45fe6037 test(ingest): SQL connector + load-bearing + golden, detach-proven (I4)
New: test_ingest_sql.py (connector mechanics + typed rendering + dispatch), test_ingest_sql_loadbearing.py (SQL seams: typed-NULL, typed-REAL, navigability via unchanged okf), test_ingest_golden_sql.py + examples/ingest-golden-sql/ (byte-frozen sqlite golden; NULL->empty, 4200.0->4200.0 and escape discriminate a typed impl from naive ones). Updated: sql-source refusal test repointed to http (I6); manifest schema-breadth comment. Each new seam proven RED at its detach point.

Refs: shared/ingest-spec.md 11 golden + load-bearing table
2026-07-04 06:56:02 +02:00

199 lines
7.1 KiB
Python

"""I4 Step 2/3 — the ``sql`` connector (``read_sql``) + ``materialize`` dispatch.
Mirrors tests/test_ingest_materialize.py for the second source type. Covers the §4 connector
rules for ``sql`` (env-resolved ``connection_ref`` → sqlite path, read-only enforcement,
single-statement, streaming ``max_rows`` cap §8) and the §5 TYPED cell rendering the ``file``
path never exercised (all-strings): INTEGER→plain decimal, REAL→shortest round-trip, TEXT
verbatim, SQL NULL→empty string, BLOB/other→fail (never silent coercion). Offline: sqlite
fixtures under tmp_path, zero network, zero credentials, zero model calls.
Pinned SQL §5 decisions (spec-silent, delegated to I4 by the session plan; see brief):
``connection_ref`` names an env var whose value is a filesystem path to a sqlite database
opened read-only; ``float`` renders via ``repr`` (Python's shortest round-trip form).
"""
from __future__ import annotations
import json
import sqlite3
from pathlib import Path
from typing import Any
import pytest
from portfolio_optimiser import okf
from portfolio_optimiser.ingest import (
IngestError,
_sql_value_to_text,
materialize,
read_sql,
)
_INGESTED_AT = "2026-07-03T12:00:00Z"
def _make_db(path: Path, script: str) -> Path:
"""Build a sqlite fixture (read-write) from a DDL/DML script, then close it."""
conn = sqlite3.connect(path)
try:
conn.executescript(script)
conn.commit()
finally:
conn.close()
return path
def _sql_manifest(
tmp_path: Path, extractions: list[dict[str, Any]], *, connection_ref: str = "PROJ_DB"
) -> Path:
manifest = {
"manifest_version": 1,
"source": {"type": "sql", "id": "db", "connection_ref": connection_ref},
"bundle_summary": "SQL extracts from the project database.",
"extractions": extractions,
}
path = tmp_path / "manifest.json"
path.write_text(json.dumps(manifest), encoding="utf-8")
return path
# --- connector: env resolution + fail-fast (§4) --------------------------------------------------
def test_read_sql_unset_connection_ref_fails(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("PROJ_DB", raising=False)
with pytest.raises(IngestError):
read_sql("PROJ_DB", "SELECT 1", max_rows=10)
def test_read_sql_missing_db_file_fails(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("PROJ_DB", str(tmp_path / "does-not-exist.sqlite"))
with pytest.raises(IngestError):
read_sql("PROJ_DB", "SELECT 1", max_rows=10)
# --- connector: §5 typed cell rendering ----------------------------------------------------------
def test_read_sql_renders_each_type_per_section5(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""INTEGER→plain decimal, REAL→shortest round-trip (``repr``, integral 4200.0 preserved),
TEXT verbatim, SQL NULL→empty string — all in one ordered extract."""
db = _make_db(
tmp_path / "s.sqlite",
"""
CREATE TABLE t (i INTEGER, r REAL, s TEXT, n TEXT);
INSERT INTO t VALUES (7, 1.5, 'hei', NULL);
INSERT INTO t VALUES (3, 4200.0, 'verden', 'x');
""",
)
monkeypatch.setenv("PROJ_DB", str(db))
header, rows = read_sql("PROJ_DB", "SELECT i, r, s, n FROM t ORDER BY i", max_rows=100)
assert header == ["i", "r", "s", "n"]
assert rows == [
["3", "4200.0", "verden", "x"],
["7", "1.5", "hei", ""],
]
def test_read_sql_null_is_empty_string(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
db = _make_db(
tmp_path / "n.sqlite",
"CREATE TABLE t (v TEXT); INSERT INTO t VALUES (NULL);",
)
monkeypatch.setenv("PROJ_DB", str(db))
_, rows = read_sql("PROJ_DB", "SELECT v FROM t", max_rows=10)
assert rows == [[""]] # SQL NULL → empty string, never "None"
def test_read_sql_blob_value_fails_never_silent(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
db = _make_db(
tmp_path / "b.sqlite",
"CREATE TABLE t (b BLOB); INSERT INTO t VALUES (x'00ff');",
)
monkeypatch.setenv("PROJ_DB", str(db))
with pytest.raises(IngestError): # §5: any other value type MUST fail
read_sql("PROJ_DB", "SELECT b FROM t", max_rows=10)
def test_sql_value_to_text_rejects_bool_defensively() -> None:
# sqlite3 never returns Python bool; the guard prevents str(True)->"True" silent coercion
# (bool is an int subclass, so it would otherwise slip through the integer branch).
with pytest.raises(IngestError):
_sql_value_to_text(True)
# --- connector: §8 cap + §4 read-only / single-statement -----------------------------------------
def test_read_sql_max_rows_exceeded_is_error(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
db = _make_db(
tmp_path / "m.sqlite",
"CREATE TABLE t (i INTEGER); INSERT INTO t VALUES (1),(2),(3);",
)
monkeypatch.setenv("PROJ_DB", str(db))
with pytest.raises(IngestError): # §8: error, never silent truncation
read_sql("PROJ_DB", "SELECT i FROM t ORDER BY i", max_rows=2)
def test_read_sql_is_read_only(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
db = _make_db(
tmp_path / "ro.sqlite",
"CREATE TABLE t (i INTEGER); INSERT INTO t VALUES (1);",
)
monkeypatch.setenv("PROJ_DB", str(db))
with pytest.raises(IngestError): # §4: read-only enforced at the DB (mode=ro)
read_sql("PROJ_DB", "INSERT INTO t VALUES (2)", max_rows=10)
_, rows = read_sql("PROJ_DB", "SELECT count(*) FROM t", max_rows=10)
assert rows == [["1"]] # the refused write never happened
def test_read_sql_rejects_multiple_statements(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
db = _make_db(
tmp_path / "ms.sqlite",
"CREATE TABLE t (i INTEGER); INSERT INTO t VALUES (1);",
)
monkeypatch.setenv("PROJ_DB", str(db))
with pytest.raises(IngestError): # §4: one statement
read_sql("PROJ_DB", "SELECT i FROM t; SELECT i FROM t", max_rows=10)
# --- materialize dispatch: sql executes (was refused in I2) ---------------------------------------
def test_sql_manifest_materializes_with_provenance(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
db = _make_db(
tmp_path / "s.sqlite",
"CREATE TABLE costs (item TEXT, amount REAL); INSERT INTO costs VALUES ('led', 120.0);",
)
monkeypatch.setenv("PROJ_DB", str(db))
manifest = _sql_manifest(
tmp_path,
[
{
"id": "costs",
"title": "Project costs",
"query": "SELECT item, amount FROM costs ORDER BY item",
"okf_type": "dataset",
"max_rows": 100,
}
],
)
written = materialize(manifest, tmp_path / "bundle", ingested_at=_INGESTED_AT)
assert len(written) == 1
fm = okf.parse_frontmatter(written[0])
assert fm["source_system"] == "db"
assert fm["generated"] == "true"
assert fm["source_query"] == "SELECT item, amount FROM costs ORDER BY item"
body = written[0].read_text(encoding="utf-8")
assert "| item | amount |" in body
assert "| led | 120.0 |" in body