rb-commons 0.7.4__py3-none-any.whl → 0.7.6__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.
- rb_commons/orm/managers.py +98 -134
- rb_commons/orm/querysets.py +56 -0
- {rb_commons-0.7.4.dist-info → rb_commons-0.7.6.dist-info}/METADATA +1 -1
- {rb_commons-0.7.4.dist-info → rb_commons-0.7.6.dist-info}/RECORD +6 -5
- {rb_commons-0.7.4.dist-info → rb_commons-0.7.6.dist-info}/WHEEL +0 -0
- {rb_commons-0.7.4.dist-info → rb_commons-0.7.6.dist-info}/top_level.txt +0 -0
rb_commons/orm/managers.py
CHANGED
@@ -2,68 +2,18 @@ from __future__ import annotations
|
|
2
2
|
|
3
3
|
import re
|
4
4
|
import uuid
|
5
|
-
from typing import TypeVar, Type, Generic, Optional, List, Dict, Literal, Union, Sequence, Any, Iterable
|
5
|
+
from typing import TypeVar, Type, Generic, Optional, List, Dict, Literal, Union, Sequence, Any, Iterable, Callable
|
6
6
|
from sqlalchemy import select, delete, update, and_, func, desc, inspect, or_, asc, true
|
7
7
|
from sqlalchemy.exc import IntegrityError, SQLAlchemyError
|
8
8
|
from sqlalchemy.ext.asyncio import AsyncSession
|
9
9
|
from sqlalchemy.orm import declarative_base, selectinload, RelationshipProperty, Load
|
10
10
|
from rb_commons.http.exceptions import NotFoundException
|
11
11
|
from rb_commons.orm.exceptions import DatabaseException, InternalException
|
12
|
-
from functools import lru_cache
|
13
|
-
|
12
|
+
from functools import lru_cache, wraps
|
13
|
+
from rb_commons.orm.querysets import Q, QJSON
|
14
14
|
|
15
15
|
ModelType = TypeVar('ModelType', bound=declarative_base())
|
16
16
|
|
17
|
-
class QJSON:
|
18
|
-
def __init__(self, field: str, key: str, operator: str, value: Any):
|
19
|
-
self.field = field
|
20
|
-
self.key = key
|
21
|
-
self.operator = operator
|
22
|
-
self.value = value
|
23
|
-
|
24
|
-
def __repr__(self):
|
25
|
-
return f"QJSON(field={self.field}, key={self.key}, op={self.operator}, value={self.value})"
|
26
|
-
|
27
|
-
class Q:
|
28
|
-
"""Boolean logic container that can be combined with `&`, `|`, and `~`."""
|
29
|
-
|
30
|
-
def __init__(self, **lookups: Any) -> None:
|
31
|
-
self.lookups: Dict[str, Any] = lookups
|
32
|
-
self.children: List[Q] = []
|
33
|
-
self._operator: str = "AND"
|
34
|
-
self.negated: bool = False
|
35
|
-
|
36
|
-
def _combine(self, other: "Q", operator: str) -> "Q":
|
37
|
-
combined = Q()
|
38
|
-
combined.children = [self, other]
|
39
|
-
combined._operator = operator
|
40
|
-
return combined
|
41
|
-
|
42
|
-
def __or__(self, other: "Q") -> "Q":
|
43
|
-
return self._combine(other, "OR")
|
44
|
-
|
45
|
-
def __and__(self, other: "Q") -> "Q":
|
46
|
-
return self._combine(other, "AND")
|
47
|
-
|
48
|
-
def __invert__(self) -> "Q":
|
49
|
-
clone = Q()
|
50
|
-
clone.lookups = self.lookups.copy()
|
51
|
-
clone.children = list(self.children)
|
52
|
-
clone._operator = self._operator
|
53
|
-
clone.negated = not self.negated
|
54
|
-
return clone
|
55
|
-
|
56
|
-
def __repr__(self) -> str:
|
57
|
-
if self.lookups:
|
58
|
-
base = f"Q({self.lookups})"
|
59
|
-
else:
|
60
|
-
base = "Q()"
|
61
|
-
if self.children:
|
62
|
-
base += f" {self._operator} {self.children}"
|
63
|
-
if self.negated:
|
64
|
-
base = f"NOT({base})"
|
65
|
-
return base
|
66
|
-
|
67
17
|
def with_transaction_error_handling(func):
|
68
18
|
async def wrapper(self, *args, **kwargs):
|
69
19
|
try:
|
@@ -79,6 +29,20 @@ def with_transaction_error_handling(func):
|
|
79
29
|
raise InternalException(f"Unexpected error: {str(e)}") from e
|
80
30
|
return wrapper
|
81
31
|
|
32
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
33
|
+
|
34
|
+
def query_mutator(func: F) -> F:
|
35
|
+
"""
|
36
|
+
Make a query‑builder method clone‑on‑write without touching its body.
|
37
|
+
"""
|
38
|
+
@wraps(func)
|
39
|
+
def wrapper(self: "BaseManager[Any]", *args, **kwargs):
|
40
|
+
clone = self._clone()
|
41
|
+
result = func(clone, *args, **kwargs)
|
42
|
+
return result if result is not None else clone
|
43
|
+
return wrapper
|
44
|
+
|
45
|
+
|
82
46
|
class BaseManager(Generic[ModelType]):
|
83
47
|
model: Type[ModelType]
|
84
48
|
|
@@ -93,6 +57,18 @@ class BaseManager(Generic[ModelType]):
|
|
93
57
|
mapper = inspect(self.model)
|
94
58
|
self._column_keys = [c.key for c in mapper.mapper.column_attrs]
|
95
59
|
|
60
|
+
def _clone(self) -> "BaseManager[ModelType]":
|
61
|
+
"""
|
62
|
+
Shallow‑copy all mutable query state into a new manager instance.
|
63
|
+
"""
|
64
|
+
clone = self.__class__(self.session)
|
65
|
+
clone.filters = list(self.filters)
|
66
|
+
clone._order_by = list(self._order_by)
|
67
|
+
clone._limit = self._limit
|
68
|
+
clone._joins = set(self._joins)
|
69
|
+
clone._filtered = self._filtered
|
70
|
+
return clone
|
71
|
+
|
96
72
|
async def _smart_commit(self, instance: Optional[ModelType] = None) -> Optional[ModelType]:
|
97
73
|
if not self.session.in_transaction():
|
98
74
|
await self.session.commit()
|
@@ -200,6 +176,66 @@ class BaseManager(Generic[ModelType]):
|
|
200
176
|
return json_expr.in_(qjson.value)
|
201
177
|
raise ValueError(f"Unsupported QJSON operator: {qjson.operator}")
|
202
178
|
|
179
|
+
def _build_relation_loaders(
|
180
|
+
self,
|
181
|
+
model: Any,
|
182
|
+
relations: Sequence[str] | None = None
|
183
|
+
) -> List[Load]:
|
184
|
+
"""
|
185
|
+
Given e.g. ["media", "properties.property", "properties__property"],
|
186
|
+
returns [
|
187
|
+
selectinload(Product.media),
|
188
|
+
selectinload(Product.properties).selectinload(Property.property)
|
189
|
+
].
|
190
|
+
|
191
|
+
If `relations` is None or empty, recurse *all* relationships once (cycle-safe).
|
192
|
+
"""
|
193
|
+
loaders: List[Load] = []
|
194
|
+
|
195
|
+
if relations:
|
196
|
+
for path in relations:
|
197
|
+
parts = re.split(r"\.|\_\_", path)
|
198
|
+
current_model = model
|
199
|
+
loader: Load | None = None
|
200
|
+
|
201
|
+
for part in parts:
|
202
|
+
attr = getattr(current_model, part, None)
|
203
|
+
if attr is None or not hasattr(attr, "property"):
|
204
|
+
raise ValueError(f"Invalid relationship path: {path!r}")
|
205
|
+
loader = selectinload(attr) if loader is None else loader.selectinload(attr)
|
206
|
+
current_model = attr.property.mapper.class_
|
207
|
+
|
208
|
+
loaders.append(loader)
|
209
|
+
|
210
|
+
return loaders
|
211
|
+
|
212
|
+
visited = set()
|
213
|
+
|
214
|
+
def recurse(curr_model: Any, curr_loader: Load | None = None):
|
215
|
+
mapper = inspect(curr_model)
|
216
|
+
if mapper in visited:
|
217
|
+
return
|
218
|
+
visited.add(mapper)
|
219
|
+
|
220
|
+
for rel in mapper.relationships:
|
221
|
+
attr = getattr(curr_model, rel.key)
|
222
|
+
loader = (
|
223
|
+
selectinload(attr)
|
224
|
+
if curr_loader is None
|
225
|
+
else curr_loader.selectinload(attr)
|
226
|
+
)
|
227
|
+
loaders.append(loader)
|
228
|
+
recurse(rel.mapper.class_, loader)
|
229
|
+
|
230
|
+
recurse(model)
|
231
|
+
return loaders
|
232
|
+
|
233
|
+
async def _execute_query(self, stmt):
|
234
|
+
result = await self.session.execute(stmt)
|
235
|
+
rows = result.scalars().all()
|
236
|
+
return list({obj.id: obj for obj in rows}.values())
|
237
|
+
|
238
|
+
@query_mutator
|
203
239
|
def order_by(self, *columns: Any):
|
204
240
|
"""Collect ORDER BY clauses.
|
205
241
|
"""
|
@@ -216,6 +252,7 @@ class BaseManager(Generic[ModelType]):
|
|
216
252
|
|
217
253
|
return self
|
218
254
|
|
255
|
+
@query_mutator
|
219
256
|
def filter(self, *expressions: Any, **lookups: Any) -> "BaseManager":
|
220
257
|
self._filtered = True
|
221
258
|
|
@@ -236,6 +273,7 @@ class BaseManager(Generic[ModelType]):
|
|
236
273
|
|
237
274
|
return self
|
238
275
|
|
276
|
+
@query_mutator
|
239
277
|
def or_filter(self, *expressions: Any, **lookups: Any) -> "BaseManager[ModelType]":
|
240
278
|
"""Add one OR group (shortcut for `filter(Q() | Q())`)."""
|
241
279
|
|
@@ -254,6 +292,7 @@ class BaseManager(Generic[ModelType]):
|
|
254
292
|
self.filters.append(or_(*or_clauses))
|
255
293
|
return self
|
256
294
|
|
295
|
+
@query_mutator
|
257
296
|
def exclude(self, *expressions: Any, **lookups: Any) -> "BaseManager[ModelType]":
|
258
297
|
"""
|
259
298
|
Exclude records that match the given conditions.
|
@@ -297,75 +336,11 @@ class BaseManager(Generic[ModelType]):
|
|
297
336
|
|
298
337
|
return self
|
299
338
|
|
339
|
+
@query_mutator
|
300
340
|
def limit(self, value: int) -> "BaseManager[ModelType]":
|
301
341
|
self._limit = value
|
302
342
|
return self
|
303
343
|
|
304
|
-
def _build_relation_loaders(
|
305
|
-
self,
|
306
|
-
model: Any,
|
307
|
-
relations: Sequence[str] | None = None
|
308
|
-
) -> List[Load]:
|
309
|
-
"""
|
310
|
-
Given e.g. ["media", "properties.property", "properties__property"],
|
311
|
-
returns [
|
312
|
-
selectinload(Product.media),
|
313
|
-
selectinload(Product.properties).selectinload(Property.property)
|
314
|
-
].
|
315
|
-
|
316
|
-
If `relations` is None or empty, recurse *all* relationships once (cycle-safe).
|
317
|
-
"""
|
318
|
-
loaders: List[Load] = []
|
319
|
-
|
320
|
-
if relations:
|
321
|
-
for path in relations:
|
322
|
-
parts = re.split(r"\.|\_\_", path)
|
323
|
-
current_model = model
|
324
|
-
loader: Load | None = None
|
325
|
-
|
326
|
-
for part in parts:
|
327
|
-
attr = getattr(current_model, part, None)
|
328
|
-
if attr is None or not hasattr(attr, "property"):
|
329
|
-
raise ValueError(f"Invalid relationship path: {path!r}")
|
330
|
-
loader = selectinload(attr) if loader is None else loader.selectinload(attr)
|
331
|
-
current_model = attr.property.mapper.class_
|
332
|
-
|
333
|
-
loaders.append(loader)
|
334
|
-
|
335
|
-
return loaders
|
336
|
-
|
337
|
-
visited = set()
|
338
|
-
|
339
|
-
def recurse(curr_model: Any, curr_loader: Load | None = None):
|
340
|
-
mapper = inspect(curr_model)
|
341
|
-
if mapper in visited:
|
342
|
-
return
|
343
|
-
visited.add(mapper)
|
344
|
-
|
345
|
-
for rel in mapper.relationships:
|
346
|
-
attr = getattr(curr_model, rel.key)
|
347
|
-
loader = (
|
348
|
-
selectinload(attr)
|
349
|
-
if curr_loader is None
|
350
|
-
else curr_loader.selectinload(attr)
|
351
|
-
)
|
352
|
-
loaders.append(loader)
|
353
|
-
recurse(rel.mapper.class_, loader)
|
354
|
-
|
355
|
-
recurse(model)
|
356
|
-
return loaders
|
357
|
-
|
358
|
-
async def _execute_query(self, stmt):
|
359
|
-
result = await self.session.execute(stmt)
|
360
|
-
rows = result.scalars().all()
|
361
|
-
return list({obj.id: obj for obj in rows}.values())
|
362
|
-
|
363
|
-
def _reset_state(self):
|
364
|
-
self.filters.clear()
|
365
|
-
self._filtered = False
|
366
|
-
self._limit = None
|
367
|
-
self._joins.clear()
|
368
|
-
|
369
344
|
async def all(self, relations: Optional[List[str]] = None):
|
370
345
|
stmt = select(self.model)
|
371
346
|
|
@@ -380,10 +355,7 @@ class BaseManager(Generic[ModelType]):
|
|
380
355
|
if self._limit:
|
381
356
|
stmt = stmt.limit(self._limit)
|
382
357
|
|
383
|
-
|
384
|
-
return await self._execute_query(stmt)
|
385
|
-
finally:
|
386
|
-
self._reset_state()
|
358
|
+
return await self._execute_query(stmt)
|
387
359
|
|
388
360
|
async def first(self, relations: Optional[Sequence[str]] = None):
|
389
361
|
self._ensure_filtered()
|
@@ -397,22 +369,19 @@ class BaseManager(Generic[ModelType]):
|
|
397
369
|
stmt = stmt.options(*opts)
|
398
370
|
|
399
371
|
result = await self.session.execute(stmt)
|
400
|
-
self._reset_state()
|
401
372
|
return result.scalars().first()
|
402
373
|
|
403
|
-
|
404
374
|
async def last(self, relations: Optional[Sequence[str]] = None):
|
405
375
|
self._ensure_filtered()
|
406
376
|
stmt = select(self.model).filter(and_(*self.filters))
|
407
377
|
order = self._order_by or [self.model.id.desc()]
|
408
|
-
stmt = stmt.order_by(*order[::-1])
|
378
|
+
stmt = stmt.order_by(*order[::-1])
|
409
379
|
|
410
380
|
if relations:
|
411
381
|
opts = self._build_relation_loaders(self.model, relations)
|
412
382
|
stmt = stmt.options(*opts)
|
413
383
|
|
414
384
|
result = await self.session.execute(stmt)
|
415
|
-
self._reset_state()
|
416
385
|
return result.scalars().first()
|
417
386
|
|
418
387
|
async def count(self) -> int | None:
|
@@ -423,7 +392,6 @@ class BaseManager(Generic[ModelType]):
|
|
423
392
|
stmt = stmt.where(and_(*self.filters))
|
424
393
|
|
425
394
|
result = await self.session.execute(stmt)
|
426
|
-
self._reset_state()
|
427
395
|
return int(result.scalar_one())
|
428
396
|
|
429
397
|
async def paginate(self, limit: int = 10, offset: int = 0, relations: Optional[Sequence[str]] = None):
|
@@ -437,10 +405,7 @@ class BaseManager(Generic[ModelType]):
|
|
437
405
|
if self._order_by:
|
438
406
|
stmt = stmt.order_by(*self._order_by)
|
439
407
|
stmt = stmt.limit(limit).offset(offset)
|
440
|
-
|
441
|
-
return await self._execute_query(stmt)
|
442
|
-
finally:
|
443
|
-
self._reset_state()
|
408
|
+
return await self._execute_query(stmt)
|
444
409
|
|
445
410
|
@with_transaction_error_handling
|
446
411
|
async def create(self, **kwargs):
|
@@ -505,7 +470,6 @@ class BaseManager(Generic[ModelType]):
|
|
505
470
|
stmt = delete(self.model).where(and_(*self.filters))
|
506
471
|
await self.session.execute(stmt)
|
507
472
|
await self.session.commit()
|
508
|
-
self._reset_state()
|
509
473
|
return True
|
510
474
|
|
511
475
|
@with_transaction_error_handling
|
@@ -523,7 +487,6 @@ class BaseManager(Generic[ModelType]):
|
|
523
487
|
stmt = delete(self.model).where(and_(*self.filters))
|
524
488
|
result = await self.session.execute(stmt)
|
525
489
|
await self._smart_commit()
|
526
|
-
self._reset_state()
|
527
490
|
return result.rowcount
|
528
491
|
|
529
492
|
async def get(self, pk: Union[str, int, uuid.UUID], relations: Optional[Sequence[str]] = None) -> Any:
|
@@ -547,9 +510,9 @@ class BaseManager(Generic[ModelType]):
|
|
547
510
|
.limit(1)
|
548
511
|
)
|
549
512
|
result = await self.session.execute(stmt)
|
550
|
-
self._reset_state()
|
551
513
|
return result.scalars().first() is not None
|
552
514
|
|
515
|
+
@query_mutator
|
553
516
|
def has_relation(self, relation_name: str):
|
554
517
|
relationship = getattr(self.model, relation_name)
|
555
518
|
subquery = (
|
@@ -562,6 +525,7 @@ class BaseManager(Generic[ModelType]):
|
|
562
525
|
self._filtered = True
|
563
526
|
return self
|
564
527
|
|
528
|
+
@query_mutator
|
565
529
|
def sort_by(self, tokens: Sequence[str]) -> "BaseManager[ModelType]":
|
566
530
|
"""
|
567
531
|
Dynamically apply ORDER BY clauses based on a list of "field" or "-field" tokens.
|
@@ -0,0 +1,56 @@
|
|
1
|
+
from typing import Any
|
2
|
+
|
3
|
+
from functools import wraps
|
4
|
+
from typing import Callable, TypeVar, Any
|
5
|
+
|
6
|
+
class QJSON:
|
7
|
+
def __init__(self, field: str, key: str, operator: str, value: Any):
|
8
|
+
self.field = field
|
9
|
+
self.key = key
|
10
|
+
self.operator = operator
|
11
|
+
self.value = value
|
12
|
+
|
13
|
+
def __repr__(self):
|
14
|
+
return f"QJSON(field={self.field}, key={self.key}, op={self.operator}, value={self.value})"
|
15
|
+
|
16
|
+
class Q:
|
17
|
+
"""Boolean logic container that can be combined with `&`, `|`, and `~`."""
|
18
|
+
|
19
|
+
def __init__(self, **lookups: Any) -> None:
|
20
|
+
self.lookups: Dict[str, Any] = lookups
|
21
|
+
self.children: List[Q] = []
|
22
|
+
self._operator: str = "AND"
|
23
|
+
self.negated: bool = False
|
24
|
+
|
25
|
+
def _combine(self, other: "Q", operator: str) -> "Q":
|
26
|
+
combined = Q()
|
27
|
+
combined.children = [self, other]
|
28
|
+
combined._operator = operator
|
29
|
+
return combined
|
30
|
+
|
31
|
+
def __or__(self, other: "Q") -> "Q":
|
32
|
+
return self._combine(other, "OR")
|
33
|
+
|
34
|
+
def __and__(self, other: "Q") -> "Q":
|
35
|
+
return self._combine(other, "AND")
|
36
|
+
|
37
|
+
def __invert__(self) -> "Q":
|
38
|
+
clone = Q()
|
39
|
+
clone.lookups = self.lookups.copy()
|
40
|
+
clone.children = list(self.children)
|
41
|
+
clone._operator = self._operator
|
42
|
+
clone.negated = not self.negated
|
43
|
+
return clone
|
44
|
+
|
45
|
+
def __repr__(self) -> str:
|
46
|
+
if self.lookups:
|
47
|
+
base = f"Q({self.lookups})"
|
48
|
+
else:
|
49
|
+
base = "Q()"
|
50
|
+
if self.children:
|
51
|
+
base += f" {self._operator} {self.children}"
|
52
|
+
if self.negated:
|
53
|
+
base = f"NOT({base})"
|
54
|
+
return base
|
55
|
+
|
56
|
+
|
@@ -13,7 +13,8 @@ rb_commons/http/consul.py,sha256=Ioq72VD1jGwoC96set7n2SgxN40olzI-myA2lwKkYi4,186
|
|
13
13
|
rb_commons/http/exceptions.py,sha256=EGRMr1cRgiJ9Q2tkfANbf0c6-zzXf1CD6J3cmCaT_FA,1885
|
14
14
|
rb_commons/orm/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
15
15
|
rb_commons/orm/exceptions.py,sha256=1aMctiEwrPjyehoXVX1l6ML5ZOhmDkmBISzlTD5ey1Y,509
|
16
|
-
rb_commons/orm/managers.py,sha256=
|
16
|
+
rb_commons/orm/managers.py,sha256=BrpMvOkRoAPKtUV1SfakXMYd73-RhqIaoj1Wmru1gAs,20319
|
17
|
+
rb_commons/orm/querysets.py,sha256=Q4iY_TKZItpNnrbCAvlSwyCxJikhaX5qVF3Y2rVgAYQ,1666
|
17
18
|
rb_commons/orm/services.py,sha256=71eRcJ4TxZvzNz-hLXo12X4U7PGK54ZfbLAb27AjZi8,1589
|
18
19
|
rb_commons/permissions/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
19
20
|
rb_commons/permissions/role_permissions.py,sha256=4dV89z6ggzLqCCiFYlMp7kQVJRESu6MHpkT5ZNjLo6A,1096
|
@@ -22,7 +23,7 @@ rb_commons/schemes/jwt.py,sha256=ZKLJ5D3fcEmEKySjzbxEgUcza4K-oPoHr14_Z0r9Yic,249
|
|
22
23
|
rb_commons/schemes/pagination.py,sha256=8VZW1wZGJIPR9jEBUgppZUoB4uqP8ORudHkMwvEJSxg,1866
|
23
24
|
rb_commons/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
24
25
|
rb_commons/utils/media.py,sha256=J2Zi0J28DhcVQVzt-myNNVuzj9Msaetul53VjZtdDdc,820
|
25
|
-
rb_commons-0.7.
|
26
|
-
rb_commons-0.7.
|
27
|
-
rb_commons-0.7.
|
28
|
-
rb_commons-0.7.
|
26
|
+
rb_commons-0.7.6.dist-info/METADATA,sha256=w9fHfL7CavqLJeDqwzhW0hYF3PKfOO5F8byUhgrASUA,6570
|
27
|
+
rb_commons-0.7.6.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
28
|
+
rb_commons-0.7.6.dist-info/top_level.txt,sha256=HPx_WAYo3_fbg1WCeGHsz3wPGio1ucbnrlm2lmqlJog,11
|
29
|
+
rb_commons-0.7.6.dist-info/RECORD,,
|
File without changes
|
File without changes
|