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
This commit is contained in:
Kjell Tore Guttormsen 2026-07-04 06:56:02 +02:00
commit 4f45fe6037
11 changed files with 446 additions and 5 deletions

View file

@ -0,0 +1,3 @@
Golden extraction case: two SQL extracts from a local project database (sql source type).
- [Project costs](ingest-costs.md)
- [Catalogue metadata](ingest-meta.md)

View file

@ -0,0 +1,15 @@
---
type: dataset
title: Project costs
source_system: portefolje-db
source_query: SELECT id, item, amount, note FROM costs ORDER BY id
ingested_at: 2026-07-04T12:00:00Z
ingest_manifest: manifest@a4b891aea55df97b
generated: true
---
| id | item | amount | note |
| --- | --- | --- | --- |
| 1 | LED-armatur | 1200.5 | godkjent |
| 2 | sensor | 89.9 | |
| 3 | kabel | 4200.0 | rest |

View file

@ -0,0 +1,14 @@
---
type: reference
title: Catalogue metadata
source_system: portefolje-db
source_query: SELECT k, v FROM meta ORDER BY k
ingested_at: 2026-07-04T12:00:00Z
ingest_manifest: manifest@a4b891aea55df97b
generated: true
---
| k | v |
| --- | --- |
| owner | anlegg \| drift |
| path | c:\\temp |

Binary file not shown.

View file

@ -0,0 +1 @@
2026-07-04T12:00:00Z

View file

@ -0,0 +1,21 @@
{
"manifest_version": 1,
"source": {"type": "sql", "id": "portefolje-db", "connection_ref": "PORTEFOLJE_SQL_DSN"},
"bundle_summary": "Golden extraction case: two SQL extracts from a local project database (sql source type).",
"extractions": [
{
"id": "costs",
"title": "Project costs",
"query": "SELECT id, item, amount, note FROM costs ORDER BY id",
"okf_type": "dataset",
"max_rows": 10
},
{
"id": "meta",
"title": "Catalogue metadata",
"query": "SELECT k, v FROM meta ORDER BY k",
"okf_type": "reference",
"max_rows": 10
}
]
}

View file

@ -0,0 +1,47 @@
"""I4 golden regression — the §11 golden extraction case for `sql`, byte-for-byte.
Mirrors tests/test_ingest_golden.py for the second source type. The case pins the §5 SQL type
rules AS RULES: SQL NULL empty cell (never ``None``), REAL 4200.0 ``4200.0`` (float form
preserved, not integer-coerced), non-integral 1200.5/89.9, and the escape order on the sql path
(``|`` ``\\|``, ``\\`` ``\\\\``). RED when ANY byte of the expected bundle diverges (spec
§11 "Golden regression" seam).
The fixture db is committed under ``fixture/`` (rebuild:
``.claude/projects/2026-07-04-i4-ingest-sql-maf/build_fixture.py``); the ``connection_ref`` env
var is set to it (the §4 run-time resolution). The db PATH never enters the bundle, so the
golden is checkout-location independent.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from portfolio_optimiser.ingest import materialize
GOLDEN = Path(__file__).resolve().parents[1] / "examples" / "ingest-golden-sql"
def _bundle_bytes(directory: Path) -> dict[str, bytes]:
return {p.name: p.read_bytes() for p in sorted(directory.iterdir()) if p.is_file()}
def test_golden_sql_extraction_is_bit_deterministic(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
ingested_at = (GOLDEN / "ingested-at.txt").read_text(encoding="utf-8").strip()
monkeypatch.setenv("PORTEFOLJE_SQL_DSN", str(GOLDEN / "fixture" / "portefolje.sqlite"))
out = tmp_path / "bundle"
materialize(GOLDEN / "manifest.json", out, ingested_at=ingested_at)
expected = _bundle_bytes(GOLDEN / "expected-bundle")
actual = _bundle_bytes(out)
# File-SET equality first — catches extra AND missing files, not just diverging bytes.
assert actual.keys() == expected.keys()
for name, content in expected.items():
assert actual[name] == content, f"golden byte divergence in {name}"
# §10: a second run over the same output leaves every byte unchanged.
materialize(GOLDEN / "manifest.json", out, ingested_at=ingested_at)
assert _bundle_bytes(out) == expected

View file

@ -163,7 +163,7 @@ def test_base_url_embedded_credentials_rejected() -> None:
def test_sql_and_http_variants_validate() -> None:
# Schema breadth (brief assumption 1): the polymorphic §4 schema validates all three
# source variants; only the `file` connector EXECUTES in I2 (sql → I4, http → I6).
# source variants; the `file` and `sql` connectors EXECUTE (I2, I4), http → I6.
sql = ManifestV1.model_validate(
_variant(source={"type": "sql", "id": "db", "connection_ref": "PROJ_DB"})
)

View file

@ -242,11 +242,12 @@ def test_two_runs_with_identical_inputs_are_byte_identical(tmp_path: Path) -> No
assert first == second # §10 idempotence at file level
def test_sql_source_has_no_connector_in_i2(tmp_path: Path) -> None:
# The polymorphic schema VALIDATES sql (brief assumption 1); executing it is I4 —
# attempting to materialize is an explicit refusal, never a silent no-op.
def test_http_source_has_no_connector(tmp_path: Path) -> None:
# The polymorphic schema VALIDATES http (brief assumption 1); executing it is the gated
# I6 extension point — attempting to materialize is an explicit refusal, never a silent
# no-op. (sql now EXECUTES, I4; see tests/test_ingest_sql.py.)
manifest_path, bundle_dir = _project(
tmp_path, source={"type": "sql", "id": "db", "connection_ref": "PROJ_DB"}
tmp_path, source={"type": "http", "id": "api", "base_url": "https://host/api"}
)
with pytest.raises(IngestError):
materialize(manifest_path, bundle_dir, ingested_at=_INGESTED_AT)

199
tests/test_ingest_sql.py Normal file
View file

@ -0,0 +1,199 @@
"""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): INTEGERplain decimal, REALshortest round-trip, TEXT
verbatim, SQL NULLempty string, BLOB/otherfail (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 NULLempty 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

View file

@ -0,0 +1,140 @@
"""I4 load-bearing seams — the ``sql`` path end-to-end (ingest spec §11's detach-RED regime).
The ``file`` seams (tests/test_ingest_loadbearing.py) are source-agnostic where they can be;
these tests prove the SEAMS the ``sql`` source introduces typed §5 rendering (the ``file``
path is all-strings) and that a SQL-generated bundle flows through the SAME navigability and
provenance machinery. Each test is built so the seam under test is the ONLY thing preventing
the observable artifact (the Fase-2 green-but-dead trap).
- NAVIGABILITY (§2/§11): a SQL-generated bundle is consumable by the UNCHANGED
``okf.navigate_bundle``/``bundle_context`` reachable via index cross-links, classified by
``okf_type``, provenance riding through. RED when index linking is detached.
- TYPED NULL (§5): SQL NULL renders as the empty string, NEVER ``"None"``. RED when the None
branch degrades to ``str(value)``.
- TYPED REAL (§5): a REAL value keeps its shortest round-trip float form (4200.0 ``4200.0``),
NEVER integer-coerced. RED when the float branch degrades to ``str(int(value))``.
"""
from __future__ import annotations
import json
import sqlite3
from pathlib import Path
from typing import Any
from portfolio_optimiser import okf
from portfolio_optimiser.ingest import materialize
_INGESTED_AT = "2026-07-03T12:00:00Z"
def _make_db(path: Path, script: str) -> Path:
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]]) -> Path:
manifest = {
"manifest_version": 1,
"source": {"type": "sql", "id": "db", "connection_ref": "PROJ_DB"},
"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
def test_sql_generated_bundle_is_navigable_via_unchanged_okf(
tmp_path: Path, monkeypatch: Any
) -> None:
"""LOAD-BEARING NAVIGABILITY: the SQL path produces a bundle the untouched OKF navigation
reaches through index cross-links (classified by okf_type, provenance preserved) and
``bundle_context`` renders. RED when index linking is detached (file exists, unreachable)."""
db = _make_db(
tmp_path / "s.sqlite",
"CREATE TABLE costs (item TEXT, amount REAL);"
" INSERT INTO costs VALUES ('led', 120.0), ('hvac', 80.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,
}
],
)
bundle_dir = tmp_path / "bundle"
materialize(manifest, bundle_dir, ingested_at=_INGESTED_AT)
bundle = okf.navigate_bundle(str(bundle_dir))
by_name = {f.name: f for f in bundle.files}
assert "ingest-costs.md" in by_name
assert by_name["ingest-costs.md"].type == "dataset"
assert by_name["ingest-costs.md"].frontmatter["ingest_manifest"] # provenance rides through
context = okf.bundle_context(bundle)
assert "Project costs" in context and "| hvac | 80.0 |" in context
def test_sql_null_renders_as_empty_not_none(tmp_path: Path, monkeypatch: Any) -> None:
"""LOAD-BEARING TYPED NULL: detaching the None branch to ``str(value)`` would render the
string ``None`` into the bundle; the empty cell is the ONLY correct §5 form."""
db = _make_db(
tmp_path / "s.sqlite",
"CREATE TABLE costs (item TEXT, note TEXT); INSERT INTO costs VALUES ('sensor', NULL);",
)
monkeypatch.setenv("PROJ_DB", str(db))
manifest = _sql_manifest(
tmp_path,
[
{
"id": "costs",
"title": "Project costs",
"query": "SELECT item, note FROM costs ORDER BY item",
"okf_type": "dataset",
"max_rows": 100,
}
],
)
bundle_dir = tmp_path / "bundle"
materialize(manifest, bundle_dir, ingested_at=_INGESTED_AT)
body = (bundle_dir / "ingest-costs.md").read_text(encoding="utf-8")
assert "| sensor | |" in body # NULL → empty cell
assert "None" not in body # RED if the None branch degrades to str()
def test_sql_real_preserves_float_form(tmp_path: Path, monkeypatch: Any) -> None:
"""LOAD-BEARING TYPED REAL: a REAL keeps its shortest round-trip float form; integer
coercion (``4200``) is the detach this test catches."""
db = _make_db(
tmp_path / "s.sqlite",
"CREATE TABLE costs (item TEXT, amount REAL); INSERT INTO costs VALUES ('kabel', 4200.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,
}
],
)
bundle_dir = tmp_path / "bundle"
materialize(manifest, bundle_dir, ingested_at=_INGESTED_AT)
body = (bundle_dir / "ingest-costs.md").read_text(encoding="utf-8")
assert "| kabel | 4200.0 |" in body # float form preserved
assert "| kabel | 4200 |" not in body # RED if integer-coerced