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.
- litedbmodel_runtime/__init__.py +60 -0
- litedbmodel_runtime/dialect.py +99 -0
- litedbmodel_runtime/driver.py +252 -0
- litedbmodel_runtime/errors.py +83 -0
- litedbmodel_runtime/livedb_runner.py +221 -0
- litedbmodel_runtime/render.py +160 -0
- litedbmodel_runtime/runtime.py +282 -0
- litedbmodel_runtime/vectors_runner.py +246 -0
- litedbmodel_runtime-2.0.0.dist-info/METADATA +43 -0
- litedbmodel_runtime-2.0.0.dist-info/RECORD +12 -0
- litedbmodel_runtime-2.0.0.dist-info/WHEEL +5 -0
- litedbmodel_runtime-2.0.0.dist-info/top_level.txt +1 -0
|
@@ -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
|