litedbmodel-runtime 2.0.0__tar.gz → 2.0.2__tar.gz

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.
Files changed (28) hide show
  1. {litedbmodel_runtime-2.0.0 → litedbmodel_runtime-2.0.2}/PKG-INFO +3 -3
  2. {litedbmodel_runtime-2.0.0 → litedbmodel_runtime-2.0.2}/litedbmodel_runtime/__init__.py +31 -10
  3. litedbmodel_runtime-2.0.2/litedbmodel_runtime/driver.py +479 -0
  4. {litedbmodel_runtime-2.0.0 → litedbmodel_runtime-2.0.2}/litedbmodel_runtime/livedb_runner.py +51 -10
  5. litedbmodel_runtime-2.0.2/litedbmodel_runtime/relation.py +189 -0
  6. litedbmodel_runtime-2.0.2/litedbmodel_runtime/runtime.py +218 -0
  7. litedbmodel_runtime-2.0.2/litedbmodel_runtime/static_bundle.py +325 -0
  8. {litedbmodel_runtime-2.0.0 → litedbmodel_runtime-2.0.2}/litedbmodel_runtime/vectors_runner.py +18 -7
  9. {litedbmodel_runtime-2.0.0 → litedbmodel_runtime-2.0.2}/litedbmodel_runtime.egg-info/PKG-INFO +3 -3
  10. {litedbmodel_runtime-2.0.0 → litedbmodel_runtime-2.0.2}/litedbmodel_runtime.egg-info/SOURCES.txt +3 -1
  11. {litedbmodel_runtime-2.0.0 → litedbmodel_runtime-2.0.2}/litedbmodel_runtime.egg-info/requires.txt +1 -1
  12. {litedbmodel_runtime-2.0.0 → litedbmodel_runtime-2.0.2}/pyproject.toml +3 -3
  13. {litedbmodel_runtime-2.0.0 → litedbmodel_runtime-2.0.2}/tests/test_conformance_corpus.py +4 -3
  14. {litedbmodel_runtime-2.0.0 → litedbmodel_runtime-2.0.2}/tests/test_consumes_bc_core.py +13 -8
  15. litedbmodel_runtime-2.0.2/tests/test_parallel_read.py +124 -0
  16. litedbmodel_runtime-2.0.2/tests/test_render.py +104 -0
  17. {litedbmodel_runtime-2.0.0 → litedbmodel_runtime-2.0.2}/tests/test_runtime.py +95 -57
  18. litedbmodel_runtime-2.0.0/litedbmodel_runtime/driver.py +0 -252
  19. litedbmodel_runtime-2.0.0/litedbmodel_runtime/render.py +0 -160
  20. litedbmodel_runtime-2.0.0/litedbmodel_runtime/runtime.py +0 -282
  21. litedbmodel_runtime-2.0.0/tests/test_render.py +0 -142
  22. {litedbmodel_runtime-2.0.0 → litedbmodel_runtime-2.0.2}/README.md +0 -0
  23. {litedbmodel_runtime-2.0.0 → litedbmodel_runtime-2.0.2}/litedbmodel_runtime/dialect.py +0 -0
  24. {litedbmodel_runtime-2.0.0 → litedbmodel_runtime-2.0.2}/litedbmodel_runtime/errors.py +0 -0
  25. {litedbmodel_runtime-2.0.0 → litedbmodel_runtime-2.0.2}/litedbmodel_runtime.egg-info/dependency_links.txt +0 -0
  26. {litedbmodel_runtime-2.0.0 → litedbmodel_runtime-2.0.2}/litedbmodel_runtime.egg-info/top_level.txt +0 -0
  27. {litedbmodel_runtime-2.0.0 → litedbmodel_runtime-2.0.2}/setup.cfg +0 -0
  28. {litedbmodel_runtime-2.0.0 → litedbmodel_runtime-2.0.2}/tests/test_dialect.py +0 -0
@@ -1,13 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: litedbmodel-runtime
3
- Version: 2.0.0
4
- Summary: Thin multi-language runtime for litedbmodel v2 SCP §8 bundles (Python port). Interprets the published SqlBundle (sql + fragment tree + Expression-IR param slots + transaction plan, dialect-tagged) and executes it against a SQL driver — semantics-identical to the TS reference. Delegates Expression-IR evaluation to behavior-contracts.
3
+ Version: 2.0.2
4
+ Summary: Thin multi-language runtime for litedbmodel v2 SCP makeSQL bundles (Python port). Executes the published static-makeSQL artifacts (a read ReadGraph = a bc surrogate ComponentGraphIR + per-node static statement templates, or a gate-first TransactionPlan) against a SQL driver — semantics-identical to the TS reference. Delegates the Expression-IR evaluation + map/Φ/wiring orchestration to behavior-contracts.
5
5
  Author: foo-ogawa
6
6
  License: MIT
7
7
  Keywords: orm,sql,ir,runtime,conformance,litedbmodel
8
8
  Requires-Python: >=3.9
9
9
  Description-Content-Type: text/markdown
10
- Requires-Dist: behavior-contracts==0.2.0
10
+ Requires-Dist: behavior-contracts==0.5.0
11
11
  Provides-Extra: test
12
12
  Requires-Dist: pytest>=7.0; extra == "test"
13
13
  Provides-Extra: livedb
@@ -15,17 +15,30 @@ from __future__ import annotations
15
15
  from .dialect import SQLITE, POSTGRES, MYSQL, Dialect, dialect_for, to_dollar_placeholders
16
16
  from .driver import Driver, MysqlDriver, PostgresDriver, PreparedStatement, RunInfo, SqliteDriver
17
17
  from .errors import SqlFailure, map_sqlite_error
18
- from .render import RenderedSql, WHERE_SLOT, render_operation
18
+ from .static_bundle import (
19
+ NODE_COMPONENT,
20
+ SCOPE_PORT,
21
+ assemble_make_sql,
22
+ compose_make_sql,
23
+ execute_read_graph,
24
+ render_placeholders,
25
+ render_read_primary,
26
+ render_statements,
27
+ )
19
28
  from .runtime import (
20
29
  ENTITY_ROOT,
21
- SCOPE_PORT,
22
30
  execute_bundle,
23
31
  execute_transaction_bundle,
24
32
  order_by_nulls,
25
- render_operation_bundle,
33
+ )
34
+ from .relation import (
35
+ dedupe_keys,
36
+ distribute_to_parent,
37
+ read_bundle,
38
+ run_relation_op,
26
39
  )
27
40
 
28
- __version__ = "2.0.0"
41
+ __version__ = "2.0.2"
29
42
 
30
43
  __all__ = [
31
44
  "__version__",
@@ -46,15 +59,23 @@ __all__ = [
46
59
  # errors
47
60
  "SqlFailure",
48
61
  "map_sqlite_error",
49
- # render
50
- "RenderedSql",
51
- "WHERE_SLOT",
52
- "render_operation",
53
- "render_operation_bundle",
62
+ # static makeSQL bundle runtime (the sole read/render path)
63
+ "NODE_COMPONENT",
64
+ "SCOPE_PORT",
65
+ "assemble_make_sql",
66
+ "compose_make_sql",
67
+ "execute_read_graph",
68
+ "render_placeholders",
69
+ "render_read_primary",
70
+ "render_statements",
54
71
  # runtime
55
72
  "ENTITY_ROOT",
56
- "SCOPE_PORT",
57
73
  "execute_bundle",
58
74
  "execute_transaction_bundle",
59
75
  "order_by_nulls",
76
+ # read-relation batch execution + hydration (#43)
77
+ "dedupe_keys",
78
+ "distribute_to_parent",
79
+ "read_bundle",
80
+ "run_relation_op",
60
81
  ]
@@ -0,0 +1,479 @@
1
+ """litedbmodel v2 SCP — SQL driver seam (WS7b).
2
+
3
+ The minimal synchronous SQL-driver surface the runtime needs, mirroring the TS `SqliteDb`
4
+ seam (`prepare(sql).all(...) / .run(...)`). The conformance bar executes against an in-process
5
+ stdlib ``sqlite3`` connection (:class:`SqliteDriver`) — the sanctioned in-proc substitute for a
6
+ docker integration DB (#31 AC; live PG/MySQL is deferred to a coordinated cross-language docker
7
+ pass). A psycopg / mysql-connector driver plugs into this SAME abstract seam later: implement
8
+ :class:`Driver.prepare` returning a :class:`PreparedStatement` (`all` / `run`) over the
9
+ paramstyle the bundle's dialect emits (`$N` for Postgres, `?`/`%s` for MySQL) — no runtime change.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import re
15
+ import sqlite3
16
+ from typing import Any, Dict, List, Optional, Protocol, Sequence
17
+
18
+
19
+ class RunInfo:
20
+ """The summary of a non-returning write: affected-row count + last insert rowid."""
21
+
22
+ __slots__ = ("changes", "last_insert_rowid")
23
+
24
+ def __init__(self, changes: int, last_insert_rowid: int) -> None:
25
+ self.changes = changes
26
+ self.last_insert_rowid = last_insert_rowid
27
+
28
+
29
+ class PreparedStatement(Protocol):
30
+ """A prepared statement: `all` returns the row list (SELECT/RETURNING); `run` a write summary."""
31
+
32
+ def all(self, params: Sequence[Any]) -> List[Dict[str, Any]]: ...
33
+
34
+ def run(self, params: Sequence[Any]) -> RunInfo: ...
35
+
36
+
37
+ class Driver(Protocol):
38
+ """The synchronous SQL-driver seam (mirrors the TS `SqliteDb`)."""
39
+
40
+ def prepare(self, sql: str) -> PreparedStatement: ...
41
+
42
+
43
+ class _SqlitePrepared:
44
+ """A prepared statement over a stdlib ``sqlite3`` connection."""
45
+
46
+ __slots__ = ("_conn", "_sql")
47
+
48
+ def __init__(self, conn: "sqlite3.Connection", sql: str) -> None:
49
+ self._conn = conn
50
+ self._sql = sql
51
+
52
+ def all(self, params: Sequence[Any]) -> List[Dict[str, Any]]:
53
+ cur = self._conn.execute(self._sql, tuple(params))
54
+ cols = [c[0] for c in cur.description] if cur.description is not None else []
55
+ rows = [dict(zip(cols, r)) for r in cur.fetchall()]
56
+ cur.close()
57
+ return rows
58
+
59
+ def run(self, params: Sequence[Any]) -> RunInfo:
60
+ cur = self._conn.execute(self._sql, tuple(params))
61
+ changes = cur.rowcount if cur.rowcount is not None else 0
62
+ last = cur.lastrowid if cur.lastrowid is not None else 0
63
+ cur.close()
64
+ return RunInfo(changes, last)
65
+
66
+
67
+ class SqliteDriver:
68
+ """An in-process stdlib ``sqlite3`` driver implementing the :class:`Driver` seam.
69
+
70
+ This is the runnable conformance seam: it binds `?` placeholders positionally, so a
71
+ Postgres-tagged bundle's `$N` text is NOT what runs here — the exec/tx vectors run only the
72
+ SQLite-tagged bundles (the §10 promise: same IR + input → same RESULT regardless of dialect
73
+ text). PG/MySQL SQL-text conformance is proven on the render axis; live PG/MySQL execution is
74
+ the coordinated docker pass.
75
+ """
76
+
77
+ __slots__ = ("conn",)
78
+
79
+ def __init__(self, conn: "sqlite3.Connection") -> None:
80
+ self.conn = conn
81
+
82
+ @classmethod
83
+ def in_memory(cls, schema: Sequence[str]) -> "SqliteDriver":
84
+ conn = sqlite3.connect(":memory:")
85
+ conn.execute("PRAGMA foreign_keys = ON")
86
+ for stmt in schema:
87
+ conn.execute(stmt)
88
+ conn.commit()
89
+ return cls(conn)
90
+
91
+ def prepare(self, sql: str) -> _SqlitePrepared:
92
+ return _SqlitePrepared(self.conn, sql)
93
+
94
+ def close(self) -> None:
95
+ self.conn.close()
96
+
97
+
98
+ # ── Live PostgreSQL / MySQL drivers (WS7g #36; async/pooled #40) ────────────────
99
+ #
100
+ # The SAME synchronous `Driver` seam, now backed by a CONNECTION POOL over REAL psycopg (Postgres)
101
+ # / PyMySQL (MySQL) connections — proving the deferred live-DB execution axis (spec §10) AND turning
102
+ # the read plan's `concurrency` into REAL parallel DB I/O (#40). The Python bc `run_plan` dispatches
103
+ # the INDEPENDENT sibling relations of a plan stage on a `ThreadPoolExecutor` when
104
+ # `concurrency > 1` (bc#23); a single DB-API connection is NOT safe for concurrent use, so each
105
+ # `prepare().all()` CHECKS OUT ITS OWN pooled connection — distinct threads run on distinct
106
+ # connections in parallel. The runtime is UNCHANGED: it renders the dialect-tagged bundle
107
+ # (Postgres → `$N`, MySQL → `?`), binds params positionally, and calls `prepare(sql).all(...)` /
108
+ # `.run(...)`. Each live driver adapts the rendered placeholder text to its DB's native paramstyle
109
+ # (both DB-API drivers here use `%s`), and MySQL emulates the missing `RETURNING` at this seam
110
+ # (strip → execute → re-select the inserted PK) — the WS6 TS ScpDialect behavior-by-convention.
111
+ #
112
+ # WRITE-TX STAYS SERIAL: the runtime issues `prepare("BEGIN"|"COMMIT"|"ROLLBACK").run([])`. On
113
+ # `BEGIN` the driver PINS one pooled connection into a single writer slot and routes every
114
+ # subsequent statement to it until `COMMIT`/`ROLLBACK` releases it — one connection, tx-DAG order,
115
+ # gate-first short-circuit. Reads (no active tx) each check out + return a pooled connection. The
116
+ # connections run with autocommit ON so the literal BEGIN…COMMIT bracket a REAL transaction.
117
+
118
+ # The read plan's default concurrency (spec) — the pool is sized to match so `concurrency` sibling
119
+ # relations can each hold a live connection without starving.
120
+ DEFAULT_POOL_SIZE = 16
121
+
122
+
123
+ class _ConnectionPool:
124
+ """A minimal thread-safe, bounded pool of DB-API connections (dependency-free).
125
+
126
+ A bounded ``queue`` of live connections created lazily up to ``max_size``. ``acquire`` blocks
127
+ for a free connection (or opens a new one below the ceiling); ``release`` returns it. This keeps
128
+ the parallel-read seam dependency-free (no psycopg_pool / DBUtils needed) while giving each
129
+ concurrent sibling its own connection.
130
+ """
131
+
132
+ __slots__ = ("_factory", "_max", "_free", "_opened", "_lock")
133
+
134
+ def __init__(self, factory, max_size: int) -> None:
135
+ import queue as _queue
136
+ import threading as _threading
137
+
138
+ self._factory = factory
139
+ self._max = max_size
140
+ self._free: "Any" = _queue.LifoQueue()
141
+ self._opened = 0
142
+ self._lock = _threading.Lock()
143
+
144
+ def acquire(self) -> Any:
145
+ import queue as _queue
146
+
147
+ # Fast path: reuse a free connection.
148
+ try:
149
+ return self._free.get_nowait()
150
+ except _queue.Empty:
151
+ pass
152
+ # Open a new one if below the ceiling; else wait for a release.
153
+ with self._lock:
154
+ if self._opened < self._max:
155
+ self._opened += 1
156
+ return self._factory()
157
+ return self._free.get() # block until a connection is released
158
+
159
+ def release(self, conn: Any) -> None:
160
+ self._free.put(conn)
161
+
162
+ def close(self) -> None:
163
+ import queue as _queue
164
+
165
+ while True:
166
+ try:
167
+ conn = self._free.get_nowait()
168
+ except _queue.Empty:
169
+ break
170
+ try:
171
+ conn.close()
172
+ except Exception:
173
+ pass
174
+
175
+ # `$1`, `$2`, … (Postgres render output).
176
+ _DOLLAR_RE = re.compile(r"\$\d+")
177
+ # `INSERT INTO <table> (...) ... RETURNING <cols>` — MySQL RETURNING emulation parse.
178
+ _RETURNING_RE = re.compile(r"\s+RETURNING\s+(.+?)\s*$", re.IGNORECASE | re.DOTALL)
179
+ _INSERT_TABLE_RE = re.compile(r"^\s*INSERT\s+(?:IGNORE\s+)?INTO\s+([A-Za-z_][A-Za-z0-9_]*)", re.IGNORECASE)
180
+ # The INSERT column list `INSERT [IGNORE] INTO <t> (c1, c2, …)` — for extracting client-PK values.
181
+ _INSERT_COLS_RE = re.compile(r"^\s*INSERT\s+(?:IGNORE\s+)?INTO\s+[A-Za-z_][A-Za-z0-9_]*\s*\(([^)]*)\)", re.IGNORECASE)
182
+ # The strip-before-execute PK hint the mysql bundle appends to an INSERT…RETURNING (tx.ts mysqlPkHint):
183
+ # ` /*scp:pk=col1,col2;ai=<autoIncCol|>*/`
184
+ _PK_HINT_RE = re.compile(r"\s*/\*scp:pk=([^;*]*);ai=([^*]*)\*/", re.IGNORECASE)
185
+
186
+
187
+ def _dollar_to_pyformat(sql: str) -> str:
188
+ """Postgres `$N` → DB-API `%s` (positional). Render already numbers left-to-right 1..N, so a
189
+ plain replace preserves order. Literal `%` is doubled so psycopg/pymysql don't treat it as a
190
+ format directive (the rendered SQL never contains a literal `%`, but this keeps the seam safe).
191
+ """
192
+ return _DOLLAR_RE.sub("%s", sql.replace("%", "%%"))
193
+
194
+
195
+ def _qmark_to_pyformat(sql: str) -> str:
196
+ """MySQL render keeps `?`; PyMySQL binds `%s`. Replace each `?` with `%s` (literal `%` doubled)."""
197
+ return sql.replace("%", "%%").replace("?", "%s")
198
+
199
+
200
+ _TXN_CONTROL = frozenset({"BEGIN", "COMMIT", "ROLLBACK", "START TRANSACTION"})
201
+
202
+
203
+ def _is_txn_control(sql: str) -> bool:
204
+ return sql.strip().upper() in _TXN_CONTROL
205
+
206
+
207
+ def _parse_pk_hint(returning_cols: str):
208
+ """Parse the ` /*scp:pk=col1,col2;ai=<col|>*/` PK hint out of the RETURNING-cols text.
209
+
210
+ Returns ``(pk_columns, auto_inc_or_None)``. Absent hint → ``([], None)`` (legacy path).
211
+ """
212
+ hm = _PK_HINT_RE.search(returning_cols)
213
+ if hm is None:
214
+ return [], None
215
+ cols = [c.strip() for c in hm.group(1).split(",") if c.strip()]
216
+ ai = hm.group(2).strip()
217
+ return cols, (ai or None)
218
+
219
+
220
+ def _returning_reselect_where(insert_sql, pk_cols, auto_inc, params, last_id, affected):
221
+ """Build the MySQL RETURNING re-select WHERE (SQL body with `?` + its params).
222
+
223
+ - AUTO_INCREMENT single-column PK: a range on the identity column covering the ``affected`` rows
224
+ just inserted (v1 `WHERE id >= ? AND id < ?` semantics, generalized to the real column name).
225
+ - Client-supplied PK (UUID / composite, ``auto_inc`` is None): the PK value(s) are among the
226
+ bound INSERT params — extract them by matching each PK column to its position in the INSERT
227
+ column list, and key the re-select `WHERE pk1 = ? AND …` on those inserted values.
228
+ - No hint (legacy): fall back to `id = ?` bound to LAST_INSERT_ID (the pre-fix auto-`id` path).
229
+ """
230
+ if not pk_cols:
231
+ return "id = ?", [last_id]
232
+ if auto_inc is not None and pk_cols == [auto_inc]:
233
+ return f"{auto_inc} >= ? AND {auto_inc} < ?", [last_id, last_id + affected]
234
+ # Client-supplied PK: pull each PK column's inserted value from the bound INSERT params by its
235
+ # column position (single-row client-PK insert; the corpus UUID / composite cases are single-row).
236
+ cm = _INSERT_COLS_RE.match(insert_sql)
237
+ if cm is None:
238
+ raise ValueError(f"scp mysql driver: cannot locate INSERT column list for PK re-select: {insert_sql!r}")
239
+ insert_cols = [c.strip() for c in cm.group(1).split(",")]
240
+ conds = []
241
+ vals = []
242
+ for pk in pk_cols:
243
+ try:
244
+ idx = insert_cols.index(pk)
245
+ except ValueError:
246
+ raise ValueError(f"scp mysql driver: PK column '{pk}' not in INSERT columns {insert_cols}")
247
+ conds.append(f"{pk} = ?")
248
+ vals.append(params[idx])
249
+ return " AND ".join(conds), vals
250
+
251
+
252
+ class _PooledPrepared:
253
+ """A prepared statement over a POOLED live DB-API driver (psycopg / PyMySQL).
254
+
255
+ For a read (no active tx) it checks out a connection from the pool, runs the statement, and
256
+ returns the connection — so concurrent siblings run on DISTINCT connections. Inside a write-tx
257
+ it runs on the driver's PINNED writer connection (set on BEGIN, released on COMMIT/ROLLBACK).
258
+ ``paramstyle_xform`` adapts the rendered placeholder text; ``emulate_returning`` toggles the
259
+ MySQL RETURNING emulation.
260
+ """
261
+
262
+ __slots__ = ("_driver", "_sql", "_params")
263
+
264
+ def __init__(self, driver: "_PooledDriver", sql: str) -> None:
265
+ self._driver = driver
266
+ self._sql = sql
267
+ self._params: Sequence[Any] = ()
268
+
269
+ @staticmethod
270
+ def _scalar(v: Any) -> Any:
271
+ """Coerce a driver cell to a canonical bc scalar (int/float/bool/str/None).
272
+
273
+ psycopg maps a PG ``uuid`` column to a Python ``uuid.UUID`` and other rich types
274
+ (Decimal, date/datetime) to their own classes. The conformance row encoding — and the
275
+ cross-language reference — are JSON scalars, so a non-native cell is stringified to its
276
+ canonical text form, exactly as SQLite/MySQL return a uuid-as-text or the Rust PG driver
277
+ falls back to ``String``. Native scalars pass through unchanged (bool before int, since
278
+ ``bool`` is an ``int`` subclass).
279
+ """
280
+ if v is None or isinstance(v, (bool, int, float, str)):
281
+ return v
282
+ from decimal import Decimal
283
+
284
+ if isinstance(v, Decimal):
285
+ f = float(v)
286
+ return int(f) if f.is_integer() else f
287
+ if isinstance(v, (bytes, bytearray)):
288
+ return bytes(v).decode("utf-8", "replace")
289
+ return str(v)
290
+
291
+ @classmethod
292
+ def _fetch_all(cls, cur) -> List[Dict[str, Any]]:
293
+ cols = [d[0] for d in cur.description] if cur.description is not None else []
294
+ return [{c: cls._scalar(x) for c, x in zip(cols, r)} for r in cur.fetchall()]
295
+
296
+ def _run_all(self, conn: Any) -> List[Dict[str, Any]]:
297
+ xform = self._driver._xform
298
+ # MySQL has no RETURNING: strip it, run the INSERT, re-select the inserted rows by the REAL
299
+ # primary key. The strip-before-execute PK hint (tx.ts mysqlPkHint) carries the PK columns +
300
+ # the AUTO_INCREMENT column so the re-select keys off the actual PK — an AUTO_INCREMENT range
301
+ # for an int identity, or the client-supplied PK values (UUID / composite) pulled from the
302
+ # bound INSERT params — NOT a hardcoded `WHERE id = ?` (which breaks for UUID / composite PKs).
303
+ if self._driver._emulate_returning:
304
+ m = _RETURNING_RE.search(self._sql)
305
+ if m is not None:
306
+ returning_cols = _PK_HINT_RE.sub("", m.group(1)).strip()
307
+ pk_cols, auto_inc = _parse_pk_hint(m.group(1))
308
+ write_sql = _PK_HINT_RE.sub("", self._sql[: m.start()])
309
+ table_m = _INSERT_TABLE_RE.match(write_sql)
310
+ if table_m is None:
311
+ # A non-INSERT RETURNING (UPDATE/DELETE … RETURNING): MySQL has no native
312
+ # RETURNING and the pre-image is gone, so v1 (`mysql.ts`) strips RETURNING, runs
313
+ # the write, and returns NO rows. Byte-faithful: execute the stripped write, [].
314
+ cur = conn.cursor()
315
+ cur.execute(xform(write_sql), tuple(self._params))
316
+ cur.close()
317
+ return []
318
+ # INSERT … RETURNING: run the INSERT, re-select the inserted rows by the REAL PK.
319
+ table = table_m.group(1)
320
+ cur = conn.cursor()
321
+ cur.execute(xform(write_sql), tuple(self._params))
322
+ last_id = cur.lastrowid
323
+ affected = cur.rowcount if cur.rowcount is not None and cur.rowcount >= 0 else 1
324
+ cur.close()
325
+ where_sql, where_params = _returning_reselect_where(
326
+ write_sql, pk_cols, auto_inc, list(self._params), last_id, affected
327
+ )
328
+ sel = conn.cursor()
329
+ sel.execute(xform(f"SELECT {returning_cols} FROM {table} WHERE {where_sql}"), tuple(where_params))
330
+ rows = self._fetch_all(sel)
331
+ sel.close()
332
+ return rows
333
+ cur = conn.cursor()
334
+ cur.execute(xform(self._sql), tuple(self._params))
335
+ rows = self._fetch_all(cur)
336
+ cur.close()
337
+ return rows
338
+
339
+ def all(self, params: Sequence[Any]) -> List[Dict[str, Any]]:
340
+ self._params = params
341
+ return self._driver._with_conn(self._run_all)
342
+
343
+ def run(self, params: Sequence[Any]) -> RunInfo:
344
+ # Transaction-control literals pin / release the single writer connection.
345
+ if _is_txn_control(self._sql):
346
+ self._driver._handle_txn_control(self._sql)
347
+ return RunInfo(0, 0)
348
+
349
+ def op(conn: Any) -> RunInfo:
350
+ cur = conn.cursor()
351
+ cur.execute(self._driver._xform(self._sql), tuple(params))
352
+ changes = cur.rowcount if cur.rowcount is not None and cur.rowcount >= 0 else 0
353
+ last = cur.lastrowid if getattr(cur, "lastrowid", None) is not None else 0
354
+ cur.close()
355
+ return RunInfo(changes, last)
356
+
357
+ return self._driver._with_conn(op)
358
+
359
+
360
+ class _PooledDriver:
361
+ """Shared pooled live-driver base (Postgres / MySQL) — the parallel-read + serial-write seam."""
362
+
363
+ __slots__ = ("_pool", "_xform", "_emulate_returning", "_writer")
364
+
365
+ def __init__(self, pool: _ConnectionPool, xform, emulate_returning: bool) -> None:
366
+ self._pool = pool
367
+ self._xform = xform
368
+ self._emulate_returning = emulate_returning
369
+ self._writer: Any = None # pinned connection for the active write-tx (single-threaded)
370
+
371
+ def _with_conn(self, op):
372
+ """Run ``op(conn)`` on the pinned writer (in a tx) or a freshly checked-out pooled conn."""
373
+ if self._writer is not None:
374
+ return op(self._writer)
375
+ conn = self._pool.acquire()
376
+ try:
377
+ return op(conn)
378
+ finally:
379
+ self._pool.release(conn)
380
+
381
+ def _handle_txn_control(self, sql: str) -> None:
382
+ upper = sql.strip().upper()
383
+ if upper in ("BEGIN", "START TRANSACTION"):
384
+ conn = self._pool.acquire()
385
+ cur = conn.cursor()
386
+ cur.execute("BEGIN")
387
+ cur.close()
388
+ self._writer = conn
389
+ else: # COMMIT / ROLLBACK: run on the pinned writer, then return it to the pool.
390
+ conn = self._writer
391
+ self._writer = None
392
+ if conn is not None:
393
+ cur = conn.cursor()
394
+ cur.execute(upper)
395
+ cur.close()
396
+ self._pool.release(conn)
397
+
398
+ def exec_ddl(self, statements: Sequence[str]) -> None:
399
+ conn = self._pool.acquire()
400
+ try:
401
+ cur = conn.cursor()
402
+ for stmt in statements:
403
+ cur.execute(stmt)
404
+ cur.close()
405
+ finally:
406
+ self._pool.release(conn)
407
+
408
+ def prepare(self, sql: str) -> _PooledPrepared:
409
+ return _PooledPrepared(self, sql)
410
+
411
+ def close(self) -> None:
412
+ if self._writer is not None:
413
+ try:
414
+ self._writer.close()
415
+ except Exception:
416
+ pass
417
+ self._writer = None
418
+ self._pool.close()
419
+
420
+
421
+ class PostgresDriver(_PooledDriver):
422
+ """A live Postgres driver (psycopg 3, POOLED) implementing the :class:`Driver` seam.
423
+
424
+ Renders a `postgres`-tagged bundle → `$N`; rewrites `$N`→`%s` for psycopg. A bounded pool of
425
+ autocommit connections lets independent sibling relations run concurrently on distinct
426
+ connections; the write-tx pins one connection for its BEGIN…COMMIT span.
427
+ """
428
+
429
+ @classmethod
430
+ def connect(
431
+ cls,
432
+ *,
433
+ host: str,
434
+ port: int,
435
+ user: str,
436
+ password: str,
437
+ dbname: str,
438
+ pool_size: int = DEFAULT_POOL_SIZE,
439
+ ) -> "PostgresDriver":
440
+ import psycopg # imported lazily so the SQLite conformance never needs the driver installed
441
+
442
+ def factory():
443
+ return psycopg.connect(
444
+ host=host, port=port, user=user, password=password, dbname=dbname, autocommit=True
445
+ )
446
+
447
+ pool = _ConnectionPool(factory, pool_size)
448
+ return cls(pool, _dollar_to_pyformat, emulate_returning=False)
449
+
450
+
451
+ class MysqlDriver(_PooledDriver):
452
+ """A live MySQL driver (PyMySQL, POOLED) implementing the :class:`Driver` seam.
453
+
454
+ Renders a `mysql`-tagged bundle → `?`; rewrites `?`→`%s` for PyMySQL. MySQL 8.0 has NO
455
+ `RETURNING`, so an INSERT…RETURNING is emulated at this seam (strip → INSERT → re-select the
456
+ AUTO_INCREMENT PK's columns) — the WS6 TS ScpDialect behavior-by-convention. A bounded pool of
457
+ autocommit connections gives concurrent siblings distinct connections; the write-tx pins one.
458
+ """
459
+
460
+ @classmethod
461
+ def connect(
462
+ cls,
463
+ *,
464
+ host: str,
465
+ port: int,
466
+ user: str,
467
+ password: str,
468
+ dbname: str,
469
+ pool_size: int = DEFAULT_POOL_SIZE,
470
+ ) -> "MysqlDriver":
471
+ import pymysql # lazy import (conformance bar never needs it)
472
+
473
+ def factory():
474
+ return pymysql.connect(
475
+ host=host, port=port, user=user, password=password, database=dbname, autocommit=True
476
+ )
477
+
478
+ pool = _ConnectionPool(factory, pool_size)
479
+ return cls(pool, _qmark_to_pyformat, emulate_returning=True)
@@ -39,9 +39,10 @@ from litedbmodel_runtime import ( # noqa: E402
39
39
  PostgresDriver,
40
40
  execute_bundle,
41
41
  execute_transaction_bundle,
42
+ read_bundle,
42
43
  )
43
44
 
44
- SUPPORTED_CORPUS_VERSION = 1
45
+ SUPPORTED_CORPUS_VERSION = 2
45
46
  # A distinct namespace per language so 4 languages share ONE docker stack without cross-talk.
46
47
  PG_SCHEMA = os.environ.get("LIVEDB_PG_SCHEMA", "scp_py")
47
48
  MYSQL_DB = os.environ.get("LIVEDB_MYSQL_DB", "scp_py")
@@ -111,7 +112,7 @@ def _encode(v: Any) -> Any:
111
112
  # ── per-dialect table lifecycle ────────────────────────────────────────────────
112
113
 
113
114
  # The tables the corpus touches (drop order respects FK dependents-first).
114
- _ALL_TABLES = ["post_tags", "posts", "tags", "users", "idem", "uniq", "outbox"]
115
+ _ALL_TABLES = ["post_tags", "order_lines", "comments", "posts", "tags", "docs", "docs2", "revs", "typed", "users", "users2", "idem", "uniq", "outbox"]
115
116
 
116
117
 
117
118
  def _reset_pg(driver: "PostgresDriver", schema: List[str]) -> None:
@@ -138,13 +139,45 @@ def _run_exec(driver, bundle, vector) -> Dict[str, Any]:
138
139
  return {"ok": ok, "detail": None if ok else f"result {json.dumps(result)} != {json.dumps(vector['expectedResult'])}"}
139
140
 
140
141
 
141
- def _run_tx(driver, bundle, vector) -> Dict[str, Any]:
142
+ def _run_read(driver, bundle, vector, expected_key) -> Dict[str, Any]:
143
+ """A read-RELATION EXECUTION vector: run the parent read + batch-load/hydrate ``with`` relations.
144
+
145
+ The hydrated shape is compared to the PER-DIALECT golden (``expected_key`` = ``expectedResultPg``
146
+ / ``expectedResultMysql``) — a limited hasMany's ``_rn`` window column is present on MySQL but
147
+ projected away by PG's LATERAL form (the ONE documented dialect divergence in the batch SQL).
148
+ """
149
+ expected = vector[expected_key]
150
+ result = _encode(read_bundle(bundle, dict(vector["input"]), driver, list(vector["with"])))
151
+ ok = _eq(result, expected)
152
+ return {"ok": ok, "detail": None if ok else f"result {json.dumps(result)} != {json.dumps(expected)}"}
153
+
154
+
155
+ def _run_crossdb(driver, secondary, secondary_reset, bundle, vector, expected_key, secondary_schema_key) -> Dict[str, Any]:
156
+ """A CROSS-DB read-RELATION vector (V0 R1): the parent runs on the PRIMARY driver and a TAGGED
157
+ relation on the SECONDARY driver (the target model's own DB). Seed the secondary DB with its own
158
+ schema (the parent DB has NO target table — a mis-route would fail loudly), then route the tagged
159
+ relation via the ``connections`` registry. A green hydrated result is unforgeable proof the tag
160
+ routed the batch to the secondary connection.
161
+ """
162
+ secondary_reset(secondary, list(vector[secondary_schema_key]))
163
+ expected = vector[expected_key]
164
+ result = _encode(
165
+ read_bundle(bundle, dict(vector["input"]), driver, list(vector["with"]), {vector["connectionTag"]: secondary})
166
+ )
167
+ ok = _eq(result, expected)
168
+ return {"ok": ok, "detail": None if ok else f"result {json.dumps(result)} != {json.dumps(expected)}"}
169
+
170
+
171
+ def _run_tx(driver, bundle, vector, tx_expected_key) -> Dict[str, Any]:
172
+ # A write may GENUINELY diverge by dialect (a DELETE…RETURNING returns the deleted row on PG/
173
+ # SQLite but MySQL has no native RETURNING → []); the mysql leg then carries `expectedResultMysql`.
174
+ expected = vector.get(tx_expected_key) if tx_expected_key in vector else vector["expectedResult"]
142
175
  result = _encode(execute_transaction_bundle(bundle, dict(vector["input"]), driver))
143
- result_ok = _eq(result, vector["expectedResult"])
176
+ result_ok = _eq(result, expected)
144
177
  state_ok = True
145
178
  detail: List[str] = []
146
179
  if not result_ok:
147
- detail.append(f"result {json.dumps(result)} != {json.dumps(vector['expectedResult'])}")
180
+ detail.append(f"result {json.dumps(result)} != {json.dumps(expected)}")
148
181
  for s in vector.get("expectedDbState", []) or []:
149
182
  got = _encode(driver.prepare(s["query"]).all([]))
150
183
  if not _eq(got, s["rows"]):
@@ -154,17 +187,23 @@ def _run_tx(driver, bundle, vector) -> Dict[str, Any]:
154
187
  return {"ok": ok, "detail": None if ok else "; ".join(detail)}
155
188
 
156
189
 
157
- def _run_dialect_leg(dialect: str, driver, reset_fn, corpus, bundle_key, schema_key) -> Dict[str, int]:
190
+ def _run_dialect_leg(dialect: str, driver, reset_fn, corpus, bundle_key, schema_key, read_expected_key, tx_expected_key, secondary, secondary_reset, secondary_schema_key) -> Dict[str, int]:
158
191
  t = {"pass": 0, "fail": 0}
159
192
  sys.stderr.write(f"\nlivedb-{dialect} — {len(corpus['vectors'])} vectors (real {dialect})\n")
160
193
  for v in corpus["vectors"]:
161
- reset_fn(driver, list(v[schema_key]))
194
+ # CROSS-DB vectors carry their OWN primary schema key (the parent DB — NO target table).
195
+ primary_schema_key = ("primarySchemaPg" if dialect == "pg" else "primarySchemaMysql") if v["kind"] == "crossdb" else schema_key
196
+ reset_fn(driver, list(v[primary_schema_key]))
162
197
  bundle = v[bundle_key]
163
198
  try:
164
199
  if v["kind"] == "exec":
165
200
  r = _run_exec(driver, bundle, v)
201
+ elif v["kind"] == "read":
202
+ r = _run_read(driver, bundle, v, read_expected_key)
203
+ elif v["kind"] == "crossdb":
204
+ r = _run_crossdb(driver, secondary, secondary_reset, bundle, v, read_expected_key, secondary_schema_key)
166
205
  elif v["kind"] == "tx":
167
- r = _run_tx(driver, bundle, v)
206
+ r = _run_tx(driver, bundle, v, tx_expected_key)
168
207
  else:
169
208
  r = {"ok": False, "detail": f"unknown kind {v['kind']}"}
170
209
  except Exception as e: # a live-DB failure is a vector FAILURE, never a fake pass
@@ -203,8 +242,10 @@ def main() -> int:
203
242
  return 3
204
243
 
205
244
  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")
245
+ # CROSS-DB (V0 R1): each leg's SECONDARY connection is the OTHER live DB (pg leg → my; mysql
246
+ # leg → pg), reset with the OTHER dialect's reset fn + the vector's per-leg secondary schema.
247
+ pg_t = _run_dialect_leg("pg", pg, _reset_pg, corpus, "bundlePg", "schemaPg", "expectedResultPg", "expectedResultPg", my, _reset_mysql, "secondarySchemaPg")
248
+ my_t = _run_dialect_leg("mysql", my, _reset_mysql, corpus, "bundleMysql", "schemaMysql", "expectedResultMysql", "expectedResultMysql", pg, _reset_pg, "secondarySchemaMysql")
208
249
  finally:
209
250
  pg.close()
210
251
  my.close()