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 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