rb-commons 0.4.13__tar.gz → 0.5.1__tar.gz
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-0.4.13 → rb_commons-0.5.1}/PKG-INFO +1 -1
- rb_commons-0.5.1/rb_commons/orm/managers.py +393 -0
- {rb_commons-0.4.13 → rb_commons-0.5.1}/rb_commons.egg-info/PKG-INFO +1 -1
- {rb_commons-0.4.13 → rb_commons-0.5.1}/setup.py +1 -1
- rb_commons-0.4.13/rb_commons/orm/managers.py +0 -463
- {rb_commons-0.4.13 → rb_commons-0.5.1}/README.md +0 -0
- {rb_commons-0.4.13 → rb_commons-0.5.1}/rb_commons/__init__.py +0 -0
- {rb_commons-0.4.13 → rb_commons-0.5.1}/rb_commons/broker/__init__.py +0 -0
- {rb_commons-0.4.13 → rb_commons-0.5.1}/rb_commons/broker/consumer.py +0 -0
- {rb_commons-0.4.13 → rb_commons-0.5.1}/rb_commons/configs/__init__.py +0 -0
- {rb_commons-0.4.13 → rb_commons-0.5.1}/rb_commons/configs/config.py +0 -0
- {rb_commons-0.4.13 → rb_commons-0.5.1}/rb_commons/configs/injections.py +0 -0
- {rb_commons-0.4.13 → rb_commons-0.5.1}/rb_commons/configs/rabbitmq.py +0 -0
- {rb_commons-0.4.13 → rb_commons-0.5.1}/rb_commons/http/__init__.py +0 -0
- {rb_commons-0.4.13 → rb_commons-0.5.1}/rb_commons/http/base_api.py +0 -0
- {rb_commons-0.4.13 → rb_commons-0.5.1}/rb_commons/http/consul.py +0 -0
- {rb_commons-0.4.13 → rb_commons-0.5.1}/rb_commons/http/exceptions.py +0 -0
- {rb_commons-0.4.13 → rb_commons-0.5.1}/rb_commons/orm/__init__.py +0 -0
- {rb_commons-0.4.13 → rb_commons-0.5.1}/rb_commons/orm/exceptions.py +0 -0
- {rb_commons-0.4.13 → rb_commons-0.5.1}/rb_commons/orm/services.py +0 -0
- {rb_commons-0.4.13 → rb_commons-0.5.1}/rb_commons/permissions/__init__.py +0 -0
- {rb_commons-0.4.13 → rb_commons-0.5.1}/rb_commons/permissions/role_permissions.py +0 -0
- {rb_commons-0.4.13 → rb_commons-0.5.1}/rb_commons/schemes/__init__.py +0 -0
- {rb_commons-0.4.13 → rb_commons-0.5.1}/rb_commons/schemes/jwt.py +0 -0
- {rb_commons-0.4.13 → rb_commons-0.5.1}/rb_commons/schemes/pagination.py +0 -0
- {rb_commons-0.4.13 → rb_commons-0.5.1}/rb_commons/utils/__init__.py +0 -0
- {rb_commons-0.4.13 → rb_commons-0.5.1}/rb_commons/utils/media.py +0 -0
- {rb_commons-0.4.13 → rb_commons-0.5.1}/rb_commons.egg-info/SOURCES.txt +0 -0
- {rb_commons-0.4.13 → rb_commons-0.5.1}/rb_commons.egg-info/dependency_links.txt +0 -0
- {rb_commons-0.4.13 → rb_commons-0.5.1}/rb_commons.egg-info/requires.txt +0 -0
- {rb_commons-0.4.13 → rb_commons-0.5.1}/rb_commons.egg-info/top_level.txt +0 -0
- {rb_commons-0.4.13 → rb_commons-0.5.1}/setup.cfg +0 -0
@@ -0,0 +1,393 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
import uuid
|
4
|
+
from typing import TypeVar, Type, Generic, Optional, List, Dict, Literal, Union, Sequence, Any, Iterable
|
5
|
+
from sqlalchemy import select, delete, update, and_, func, desc, inspect, or_
|
6
|
+
from sqlalchemy.exc import IntegrityError, SQLAlchemyError, NoResultFound
|
7
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
8
|
+
from sqlalchemy.orm import declarative_base, InstrumentedAttribute, selectinload, RelationshipProperty
|
9
|
+
|
10
|
+
from rb_commons.http.exceptions import NotFoundException
|
11
|
+
from rb_commons.orm.exceptions import DatabaseException, InternalException
|
12
|
+
|
13
|
+
ModelType = TypeVar('ModelType', bound=declarative_base())
|
14
|
+
|
15
|
+
class Q:
|
16
|
+
"""Boolean logic container that can be combined with `&`, `|`, and `~`."""
|
17
|
+
|
18
|
+
def __init__(self, **lookups: Any) -> None:
|
19
|
+
self.lookups: Dict[str, Any] = lookups
|
20
|
+
self.children: List[Q] = []
|
21
|
+
self._operator: str = "AND"
|
22
|
+
self.negated: bool = False
|
23
|
+
|
24
|
+
def _combine(self, other: "Q", operator: str) -> "Q":
|
25
|
+
combined = Q()
|
26
|
+
combined.children = [self, other]
|
27
|
+
combined._operator = operator
|
28
|
+
return combined
|
29
|
+
|
30
|
+
def __or__(self, other: "Q") -> "Q":
|
31
|
+
return self._combine(other, "OR")
|
32
|
+
|
33
|
+
def __and__(self, other: "Q") -> "Q":
|
34
|
+
return self._combine(other, "AND")
|
35
|
+
|
36
|
+
def __invert__(self) -> "Q":
|
37
|
+
clone = Q()
|
38
|
+
clone.lookups = self.lookups.copy()
|
39
|
+
clone.children = list(self.children)
|
40
|
+
clone._operator = self._operator
|
41
|
+
clone.negated = not self.negated
|
42
|
+
return clone
|
43
|
+
|
44
|
+
def __repr__(self) -> str:
|
45
|
+
if self.lookups:
|
46
|
+
base = f"Q({self.lookups})"
|
47
|
+
else:
|
48
|
+
base = "Q()"
|
49
|
+
if self.children:
|
50
|
+
base += f" {self._operator} {self.children}"
|
51
|
+
if self.negated:
|
52
|
+
base = f"NOT({base})"
|
53
|
+
return base
|
54
|
+
|
55
|
+
def with_transaction_error_handling(func):
|
56
|
+
async def wrapper(self, *args, **kwargs):
|
57
|
+
try:
|
58
|
+
return await func(self, *args, **kwargs)
|
59
|
+
except IntegrityError as e:
|
60
|
+
await self.session.rollback()
|
61
|
+
raise InternalException(f"Constraint violation: {str(e)}") from e
|
62
|
+
except SQLAlchemyError as e:
|
63
|
+
await self.session.rollback()
|
64
|
+
raise DatabaseException(f"Database error: {str(e)}") from e
|
65
|
+
except Exception as e:
|
66
|
+
await self.session.rollback()
|
67
|
+
raise InternalException(f"Unexpected error: {str(e)}") from e
|
68
|
+
return wrapper
|
69
|
+
|
70
|
+
class BaseManager(Generic[ModelType]):
|
71
|
+
model: Type[ModelType]
|
72
|
+
|
73
|
+
def __init__(self, session: AsyncSession) -> None:
|
74
|
+
self.session: AsyncSession = session
|
75
|
+
self.filters: List[Any] = []
|
76
|
+
self._filtered: bool = False
|
77
|
+
self._limit: Optional[int] = None
|
78
|
+
|
79
|
+
async def _smart_commit(self, instance: Optional[ModelType] = None) -> Optional[ModelType]:
|
80
|
+
if not self.session.in_transaction():
|
81
|
+
await self.session.commit()
|
82
|
+
if instance is not None:
|
83
|
+
await self.session.refresh(instance)
|
84
|
+
return instance
|
85
|
+
return None
|
86
|
+
|
87
|
+
def _parse_lookup(self, lookup: str, value: Any):
|
88
|
+
parts = lookup.split("__")
|
89
|
+
operator = "eq"
|
90
|
+
if parts[-1] in {"eq", "ne", "gt", "lt", "gte", "lte", "in", "contains", "null"}:
|
91
|
+
operator = parts.pop()
|
92
|
+
|
93
|
+
current: Union[Type[ModelType], InstrumentedAttribute] = self.model
|
94
|
+
# walk relationships
|
95
|
+
for field in parts:
|
96
|
+
attr = getattr(current, field, None)
|
97
|
+
if attr is None:
|
98
|
+
raise ValueError(f"Invalid filter field: {'.'.join(parts)}")
|
99
|
+
if hasattr(attr, "property") and isinstance(attr.property, RelationshipProperty):
|
100
|
+
current = attr.property.mapper.class_
|
101
|
+
else:
|
102
|
+
current = attr
|
103
|
+
|
104
|
+
if operator == "eq":
|
105
|
+
return current == value
|
106
|
+
if operator == "ne":
|
107
|
+
return current != value
|
108
|
+
if operator == "gt":
|
109
|
+
return current > value
|
110
|
+
if operator == "lt":
|
111
|
+
return current < value
|
112
|
+
if operator == "gte":
|
113
|
+
return current >= value
|
114
|
+
if operator == "lte":
|
115
|
+
return current <= value
|
116
|
+
if operator == "in":
|
117
|
+
if not isinstance(value, (list, tuple, set)):
|
118
|
+
raise ValueError(f"{lookup}__in requires an iterable")
|
119
|
+
return current.in_(value)
|
120
|
+
if operator == "contains":
|
121
|
+
return current.ilike(f"%{value}%")
|
122
|
+
if operator == "null":
|
123
|
+
return current.is_(None) if value else current.isnot(None)
|
124
|
+
raise ValueError(f"Unsupported operator in lookup: {lookup}")
|
125
|
+
|
126
|
+
def _q_to_expr(self, q: Q):
|
127
|
+
clauses: List[Any] = [self._parse_lookup(k, v) for k, v in q.lookups.items()]
|
128
|
+
for child in q.children:
|
129
|
+
clauses.append(self._q_to_expr(child))
|
130
|
+
combined = (
|
131
|
+
True
|
132
|
+
if not clauses
|
133
|
+
else (or_(*clauses) if q._operator == "OR" else and_(*clauses))
|
134
|
+
)
|
135
|
+
return ~combined if q.negated else combined
|
136
|
+
|
137
|
+
def filter(self, *expressions: Any, **lookups: Any) -> "BaseManager[ModelType]":
|
138
|
+
"""Add **AND** constraints (default behaviour).
|
139
|
+
|
140
|
+
* `expressions` can be raw SQLAlchemy clauses **or** `Q` objects.
|
141
|
+
* `lookups` are Django‑style keyword filters.
|
142
|
+
"""
|
143
|
+
self._filtered = True
|
144
|
+
for expr in expressions:
|
145
|
+
self.filters.append(self._q_to_expr(expr) if isinstance(expr, Q) else expr)
|
146
|
+
for k, v in lookups.items():
|
147
|
+
self.filters.append(self._parse_lookup(k, v))
|
148
|
+
return self
|
149
|
+
|
150
|
+
def or_filter(self, *expressions: Any, **lookups: Any) -> "BaseManager[ModelType]":
|
151
|
+
"""Add one OR group (shortcut for `filter(Q() | Q())`)."""
|
152
|
+
or_clauses: List[Any] = []
|
153
|
+
for expr in expressions:
|
154
|
+
or_clauses.append(self._q_to_expr(expr) if isinstance(expr, Q) else expr)
|
155
|
+
for k, v in lookups.items():
|
156
|
+
or_clauses.append(self._parse_lookup(k, v))
|
157
|
+
if or_clauses:
|
158
|
+
self._filtered = True
|
159
|
+
self.filters.append(or_(*or_clauses))
|
160
|
+
return self
|
161
|
+
|
162
|
+
def limit(self, value: int) -> "BaseManager[ModelType]":
|
163
|
+
self._limit = value
|
164
|
+
return self
|
165
|
+
|
166
|
+
def _apply_eager_loading(self, stmt, load_all_relations: bool = False):
|
167
|
+
if not load_all_relations:
|
168
|
+
return stmt
|
169
|
+
opts: List[Any] = []
|
170
|
+
visited: set[Any] = set()
|
171
|
+
|
172
|
+
def recurse(model, loader=None):
|
173
|
+
mapper = inspect(model)
|
174
|
+
if mapper in visited:
|
175
|
+
return
|
176
|
+
visited.add(mapper)
|
177
|
+
for rel in mapper.relationships:
|
178
|
+
attr = getattr(model, rel.key)
|
179
|
+
this_loader = selectinload(attr) if loader is None else loader.selectinload(attr)
|
180
|
+
opts.append(this_loader)
|
181
|
+
recurse(rel.mapper.class_, this_loader)
|
182
|
+
|
183
|
+
recurse(self.model)
|
184
|
+
return stmt.options(*opts)
|
185
|
+
|
186
|
+
async def _execute_query(self, stmt, load_all_relations: bool):
|
187
|
+
stmt = self._apply_eager_loading(stmt, load_all_relations)
|
188
|
+
result = await self.session.execute(stmt)
|
189
|
+
rows = result.scalars().all()
|
190
|
+
return list({obj.id: obj for obj in rows}.values())
|
191
|
+
|
192
|
+
def _reset_state(self):
|
193
|
+
self.filters.clear()
|
194
|
+
self._filtered = False
|
195
|
+
self._limit = None
|
196
|
+
|
197
|
+
async def all(self, load_all_relations: bool = False):
|
198
|
+
stmt = select(self.model)
|
199
|
+
if self.filters:
|
200
|
+
stmt = stmt.filter(and_(*self.filters))
|
201
|
+
if self._limit:
|
202
|
+
stmt = stmt.limit(self._limit)
|
203
|
+
try:
|
204
|
+
return await self._execute_query(stmt, load_all_relations)
|
205
|
+
finally:
|
206
|
+
self._reset_state()
|
207
|
+
|
208
|
+
async def first(self, load_relations: Optional[Sequence[str]] = None):
|
209
|
+
self._ensure_filtered()
|
210
|
+
stmt = select(self.model).filter(and_(*self.filters))
|
211
|
+
if load_relations:
|
212
|
+
for rel in load_relations:
|
213
|
+
stmt = stmt.options(selectinload(getattr(self.model, rel)))
|
214
|
+
result = await self.session.execute(stmt)
|
215
|
+
self._reset_state()
|
216
|
+
return result.scalars().first()
|
217
|
+
|
218
|
+
async def last(self, load_relations: Optional[Sequence[str]] = None):
|
219
|
+
self._ensure_filtered()
|
220
|
+
stmt = (
|
221
|
+
select(self.model)
|
222
|
+
.filter(and_(*self.filters))
|
223
|
+
.order_by(desc(self.model.id))
|
224
|
+
)
|
225
|
+
if load_relations:
|
226
|
+
for rel in load_relations:
|
227
|
+
stmt = stmt.options(selectinload(getattr(self.model, rel)))
|
228
|
+
result = await self.session.execute(stmt)
|
229
|
+
self._reset_state()
|
230
|
+
return result.scalars().first()
|
231
|
+
|
232
|
+
async def count(self) -> int:
|
233
|
+
self._ensure_filtered()
|
234
|
+
query = select(func.count()).select_from(self.model).filter(and_(*self.filters))
|
235
|
+
result = await self.session.execute(query)
|
236
|
+
self._reset_state()
|
237
|
+
return result.scalar_one()
|
238
|
+
|
239
|
+
async def paginate(
|
240
|
+
self,
|
241
|
+
limit: int = 10,
|
242
|
+
offset: int = 0,
|
243
|
+
load_all_relations: bool = False,
|
244
|
+
):
|
245
|
+
self._ensure_filtered()
|
246
|
+
stmt = (
|
247
|
+
select(self.model)
|
248
|
+
.filter(and_(*self.filters))
|
249
|
+
.limit(limit)
|
250
|
+
.offset(offset)
|
251
|
+
)
|
252
|
+
try:
|
253
|
+
return await self._execute_query(stmt, load_all_relations)
|
254
|
+
finally:
|
255
|
+
self._reset_state()
|
256
|
+
|
257
|
+
@with_transaction_error_handling
|
258
|
+
async def create(self, **kwargs):
|
259
|
+
obj = self.model(**kwargs)
|
260
|
+
self.session.add(obj)
|
261
|
+
await self.session.flush()
|
262
|
+
return await self._smart_commit(obj)
|
263
|
+
|
264
|
+
@with_transaction_error_handling
|
265
|
+
async def save(self, instance: ModelType):
|
266
|
+
self.session.add(instance)
|
267
|
+
await self.session.flush()
|
268
|
+
return await self._smart_commit(instance)
|
269
|
+
|
270
|
+
@with_transaction_error_handling
|
271
|
+
async def lazy_save(self, instance: ModelType, load_relations: Sequence[str] = None) -> Optional[ModelType]:
|
272
|
+
self.session.add(instance)
|
273
|
+
await self.session.flush()
|
274
|
+
await self._smart_commit(instance)
|
275
|
+
|
276
|
+
if load_relations is None:
|
277
|
+
mapper = inspect(self.model)
|
278
|
+
load_relations = [rel.key for rel in mapper.relationships]
|
279
|
+
|
280
|
+
if not load_relations:
|
281
|
+
return instance
|
282
|
+
|
283
|
+
stmt = select(self.model).filter_by(id=instance.id)
|
284
|
+
|
285
|
+
for rel in load_relations:
|
286
|
+
stmt = stmt.options(selectinload(getattr(self.model, rel)))
|
287
|
+
|
288
|
+
result = await self.session.execute(stmt)
|
289
|
+
loaded_instance = result.scalar_one_or_none()
|
290
|
+
|
291
|
+
if loaded_instance is None:
|
292
|
+
raise NotFoundException(
|
293
|
+
message="Object saved but could not be retrieved with relationships",
|
294
|
+
status=404,
|
295
|
+
code="0001",
|
296
|
+
)
|
297
|
+
|
298
|
+
return loaded_instance
|
299
|
+
|
300
|
+
@with_transaction_error_handling
|
301
|
+
async def update(self, instance: ModelType, **fields):
|
302
|
+
if not fields:
|
303
|
+
raise InternalException("No fields provided for update")
|
304
|
+
for k, v in fields.items():
|
305
|
+
setattr(instance, k, v)
|
306
|
+
self.session.add(instance)
|
307
|
+
await self._smart_commit()
|
308
|
+
return instance
|
309
|
+
|
310
|
+
@with_transaction_error_handling
|
311
|
+
async def update_by_filters(self, filters: Dict[str, Any], **fields):
|
312
|
+
if not fields:
|
313
|
+
raise InternalException("No fields provided for update")
|
314
|
+
stmt = update(self.model).filter_by(**filters).values(**fields)
|
315
|
+
await self.session.execute(stmt)
|
316
|
+
await self.session.commit()
|
317
|
+
return await self.get(**filters)
|
318
|
+
|
319
|
+
@with_transaction_error_handling
|
320
|
+
async def delete(self, instance: Optional[ModelType] = None):
|
321
|
+
if instance is not None:
|
322
|
+
await self.session.delete(instance)
|
323
|
+
await self.session.commit()
|
324
|
+
return True
|
325
|
+
self._ensure_filtered()
|
326
|
+
stmt = delete(self.model).where(and_(*self.filters))
|
327
|
+
await self.session.execute(stmt)
|
328
|
+
await self.session.commit()
|
329
|
+
self._reset_state()
|
330
|
+
return True
|
331
|
+
|
332
|
+
@with_transaction_error_handling
|
333
|
+
async def bulk_save(self, instances: Iterable[ModelType]):
|
334
|
+
if not instances:
|
335
|
+
return
|
336
|
+
self.session.add_all(list(instances))
|
337
|
+
await self.session.flush()
|
338
|
+
if not self.session.in_transaction():
|
339
|
+
await self.session.commit()
|
340
|
+
|
341
|
+
@with_transaction_error_handling
|
342
|
+
async def bulk_delete(self):
|
343
|
+
self._ensure_filtered()
|
344
|
+
stmt = delete(self.model).where(and_(*self.filters))
|
345
|
+
result = await self.session.execute(stmt)
|
346
|
+
await self._smart_commit()
|
347
|
+
self._reset_state()
|
348
|
+
return result.rowcount
|
349
|
+
|
350
|
+
async def get(
|
351
|
+
self,
|
352
|
+
pk: Union[str, int, uuid.UUID],
|
353
|
+
load_relations: Optional[Sequence[str]] = None,
|
354
|
+
):
|
355
|
+
stmt = select(self.model).filter_by(id=pk)
|
356
|
+
if load_relations:
|
357
|
+
for rel in load_relations:
|
358
|
+
stmt = stmt.options(selectinload(getattr(self.model, rel)))
|
359
|
+
result = await self.session.execute(stmt)
|
360
|
+
instance = result.scalar_one_or_none()
|
361
|
+
if instance is None:
|
362
|
+
raise NotFoundException("Object does not exist", 404, "0001")
|
363
|
+
return instance
|
364
|
+
|
365
|
+
async def is_exists(self, **lookups):
|
366
|
+
stmt = select(self.model).filter_by(**lookups)
|
367
|
+
result = await self.session.execute(stmt)
|
368
|
+
return result.scalar_one_or_none() is not None
|
369
|
+
|
370
|
+
def has_relation(self, relation_name: str):
|
371
|
+
relationship = getattr(self.model, relation_name)
|
372
|
+
subquery = (
|
373
|
+
select(1)
|
374
|
+
.select_from(relationship.property.mapper.class_)
|
375
|
+
.where(relationship.property.primaryjoin)
|
376
|
+
.exists()
|
377
|
+
)
|
378
|
+
self.filters.append(subquery)
|
379
|
+
self._filtered = True
|
380
|
+
return self
|
381
|
+
|
382
|
+
def model_to_dict(self, instance: ModelType, exclude: set[str] | None = None):
|
383
|
+
exclude = exclude or set()
|
384
|
+
return {
|
385
|
+
col.key: getattr(instance, col.key)
|
386
|
+
for col in inspect(instance).mapper.column_attrs
|
387
|
+
if col.key not in exclude
|
388
|
+
}
|
389
|
+
|
390
|
+
def _ensure_filtered(self):
|
391
|
+
if not self._filtered:
|
392
|
+
raise RuntimeError("You must call `filter()` before this operation.")
|
393
|
+
|
@@ -5,7 +5,7 @@ with open("README.md", "r", encoding="utf-8") as fh:
|
|
5
5
|
|
6
6
|
setup(
|
7
7
|
name="rb-commons",
|
8
|
-
version="0.
|
8
|
+
version="0.5.1",
|
9
9
|
author="Abdulvoris",
|
10
10
|
author_email="erkinovabdulvoris101@gmail.com",
|
11
11
|
description="Commons of project and simplified orm based on sqlalchemy.",
|
@@ -1,463 +0,0 @@
|
|
1
|
-
import uuid
|
2
|
-
from typing import TypeVar, Type, Generic, Optional, List, Dict, Literal, Union, Sequence
|
3
|
-
from sqlalchemy import select, delete, update, and_, func, desc, inspect
|
4
|
-
from sqlalchemy.exc import IntegrityError, SQLAlchemyError, NoResultFound
|
5
|
-
from sqlalchemy.ext.asyncio import AsyncSession
|
6
|
-
from sqlalchemy.orm import declarative_base, InstrumentedAttribute, selectinload, RelationshipProperty
|
7
|
-
|
8
|
-
from rb_commons.http.exceptions import NotFoundException
|
9
|
-
from rb_commons.orm.exceptions import DatabaseException, InternalException
|
10
|
-
|
11
|
-
ModelType = TypeVar('ModelType', bound=declarative_base())
|
12
|
-
|
13
|
-
def with_transaction_error_handling(func):
|
14
|
-
async def wrapper(self, *args, **kwargs):
|
15
|
-
try:
|
16
|
-
return await func(self, *args, **kwargs)
|
17
|
-
except IntegrityError as e:
|
18
|
-
await self.session.rollback()
|
19
|
-
raise InternalException(f"Constraint violation: {str(e)}") from e
|
20
|
-
except SQLAlchemyError as e:
|
21
|
-
await self.session.rollback()
|
22
|
-
raise DatabaseException(f"Database error: {str(e)}") from e
|
23
|
-
except Exception as e:
|
24
|
-
await self.session.rollback()
|
25
|
-
raise InternalException(f"Unexpected error: {str(e)}") from e
|
26
|
-
return wrapper
|
27
|
-
|
28
|
-
class BaseManager(Generic[ModelType]):
|
29
|
-
model: Type[ModelType]
|
30
|
-
|
31
|
-
def __init__(self, session: AsyncSession) -> None:
|
32
|
-
self.session = session
|
33
|
-
self.data = None
|
34
|
-
self.filters = []
|
35
|
-
self._filtered = False
|
36
|
-
self._limit = None
|
37
|
-
|
38
|
-
async def _smart_commit(self, instance: Optional[ModelType] = None) -> Optional[ModelType]:
|
39
|
-
if not self.session.in_transaction():
|
40
|
-
await self.session.commit()
|
41
|
-
|
42
|
-
if instance:
|
43
|
-
await self.session.refresh(instance)
|
44
|
-
return instance
|
45
|
-
|
46
|
-
async def get(self, pk: Union[str, int, uuid.UUID], load_relations: Sequence[str] = None) -> Optional[ModelType]:
|
47
|
-
"""
|
48
|
-
get object based on conditions
|
49
|
-
"""
|
50
|
-
stmt = select(self.model).filter_by(id=pk)
|
51
|
-
|
52
|
-
if load_relations:
|
53
|
-
for rel in load_relations:
|
54
|
-
stmt = stmt.options(selectinload(getattr(self.model, rel)))
|
55
|
-
|
56
|
-
result = await self.session.execute(stmt)
|
57
|
-
instance = result.scalar_one_or_none()
|
58
|
-
|
59
|
-
if instance is None:
|
60
|
-
raise NotFoundException(
|
61
|
-
message="Object does not exist",
|
62
|
-
status=404,
|
63
|
-
code="0001",
|
64
|
-
)
|
65
|
-
|
66
|
-
return instance
|
67
|
-
|
68
|
-
def _apply_eager_loading(self, stmt, load_all_relations: bool = False):
|
69
|
-
if not load_all_relations:
|
70
|
-
return stmt
|
71
|
-
|
72
|
-
opts = []
|
73
|
-
visited = set()
|
74
|
-
|
75
|
-
def recurse(model, loader=None):
|
76
|
-
mapper = inspect(model)
|
77
|
-
if mapper in visited:
|
78
|
-
return
|
79
|
-
visited.add(mapper)
|
80
|
-
|
81
|
-
for rel in mapper.relationships:
|
82
|
-
attr = getattr(model, rel.key)
|
83
|
-
if loader is None:
|
84
|
-
this_loader = selectinload(attr)
|
85
|
-
else:
|
86
|
-
this_loader = loader.selectinload(attr)
|
87
|
-
opts.append(this_loader)
|
88
|
-
recurse(rel.mapper.class_, this_loader)
|
89
|
-
|
90
|
-
recurse(self.model)
|
91
|
-
return stmt.options(*opts)
|
92
|
-
|
93
|
-
def filter(self, **kwargs) -> 'BaseManager[ModelType]':
|
94
|
-
"""
|
95
|
-
Dynamically apply filters to the query.
|
96
|
-
|
97
|
-
Supported operators:
|
98
|
-
- __eq (e.g., field__eq=value) or just field=value
|
99
|
-
- __ne (field__ne=value)
|
100
|
-
- __gt (field__gt=value)
|
101
|
-
- __lt (field__lt=value)
|
102
|
-
- __gte (field__gte=value)
|
103
|
-
- __lte (field__lte=value)
|
104
|
-
- __in (field__in=[val1, val2, ...])
|
105
|
-
- __contains (field__contains='text')
|
106
|
-
- __null (field__null=True/False) - True for IS NULL, False for IS NOT NULL
|
107
|
-
|
108
|
-
Additionally supports nested paths, e.g.,
|
109
|
-
product__shop_id=None
|
110
|
-
product__shop__country='US'
|
111
|
-
"""
|
112
|
-
self._filtered = True
|
113
|
-
self.filters = []
|
114
|
-
|
115
|
-
for key, value in kwargs.items():
|
116
|
-
parts = key.split("__")
|
117
|
-
|
118
|
-
operator = "eq"
|
119
|
-
|
120
|
-
if parts[-1] in {"eq", "ne", "gt", "lt", "gte", "lte", "in", "contains", "null"}:
|
121
|
-
operator = parts.pop()
|
122
|
-
|
123
|
-
current_attr = self.model
|
124
|
-
|
125
|
-
for field_name in parts:
|
126
|
-
attr_candidate = getattr(current_attr, field_name, None)
|
127
|
-
if attr_candidate is None:
|
128
|
-
raise ValueError(f"Invalid filter field: {'.'.join(parts)}")
|
129
|
-
|
130
|
-
if hasattr(attr_candidate, "property") and isinstance(attr_candidate.property, RelationshipProperty):
|
131
|
-
current_attr = attr_candidate.property.mapper.class_
|
132
|
-
else:
|
133
|
-
current_attr = attr_candidate
|
134
|
-
|
135
|
-
if operator == "eq":
|
136
|
-
# e.g., column == value
|
137
|
-
self.filters.append(current_attr == value)
|
138
|
-
|
139
|
-
elif operator == "ne":
|
140
|
-
# e.g., column != value
|
141
|
-
self.filters.append(current_attr != value)
|
142
|
-
|
143
|
-
elif operator == "gt":
|
144
|
-
self.filters.append(current_attr > value)
|
145
|
-
|
146
|
-
elif operator == "lt":
|
147
|
-
self.filters.append(current_attr < value)
|
148
|
-
|
149
|
-
elif operator == "gte":
|
150
|
-
self.filters.append(current_attr >= value)
|
151
|
-
|
152
|
-
elif operator == "lte":
|
153
|
-
self.filters.append(current_attr <= value)
|
154
|
-
|
155
|
-
elif operator == "in":
|
156
|
-
if not isinstance(value, list):
|
157
|
-
raise ValueError(f"{'.'.join(parts)}__in requires a list, got {type(value)}")
|
158
|
-
self.filters.append(current_attr.in_(value))
|
159
|
-
|
160
|
-
elif operator == "contains":
|
161
|
-
# e.g., column ILIKE %value%
|
162
|
-
self.filters.append(current_attr.ilike(f"%{value}%"))
|
163
|
-
|
164
|
-
elif operator == "null":
|
165
|
-
if value is True:
|
166
|
-
self.filters.append(current_attr.is_(None))
|
167
|
-
else:
|
168
|
-
self.filters.append(current_attr.isnot(None))
|
169
|
-
|
170
|
-
return self
|
171
|
-
|
172
|
-
def _ensure_filtered(self):
|
173
|
-
"""Ensure that `filter()` has been called before using certain methods."""
|
174
|
-
if not self._filtered:
|
175
|
-
raise RuntimeError("You must call `filter()` before using this method.")
|
176
|
-
|
177
|
-
async def _execute_query_and_unique_data(self, stmt, load_all_relations: bool) -> List[ModelType]:
|
178
|
-
stmt = self._apply_eager_loading(stmt, load_all_relations)
|
179
|
-
result = await self.session.execute(stmt)
|
180
|
-
rows = result.scalars().all()
|
181
|
-
unique_by_pk = {obj.id: obj for obj in rows}
|
182
|
-
return list(unique_by_pk.values())
|
183
|
-
|
184
|
-
async def all(self, load_all_relations: bool = False) -> List[ModelType] | None:
|
185
|
-
try:
|
186
|
-
stmt = select(self.model)
|
187
|
-
|
188
|
-
if self._filtered:
|
189
|
-
stmt = stmt.filter(and_(*self.filters))
|
190
|
-
|
191
|
-
if self._limit:
|
192
|
-
stmt = stmt.limit(self._limit)
|
193
|
-
|
194
|
-
self._clear_query_state()
|
195
|
-
|
196
|
-
return await self._execute_query_and_unique_data(stmt, load_all_relations)
|
197
|
-
finally:
|
198
|
-
self._clear_query_state()
|
199
|
-
|
200
|
-
def _clear_query_state(self):
|
201
|
-
"""Clear all query state after execution"""
|
202
|
-
self._filtered = False
|
203
|
-
self.filters = []
|
204
|
-
self._limit = None
|
205
|
-
|
206
|
-
async def paginate(self, limit: int = 10, offset: int = 0, load_all_relations: bool = False) -> List[ModelType]:
|
207
|
-
self._ensure_filtered()
|
208
|
-
stmt = select(self.model).filter(and_(*self.filters)).limit(limit).offset(offset)
|
209
|
-
return await self._execute_query_and_unique_data(stmt, load_all_relations)
|
210
|
-
|
211
|
-
def limit(self, value: int) -> 'BaseManager[ModelType]':
|
212
|
-
"""
|
213
|
-
Set a limit on the number of results returned by queries like `all()` or `first()`.
|
214
|
-
"""
|
215
|
-
self._limit = value
|
216
|
-
return self
|
217
|
-
|
218
|
-
async def first(self, load_relations: Sequence[str] = None) -> Optional[ModelType]:
|
219
|
-
"""Return the first matching object, or None."""
|
220
|
-
self._ensure_filtered()
|
221
|
-
|
222
|
-
stmt = select(self.model).filter(and_(*self.filters))
|
223
|
-
|
224
|
-
if load_relations:
|
225
|
-
for rel in load_relations:
|
226
|
-
stmt = stmt.options(selectinload(getattr(self.model, rel)))
|
227
|
-
|
228
|
-
result = await self.session.execute(stmt)
|
229
|
-
return result.scalars().first()
|
230
|
-
|
231
|
-
async def last(self, load_relations: Sequence[str] = None) -> Optional[ModelType]:
|
232
|
-
"""Return the last matching object, or None."""
|
233
|
-
|
234
|
-
self._ensure_filtered()
|
235
|
-
|
236
|
-
stmt = select(self.model).filter(and_(*self.filters)).order_by(desc(self.model.id))
|
237
|
-
|
238
|
-
if load_relations:
|
239
|
-
for rel in load_relations:
|
240
|
-
stmt = stmt.options(selectinload(getattr(self.model, rel)))
|
241
|
-
|
242
|
-
result = await self.session.execute(stmt)
|
243
|
-
return result.scalars().first()
|
244
|
-
|
245
|
-
async def count(self) -> int:
|
246
|
-
"""Return the count of matching records."""
|
247
|
-
|
248
|
-
self._ensure_filtered()
|
249
|
-
query = select(func.count()).select_from(self.model).filter(and_(*self.filters))
|
250
|
-
result = await self.session.execute(query)
|
251
|
-
return result.scalar_one()
|
252
|
-
|
253
|
-
@with_transaction_error_handling
|
254
|
-
async def create(self, **kwargs) -> ModelType:
|
255
|
-
"""
|
256
|
-
Create a new object
|
257
|
-
"""
|
258
|
-
obj = self.model(**kwargs)
|
259
|
-
|
260
|
-
self.session.add(obj)
|
261
|
-
await self.session.flush()
|
262
|
-
return await self._smart_commit(obj)
|
263
|
-
|
264
|
-
@with_transaction_error_handling
|
265
|
-
async def delete(self, instance: ModelType = None) -> bool:
|
266
|
-
"""
|
267
|
-
Delete object(s) with flexible filtering options
|
268
|
-
- If `instance` is provided, delete that single instance.
|
269
|
-
- If `instance` is not provided, delete according to self.filters.
|
270
|
-
|
271
|
-
:arg instance: Model instance to delete.
|
272
|
-
:return: Number of deleted records or None
|
273
|
-
"""
|
274
|
-
|
275
|
-
if instance is not None:
|
276
|
-
await self.session.delete(instance)
|
277
|
-
await self.session.commit()
|
278
|
-
return True
|
279
|
-
|
280
|
-
self._ensure_filtered()
|
281
|
-
|
282
|
-
try:
|
283
|
-
delete_stmt = delete(self.model).where(and_(*self.filters))
|
284
|
-
await self.session.execute(delete_stmt)
|
285
|
-
await self.session.commit()
|
286
|
-
return True
|
287
|
-
except NoResultFound:
|
288
|
-
return False
|
289
|
-
|
290
|
-
@with_transaction_error_handling
|
291
|
-
async def bulk_delete(self) -> int:
|
292
|
-
"""
|
293
|
-
Bulk delete with flexible filtering.
|
294
|
-
|
295
|
-
Automatically commits if not inside a transaction.
|
296
|
-
|
297
|
-
:return: Number of deleted records
|
298
|
-
"""
|
299
|
-
self._ensure_filtered()
|
300
|
-
|
301
|
-
delete_stmt = delete(self.model).where(and_(*self.filters))
|
302
|
-
result = await self.session.execute(delete_stmt)
|
303
|
-
await self._smart_commit()
|
304
|
-
return result.rowcount # ignore
|
305
|
-
|
306
|
-
@with_transaction_error_handling
|
307
|
-
async def update_by_filters(self, filters: Dict, **update_fields) -> Optional[ModelType]:
|
308
|
-
"""
|
309
|
-
Update object(s) with flexible filtering options
|
310
|
-
|
311
|
-
:param filters: Conditions for selecting records to update
|
312
|
-
:param update_fields: Fields and values to update
|
313
|
-
:return: Number of updated records
|
314
|
-
"""
|
315
|
-
if not update_fields:
|
316
|
-
raise InternalException("No fields provided for update")
|
317
|
-
|
318
|
-
update_stmt = update(self.model).filter_by(**filters).values(**update_fields)
|
319
|
-
await self.session.execute(update_stmt)
|
320
|
-
await self.session.commit()
|
321
|
-
updated_instance = await self.get(**filters)
|
322
|
-
return updated_instance
|
323
|
-
|
324
|
-
@with_transaction_error_handling
|
325
|
-
async def update(self, instance: ModelType, **update_fields) -> Optional[ModelType]:
|
326
|
-
"""
|
327
|
-
Update an existing database instance with new fields
|
328
|
-
|
329
|
-
:param instance: The database model instance to update
|
330
|
-
:param update_fields: Keyword arguments of fields to update
|
331
|
-
:return: The updated instance
|
332
|
-
|
333
|
-
:raises InternalException: If integrity violation
|
334
|
-
:raises DatabaseException: For database-related errors
|
335
|
-
"""
|
336
|
-
# Validate update fields
|
337
|
-
if not update_fields:
|
338
|
-
raise InternalException("No fields provided for update")
|
339
|
-
|
340
|
-
# Apply updates directly to the instance
|
341
|
-
for key, value in update_fields.items():
|
342
|
-
setattr(instance, key, value)
|
343
|
-
|
344
|
-
self.session.add(instance)
|
345
|
-
await self._smart_commit()
|
346
|
-
|
347
|
-
return instance
|
348
|
-
|
349
|
-
@with_transaction_error_handling
|
350
|
-
async def save(self, instance: ModelType) -> Optional[ModelType]:
|
351
|
-
"""
|
352
|
-
Save instance
|
353
|
-
|
354
|
-
:param instance: The database model instance to save
|
355
|
-
:return: The saved instance
|
356
|
-
|
357
|
-
Automatically commits if not inside a transaction.
|
358
|
-
|
359
|
-
:raises InternalException: If integrity violation
|
360
|
-
:raises DatabaseException: For database-related errors
|
361
|
-
"""
|
362
|
-
self.session.add(instance)
|
363
|
-
await self.session.flush()
|
364
|
-
return await self._smart_commit(instance)
|
365
|
-
|
366
|
-
@with_transaction_error_handling
|
367
|
-
async def lazy_save(self, instance: ModelType, load_relations: Sequence[str] = None) -> Optional[ModelType]:
|
368
|
-
self.session.add(instance)
|
369
|
-
await self.session.flush()
|
370
|
-
await self._smart_commit(instance)
|
371
|
-
|
372
|
-
if load_relations is None:
|
373
|
-
mapper = inspect(self.model)
|
374
|
-
load_relations = [rel.key for rel in mapper.relationships]
|
375
|
-
|
376
|
-
if not load_relations:
|
377
|
-
return instance
|
378
|
-
|
379
|
-
stmt = select(self.model).filter_by(id=instance.id)
|
380
|
-
|
381
|
-
for rel in load_relations:
|
382
|
-
stmt = stmt.options(selectinload(getattr(self.model, rel)))
|
383
|
-
|
384
|
-
result = await self.session.execute(stmt)
|
385
|
-
loaded_instance = result.scalar_one_or_none()
|
386
|
-
|
387
|
-
if loaded_instance is None:
|
388
|
-
raise NotFoundException(
|
389
|
-
message="Object saved but could not be retrieved with relationships",
|
390
|
-
status=404,
|
391
|
-
code="0001",
|
392
|
-
)
|
393
|
-
|
394
|
-
return loaded_instance
|
395
|
-
|
396
|
-
async def is_exists(self, **kwargs) -> bool:
|
397
|
-
stmt = select(self.model).filter_by(**kwargs)
|
398
|
-
result = await self.session.execute(stmt)
|
399
|
-
return result.scalar_one_or_none() is not None
|
400
|
-
|
401
|
-
@with_transaction_error_handling
|
402
|
-
async def bulk_save(self, instances: List[ModelType]) -> None:
|
403
|
-
"""
|
404
|
-
Bulk save a list of instances into the database.
|
405
|
-
|
406
|
-
If inside a transaction, flushes only.
|
407
|
-
If not in a transaction, commits after flush.
|
408
|
-
|
409
|
-
:param instances: List of instances
|
410
|
-
:raises DatabaseException: If any database error occurs
|
411
|
-
:raises InternalException: If any unexpected error occurs
|
412
|
-
"""
|
413
|
-
if not instances:
|
414
|
-
return
|
415
|
-
|
416
|
-
self.session.add_all(instances)
|
417
|
-
await self.session.flush()
|
418
|
-
|
419
|
-
if not self.session.in_transaction():
|
420
|
-
await self.session.commit()
|
421
|
-
|
422
|
-
def has_relation(self, relation_name: str) -> 'BaseManager[ModelType]':
|
423
|
-
"""
|
424
|
-
Check if a relationship exists between models using an EXISTS subquery.
|
425
|
-
|
426
|
-
:param relation_name Name of the relationship to check. Must be a valid relationship
|
427
|
-
defined in the model.
|
428
|
-
|
429
|
-
:return BaseManager[ModelType]: Self instance for method chaining.
|
430
|
-
|
431
|
-
:raise DatabaseException: If there's an error constructing the subquery.
|
432
|
-
:raise InternalException: If there's an unexpected error in relationship handling.
|
433
|
-
|
434
|
-
Notes:
|
435
|
-
- The relationship must be properly defined in the SQLAlchemy model
|
436
|
-
- Uses a non-correlated EXISTS subquery for better performance
|
437
|
-
- Silently continues if the relationship doesn't exist
|
438
|
-
"""
|
439
|
-
# Get the relationship property
|
440
|
-
relationship = getattr(self.model, relation_name)
|
441
|
-
|
442
|
-
# Create subquery using select
|
443
|
-
subquery = (
|
444
|
-
select(1)
|
445
|
-
.select_from(relationship.property.mapper.class_)
|
446
|
-
.where(relationship.property.primaryjoin)
|
447
|
-
.exists()
|
448
|
-
)
|
449
|
-
|
450
|
-
# Add the exists condition to filters
|
451
|
-
self.filters.append(subquery)
|
452
|
-
|
453
|
-
return self
|
454
|
-
|
455
|
-
def model_to_dict(self, instance: ModelType, exclude: set = None):
|
456
|
-
exclude = exclude or set()
|
457
|
-
|
458
|
-
return {
|
459
|
-
c.key: getattr(instance, c.key)
|
460
|
-
for c in inspect(instance).mapper.column_attrs
|
461
|
-
if c.key not in exclude
|
462
|
-
}
|
463
|
-
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|