diff --git a/.claude/projects/2026-07-04-i4-ingest-sql-maf/brief.md b/.claude/projects/2026-07-04-i4-ingest-sql-maf/brief.md new file mode 100644 index 0000000..3fbb76a --- /dev/null +++ b/.claude/projects/2026-07-04-i4-ingest-sql-maf/brief.md @@ -0,0 +1,76 @@ +# I4 — SQL-kilde på MAF-siden (brief) + +> **Økt-type:** kode-økt, offline, IKKE gatet (lokalt/offline; driftsmodellens klarsignal +> dekker den). Andre kildetype i ingest-programmet, mot I1s polymorfe manifest-skjema UTEN +> spec-endring. Speiler I2 (`file`/CSV) for `sql`. +> +> **Kilder:** [ingest-spec](../../../shared/ingest-spec.md) (normativ, FROSSET) · [målbilde +> ingest](../../../docs/plan/2026-07-03-maalbilde-ingest-lag.md) · [sesjonsplan §I4](../../../docs/plan/2026-07-03-sesjonsplan-ingest.md). + +## Mål (én lukket leveranse) + +sqlite-konnektor på MAF-siden mot det polymorfe manifest-skjemaet: `manifest (source.type: +sql)` → read-only sqlite-uttrekk → materialisert OKF-bundle (samme §5–§7 provenance + §6 +index som `file`), hele veien load-bearing- og golden-testet. `materialize()` går fra å +avvise alt ≠ `FileSource` til å dispatche `FileSource → read_csv`, `SqlSource → read_sql` +(`http` forblir I6-avvist). + +## Suksesskriterier (hver = verifiserbar) + +1. `read_sql(connection_ref, query, *, max_rows)` eksekverer et read-only SELECT mot en + sqlite-fil resolvet fra env-varen `connection_ref` navner, med §5-typet celle-rendering. +2. `materialize()` materialiserer et `sql`-manifest til en bundle bit-identisk med en golden + (`examples/ingest-golden-sql/`), idempotent ved re-run. +3. Nye load-bearing-tester (SQL-navigerbarhet + §5-type-sømmer) er RØDE ved detach (bevist). +4. Full suite grønn uten nøkkel/nettverk; `okf.py`/`shared/`/`run.py` urørt; ruff+format+mypy + grønne; null modellkall. + +## Non-goals (bygg IKKE) + +- Ingen spec-/commons-endring (spec FROSSET; oppdages drift → STOPP, egen GATET runde). +- Ingen `http`-konnektor (I6), ingen `python -m`-CLI, ingen live-DB, ingen ORDER-BY-håndhevelse + i kode (manifest-forfatters SHOULD, §4), ingen per-rad-filsplitting, ingen inkrementell + re-ingest, ingen index-typing-RFC (åpen, utsatt). +- Ingen søsken-repo-endring (I5, GATET). + +## Låste beslutninger — SQL §5-type/tall-regler (delegert HIT av STATE; pinnes i kode+golden) + +STATE: «CSV-celle-verbatim-regelen utsatte tall-/type-håndtering eksplisitt til sql/I4 → +SQL-kolonners type/tall-regler avgjøres HER.» Konsistente med §5, ikke en spec-endring: + +| Python-type (sqlite default) | §5-tekst | Begrunnelse | +|---|---|---| +| `None` (SQL NULL) | `""` (tom) | §5 «SQL NULL as the empty string» | +| `int` (INTEGER) | `str(v)` plain decimal | §5 «integers in plain decimal» | +| `float` (REAL) | `repr(v)` korteste round-trip | §5 «non-integral numbers … shortest round-trip decimal form»; `repr` er Pythons korteste round-trip (ekstrem magnitude → E-notasjon, akseptert som korteste form) | +| `str` (TEXT) | verbatim (escapes i `render_table`) | §5 «text verbatim» | +| `bool` | `IngestError` | sqlite emitter den ikke; guard hindrer `str(True)`→`"True"` silent-coercion | +| `bytes` (BLOB) / annet | `IngestError` | §5 «any other value type MUST fail (never silent coercion)» | + +**Konnektor-regler:** +- `connection_ref` = env-var-NAVN → verdi = **sti til sqlite-fil** (I4-scope: sqlite-fixture). + Env-var usatt → `IngestError` fail-fast; fil mangler → `IngestError` (speiler `read_csv`). +- Read-only: åpnes `file:{quote(path)}?mode=ro` med `uri=True` → et skrive-forsøk feiler på + DB-nivå (§4 «SHOULD enforce read-only», robust — ikke skjør streng-parsing). +- Single-statement: `Connection.execute` avviser flere statements naturlig (§4 «one statement»). +- `max_rows`: streaming-tak, `IngestError` ved overskridelse (§8, aldri silent truncation — + speiler `read_csv`). +- sqlite3-feil (syntaks, skrive-forsøk, korrupt db) → wrappes som `IngestError` (fail-fast, + ærlig — som `read_csv`s `FileNotFoundError`-wrapping). + +## Antakelser (med test) + +- **A1: sqlite3 default-typing gir int/float/str/bytes/None** (ingen registrerte konvertere) + → testet i `test_ingest_sql.py` (typet fixture-db, én rad per type). +- **A2: `mode=ro` håndhever read-only** → test: skrive-query mot ro-connection raiser. +- **A3: golden-db-bytene påvirker ikke bundle-determinismen** (kun DB-INNHOLD via query; + env-sti lekker aldri inn i frontmatter) → golden bit-deterministisk uansett checkout-sti. + +## Verifisering (kommandoer) + +- `uv run pytest -q` — 211+ passerer, ingen ny skip, ingen nett/nøkkel. +- Detach-bevis (manuelt, revertert): bryt hver ny load-bearing-søm → bekreft RØD → revert. +- `uv run ruff check . && uv run ruff format --check . && uv run mypy src` — grønne. +- `git diff --stat` — `src/portfolio_optimiser/okf.py`, `shared/`, `src/portfolio_optimiser/run.py` + IKKE i diffen. +- MAF-fri: `test_ingest_module_is_maf_free_and_context_layer_pure` grønn (kun stdlib lagt til). diff --git a/.claude/projects/2026-07-04-i4-ingest-sql-maf/build_fixture.py b/.claude/projects/2026-07-04-i4-ingest-sql-maf/build_fixture.py new file mode 100644 index 0000000..c03b858 --- /dev/null +++ b/.claude/projects/2026-07-04-i4-ingest-sql-maf/build_fixture.py @@ -0,0 +1,53 @@ +"""Build the I4 sql golden fixture: examples/ingest-golden-sql/fixture/portefolje.sqlite ++ ingested-at.txt. + +Regenerable; the committed db BYTES need not be reproduced bit-for-bit — the golden test +compares the derived bundle (from db CONTENT via the manifest queries), not the db file. +Parameterized inserts keep the backslash/pipe escape-probe values exact (no SQL string-literal +surprises: 'c:\\temp' would be a tab if written as a Python literal). The data is chosen to +discriminate a typed §5 implementation from naive ones — SQL NULL, an integral REAL (4200.0), +non-integral REALs, and pipe/backslash text. +""" + +from __future__ import annotations + +import sqlite3 +from pathlib import Path + +GOLDEN = Path(__file__).resolve().parents[3] / "examples" / "ingest-golden-sql" + + +def main() -> None: + fixture = GOLDEN / "fixture" + fixture.mkdir(parents=True, exist_ok=True) + db = fixture / "portefolje.sqlite" + if db.exists(): + db.unlink() + conn = sqlite3.connect(db) + try: + conn.execute("CREATE TABLE costs (id INTEGER, item TEXT, amount REAL, note TEXT)") + conn.executemany( + "INSERT INTO costs VALUES (?, ?, ?, ?)", + [ + (1, "LED-armatur", 1200.5, "godkjent"), + (2, "sensor", 89.9, None), # NULL → empty cell (discriminator) + (3, "kabel", 4200.0, "rest"), # integral REAL → "4200.0", not "4200" + ], + ) + conn.execute("CREATE TABLE meta (k TEXT, v TEXT)") + conn.executemany( + "INSERT INTO meta VALUES (?, ?)", + [ + ("owner", "anlegg | drift"), # pipe → escaped \| + ("path", "c:\\temp"), # backslash → escaped \\ + ], + ) + conn.commit() + finally: + conn.close() + (GOLDEN / "ingested-at.txt").write_bytes(b"2026-07-04T12:00:00Z\n") + print(f"built {db} ({db.stat().st_size} bytes) + ingested-at.txt") + + +if __name__ == "__main__": + main() diff --git a/.claude/projects/2026-07-04-i4-ingest-sql-maf/plan.md b/.claude/projects/2026-07-04-i4-ingest-sql-maf/plan.md new file mode 100644 index 0000000..8e7620a --- /dev/null +++ b/.claude/projects/2026-07-04-i4-ingest-sql-maf/plan.md @@ -0,0 +1,72 @@ +# I4 — SQL-kilde på MAF-siden (plan) + +TDD (Iron Law: ingen produksjonskode uten rød test først). Ett `ingest.py` endres; ny konnektor ++ dispatch + typet §5-rendering. Ren stdlib-tillegg (`os`, `sqlite3`, `contextlib.closing`, +`urllib.parse.quote`) → MAF-fri-guarden urørt. + +## Steg + +### S1 — Røde tester (skriv FØR impl) +- `tests/test_ingest_sql.py` (konnektor + `materialize`-dispatch): + - env-var usatt → `IngestError`; env peker på manglende fil → `IngestError`. + - typet rendering via tmp sqlite-db: INTEGER→decimal, REAL→`repr`, TEXT→verbatim, NULL→`""`. + - `float` integral (`4200.0`) → `"4200.0"` (bevart, ikke `"4200"`). + - BLOB → `IngestError` (never silent coercion); `bool`-guard (konstruér via `SELECT`? sqlite + gir int — dekk `bytes` som «other»; bool-guarden er defensiv, dekkes med en direkte + `_sql_value_to_text(True)`-kall-assert). + - `max_rows` overskredet → `IngestError`. + - read-only (A2): skrive-query mot ro-connection → `IngestError`. + - happy path: `sql`-manifest → bundle med korrekt provenance + body. +- `tests/test_ingest_sql_loadbearing.py` (SQL-spesifikke sømmer, detach-RED): + - `test_sql_generated_bundle_is_navigable_via_unchanged_okf` (speiler seam 3: `navigate_bundle` + + `bundle_context` mot SQL-generert bundle; RØD hvis index-linking detaches). + - `test_sql_null_renders_as_empty_not_none` (§5-load-bearing: RØD hvis None→`str()`). + - `test_sql_real_preserves_float_form` (§5: `4200.0`→`"4200.0"`; RØD hvis int-coerced). + - `test_sql_blob_fails_never_silent_coercion` (§5: BLOB→`IngestError`; RØD hvis silent str). + - `test_sql_manifest_mapping_to_verdict_is_rejected` (§3-reservasjon fyrer på `sql`-source). +- Oppdater 2 eksisterende: + - `test_ingest_materialize.py::test_sql_source_has_no_connector_in_i2` → repekes til `http` + (`test_http_source_has_no_connector` — den gjenstående I6-avvisningen; sql eksekverer nå). + - `test_ingest_manifest.py::test_sql_and_http_variants_validate` — kommentar «sql → I4» + oppdateres til at `file`+`sql` eksekverer, `http`→I6. +- **Kjør → RØD** (read_sql finnes ikke; materialize avviser sql). + +### S2 — Implementasjon (`ingest.py`) +- Import: `os`, `sqlite3`, `from contextlib import closing`, `from urllib.parse import quote`. +- `_sql_value_to_text(value) -> str` (rekkefølge: None → bool-guard → int → float → str → fail). +- `read_sql(connection_ref, query, *, max_rows) -> tuple[list[str], list[list[str]]]`: + env-resolusjon → fil-eksistens → ro-connection (`closing`) → `execute` → header fra + `cursor.description` → streaming max_rows-tak → `_sql_value_to_text` per celle → wrap + `sqlite3.Error/Warning` som `IngestError`. +- `materialize`: erstatt `if not isinstance(..FileSource): raise` med dispatch: + `http`→refuse (I6); `file`→root-resolusjon+`read_csv`; `sql`→`read_sql`. §8-logg per uttrekk. +- Docstring-oppdatering: modul-docstring (SQL nå implementert, §5-tallregler pinnet); + `render_table` parentes (SQL-verdier pre-konvertert til §5-tekst i `read_sql`). +- **Kjør → GRØNN** (unit + loadbearing). + +### S3 — Golden-fixture +- `examples/ingest-golden-sql/`: + - `fixture/portefolje.sqlite` — committet db (gen-script i denne prosjektmappa; schema i brief). + Tabeller: `costs`(id INTEGER, item TEXT, amount REAL, note TEXT-nullable) med NULL + `4200.0`; + `meta`(k TEXT, v TEXT) med pipe/backslash for escape-bevis. + - `manifest.json` — `source.type:sql`, `connection_ref:"PORTEFOLJE_SQL_DSN"`, to uttrekk med + ORDER BY. + - `ingested-at.txt` — fast timestamp. + - `expected-bundle/` — generert av `materialize`, HÅNDVERIFISERT mot §5 celle-for-celle, frosset. +- `tests/test_ingest_golden_sql.py` — speiler `test_ingest_golden.py`; setter env-var til + fixture-db (monkeypatch), asserterer bit-likhet + idempotent re-run. +- **Kjør → GRØNN**. + +### S4 — Detach-bevis (hver ny load-bearing, revertert) +- null→str, float→int-cast, BLOB→str, index-link-detach, verdict-validator-fjern → bekreft RØD. + +### S5 — Full verifisering + commit +- `pytest -q` grønn · ruff+format+mypy grønne · `git diff --stat` (okf/shared/run urørt) · + MAF-fri-guard grønn. +- Overskriv STATE.md (I4 levert, NESTE = I5 gated søsken). Commits (Conventional, `(I4)`-scope): + test(røde) → feat(impl) → test(golden+fixture) → docs(brief/plan) → evt. style(ruff). Push Forgejo. + +## Verifisering (plan som helhet) +Program-sluttkriteriets SQL-halvdel: golden bit-deterministisk fra delt spec, load-bearing røde +ved detach, suite grønn uten nøkkel/nett, ingen artefakt påstår mer enn koden gjør (sqlite-fixture, +ikke live-DB). Ingen §-endring i spec.