django-libsql-backend 0.1.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.
- django_libsql/__init__.py +28 -0
- django_libsql/base.py +528 -0
- django_libsql/client.py +20 -0
- django_libsql/creation.py +39 -0
- django_libsql/features.py +81 -0
- django_libsql/introspection.py +20 -0
- django_libsql/operations.py +341 -0
- django_libsql/schema.py +42 -0
- django_libsql_backend-0.1.0.dist-info/METADATA +318 -0
- django_libsql_backend-0.1.0.dist-info/RECORD +13 -0
- django_libsql_backend-0.1.0.dist-info/WHEEL +5 -0
- django_libsql_backend-0.1.0.dist-info/licenses/LICENSE +21 -0
- django_libsql_backend-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Django database backend for libSQL/Turso.
|
|
3
|
+
|
|
4
|
+
Provides a Django database backend that communicates with remote libSQL/SQLite
|
|
5
|
+
databases via Turso's HTTP REST API (Hrana protocol over HTTP).
|
|
6
|
+
|
|
7
|
+
Usage in Django settings.py::
|
|
8
|
+
|
|
9
|
+
DATABASES = {
|
|
10
|
+
"default": {
|
|
11
|
+
"ENGINE": "django_libsql",
|
|
12
|
+
"NAME": "https://your-database.turso.io",
|
|
13
|
+
"AUTH_TOKEN": "your-jwt-auth-token",
|
|
14
|
+
"OPTIONS": {
|
|
15
|
+
"timeout": 30,
|
|
16
|
+
},
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
The ``NAME`` can be a full URL (``https://db-name.turso.io``) or a bare
|
|
21
|
+
hostname (``db-name.turso.io``) — ``https://`` is prepended automatically.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
__version__ = "0.1.0"
|
|
25
|
+
|
|
26
|
+
from .base import DatabaseWrapper
|
|
27
|
+
|
|
28
|
+
__all__ = ["DatabaseWrapper", "__version__"]
|
django_libsql/base.py
ADDED
|
@@ -0,0 +1,528 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Django database backend for libSQL/Turso.
|
|
3
|
+
|
|
4
|
+
Supports both remote Turso/libSQL databases via HTTP REST API and local
|
|
5
|
+
SQLite files. Connection type is auto-detected from the NAME setting.
|
|
6
|
+
|
|
7
|
+
Remote (Turso HTTP):
|
|
8
|
+
Each HTTP request is an independent SQLite connection — there is no
|
|
9
|
+
persistent session. Transactions, savepoints, and connection-stateful
|
|
10
|
+
PRAGMAs behave accordingly.
|
|
11
|
+
|
|
12
|
+
Local (sqlite3 file):
|
|
13
|
+
Uses Python's built-in sqlite3 module. Full transaction support, WAL
|
|
14
|
+
mode, and persistent PRAGMA state within a connection.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import sqlite3
|
|
19
|
+
import urllib.request
|
|
20
|
+
import urllib.error
|
|
21
|
+
import re
|
|
22
|
+
from collections.abc import Mapping
|
|
23
|
+
|
|
24
|
+
from sqlite3 import dbapi2 as Database
|
|
25
|
+
|
|
26
|
+
from django.core.exceptions import ImproperlyConfigured
|
|
27
|
+
from django.db.backends.base.base import BaseDatabaseWrapper
|
|
28
|
+
from django.utils.asyncio import async_unsafe
|
|
29
|
+
|
|
30
|
+
from .features import DatabaseFeatures
|
|
31
|
+
from .operations import DatabaseOperations
|
|
32
|
+
from .client import DatabaseClient
|
|
33
|
+
from .creation import DatabaseCreation
|
|
34
|
+
from .introspection import DatabaseIntrospection
|
|
35
|
+
from .schema import DatabaseSchemaEditor
|
|
36
|
+
|
|
37
|
+
FORMAT_QMARK_REGEX = re.compile(r"(?<!%)%s")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _is_local_name(name):
|
|
41
|
+
"""Return True if NAME looks like a local file path, False if remote URL."""
|
|
42
|
+
if not name:
|
|
43
|
+
return False
|
|
44
|
+
if name.startswith(("http://", "https://", "libsql://")):
|
|
45
|
+
return False
|
|
46
|
+
if name.startswith(("/", ".")):
|
|
47
|
+
return True
|
|
48
|
+
if name.endswith((".sqlite3", ".db", ".sqlite", ".s3db", ".sl3")):
|
|
49
|
+
return True
|
|
50
|
+
# Bare hostname like "db.turso.io" — has dots, no path separators,
|
|
51
|
+
# and does not match any known file extension above.
|
|
52
|
+
if "." in name and "/" not in name and "\\" not in name:
|
|
53
|
+
return False
|
|
54
|
+
return True
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _py_value_to_turso_type(value):
|
|
58
|
+
"""Convert a Python value to a Turso typed-value dict."""
|
|
59
|
+
if value is None:
|
|
60
|
+
return {"type": "null"}
|
|
61
|
+
if isinstance(value, bool):
|
|
62
|
+
return {"type": "integer", "value": "1" if value else "0"}
|
|
63
|
+
if isinstance(value, int):
|
|
64
|
+
return {"type": "integer", "value": str(value)}
|
|
65
|
+
if isinstance(value, float):
|
|
66
|
+
return {"type": "real", "value": value}
|
|
67
|
+
if isinstance(value, (bytes, memoryview, bytearray)):
|
|
68
|
+
import base64
|
|
69
|
+
return {
|
|
70
|
+
"type": "blob",
|
|
71
|
+
"value": base64.b64encode(bytes(value)).decode("ascii"),
|
|
72
|
+
}
|
|
73
|
+
return {"type": "text", "value": str(value)}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _turso_value_to_py(cell):
|
|
77
|
+
"""Convert a Turso typed-value dict to a Python value."""
|
|
78
|
+
ctype = cell.get("type", "text")
|
|
79
|
+
value = cell.get("value")
|
|
80
|
+
if ctype == "null" or value is None or (ctype == "text" and value == "NULL"):
|
|
81
|
+
return None if ctype == "null" else value
|
|
82
|
+
if ctype == "integer":
|
|
83
|
+
return int(value)
|
|
84
|
+
if ctype == "real":
|
|
85
|
+
return float(value)
|
|
86
|
+
if ctype == "blob":
|
|
87
|
+
import base64
|
|
88
|
+
return base64.b64decode(value)
|
|
89
|
+
return value
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _build_turso_args(params):
|
|
93
|
+
"""Build Turso-format args list from Python params."""
|
|
94
|
+
if params is None:
|
|
95
|
+
return None
|
|
96
|
+
if isinstance(params, Mapping):
|
|
97
|
+
raise NotImplementedError("Named parameters not supported; use qmark style")
|
|
98
|
+
return [_py_value_to_turso_type(p) for p in params]
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class TursoHTTPConnection:
|
|
102
|
+
"""Minimal HTTP connection to Turso's REST API."""
|
|
103
|
+
|
|
104
|
+
def __init__(self, base_url, auth_token, timeout=30):
|
|
105
|
+
self.base_url = base_url.rstrip("/")
|
|
106
|
+
self.auth_token = auth_token
|
|
107
|
+
self.timeout = timeout
|
|
108
|
+
|
|
109
|
+
def request(self, path, body):
|
|
110
|
+
url = f"{self.base_url}{path}"
|
|
111
|
+
data = json.dumps(body).encode("utf-8")
|
|
112
|
+
req = urllib.request.Request(
|
|
113
|
+
url,
|
|
114
|
+
data=data,
|
|
115
|
+
headers={
|
|
116
|
+
"Content-Type": "application/json",
|
|
117
|
+
"Authorization": f"Bearer {self.auth_token}",
|
|
118
|
+
},
|
|
119
|
+
)
|
|
120
|
+
try:
|
|
121
|
+
resp = urllib.request.urlopen(req, timeout=self.timeout)
|
|
122
|
+
return json.loads(resp.read())
|
|
123
|
+
except urllib.error.HTTPError as e:
|
|
124
|
+
body = e.read().decode(errors="replace")
|
|
125
|
+
raise RuntimeError(f"Turso HTTP {e.code}: {body}") from e
|
|
126
|
+
except urllib.error.URLError as e:
|
|
127
|
+
raise RuntimeError(f"Turso connection error: {e.reason}") from e
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class TursoCursor:
|
|
131
|
+
"""DB-API 2.0 compatible cursor that calls Turso HTTP API."""
|
|
132
|
+
|
|
133
|
+
def __init__(self, connection):
|
|
134
|
+
self.connection = connection
|
|
135
|
+
self._rows = []
|
|
136
|
+
self._columns = ()
|
|
137
|
+
self._index = 0
|
|
138
|
+
self.rowcount = -1
|
|
139
|
+
self.lastrowid = None
|
|
140
|
+
self.description = None
|
|
141
|
+
self._closed = False
|
|
142
|
+
|
|
143
|
+
def _convert_query(self, query):
|
|
144
|
+
"""Convert Django format-style %s to qmark ? for Turso."""
|
|
145
|
+
return FORMAT_QMARK_REGEX.sub("?", query).replace("%%", "%")
|
|
146
|
+
|
|
147
|
+
def execute(self, sql, params=None):
|
|
148
|
+
if self._closed:
|
|
149
|
+
raise RuntimeError("Cursor is closed")
|
|
150
|
+
sql = self._convert_query(sql)
|
|
151
|
+
payload = {"stmt": {"sql": sql}}
|
|
152
|
+
if params:
|
|
153
|
+
payload["stmt"]["args"] = _build_turso_args(params)
|
|
154
|
+
|
|
155
|
+
data = self.connection.request("/v1/execute", payload)
|
|
156
|
+
result = data.get("result", {})
|
|
157
|
+
|
|
158
|
+
self._columns = tuple(c["name"] for c in result.get("cols", []))
|
|
159
|
+
self._rows = [
|
|
160
|
+
tuple(_turso_value_to_py(cell) for cell in row)
|
|
161
|
+
for row in result.get("rows", [])
|
|
162
|
+
]
|
|
163
|
+
self._index = 0
|
|
164
|
+
self.rowcount = result.get("affected_row_count", -1)
|
|
165
|
+
self.lastrowid = result.get("last_insert_rowid")
|
|
166
|
+
|
|
167
|
+
if self._columns:
|
|
168
|
+
self.description = [
|
|
169
|
+
(name, None, None, None, None, None, None) for name in self._columns
|
|
170
|
+
]
|
|
171
|
+
return self
|
|
172
|
+
|
|
173
|
+
def executemany(self, sql, param_list):
|
|
174
|
+
"""Use batch endpoint for multiple parameter sets."""
|
|
175
|
+
if self._closed:
|
|
176
|
+
raise RuntimeError("Cursor is closed")
|
|
177
|
+
sql = self._convert_query(sql)
|
|
178
|
+
steps = [
|
|
179
|
+
{"stmt": {"sql": sql, "args": _build_turso_args(p) or []}}
|
|
180
|
+
for p in param_list
|
|
181
|
+
]
|
|
182
|
+
data = self.connection.request("/v1/batch", {"batch": {"steps": steps}})
|
|
183
|
+
result = data.get("result", {})
|
|
184
|
+
self.rowcount = 0
|
|
185
|
+
for step_result in result.get("step_results", []):
|
|
186
|
+
self.rowcount += step_result.get("affected_row_count", 0)
|
|
187
|
+
self.lastrowid = None
|
|
188
|
+
self._rows = []
|
|
189
|
+
self._columns = ()
|
|
190
|
+
self._index = 0
|
|
191
|
+
self.description = None
|
|
192
|
+
return self
|
|
193
|
+
|
|
194
|
+
def fetchone(self):
|
|
195
|
+
if self._index < len(self._rows):
|
|
196
|
+
row = self._rows[self._index]
|
|
197
|
+
self._index += 1
|
|
198
|
+
return row
|
|
199
|
+
return None
|
|
200
|
+
|
|
201
|
+
def fetchall(self):
|
|
202
|
+
rows = self._rows[self._index:]
|
|
203
|
+
self._index = len(self._rows)
|
|
204
|
+
return rows
|
|
205
|
+
|
|
206
|
+
def fetchmany(self, size=None):
|
|
207
|
+
if size is None:
|
|
208
|
+
size = 1
|
|
209
|
+
rows = self._rows[self._index:self._index + size]
|
|
210
|
+
self._index += len(rows)
|
|
211
|
+
return rows
|
|
212
|
+
|
|
213
|
+
def close(self):
|
|
214
|
+
self._closed = True
|
|
215
|
+
|
|
216
|
+
@property
|
|
217
|
+
def closed(self):
|
|
218
|
+
return self._closed
|
|
219
|
+
|
|
220
|
+
def __iter__(self):
|
|
221
|
+
return self
|
|
222
|
+
|
|
223
|
+
def __next__(self):
|
|
224
|
+
row = self.fetchone()
|
|
225
|
+
if row is None:
|
|
226
|
+
raise StopIteration
|
|
227
|
+
return row
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
class LocalSQLiteCursor:
|
|
231
|
+
"""DB-API 2.0 compatible cursor wrapping a local sqlite3.Cursor."""
|
|
232
|
+
|
|
233
|
+
def __init__(self, sqlite_conn):
|
|
234
|
+
self._cursor = sqlite_conn.cursor()
|
|
235
|
+
self._closed = False
|
|
236
|
+
|
|
237
|
+
def _convert_query(self, query):
|
|
238
|
+
return FORMAT_QMARK_REGEX.sub("?", query).replace("%%", "%")
|
|
239
|
+
|
|
240
|
+
def execute(self, sql, params=None):
|
|
241
|
+
if self._closed:
|
|
242
|
+
raise RuntimeError("Cursor is closed")
|
|
243
|
+
sql = self._convert_query(sql)
|
|
244
|
+
if params:
|
|
245
|
+
self._cursor.execute(sql, params)
|
|
246
|
+
else:
|
|
247
|
+
self._cursor.execute(sql)
|
|
248
|
+
return self
|
|
249
|
+
|
|
250
|
+
def executemany(self, sql, param_list):
|
|
251
|
+
if self._closed:
|
|
252
|
+
raise RuntimeError("Cursor is closed")
|
|
253
|
+
sql = self._convert_query(sql)
|
|
254
|
+
self._cursor.executemany(sql, param_list)
|
|
255
|
+
return self
|
|
256
|
+
|
|
257
|
+
def fetchone(self):
|
|
258
|
+
return self._cursor.fetchone()
|
|
259
|
+
|
|
260
|
+
def fetchall(self):
|
|
261
|
+
return self._cursor.fetchall()
|
|
262
|
+
|
|
263
|
+
def fetchmany(self, size=None):
|
|
264
|
+
return self._cursor.fetchmany(size)
|
|
265
|
+
|
|
266
|
+
def close(self):
|
|
267
|
+
self._closed = True
|
|
268
|
+
self._cursor.close()
|
|
269
|
+
|
|
270
|
+
@property
|
|
271
|
+
def closed(self):
|
|
272
|
+
return self._closed
|
|
273
|
+
|
|
274
|
+
@property
|
|
275
|
+
def rowcount(self):
|
|
276
|
+
return self._cursor.rowcount
|
|
277
|
+
|
|
278
|
+
@property
|
|
279
|
+
def lastrowid(self):
|
|
280
|
+
return self._cursor.lastrowid
|
|
281
|
+
|
|
282
|
+
@property
|
|
283
|
+
def description(self):
|
|
284
|
+
return self._cursor.description
|
|
285
|
+
|
|
286
|
+
def __iter__(self):
|
|
287
|
+
return self
|
|
288
|
+
|
|
289
|
+
def __next__(self):
|
|
290
|
+
row = self.fetchone()
|
|
291
|
+
if row is None:
|
|
292
|
+
raise StopIteration
|
|
293
|
+
return row
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
class DatabaseWrapper(BaseDatabaseWrapper):
|
|
297
|
+
vendor = "libsql"
|
|
298
|
+
display_name = "libSQL (Turso)"
|
|
299
|
+
Database = Database
|
|
300
|
+
|
|
301
|
+
data_types = {
|
|
302
|
+
"AutoField": "integer",
|
|
303
|
+
"BigAutoField": "integer",
|
|
304
|
+
"BinaryField": "BLOB",
|
|
305
|
+
"BooleanField": "bool",
|
|
306
|
+
"CharField": "varchar(%(max_length)s)",
|
|
307
|
+
"DateField": "date",
|
|
308
|
+
"DateTimeField": "datetime",
|
|
309
|
+
"DecimalField": "decimal",
|
|
310
|
+
"DurationField": "bigint",
|
|
311
|
+
"FileField": "varchar(%(max_length)s)",
|
|
312
|
+
"FilePathField": "varchar(%(max_length)s)",
|
|
313
|
+
"FloatField": "real",
|
|
314
|
+
"IntegerField": "integer",
|
|
315
|
+
"BigIntegerField": "bigint",
|
|
316
|
+
"IPAddressField": "char(15)",
|
|
317
|
+
"GenericIPAddressField": "char(39)",
|
|
318
|
+
"JSONField": "text",
|
|
319
|
+
"PositiveBigIntegerField": "bigint unsigned",
|
|
320
|
+
"PositiveIntegerField": "integer unsigned",
|
|
321
|
+
"PositiveSmallIntegerField": "smallint unsigned",
|
|
322
|
+
"SlugField": "varchar(%(max_length)s)",
|
|
323
|
+
"SmallAutoField": "integer",
|
|
324
|
+
"SmallIntegerField": "smallint",
|
|
325
|
+
"TextField": "text",
|
|
326
|
+
"TimeField": "time",
|
|
327
|
+
"UUIDField": "char(32)",
|
|
328
|
+
}
|
|
329
|
+
data_type_check_constraints = {
|
|
330
|
+
"PositiveBigIntegerField": '"%(column)s" >= 0',
|
|
331
|
+
"JSONField": '(JSON_VALID("%(column)s") OR "%(column)s" IS NULL)',
|
|
332
|
+
"PositiveIntegerField": '"%(column)s" >= 0',
|
|
333
|
+
"PositiveSmallIntegerField": '"%(column)s" >= 0',
|
|
334
|
+
}
|
|
335
|
+
data_types_suffix = {
|
|
336
|
+
"AutoField": "AUTOINCREMENT",
|
|
337
|
+
"BigAutoField": "AUTOINCREMENT",
|
|
338
|
+
"SmallAutoField": "AUTOINCREMENT",
|
|
339
|
+
}
|
|
340
|
+
operators = {
|
|
341
|
+
"exact": "= %s",
|
|
342
|
+
"iexact": "LIKE %s ESCAPE '\\'",
|
|
343
|
+
"contains": "LIKE %s ESCAPE '\\'",
|
|
344
|
+
"icontains": "LIKE %s ESCAPE '\\'",
|
|
345
|
+
"regex": "REGEXP %s",
|
|
346
|
+
"iregex": "REGEXP '(?i)' || %s",
|
|
347
|
+
"gt": "> %s",
|
|
348
|
+
"gte": ">= %s",
|
|
349
|
+
"lt": "< %s",
|
|
350
|
+
"lte": "<= %s",
|
|
351
|
+
"startswith": "LIKE %s ESCAPE '\\'",
|
|
352
|
+
"endswith": "LIKE %s ESCAPE '\\'",
|
|
353
|
+
"istartswith": "LIKE %s ESCAPE '\\'",
|
|
354
|
+
"iendswith": "LIKE %s ESCAPE '\\'",
|
|
355
|
+
}
|
|
356
|
+
pattern_esc = (
|
|
357
|
+
r"REPLACE(REPLACE(REPLACE({}, '\', '\\'), '%%', '\%%'), '_', '\_')"
|
|
358
|
+
)
|
|
359
|
+
pattern_ops = {
|
|
360
|
+
"contains": r"LIKE '%%' || {} || '%%' ESCAPE '\'",
|
|
361
|
+
"icontains": r"LIKE '%%' || UPPER({}) || '%%' ESCAPE '\'",
|
|
362
|
+
"startswith": r"LIKE {} || '%%' ESCAPE '\'",
|
|
363
|
+
"istartswith": r"LIKE UPPER({}) || '%%' ESCAPE '\'",
|
|
364
|
+
"endswith": r"LIKE '%%' || {} ESCAPE '\'",
|
|
365
|
+
"iendswith": r"LIKE '%%' || UPPER({}) ESCAPE '\'",
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
SchemaEditorClass = DatabaseSchemaEditor
|
|
369
|
+
client_class = DatabaseClient
|
|
370
|
+
creation_class = DatabaseCreation
|
|
371
|
+
features_class = DatabaseFeatures
|
|
372
|
+
introspection_class = DatabaseIntrospection
|
|
373
|
+
ops_class = DatabaseOperations
|
|
374
|
+
|
|
375
|
+
def __init__(self, settings_dict, alias="default"):
|
|
376
|
+
super().__init__(settings_dict, alias)
|
|
377
|
+
self._http_connection = None
|
|
378
|
+
|
|
379
|
+
def get_connection_params(self):
|
|
380
|
+
settings_dict = self.settings_dict
|
|
381
|
+
name = settings_dict.get("NAME")
|
|
382
|
+
if not name:
|
|
383
|
+
# Django's _nodb_cursor passes NAME=None for test DB teardown.
|
|
384
|
+
# Default to in-memory local SQLite.
|
|
385
|
+
return {"is_local": True, "filepath": ":memory:"}
|
|
386
|
+
if _is_local_name(name):
|
|
387
|
+
# sqlite3.connect() handles relative paths natively — no
|
|
388
|
+
# need to resolve against BASE_DIR.
|
|
389
|
+
return {"is_local": True, "filepath": name}
|
|
390
|
+
# Remote: convert libsql:// → https://, add https:// to bare hostnames
|
|
391
|
+
if name.startswith("libsql://"):
|
|
392
|
+
url = name.replace("libsql://", "https://", 1)
|
|
393
|
+
elif "://" in name:
|
|
394
|
+
url = name
|
|
395
|
+
else:
|
|
396
|
+
url = f"https://{name}"
|
|
397
|
+
return {
|
|
398
|
+
"is_local": False,
|
|
399
|
+
"url": url,
|
|
400
|
+
"auth_token": settings_dict.get("AUTH_TOKEN", ""),
|
|
401
|
+
"timeout": settings_dict.get("OPTIONS", {}).get("timeout", 30),
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
@async_unsafe
|
|
405
|
+
def get_new_connection(self, conn_params):
|
|
406
|
+
if conn_params["is_local"]:
|
|
407
|
+
conn = sqlite3.connect(
|
|
408
|
+
conn_params["filepath"],
|
|
409
|
+
check_same_thread=False,
|
|
410
|
+
)
|
|
411
|
+
conn.execute("PRAGMA journal_mode=WAL")
|
|
412
|
+
conn.execute("PRAGMA foreign_keys=ON")
|
|
413
|
+
return conn
|
|
414
|
+
return TursoHTTPConnection(
|
|
415
|
+
base_url=conn_params["url"],
|
|
416
|
+
auth_token=conn_params["auth_token"],
|
|
417
|
+
timeout=conn_params["timeout"],
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
def init_connection_state(self):
|
|
421
|
+
"""Initialize database connection settings."""
|
|
422
|
+
pass
|
|
423
|
+
|
|
424
|
+
@async_unsafe
|
|
425
|
+
def create_cursor(self, name=None):
|
|
426
|
+
if self.connection is None:
|
|
427
|
+
raise RuntimeError("No connection established")
|
|
428
|
+
if isinstance(self.connection, TursoHTTPConnection):
|
|
429
|
+
return TursoCursor(self.connection)
|
|
430
|
+
return LocalSQLiteCursor(self.connection)
|
|
431
|
+
|
|
432
|
+
def is_usable(self):
|
|
433
|
+
if self.connection is None:
|
|
434
|
+
return False
|
|
435
|
+
try:
|
|
436
|
+
if isinstance(self.connection, TursoHTTPConnection):
|
|
437
|
+
self.connection.request("/v1/execute", {"stmt": {"sql": "SELECT 1"}})
|
|
438
|
+
else:
|
|
439
|
+
self.connection.execute("SELECT 1")
|
|
440
|
+
return True
|
|
441
|
+
except Exception:
|
|
442
|
+
return False
|
|
443
|
+
|
|
444
|
+
def _close(self):
|
|
445
|
+
if self.connection is not None:
|
|
446
|
+
if isinstance(self.connection, TursoHTTPConnection):
|
|
447
|
+
# HTTP connections are stateless — nothing to close.
|
|
448
|
+
pass
|
|
449
|
+
else:
|
|
450
|
+
self.connection.close()
|
|
451
|
+
|
|
452
|
+
def _set_autocommit(self, autocommit):
|
|
453
|
+
# For local SQLite, setting isolation_level=None enables autocommit
|
|
454
|
+
# mode, while setting it to 'DEFERRED' starts implicit transactions.
|
|
455
|
+
if self.connection is not None and not isinstance(
|
|
456
|
+
self.connection, TursoHTTPConnection
|
|
457
|
+
):
|
|
458
|
+
if autocommit:
|
|
459
|
+
self.connection.isolation_level = None
|
|
460
|
+
else:
|
|
461
|
+
self.connection.isolation_level = "DEFERRED"
|
|
462
|
+
|
|
463
|
+
def _start_transaction_under_autocommit(self):
|
|
464
|
+
"""Start an explicit transaction while staying in autocommit mode."""
|
|
465
|
+
if self.connection is not None and not isinstance(
|
|
466
|
+
self.connection, TursoHTTPConnection
|
|
467
|
+
):
|
|
468
|
+
self.connection.execute("BEGIN")
|
|
469
|
+
|
|
470
|
+
def _commit(self):
|
|
471
|
+
if self.connection is not None and not isinstance(
|
|
472
|
+
self.connection, TursoHTTPConnection
|
|
473
|
+
):
|
|
474
|
+
self.connection.commit()
|
|
475
|
+
|
|
476
|
+
def _rollback(self):
|
|
477
|
+
if self.connection is not None and not isinstance(
|
|
478
|
+
self.connection, TursoHTTPConnection
|
|
479
|
+
):
|
|
480
|
+
self.connection.rollback()
|
|
481
|
+
|
|
482
|
+
def disable_constraint_checking(self):
|
|
483
|
+
"""Disable FK checks via PRAGMA. Returns True if successfully disabled."""
|
|
484
|
+
with self.cursor() as cursor:
|
|
485
|
+
cursor.execute("PRAGMA foreign_keys")
|
|
486
|
+
was_enabled = bool(cursor.fetchone()[0])
|
|
487
|
+
if was_enabled:
|
|
488
|
+
cursor.execute("PRAGMA foreign_keys = OFF")
|
|
489
|
+
return was_enabled
|
|
490
|
+
|
|
491
|
+
def enable_constraint_checking(self):
|
|
492
|
+
"""Re-enable FK checks."""
|
|
493
|
+
with self.cursor() as cursor:
|
|
494
|
+
cursor.execute("PRAGMA foreign_keys = ON")
|
|
495
|
+
|
|
496
|
+
def check_constraints(self, table_names=None):
|
|
497
|
+
with self.cursor() as cursor:
|
|
498
|
+
if table_names is None:
|
|
499
|
+
violations = cursor.execute("PRAGMA foreign_key_check").fetchall()
|
|
500
|
+
else:
|
|
501
|
+
from itertools import chain
|
|
502
|
+
violations = chain.from_iterable(
|
|
503
|
+
cursor.execute(
|
|
504
|
+
'PRAGMA foreign_key_check("%s")' % table_name
|
|
505
|
+
).fetchall()
|
|
506
|
+
for table_name in table_names
|
|
507
|
+
)
|
|
508
|
+
for (table_name, rowid, ref_table, fk_idx) in violations:
|
|
509
|
+
raise RuntimeError(
|
|
510
|
+
f"Foreign key violation in table '{table_name}', "
|
|
511
|
+
f"rowid={rowid}"
|
|
512
|
+
)
|
|
513
|
+
|
|
514
|
+
def is_in_memory_db(self):
|
|
515
|
+
return False
|
|
516
|
+
|
|
517
|
+
def get_database_version(self):
|
|
518
|
+
if self.connection is not None and not isinstance(
|
|
519
|
+
self.connection, TursoHTTPConnection
|
|
520
|
+
):
|
|
521
|
+
return sqlite3.sqlite_version_info[:3]
|
|
522
|
+
with self.cursor() as cursor:
|
|
523
|
+
cursor.execute("SELECT sqlite_version()")
|
|
524
|
+
row = cursor.fetchone()
|
|
525
|
+
if row:
|
|
526
|
+
parts = row[0].split(".")
|
|
527
|
+
return tuple(int(p) for p in parts[:3])
|
|
528
|
+
return (3, 0, 0)
|
django_libsql/client.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Database client for the libSQL/Turso backend.
|
|
3
|
+
|
|
4
|
+
Provides a shell entry point for Turso databases.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from django.db.backends.base.client import BaseDatabaseClient
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class DatabaseClient(BaseDatabaseClient):
|
|
11
|
+
executable_name = "turso"
|
|
12
|
+
|
|
13
|
+
@classmethod
|
|
14
|
+
def settings_to_cmd_args_env(cls, settings_dict, parameters):
|
|
15
|
+
args = [cls.executable_name, "db", "shell", settings_dict["NAME"]]
|
|
16
|
+
if settings_dict.get("AUTH_TOKEN"):
|
|
17
|
+
args.extend(["--token", settings_dict["AUTH_TOKEN"]])
|
|
18
|
+
if parameters:
|
|
19
|
+
args.extend(parameters)
|
|
20
|
+
return args, None
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Database creation for the libSQL/Turso backend.
|
|
3
|
+
|
|
4
|
+
Since Turso databases are provisioned externally (not created via
|
|
5
|
+
Django), this module provides basic test-database creation that
|
|
6
|
+
reuses the production connection or creates a separate database.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from django.db.backends.base.creation import BaseDatabaseCreation
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class DatabaseCreation(BaseDatabaseCreation):
|
|
13
|
+
def _get_test_db_name(self):
|
|
14
|
+
return self.connection.settings_dict["NAME"]
|
|
15
|
+
|
|
16
|
+
def create_test_db(self, verbosity=1, autoclobber=False, keepdb=False):
|
|
17
|
+
if not keepdb:
|
|
18
|
+
self._destroy_test_db(verbosity=verbosity)
|
|
19
|
+
return self.connection.settings_dict["NAME"]
|
|
20
|
+
|
|
21
|
+
def destroy_test_db(self, old_database_name, verbosity=1, keepdb=False):
|
|
22
|
+
self._destroy_test_db(verbosity=verbosity)
|
|
23
|
+
|
|
24
|
+
def _destroy_test_db(self, verbosity=1):
|
|
25
|
+
with self.connection._nodb_cursor() as cursor:
|
|
26
|
+
cursor.execute(
|
|
27
|
+
"SELECT name FROM sqlite_master WHERE type='table' AND "
|
|
28
|
+
"name NOT LIKE 'sqlite_%%' AND name NOT LIKE '_%%'"
|
|
29
|
+
)
|
|
30
|
+
tables = [row[0] for row in cursor.fetchall()]
|
|
31
|
+
for table in tables:
|
|
32
|
+
cursor.execute(f'DROP TABLE IF EXISTS "{table}"')
|
|
33
|
+
|
|
34
|
+
def test_db_signature(self):
|
|
35
|
+
settings_dict = self.connection.settings_dict
|
|
36
|
+
return (
|
|
37
|
+
self.connection.settings_dict["NAME"],
|
|
38
|
+
settings_dict.get("AUTH_TOKEN", ""),
|
|
39
|
+
)
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Features for the libSQL/Turso backend — SQLite-compatible, remote HTTP."""
|
|
2
|
+
|
|
3
|
+
import operator
|
|
4
|
+
|
|
5
|
+
from django.db.backends.base.features import BaseDatabaseFeatures
|
|
6
|
+
from django.utils.functional import cached_property
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class DatabaseFeatures(BaseDatabaseFeatures):
|
|
10
|
+
minimum_database_version = (3, 31)
|
|
11
|
+
test_db_allows_multiple_connections = True
|
|
12
|
+
supports_unspecified_pk = True
|
|
13
|
+
supports_timezones = False
|
|
14
|
+
atomic_transactions = False
|
|
15
|
+
can_rollback_ddl = True
|
|
16
|
+
can_create_inline_fk = False
|
|
17
|
+
requires_literal_defaults = True
|
|
18
|
+
can_clone_databases = False
|
|
19
|
+
supports_temporal_subtraction = True
|
|
20
|
+
ignores_table_name_case = True
|
|
21
|
+
supports_cast_with_precision = False
|
|
22
|
+
time_cast_precision = 3
|
|
23
|
+
can_release_savepoints = True
|
|
24
|
+
has_case_insensitive_like = True
|
|
25
|
+
supports_parentheses_in_compound = False
|
|
26
|
+
can_defer_constraint_checks = True
|
|
27
|
+
supports_over_clause = True
|
|
28
|
+
supports_frame_range_fixed_distance = True
|
|
29
|
+
supports_frame_exclusion = True
|
|
30
|
+
supports_aggregate_filter_clause = True
|
|
31
|
+
supports_aggregate_order_by_clause = True
|
|
32
|
+
supports_json_field_contains = False
|
|
33
|
+
supports_update_conflicts = True
|
|
34
|
+
supports_update_conflicts_with_target = True
|
|
35
|
+
order_by_nulls_first = True
|
|
36
|
+
supports_index_on_text_field = True
|
|
37
|
+
supports_stored_generated_columns = True
|
|
38
|
+
supports_virtual_generated_columns = True
|
|
39
|
+
can_alter_table_drop_column = True
|
|
40
|
+
supports_transactions = True
|
|
41
|
+
supports_unlimited_charfield = True
|
|
42
|
+
supports_any_value = True
|
|
43
|
+
supports_aggregate_distinct_multiple_argument = False
|
|
44
|
+
supports_default_keyword_in_insert = False
|
|
45
|
+
insert_test_table_with_defaults = 'INSERT INTO {} ("null") VALUES (1)'
|
|
46
|
+
|
|
47
|
+
test_collations = {
|
|
48
|
+
"ci": "nocase",
|
|
49
|
+
"cs": "binary",
|
|
50
|
+
"non_default": "nocase",
|
|
51
|
+
}
|
|
52
|
+
django_test_expected_failures = set()
|
|
53
|
+
|
|
54
|
+
@cached_property
|
|
55
|
+
def introspected_field_types(self):
|
|
56
|
+
return {
|
|
57
|
+
**super().introspected_field_types,
|
|
58
|
+
"BigAutoField": "AutoField",
|
|
59
|
+
"DurationField": "BigIntegerField",
|
|
60
|
+
"GenericIPAddressField": "CharField",
|
|
61
|
+
"SmallAutoField": "AutoField",
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
@cached_property
|
|
65
|
+
def supports_json_field(self):
|
|
66
|
+
return True
|
|
67
|
+
|
|
68
|
+
can_introspect_json_field = property(operator.attrgetter("supports_json_field"))
|
|
69
|
+
has_json_object_function = property(operator.attrgetter("supports_json_field"))
|
|
70
|
+
|
|
71
|
+
@cached_property
|
|
72
|
+
def can_return_columns_from_insert(self):
|
|
73
|
+
return True
|
|
74
|
+
|
|
75
|
+
can_return_rows_from_bulk_insert = property(
|
|
76
|
+
operator.attrgetter("can_return_columns_from_insert")
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
can_return_rows_from_update = property(
|
|
80
|
+
operator.attrgetter("can_return_columns_from_insert")
|
|
81
|
+
)
|