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.
- {litedbmodel_runtime-2.0.0 → litedbmodel_runtime-2.0.2}/PKG-INFO +3 -3
- {litedbmodel_runtime-2.0.0 → litedbmodel_runtime-2.0.2}/litedbmodel_runtime/__init__.py +31 -10
- litedbmodel_runtime-2.0.2/litedbmodel_runtime/driver.py +479 -0
- {litedbmodel_runtime-2.0.0 → litedbmodel_runtime-2.0.2}/litedbmodel_runtime/livedb_runner.py +51 -10
- litedbmodel_runtime-2.0.2/litedbmodel_runtime/relation.py +189 -0
- litedbmodel_runtime-2.0.2/litedbmodel_runtime/runtime.py +218 -0
- litedbmodel_runtime-2.0.2/litedbmodel_runtime/static_bundle.py +325 -0
- {litedbmodel_runtime-2.0.0 → litedbmodel_runtime-2.0.2}/litedbmodel_runtime/vectors_runner.py +18 -7
- {litedbmodel_runtime-2.0.0 → litedbmodel_runtime-2.0.2}/litedbmodel_runtime.egg-info/PKG-INFO +3 -3
- {litedbmodel_runtime-2.0.0 → litedbmodel_runtime-2.0.2}/litedbmodel_runtime.egg-info/SOURCES.txt +3 -1
- {litedbmodel_runtime-2.0.0 → litedbmodel_runtime-2.0.2}/litedbmodel_runtime.egg-info/requires.txt +1 -1
- {litedbmodel_runtime-2.0.0 → litedbmodel_runtime-2.0.2}/pyproject.toml +3 -3
- {litedbmodel_runtime-2.0.0 → litedbmodel_runtime-2.0.2}/tests/test_conformance_corpus.py +4 -3
- {litedbmodel_runtime-2.0.0 → litedbmodel_runtime-2.0.2}/tests/test_consumes_bc_core.py +13 -8
- litedbmodel_runtime-2.0.2/tests/test_parallel_read.py +124 -0
- litedbmodel_runtime-2.0.2/tests/test_render.py +104 -0
- {litedbmodel_runtime-2.0.0 → litedbmodel_runtime-2.0.2}/tests/test_runtime.py +95 -57
- litedbmodel_runtime-2.0.0/litedbmodel_runtime/driver.py +0 -252
- litedbmodel_runtime-2.0.0/litedbmodel_runtime/render.py +0 -160
- litedbmodel_runtime-2.0.0/litedbmodel_runtime/runtime.py +0 -282
- litedbmodel_runtime-2.0.0/tests/test_render.py +0 -142
- {litedbmodel_runtime-2.0.0 → litedbmodel_runtime-2.0.2}/README.md +0 -0
- {litedbmodel_runtime-2.0.0 → litedbmodel_runtime-2.0.2}/litedbmodel_runtime/dialect.py +0 -0
- {litedbmodel_runtime-2.0.0 → litedbmodel_runtime-2.0.2}/litedbmodel_runtime/errors.py +0 -0
- {litedbmodel_runtime-2.0.0 → litedbmodel_runtime-2.0.2}/litedbmodel_runtime.egg-info/dependency_links.txt +0 -0
- {litedbmodel_runtime-2.0.0 → litedbmodel_runtime-2.0.2}/litedbmodel_runtime.egg-info/top_level.txt +0 -0
- {litedbmodel_runtime-2.0.0 → litedbmodel_runtime-2.0.2}/setup.cfg +0 -0
- {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.
|
|
4
|
-
Summary: Thin multi-language runtime for litedbmodel v2 SCP
|
|
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.
|
|
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 .
|
|
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
|
-
|
|
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.
|
|
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
|
-
"
|
|
51
|
-
"
|
|
52
|
-
"
|
|
53
|
-
"
|
|
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)
|
{litedbmodel_runtime-2.0.0 → litedbmodel_runtime-2.0.2}/litedbmodel_runtime/livedb_runner.py
RENAMED
|
@@ -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 =
|
|
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
|
|
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,
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
207
|
-
|
|
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()
|