lenapi 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.
- lenapi/__init__.py +79 -0
- lenapi/application.py +636 -0
- lenapi/authorization.py +96 -0
- lenapi/background.py +395 -0
- lenapi/cli.py +731 -0
- lenapi/database.py +84 -0
- lenapi/dependencies.py +12 -0
- lenapi/logging.py +13 -0
- lenapi/middleware.py +144 -0
- lenapi/model.py +54 -0
- lenapi/models.py +1052 -0
- lenapi/mongodb.py +262 -0
- lenapi/openapi.py +181 -0
- lenapi/parameters.py +8 -0
- lenapi/payments.py +543 -0
- lenapi/request.py +177 -0
- lenapi/response.py +91 -0
- lenapi/security.py +665 -0
- lenapi/security_store.py +193 -0
- lenapi/service.py +350 -0
- lenapi-0.1.0.dist-info/METADATA +959 -0
- lenapi-0.1.0.dist-info/RECORD +25 -0
- lenapi-0.1.0.dist-info/WHEEL +5 -0
- lenapi-0.1.0.dist-info/entry_points.txt +2 -0
- lenapi-0.1.0.dist-info/top_level.txt +1 -0
lenapi/models.py
ADDED
|
@@ -0,0 +1,1052 @@
|
|
|
1
|
+
"""Django-inspired model base and field helpers backed by SQLAlchemy."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
from contextvars import ContextVar
|
|
5
|
+
import uuid
|
|
6
|
+
|
|
7
|
+
from sqlalchemy import BigInteger, Boolean, Column, DateTime, Float, ForeignKey, Integer, JSON, Numeric, String, Table, Text, Uuid, and_, delete, func, inspect, or_, select
|
|
8
|
+
from sqlalchemy.exc import IntegrityError
|
|
9
|
+
from sqlalchemy.orm import DeclarativeBase, Mapped, declared_attr, mapped_column, relationship
|
|
10
|
+
|
|
11
|
+
from .response import HTTPException
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
_current_session: ContextVar[Any | None] = ContextVar("lenapi_current_session", default=None)
|
|
15
|
+
_current_async_session: ContextVar[Any | None] = ContextVar("lenapi_current_async_session", default=None)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Aggregate:
|
|
19
|
+
"""Description of a SQL aggregate to be used with Model.aggregate()."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, function: str, field: str = "*"):
|
|
22
|
+
if function not in {"count", "sum", "min", "max", "avg"}:
|
|
23
|
+
raise ValueError(f"Unsupported aggregate '{function}'")
|
|
24
|
+
self.function = function
|
|
25
|
+
self.field = field
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def Count(field: str = "*") -> Aggregate:
|
|
29
|
+
return Aggregate("count", field)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def Sum(field: str) -> Aggregate:
|
|
33
|
+
return Aggregate("sum", field)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def Min(field: str) -> Aggregate:
|
|
37
|
+
return Aggregate("min", field)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def Max(field: str) -> Aggregate:
|
|
41
|
+
return Aggregate("max", field)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def Avg(field: str) -> Aggregate:
|
|
45
|
+
return Aggregate("avg", field)
|
|
46
|
+
|
|
47
|
+
CASCADE = "CASCADE"
|
|
48
|
+
SET_NULL = "SET_NULL"
|
|
49
|
+
PROTECT = "PROTECT"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class _CascadePolicy:
|
|
53
|
+
delete = CASCADE
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
cascade = _CascadePolicy()
|
|
57
|
+
null = SET_NULL
|
|
58
|
+
protected = PROTECT
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def set_current_session(session):
|
|
62
|
+
return _current_session.set(session)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def reset_current_session(token) -> None:
|
|
66
|
+
_current_session.reset(token)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def set_current_async_session(session):
|
|
70
|
+
return _current_async_session.set(session)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def reset_current_async_session(token) -> None:
|
|
74
|
+
_current_async_session.reset(token)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _session():
|
|
78
|
+
session = _current_session.get()
|
|
79
|
+
if session is None:
|
|
80
|
+
raise RuntimeError("Model queries require an active lenApi request with a configured database")
|
|
81
|
+
return session
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _async_session(session=None):
|
|
85
|
+
session = session or _current_async_session.get()
|
|
86
|
+
if session is None:
|
|
87
|
+
raise RuntimeError(
|
|
88
|
+
"Async model queries require an active lenApi request with an AsyncDatabase, "
|
|
89
|
+
"or an explicit AsyncSession argument"
|
|
90
|
+
)
|
|
91
|
+
return session
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class _QueryResults(list):
|
|
95
|
+
"""List of model rows with bulk delete support."""
|
|
96
|
+
|
|
97
|
+
def __init__(self, model, rows):
|
|
98
|
+
super().__init__(rows)
|
|
99
|
+
self.model = model
|
|
100
|
+
|
|
101
|
+
def delete(self) -> int:
|
|
102
|
+
session = _session()
|
|
103
|
+
if any(not isinstance(row, self.model) for row in self):
|
|
104
|
+
raise ValueError("Delete model results, not projected values")
|
|
105
|
+
for row in self:
|
|
106
|
+
session.delete(row)
|
|
107
|
+
session.commit()
|
|
108
|
+
return len(self)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class _QuerySet:
|
|
112
|
+
_FILTER_OPERATORS = {
|
|
113
|
+
"exact", "iexact", "ne", "gt", "gte", "lt", "lte", "in", "notin",
|
|
114
|
+
"contains", "icontains", "startswith", "istartswith", "endswith", "iendswith", "isnull",
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
def __init__(self, model, *, search=False, search_any=False, columns=None, ordering=None, limit=None, offset=0, **filters):
|
|
118
|
+
self.model = model
|
|
119
|
+
self.filters = filters
|
|
120
|
+
self.search = search
|
|
121
|
+
self.search_any = search_any
|
|
122
|
+
self.columns = list(columns) if columns is not None else None
|
|
123
|
+
self.ordering = list(ordering or [])
|
|
124
|
+
self._limit = limit
|
|
125
|
+
self._offset = offset
|
|
126
|
+
self._validate_pagination()
|
|
127
|
+
|
|
128
|
+
def _validate_pagination(self):
|
|
129
|
+
if self._limit is not None and (isinstance(self._limit, bool) or not isinstance(self._limit, int) or self._limit < 0):
|
|
130
|
+
raise HTTPException(422, "limit must be a non-negative integer")
|
|
131
|
+
if isinstance(self._offset, bool) or not isinstance(self._offset, int) or self._offset < 0:
|
|
132
|
+
raise HTTPException(422, "offset must be a non-negative integer")
|
|
133
|
+
|
|
134
|
+
def _column(self, field):
|
|
135
|
+
field = "id" if field == "_id" else field
|
|
136
|
+
if field not in self.model.__table__.columns:
|
|
137
|
+
raise ValueError(f"Unknown field '{field}' for {self.model.__name__}")
|
|
138
|
+
return getattr(self.model, field)
|
|
139
|
+
|
|
140
|
+
def _statement(self):
|
|
141
|
+
selected = [self._column(field) for field in self.columns] if self.columns else [self.model]
|
|
142
|
+
statement = select(*selected)
|
|
143
|
+
if self.search or self.search_any:
|
|
144
|
+
conditions = []
|
|
145
|
+
for field, value in self.filters.items():
|
|
146
|
+
column = self._column(field)
|
|
147
|
+
self._require_string_field(field, column)
|
|
148
|
+
conditions.append(column.ilike(f"%{value}%"))
|
|
149
|
+
if conditions:
|
|
150
|
+
statement = statement.where(or_(*conditions) if self.search_any else and_(*conditions))
|
|
151
|
+
else:
|
|
152
|
+
conditions = self._filter_conditions()
|
|
153
|
+
if conditions:
|
|
154
|
+
statement = statement.where(and_(*conditions))
|
|
155
|
+
for field, descending in self.ordering:
|
|
156
|
+
column = self._column(field)
|
|
157
|
+
statement = statement.order_by(column.desc() if descending else column.asc())
|
|
158
|
+
if self._limit is not None:
|
|
159
|
+
statement = statement.limit(self._limit)
|
|
160
|
+
if self._offset:
|
|
161
|
+
statement = statement.offset(self._offset)
|
|
162
|
+
return statement
|
|
163
|
+
|
|
164
|
+
def _require_string_field(self, field, column):
|
|
165
|
+
if not isinstance(column.property.columns[0].type, String):
|
|
166
|
+
raise ValueError(f"Search field '{field}' on {self.model.__name__} must be a string")
|
|
167
|
+
|
|
168
|
+
def _filter_conditions(self):
|
|
169
|
+
conditions = []
|
|
170
|
+
for lookup, value in self.filters.items():
|
|
171
|
+
field, separator, operator = lookup.rpartition("__")
|
|
172
|
+
if not separator or operator not in self._FILTER_OPERATORS:
|
|
173
|
+
field, operator = lookup, "exact"
|
|
174
|
+
column = self._column(field)
|
|
175
|
+
if operator == "exact":
|
|
176
|
+
condition = column == value
|
|
177
|
+
elif operator == "iexact":
|
|
178
|
+
self._require_string_field(field, column)
|
|
179
|
+
condition = column.ilike(value)
|
|
180
|
+
elif operator == "ne":
|
|
181
|
+
condition = column != value
|
|
182
|
+
elif operator == "gt":
|
|
183
|
+
condition = column > value
|
|
184
|
+
elif operator == "gte":
|
|
185
|
+
condition = column >= value
|
|
186
|
+
elif operator == "lt":
|
|
187
|
+
condition = column < value
|
|
188
|
+
elif operator == "lte":
|
|
189
|
+
condition = column <= value
|
|
190
|
+
elif operator == "in":
|
|
191
|
+
if not isinstance(value, (list, tuple, set, frozenset)):
|
|
192
|
+
raise ValueError(f"Filter '{lookup}' requires a list, tuple, or set")
|
|
193
|
+
condition = column.in_(value)
|
|
194
|
+
elif operator == "notin":
|
|
195
|
+
if not isinstance(value, (list, tuple, set, frozenset)):
|
|
196
|
+
raise ValueError(f"Filter '{lookup}' requires a list, tuple, or set")
|
|
197
|
+
condition = column.not_in(value)
|
|
198
|
+
elif operator == "isnull":
|
|
199
|
+
if not isinstance(value, bool):
|
|
200
|
+
raise ValueError(f"Filter '{lookup}' requires a boolean")
|
|
201
|
+
condition = column.is_(None) if value else column.is_not(None)
|
|
202
|
+
else:
|
|
203
|
+
self._require_string_field(field, column)
|
|
204
|
+
if operator == "contains":
|
|
205
|
+
condition = column.contains(value)
|
|
206
|
+
elif operator == "icontains":
|
|
207
|
+
condition = column.ilike(f"%{value}%")
|
|
208
|
+
elif operator == "startswith":
|
|
209
|
+
condition = column.startswith(value)
|
|
210
|
+
elif operator == "istartswith":
|
|
211
|
+
condition = column.ilike(f"{value}%")
|
|
212
|
+
elif operator == "endswith":
|
|
213
|
+
condition = column.endswith(value)
|
|
214
|
+
else: # iendswith
|
|
215
|
+
condition = column.ilike(f"%{value}")
|
|
216
|
+
conditions.append(condition)
|
|
217
|
+
return conditions
|
|
218
|
+
|
|
219
|
+
def _aggregate_statement(self, group_by, aggregates):
|
|
220
|
+
if not aggregates:
|
|
221
|
+
raise ValueError("Provide at least one named aggregate, such as total=Sum('amount')")
|
|
222
|
+
group_fields = (group_by,) if isinstance(group_by, str) else tuple(group_by or ())
|
|
223
|
+
group_columns = [self._column(field) for field in group_fields]
|
|
224
|
+
selections = list(group_columns)
|
|
225
|
+
for name, aggregate in aggregates.items():
|
|
226
|
+
if not isinstance(aggregate, Aggregate):
|
|
227
|
+
raise TypeError(f"Aggregate '{name}' must use Count(), Sum(), Min(), Max(), or Avg()")
|
|
228
|
+
expression = (
|
|
229
|
+
func.count() if aggregate.function == "count" and aggregate.field == "*"
|
|
230
|
+
else getattr(func, aggregate.function)(self._column(aggregate.field))
|
|
231
|
+
)
|
|
232
|
+
selections.append(expression.label(name))
|
|
233
|
+
statement = select(*selections).select_from(self.model)
|
|
234
|
+
if self.search or self.search_any:
|
|
235
|
+
conditions = []
|
|
236
|
+
for field, value in self.filters.items():
|
|
237
|
+
column = self._column(field)
|
|
238
|
+
self._require_string_field(field, column)
|
|
239
|
+
conditions.append(column.ilike(f"%{value}%"))
|
|
240
|
+
if conditions:
|
|
241
|
+
statement = statement.where(or_(*conditions) if self.search_any else and_(*conditions))
|
|
242
|
+
else:
|
|
243
|
+
conditions = self._filter_conditions()
|
|
244
|
+
if conditions:
|
|
245
|
+
statement = statement.where(and_(*conditions))
|
|
246
|
+
if group_columns:
|
|
247
|
+
statement = statement.group_by(*group_columns)
|
|
248
|
+
return statement
|
|
249
|
+
|
|
250
|
+
def aggregate(self, *, group_by=None, **aggregates):
|
|
251
|
+
"""Return aggregate values, or one dictionary per group when grouped."""
|
|
252
|
+
result = _session().execute(self._aggregate_statement(group_by, aggregates))
|
|
253
|
+
if group_by is not None:
|
|
254
|
+
return [dict(row._mapping) for row in result]
|
|
255
|
+
row = result.mappings().one()
|
|
256
|
+
return dict(row)
|
|
257
|
+
|
|
258
|
+
def count(self, field: str = "*") -> int:
|
|
259
|
+
"""Count rows matching this query."""
|
|
260
|
+
return self.aggregate(count=Count(field))["count"]
|
|
261
|
+
|
|
262
|
+
def all(self, *, limit=None, offset=None):
|
|
263
|
+
if limit is not None:
|
|
264
|
+
self.limit(limit)
|
|
265
|
+
if offset is not None:
|
|
266
|
+
self.offset(offset)
|
|
267
|
+
result = _session().execute(self._statement())
|
|
268
|
+
if not self.columns:
|
|
269
|
+
return _QueryResults(self.model, result.scalars().all())
|
|
270
|
+
if len(self.columns) == 1:
|
|
271
|
+
return _QueryResults(self.model, result.scalars().all())
|
|
272
|
+
return _QueryResults(self.model, [dict(row._mapping) for row in result])
|
|
273
|
+
|
|
274
|
+
def first(self):
|
|
275
|
+
result = _session().execute(self._statement())
|
|
276
|
+
if not self.columns or len(self.columns) == 1:
|
|
277
|
+
return result.scalars().first()
|
|
278
|
+
row = result.mappings().first()
|
|
279
|
+
return None if row is None else dict(row)
|
|
280
|
+
|
|
281
|
+
def one_or_none(self):
|
|
282
|
+
"""Return the sole matching row, or raise if the lookup is ambiguous."""
|
|
283
|
+
result = _session().execute(self._statement())
|
|
284
|
+
if not self.columns:
|
|
285
|
+
return result.scalars().one_or_none()
|
|
286
|
+
if len(self.columns) == 1:
|
|
287
|
+
return result.scalars().one_or_none()
|
|
288
|
+
row = result.mappings().one_or_none()
|
|
289
|
+
return None if row is None else dict(row)
|
|
290
|
+
|
|
291
|
+
def get(self, **filters):
|
|
292
|
+
"""Return the sole matching model instance, or None if no row matches."""
|
|
293
|
+
query = self._clone_with_filters(filters)
|
|
294
|
+
return query.one_or_none()
|
|
295
|
+
|
|
296
|
+
def _clone_with_filters(self, filters):
|
|
297
|
+
return _QuerySet(
|
|
298
|
+
self.model, search=self.search, search_any=self.search_any,
|
|
299
|
+
columns=self.columns, ordering=self.ordering,
|
|
300
|
+
limit=self._limit, offset=self._offset,
|
|
301
|
+
**(self.filters | filters),
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
def limit(self, value: int | None):
|
|
305
|
+
"""Limit the number of rows returned by the next query execution."""
|
|
306
|
+
self._limit = value
|
|
307
|
+
self._validate_pagination()
|
|
308
|
+
return self
|
|
309
|
+
|
|
310
|
+
def offset(self, value: int):
|
|
311
|
+
"""Skip this many rows before returning query results."""
|
|
312
|
+
self._offset = value
|
|
313
|
+
self._validate_pagination()
|
|
314
|
+
return self
|
|
315
|
+
|
|
316
|
+
def values(self, *columns):
|
|
317
|
+
"""Select the requested columns, defaulting to all model columns."""
|
|
318
|
+
selected = columns or tuple(self.model.__table__.columns.keys())
|
|
319
|
+
for field in selected:
|
|
320
|
+
self._column(field)
|
|
321
|
+
self.columns = list(selected)
|
|
322
|
+
return self
|
|
323
|
+
|
|
324
|
+
def order(self, field):
|
|
325
|
+
descending = field.startswith("-")
|
|
326
|
+
name = field[1:] if descending else field
|
|
327
|
+
self._column(name)
|
|
328
|
+
self.ordering.append((name, descending))
|
|
329
|
+
return self
|
|
330
|
+
|
|
331
|
+
def delete(self) -> int:
|
|
332
|
+
"""Delete rows matching this query and return the number removed."""
|
|
333
|
+
if self._limit is not None or self._offset:
|
|
334
|
+
raise ValueError("Cannot delete a paginated query; remove limit and offset first")
|
|
335
|
+
session = _session()
|
|
336
|
+
statement = delete(self.model)
|
|
337
|
+
conditions = (
|
|
338
|
+
[self._column(field).ilike(f"%{value}%") for field, value in self.filters.items()]
|
|
339
|
+
if self.search or self.search_any else self._filter_conditions()
|
|
340
|
+
)
|
|
341
|
+
if conditions:
|
|
342
|
+
statement = statement.where(or_(*conditions) if self.search_any else and_(*conditions))
|
|
343
|
+
result = session.execute(statement)
|
|
344
|
+
session.commit()
|
|
345
|
+
return result.rowcount or 0
|
|
346
|
+
|
|
347
|
+
def value(self, field: str | None = None):
|
|
348
|
+
rows = self.all()
|
|
349
|
+
if field is None:
|
|
350
|
+
return rows
|
|
351
|
+
if field not in self.model.__table__.columns:
|
|
352
|
+
raise ValueError(f"Unknown field '{field}' for {self.model.__name__}")
|
|
353
|
+
return [getattr(row, field) for row in rows]
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
class _AsyncQuerySet(_QuerySet):
|
|
357
|
+
"""Async SQLAlchemy query builder for the ``a*`` model operations."""
|
|
358
|
+
|
|
359
|
+
def _clone_with_filters(self, filters):
|
|
360
|
+
return _AsyncQuerySet(
|
|
361
|
+
self.model, search=self.search, search_any=self.search_any,
|
|
362
|
+
columns=self.columns, ordering=self.ordering,
|
|
363
|
+
limit=self._limit, offset=self._offset, **(self.filters | filters),
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
async def all(self, *, limit=None, offset=None, session=None):
|
|
367
|
+
if limit is not None:
|
|
368
|
+
self.limit(limit)
|
|
369
|
+
if offset is not None:
|
|
370
|
+
self.offset(offset)
|
|
371
|
+
result = await _async_session(session).execute(self._statement())
|
|
372
|
+
if not self.columns or len(self.columns) == 1:
|
|
373
|
+
return result.scalars().all()
|
|
374
|
+
return [dict(row._mapping) for row in result]
|
|
375
|
+
|
|
376
|
+
async def aggregate(self, *, group_by=None, session=None, **aggregates):
|
|
377
|
+
result = await _async_session(session).execute(self._aggregate_statement(group_by, aggregates))
|
|
378
|
+
if group_by is not None:
|
|
379
|
+
return [dict(row._mapping) for row in result]
|
|
380
|
+
row = result.mappings().one()
|
|
381
|
+
return dict(row)
|
|
382
|
+
|
|
383
|
+
async def count(self, field: str = "*", *, session=None) -> int:
|
|
384
|
+
values = await self.aggregate(count=Count(field), session=session)
|
|
385
|
+
return values["count"]
|
|
386
|
+
|
|
387
|
+
async def first(self, *, session=None):
|
|
388
|
+
result = await _async_session(session).execute(self._statement())
|
|
389
|
+
if not self.columns or len(self.columns) == 1:
|
|
390
|
+
return result.scalars().first()
|
|
391
|
+
row = result.mappings().first()
|
|
392
|
+
return None if row is None else dict(row)
|
|
393
|
+
|
|
394
|
+
async def one_or_none(self, *, session=None):
|
|
395
|
+
result = await _async_session(session).execute(self._statement())
|
|
396
|
+
if not self.columns or len(self.columns) == 1:
|
|
397
|
+
return result.scalars().one_or_none()
|
|
398
|
+
row = result.mappings().one_or_none()
|
|
399
|
+
return None if row is None else dict(row)
|
|
400
|
+
|
|
401
|
+
async def get(self, *, session=None, **filters):
|
|
402
|
+
return await self._clone_with_filters(filters).one_or_none(session=session)
|
|
403
|
+
|
|
404
|
+
async def exists(self, *, session=None) -> bool:
|
|
405
|
+
return await self.one_or_none(session=session) is not None
|
|
406
|
+
|
|
407
|
+
async def delete(self, *, session=None) -> int:
|
|
408
|
+
if self._limit is not None or self._offset:
|
|
409
|
+
raise ValueError("Cannot delete a paginated query; remove limit and offset first")
|
|
410
|
+
active_session = _async_session(session)
|
|
411
|
+
statement = delete(self.model)
|
|
412
|
+
conditions = (
|
|
413
|
+
[self._column(field).ilike(f"%{value}%") for field, value in self.filters.items()]
|
|
414
|
+
if self.search else self._filter_conditions()
|
|
415
|
+
)
|
|
416
|
+
if conditions:
|
|
417
|
+
statement = statement.where(and_(*conditions))
|
|
418
|
+
try:
|
|
419
|
+
result = await active_session.execute(statement)
|
|
420
|
+
await active_session.commit()
|
|
421
|
+
except IntegrityError as exc:
|
|
422
|
+
await active_session.rollback()
|
|
423
|
+
raise HTTPException(409, "Cannot delete records; protected records still reference them") from exc
|
|
424
|
+
return result.rowcount or 0
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
class _ModelManager:
|
|
428
|
+
def __init__(self, model):
|
|
429
|
+
self.model = model
|
|
430
|
+
|
|
431
|
+
def all(self, *, limit=None, offset=0):
|
|
432
|
+
return _QuerySet(self.model, limit=limit, offset=offset).all()
|
|
433
|
+
|
|
434
|
+
def __call__(self, *columns, **filters):
|
|
435
|
+
return _SingleQuery(self.model, columns=columns or None, **filters)
|
|
436
|
+
|
|
437
|
+
def unique(self, *columns):
|
|
438
|
+
return _SingleQuery(self.model, columns=columns or None)
|
|
439
|
+
|
|
440
|
+
def __getattr__(self, name: str):
|
|
441
|
+
if name == self.model.__name__.lower():
|
|
442
|
+
return lambda **filters: _SingleQuery(self.model, **filters)
|
|
443
|
+
raise AttributeError(name)
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
class _SingleQuery(_QuerySet):
|
|
447
|
+
"""A unique model lookup that can check existence or return its row."""
|
|
448
|
+
|
|
449
|
+
_query_state = {"model", "filters", "search", "search_any", "columns", "ordering", "_limit", "_offset"}
|
|
450
|
+
|
|
451
|
+
def _instance(self):
|
|
452
|
+
instance = self.one_or_none()
|
|
453
|
+
if instance is None:
|
|
454
|
+
raise LookupError(f"No {self.model.__name__} row matched this lookup")
|
|
455
|
+
if not isinstance(instance, self.model):
|
|
456
|
+
raise ValueError("This operation requires a full model lookup, not selected columns")
|
|
457
|
+
return instance
|
|
458
|
+
|
|
459
|
+
def value(self, field: str | None = None):
|
|
460
|
+
if field is not None:
|
|
461
|
+
self.values(field)
|
|
462
|
+
return self.one_or_none()
|
|
463
|
+
|
|
464
|
+
def get(self, **filters):
|
|
465
|
+
return self._clone_with_filters(filters).one_or_none()
|
|
466
|
+
|
|
467
|
+
def exist(self, **filters) -> bool:
|
|
468
|
+
return self._clone_with_filters(filters).one_or_none() is not None
|
|
469
|
+
|
|
470
|
+
def exists(self, **filters) -> bool:
|
|
471
|
+
return self.exist(**filters)
|
|
472
|
+
|
|
473
|
+
def save(self):
|
|
474
|
+
"""Save changes made through this lookup and return the model instance."""
|
|
475
|
+
return self._instance().save()
|
|
476
|
+
|
|
477
|
+
def delete(self) -> bool:
|
|
478
|
+
"""Delete the uniquely matching row, returning whether one was removed."""
|
|
479
|
+
session = _session()
|
|
480
|
+
instance = self.one_or_none()
|
|
481
|
+
if instance is None:
|
|
482
|
+
return False
|
|
483
|
+
if not isinstance(instance, self.model):
|
|
484
|
+
raise ValueError("Delete a model lookup, not a projected-column lookup")
|
|
485
|
+
session.delete(instance)
|
|
486
|
+
session.commit()
|
|
487
|
+
return True
|
|
488
|
+
|
|
489
|
+
def delet(self) -> bool:
|
|
490
|
+
"""Alias for delete(), preserving the shorter spelling."""
|
|
491
|
+
return self.delete()
|
|
492
|
+
|
|
493
|
+
def special(self, foreign_key_name: str):
|
|
494
|
+
"""Resolve a declared foreign key and expose its related model by field name."""
|
|
495
|
+
instance = self.one_or_none()
|
|
496
|
+
if instance is None:
|
|
497
|
+
return None
|
|
498
|
+
foreign_key_column = self.model.__table__.columns.get(foreign_key_name)
|
|
499
|
+
if foreign_key_column is None or not foreign_key_column.foreign_keys:
|
|
500
|
+
raise ValueError(
|
|
501
|
+
f"'{foreign_key_name}' is not a foreign key field on {self.model.__name__}"
|
|
502
|
+
)
|
|
503
|
+
foreign_key = next(iter(foreign_key_column.foreign_keys))
|
|
504
|
+
target_column = foreign_key.column
|
|
505
|
+
target_model = next(
|
|
506
|
+
(
|
|
507
|
+
mapper.class_
|
|
508
|
+
for mapper in Model.registry.mappers
|
|
509
|
+
if mapper.local_table is target_column.table
|
|
510
|
+
),
|
|
511
|
+
None,
|
|
512
|
+
)
|
|
513
|
+
if target_model is None:
|
|
514
|
+
raise ValueError(
|
|
515
|
+
f"No lenApi model is registered for foreign key target '{target_column.table.fullname}'"
|
|
516
|
+
)
|
|
517
|
+
foreign_value = getattr(instance, foreign_key_name)
|
|
518
|
+
related = None
|
|
519
|
+
if foreign_value is not None:
|
|
520
|
+
related = _session().scalar(
|
|
521
|
+
select(target_model).where(target_column == foreign_value)
|
|
522
|
+
)
|
|
523
|
+
return _SpecialResult(instance, foreign_key_name, related)
|
|
524
|
+
|
|
525
|
+
def __getattr__(self, name):
|
|
526
|
+
return getattr(self._instance(), name)
|
|
527
|
+
|
|
528
|
+
def __setattr__(self, name, value):
|
|
529
|
+
if name in self._query_state or name.startswith("_"):
|
|
530
|
+
object.__setattr__(self, name, value)
|
|
531
|
+
return
|
|
532
|
+
setattr(self._instance(), name, value)
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
class _SpecialResult:
|
|
536
|
+
"""Proxy for a model row with one foreign-key field resolved to its model."""
|
|
537
|
+
|
|
538
|
+
def __init__(self, instance, foreign_key_name: str, related):
|
|
539
|
+
self._instance = instance
|
|
540
|
+
self._foreign_key_name = foreign_key_name
|
|
541
|
+
self._related = related
|
|
542
|
+
|
|
543
|
+
def __getattr__(self, name):
|
|
544
|
+
if name == self._foreign_key_name:
|
|
545
|
+
return self._related
|
|
546
|
+
return getattr(self._instance, name)
|
|
547
|
+
|
|
548
|
+
def __repr__(self):
|
|
549
|
+
return repr(self._instance)
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
class _TableQuery(_QuerySet):
|
|
553
|
+
"""Full-table query whose delete operation drops the model table."""
|
|
554
|
+
|
|
555
|
+
def delete(self) -> bool:
|
|
556
|
+
session = _session()
|
|
557
|
+
connection = session.connection()
|
|
558
|
+
if not inspect(connection).has_table(self.model.__tablename__):
|
|
559
|
+
return False
|
|
560
|
+
self.model.__table__.drop(bind=connection)
|
|
561
|
+
session.commit()
|
|
562
|
+
return True
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
class _ModelManagerDescriptor:
|
|
566
|
+
def __get__(self, instance, owner):
|
|
567
|
+
return _ModelManager(owner)
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
class _AsyncDeleteDescriptor:
|
|
571
|
+
"""Support both ``await Model.adelete(**filters)`` and instance deletion."""
|
|
572
|
+
|
|
573
|
+
def __get__(self, instance, owner):
|
|
574
|
+
if instance is None:
|
|
575
|
+
async def delete_matching(*, session=None, **filters):
|
|
576
|
+
if not filters:
|
|
577
|
+
raise ValueError("Model.adelete requires filters; use afilter(...).delete() for bulk deletion")
|
|
578
|
+
return await _AsyncQuerySet(owner, **filters).delete(session=session)
|
|
579
|
+
return delete_matching
|
|
580
|
+
|
|
581
|
+
async def delete_instance(*, session=None):
|
|
582
|
+
active_session = _async_session(session)
|
|
583
|
+
if not inspect(instance).persistent:
|
|
584
|
+
return False
|
|
585
|
+
await active_session.delete(instance)
|
|
586
|
+
try:
|
|
587
|
+
await active_session.commit()
|
|
588
|
+
except IntegrityError as exc:
|
|
589
|
+
await active_session.rollback()
|
|
590
|
+
raise HTTPException(
|
|
591
|
+
409,
|
|
592
|
+
f"Cannot delete {owner.__name__}; protected records still reference it",
|
|
593
|
+
) from exc
|
|
594
|
+
return True
|
|
595
|
+
return delete_instance
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
class Model(DeclarativeBase):
|
|
599
|
+
"""Base class for lenApi ORM models."""
|
|
600
|
+
|
|
601
|
+
__abstract__ = True
|
|
602
|
+
|
|
603
|
+
get = _ModelManagerDescriptor()
|
|
604
|
+
adelete = _AsyncDeleteDescriptor()
|
|
605
|
+
|
|
606
|
+
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
607
|
+
|
|
608
|
+
@declared_attr.directive
|
|
609
|
+
def __tablename__(cls) -> str:
|
|
610
|
+
return cls.__name__.lower()
|
|
611
|
+
|
|
612
|
+
@classmethod
|
|
613
|
+
def create(cls, **values):
|
|
614
|
+
instance = cls(**values)
|
|
615
|
+
instance.save()
|
|
616
|
+
return instance
|
|
617
|
+
|
|
618
|
+
@classmethod
|
|
619
|
+
async def acreate(cls, *, session=None, **values):
|
|
620
|
+
"""Create and persist a row using the request's AsyncSession."""
|
|
621
|
+
instance = cls(**values)
|
|
622
|
+
await instance.asave(session=session)
|
|
623
|
+
return instance
|
|
624
|
+
|
|
625
|
+
@classmethod
|
|
626
|
+
async def aget(cls, *, session=None, **filters):
|
|
627
|
+
"""Return the sole matching row asynchronously, or None if it is absent."""
|
|
628
|
+
return await _AsyncQuerySet(cls, **filters).one_or_none(session=session)
|
|
629
|
+
|
|
630
|
+
@classmethod
|
|
631
|
+
def afilter(cls, **filters):
|
|
632
|
+
"""Build an async query; execute it with ``await query.all()``."""
|
|
633
|
+
return _AsyncQuerySet(cls, **filters)
|
|
634
|
+
|
|
635
|
+
@classmethod
|
|
636
|
+
def aggregate(cls, *, group_by=None, **aggregates):
|
|
637
|
+
"""Aggregate all rows, optionally returning one result per group."""
|
|
638
|
+
return _QuerySet(cls).aggregate(group_by=group_by, **aggregates)
|
|
639
|
+
|
|
640
|
+
@classmethod
|
|
641
|
+
async def aaggregate(cls, *, group_by=None, session=None, **aggregates):
|
|
642
|
+
"""Async counterpart of aggregate()."""
|
|
643
|
+
return await _AsyncQuerySet(cls).aggregate(
|
|
644
|
+
group_by=group_by, session=session, **aggregates
|
|
645
|
+
)
|
|
646
|
+
|
|
647
|
+
@classmethod
|
|
648
|
+
def count(cls) -> int:
|
|
649
|
+
"""Count all records in this model's table."""
|
|
650
|
+
return _QuerySet(cls).count()
|
|
651
|
+
|
|
652
|
+
@classmethod
|
|
653
|
+
async def acount(cls, *, session=None) -> int:
|
|
654
|
+
"""Async count of all records in this model's table."""
|
|
655
|
+
return await _AsyncQuerySet(cls).count(session=session)
|
|
656
|
+
|
|
657
|
+
@classmethod
|
|
658
|
+
def get_or_404(cls, **filters):
|
|
659
|
+
"""Return the single matching row, or raise an HTTP 404 exception."""
|
|
660
|
+
instance = cls.get(**filters).get()
|
|
661
|
+
if instance is None:
|
|
662
|
+
raise HTTPException(404, f"{cls.__name__} not found")
|
|
663
|
+
return instance
|
|
664
|
+
|
|
665
|
+
@classmethod
|
|
666
|
+
def create_update(cls, defaults: dict | None = None, **lookup):
|
|
667
|
+
"""Create a row when lookup misses; otherwise apply defaults and save."""
|
|
668
|
+
if not lookup:
|
|
669
|
+
raise ValueError("create_update requires at least one lookup field")
|
|
670
|
+
instance = cls.get(**lookup).get()
|
|
671
|
+
if instance is None:
|
|
672
|
+
return cls.create(**(lookup | (defaults or {})))
|
|
673
|
+
for field, value in (defaults or {}).items():
|
|
674
|
+
setattr(instance, field, value)
|
|
675
|
+
return instance.save()
|
|
676
|
+
|
|
677
|
+
@classmethod
|
|
678
|
+
def update_or_404(cls, defaults: dict | None = None, **lookup):
|
|
679
|
+
"""Update a matching row, or raise an HTTP 404 exception."""
|
|
680
|
+
if not lookup:
|
|
681
|
+
raise ValueError("update_or_404 requires at least one lookup field")
|
|
682
|
+
instance = cls.get_or_404(**lookup)
|
|
683
|
+
for field, value in (defaults or {}).items():
|
|
684
|
+
setattr(instance, field, value)
|
|
685
|
+
return instance.save()
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
@classmethod
|
|
689
|
+
def delete_or_404(cls, **filters) -> bool:
|
|
690
|
+
"""Delete exactly one matching row, or raise an HTTP 404 exception."""
|
|
691
|
+
if not filters:
|
|
692
|
+
raise ValueError("delete_or_404 requires at least one lookup field")
|
|
693
|
+
instance = cls.get_or_404(**filters)
|
|
694
|
+
instance.delete()
|
|
695
|
+
return True
|
|
696
|
+
|
|
697
|
+
@classmethod
|
|
698
|
+
def get_or_create(cls, defaults: dict | None = None, **lookup):
|
|
699
|
+
"""Return (instance, created), following Django's get_or_create convention."""
|
|
700
|
+
if not lookup:
|
|
701
|
+
raise ValueError("get_or_create requires at least one lookup field")
|
|
702
|
+
instance = cls.get(**lookup).get()
|
|
703
|
+
if instance is not None:
|
|
704
|
+
return instance, False
|
|
705
|
+
return cls.create(**(lookup | (defaults or {}))), True
|
|
706
|
+
|
|
707
|
+
@classmethod
|
|
708
|
+
def all(cls, *, limit=None, offset=0):
|
|
709
|
+
"""Build a full-table query; calling delete() on it drops the table."""
|
|
710
|
+
return _TableQuery(cls, limit=limit, offset=offset)
|
|
711
|
+
|
|
712
|
+
def save(self):
|
|
713
|
+
"""Insert or update this model instance, commit, and refresh it."""
|
|
714
|
+
session = _session()
|
|
715
|
+
mapper = inspect(type(self))
|
|
716
|
+
state = inspect(self)
|
|
717
|
+
identity = state.identity
|
|
718
|
+
primary_key = mapper.primary_key[0]
|
|
719
|
+
current_id = identity[0] if identity else getattr(self, primary_key.key, None)
|
|
720
|
+
for column in self.__table__.columns:
|
|
721
|
+
value = getattr(self, column.key)
|
|
722
|
+
if not column.unique or value is None:
|
|
723
|
+
continue
|
|
724
|
+
if identity is not None and not state.attrs[column.key].history.has_changes():
|
|
725
|
+
continue
|
|
726
|
+
statement = select(self.__class__).where(getattr(self.__class__, column.key) == value)
|
|
727
|
+
if current_id is not None:
|
|
728
|
+
statement = statement.where(primary_key != current_id)
|
|
729
|
+
if session.scalar(statement) is not None:
|
|
730
|
+
raise HTTPException(
|
|
731
|
+
409,
|
|
732
|
+
f"Double entries are not allowed: {self.__class__.__name__}.{column.key}",
|
|
733
|
+
)
|
|
734
|
+
session.add(self)
|
|
735
|
+
session.commit()
|
|
736
|
+
session.refresh(self)
|
|
737
|
+
return self
|
|
738
|
+
|
|
739
|
+
async def asave(self, *, session=None):
|
|
740
|
+
"""Insert or update this model instance asynchronously and refresh it."""
|
|
741
|
+
active_session = _async_session(session)
|
|
742
|
+
mapper = inspect(type(self))
|
|
743
|
+
state = inspect(self)
|
|
744
|
+
identity = state.identity
|
|
745
|
+
primary_key = mapper.primary_key[0]
|
|
746
|
+
current_id = identity[0] if identity else getattr(self, primary_key.key, None)
|
|
747
|
+
for column in self.__table__.columns:
|
|
748
|
+
value = getattr(self, column.key)
|
|
749
|
+
if not column.unique or value is None:
|
|
750
|
+
continue
|
|
751
|
+
if identity is not None and not state.attrs[column.key].history.has_changes():
|
|
752
|
+
continue
|
|
753
|
+
statement = select(self.__class__).where(getattr(self.__class__, column.key) == value)
|
|
754
|
+
if current_id is not None:
|
|
755
|
+
statement = statement.where(primary_key != current_id)
|
|
756
|
+
if (await active_session.scalar(statement)) is not None:
|
|
757
|
+
raise HTTPException(
|
|
758
|
+
409,
|
|
759
|
+
f"Double entries are not allowed: {self.__class__.__name__}.{column.key}",
|
|
760
|
+
)
|
|
761
|
+
active_session.add(self)
|
|
762
|
+
try:
|
|
763
|
+
await active_session.commit()
|
|
764
|
+
await active_session.refresh(self)
|
|
765
|
+
except IntegrityError as exc:
|
|
766
|
+
await active_session.rollback()
|
|
767
|
+
raise HTTPException(409, "Duplicate or conflicting data") from exc
|
|
768
|
+
return self
|
|
769
|
+
|
|
770
|
+
def delete(self) -> bool:
|
|
771
|
+
"""Delete this model instance, commit, and report whether it was persisted."""
|
|
772
|
+
from sqlalchemy import inspect as sqlalchemy_inspect
|
|
773
|
+
|
|
774
|
+
session = _session()
|
|
775
|
+
if not sqlalchemy_inspect(self).persistent:
|
|
776
|
+
return False
|
|
777
|
+
session.delete(self)
|
|
778
|
+
try:
|
|
779
|
+
session.commit()
|
|
780
|
+
except IntegrityError as exc:
|
|
781
|
+
session.rollback()
|
|
782
|
+
raise HTTPException(
|
|
783
|
+
409,
|
|
784
|
+
f"Cannot delete {self.__class__.__name__}; protected records still reference it",
|
|
785
|
+
) from exc
|
|
786
|
+
return True
|
|
787
|
+
|
|
788
|
+
def delet(self) -> bool:
|
|
789
|
+
"""Alias for delete()."""
|
|
790
|
+
return self.delete()
|
|
791
|
+
|
|
792
|
+
@classmethod
|
|
793
|
+
def filter(cls, **filters):
|
|
794
|
+
return _QuerySet(cls, **filters)
|
|
795
|
+
|
|
796
|
+
@classmethod
|
|
797
|
+
def search(cls, **terms):
|
|
798
|
+
"""Find rows by case-insensitive substring matches on string fields."""
|
|
799
|
+
return _QuerySet(cls, search=True, **terms)
|
|
800
|
+
|
|
801
|
+
@classmethod
|
|
802
|
+
def search_any(cls, query: str, *fields):
|
|
803
|
+
"""Search a term across selected string fields using OR matching."""
|
|
804
|
+
if not isinstance(query, str) or not query:
|
|
805
|
+
raise ValueError("search query must be a non-empty string")
|
|
806
|
+
if not fields:
|
|
807
|
+
fields = tuple(
|
|
808
|
+
column.key for column in cls.__table__.columns
|
|
809
|
+
if isinstance(column.type, String)
|
|
810
|
+
)
|
|
811
|
+
if not fields:
|
|
812
|
+
raise ValueError(f"{cls.__name__} has no string fields to search")
|
|
813
|
+
return _QuerySet(cls, search_any=True, **{field: query for field in fields})
|
|
814
|
+
|
|
815
|
+
@classmethod
|
|
816
|
+
def asearch(cls, **terms):
|
|
817
|
+
"""Build an async case-insensitive substring search across all terms."""
|
|
818
|
+
return _AsyncQuerySet(cls, search=True, **terms)
|
|
819
|
+
|
|
820
|
+
@classmethod
|
|
821
|
+
def asearch_any(cls, query: str, *fields):
|
|
822
|
+
"""Build an async OR search across selected string fields."""
|
|
823
|
+
if not isinstance(query, str) or not query:
|
|
824
|
+
raise ValueError("search query must be a non-empty string")
|
|
825
|
+
if not fields:
|
|
826
|
+
fields = tuple(
|
|
827
|
+
column.key for column in cls.__table__.columns
|
|
828
|
+
if isinstance(column.type, String)
|
|
829
|
+
)
|
|
830
|
+
if not fields:
|
|
831
|
+
raise ValueError(f"{cls.__name__} has no string fields to search")
|
|
832
|
+
return _AsyncQuerySet(cls, search_any=True, **{field: query for field in fields})
|
|
833
|
+
|
|
834
|
+
@classmethod
|
|
835
|
+
def unique(cls, **filters):
|
|
836
|
+
"""Build a lookup that requires at most one matching row."""
|
|
837
|
+
return _SingleQuery(cls, **filters)
|
|
838
|
+
|
|
839
|
+
|
|
840
|
+
def _field(sql_type: Any, **kwargs):
|
|
841
|
+
blank = kwargs.pop("blank", True)
|
|
842
|
+
info = dict(kwargs.pop("info", {}) or {})
|
|
843
|
+
info.setdefault("blank", blank)
|
|
844
|
+
kwargs["info"] = info
|
|
845
|
+
kwargs.setdefault("nullable", True)
|
|
846
|
+
return mapped_column(sql_type, **kwargs)
|
|
847
|
+
|
|
848
|
+
|
|
849
|
+
def AutoField(**kwargs):
|
|
850
|
+
"""Declare an integer primary key; `Model` already provides `id` by default."""
|
|
851
|
+
kwargs.setdefault("primary_key", True)
|
|
852
|
+
kwargs.setdefault("autoincrement", True)
|
|
853
|
+
kwargs.setdefault("nullable", False)
|
|
854
|
+
return _field(Integer, **kwargs)
|
|
855
|
+
|
|
856
|
+
|
|
857
|
+
def IntegerField(**kwargs):
|
|
858
|
+
return _field(Integer, **kwargs)
|
|
859
|
+
|
|
860
|
+
|
|
861
|
+
def BigIntegerField(**kwargs):
|
|
862
|
+
"""Declare a signed 64-bit integer column."""
|
|
863
|
+
return _field(BigInteger, **kwargs)
|
|
864
|
+
|
|
865
|
+
|
|
866
|
+
BigIntField = BigIntegerField
|
|
867
|
+
|
|
868
|
+
|
|
869
|
+
def StringField(max_length: int = 255, **kwargs):
|
|
870
|
+
return _field(String(max_length), **kwargs)
|
|
871
|
+
|
|
872
|
+
|
|
873
|
+
def UUIDField(**kwargs):
|
|
874
|
+
"""Declare a native UUID column, generating a UUID4 when omitted."""
|
|
875
|
+
kwargs.setdefault("default", uuid.uuid4)
|
|
876
|
+
return _field(Uuid(as_uuid=True), **kwargs)
|
|
877
|
+
|
|
878
|
+
|
|
879
|
+
def ImageField(upload_to: str = "uploads/images", max_length: int = 500, **kwargs):
|
|
880
|
+
"""Store a relative image path; upload bytes through ``Request.form()``."""
|
|
881
|
+
upload_to = upload_to.strip("/")
|
|
882
|
+
if not upload_to or any(part in {".", ".."} for part in upload_to.split("/")):
|
|
883
|
+
raise ValueError("upload_to must be a safe relative directory")
|
|
884
|
+
info = dict(kwargs.pop("info", {}) or {})
|
|
885
|
+
info.update({"upload_to": upload_to, "media_type": "image"})
|
|
886
|
+
return _field(String(max_length), info=info, **kwargs)
|
|
887
|
+
|
|
888
|
+
|
|
889
|
+
def EmailField(max_length: int = 254, **kwargs):
|
|
890
|
+
"""Declare a string column intended for an email address."""
|
|
891
|
+
return StringField(max_length=max_length, **kwargs)
|
|
892
|
+
|
|
893
|
+
|
|
894
|
+
def IPField(max_length: int = 45, **kwargs):
|
|
895
|
+
"""Declare a string column sized for IPv4 or IPv6 addresses."""
|
|
896
|
+
return StringField(max_length=max_length, **kwargs)
|
|
897
|
+
|
|
898
|
+
|
|
899
|
+
def PhoneField(max_length: int = 32, **kwargs):
|
|
900
|
+
"""Declare a string column intended for an international phone number."""
|
|
901
|
+
return StringField(max_length=max_length, **kwargs)
|
|
902
|
+
|
|
903
|
+
|
|
904
|
+
def TextField(**kwargs):
|
|
905
|
+
return _field(Text, **kwargs)
|
|
906
|
+
|
|
907
|
+
|
|
908
|
+
# Spelling aliases for projects that prefer the original snake-style names.
|
|
909
|
+
Email_field = EmailField
|
|
910
|
+
IP_field = IPField
|
|
911
|
+
Phone_field = PhoneField
|
|
912
|
+
TExtfield = TextField
|
|
913
|
+
|
|
914
|
+
|
|
915
|
+
def BooleanField(**kwargs):
|
|
916
|
+
"""Declare a boolean column that defaults to ``True`` unless overridden."""
|
|
917
|
+
kwargs.setdefault("default", True)
|
|
918
|
+
return _field(Boolean, **kwargs)
|
|
919
|
+
|
|
920
|
+
|
|
921
|
+
def FloatField(**kwargs):
|
|
922
|
+
return _field(Float, **kwargs)
|
|
923
|
+
|
|
924
|
+
|
|
925
|
+
def DecimalField(max_digits: int = 10, decimal_places: int = 2, **kwargs):
|
|
926
|
+
"""Declare a fixed-precision decimal column returning ``decimal.Decimal`` values."""
|
|
927
|
+
if max_digits <= 0 or decimal_places < 0 or decimal_places > max_digits:
|
|
928
|
+
raise ValueError("DecimalField requires max_digits > 0 and 0 <= decimal_places <= max_digits")
|
|
929
|
+
return _field(Numeric(precision=max_digits, scale=decimal_places, asdecimal=True), **kwargs)
|
|
930
|
+
|
|
931
|
+
|
|
932
|
+
def DateTimeField(**kwargs):
|
|
933
|
+
return _field(DateTime, **kwargs)
|
|
934
|
+
|
|
935
|
+
|
|
936
|
+
def JSONField(**kwargs):
|
|
937
|
+
return _field(JSON, **kwargs)
|
|
938
|
+
|
|
939
|
+
|
|
940
|
+
def ForeignKeyField(target: str, *, model=PROTECT, on_delete: str | None = None, **kwargs):
|
|
941
|
+
"""Declare a foreign key with model=cascade.delete, model=null, or model=protected."""
|
|
942
|
+
constraint_name = kwargs.pop("constraint_name", None)
|
|
943
|
+
policies = {CASCADE: "CASCADE", SET_NULL: "SET NULL", PROTECT: "RESTRICT"}
|
|
944
|
+
# Keep on_delete as a readable alias while accepting the requested `model=` API.
|
|
945
|
+
if on_delete is not None:
|
|
946
|
+
if model != PROTECT:
|
|
947
|
+
raise ValueError("Pass either model= or on_delete=, not both")
|
|
948
|
+
model = on_delete
|
|
949
|
+
on_delete = model
|
|
950
|
+
if isinstance(on_delete, str):
|
|
951
|
+
normalized = on_delete.upper().replace(".", "_")
|
|
952
|
+
aliases = {
|
|
953
|
+
"CASCADE_DELETE": CASCADE,
|
|
954
|
+
"NULL": SET_NULL,
|
|
955
|
+
"PROTECTED": PROTECT,
|
|
956
|
+
}
|
|
957
|
+
on_delete = aliases.get(normalized, normalized)
|
|
958
|
+
if on_delete not in policies:
|
|
959
|
+
raise ValueError("on_delete must be CASCADE, SET_NULL, or PROTECT")
|
|
960
|
+
nullable = kwargs.pop("nullable", True)
|
|
961
|
+
if on_delete == SET_NULL and not nullable:
|
|
962
|
+
raise ValueError("ForeignKeyField(on_delete=SET_NULL) requires nullable=True")
|
|
963
|
+
return _field(
|
|
964
|
+
ForeignKey(target, name=constraint_name, ondelete=policies[on_delete]),
|
|
965
|
+
nullable=nullable,
|
|
966
|
+
**kwargs,
|
|
967
|
+
)
|
|
968
|
+
|
|
969
|
+
|
|
970
|
+
class _ManyRelatedSet(set):
|
|
971
|
+
"""Set-like SQLAlchemy relationship collection with list-style all()."""
|
|
972
|
+
|
|
973
|
+
def all(self):
|
|
974
|
+
return list(self)
|
|
975
|
+
|
|
976
|
+
def set(self, values):
|
|
977
|
+
self.clear()
|
|
978
|
+
self.update(values)
|
|
979
|
+
return self
|
|
980
|
+
|
|
981
|
+
|
|
982
|
+
def ManyToManyField(target, *, table_name: str | None = None, back_populates: str | None = None):
|
|
983
|
+
"""Declare a many-to-many relationship with an inferred association table.
|
|
984
|
+
|
|
985
|
+
``target`` may be a model class or its class name. By default the join
|
|
986
|
+
table is named ``<source_table>_<field_name>`` and is included in model
|
|
987
|
+
metadata so Alembic can migrate it.
|
|
988
|
+
"""
|
|
989
|
+
field_holder = {}
|
|
990
|
+
|
|
991
|
+
def make_relationship(cls):
|
|
992
|
+
field_name = next(
|
|
993
|
+
(name for name, value in cls.__dict__.items() if value is field_holder["attribute"]),
|
|
994
|
+
None,
|
|
995
|
+
)
|
|
996
|
+
if field_name is None:
|
|
997
|
+
raise RuntimeError("Could not determine ManyToManyField name")
|
|
998
|
+
|
|
999
|
+
target_class = target if isinstance(target, type) else None
|
|
1000
|
+
target_name = target_class.__name__ if target_class is not None else str(target).rsplit(".", 1)[-1]
|
|
1001
|
+
target_table = getattr(target_class, "__tablename__", target_name.lower())
|
|
1002
|
+
source_table = cls.__tablename__
|
|
1003
|
+
association_name = table_name or f"{source_table}_{field_name}"
|
|
1004
|
+
association = cls.metadata.tables.get(association_name)
|
|
1005
|
+
if association is None:
|
|
1006
|
+
if source_table == target_table:
|
|
1007
|
+
source_column, target_column = f"{field_name}_source_id", f"{field_name}_target_id"
|
|
1008
|
+
else:
|
|
1009
|
+
source_column, target_column = f"{source_table}_id", f"{target_table}_id"
|
|
1010
|
+
association = Table(
|
|
1011
|
+
association_name,
|
|
1012
|
+
cls.metadata,
|
|
1013
|
+
Column(source_column, ForeignKey(f"{source_table}.id", ondelete="CASCADE"), primary_key=True),
|
|
1014
|
+
Column(target_column, ForeignKey(f"{target_table}.id", ondelete="CASCADE"), primary_key=True),
|
|
1015
|
+
)
|
|
1016
|
+
return relationship(
|
|
1017
|
+
target if target_class is not None else target_name,
|
|
1018
|
+
secondary=association,
|
|
1019
|
+
back_populates=back_populates,
|
|
1020
|
+
collection_class=_ManyRelatedSet,
|
|
1021
|
+
)
|
|
1022
|
+
|
|
1023
|
+
attribute = declared_attr(make_relationship)
|
|
1024
|
+
field_holder["attribute"] = attribute
|
|
1025
|
+
return attribute
|
|
1026
|
+
|
|
1027
|
+
|
|
1028
|
+
# Shared persistent key/value table for stateful security features. It is part
|
|
1029
|
+
# of normal model metadata so `python lenora.py migration` manages its lifecycle.
|
|
1030
|
+
SECURITY_STORE_TABLE = Table(
|
|
1031
|
+
"lenapi_security_store",
|
|
1032
|
+
Model.metadata,
|
|
1033
|
+
Column("key", String(64), primary_key=True),
|
|
1034
|
+
Column("value", Text, nullable=True),
|
|
1035
|
+
Column("expires_at", Float, nullable=True),
|
|
1036
|
+
)
|
|
1037
|
+
|
|
1038
|
+
# Persistent background jobs are managed by the normal Alembic migration flow.
|
|
1039
|
+
BACKGROUND_JOBS_TABLE = Table(
|
|
1040
|
+
"lenapi_background_jobs",
|
|
1041
|
+
Model.metadata,
|
|
1042
|
+
Column("job_id", String(36), primary_key=True),
|
|
1043
|
+
Column("task_name", String(200), nullable=False, index=True),
|
|
1044
|
+
Column("payload", JSON, nullable=False),
|
|
1045
|
+
Column("status", String(16), nullable=False, index=True),
|
|
1046
|
+
Column("attempts", Integer, nullable=False, default=0),
|
|
1047
|
+
Column("available_at", Float, nullable=False, index=True),
|
|
1048
|
+
Column("locked_until", Float, nullable=True),
|
|
1049
|
+
Column("last_error", Text, nullable=True),
|
|
1050
|
+
Column("created_at", Float, nullable=False),
|
|
1051
|
+
Column("completed_at", Float, nullable=True),
|
|
1052
|
+
)
|