litedbmodel-runtime 2.0.0__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/PKG-INFO +43 -0
- litedbmodel_runtime-2.0.0/README.md +27 -0
- litedbmodel_runtime-2.0.0/litedbmodel_runtime/__init__.py +60 -0
- litedbmodel_runtime-2.0.0/litedbmodel_runtime/dialect.py +99 -0
- litedbmodel_runtime-2.0.0/litedbmodel_runtime/driver.py +252 -0
- litedbmodel_runtime-2.0.0/litedbmodel_runtime/errors.py +83 -0
- litedbmodel_runtime-2.0.0/litedbmodel_runtime/livedb_runner.py +221 -0
- litedbmodel_runtime-2.0.0/litedbmodel_runtime/render.py +160 -0
- litedbmodel_runtime-2.0.0/litedbmodel_runtime/runtime.py +282 -0
- litedbmodel_runtime-2.0.0/litedbmodel_runtime/vectors_runner.py +246 -0
- litedbmodel_runtime-2.0.0/litedbmodel_runtime.egg-info/PKG-INFO +43 -0
- litedbmodel_runtime-2.0.0/litedbmodel_runtime.egg-info/SOURCES.txt +20 -0
- litedbmodel_runtime-2.0.0/litedbmodel_runtime.egg-info/dependency_links.txt +1 -0
- litedbmodel_runtime-2.0.0/litedbmodel_runtime.egg-info/requires.txt +8 -0
- litedbmodel_runtime-2.0.0/litedbmodel_runtime.egg-info/top_level.txt +1 -0
- litedbmodel_runtime-2.0.0/pyproject.toml +33 -0
- litedbmodel_runtime-2.0.0/setup.cfg +4 -0
- litedbmodel_runtime-2.0.0/tests/test_conformance_corpus.py +43 -0
- litedbmodel_runtime-2.0.0/tests/test_consumes_bc_core.py +55 -0
- litedbmodel_runtime-2.0.0/tests/test_dialect.py +31 -0
- litedbmodel_runtime-2.0.0/tests/test_render.py +142 -0
- litedbmodel_runtime-2.0.0/tests/test_runtime.py +178 -0
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
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.
|
|
5
|
+
Author: foo-ogawa
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: orm,sql,ir,runtime,conformance,litedbmodel
|
|
8
|
+
Requires-Python: >=3.9
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
Requires-Dist: behavior-contracts==0.2.0
|
|
11
|
+
Provides-Extra: test
|
|
12
|
+
Requires-Dist: pytest>=7.0; extra == "test"
|
|
13
|
+
Provides-Extra: livedb
|
|
14
|
+
Requires-Dist: psycopg[binary]>=3.1; extra == "livedb"
|
|
15
|
+
Requires-Dist: pymysql>=1.1; extra == "livedb"
|
|
16
|
+
|
|
17
|
+
# litedbmodel-runtime (Python)
|
|
18
|
+
|
|
19
|
+
The Python leg of the litedbmodel v2 SCP multi-language runtime. Interprets the language-neutral
|
|
20
|
+
§8 published bundle (`SqlBundle`) and executes it against a DB-API SQL driver,
|
|
21
|
+
semantics-identical to the TS reference (`src/scp`).
|
|
22
|
+
|
|
23
|
+
**Status: WS7a scaffold.** The buildable package skeleton + the conformance runner entry point are
|
|
24
|
+
here; the runtime body (render / execute / transaction) is **WS7b**.
|
|
25
|
+
|
|
26
|
+
## behavior-contracts dependency
|
|
27
|
+
|
|
28
|
+
The runtime delegates the CLOSED Expression-IR evaluation to the shared common core
|
|
29
|
+
[`behavior-contracts`](https://pypi.org/project/behavior-contracts/) — **consumed from PyPI**
|
|
30
|
+
(`behavior-contracts==0.2.0`), exactly as the TS reference imports it from npm. No local path
|
|
31
|
+
dependency (the `check-no-local-deps` gate forbids `../`-escaping deps).
|
|
32
|
+
|
|
33
|
+
## Layout
|
|
34
|
+
|
|
35
|
+
```
|
|
36
|
+
python/
|
|
37
|
+
pyproject.toml # PyPI package (litedbmodel-runtime), version-synced from package.json
|
|
38
|
+
litedbmodel_runtime/
|
|
39
|
+
__init__.py
|
|
40
|
+
runtime.py # WS7b: the §8 bundle interpreter surface
|
|
41
|
+
vectors_runner.py # conformance runner entry (WS7b body)
|
|
42
|
+
tests/ # WS7b runtime tests
|
|
43
|
+
```
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# litedbmodel-runtime (Python)
|
|
2
|
+
|
|
3
|
+
The Python leg of the litedbmodel v2 SCP multi-language runtime. Interprets the language-neutral
|
|
4
|
+
§8 published bundle (`SqlBundle`) and executes it against a DB-API SQL driver,
|
|
5
|
+
semantics-identical to the TS reference (`src/scp`).
|
|
6
|
+
|
|
7
|
+
**Status: WS7a scaffold.** The buildable package skeleton + the conformance runner entry point are
|
|
8
|
+
here; the runtime body (render / execute / transaction) is **WS7b**.
|
|
9
|
+
|
|
10
|
+
## behavior-contracts dependency
|
|
11
|
+
|
|
12
|
+
The runtime delegates the CLOSED Expression-IR evaluation to the shared common core
|
|
13
|
+
[`behavior-contracts`](https://pypi.org/project/behavior-contracts/) — **consumed from PyPI**
|
|
14
|
+
(`behavior-contracts==0.2.0`), exactly as the TS reference imports it from npm. No local path
|
|
15
|
+
dependency (the `check-no-local-deps` gate forbids `../`-escaping deps).
|
|
16
|
+
|
|
17
|
+
## Layout
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
python/
|
|
21
|
+
pyproject.toml # PyPI package (litedbmodel-runtime), version-synced from package.json
|
|
22
|
+
litedbmodel_runtime/
|
|
23
|
+
__init__.py
|
|
24
|
+
runtime.py # WS7b: the §8 bundle interpreter surface
|
|
25
|
+
vectors_runner.py # conformance runner entry (WS7b body)
|
|
26
|
+
tests/ # WS7b runtime tests
|
|
27
|
+
```
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""litedbmodel v2 SCP — Python runtime (WS7b, #31).
|
|
2
|
+
|
|
3
|
+
The Python leg of the multi-language SCP runtime. It interprets the language-neutral §8 published
|
|
4
|
+
bundle (``SqlBundle``: sql text + fragment tree + closed-set Expression-IR param slots +
|
|
5
|
+
transaction plan, dialect-tagged) and executes it against a SQL driver, semantics-identical to
|
|
6
|
+
the TS reference (``src/scp``). The generic Expression-IR evaluation (SKIP guards, param slots)
|
|
7
|
+
and the plan/map/wire/output orchestration are delegated to the shared common core
|
|
8
|
+
``behavior-contracts`` (PyPI) — this package re-implements NO generic evaluator, only the
|
|
9
|
+
SQL-backend concerns (render → bind → execute → assembly + gate-first transaction), exactly like
|
|
10
|
+
the TS runtime.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from .dialect import SQLITE, POSTGRES, MYSQL, Dialect, dialect_for, to_dollar_placeholders
|
|
16
|
+
from .driver import Driver, MysqlDriver, PostgresDriver, PreparedStatement, RunInfo, SqliteDriver
|
|
17
|
+
from .errors import SqlFailure, map_sqlite_error
|
|
18
|
+
from .render import RenderedSql, WHERE_SLOT, render_operation
|
|
19
|
+
from .runtime import (
|
|
20
|
+
ENTITY_ROOT,
|
|
21
|
+
SCOPE_PORT,
|
|
22
|
+
execute_bundle,
|
|
23
|
+
execute_transaction_bundle,
|
|
24
|
+
order_by_nulls,
|
|
25
|
+
render_operation_bundle,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
__version__ = "2.0.0"
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"__version__",
|
|
32
|
+
# dialect
|
|
33
|
+
"SQLITE",
|
|
34
|
+
"POSTGRES",
|
|
35
|
+
"MYSQL",
|
|
36
|
+
"Dialect",
|
|
37
|
+
"dialect_for",
|
|
38
|
+
"to_dollar_placeholders",
|
|
39
|
+
# driver seam
|
|
40
|
+
"Driver",
|
|
41
|
+
"PreparedStatement",
|
|
42
|
+
"RunInfo",
|
|
43
|
+
"SqliteDriver",
|
|
44
|
+
"PostgresDriver",
|
|
45
|
+
"MysqlDriver",
|
|
46
|
+
# errors
|
|
47
|
+
"SqlFailure",
|
|
48
|
+
"map_sqlite_error",
|
|
49
|
+
# render
|
|
50
|
+
"RenderedSql",
|
|
51
|
+
"WHERE_SLOT",
|
|
52
|
+
"render_operation",
|
|
53
|
+
"render_operation_bundle",
|
|
54
|
+
# runtime
|
|
55
|
+
"ENTITY_ROOT",
|
|
56
|
+
"SCOPE_PORT",
|
|
57
|
+
"execute_bundle",
|
|
58
|
+
"execute_transaction_bundle",
|
|
59
|
+
"order_by_nulls",
|
|
60
|
+
]
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""litedbmodel v2 SCP — dialect strategy table (Python port of ``src/scp/dialect.ts``).
|
|
2
|
+
|
|
3
|
+
The SINGLE SOURCE OF TRUTH for every SQL-dialect difference the render pipeline needs, ported
|
|
4
|
+
byte-for-byte from the TS reference (spec §4/§5/§8/§10). The dialect axis is compiled ONCE
|
|
5
|
+
TS-side; the published bundle carries `?` placeholders and a `dialect` tag, and this module only
|
|
6
|
+
needs the render-time concerns a thin runtime touches:
|
|
7
|
+
|
|
8
|
+
- ``finalize_placeholders`` — the `?`→`$N` final one-pass (Postgres only; SQLite/MySQL identity).
|
|
9
|
+
- ``order_by_nulls`` — deterministic NULLS ordering (native for PG/SQLite, `IS NULL` emulation
|
|
10
|
+
for MySQL) — the WS6-flagged dialect primitive exercised by the conformance `dialect` suite.
|
|
11
|
+
|
|
12
|
+
The INSERT-conflict / guard-INSERT strategy methods are NOT needed by the runtime: those are a
|
|
13
|
+
compile-time concern (the published bundle's `operations[*].sql` already carries the fully
|
|
14
|
+
rendered conflict clause — e.g. `ON CONFLICT DO NOTHING`), so the runtime never re-derives them.
|
|
15
|
+
This mirrors the TS runtime, which likewise only calls `finalizePlaceholders` / `orderByNulls`.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from typing import Callable, Dict
|
|
21
|
+
|
|
22
|
+
# Known dialects (spec §4 breadth: PG/MySQL/SQLite).
|
|
23
|
+
DIALECT_NAMES = ("sqlite", "postgres", "mysql")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def to_dollar_placeholders(sql: str) -> str:
|
|
27
|
+
"""Replace each `?` with `$1, $2, …` left-to-right (Postgres §8 final one-pass).
|
|
28
|
+
|
|
29
|
+
Byte-identical to the TS `toDollarPlaceholders`: it runs ONCE over the fully-assembled,
|
|
30
|
+
param-flattened SQL text, so placeholder numbering is a plain running counter (the
|
|
31
|
+
number-reassignment problem cannot reappear). Every `?` on the compiled surface is a bound
|
|
32
|
+
param position — the render pipeline never emits a literal `?` inside a string literal.
|
|
33
|
+
"""
|
|
34
|
+
n = 0
|
|
35
|
+
out = []
|
|
36
|
+
for ch in sql:
|
|
37
|
+
if ch == "?":
|
|
38
|
+
n += 1
|
|
39
|
+
out.append(f"${n}")
|
|
40
|
+
else:
|
|
41
|
+
out.append(ch)
|
|
42
|
+
return "".join(out)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _identity(sql: str) -> str:
|
|
46
|
+
return sql
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _order_by_nulls_native(expr: str, direction: str, nulls: str) -> str:
|
|
50
|
+
# Postgres / SQLite (3.30+): native `NULLS FIRST/LAST`.
|
|
51
|
+
return f"{expr} {direction} NULLS {nulls}"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _order_by_nulls_mysql(expr: str, direction: str, nulls: str) -> str:
|
|
55
|
+
# MySQL has no NULLS FIRST/LAST — emulate with a leading `IS NULL` sort key.
|
|
56
|
+
# In MySQL NULL sorts LOWEST; `expr IS NULL` is 1 for null, 0 otherwise.
|
|
57
|
+
# NULLS FIRST: nulls must come first → order the IS-NULL flag DESC (1 before 0).
|
|
58
|
+
# NULLS LAST: nulls must come last → order the IS-NULL flag ASC (0 before 1).
|
|
59
|
+
flag_dir = "DESC" if nulls == "FIRST" else "ASC"
|
|
60
|
+
return f"{expr} IS NULL {flag_dir}, {expr} {direction}"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class Dialect:
|
|
64
|
+
"""A frozen dialect strategy: the render-time text producers a thin runtime consumes."""
|
|
65
|
+
|
|
66
|
+
__slots__ = ("name", "_finalize", "_order_by_nulls")
|
|
67
|
+
|
|
68
|
+
def __init__(
|
|
69
|
+
self,
|
|
70
|
+
name: str,
|
|
71
|
+
finalize: Callable[[str], str],
|
|
72
|
+
order_by_nulls: Callable[[str, str, str], str],
|
|
73
|
+
) -> None:
|
|
74
|
+
self.name = name
|
|
75
|
+
self._finalize = finalize
|
|
76
|
+
self._order_by_nulls = order_by_nulls
|
|
77
|
+
|
|
78
|
+
def finalize_placeholders(self, sql: str) -> str:
|
|
79
|
+
return self._finalize(sql)
|
|
80
|
+
|
|
81
|
+
def order_by_nulls(self, expr: str, direction: str, nulls: str) -> str:
|
|
82
|
+
return self._order_by_nulls(expr, direction, nulls)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
SQLITE = Dialect("sqlite", _identity, _order_by_nulls_native)
|
|
86
|
+
POSTGRES = Dialect("postgres", to_dollar_placeholders, _order_by_nulls_native)
|
|
87
|
+
MYSQL = Dialect("mysql", _identity, _order_by_nulls_mysql)
|
|
88
|
+
|
|
89
|
+
_DIALECTS: Dict[str, Dialect] = {"sqlite": SQLITE, "postgres": POSTGRES, "mysql": MYSQL}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def dialect_for(name: str) -> Dialect:
|
|
93
|
+
"""Resolve a dialect name to its strategy (fail-closed — no silent default)."""
|
|
94
|
+
d = _DIALECTS.get(name)
|
|
95
|
+
if d is None:
|
|
96
|
+
raise ValueError(
|
|
97
|
+
f"scp dialect: unknown dialect '{name}' (known: {', '.join(_DIALECTS)})"
|
|
98
|
+
)
|
|
99
|
+
return d
|
|
@@ -0,0 +1,252 @@
|
|
|
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) ────────────────────────────────
|
|
99
|
+
#
|
|
100
|
+
# The SAME synchronous `Driver` seam, now backed by REAL psycopg (Postgres) / PyMySQL (MySQL)
|
|
101
|
+
# connections — proving the deferred live-DB execution axis (spec §10 dialect axis). The runtime
|
|
102
|
+
# is UNCHANGED: it renders the dialect-tagged bundle (Postgres → `$N`, MySQL → `?`), binds the
|
|
103
|
+
# rendered params positionally, and calls `prepare(sql).all(...)` / `.run(...)`. Each live driver
|
|
104
|
+
# adapts the rendered placeholder text to its DB's native paramstyle (both DB-API drivers here use
|
|
105
|
+
# `%s`), and MySQL emulates the missing `RETURNING` at this seam (strip → execute → re-select the
|
|
106
|
+
# inserted PK) — the sanctioned dialect-behavior-by-convention (mirrors the WS6 TS ScpDialect).
|
|
107
|
+
#
|
|
108
|
+
# The transaction envelope: the runtime issues `prepare("BEGIN"|"COMMIT"|"ROLLBACK").run([])`.
|
|
109
|
+
# The live drivers run with autocommit ON so those literal statements control the transaction
|
|
110
|
+
# exactly like the SQLite seam's implicit-then-explicit tx — a real BEGIN…COMMIT on the live DB.
|
|
111
|
+
|
|
112
|
+
# `$1`, `$2`, … (Postgres render output).
|
|
113
|
+
_DOLLAR_RE = re.compile(r"\$\d+")
|
|
114
|
+
# `INSERT INTO <table> (...) ... RETURNING <cols>` — MySQL RETURNING emulation parse.
|
|
115
|
+
_RETURNING_RE = re.compile(r"\s+RETURNING\s+(.+?)\s*$", re.IGNORECASE | re.DOTALL)
|
|
116
|
+
_INSERT_TABLE_RE = re.compile(r"^\s*INSERT\s+(?:IGNORE\s+)?INTO\s+([A-Za-z_][A-Za-z0-9_]*)", re.IGNORECASE)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _dollar_to_pyformat(sql: str) -> str:
|
|
120
|
+
"""Postgres `$N` → DB-API `%s` (positional). Render already numbers left-to-right 1..N, so a
|
|
121
|
+
plain replace preserves order. Literal `%` is doubled so psycopg/pymysql don't treat it as a
|
|
122
|
+
format directive (the rendered SQL never contains a literal `%`, but this keeps the seam safe).
|
|
123
|
+
"""
|
|
124
|
+
return _DOLLAR_RE.sub("%s", sql.replace("%", "%%"))
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _qmark_to_pyformat(sql: str) -> str:
|
|
128
|
+
"""MySQL render keeps `?`; PyMySQL binds `%s`. Replace each `?` with `%s` (literal `%` doubled)."""
|
|
129
|
+
return sql.replace("%", "%%").replace("?", "%s")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class _LivePrepared:
|
|
133
|
+
"""A prepared statement over a live DB-API connection (psycopg / PyMySQL).
|
|
134
|
+
|
|
135
|
+
`paramstyle_xform` adapts the rendered placeholder text; `emulate_returning` toggles the MySQL
|
|
136
|
+
RETURNING emulation. Transaction-control literals (BEGIN/COMMIT/ROLLBACK) execute verbatim.
|
|
137
|
+
"""
|
|
138
|
+
|
|
139
|
+
__slots__ = ("_conn", "_sql", "_xform", "_emulate_returning")
|
|
140
|
+
|
|
141
|
+
def __init__(self, conn: Any, sql: str, xform, emulate_returning: bool) -> None:
|
|
142
|
+
self._conn = conn
|
|
143
|
+
self._sql = sql
|
|
144
|
+
self._xform = xform
|
|
145
|
+
self._emulate_returning = emulate_returning
|
|
146
|
+
|
|
147
|
+
def _fetch_all(self, cur) -> List[Dict[str, Any]]:
|
|
148
|
+
cols = [d[0] for d in cur.description] if cur.description is not None else []
|
|
149
|
+
return [dict(zip(cols, r)) for r in cur.fetchall()]
|
|
150
|
+
|
|
151
|
+
def all(self, params: Sequence[Any]) -> List[Dict[str, Any]]:
|
|
152
|
+
# MySQL has no RETURNING: strip it, run the INSERT, re-select the inserted PK's columns.
|
|
153
|
+
if self._emulate_returning:
|
|
154
|
+
m = _RETURNING_RE.search(self._sql)
|
|
155
|
+
if m is not None:
|
|
156
|
+
returning_cols = m.group(1)
|
|
157
|
+
insert_sql = self._sql[: m.start()]
|
|
158
|
+
table_m = _INSERT_TABLE_RE.match(insert_sql)
|
|
159
|
+
if table_m is None:
|
|
160
|
+
raise ValueError(
|
|
161
|
+
f"scp mysql driver: cannot emulate RETURNING for non-INSERT statement: {self._sql!r}"
|
|
162
|
+
)
|
|
163
|
+
table = table_m.group(1)
|
|
164
|
+
cur = self._conn.cursor()
|
|
165
|
+
cur.execute(self._xform(insert_sql), tuple(params))
|
|
166
|
+
last_id = cur.lastrowid
|
|
167
|
+
cur.close()
|
|
168
|
+
sel = self._conn.cursor()
|
|
169
|
+
sel.execute(f"SELECT {returning_cols} FROM {table} WHERE id = %s", (last_id,))
|
|
170
|
+
rows = self._fetch_all(sel)
|
|
171
|
+
sel.close()
|
|
172
|
+
return rows
|
|
173
|
+
cur = self._conn.cursor()
|
|
174
|
+
cur.execute(self._xform(self._sql), tuple(params))
|
|
175
|
+
rows = self._fetch_all(cur)
|
|
176
|
+
cur.close()
|
|
177
|
+
return rows
|
|
178
|
+
|
|
179
|
+
def run(self, params: Sequence[Any]) -> RunInfo:
|
|
180
|
+
cur = self._conn.cursor()
|
|
181
|
+
cur.execute(self._xform(self._sql), tuple(params))
|
|
182
|
+
changes = cur.rowcount if cur.rowcount is not None and cur.rowcount >= 0 else 0
|
|
183
|
+
last = cur.lastrowid if getattr(cur, "lastrowid", None) is not None else 0
|
|
184
|
+
cur.close()
|
|
185
|
+
return RunInfo(changes, last)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
class PostgresDriver:
|
|
189
|
+
"""A live Postgres driver (psycopg 3) implementing the :class:`Driver` seam.
|
|
190
|
+
|
|
191
|
+
Renders a `postgres`-tagged bundle → `$N` text; this driver rewrites `$N`→`%s` for psycopg and
|
|
192
|
+
executes REAL SQL over a live connection. Autocommit ON so the runtime's BEGIN/COMMIT/ROLLBACK
|
|
193
|
+
literals control the transaction (a genuine PG transaction for the gate-first write-tx).
|
|
194
|
+
"""
|
|
195
|
+
|
|
196
|
+
__slots__ = ("conn",)
|
|
197
|
+
|
|
198
|
+
def __init__(self, conn: Any) -> None:
|
|
199
|
+
self.conn = conn
|
|
200
|
+
|
|
201
|
+
@classmethod
|
|
202
|
+
def connect(cls, *, host: str, port: int, user: str, password: str, dbname: str) -> "PostgresDriver":
|
|
203
|
+
import psycopg # imported lazily so the SQLite conformance never needs the driver installed
|
|
204
|
+
|
|
205
|
+
conn = psycopg.connect(host=host, port=port, user=user, password=password, dbname=dbname, autocommit=True)
|
|
206
|
+
return cls(conn)
|
|
207
|
+
|
|
208
|
+
def exec_ddl(self, statements: Sequence[str]) -> None:
|
|
209
|
+
cur = self.conn.cursor()
|
|
210
|
+
for stmt in statements:
|
|
211
|
+
cur.execute(stmt)
|
|
212
|
+
cur.close()
|
|
213
|
+
|
|
214
|
+
def prepare(self, sql: str) -> _LivePrepared:
|
|
215
|
+
return _LivePrepared(self.conn, sql, _dollar_to_pyformat, emulate_returning=False)
|
|
216
|
+
|
|
217
|
+
def close(self) -> None:
|
|
218
|
+
self.conn.close()
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
class MysqlDriver:
|
|
222
|
+
"""A live MySQL driver (PyMySQL) implementing the :class:`Driver` seam.
|
|
223
|
+
|
|
224
|
+
Renders a `mysql`-tagged bundle → `?` text; this driver rewrites `?`→`%s` for PyMySQL. MySQL
|
|
225
|
+
8.0 has NO `RETURNING`, so an INSERT…RETURNING is emulated at this seam (strip → INSERT →
|
|
226
|
+
re-select the AUTO_INCREMENT PK's columns) — the dialect-behavior-by-convention the WS6 TS
|
|
227
|
+
ScpDialect uses. Autocommit ON so the runtime's BEGIN/COMMIT/ROLLBACK literals bracket the tx.
|
|
228
|
+
"""
|
|
229
|
+
|
|
230
|
+
__slots__ = ("conn",)
|
|
231
|
+
|
|
232
|
+
def __init__(self, conn: Any) -> None:
|
|
233
|
+
self.conn = conn
|
|
234
|
+
|
|
235
|
+
@classmethod
|
|
236
|
+
def connect(cls, *, host: str, port: int, user: str, password: str, dbname: str) -> "MysqlDriver":
|
|
237
|
+
import pymysql # lazy import (conformance bar never needs it)
|
|
238
|
+
|
|
239
|
+
conn = pymysql.connect(host=host, port=port, user=user, password=password, database=dbname, autocommit=True)
|
|
240
|
+
return cls(conn)
|
|
241
|
+
|
|
242
|
+
def exec_ddl(self, statements: Sequence[str]) -> None:
|
|
243
|
+
cur = self.conn.cursor()
|
|
244
|
+
for stmt in statements:
|
|
245
|
+
cur.execute(stmt)
|
|
246
|
+
cur.close()
|
|
247
|
+
|
|
248
|
+
def prepare(self, sql: str) -> _LivePrepared:
|
|
249
|
+
return _LivePrepared(self.conn, sql, _qmark_to_pyformat, emulate_returning=True)
|
|
250
|
+
|
|
251
|
+
def close(self) -> None:
|
|
252
|
+
self.conn.close()
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""litedbmodel v2 SCP — Error Mapping (Python port of ``src/scp/errors.ts``, spec §11 item 5).
|
|
2
|
+
|
|
3
|
+
Maps a SQLite driver error (Python ``sqlite3`` exceptions, or a better-sqlite3-shaped
|
|
4
|
+
``SQLITE_*`` code carried in a message tag) to a structured :class:`SqlFailure` with a stable
|
|
5
|
+
`kind` + the bc Execution-Plan Policy Kind the runtime honors (fail / retry / continue).
|
|
6
|
+
|
|
7
|
+
The mapping is closed and explicit (no silent catch-all that hides a driver error): an
|
|
8
|
+
unrecognized error maps to ``kind='driver_error'`` / ``policy='fail'`` — loud, and carrying the
|
|
9
|
+
original code + message. The `SQLITE_*` code family is mirrored from the TS reference; for the
|
|
10
|
+
stdlib ``sqlite3`` driver (whose exceptions do not carry a `SQLITE_*` string code on Python <3.11)
|
|
11
|
+
the code is derived from the message text ("UNIQUE constraint failed", "FOREIGN KEY constraint
|
|
12
|
+
failed", …) so the same kind/policy is produced as the better-sqlite3 seam.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import re
|
|
18
|
+
import sqlite3
|
|
19
|
+
from typing import Optional
|
|
20
|
+
|
|
21
|
+
# The SCP failure kinds and their honored bc Policy Kind.
|
|
22
|
+
_KIND_POLICY = {
|
|
23
|
+
"constraint_violation": "fail",
|
|
24
|
+
"foreign_key_violation": "fail",
|
|
25
|
+
"retryable": "retry",
|
|
26
|
+
"driver_error": "fail",
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class SqlFailure(Exception):
|
|
31
|
+
"""A mapped SCP failure: SCP `kind`, honored bc Policy Kind, the SQLite code, a message."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, kind: str, policy: str, sqlite_code: Optional[str], message: str) -> None:
|
|
34
|
+
super().__init__(message)
|
|
35
|
+
self.kind = kind
|
|
36
|
+
self.policy = policy
|
|
37
|
+
self.sqlite_code = sqlite_code
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _code_from_bettersqlite_tag(message: str) -> Optional[str]:
|
|
41
|
+
"""Extract a `SQLITE_*` code embedded by the TS seam (`[SQLITE_...] ...`) or a bare mention."""
|
|
42
|
+
m = re.search(r"(SQLITE_[A-Z_]+)", message)
|
|
43
|
+
return m.group(1) if m else None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _code_from_stdlib(e: BaseException) -> Optional[str]:
|
|
47
|
+
"""Derive a `SQLITE_*`-style code from a stdlib sqlite3 exception (type + message text)."""
|
|
48
|
+
# Python 3.11+ exposes sqlite_errorname (e.g. 'SQLITE_CONSTRAINT_UNIQUE'); prefer it.
|
|
49
|
+
name = getattr(e, "sqlite_errorname", None)
|
|
50
|
+
if isinstance(name, str) and name.startswith("SQLITE_"):
|
|
51
|
+
return name
|
|
52
|
+
msg = str(e)
|
|
53
|
+
if isinstance(e, sqlite3.IntegrityError):
|
|
54
|
+
if "FOREIGN KEY" in msg:
|
|
55
|
+
return "SQLITE_CONSTRAINT_FOREIGNKEY"
|
|
56
|
+
return "SQLITE_CONSTRAINT"
|
|
57
|
+
if isinstance(e, sqlite3.OperationalError):
|
|
58
|
+
if "locked" in msg:
|
|
59
|
+
return "SQLITE_LOCKED"
|
|
60
|
+
if "busy" in msg:
|
|
61
|
+
return "SQLITE_BUSY"
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def map_sqlite_error(e: BaseException) -> SqlFailure:
|
|
66
|
+
"""Map a caught driver error to a :class:`SqlFailure` (byte-for-byte kind/policy with TS)."""
|
|
67
|
+
if isinstance(e, sqlite3.Error):
|
|
68
|
+
code = _code_from_stdlib(e)
|
|
69
|
+
else:
|
|
70
|
+
code = _code_from_bettersqlite_tag(str(e))
|
|
71
|
+
|
|
72
|
+
if code is None:
|
|
73
|
+
message = str(e)
|
|
74
|
+
return SqlFailure("driver_error", "fail", None, f"non-SQLite driver error: {message}")
|
|
75
|
+
|
|
76
|
+
tagged = f"[{code}] {e}"
|
|
77
|
+
if code == "SQLITE_CONSTRAINT_FOREIGNKEY":
|
|
78
|
+
return SqlFailure("foreign_key_violation", "fail", code, tagged)
|
|
79
|
+
if code.startswith("SQLITE_CONSTRAINT"):
|
|
80
|
+
return SqlFailure("constraint_violation", "fail", code, tagged)
|
|
81
|
+
if code in ("SQLITE_BUSY", "SQLITE_LOCKED"):
|
|
82
|
+
return SqlFailure("retryable", "retry", code, tagged)
|
|
83
|
+
return SqlFailure("driver_error", "fail", code, tagged)
|