sqlodin 0.6.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sqlodin/__init__.py +15 -0
- sqlodin/client.py +333 -0
- sqlodin/dbapi.py +324 -0
- sqlodin/errors.py +42 -0
- sqlodin/parameters.py +86 -0
- sqlodin/py.typed +0 -0
- sqlodin/results.py +67 -0
- sqlodin/search.py +110 -0
- sqlodin/sqlalchemy.py +138 -0
- sqlodin/transaction.py +49 -0
- sqlodin/transport.py +97 -0
- sqlodin/vector.py +47 -0
- sqlodin-0.6.0.dist-info/METADATA +253 -0
- sqlodin-0.6.0.dist-info/RECORD +17 -0
- sqlodin-0.6.0.dist-info/WHEEL +4 -0
- sqlodin-0.6.0.dist-info/entry_points.txt +2 -0
- sqlodin-0.6.0.dist-info/licenses/LICENSE +21 -0
sqlodin/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""SQLodin: durable SQL with a small, explicit Python API."""
|
|
2
|
+
from .client import Connection, PendingWrite, connect
|
|
3
|
+
from .errors import (ConnectionError, ConstraintError, Error, PendingWriteError,
|
|
4
|
+
QueryError, SerializationError, SessionError, UnknownOutcome)
|
|
5
|
+
from .results import Row, Rows, WriteResult
|
|
6
|
+
from .transport import Endpoint, TLS
|
|
7
|
+
from .vector import Vector
|
|
8
|
+
from .search import SearchIndex
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
'Vector', 'SearchIndex', 'connect', 'Connection', 'Endpoint', 'TLS', 'PendingWrite', 'Row', 'Rows', 'WriteResult',
|
|
12
|
+
'Error', 'ConnectionError', 'ConstraintError', 'PendingWriteError', 'QueryError',
|
|
13
|
+
'SessionError', 'SerializationError', 'UnknownOutcome',
|
|
14
|
+
]
|
|
15
|
+
__version__ = '0.6.0'
|
sqlodin/client.py
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
"""Synchronous connections with explicit recovery of uncertain durable writes."""
|
|
2
|
+
import base64
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
import secrets
|
|
6
|
+
import threading
|
|
7
|
+
import time
|
|
8
|
+
from collections.abc import Sequence
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
|
|
11
|
+
from .errors import (ConnectionError, ConstraintError, PendingWriteError, QueryError,
|
|
12
|
+
SessionError, SerializationError, UnknownOutcome)
|
|
13
|
+
from .parameters import Value, encode, prepare
|
|
14
|
+
from .vector import Vector
|
|
15
|
+
from .results import Row, Rows, WriteResult
|
|
16
|
+
from .transport import Endpoint, TLS, Transport
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class PendingWrite:
|
|
21
|
+
session: str
|
|
22
|
+
sequence: int
|
|
23
|
+
sql: str
|
|
24
|
+
parameters: tuple[Value, ...] = ()
|
|
25
|
+
read_version: int = 0
|
|
26
|
+
epoch: int = 0
|
|
27
|
+
|
|
28
|
+
def __post_init__(self):
|
|
29
|
+
if not re.fullmatch(r'[0-9a-f]{32}', self.session) or int(self.session, 16) == 0:
|
|
30
|
+
raise ValueError("Session must be 32 lowercase hexadecimal digits, not all zero")
|
|
31
|
+
if type(self.sequence) is not int or not 1 <= self.sequence < 2**63:
|
|
32
|
+
raise ValueError("Sequence must be a positive signed 64-bit integer")
|
|
33
|
+
if not self.sql or '\x00' in self.sql or len(self.sql.encode('utf-8')) > 4096:
|
|
34
|
+
raise ValueError("Invalid pending SQL body")
|
|
35
|
+
if type(self.read_version) is not int or not 0 <= self.read_version < 2**63:
|
|
36
|
+
raise ValueError("Invalid transaction read version")
|
|
37
|
+
if type(self.epoch) is not int or not 0 <= self.epoch < 2**63:
|
|
38
|
+
raise ValueError("Invalid session epoch")
|
|
39
|
+
object.__setattr__(self, 'parameters', tuple(self.parameters))
|
|
40
|
+
if len(self.parameters) > 16:
|
|
41
|
+
raise ValueError("Too many parameters")
|
|
42
|
+
if sum(len(v) for v in self.parameters if isinstance(v, Vector)) > 384:
|
|
43
|
+
raise ValueError("A request supports at most 384 vector components in total")
|
|
44
|
+
for value in self.parameters:
|
|
45
|
+
encode(value)
|
|
46
|
+
|
|
47
|
+
def to_json(self) -> str:
|
|
48
|
+
"""Save securely before recovery; contains SQL and parameter values."""
|
|
49
|
+
parameters = [{"vector": list(v)} if isinstance(v, Vector) else v for v in self.parameters]
|
|
50
|
+
return json.dumps(dict(session=self.session, sequence=self.sequence, sql=self.sql,
|
|
51
|
+
parameters=parameters, read_version=self.read_version, epoch=self.epoch), allow_nan=False)
|
|
52
|
+
|
|
53
|
+
@classmethod
|
|
54
|
+
def from_json(cls, text: str) -> 'PendingWrite':
|
|
55
|
+
value = json.loads(text)
|
|
56
|
+
value["parameters"] = tuple(Vector(p["vector"]) if isinstance(p, dict) and set(p) == {"vector"}
|
|
57
|
+
else p for p in value.get("parameters", ()))
|
|
58
|
+
return cls(**value)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class Connection:
|
|
62
|
+
def __init__(self, endpoints: Sequence[Endpoint], *, cluster: str, tls: TLS,
|
|
63
|
+
timeout: float = 10, pending: PendingWrite | None = None):
|
|
64
|
+
self.endpoints = tuple(endpoints)
|
|
65
|
+
if not self.endpoints or any(not isinstance(e, Endpoint) for e in self.endpoints):
|
|
66
|
+
raise ValueError("Provide at least one Endpoint(address, server_name)")
|
|
67
|
+
if not cluster or not 0 < timeout <= 60:
|
|
68
|
+
raise ValueError("Cluster is required; timeout must be in (0, 60] seconds")
|
|
69
|
+
self.cluster, self.timeout = cluster, timeout
|
|
70
|
+
self._session = pending.session if pending else secrets.token_hex(16)
|
|
71
|
+
self._sequence = pending.sequence if pending else 1
|
|
72
|
+
self._pending = pending
|
|
73
|
+
self._epoch = pending.epoch if pending else None
|
|
74
|
+
self._transport = Transport(tls)
|
|
75
|
+
self._index = 0
|
|
76
|
+
self._lock = threading.RLock()
|
|
77
|
+
self._closed = False
|
|
78
|
+
self._transaction = False
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def pending(self) -> PendingWrite | None:
|
|
82
|
+
return self._pending
|
|
83
|
+
|
|
84
|
+
def close(self) -> None:
|
|
85
|
+
with self._lock:
|
|
86
|
+
self._transport.close()
|
|
87
|
+
self._closed = True
|
|
88
|
+
|
|
89
|
+
def __enter__(self):
|
|
90
|
+
self._ready()
|
|
91
|
+
return self
|
|
92
|
+
|
|
93
|
+
def __exit__(self, *exc):
|
|
94
|
+
self.close()
|
|
95
|
+
|
|
96
|
+
def _ready(self):
|
|
97
|
+
if self._closed:
|
|
98
|
+
raise ConnectionError("Connection is closed")
|
|
99
|
+
if self._transaction:
|
|
100
|
+
raise PendingWriteError("Use the transaction's execute() while building an atomic batch")
|
|
101
|
+
|
|
102
|
+
def execute(self, sql: str, parameters: Sequence[Value] = ()) -> WriteResult:
|
|
103
|
+
"""Execute a durable transaction body; ? values are bound, never formatted."""
|
|
104
|
+
text, values = prepare(sql, parameters)
|
|
105
|
+
with self._lock:
|
|
106
|
+
self._ready()
|
|
107
|
+
return self._execute_prepared(text, values)
|
|
108
|
+
|
|
109
|
+
def _execute_prepared(self, text, values, read_version=0):
|
|
110
|
+
if self._pending is not None:
|
|
111
|
+
raise PendingWriteError("Resolve the outstanding write before submitting another")
|
|
112
|
+
if self._epoch is None:
|
|
113
|
+
self._epoch = self.session_epoch()
|
|
114
|
+
self._pending = PendingWrite(self._session, self._sequence, text, values, read_version, self._epoch)
|
|
115
|
+
return self._resolve()
|
|
116
|
+
|
|
117
|
+
def resolve_pending(self) -> WriteResult:
|
|
118
|
+
"""Retry the exact saved identity and content, including after failover."""
|
|
119
|
+
with self._lock:
|
|
120
|
+
self._ready()
|
|
121
|
+
if self._pending is None:
|
|
122
|
+
raise PendingWriteError("There is no pending write")
|
|
123
|
+
return self._resolve()
|
|
124
|
+
|
|
125
|
+
def _resolve(self):
|
|
126
|
+
pending = self._pending
|
|
127
|
+
request = dict(op='execute', sql=pending.sql, session=pending.session,
|
|
128
|
+
sequence=pending.sequence, session_epoch=pending.epoch, read_version=pending.read_version, parameters=[encode(v) for v in pending.parameters])
|
|
129
|
+
try:
|
|
130
|
+
response = self._call(request)
|
|
131
|
+
except ConnectionError as exc:
|
|
132
|
+
raise UnknownOutcome(pending) from exc
|
|
133
|
+
code = response.get('error', '')
|
|
134
|
+
if code in ('Expired', 'Identity_Conflict', 'Session_Limit'):
|
|
135
|
+
raise SessionError(code)
|
|
136
|
+
if code not in ('', 'Constraint', 'Policy', 'Sequence_Gap', 'Invalid_SQL',
|
|
137
|
+
'Invalid_Request', 'Unsupported', 'Conflict'):
|
|
138
|
+
raise UnknownOutcome(pending)
|
|
139
|
+
self._pending = None
|
|
140
|
+
if code not in ('Invalid_Request', 'Unsupported'):
|
|
141
|
+
self._sequence += 1
|
|
142
|
+
if code == "Conflict":
|
|
143
|
+
raise SerializationError(code)
|
|
144
|
+
if code:
|
|
145
|
+
raise ConstraintError(code) if code == 'Constraint' else QueryError(code)
|
|
146
|
+
return WriteResult(response['changes'], response['applied'], response['node'], response['sequence'])
|
|
147
|
+
|
|
148
|
+
def query(self, sql: str, parameters: Sequence[Value] = (), *, consistency: str = 'linearizable') -> Rows:
|
|
149
|
+
"""Read after a fresh quorum barrier; local reads explicitly allow stale data."""
|
|
150
|
+
if consistency not in ('linearizable', 'local'):
|
|
151
|
+
raise ValueError("Consistency must be 'linearizable' or 'local'")
|
|
152
|
+
text, values = prepare(sql, parameters)
|
|
153
|
+
with self._lock:
|
|
154
|
+
self._ready()
|
|
155
|
+
response = self._call(dict(op='query', sql=text, consistency=consistency,
|
|
156
|
+
parameters=[encode(v) for v in values]))
|
|
157
|
+
self._raise_query(response)
|
|
158
|
+
try:
|
|
159
|
+
columns = tuple(response['columns'])
|
|
160
|
+
if not all(isinstance(c, str) for c in columns):
|
|
161
|
+
raise ValueError('Invalid column names')
|
|
162
|
+
rows = tuple(Row(columns, tuple(_decode(v) for v in row)) for row in response['rows'])
|
|
163
|
+
if any(len(row.as_tuple()) != len(columns) for row in rows):
|
|
164
|
+
raise ValueError('Invalid row width')
|
|
165
|
+
return Rows(columns, rows, response['applied'], response['node'])
|
|
166
|
+
except (KeyError, TypeError, ValueError) as exc:
|
|
167
|
+
raise ConnectionError("Malformed query result") from exc
|
|
168
|
+
|
|
169
|
+
def status(self) -> dict:
|
|
170
|
+
"""Local node information, not a quorum health check."""
|
|
171
|
+
with self._lock:
|
|
172
|
+
self._ready()
|
|
173
|
+
result = self._call(dict(op='status'))
|
|
174
|
+
self._raise_query(result)
|
|
175
|
+
return {k: result[k] for k in ('cluster', 'node', 'protocol', 'policy', 'applied',
|
|
176
|
+
'snapshot_prefix', 'snapshot_sealed', 'snapshot_error', 'generation_prefix') if k in result}
|
|
177
|
+
|
|
178
|
+
def session_epoch(self) -> int:
|
|
179
|
+
"""Read the current retry-session epoch through a fresh quorum barrier."""
|
|
180
|
+
with self._lock:
|
|
181
|
+
self._ready()
|
|
182
|
+
response = self._call(dict(op='session_epoch'))
|
|
183
|
+
# Pre-epoch services never retire session rows and have only epoch zero.
|
|
184
|
+
if response.get('error') == 'Unsupported':
|
|
185
|
+
return 0
|
|
186
|
+
self._raise_query(response)
|
|
187
|
+
epoch = response.get('session_epoch')
|
|
188
|
+
if type(epoch) is not int or not 0 <= epoch < 2**63:
|
|
189
|
+
raise ConnectionError('Malformed session epoch')
|
|
190
|
+
return epoch
|
|
191
|
+
|
|
192
|
+
def retire_sessions(self, *, expected_epoch: int) -> int:
|
|
193
|
+
"""Fence the old epoch and reclaim its session rows.
|
|
194
|
+
|
|
195
|
+
Quiesce clients and resolve their pending writes first. Old pending writes
|
|
196
|
+
then fail with Expired; never relabel them into a new epoch. After a lost
|
|
197
|
+
response, retry this same expected_epoch. Open new connections afterwards.
|
|
198
|
+
"""
|
|
199
|
+
if type(expected_epoch) is not int or not 0 <= expected_epoch < 2**63-1:
|
|
200
|
+
raise ValueError('Invalid expected session epoch')
|
|
201
|
+
with self._lock:
|
|
202
|
+
self._ready()
|
|
203
|
+
if self._pending is not None or self._transaction:
|
|
204
|
+
raise PendingWriteError('Resolve pending work before retiring sessions')
|
|
205
|
+
response = self._call(dict(op='retire_sessions', session_epoch=expected_epoch))
|
|
206
|
+
self._raise_query(response)
|
|
207
|
+
epoch = response.get('session_epoch')
|
|
208
|
+
if type(epoch) is not int or epoch < expected_epoch+1:
|
|
209
|
+
raise ConnectionError('Malformed retirement response; retry the same expected_epoch')
|
|
210
|
+
return epoch
|
|
211
|
+
|
|
212
|
+
def request_snapshot(self) -> int:
|
|
213
|
+
"""Request a distributed snapshot; return its proposed checkpoint slot.
|
|
214
|
+
|
|
215
|
+
This is admission, not certification or a backup completion guarantee.
|
|
216
|
+
Inspect status()['snapshot_sealed'] for the locally learned certificate.
|
|
217
|
+
A displaced checkpoint may require a new request.
|
|
218
|
+
"""
|
|
219
|
+
with self._lock:
|
|
220
|
+
self._ready()
|
|
221
|
+
response = self._call(dict(op='snapshot'))
|
|
222
|
+
self._raise_query(response)
|
|
223
|
+
slot = response.get('snapshot_requested')
|
|
224
|
+
if type(slot) is not int or slot <= 0:
|
|
225
|
+
raise ConnectionError('Malformed snapshot admission result')
|
|
226
|
+
return slot
|
|
227
|
+
|
|
228
|
+
@staticmethod
|
|
229
|
+
def _raise_query(response):
|
|
230
|
+
if response['status'] != 'ok':
|
|
231
|
+
if response.get('error') == 'Conflict': raise SerializationError('Conflict')
|
|
232
|
+
if response.get('error') == 'Constraint': raise ConstraintError('Constraint')
|
|
233
|
+
raise QueryError(response.get('error', 'Unknown'))
|
|
234
|
+
|
|
235
|
+
def _call(self, request):
|
|
236
|
+
deadline = time.monotonic() + self.timeout
|
|
237
|
+
request = dict(request, protocol=1, cluster=self.cluster)
|
|
238
|
+
last_error = None
|
|
239
|
+
while time.monotonic() < deadline:
|
|
240
|
+
endpoint = self.endpoints[self._index]
|
|
241
|
+
# Divide the remaining budget so an unavailable seed cannot consume
|
|
242
|
+
# the entire failover deadline. Every retry retains the write identity.
|
|
243
|
+
attempt = min(deadline, time.monotonic() + max(0.1, (deadline - time.monotonic()) / len(self.endpoints)))
|
|
244
|
+
request['timeout_ms'] = max(1, min(60000, int((attempt - time.monotonic()) * 900)))
|
|
245
|
+
try:
|
|
246
|
+
response = self._transport.exchange(endpoint, request, attempt)
|
|
247
|
+
self._validate(response, request)
|
|
248
|
+
if response.get('error') not in ('Busy', 'Unknown_Outcome', 'Read_Timeout'):
|
|
249
|
+
return response
|
|
250
|
+
last_error = response['error']
|
|
251
|
+
except (ConnectionError, TimeoutError) as exc:
|
|
252
|
+
last_error = str(exc)
|
|
253
|
+
self._transport.close()
|
|
254
|
+
self._index = (self._index + 1) % len(self.endpoints)
|
|
255
|
+
time.sleep(min(0.01, max(0, deadline - time.monotonic())))
|
|
256
|
+
raise ConnectionError(f"Operation deadline exceeded: {last_error}")
|
|
257
|
+
|
|
258
|
+
def _validate(self, response, request):
|
|
259
|
+
if response.get('cluster') != self.cluster or response.get('protocol') != 1:
|
|
260
|
+
raise ConnectionError("Response cluster or protocol mismatch")
|
|
261
|
+
if response.get('status') not in ('ok', 'error'):
|
|
262
|
+
raise ConnectionError("Invalid response status")
|
|
263
|
+
for name in ('node', 'applied', 'changes', 'sequence'):
|
|
264
|
+
if type(response.get(name)) is not int or response[name] < 0:
|
|
265
|
+
raise ConnectionError(f"Invalid response {name}")
|
|
266
|
+
if request['op'] == 'execute' and response['sequence'] != request['sequence']:
|
|
267
|
+
raise ConnectionError("Write response identity mismatch")
|
|
268
|
+
code = response.get('error')
|
|
269
|
+
if not isinstance(code, str) or (response['status'] == 'ok') != (code == ''):
|
|
270
|
+
raise ConnectionError("Invalid error/status combination")
|
|
271
|
+
|
|
272
|
+
def _begin_optimistic(self):
|
|
273
|
+
with self._lock:
|
|
274
|
+
self._ready()
|
|
275
|
+
response = self._call(dict(op='begin'))
|
|
276
|
+
self._raise_query(response)
|
|
277
|
+
epoch = response.get('session_epoch', 0)
|
|
278
|
+
if type(epoch) is not int or not 0 <= epoch < 2**63:
|
|
279
|
+
raise ConnectionError('Malformed transaction session epoch')
|
|
280
|
+
if self._epoch is None:
|
|
281
|
+
self._epoch = epoch
|
|
282
|
+
version = response.get('read_version')
|
|
283
|
+
if type(version) is not int or not 1 <= version < 2**63:
|
|
284
|
+
raise ConnectionError('Invalid transaction read version')
|
|
285
|
+
return version
|
|
286
|
+
|
|
287
|
+
def _preview(self, body, values, version, query='', query_values=()):
|
|
288
|
+
with self._lock:
|
|
289
|
+
self._ready()
|
|
290
|
+
response = self._call(dict(op='preview', sql=body, read_version=version,
|
|
291
|
+
parameters=[encode(v) for v in values], read_sql=query,
|
|
292
|
+
read_parameters=[encode(v) for v in query_values]))
|
|
293
|
+
self._raise_query(response)
|
|
294
|
+
return response
|
|
295
|
+
|
|
296
|
+
def search_index(self, name: str, *, dimensions: int):
|
|
297
|
+
"""Open a named ordinary-BLOB/FTS5 index handle without creating schema."""
|
|
298
|
+
from .search import SearchIndex
|
|
299
|
+
return SearchIndex(self, name, dimensions=dimensions)
|
|
300
|
+
|
|
301
|
+
def create_search_index(self, name: str, *, dimensions: int):
|
|
302
|
+
"""Atomically create an FTS5 index and its typed-vector content table."""
|
|
303
|
+
index = self.search_index(name, dimensions=dimensions)
|
|
304
|
+
index.create()
|
|
305
|
+
return index
|
|
306
|
+
|
|
307
|
+
def transaction(self):
|
|
308
|
+
"""Buffer a write-only batch; commit the complete body on successful exit."""
|
|
309
|
+
from .transaction import Transaction
|
|
310
|
+
return Transaction(self)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _decode(value):
|
|
314
|
+
kind = value['kind']
|
|
315
|
+
if kind == 'Null':
|
|
316
|
+
return None
|
|
317
|
+
if kind == 'Integer':
|
|
318
|
+
return value['integer']
|
|
319
|
+
if kind == 'Real':
|
|
320
|
+
return value['real']
|
|
321
|
+
if kind == 'Text':
|
|
322
|
+
return value['text']
|
|
323
|
+
if kind == 'Blob':
|
|
324
|
+
return base64.b64decode(value['text'], validate=True)
|
|
325
|
+
raise ValueError(f"Unknown result kind: {kind}")
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def connect(endpoints: Endpoint | Sequence[Endpoint], *, cluster: str, tls: TLS,
|
|
329
|
+
timeout: float = 10, pending: PendingWrite | None = None) -> Connection:
|
|
330
|
+
"""Create a reusable connection; the first operation establishes mTLS."""
|
|
331
|
+
if isinstance(endpoints, Endpoint):
|
|
332
|
+
endpoints = [endpoints]
|
|
333
|
+
return Connection(endpoints, cluster=cluster, tls=tls, timeout=timeout, pending=pending)
|
sqlodin/dbapi.py
ADDED
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
"""DB-API transactions using bounded optimistic execution and durable commit."""
|
|
2
|
+
from collections.abc import Mapping
|
|
3
|
+
from itertools import islice
|
|
4
|
+
from .parameters import prepare
|
|
5
|
+
from .vector import Vector
|
|
6
|
+
from . import client as native
|
|
7
|
+
from . import errors
|
|
8
|
+
|
|
9
|
+
apilevel = '2.0'
|
|
10
|
+
threadsafety = 1
|
|
11
|
+
paramstyle = 'qmark'
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Warning(Exception): pass
|
|
15
|
+
class Error(Exception): pass
|
|
16
|
+
class InterfaceError(Error): pass
|
|
17
|
+
class DatabaseError(Error): pass
|
|
18
|
+
class DataError(DatabaseError): pass
|
|
19
|
+
class OperationalError(DatabaseError):
|
|
20
|
+
def __init__(self, message, *, pending=None):
|
|
21
|
+
self.pending = pending
|
|
22
|
+
super().__init__(message)
|
|
23
|
+
class SerializationError(OperationalError):
|
|
24
|
+
sqlstate = "40001"
|
|
25
|
+
|
|
26
|
+
class IntegrityError(DatabaseError): pass
|
|
27
|
+
class InternalError(DatabaseError): pass
|
|
28
|
+
class ProgrammingError(DatabaseError): pass
|
|
29
|
+
class NotSupportedError(DatabaseError): pass
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _translate(exc, pending=None):
|
|
33
|
+
if isinstance(exc, errors.SerializationError):
|
|
34
|
+
return SerializationError(str(exc))
|
|
35
|
+
if isinstance(exc, errors.ConstraintError):
|
|
36
|
+
return IntegrityError(str(exc))
|
|
37
|
+
if isinstance(exc, (errors.UnknownOutcome, errors.PendingWriteError, errors.ConnectionError)):
|
|
38
|
+
return OperationalError(str(exc), pending=getattr(exc, 'pending', pending))
|
|
39
|
+
if isinstance(exc, errors.QueryError):
|
|
40
|
+
return ProgrammingError(str(exc))
|
|
41
|
+
if isinstance(exc, (ValueError, TypeError)):
|
|
42
|
+
return DataError(str(exc))
|
|
43
|
+
return OperationalError(str(exc), pending=pending)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _kind(sql):
|
|
47
|
+
"""Classify one statement using top-level tokens, preserving quoted SQL/data."""
|
|
48
|
+
words, depth, i = [], 0, 0
|
|
49
|
+
while i < len(sql):
|
|
50
|
+
c = sql[i]
|
|
51
|
+
if sql.startswith('--', i):
|
|
52
|
+
end = sql.find('\n', i)
|
|
53
|
+
i = len(sql) if end < 0 else end
|
|
54
|
+
elif sql.startswith('/*', i):
|
|
55
|
+
end = sql.find('*/', i + 2)
|
|
56
|
+
if end < 0: raise ProgrammingError('Unterminated comment')
|
|
57
|
+
i = end + 2
|
|
58
|
+
elif c in "'\"`[":
|
|
59
|
+
closing = ']' if c == '[' else c
|
|
60
|
+
i += 1
|
|
61
|
+
while i < len(sql):
|
|
62
|
+
if sql[i] == closing:
|
|
63
|
+
i += 1
|
|
64
|
+
if closing != ']' and i < len(sql) and sql[i] == closing:
|
|
65
|
+
i += 1
|
|
66
|
+
continue
|
|
67
|
+
break
|
|
68
|
+
i += 1
|
|
69
|
+
else: raise ProgrammingError('Unterminated quote')
|
|
70
|
+
elif c == '(':
|
|
71
|
+
depth += 1
|
|
72
|
+
i += 1
|
|
73
|
+
elif c == ')':
|
|
74
|
+
depth -= 1
|
|
75
|
+
i += 1
|
|
76
|
+
elif c == ';' and depth == 0:
|
|
77
|
+
words.append(';')
|
|
78
|
+
i += 1
|
|
79
|
+
elif (c.isalpha() or c == '_') and depth == 0:
|
|
80
|
+
end = i + 1
|
|
81
|
+
while end < len(sql) and (sql[end].isalnum() or sql[end] == '_'): end += 1
|
|
82
|
+
words.append(sql[i:end].upper())
|
|
83
|
+
i = end
|
|
84
|
+
else:
|
|
85
|
+
i += 1
|
|
86
|
+
while words and words[-1] == ';': words.pop()
|
|
87
|
+
if not words or ';' in words or depth != 0:
|
|
88
|
+
raise ProgrammingError('Execute exactly one balanced SQL statement')
|
|
89
|
+
if 'RETURNING' in words:
|
|
90
|
+
raise NotSupportedError('DML RETURNING is not supported by the SQLodin service')
|
|
91
|
+
kind = words[0]
|
|
92
|
+
if kind == 'WITH':
|
|
93
|
+
kind = next((w for w in words[1:] if w in ('SELECT', 'INSERT', 'UPDATE', 'DELETE', 'REPLACE')), '')
|
|
94
|
+
if kind in ('SELECT', 'VALUES'):
|
|
95
|
+
return 'query'
|
|
96
|
+
if kind in ('INSERT', 'UPDATE', 'DELETE', 'REPLACE', 'CREATE', 'DROP', 'ALTER'):
|
|
97
|
+
return 'execute'
|
|
98
|
+
raise NotSupportedError('Only queries and DML/DDL statements are supported; transaction control uses Connection methods')
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def connect(endpoints, *, cluster, tls, timeout=10, autocommit=False):
|
|
102
|
+
if type(autocommit) is not bool: raise ValueError('autocommit must be bool')
|
|
103
|
+
return Connection(native.connect(endpoints, cluster=cluster, tls=tls, timeout=timeout), autocommit=autocommit)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class Connection:
|
|
107
|
+
def __init__(self, connection, *, autocommit=False):
|
|
108
|
+
self.native = connection
|
|
109
|
+
self.closed = False
|
|
110
|
+
self._autocommit = autocommit
|
|
111
|
+
self._version = None
|
|
112
|
+
self._statements = []
|
|
113
|
+
self._savepoints = []
|
|
114
|
+
self._failed = False
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def autocommit(self): return self._autocommit
|
|
118
|
+
@autocommit.setter
|
|
119
|
+
def autocommit(self, value):
|
|
120
|
+
if type(value) is not bool: raise NotSupportedError('autocommit must be bool')
|
|
121
|
+
self._ready()
|
|
122
|
+
if value != self._autocommit and self._version is not None:
|
|
123
|
+
raise ProgrammingError('Commit or roll back before changing autocommit')
|
|
124
|
+
self._autocommit = value
|
|
125
|
+
|
|
126
|
+
def _ready(self, *, allow_failed=False):
|
|
127
|
+
if self.closed: raise InterfaceError('Connection is closed')
|
|
128
|
+
if self.native.pending is not None:
|
|
129
|
+
raise OperationalError('Unresolved commit; recover its identity before reuse', pending=self.native.pending)
|
|
130
|
+
if self._failed and not allow_failed:
|
|
131
|
+
raise OperationalError('Transaction aborted; roll back before reuse')
|
|
132
|
+
|
|
133
|
+
def _begin(self):
|
|
134
|
+
self._ready()
|
|
135
|
+
if self._version is None:
|
|
136
|
+
try:
|
|
137
|
+
self._version = self.native._begin_optimistic()
|
|
138
|
+
except errors.Error as exc:
|
|
139
|
+
self._failed = True
|
|
140
|
+
raise _translate(exc, self.native.pending) from exc
|
|
141
|
+
|
|
142
|
+
@staticmethod
|
|
143
|
+
def _body(statements):
|
|
144
|
+
texts, values = [], []
|
|
145
|
+
for sql, params in statements:
|
|
146
|
+
text, bound = prepare(sql, params, offset=len(values))
|
|
147
|
+
texts.append(text + '\n')
|
|
148
|
+
values.extend(bound)
|
|
149
|
+
body = '\n;\n'.join(texts)
|
|
150
|
+
if len(statements) > 8 or len(body.encode('utf-8')) > 4096:
|
|
151
|
+
raise DataError('Transaction exceeds eight statements or 4096 SQL bytes')
|
|
152
|
+
if sum(len(v) for v in values if isinstance(v, Vector)) > 384:
|
|
153
|
+
raise DataError('Transaction exceeds 384 vector components')
|
|
154
|
+
return body, tuple(values)
|
|
155
|
+
|
|
156
|
+
def _preview(self, statements, query='', values=()):
|
|
157
|
+
body, parameters = self._body(statements)
|
|
158
|
+
text, bound = prepare(query, values) if query else ('', ())
|
|
159
|
+
self._begin()
|
|
160
|
+
return self.native._preview(body, parameters, self._version, text, bound)
|
|
161
|
+
|
|
162
|
+
def _clear(self):
|
|
163
|
+
self._version = None
|
|
164
|
+
self._statements.clear()
|
|
165
|
+
self._savepoints.clear()
|
|
166
|
+
self._failed = False
|
|
167
|
+
|
|
168
|
+
def cursor(self):
|
|
169
|
+
self._ready()
|
|
170
|
+
return Cursor(self)
|
|
171
|
+
|
|
172
|
+
def commit(self):
|
|
173
|
+
self._ready()
|
|
174
|
+
try:
|
|
175
|
+
if self._statements:
|
|
176
|
+
body, values = self._body(self._statements)
|
|
177
|
+
with self.native._lock:
|
|
178
|
+
self.native._execute_prepared(body, values, self._version)
|
|
179
|
+
except (errors.Error, ValueError, TypeError) as exc:
|
|
180
|
+
self._failed = True
|
|
181
|
+
raise _translate(exc, self.native.pending) from exc
|
|
182
|
+
self._clear()
|
|
183
|
+
|
|
184
|
+
def rollback(self):
|
|
185
|
+
self._ready(allow_failed=True)
|
|
186
|
+
self._clear() # Previews always roll back on the server; nothing was committed.
|
|
187
|
+
|
|
188
|
+
def savepoint(self, name):
|
|
189
|
+
if self.autocommit: raise NotSupportedError('Savepoints require transactional mode')
|
|
190
|
+
self._begin()
|
|
191
|
+
self._savepoints.append((name, len(self._statements)))
|
|
192
|
+
|
|
193
|
+
def _savepoint_index(self, name):
|
|
194
|
+
self._ready()
|
|
195
|
+
for index in range(len(self._savepoints) - 1, -1, -1):
|
|
196
|
+
if self._savepoints[index][0] == name: return index
|
|
197
|
+
raise ProgrammingError('Unknown savepoint')
|
|
198
|
+
|
|
199
|
+
def rollback_savepoint(self, name):
|
|
200
|
+
index = self._savepoint_index(name)
|
|
201
|
+
del self._statements[self._savepoints[index][1]:]
|
|
202
|
+
del self._savepoints[index + 1:]
|
|
203
|
+
|
|
204
|
+
def release_savepoint(self, name):
|
|
205
|
+
index = self._savepoint_index(name)
|
|
206
|
+
del self._savepoints[index:]
|
|
207
|
+
|
|
208
|
+
def close(self):
|
|
209
|
+
self._clear()
|
|
210
|
+
self.native.close()
|
|
211
|
+
self.closed = True
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
class Cursor:
|
|
215
|
+
arraysize = 1
|
|
216
|
+
|
|
217
|
+
def __init__(self, connection):
|
|
218
|
+
self.connection = connection
|
|
219
|
+
self.description, self.lastrowid = None, None
|
|
220
|
+
self.rowcount = -1
|
|
221
|
+
self._rows, self._position = (), 0
|
|
222
|
+
self.closed = False
|
|
223
|
+
|
|
224
|
+
def _ready(self):
|
|
225
|
+
if self.closed: raise InterfaceError('Cursor is closed')
|
|
226
|
+
self.connection._ready()
|
|
227
|
+
|
|
228
|
+
def execute(self, operation, parameters=()):
|
|
229
|
+
self._ready()
|
|
230
|
+
self.description, self.lastrowid, self.rowcount = None, None, -1
|
|
231
|
+
self._rows, self._position = (), 0
|
|
232
|
+
if isinstance(parameters, Mapping): raise ProgrammingError('Use qmark positional parameters')
|
|
233
|
+
kind = _kind(operation)
|
|
234
|
+
try:
|
|
235
|
+
connection = self.connection
|
|
236
|
+
if kind == 'query':
|
|
237
|
+
if connection.autocommit:
|
|
238
|
+
result = connection.native.query(operation, parameters)
|
|
239
|
+
columns, rows = result.columns, tuple(row.as_tuple() for row in result)
|
|
240
|
+
else:
|
|
241
|
+
result = connection._preview(connection._statements, operation, parameters)
|
|
242
|
+
columns = result['columns']
|
|
243
|
+
rows = tuple(tuple(native._decode(v) for v in row) for row in result['rows'])
|
|
244
|
+
self.description = tuple((name, None, None, None, None, None, None) for name in columns)
|
|
245
|
+
self._rows = rows
|
|
246
|
+
else:
|
|
247
|
+
candidate = [*connection._statements, (operation, tuple(parameters))]
|
|
248
|
+
result = connection._preview(candidate)
|
|
249
|
+
connection._statements = candidate
|
|
250
|
+
self.rowcount, self.lastrowid = result['changes'], result['lastrowid']
|
|
251
|
+
if connection.autocommit: connection.commit()
|
|
252
|
+
except (errors.Error, ValueError, TypeError) as exc:
|
|
253
|
+
if self.connection.autocommit and self.connection.native.pending is None:
|
|
254
|
+
self.connection._clear()
|
|
255
|
+
elif isinstance(exc, (errors.SerializationError, errors.ConnectionError)):
|
|
256
|
+
self.connection._failed = True
|
|
257
|
+
raise _translate(exc, self.connection.native.pending) from exc
|
|
258
|
+
except Error:
|
|
259
|
+
if self.connection.autocommit and self.connection.native.pending is None:
|
|
260
|
+
self.connection._clear()
|
|
261
|
+
raise
|
|
262
|
+
return self
|
|
263
|
+
|
|
264
|
+
def executemany(self, operation, seq_of_parameters):
|
|
265
|
+
self._ready()
|
|
266
|
+
if _kind(operation) != 'execute': raise NotSupportedError('executemany only supports writes')
|
|
267
|
+
parameters = list(islice(iter(seq_of_parameters), 9))
|
|
268
|
+
if len(parameters) > 8: raise NotSupportedError('An executemany is limited to eight statements')
|
|
269
|
+
self.description, self.lastrowid, self.rowcount = None, None, 0
|
|
270
|
+
self._rows, self._position = (), 0
|
|
271
|
+
if not parameters: return self
|
|
272
|
+
connection = self.connection
|
|
273
|
+
before = list(connection._statements)
|
|
274
|
+
auto = connection._autocommit
|
|
275
|
+
connection._autocommit = False
|
|
276
|
+
try:
|
|
277
|
+
count = 0
|
|
278
|
+
for values in parameters:
|
|
279
|
+
self.execute(operation, values)
|
|
280
|
+
count += self.rowcount
|
|
281
|
+
self.rowcount, self.lastrowid = count, None
|
|
282
|
+
if auto: connection.commit()
|
|
283
|
+
except BaseException:
|
|
284
|
+
connection._statements = before
|
|
285
|
+
if auto and connection.native.pending is None: connection._clear()
|
|
286
|
+
raise
|
|
287
|
+
finally:
|
|
288
|
+
connection._autocommit = auto
|
|
289
|
+
return self
|
|
290
|
+
|
|
291
|
+
def fetchone(self):
|
|
292
|
+
self._ready()
|
|
293
|
+
if self.description is None: raise ProgrammingError('No query result is available')
|
|
294
|
+
if self._position == len(self._rows): return None
|
|
295
|
+
row = self._rows[self._position]
|
|
296
|
+
self._position += 1
|
|
297
|
+
return row
|
|
298
|
+
|
|
299
|
+
def fetchmany(self, size=None):
|
|
300
|
+
self._ready()
|
|
301
|
+
if self.description is None: raise ProgrammingError('No query result is available')
|
|
302
|
+
size = self.arraysize if size is None else size
|
|
303
|
+
if type(size) is not int or size < 0: raise ProgrammingError('Invalid fetch size')
|
|
304
|
+
rows = []
|
|
305
|
+
for _ in range(size):
|
|
306
|
+
row = self.fetchone()
|
|
307
|
+
if row is None: break
|
|
308
|
+
rows.append(row)
|
|
309
|
+
return rows
|
|
310
|
+
|
|
311
|
+
def fetchall(self):
|
|
312
|
+
return self.fetchmany(len(self._rows) - self._position)
|
|
313
|
+
|
|
314
|
+
def close(self):
|
|
315
|
+
self._rows = ()
|
|
316
|
+
self.closed = True
|
|
317
|
+
|
|
318
|
+
def setinputsizes(self, *args): pass
|
|
319
|
+
def setoutputsize(self, *args): pass
|
|
320
|
+
def __iter__(self): return self
|
|
321
|
+
def __next__(self):
|
|
322
|
+
row = self.fetchone()
|
|
323
|
+
if row is None: raise StopIteration
|
|
324
|
+
return row
|