litedbmodel-runtime 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,221 @@
1
+ #!/usr/bin/env python3
2
+ """litedbmodel SCP LIVE-DB conformance — Python runner (WS7g, #36).
3
+
4
+ The Python leg of the coordinated cross-language LIVE-DB validation pass (spec §10 dialect axis).
5
+ It loads the WS7g live-DB corpus (``conformance/vectors-livedb/livedb.json``) — the exec/tx bundles
6
+ compiled for `postgres` + `mysql` — connects to REAL dockerized Postgres + MySQL, creates the
7
+ needed tables in an ISOLATED per-language namespace, and runs each bundle through the SAME
8
+ ``litedbmodel_runtime`` (``execute_bundle`` / ``execute_transaction_bundle``) that the SQLite
9
+ conformance uses — now backed by the live :class:`PostgresDriver` / :class:`MysqlDriver` seam. It
10
+ asserts the assembled result equals the frozen SQLite reference (`expectedResult` /
11
+ `expectedDbState`) — the §10 promise (same IR + input → same RESULT regardless of dialect).
12
+
13
+ REAL DBs, no mock, NO silent skip: if PG or MySQL is unreachable the runner ERRORS OUT LOUDLY
14
+ (exit 3) — it never passes vacuously. Connection config is env-driven (matching
15
+ docker-compose.test.yml / the WS6 host defaults):
16
+
17
+ TEST_DB_HOST/PORT/USER/PASSWORD/NAME (Postgres, default localhost:5433)
18
+ TEST_MYSQL_HOST/PORT/USER/PASSWORD/DB (MySQL, default localhost:3307)
19
+
20
+ Emits the SAME machine-readable JSON summary the orchestrator expects as its LAST stdout line:
21
+ {"lang":"py-livedb","suites":{"livedb-pg":{..},"livedb-mysql":{..}},"total_pass",...}
22
+ Exit: 0 all pass, 1 any fail, 2 corpus-version mismatch, 3 DB unreachable.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import json
28
+ import os
29
+ import sys
30
+ from pathlib import Path
31
+ from typing import Any, Dict, List
32
+
33
+ _PKG_PARENT = str(Path(__file__).resolve().parent.parent)
34
+ if _PKG_PARENT not in sys.path:
35
+ sys.path.insert(0, _PKG_PARENT)
36
+
37
+ from litedbmodel_runtime import ( # noqa: E402
38
+ MysqlDriver,
39
+ PostgresDriver,
40
+ execute_bundle,
41
+ execute_transaction_bundle,
42
+ )
43
+
44
+ SUPPORTED_CORPUS_VERSION = 1
45
+ # A distinct namespace per language so 4 languages share ONE docker stack without cross-talk.
46
+ PG_SCHEMA = os.environ.get("LIVEDB_PG_SCHEMA", "scp_py")
47
+ MYSQL_DB = os.environ.get("LIVEDB_MYSQL_DB", "scp_py")
48
+
49
+
50
+ def _corpus_path() -> Path:
51
+ env = os.environ.get("LITEDBMODEL_LIVEDB_VECTORS")
52
+ if env:
53
+ return Path(env)
54
+ return Path(__file__).resolve().parent.parent.parent / "conformance" / "vectors-livedb" / "livedb.json"
55
+
56
+
57
+ def _pg_cfg() -> Dict[str, Any]:
58
+ return dict(
59
+ host=os.environ.get("TEST_DB_HOST", "localhost"),
60
+ port=int(os.environ.get("TEST_DB_PORT", "5433")),
61
+ user=os.environ.get("TEST_DB_USER", "testuser"),
62
+ password=os.environ.get("TEST_DB_PASSWORD", "testpass"),
63
+ dbname=os.environ.get("TEST_DB_NAME", "testdb"),
64
+ )
65
+
66
+
67
+ def _mysql_cfg() -> Dict[str, Any]:
68
+ return dict(
69
+ host=os.environ.get("TEST_MYSQL_HOST", "localhost"),
70
+ port=int(os.environ.get("TEST_MYSQL_PORT", "3307")),
71
+ user=os.environ.get("TEST_MYSQL_USER", "testuser"),
72
+ password=os.environ.get("TEST_MYSQL_PASSWORD", "testpass"),
73
+ dbname=os.environ.get("TEST_MYSQL_DB", "testdb"),
74
+ )
75
+
76
+
77
+ # ── value equality (numeric canon, mirror of vectors_runner) ───────────────────
78
+
79
+
80
+ def _numeric_canon(x: Any) -> Any:
81
+ if isinstance(x, dict):
82
+ if len(x) == 1 and "$bigint" in x:
83
+ return int(x["$bigint"])
84
+ return {k: _numeric_canon(v) for k, v in x.items()}
85
+ if isinstance(x, list):
86
+ return [_numeric_canon(v) for v in x]
87
+ return x
88
+
89
+
90
+ def _canon(x: Any) -> str:
91
+ return json.dumps(_numeric_canon(x), sort_keys=True, ensure_ascii=False)
92
+
93
+
94
+ def _eq(a: Any, b: Any) -> bool:
95
+ return _canon(a) == _canon(b)
96
+
97
+
98
+ def _encode(v: Any) -> Any:
99
+ """Encode a runtime value to pure JSON (mirror of vectors_runner.encode_value)."""
100
+ if isinstance(v, bool):
101
+ return v
102
+ if v is None or isinstance(v, (int, float, str)):
103
+ return v
104
+ if isinstance(v, list):
105
+ return [_encode(x) for x in v]
106
+ if isinstance(v, dict):
107
+ return {k: _encode(x) for k, x in v.items()}
108
+ return v
109
+
110
+
111
+ # ── per-dialect table lifecycle ────────────────────────────────────────────────
112
+
113
+ # The tables the corpus touches (drop order respects FK dependents-first).
114
+ _ALL_TABLES = ["post_tags", "posts", "tags", "users", "idem", "uniq", "outbox"]
115
+
116
+
117
+ def _reset_pg(driver: "PostgresDriver", schema: List[str]) -> None:
118
+ """Fresh namespace: drop the corpus tables (CASCADE) then apply the vector's DDL.
119
+
120
+ Dropping + recreating restarts SERIAL identity at 1, so the tx `entity.id` matches the SQLite
121
+ AUTOINCREMENT reference.
122
+ """
123
+ driver.exec_ddl([f"DROP TABLE IF EXISTS {t} CASCADE" for t in _ALL_TABLES])
124
+ driver.exec_ddl(list(schema))
125
+
126
+
127
+ def _reset_mysql(driver: "MysqlDriver", schema: List[str]) -> None:
128
+ stmts = ["SET FOREIGN_KEY_CHECKS = 0"]
129
+ stmts += [f"DROP TABLE IF EXISTS {t}" for t in _ALL_TABLES]
130
+ stmts += ["SET FOREIGN_KEY_CHECKS = 1"]
131
+ driver.exec_ddl(stmts)
132
+ driver.exec_ddl(list(schema))
133
+
134
+
135
+ def _run_exec(driver, bundle, vector) -> Dict[str, Any]:
136
+ result = _encode(execute_bundle(bundle, dict(vector["input"]), driver))
137
+ ok = _eq(result, vector["expectedResult"])
138
+ return {"ok": ok, "detail": None if ok else f"result {json.dumps(result)} != {json.dumps(vector['expectedResult'])}"}
139
+
140
+
141
+ def _run_tx(driver, bundle, vector) -> Dict[str, Any]:
142
+ result = _encode(execute_transaction_bundle(bundle, dict(vector["input"]), driver))
143
+ result_ok = _eq(result, vector["expectedResult"])
144
+ state_ok = True
145
+ detail: List[str] = []
146
+ if not result_ok:
147
+ detail.append(f"result {json.dumps(result)} != {json.dumps(vector['expectedResult'])}")
148
+ for s in vector.get("expectedDbState", []) or []:
149
+ got = _encode(driver.prepare(s["query"]).all([]))
150
+ if not _eq(got, s["rows"]):
151
+ state_ok = False
152
+ detail.append(f"db-state '{s['query']}': {json.dumps(got)} != {json.dumps(s['rows'])}")
153
+ ok = result_ok and state_ok
154
+ return {"ok": ok, "detail": None if ok else "; ".join(detail)}
155
+
156
+
157
+ def _run_dialect_leg(dialect: str, driver, reset_fn, corpus, bundle_key, schema_key) -> Dict[str, int]:
158
+ t = {"pass": 0, "fail": 0}
159
+ sys.stderr.write(f"\nlivedb-{dialect} — {len(corpus['vectors'])} vectors (real {dialect})\n")
160
+ for v in corpus["vectors"]:
161
+ reset_fn(driver, list(v[schema_key]))
162
+ bundle = v[bundle_key]
163
+ try:
164
+ if v["kind"] == "exec":
165
+ r = _run_exec(driver, bundle, v)
166
+ elif v["kind"] == "tx":
167
+ r = _run_tx(driver, bundle, v)
168
+ else:
169
+ r = {"ok": False, "detail": f"unknown kind {v['kind']}"}
170
+ except Exception as e: # a live-DB failure is a vector FAILURE, never a fake pass
171
+ import traceback
172
+
173
+ r = {"ok": False, "detail": f"threw: {e}\n{traceback.format_exc()}"}
174
+ if r["ok"]:
175
+ t["pass"] += 1
176
+ sys.stderr.write(f" ok {v['name']}\n")
177
+ else:
178
+ t["fail"] += 1
179
+ sys.stderr.write(f" XX {v['name']}\n {r.get('detail')}\n")
180
+ return t
181
+
182
+
183
+ def main() -> int:
184
+ sys.stderr.write("litedbmodel SCP LIVE-DB conformance — Python runner (real PG + MySQL)\n")
185
+ corpus = json.loads(_corpus_path().read_text(encoding="utf-8"))
186
+ if corpus.get("corpusVersion") != SUPPORTED_CORPUS_VERSION:
187
+ sys.stderr.write(f"FAIL-CLOSED: corpusVersion {corpus.get('corpusVersion')} != {SUPPORTED_CORPUS_VERSION}\n")
188
+ print(json.dumps({"lang": "py-livedb", "suites": {}, "total_pass": 0, "total_fail": 0, "version_mismatch": True}))
189
+ return 2
190
+
191
+ # Connect (LOUD failure if unreachable — never skip).
192
+ try:
193
+ pg = PostgresDriver.connect(**_pg_cfg())
194
+ pg.exec_ddl([f"CREATE SCHEMA IF NOT EXISTS {PG_SCHEMA}", f"SET search_path TO {PG_SCHEMA}"])
195
+ except Exception as e:
196
+ sys.stderr.write(f"FATAL: Postgres unreachable at {_pg_cfg()['host']}:{_pg_cfg()['port']} — {e}\n")
197
+ return 3
198
+ try:
199
+ my = MysqlDriver.connect(**_mysql_cfg())
200
+ my.exec_ddl([f"CREATE DATABASE IF NOT EXISTS {MYSQL_DB}", f"USE {MYSQL_DB}"])
201
+ except Exception as e:
202
+ sys.stderr.write(f"FATAL: MySQL unreachable at {_mysql_cfg()['host']}:{_mysql_cfg()['port']} — {e}\n")
203
+ return 3
204
+
205
+ try:
206
+ pg_t = _run_dialect_leg("pg", pg, _reset_pg, corpus, "bundlePg", "schemaPg")
207
+ my_t = _run_dialect_leg("mysql", my, _reset_mysql, corpus, "bundleMysql", "schemaMysql")
208
+ finally:
209
+ pg.close()
210
+ my.close()
211
+
212
+ suites = {"livedb-pg": pg_t, "livedb-mysql": my_t}
213
+ total_pass = pg_t["pass"] + my_t["pass"]
214
+ total_fail = pg_t["fail"] + my_t["fail"]
215
+ sys.stderr.write(f"\n{total_pass} passed, {total_fail} failed / {total_pass + total_fail} live-DB vectors\n")
216
+ print(json.dumps({"lang": "py-livedb", "suites": suites, "total_pass": total_pass, "total_fail": total_fail, "version_mismatch": False}))
217
+ return 1 if total_fail > 0 else 0
218
+
219
+
220
+ if __name__ == "__main__":
221
+ raise SystemExit(main())
@@ -0,0 +1,160 @@
1
+ """litedbmodel v2 SCP — fragment-tree render + param assembly (Python port of ``src/scp/render.ts``).
2
+
3
+ A byte-for-byte port of the NORMATIVE dynamic-expansion reference
4
+ (`docs/proposal/sql-dynamic-expansion-spec.md`). Given a §8 CompiledOperation and a bound input
5
+ scope, it deterministically produces the final SQL text (`?` placeholders, or `$N` for Postgres
6
+ via the dialect's final pass) + the flat params list — reproducing the TS `renderOperation`
7
+ exactly. The four moving parts:
8
+
9
+ §2 SKIP → fragment existence: a fragment with `when` is present iff `when` evaluates to a
10
+ present (non-null / non-false) binding; an absent fragment contributes NO SQL and NO params.
11
+ §3 empty-WHERE degeneration: if no fragment is present the whole ` WHERE …` splice collapses.
12
+ §4 AND/OR structure + parenthesization: a nested tree renders `(… <connector> …)`.
13
+ §5 IN-list array expansion: an `expand` slot turns its `(?)` into `(?, ?, …)` per element (0
14
+ elements → the `1 = 0` always-false degeneration).
15
+
16
+ The CLOSED Expression-IR evaluation (ref/refOpt/coalesce/eq/…) is delegated to
17
+ behavior-contracts (`evaluate_expression`) — this module re-implements NO generic evaluator,
18
+ exactly like the TS reference imports `evaluateExpression` from `behavior-contracts`.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from typing import Any, List, Mapping
24
+
25
+ from behavior_contracts import evaluate_expression
26
+
27
+ from .dialect import Dialect, SQLITE
28
+
29
+ # The literal `{where}` splice marker inside CompiledOperation.sql (spec §8 / ir.ts WHERE_SLOT).
30
+ WHERE_SLOT = "{where}"
31
+
32
+
33
+ class RenderedSql:
34
+ """The result of rendering: final SQL text + flat params (1:1 with `?`)."""
35
+
36
+ __slots__ = ("sql", "params")
37
+
38
+ def __init__(self, sql: str, params: List[Any]) -> None:
39
+ self.sql = sql
40
+ self.params = params
41
+
42
+
43
+ def _is_tree(node: Mapping[str, Any]) -> bool:
44
+ """A fragment tree carries a `connector`; a leaf fragment carries `sql`."""
45
+ return "connector" in node
46
+
47
+
48
+ def _fragment_present(fragment: Mapping[str, Any], scope: Mapping[str, Any]) -> bool:
49
+ """SKIP existence (spec §2): present iff `always`, or `when` evaluates truthy-present.
50
+
51
+ `null` and `false` are absent; everything else (including `0`, `""`) is present — mirroring
52
+ the TS `fragmentPresent`. `when` is an explicit presence/bool Expression evaluated fail-closed
53
+ by bc's `evaluate_expression`. Neither `always` nor `when` set ⇒ fail-closed absent.
54
+ """
55
+ if fragment.get("always") is True:
56
+ return True
57
+ if "when" not in fragment:
58
+ return False # fail-closed: neither always nor when
59
+ v = evaluate_expression(fragment["when"], scope)
60
+ return v is not None and v is not False
61
+
62
+
63
+ def _render_fragment(fragment: Mapping[str, Any], scope: Mapping[str, Any], params: List[Any]) -> str:
64
+ """Render one leaf fragment's SQL + params into the accumulator (IN-list expansion, §5)."""
65
+ expand = fragment.get("expand")
66
+ frag_params = fragment["params"]
67
+ if expand is None:
68
+ for slot in frag_params:
69
+ params.append(evaluate_expression(slot, scope))
70
+ return fragment["sql"]
71
+
72
+ # IN-list expansion. Evaluate all slots; the `expand` slot must be an array.
73
+ sql = fragment["sql"]
74
+ for i, slot in enumerate(frag_params):
75
+ v = evaluate_expression(slot, scope)
76
+ if i == expand:
77
+ if not isinstance(v, list):
78
+ kind = "null" if v is None else type(v).__name__
79
+ raise ValueError(
80
+ f"IN-list expansion slot {i} did not bind to an array (got {kind})"
81
+ )
82
+ if len(v) == 0:
83
+ # Empty-array degeneration (spec §5): `col IN (?)` collapses to the always-false
84
+ # sentinel `1 = 0`. No params pushed for this slot. Byte-identical to TS/v1.
85
+ sql = "1 = 0"
86
+ else:
87
+ # Replace the single `(?)` with `(?, ?, …)`; push each element.
88
+ sql = sql.replace("(?)", "(" + ", ".join("?" for _ in v) + ")", 1)
89
+ for el in v:
90
+ params.append(el)
91
+ else:
92
+ params.append(v)
93
+ return sql
94
+
95
+
96
+ def _render_tree(tree: Mapping[str, Any], scope: Mapping[str, Any], params: List[Any]) -> str:
97
+ """Render a fragment tree into a WHERE body (no leading ` WHERE `). Empty when none present (§3)."""
98
+ parts: List[str] = []
99
+ for node in tree["fragments"]:
100
+ if _is_tree(node):
101
+ inner = _render_tree(node, scope, params)
102
+ if inner != "":
103
+ parts.append(f"({inner})")
104
+ elif _fragment_present(node, scope):
105
+ parts.append(_render_fragment(node, scope, params))
106
+ if not parts:
107
+ return ""
108
+ return (f" {tree['connector']} ").join(parts)
109
+
110
+
111
+ def _count_placeholders(sql: str) -> int:
112
+ """Count `?` placeholders in a static SQL segment (no fragment markers present)."""
113
+ return sql.count("?")
114
+
115
+
116
+ def render_operation(
117
+ operation: Mapping[str, Any],
118
+ input_scope: Mapping[str, Any],
119
+ dialect: Dialect = SQLITE,
120
+ ) -> RenderedSql:
121
+ """Render a §8 CompiledOperation to final SQL + params for a bound input scope.
122
+
123
+ Byte-for-byte port of the TS `renderOperation`. Param order matches SQL text order (spec §6):
124
+ pre-WHERE statics, then fragment params in tree order, then post-WHERE statics. A single
125
+ left-to-right walk of the spliced SQL yields the canonical placeholder order; the dialect's
126
+ `finalize_placeholders` applies the `?`→`$N` pass ONCE over the fully-assembled text.
127
+ """
128
+ params: List[Any] = []
129
+ op_sql = operation["sql"]
130
+ op_params = operation["params"]
131
+ marker_idx = op_sql.find(WHERE_SLOT)
132
+
133
+ if marker_idx == -1:
134
+ # No dynamic WHERE: all params are static, in position order.
135
+ for slot in op_params:
136
+ params.append(evaluate_expression(slot, input_scope))
137
+ return RenderedSql(dialect.finalize_placeholders(op_sql), params)
138
+
139
+ before = op_sql[:marker_idx]
140
+ after = op_sql[marker_idx + len(WHERE_SLOT):]
141
+
142
+ # Static params are partitioned by whether their `?` sits before or after the marker.
143
+ before_q = _count_placeholders(before)
144
+ pre_statics = op_params[:before_q]
145
+ post_statics = op_params[before_q:]
146
+
147
+ for slot in pre_statics:
148
+ params.append(evaluate_expression(slot, input_scope))
149
+
150
+ where_sql = ""
151
+ where_tree = operation.get("where")
152
+ if where_tree is not None:
153
+ body = _render_tree(where_tree, input_scope, params)
154
+ if body != "":
155
+ where_sql = f" WHERE {body}" # degeneration §3: drop keyword when empty
156
+
157
+ for slot in post_statics:
158
+ params.append(evaluate_expression(slot, input_scope))
159
+
160
+ return RenderedSql(dialect.finalize_placeholders(before + where_sql + after), params)
@@ -0,0 +1,282 @@
1
+ """litedbmodel SCP Python runtime — the §8 bundle interpreter (WS7b, #31).
2
+
3
+ The thin Python leg of the multi-language SCP runtime. It consumes the language-neutral §8
4
+ published :class:`SqlBundle` (pure JSON: `sql` + fragment tree + closed-set Expression-IR param
5
+ slots + optional transaction plan, dialect-tagged) and executes it against a SQL driver —
6
+ SEMANTICS-IDENTICAL to the TS reference (``src/scp/runtime.ts`` + ``write-runtime.ts``).
7
+
8
+ It re-implements NO generic execution/expression evaluation: bc's ``run_behavior`` owns the
9
+ plan/map/wire/output orchestration and its ``evaluate_expression`` owns the CLOSED Expression-IR
10
+ (ref/refOpt/coalesce/eq/…, the SKIP guards + param slots). This module adds ONLY the SQL-backend
11
+ concerns (spec §11): render the pre-compiled op per dialect (``render_operation``) → bind params
12
+ → execute REAL SQL → row→result assembly; and, for a Command bundle, the gate-first write
13
+ transaction (``execute_transaction_bundle``). This mirrors the TS runtime's division of labor
14
+ exactly (bc-core + a SQL handler).
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import re
21
+ from typing import Any, Dict, List, Mapping, Optional
22
+
23
+ from behavior_contracts import run_behavior
24
+
25
+ from .dialect import Dialect, dialect_for
26
+ from .driver import Driver
27
+ from .errors import SqlFailure, map_sqlite_error
28
+ from .render import render_operation
29
+
30
+ # The synthetic port that carries a SQL node's render scope (mirrors TS SCOPE_PORT).
31
+ SCOPE_PORT = "__scope"
32
+ # The reserved binding the body write's RETURNING row is exposed under (mirrors TS ENTITY_ROOT).
33
+ ENTITY_ROOT = "__entity"
34
+
35
+ _RETURNING_RE = re.compile(r"\breturning\b", re.IGNORECASE)
36
+
37
+
38
+ # ── Value normalization at the SQL boundary ───────────────────────────────────
39
+
40
+
41
+ def _to_driver_param(v: Any) -> Any:
42
+ """Convert a rendered param to a driver-bindable value (mirrors TS toDriverParam).
43
+
44
+ bc evaluates integers to plain Python `int` (not bigint), so no bigint narrowing is needed;
45
+ a bool is left as-is (sqlite3 binds it as 0/1, matching better-sqlite3). An object/dict is an
46
+ emit payload (`{obj:…}`) serialized to compact JSON for a text column — byte-identical to the
47
+ TS `JSON.stringify` (no spaces). Lists are IN-list elements handled during expansion, never
48
+ reaching here as a single param on the write path; pass through.
49
+ """
50
+ if isinstance(v, bool):
51
+ return v
52
+ if isinstance(v, dict):
53
+ # Compact separators to match JS JSON.stringify (`{"a":1,"b":2}`, no spaces).
54
+ return json.dumps(v, separators=(",", ":"), ensure_ascii=False)
55
+ return v
56
+
57
+
58
+ def _is_return_stmt(op: Mapping[str, Any], sql: str) -> bool:
59
+ """A SELECT or a write with RETURNING yields rows (mirrors TS hasReturn)."""
60
+ return op.get("component") == "Select" or bool(_RETURNING_RE.search(sql))
61
+
62
+
63
+ # ── Handler: render → execute → assembly ──────────────────────────────────────
64
+
65
+
66
+ def _execute_rendered(
67
+ driver: Driver,
68
+ op: Mapping[str, Any],
69
+ scope: Mapping[str, Any],
70
+ dialect: Dialect,
71
+ ) -> Mapping[str, Any]:
72
+ """Render + execute one op; return a bc ExecOutcome (`{ok}` / `{error}`). Mirrors TS."""
73
+ rendered = render_operation(op, scope, dialect)
74
+ params = [_to_driver_param(p) for p in rendered.params]
75
+ try:
76
+ stmt = driver.prepare(rendered.sql)
77
+ except Exception as e: # driver error at prepare → mapped {error}
78
+ return {"error": map_sqlite_error(e).args[0] if e.args else str(e)}
79
+ try:
80
+ if _is_return_stmt(op, rendered.sql):
81
+ rows = stmt.all(params)
82
+ return {"ok": rows}
83
+ info = stmt.run(params)
84
+ # A non-returning write returns the RETURNING-less single-row summary shape.
85
+ return {"ok": [{"changes": info.changes, "lastInsertRowid": info.last_insert_rowid}]}
86
+ except Exception as e:
87
+ return {"error": str(map_sqlite_error(e))}
88
+
89
+
90
+ def _build_handlers(
91
+ driver: Driver,
92
+ operations: Mapping[str, Any],
93
+ dialect: Dialect,
94
+ ):
95
+ """Build the SQL handler registry: one handler per SQL Catalog name (spec §11 item 4)."""
96
+
97
+ def handle(ports: Mapping[str, Any], ctx: Mapping[str, Any]) -> Mapping[str, Any]:
98
+ node_id = ctx["nodeId"]
99
+ op = operations.get(node_id)
100
+ if op is None:
101
+ return {"error": f"scp runtime: no compiled operation for node '{node_id}' ({ctx.get('component')})"}
102
+ scope = ports.get(SCOPE_PORT)
103
+ if not isinstance(scope, dict):
104
+ return {"error": f"scp runtime: node '{node_id}' surrogate scope did not evaluate to an object"}
105
+ return _execute_rendered(driver, op, scope, dialect)
106
+
107
+ return {"Select": handle, "Insert": handle, "Update": handle, "Delete": handle}
108
+
109
+
110
+ # ── Input normalization (schema-driven — SSoT, no ad-hoc code default) ─────────
111
+
112
+
113
+ def _normalize_input(
114
+ component: Mapping[str, Any],
115
+ optional_heads: List[str],
116
+ input_scope: Mapping[str, Any],
117
+ ) -> Dict[str, Any]:
118
+ """Normalize omitted OPTIONAL bindings to present-as-null (mirrors TS normalizeInput).
119
+
120
+ "Optional" comes from the SSoT: EITHER the component's Input Port schema marks the port
121
+ `required != True`, OR the head is listed in the bundle's `optionalHeads` (a SKIP-guarded /
122
+ refOpt head the TS compile derived). A REQUIRED, non-optional missing head is left absent so a
123
+ real wiring bug surfaces loudly as bc's UNKNOWN_BINDING — never silently defaulted.
124
+ """
125
+ out = dict(input_scope)
126
+ for port, schema in component.get("inputPorts", {}).items():
127
+ if schema.get("required") is not True and port not in out:
128
+ out[port] = None
129
+ for head in optional_heads:
130
+ if head not in out:
131
+ out[head] = None
132
+ return out
133
+
134
+
135
+ # ── Public runtime entrypoints ────────────────────────────────────────────────
136
+
137
+
138
+ def render_operation_bundle(operation: Mapping[str, Any], scope: Mapping[str, Any], dialect_name: str) -> Dict[str, Any]:
139
+ """Render a §8 CompiledOperation → {"sql", "params"} for a dialect name (render axis API)."""
140
+ rendered = render_operation(operation, scope, dialect_for(dialect_name))
141
+ return {"sql": rendered.sql, "params": rendered.params}
142
+
143
+
144
+ def execute_bundle(bundle: Mapping[str, Any], input_scope: Mapping[str, Any], driver: Driver) -> Any:
145
+ """Execute a §8 read/exec SqlBundle end-to-end (bc run_behavior + SQL handlers).
146
+
147
+ The SAME code path a consumer runtime follows: it consumes ONLY the serialized bundle + bc
148
+ runtime-core, never re-running litedbmodel's Backend-Compile. Returns the component's `output`
149
+ (Φ merge) with each SQL node's slot filled by its executed row list — byte-identical to the TS
150
+ `executeBundle`.
151
+ """
152
+ surrogate = bundle["component"]
153
+ ir = {
154
+ "irVersion": bundle["irVersion"],
155
+ "exprVersion": bundle["exprVersion"],
156
+ "components": [surrogate],
157
+ }
158
+ handlers = _build_handlers(driver, bundle["operations"], dialect_for(bundle["dialect"]))
159
+ normalized = _normalize_input(surrogate, list(bundle.get("optionalHeads", [])), input_scope)
160
+ try:
161
+ return run_behavior(ir, handlers, normalized, surrogate["name"])
162
+ except Exception as e:
163
+ raise _re_error_to_sql_failure(e)
164
+
165
+
166
+ # ── Write-time relations: gate-first transaction (spec §6 — port of write-runtime.ts) ──
167
+
168
+
169
+ def _gate_short_circuit(gate: str, rows: List[Dict[str, Any]], changes: int) -> Optional[str]:
170
+ """Evaluate a gate rule on a statement result → short-circuit reason, or None to continue."""
171
+ if gate == "existsElseRollback":
172
+ return "requires_absent" if len(rows) == 0 else None
173
+ if gate == "insertedElseRollback":
174
+ return "unique_collision" if changes == 0 else None
175
+ if gate == "insertedElseNoop":
176
+ return "idempotent_duplicate" if changes == 0 else None
177
+ raise ValueError(f"scp write: unknown gate rule '{gate}'")
178
+
179
+
180
+ def _exec_statement(
181
+ driver: Driver,
182
+ op: Mapping[str, Any],
183
+ scope: Mapping[str, Any],
184
+ dialect: Dialect,
185
+ ):
186
+ """Render + execute one tx statement; return (rows, changes). Mirrors TS execStatement."""
187
+ rendered = render_operation(op, scope, dialect)
188
+ params = [_to_driver_param(p) for p in rendered.params]
189
+ stmt = driver.prepare(rendered.sql)
190
+ if _is_return_stmt(op, rendered.sql):
191
+ rows = stmt.all(params)
192
+ return rows, len(rows)
193
+ info = stmt.run(params)
194
+ return [], info.changes
195
+
196
+
197
+ def execute_transaction_bundle(bundle: Mapping[str, Any], input_scope: Mapping[str, Any], driver: Driver) -> Dict[str, Any]:
198
+ """Execute a §8 SqlBundle's derived transaction plan as ONE real transaction (gate-first).
199
+
200
+ Byte-for-byte port of the TS `executeTransactionBundle` → `executeTransaction` (spec §6):
201
+ statements run in the plan's fixed order (requires → idempotency → unique → body → derive →
202
+ edges → emits); a failing gate ROLLBACKs and the tail never executes. On success COMMITs and
203
+ returns the `$.entity` RETURNING row. A short-circuit returns `committed:false` (a legitimate
204
+ gate outcome), NOT a raised failure; a driver failure ROLLBACKs then raises a mapped SqlFailure.
205
+ """
206
+ plan = bundle.get("transaction")
207
+ if plan is None:
208
+ raise ValueError("scp write: this bundle carries no transaction plan (not a write-time-relations Command bundle)")
209
+ dialect = dialect_for(bundle["dialect"])
210
+
211
+ driver.prepare("BEGIN").run([])
212
+ executed: List[str] = []
213
+ scope: Dict[str, Any] = dict(input_scope)
214
+ entity: Optional[Dict[str, Any]] = None
215
+
216
+ try:
217
+ for stmt in plan["statements"]:
218
+ rows, changes = _exec_statement(driver, stmt["op"], scope, dialect)
219
+ executed.append(stmt["id"])
220
+
221
+ gate = stmt.get("gate")
222
+ if gate is not None:
223
+ reason = _gate_short_circuit(gate, rows, changes)
224
+ if reason is not None:
225
+ driver.prepare("ROLLBACK").run([])
226
+ return {
227
+ "committed": False,
228
+ "shortCircuit": {"statementId": stmt["id"], "reason": reason},
229
+ "entity": None,
230
+ "executed": executed,
231
+ }
232
+
233
+ if stmt["id"] == plan.get("entityFrom"):
234
+ entity = rows[0] if rows else None
235
+ if entity is not None:
236
+ scope[ENTITY_ROOT] = entity
237
+
238
+ # WS8a composite: bind THIS statement's RETURNING row under its `binds` name so a later
239
+ # `$.ref.<binds>.<field>` resolves against it (the tx-DAG data-dependency edge). Self-
240
+ # describing — the runtime binds the row the plan told it to; no re-derivation.
241
+ binds = stmt.get("binds")
242
+ if binds is not None and rows:
243
+ scope[binds] = rows[0]
244
+
245
+ driver.prepare("COMMIT").run([])
246
+ result: Dict[str, Any] = {"committed": True, "entity": entity, "executed": executed}
247
+ return result
248
+ except SqlFailure:
249
+ _safe_rollback(driver)
250
+ raise
251
+ except Exception as e:
252
+ _safe_rollback(driver)
253
+ raise map_sqlite_error(e)
254
+
255
+
256
+ def _safe_rollback(driver: Driver) -> None:
257
+ try:
258
+ driver.prepare("ROLLBACK").run([])
259
+ except Exception:
260
+ pass # best-effort; the original failure is surfaced by the caller
261
+
262
+
263
+ # ── Dialect primitive (render axis + dialect suite) ────────────────────────────
264
+
265
+
266
+ def order_by_nulls(expr: str, direction: str, nulls: str, dialect_name: str) -> str:
267
+ """The dialect NULLS-ordering primitive (native for PG/SQLite, IS NULL for MySQL)."""
268
+ return dialect_for(dialect_name).order_by_nulls(expr, direction, nulls)
269
+
270
+
271
+ # ── Error re-surfacing (mirrors TS reErrorToSqlFailure) ────────────────────────
272
+
273
+
274
+ def _re_error_to_sql_failure(e: Exception) -> Exception:
275
+ """Re-surface a structured SqlFailure from a bc OP_FAILED whose message embeds a SQLITE_ code."""
276
+ if isinstance(e, SqlFailure):
277
+ return e
278
+ message = str(e)
279
+ m = re.search(r"(SQLITE_[A-Z_]+)", message)
280
+ if m:
281
+ return map_sqlite_error(Exception(message))
282
+ return e