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/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
+ )