docs(i4): brief + plan + golden fixture builder (I4)
Session brief (scope, non-goals, pinned SQL type/number decisions, verification) + TDD plan + build_fixture.py (regenerates the sqlite golden; the committed db bytes need not reproduce — the golden compares the derived bundle).
This commit is contained in:
parent
4f45fe6037
commit
1b7612b22d
3 changed files with 201 additions and 0 deletions
76
.claude/projects/2026-07-04-i4-ingest-sql-maf/brief.md
Normal file
76
.claude/projects/2026-07-04-i4-ingest-sql-maf/brief.md
Normal file
|
|
@ -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).
|
||||
|
|
@ -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()
|
||||
72
.claude/projects/2026-07-04-i4-ingest-sql-maf/plan.md
Normal file
72
.claude/projects/2026-07-04-i4-ingest-sql-maf/plan.md
Normal file
|
|
@ -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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue