rb-commons 0.4.13__tar.gz → 0.5.0__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.
Files changed (32) hide show
  1. {rb_commons-0.4.13 → rb_commons-0.5.0}/PKG-INFO +1 -1
  2. rb_commons-0.5.0/rb_commons/orm/managers.py +363 -0
  3. {rb_commons-0.4.13 → rb_commons-0.5.0}/rb_commons.egg-info/PKG-INFO +1 -1
  4. {rb_commons-0.4.13 → rb_commons-0.5.0}/setup.py +1 -1
  5. rb_commons-0.4.13/rb_commons/orm/managers.py +0 -463
  6. {rb_commons-0.4.13 → rb_commons-0.5.0}/README.md +0 -0
  7. {rb_commons-0.4.13 → rb_commons-0.5.0}/rb_commons/__init__.py +0 -0
  8. {rb_commons-0.4.13 → rb_commons-0.5.0}/rb_commons/broker/__init__.py +0 -0
  9. {rb_commons-0.4.13 → rb_commons-0.5.0}/rb_commons/broker/consumer.py +0 -0
  10. {rb_commons-0.4.13 → rb_commons-0.5.0}/rb_commons/configs/__init__.py +0 -0
  11. {rb_commons-0.4.13 → rb_commons-0.5.0}/rb_commons/configs/config.py +0 -0
  12. {rb_commons-0.4.13 → rb_commons-0.5.0}/rb_commons/configs/injections.py +0 -0
  13. {rb_commons-0.4.13 → rb_commons-0.5.0}/rb_commons/configs/rabbitmq.py +0 -0
  14. {rb_commons-0.4.13 → rb_commons-0.5.0}/rb_commons/http/__init__.py +0 -0
  15. {rb_commons-0.4.13 → rb_commons-0.5.0}/rb_commons/http/base_api.py +0 -0
  16. {rb_commons-0.4.13 → rb_commons-0.5.0}/rb_commons/http/consul.py +0 -0
  17. {rb_commons-0.4.13 → rb_commons-0.5.0}/rb_commons/http/exceptions.py +0 -0
  18. {rb_commons-0.4.13 → rb_commons-0.5.0}/rb_commons/orm/__init__.py +0 -0
  19. {rb_commons-0.4.13 → rb_commons-0.5.0}/rb_commons/orm/exceptions.py +0 -0
  20. {rb_commons-0.4.13 → rb_commons-0.5.0}/rb_commons/orm/services.py +0 -0
  21. {rb_commons-0.4.13 → rb_commons-0.5.0}/rb_commons/permissions/__init__.py +0 -0
  22. {rb_commons-0.4.13 → rb_commons-0.5.0}/rb_commons/permissions/role_permissions.py +0 -0
  23. {rb_commons-0.4.13 → rb_commons-0.5.0}/rb_commons/schemes/__init__.py +0 -0
  24. {rb_commons-0.4.13 → rb_commons-0.5.0}/rb_commons/schemes/jwt.py +0 -0
  25. {rb_commons-0.4.13 → rb_commons-0.5.0}/rb_commons/schemes/pagination.py +0 -0
  26. {rb_commons-0.4.13 → rb_commons-0.5.0}/rb_commons/utils/__init__.py +0 -0
  27. {rb_commons-0.4.13 → rb_commons-0.5.0}/rb_commons/utils/media.py +0 -0
  28. {rb_commons-0.4.13 → rb_commons-0.5.0}/rb_commons.egg-info/SOURCES.txt +0 -0
  29. {rb_commons-0.4.13 → rb_commons-0.5.0}/rb_commons.egg-info/dependency_links.txt +0 -0
  30. {rb_commons-0.4.13 → rb_commons-0.5.0}/rb_commons.egg-info/requires.txt +0 -0
  31. {rb_commons-0.4.13 → rb_commons-0.5.0}/rb_commons.egg-info/top_level.txt +0 -0
  32. {rb_commons-0.4.13 → rb_commons-0.5.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rb-commons
3
- Version: 0.4.13
3
+ Version: 0.5.0
4
4
  Summary: Commons of project and simplified orm based on sqlalchemy.
5
5
  Home-page: https://github.com/RoboSell-organization/rb-commons
6
6
  Author: Abdulvoris
@@ -0,0 +1,363 @@
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 update(self, instance: ModelType, **fields):
272
+ if not fields:
273
+ raise InternalException("No fields provided for update")
274
+ for k, v in fields.items():
275
+ setattr(instance, k, v)
276
+ self.session.add(instance)
277
+ await self._smart_commit()
278
+ return instance
279
+
280
+ @with_transaction_error_handling
281
+ async def update_by_filters(self, filters: Dict[str, Any], **fields):
282
+ if not fields:
283
+ raise InternalException("No fields provided for update")
284
+ stmt = update(self.model).filter_by(**filters).values(**fields)
285
+ await self.session.execute(stmt)
286
+ await self.session.commit()
287
+ return await self.get(**filters)
288
+
289
+ @with_transaction_error_handling
290
+ async def delete(self, instance: Optional[ModelType] = None):
291
+ if instance is not None:
292
+ await self.session.delete(instance)
293
+ await self.session.commit()
294
+ return True
295
+ self._ensure_filtered()
296
+ stmt = delete(self.model).where(and_(*self.filters))
297
+ await self.session.execute(stmt)
298
+ await self.session.commit()
299
+ self._reset_state()
300
+ return True
301
+
302
+ @with_transaction_error_handling
303
+ async def bulk_save(self, instances: Iterable[ModelType]):
304
+ if not instances:
305
+ return
306
+ self.session.add_all(list(instances))
307
+ await self.session.flush()
308
+ if not self.session.in_transaction():
309
+ await self.session.commit()
310
+
311
+ @with_transaction_error_handling
312
+ async def bulk_delete(self):
313
+ self._ensure_filtered()
314
+ stmt = delete(self.model).where(and_(*self.filters))
315
+ result = await self.session.execute(stmt)
316
+ await self._smart_commit()
317
+ self._reset_state()
318
+ return result.rowcount
319
+
320
+ async def get(
321
+ self,
322
+ pk: Union[str, int, uuid.UUID],
323
+ load_relations: Optional[Sequence[str]] = None,
324
+ ):
325
+ stmt = select(self.model).filter_by(id=pk)
326
+ if load_relations:
327
+ for rel in load_relations:
328
+ stmt = stmt.options(selectinload(getattr(self.model, rel)))
329
+ result = await self.session.execute(stmt)
330
+ instance = result.scalar_one_or_none()
331
+ if instance is None:
332
+ raise NotFoundException("Object does not exist", 404, "0001")
333
+ return instance
334
+
335
+ async def is_exists(self, **lookups):
336
+ stmt = select(self.model).filter_by(**lookups)
337
+ result = await self.session.execute(stmt)
338
+ return result.scalar_one_or_none() is not None
339
+
340
+ def has_relation(self, relation_name: str):
341
+ relationship = getattr(self.model, relation_name)
342
+ subquery = (
343
+ select(1)
344
+ .select_from(relationship.property.mapper.class_)
345
+ .where(relationship.property.primaryjoin)
346
+ .exists()
347
+ )
348
+ self.filters.append(subquery)
349
+ self._filtered = True
350
+ return self
351
+
352
+ def model_to_dict(self, instance: ModelType, exclude: set[str] | None = None):
353
+ exclude = exclude or set()
354
+ return {
355
+ col.key: getattr(instance, col.key)
356
+ for col in inspect(instance).mapper.column_attrs
357
+ if col.key not in exclude
358
+ }
359
+
360
+ def _ensure_filtered(self):
361
+ if not self._filtered:
362
+ raise RuntimeError("You must call `filter()` before this operation.")
363
+
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rb-commons
3
- Version: 0.4.13
3
+ Version: 0.5.0
4
4
  Summary: Commons of project and simplified orm based on sqlalchemy.
5
5
  Home-page: https://github.com/RoboSell-organization/rb-commons
6
6
  Author: Abdulvoris
@@ -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.4.13",
8
+ version="0.5.0",
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