dclassql 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.
- dclassql/__init__.py +12 -0
- dclassql/cli.py +123 -0
- dclassql/codegen.py +508 -0
- dclassql/db_pool.py +76 -0
- dclassql/model_inspector.py +383 -0
- dclassql/push/__init__.py +60 -0
- dclassql/push/base.py +417 -0
- dclassql/push/sqlite.py +195 -0
- dclassql/runtime/backends.py +669 -0
- dclassql/runtime/datasource.py +39 -0
- dclassql/table_spec.py +142 -0
- dclassql/unwarp.py +12 -0
- dclassql-0.1.0.dist-info/METADATA +164 -0
- dclassql-0.1.0.dist-info/RECORD +16 -0
- dclassql-0.1.0.dist-info/WHEEL +4 -0
- dclassql-0.1.0.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,669 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sqlite3
|
|
4
|
+
import sys
|
|
5
|
+
import threading
|
|
6
|
+
from dataclasses import MISSING, dataclass, fields, is_dataclass
|
|
7
|
+
from typing import (
|
|
8
|
+
Any,
|
|
9
|
+
Callable,
|
|
10
|
+
Mapping,
|
|
11
|
+
Protocol,
|
|
12
|
+
Sequence,
|
|
13
|
+
cast,
|
|
14
|
+
runtime_checkable,
|
|
15
|
+
get_origin,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
from weakref import WeakKeyDictionary
|
|
19
|
+
|
|
20
|
+
from pypika import Query, Table
|
|
21
|
+
from pypika.enums import Order
|
|
22
|
+
from pypika.terms import Parameter
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
ConnectionFactory = Callable[[], sqlite3.Connection]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(slots=True)
|
|
29
|
+
class RelationSpec:
|
|
30
|
+
name: str
|
|
31
|
+
table_name: str
|
|
32
|
+
table_module: str
|
|
33
|
+
many: bool
|
|
34
|
+
mapping: tuple[tuple[str, str], ...]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@runtime_checkable
|
|
38
|
+
class TableProtocol[ModelT, InsertT, WhereT](Protocol):
|
|
39
|
+
def __init__(self, backend: BackendProtocol[ModelT, InsertT, WhereT]) -> None: ...
|
|
40
|
+
|
|
41
|
+
model: type[ModelT]
|
|
42
|
+
insert_model: type[InsertT]
|
|
43
|
+
columns: tuple[str, ...]
|
|
44
|
+
auto_increment_columns: tuple[str, ...]
|
|
45
|
+
primary_key: tuple[str, ...]
|
|
46
|
+
relations: tuple[RelationSpec, ...]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@runtime_checkable
|
|
50
|
+
class BackendProtocol[ModelT, InsertT, WhereT](Protocol):
|
|
51
|
+
def insert(self, table: TableProtocol[ModelT, InsertT, WhereT], data: InsertT | Mapping[str, object]) -> ModelT: ...
|
|
52
|
+
|
|
53
|
+
def insert_many(
|
|
54
|
+
self,
|
|
55
|
+
table: TableProtocol[ModelT, InsertT, WhereT],
|
|
56
|
+
data: Sequence[InsertT | Mapping[str, object]],
|
|
57
|
+
*,
|
|
58
|
+
batch_size: int | None = None,
|
|
59
|
+
) -> list[ModelT]: ...
|
|
60
|
+
|
|
61
|
+
def find_many(
|
|
62
|
+
self,
|
|
63
|
+
table: TableProtocol[ModelT, InsertT, WhereT],
|
|
64
|
+
*,
|
|
65
|
+
where: WhereT | None = None,
|
|
66
|
+
include: Mapping[str, bool] | None = None,
|
|
67
|
+
order_by: Sequence[tuple[str, str]] | None = None,
|
|
68
|
+
take: int | None = None,
|
|
69
|
+
skip: int | None = None,
|
|
70
|
+
) -> list[ModelT]: ...
|
|
71
|
+
|
|
72
|
+
def find_first(
|
|
73
|
+
self,
|
|
74
|
+
table: TableProtocol[ModelT, InsertT, WhereT],
|
|
75
|
+
*,
|
|
76
|
+
where: WhereT | None = None,
|
|
77
|
+
include: Mapping[str, bool] | None = None,
|
|
78
|
+
order_by: Sequence[tuple[str, str]] | None = None,
|
|
79
|
+
skip: int | None = None,
|
|
80
|
+
) -> ModelT | None: ...
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass(slots=True)
|
|
84
|
+
class LazyRelationState:
|
|
85
|
+
attribute: str
|
|
86
|
+
backend: BackendProtocol[Any, Any, Mapping[str, object]]
|
|
87
|
+
table_cls: type[Any]
|
|
88
|
+
mapping: tuple[tuple[str, str], ...]
|
|
89
|
+
many: bool
|
|
90
|
+
loaded: bool = False
|
|
91
|
+
value: Any = None
|
|
92
|
+
loading: bool = False
|
|
93
|
+
_LAZY_CLASS_CACHE: dict[type[Any], type[Any]] = {}
|
|
94
|
+
_LAZY_RELATION_STATE: WeakKeyDictionary[Any, dict[str, LazyRelationState]] = WeakKeyDictionary()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _resolve_lazy_relation(instance: Any, state: LazyRelationState) -> Any:
|
|
98
|
+
if state.loaded:
|
|
99
|
+
return state.value
|
|
100
|
+
if state.loading:
|
|
101
|
+
return state.value
|
|
102
|
+
state.loading = True
|
|
103
|
+
state.value = [] if state.many else None
|
|
104
|
+
where: dict[str, object] = {}
|
|
105
|
+
for owner_column, target_column in state.mapping:
|
|
106
|
+
owner_value = getattr(instance, owner_column, None)
|
|
107
|
+
if owner_value is None:
|
|
108
|
+
value: Any = [] if state.many else None
|
|
109
|
+
state.loaded = True
|
|
110
|
+
state.value = value
|
|
111
|
+
object.__setattr__(instance, state.attribute, value)
|
|
112
|
+
state.loading = False
|
|
113
|
+
return value
|
|
114
|
+
where[target_column] = owner_value
|
|
115
|
+
|
|
116
|
+
table = state.table_cls(state.backend)
|
|
117
|
+
if state.many:
|
|
118
|
+
loaded = table.find_many(where=cast(Mapping[str, object], where))
|
|
119
|
+
else:
|
|
120
|
+
loaded = table.find_first(where=cast(Mapping[str, object], where))
|
|
121
|
+
|
|
122
|
+
if state.many and loaded is None:
|
|
123
|
+
loaded = []
|
|
124
|
+
|
|
125
|
+
state.loaded = True
|
|
126
|
+
state.value = loaded
|
|
127
|
+
state.loading = False
|
|
128
|
+
object.__setattr__(instance, state.attribute, loaded)
|
|
129
|
+
return loaded
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _ensure_lazy_model_class(model_cls: type[Any]) -> type[Any]:
|
|
133
|
+
cached = _LAZY_CLASS_CACHE.get(model_cls)
|
|
134
|
+
if cached is not None:
|
|
135
|
+
return cached
|
|
136
|
+
|
|
137
|
+
base_getattribute = cast(Callable[[Any, str], Any], model_cls.__getattribute__)
|
|
138
|
+
base_setattr = cast(Callable[[Any, str, Any], None], model_cls.__setattr__)
|
|
139
|
+
|
|
140
|
+
def __getattribute__(self: Any, name: str) -> Any:
|
|
141
|
+
state_map = _LAZY_RELATION_STATE.get(self)
|
|
142
|
+
if state_map is not None:
|
|
143
|
+
state = state_map.get(name)
|
|
144
|
+
if state is not None:
|
|
145
|
+
if not state.loaded:
|
|
146
|
+
_resolve_lazy_relation(self, state)
|
|
147
|
+
return state.value
|
|
148
|
+
return base_getattribute(self, name)
|
|
149
|
+
|
|
150
|
+
def __setattr__(self: Any, name: str, value: Any) -> None:
|
|
151
|
+
state_map = _LAZY_RELATION_STATE.get(self)
|
|
152
|
+
if state_map is not None:
|
|
153
|
+
state = state_map.get(name)
|
|
154
|
+
if state is not None:
|
|
155
|
+
state.loaded = True
|
|
156
|
+
state.value = value
|
|
157
|
+
base_setattr(self, name, value)
|
|
158
|
+
|
|
159
|
+
namespace: dict[str, Any] = {
|
|
160
|
+
"__slots__": (),
|
|
161
|
+
"__lazy_base__": model_cls,
|
|
162
|
+
"__getattribute__": __getattribute__,
|
|
163
|
+
"__setattr__": __setattr__,
|
|
164
|
+
"__hash__": object.__hash__,
|
|
165
|
+
}
|
|
166
|
+
lazy_cls = type(f"{model_cls.__name__}LazyRuntime", (model_cls,), namespace)
|
|
167
|
+
try:
|
|
168
|
+
lazy_cls.__name__ = model_cls.__name__
|
|
169
|
+
except AttributeError:
|
|
170
|
+
pass
|
|
171
|
+
try:
|
|
172
|
+
lazy_cls.__qualname__ = model_cls.__qualname__
|
|
173
|
+
except AttributeError:
|
|
174
|
+
pass
|
|
175
|
+
lazy_cls.__module__ = model_cls.__module__
|
|
176
|
+
_LAZY_CLASS_CACHE[model_cls] = lazy_cls
|
|
177
|
+
return lazy_cls
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _find_backref_relations(
|
|
181
|
+
table: TableProtocol[Any, Any, Any],
|
|
182
|
+
existing_names: set[str],
|
|
183
|
+
) -> list[RelationSpec]:
|
|
184
|
+
model = table.model
|
|
185
|
+
module_name = table.__class__.__module__
|
|
186
|
+
module = sys.modules.get(module_name)
|
|
187
|
+
if module is None:
|
|
188
|
+
return []
|
|
189
|
+
|
|
190
|
+
extras: list[RelationSpec] = []
|
|
191
|
+
for attr in dir(module):
|
|
192
|
+
candidate = getattr(module, attr, None)
|
|
193
|
+
if not isinstance(candidate, type):
|
|
194
|
+
continue
|
|
195
|
+
if candidate is table.__class__:
|
|
196
|
+
continue
|
|
197
|
+
foreign_keys = getattr(candidate, "foreign_keys", None)
|
|
198
|
+
if not foreign_keys:
|
|
199
|
+
continue
|
|
200
|
+
candidate_model = getattr(candidate, "model", None)
|
|
201
|
+
if candidate_model is None:
|
|
202
|
+
continue
|
|
203
|
+
for fk in foreign_keys:
|
|
204
|
+
if fk.backref is None:
|
|
205
|
+
continue
|
|
206
|
+
if fk.remote_model is not model:
|
|
207
|
+
continue
|
|
208
|
+
name = fk.backref
|
|
209
|
+
if name in existing_names:
|
|
210
|
+
continue
|
|
211
|
+
mapping_pairs: tuple[tuple[str, str], ...] = tuple(
|
|
212
|
+
(remote, local) for remote, local in zip(fk.remote_columns, fk.local_columns)
|
|
213
|
+
)
|
|
214
|
+
primary_key = getattr(candidate, "primary_key", ())
|
|
215
|
+
many = tuple(primary_key) != tuple(fk.local_columns)
|
|
216
|
+
extras.append(
|
|
217
|
+
RelationSpec(
|
|
218
|
+
name=name,
|
|
219
|
+
table_name=candidate.__name__,
|
|
220
|
+
table_module=candidate.__module__,
|
|
221
|
+
many=many,
|
|
222
|
+
mapping=mapping_pairs,
|
|
223
|
+
)
|
|
224
|
+
)
|
|
225
|
+
existing_names.add(name)
|
|
226
|
+
return extras
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
class SQLiteBackend[ModelT, InsertT, WhereT: Mapping[str, object]](BackendProtocol[ModelT, InsertT, WhereT]):
|
|
230
|
+
def __init__(self, source: sqlite3.Connection | ConnectionFactory | "SQLiteBackend") -> None:
|
|
231
|
+
if isinstance(source, SQLiteBackend):
|
|
232
|
+
self._factory: ConnectionFactory | None = source._factory
|
|
233
|
+
self._connection: sqlite3.Connection | None = source._connection
|
|
234
|
+
self._local = source._local
|
|
235
|
+
self._identity_map = source._identity_map
|
|
236
|
+
elif isinstance(source, sqlite3.Connection):
|
|
237
|
+
self._factory = None
|
|
238
|
+
self._connection = source
|
|
239
|
+
self._ensure_row_factory(self._connection)
|
|
240
|
+
self._local = threading.local()
|
|
241
|
+
self._identity_map: dict[tuple[type[Any], tuple[Any, ...]], Any] = {}
|
|
242
|
+
elif callable(source):
|
|
243
|
+
self._factory = source
|
|
244
|
+
self._connection = None
|
|
245
|
+
self._local = threading.local()
|
|
246
|
+
self._identity_map = {}
|
|
247
|
+
else:
|
|
248
|
+
raise TypeError("SQLite backend source must be connection or callable returning connection")
|
|
249
|
+
|
|
250
|
+
def insert(self, table: TableProtocol[ModelT, InsertT, WhereT], data: InsertT | Mapping[str, object]) -> ModelT:
|
|
251
|
+
payload = self._normalize_insert_payload(table, data)
|
|
252
|
+
if not payload:
|
|
253
|
+
raise ValueError("Insert payload cannot be empty")
|
|
254
|
+
|
|
255
|
+
table_name = table.model.__name__
|
|
256
|
+
sql_table = Table(table_name)
|
|
257
|
+
columns = list(payload.keys())
|
|
258
|
+
params = [payload[name] for name in columns]
|
|
259
|
+
|
|
260
|
+
insert_query = (
|
|
261
|
+
Query.into(sql_table)
|
|
262
|
+
.columns(*columns)
|
|
263
|
+
.insert(*(Parameter("?") for _ in columns))
|
|
264
|
+
)
|
|
265
|
+
sql = insert_query.get_sql(quote_char='"') + ";"
|
|
266
|
+
cursor = self._execute(sql, params)
|
|
267
|
+
|
|
268
|
+
pk_filter: dict[str, object] = {}
|
|
269
|
+
auto_increment = set(getattr(table, "auto_increment_columns", ()))
|
|
270
|
+
primary_key = getattr(table, "primary_key", ())
|
|
271
|
+
if not primary_key:
|
|
272
|
+
raise ValueError(f"Table {table_name} does not define primary key")
|
|
273
|
+
if len(primary_key) == 1:
|
|
274
|
+
pk = primary_key[0]
|
|
275
|
+
value = payload.get(pk)
|
|
276
|
+
if value is None and pk in auto_increment:
|
|
277
|
+
value = cursor.lastrowid
|
|
278
|
+
if value is None:
|
|
279
|
+
raise ValueError(f"Primary key column '{pk}' is null after insert")
|
|
280
|
+
pk_filter[pk] = value
|
|
281
|
+
else:
|
|
282
|
+
for pk in primary_key:
|
|
283
|
+
value = payload.get(pk)
|
|
284
|
+
if value is None:
|
|
285
|
+
raise ValueError(f"Composite key requires '{pk}' value")
|
|
286
|
+
pk_filter[pk] = value
|
|
287
|
+
|
|
288
|
+
result = self._fetch_single(table, pk_filter, include=None)
|
|
289
|
+
self._invalidate_backrefs(table, result)
|
|
290
|
+
return result
|
|
291
|
+
|
|
292
|
+
def insert_many(
|
|
293
|
+
self,
|
|
294
|
+
table: TableProtocol[ModelT, InsertT, WhereT],
|
|
295
|
+
data: Sequence[InsertT | Mapping[str, object]],
|
|
296
|
+
*,
|
|
297
|
+
batch_size: int | None = None,
|
|
298
|
+
) -> list[ModelT]:
|
|
299
|
+
items = list(data)
|
|
300
|
+
if not items:
|
|
301
|
+
return []
|
|
302
|
+
|
|
303
|
+
payloads = [self._normalize_insert_payload(table, item) for item in items]
|
|
304
|
+
columns = list(payloads[0].keys())
|
|
305
|
+
if not columns:
|
|
306
|
+
raise ValueError("Insert payload cannot be empty")
|
|
307
|
+
|
|
308
|
+
params_matrix = [[payload.get(column) for column in columns] for payload in payloads]
|
|
309
|
+
table_name = table.model.__name__
|
|
310
|
+
sql_table = Table(table_name)
|
|
311
|
+
insert_query = (
|
|
312
|
+
Query.into(sql_table)
|
|
313
|
+
.columns(*columns)
|
|
314
|
+
.insert(*(Parameter("?") for _ in columns))
|
|
315
|
+
)
|
|
316
|
+
sql = insert_query.get_sql(quote_char='"') + ';'
|
|
317
|
+
|
|
318
|
+
pk_columns = list(table.primary_key)
|
|
319
|
+
if not pk_columns:
|
|
320
|
+
raise ValueError(f"Table {table_name} does not define primary key")
|
|
321
|
+
auto_increment = set(table.auto_increment_columns)
|
|
322
|
+
|
|
323
|
+
results: list[ModelT] = []
|
|
324
|
+
step = batch_size if batch_size and batch_size > 0 else len(payloads)
|
|
325
|
+
start = 0
|
|
326
|
+
connection = self._acquire_connection()
|
|
327
|
+
while start < len(payloads):
|
|
328
|
+
end = min(start + step, len(payloads))
|
|
329
|
+
subset_payloads = payloads[start:end]
|
|
330
|
+
subset_params = params_matrix[start:end]
|
|
331
|
+
if not subset_params:
|
|
332
|
+
start = end
|
|
333
|
+
continue
|
|
334
|
+
cursor = connection.executemany(sql, [tuple(param) for param in subset_params])
|
|
335
|
+
connection.commit()
|
|
336
|
+
generated_start: int | None = None
|
|
337
|
+
if len(pk_columns) == 1 and pk_columns[0] in auto_increment:
|
|
338
|
+
last_rowid_result = connection.execute("SELECT last_insert_rowid()").fetchone()
|
|
339
|
+
if last_rowid_result is None or last_rowid_result[0] is None:
|
|
340
|
+
raise RuntimeError("Unable to determine lastrowid for bulk insert")
|
|
341
|
+
last_id = int(last_rowid_result[0])
|
|
342
|
+
generated_start = last_id - len(subset_payloads) + 1
|
|
343
|
+
|
|
344
|
+
for offset, payload in enumerate(subset_payloads):
|
|
345
|
+
pk_filter: dict[str, object] = {}
|
|
346
|
+
for pk in pk_columns:
|
|
347
|
+
value = payload.get(pk)
|
|
348
|
+
if value is None and generated_start is not None and pk == pk_columns[0]:
|
|
349
|
+
value = generated_start + offset
|
|
350
|
+
payload[pk] = value
|
|
351
|
+
if value is None:
|
|
352
|
+
raise ValueError(f"Primary key column '{pk}' is null after insert")
|
|
353
|
+
pk_filter[pk] = value
|
|
354
|
+
instance = self._fetch_single(table, pk_filter, include=None)
|
|
355
|
+
self._invalidate_backrefs(table, instance)
|
|
356
|
+
results.append(instance)
|
|
357
|
+
start = end
|
|
358
|
+
return results
|
|
359
|
+
|
|
360
|
+
def find_many(
|
|
361
|
+
self,
|
|
362
|
+
table: TableProtocol[ModelT, InsertT, WhereT],
|
|
363
|
+
*,
|
|
364
|
+
where: WhereT | None = None,
|
|
365
|
+
include: Mapping[str, bool] | None = None,
|
|
366
|
+
order_by: Sequence[tuple[str, str]] | None = None,
|
|
367
|
+
take: int | None = None,
|
|
368
|
+
skip: int | None = None,
|
|
369
|
+
) -> list[ModelT]:
|
|
370
|
+
table_name = table.model.__name__
|
|
371
|
+
sql_table = Table(table_name)
|
|
372
|
+
select_query = Query.from_(sql_table).select(*[sql_table.field(col) for col in table.columns])
|
|
373
|
+
params: list[Any] = []
|
|
374
|
+
|
|
375
|
+
if where:
|
|
376
|
+
for column, value in where.items():
|
|
377
|
+
if column not in table.columns:
|
|
378
|
+
raise KeyError(f"Unknown column '{column}' in where clause")
|
|
379
|
+
field = sql_table.field(column)
|
|
380
|
+
if value is None:
|
|
381
|
+
select_query = select_query.where(field.isnull())
|
|
382
|
+
else:
|
|
383
|
+
select_query = select_query.where(field == Parameter("?"))
|
|
384
|
+
params.append(value)
|
|
385
|
+
|
|
386
|
+
if order_by:
|
|
387
|
+
for column, direction in order_by:
|
|
388
|
+
if column not in table.columns:
|
|
389
|
+
raise KeyError(f"Unknown column '{column}' in order_by clause")
|
|
390
|
+
direction_lower = direction.lower()
|
|
391
|
+
if direction_lower not in {"asc", "desc"}:
|
|
392
|
+
raise ValueError("order_by direction must be 'asc' or 'desc'")
|
|
393
|
+
select_query = select_query.orderby(sql_table.field(column), order=Order[direction_lower])
|
|
394
|
+
|
|
395
|
+
if skip is not None:
|
|
396
|
+
select_query = select_query.offset(skip)
|
|
397
|
+
if take is not None:
|
|
398
|
+
select_query = select_query.limit(take)
|
|
399
|
+
|
|
400
|
+
sql = select_query.get_sql(quote_char='"') + ";"
|
|
401
|
+
rows = self._fetch_all(sql, params)
|
|
402
|
+
include_map = include or {}
|
|
403
|
+
return [self._row_to_model(table, row, include_map) for row in rows]
|
|
404
|
+
|
|
405
|
+
def find_first(
|
|
406
|
+
self,
|
|
407
|
+
table: TableProtocol[ModelT, InsertT, WhereT],
|
|
408
|
+
*,
|
|
409
|
+
where: WhereT | None = None,
|
|
410
|
+
include: Mapping[str, bool] | None = None,
|
|
411
|
+
order_by: Sequence[tuple[str, str]] | None = None,
|
|
412
|
+
skip: int | None = None,
|
|
413
|
+
) -> ModelT | None:
|
|
414
|
+
results = self.find_many(
|
|
415
|
+
table,
|
|
416
|
+
where=where,
|
|
417
|
+
include=include,
|
|
418
|
+
order_by=order_by,
|
|
419
|
+
take=1,
|
|
420
|
+
skip=skip,
|
|
421
|
+
)
|
|
422
|
+
return results[0] if results else None
|
|
423
|
+
|
|
424
|
+
def _fetch_single(
|
|
425
|
+
self,
|
|
426
|
+
table: TableProtocol[ModelT, InsertT, WhereT],
|
|
427
|
+
where: Mapping[str, object],
|
|
428
|
+
include: Mapping[str, bool] | None,
|
|
429
|
+
) -> ModelT:
|
|
430
|
+
results = self.find_many(table, where=cast(WhereT, where), include=include)
|
|
431
|
+
if not results:
|
|
432
|
+
raise RuntimeError("Inserted row could not be reloaded")
|
|
433
|
+
return results[0]
|
|
434
|
+
|
|
435
|
+
def _normalize_insert_payload(
|
|
436
|
+
self,
|
|
437
|
+
table: TableProtocol[ModelT, InsertT, WhereT],
|
|
438
|
+
data: InsertT | Mapping[str, object],
|
|
439
|
+
) -> dict[str, object]:
|
|
440
|
+
allowed = set(table.columns)
|
|
441
|
+
if isinstance(data, dict):
|
|
442
|
+
return {k: v for k, v in data.items() if k in allowed}
|
|
443
|
+
insert_model = getattr(table, "insert_model", None)
|
|
444
|
+
if insert_model and isinstance(data, insert_model):
|
|
445
|
+
return {column: getattr(data, column) for column in table.columns if hasattr(data, column)}
|
|
446
|
+
if is_dataclass(data):
|
|
447
|
+
return {column: getattr(data, column) for column in table.columns if hasattr(data, column)}
|
|
448
|
+
raise TypeError("Unsupported insert payload type")
|
|
449
|
+
|
|
450
|
+
def _fetch_all(self, sql: str, params: Sequence[Any]) -> list[sqlite3.Row]:
|
|
451
|
+
cursor = self._execute(sql, params)
|
|
452
|
+
return cursor.fetchall()
|
|
453
|
+
|
|
454
|
+
def _execute(self, sql: str, params: Sequence[Any], auto_commit: bool = True) -> sqlite3.Cursor:
|
|
455
|
+
connection = self._acquire_connection()
|
|
456
|
+
cursor = connection.execute(sql, tuple(params))
|
|
457
|
+
if auto_commit:
|
|
458
|
+
connection.commit()
|
|
459
|
+
return cursor
|
|
460
|
+
|
|
461
|
+
def _acquire_connection(self) -> sqlite3.Connection:
|
|
462
|
+
if self._factory is None:
|
|
463
|
+
assert self._connection is not None
|
|
464
|
+
self._ensure_row_factory(self._connection)
|
|
465
|
+
return self._connection
|
|
466
|
+
|
|
467
|
+
connection = getattr(self._local, "connection", None)
|
|
468
|
+
if connection is None:
|
|
469
|
+
connection = self._factory()
|
|
470
|
+
if not isinstance(connection, sqlite3.Connection):
|
|
471
|
+
raise TypeError("SQLite backend factory must return sqlite3.Connection")
|
|
472
|
+
self._ensure_row_factory(connection)
|
|
473
|
+
self._local.connection = connection
|
|
474
|
+
return connection
|
|
475
|
+
|
|
476
|
+
@staticmethod
|
|
477
|
+
def _ensure_row_factory(connection: sqlite3.Connection) -> None:
|
|
478
|
+
if connection.row_factory is None:
|
|
479
|
+
connection.row_factory = sqlite3.Row
|
|
480
|
+
|
|
481
|
+
def _row_to_model(
|
|
482
|
+
self,
|
|
483
|
+
table: TableProtocol[ModelT, Any, Any],
|
|
484
|
+
row: sqlite3.Row,
|
|
485
|
+
include_map: Mapping[str, bool],
|
|
486
|
+
) -> ModelT:
|
|
487
|
+
key = self._identity_key(table, row)
|
|
488
|
+
if key is not None:
|
|
489
|
+
cached = self._identity_map.get(key)
|
|
490
|
+
else:
|
|
491
|
+
cached = None
|
|
492
|
+
model = table.model
|
|
493
|
+
if cached is None:
|
|
494
|
+
if is_dataclass(model):
|
|
495
|
+
values: dict[str, Any] = {column: row[column] for column in table.columns}
|
|
496
|
+
instance = model.__new__(model)
|
|
497
|
+
for field in fields(model):
|
|
498
|
+
if field.name in values:
|
|
499
|
+
value = values[field.name]
|
|
500
|
+
elif field.default is not MISSING:
|
|
501
|
+
value = field.default
|
|
502
|
+
elif field.default_factory is not MISSING: # type: ignore[attr-defined]
|
|
503
|
+
value = field.default_factory() # type: ignore[misc]
|
|
504
|
+
else:
|
|
505
|
+
origin = get_origin(field.type)
|
|
506
|
+
if origin in (list, set, frozenset):
|
|
507
|
+
value = origin() # type: ignore[call-arg]
|
|
508
|
+
else:
|
|
509
|
+
value = None
|
|
510
|
+
object.__setattr__(instance, field.name, value)
|
|
511
|
+
else:
|
|
512
|
+
instance = cast(ModelT, model(**{column: row[column] for column in table.columns}))
|
|
513
|
+
if key is not None:
|
|
514
|
+
self._identity_map[key] = instance
|
|
515
|
+
else:
|
|
516
|
+
instance = cast(ModelT, cached)
|
|
517
|
+
for column in table.columns:
|
|
518
|
+
object.__setattr__(instance, column, row[column])
|
|
519
|
+
self._attach_relations(table, instance, include_map)
|
|
520
|
+
return instance
|
|
521
|
+
|
|
522
|
+
def _invalidate_backrefs(
|
|
523
|
+
self,
|
|
524
|
+
table: TableProtocol[ModelT, Any, Any],
|
|
525
|
+
instance: ModelT,
|
|
526
|
+
) -> None:
|
|
527
|
+
foreign_keys = getattr(table, "foreign_keys", ())
|
|
528
|
+
if not foreign_keys:
|
|
529
|
+
return
|
|
530
|
+
for fk in foreign_keys:
|
|
531
|
+
backref = getattr(fk, "backref", None)
|
|
532
|
+
if not backref:
|
|
533
|
+
continue
|
|
534
|
+
remote_model = fk.remote_model
|
|
535
|
+
if remote_model is None:
|
|
536
|
+
continue
|
|
537
|
+
key_values: list[Any] = []
|
|
538
|
+
for local_col, remote_col in zip(fk.local_columns, fk.remote_columns):
|
|
539
|
+
value = getattr(instance, local_col, None)
|
|
540
|
+
if value is None:
|
|
541
|
+
key_values = []
|
|
542
|
+
break
|
|
543
|
+
key_values.append(value)
|
|
544
|
+
if not key_values:
|
|
545
|
+
continue
|
|
546
|
+
identity_key = (remote_model, tuple(key_values))
|
|
547
|
+
owner = self._identity_map.get(identity_key)
|
|
548
|
+
if owner is None:
|
|
549
|
+
continue
|
|
550
|
+
state_map = _LAZY_RELATION_STATE.get(owner)
|
|
551
|
+
if state_map is None:
|
|
552
|
+
continue
|
|
553
|
+
state = state_map.get(backref)
|
|
554
|
+
if state is None:
|
|
555
|
+
continue
|
|
556
|
+
state.loaded = False
|
|
557
|
+
state.loading = False
|
|
558
|
+
state.value = [] if state.many else None
|
|
559
|
+
|
|
560
|
+
def _identity_key(
|
|
561
|
+
self,
|
|
562
|
+
table: TableProtocol[ModelT, Any, Any],
|
|
563
|
+
row: sqlite3.Row,
|
|
564
|
+
) -> tuple[type[Any], tuple[Any, ...]] | None:
|
|
565
|
+
pk_columns = getattr(table, "primary_key", ())
|
|
566
|
+
if not pk_columns:
|
|
567
|
+
return None
|
|
568
|
+
values: list[Any] = []
|
|
569
|
+
for column in pk_columns:
|
|
570
|
+
value = row[column]
|
|
571
|
+
if value is None:
|
|
572
|
+
return None
|
|
573
|
+
values.append(value)
|
|
574
|
+
return (table.model, tuple(values))
|
|
575
|
+
|
|
576
|
+
def _attach_relations(
|
|
577
|
+
self,
|
|
578
|
+
table: TableProtocol[ModelT, Any, Any],
|
|
579
|
+
instance: ModelT,
|
|
580
|
+
include_map: Mapping[str, bool],
|
|
581
|
+
) -> None:
|
|
582
|
+
relations_attr = cast(Sequence[Any], getattr(table, "relations", ()))
|
|
583
|
+
relations: list[RelationSpec] = []
|
|
584
|
+
for entry in relations_attr:
|
|
585
|
+
if isinstance(entry, RelationSpec):
|
|
586
|
+
relations.append(entry)
|
|
587
|
+
elif isinstance(entry, dict):
|
|
588
|
+
relations.append(
|
|
589
|
+
RelationSpec(
|
|
590
|
+
name=entry['name'],
|
|
591
|
+
table_name=entry['table_name'],
|
|
592
|
+
table_module=entry.get('table_module', table.__class__.__module__),
|
|
593
|
+
many=bool(entry['many']),
|
|
594
|
+
mapping=tuple(tuple(pair) for pair in entry['mapping']),
|
|
595
|
+
)
|
|
596
|
+
)
|
|
597
|
+
else:
|
|
598
|
+
raise TypeError("Unsupported relation specification")
|
|
599
|
+
|
|
600
|
+
existing_names: set[str] = {spec.name for spec in relations}
|
|
601
|
+
relations.extend(_find_backref_relations(table, existing_names))
|
|
602
|
+
if not relations:
|
|
603
|
+
return
|
|
604
|
+
backend_for_lazy = cast(BackendProtocol[Any, Any, Mapping[str, object]], self)
|
|
605
|
+
|
|
606
|
+
base_model_cls = cast(type[Any], getattr(instance.__class__, "__lazy_base__", instance.__class__))
|
|
607
|
+
lazy_cls = _ensure_lazy_model_class(base_model_cls)
|
|
608
|
+
if instance.__class__ is not lazy_cls:
|
|
609
|
+
instance.__class__ = lazy_cls
|
|
610
|
+
|
|
611
|
+
states = _LAZY_RELATION_STATE.get(instance)
|
|
612
|
+
if states is None:
|
|
613
|
+
states = {}
|
|
614
|
+
|
|
615
|
+
for spec in relations:
|
|
616
|
+
name = spec.name
|
|
617
|
+
table_module_name = spec.table_module or table.__class__.__module__
|
|
618
|
+
module = sys.modules.get(table_module_name)
|
|
619
|
+
if module is None:
|
|
620
|
+
raise RuntimeError(f"Module '{table_module_name}' not loaded for relation '{name}'")
|
|
621
|
+
table_cls_name = spec.table_name
|
|
622
|
+
table_cls = getattr(module, table_cls_name)
|
|
623
|
+
mapping = spec.mapping
|
|
624
|
+
many = spec.many
|
|
625
|
+
existing_state = states.get(name)
|
|
626
|
+
if existing_state is None:
|
|
627
|
+
state = LazyRelationState(
|
|
628
|
+
attribute=name,
|
|
629
|
+
backend=backend_for_lazy,
|
|
630
|
+
table_cls=cast(type[Any], table_cls),
|
|
631
|
+
mapping=mapping,
|
|
632
|
+
many=many,
|
|
633
|
+
)
|
|
634
|
+
states[name] = state
|
|
635
|
+
else:
|
|
636
|
+
existing_state.backend = backend_for_lazy
|
|
637
|
+
existing_state.table_cls = cast(type[Any], table_cls)
|
|
638
|
+
existing_state.mapping = mapping
|
|
639
|
+
existing_state.many = many
|
|
640
|
+
state = existing_state
|
|
641
|
+
|
|
642
|
+
if not states:
|
|
643
|
+
return
|
|
644
|
+
_LAZY_RELATION_STATE[instance] = states
|
|
645
|
+
|
|
646
|
+
for name, state in states.items():
|
|
647
|
+
if include_map.get(name):
|
|
648
|
+
_resolve_lazy_relation(instance, state)
|
|
649
|
+
|
|
650
|
+
def close(self) -> None:
|
|
651
|
+
if self._factory is None:
|
|
652
|
+
if self._connection is not None:
|
|
653
|
+
self._connection.close()
|
|
654
|
+
self._connection = None
|
|
655
|
+
self._identity_map.clear()
|
|
656
|
+
return
|
|
657
|
+
connection = getattr(self._local, "connection", None)
|
|
658
|
+
if connection is not None:
|
|
659
|
+
connection.close()
|
|
660
|
+
delattr(self._local, "connection")
|
|
661
|
+
self._identity_map.clear()
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
def create_backend(provider: str, connection: Any) -> BackendProtocol[Any, Any, Mapping[str, object]]:
|
|
665
|
+
if isinstance(connection, SQLiteBackend):
|
|
666
|
+
return connection
|
|
667
|
+
if provider == "sqlite":
|
|
668
|
+
return SQLiteBackend(connection)
|
|
669
|
+
raise ValueError(f"Unsupported provider '{provider}'")
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sqlite3
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
from urllib.parse import urlparse
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def resolve_sqlite_path(url: str | None) -> str:
|
|
10
|
+
if not url:
|
|
11
|
+
raise ValueError("SQLite datasource must specify a url, e.g. sqlite:///path/to.db")
|
|
12
|
+
|
|
13
|
+
parsed = urlparse(url)
|
|
14
|
+
if parsed.scheme != "sqlite":
|
|
15
|
+
raise ValueError(f"Unsupported sqlite url '{url}'")
|
|
16
|
+
|
|
17
|
+
if parsed.path in {":memory:", "/:memory:"}:
|
|
18
|
+
return ":memory:"
|
|
19
|
+
|
|
20
|
+
if parsed.netloc:
|
|
21
|
+
raise ValueError(f"Unsupported sqlite netloc '{parsed.netloc}' in url '{url}'")
|
|
22
|
+
|
|
23
|
+
path = parsed.path
|
|
24
|
+
if not path:
|
|
25
|
+
return ":memory:"
|
|
26
|
+
|
|
27
|
+
if path.startswith("//"):
|
|
28
|
+
target = Path(path[1:])
|
|
29
|
+
return target.as_posix()
|
|
30
|
+
|
|
31
|
+
if path.startswith("/"):
|
|
32
|
+
path = path[1:]
|
|
33
|
+
target = Path(path)
|
|
34
|
+
return target.as_posix()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def open_sqlite_connection(url: str | None) -> sqlite3.Connection:
|
|
38
|
+
path = resolve_sqlite_path(url)
|
|
39
|
+
return sqlite3.connect(path, check_same_thread=False)
|