rb-commons 0.5.28__py3-none-any.whl → 0.6.1__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.
@@ -3,12 +3,13 @@ from __future__ import annotations
3
3
  import uuid
4
4
  from typing import TypeVar, Type, Generic, Optional, List, Dict, Literal, Union, Sequence, Any, Iterable
5
5
  from sqlalchemy import select, delete, update, and_, func, desc, inspect, or_, asc
6
- from sqlalchemy.exc import IntegrityError, SQLAlchemyError, NoResultFound
6
+ from sqlalchemy.exc import IntegrityError, SQLAlchemyError
7
7
  from sqlalchemy.ext.asyncio import AsyncSession
8
- from sqlalchemy.orm import declarative_base, InstrumentedAttribute, selectinload, RelationshipProperty, Load
9
-
8
+ from sqlalchemy.orm import declarative_base, selectinload, RelationshipProperty, Load
10
9
  from rb_commons.http.exceptions import NotFoundException
11
10
  from rb_commons.orm.exceptions import DatabaseException, InternalException
11
+ from functools import lru_cache
12
+
12
13
 
13
14
  ModelType = TypeVar('ModelType', bound=declarative_base())
14
15
 
@@ -88,6 +89,9 @@ class BaseManager(Generic[ModelType]):
88
89
  self._order_by: List[Any] = []
89
90
  self._joins: set[str] = set()
90
91
 
92
+ mapper = inspect(self.model)
93
+ self._column_keys = [c.key for c in mapper.mapper.column_attrs]
94
+
91
95
  async def _smart_commit(self, instance: Optional[ModelType] = None) -> Optional[ModelType]:
92
96
  if not self.session.in_transaction():
93
97
  await self.session.commit()
@@ -117,38 +121,39 @@ class BaseManager(Generic[ModelType]):
117
121
  return col.is_(None) if value else col.isnot(None)
118
122
  raise ValueError(f"Unsupported operator: {operator}")
119
123
 
120
- def _parse_lookup(self, lookup: str, value: Any):
124
+ @lru_cache(maxsize=None)
125
+ def _parse_lookup_meta(self, lookup: str):
126
+ """
127
+ One-time parse of "foo__bar__lt" into:
128
+ - parts = ["foo","bar"]
129
+ - operator="lt"
130
+ - relationship_attr, column_attr pointers
131
+ """
132
+
121
133
  parts = lookup.split("__")
122
134
  operator = "eq"
123
135
  if parts[-1] in {"eq", "ne", "gt", "lt", "gte", "lte", "in", "contains", "null"}:
124
136
  operator = parts.pop()
125
137
 
126
- current_model = self.model
127
- attr = None
128
- relationship_attr = None
129
- for idx, part in enumerate(parts):
130
- candidate = getattr(current_model, part, None)
131
- if candidate is None:
132
- raise ValueError(f"Invalid filter field: {lookup!r}")
133
-
134
- prop = getattr(candidate, "property", None)
135
- if prop and isinstance(prop, RelationshipProperty):
136
- relationship_attr = candidate
137
- current_model = prop.mapper.class_
138
- else:
139
- attr = candidate
140
-
141
- if relationship_attr:
142
- col = attr
143
- expr = self._build_comparison(col, operator, value)
144
- prop = relationship_attr.property
145
- if getattr(prop, "uselist", False):
146
- return relationship_attr.any(expr)
138
+ current = self.model
139
+ rel = None
140
+ col = None
141
+ for p in parts:
142
+ a = getattr(current, p)
143
+ if hasattr(a, "property") and isinstance(a.property, RelationshipProperty):
144
+ rel = a
145
+ current = a.property.mapper.class_
147
146
  else:
148
- return relationship_attr.has(expr)
147
+ col = a
148
+ return parts, operator, rel, col
149
149
 
150
- col = getattr(self.model, parts[0], None) if len(parts) == 1 else attr
151
- return self._build_comparison(col, operator, value)
150
+ def _parse_lookup(self, lookup: str, value: Any):
151
+ parts, operator, rel_attr, col_attr = self._parse_lookup_meta(lookup)
152
+ expr = self._build_comparison(col_attr, operator, value)
153
+ if rel_attr:
154
+ prop = rel_attr.property
155
+ return prop.uselist and rel_attr.any(expr) or rel_attr.has(expr)
156
+ return expr
152
157
 
153
158
  def _q_to_expr(self, q: Union[Q, QJSON]):
154
159
  if isinstance(q, QJSON):
@@ -187,27 +192,6 @@ class BaseManager(Generic[ModelType]):
187
192
  return json_expr.in_(qjson.value)
188
193
  raise ValueError(f"Unsupported QJSON operator: {qjson.operator}")
189
194
 
190
- def _loader_from_path(self, path: str) -> Load:
191
- """
192
- Turn 'attributes.attribute.attribute_group' into
193
- selectinload(Product.attributes)
194
- .selectinload(Attribute.attribute)
195
- .selectinload(ProductAttributeGroup.attribute_group)
196
- """
197
- parts = path.split(".")
198
- current_model = self.model
199
- loader = None
200
-
201
- for segment in parts:
202
- attr = getattr(current_model, segment, None)
203
- if attr is None or not hasattr(attr, "property"):
204
- raise ValueError(f"Invalid relationship path: {path!r}")
205
-
206
- loader = selectinload(attr) if loader is None else loader.selectinload(attr)
207
- current_model = attr.property.mapper.class_ # step down the graph
208
-
209
- return loader
210
-
211
195
  def order_by(self, *columns: Any):
212
196
  """Collect ORDER BY clauses.
213
197
  """
@@ -266,28 +250,22 @@ class BaseManager(Generic[ModelType]):
266
250
  self._limit = value
267
251
  return self
268
252
 
269
- def _apply_eager_loading(self, stmt, load_all_relations: bool = False):
270
- if not load_all_relations:
271
- return stmt
272
- opts: List[Any] = []
273
- visited: set[Any] = set()
274
-
275
- def recurse(model, loader=None):
276
- mapper = inspect(model)
277
- if mapper in visited:
278
- return
279
- visited.add(mapper)
280
- for rel in mapper.relationships:
281
- attr = getattr(model, rel.key)
282
- this_loader = selectinload(attr) if loader is None else loader.selectinload(attr)
283
- opts.append(this_loader)
284
- recurse(rel.mapper.class_, this_loader)
285
-
286
- recurse(self.model)
287
- return stmt.options(*opts)
288
-
289
- async def _execute_query(self, stmt, load_all_relations: bool):
290
- stmt = self._apply_eager_loading(stmt, load_all_relations)
253
+ def _build_relation_loaders(self, model: Any, relations: Sequence[str]) -> List[Load]:
254
+ """
255
+ Turn ['cat','product__tags'] into
256
+ [selectinload(model.cat),
257
+ selectinload(model.product).selectinload('tags')]
258
+ """
259
+ loaders: List[Load] = []
260
+ for path in relations:
261
+ parts = path.split("__")
262
+ loader = selectinload(getattr(model, parts[0]))
263
+ for sub in parts[1:]:
264
+ loader = loader.selectinload(sub)
265
+ loaders.append(loader)
266
+ return loaders
267
+
268
+ async def _execute_query(self, stmt):
291
269
  result = await self.session.execute(stmt)
292
270
  rows = result.scalars().all()
293
271
  return list({obj.id: obj for obj in rows}.values())
@@ -298,20 +276,12 @@ class BaseManager(Generic[ModelType]):
298
276
  self._limit = None
299
277
  self._joins.clear()
300
278
 
301
- async def all(self, load_all_relations: bool = False):
279
+ async def all(self, relations: Optional[List[str]] = None):
302
280
  stmt = select(self.model)
303
281
 
304
- for rel_path in self._joins:
305
- rel_model = self.model
306
- join_attr = None
307
-
308
- for part in rel_path.split("__"):
309
- join_attr = getattr(rel_model, part)
310
- if not hasattr(join_attr, "property"):
311
- raise ValueError(f"Invalid join path: {rel_path}")
312
- rel_model = join_attr.property.mapper.class_
313
-
314
- stmt = stmt.join(join_attr)
282
+ if relations:
283
+ opts = self._build_relation_loaders(self.model, relations)
284
+ stmt = stmt.options(*opts)
315
285
 
316
286
  if self.filters:
317
287
  stmt = stmt.filter(and_(*self.filters))
@@ -319,32 +289,38 @@ class BaseManager(Generic[ModelType]):
319
289
  stmt = stmt.order_by(*self._order_by)
320
290
  if self._limit:
321
291
  stmt = stmt.limit(self._limit)
292
+
322
293
  try:
323
- return await self._execute_query(stmt, load_all_relations)
294
+ return await self._execute_query(stmt)
324
295
  finally:
325
296
  self._reset_state()
326
297
 
327
- async def first(self, load_relations: Optional[Sequence[str]] = None):
298
+ async def first(self, relations: Optional[Sequence[str]] = None):
328
299
  self._ensure_filtered()
329
300
  stmt = select(self.model).filter(and_(*self.filters))
301
+
330
302
  if self._order_by:
331
303
  stmt = stmt.order_by(*self._order_by)
332
- if load_relations:
333
- for rel in load_relations:
334
- stmt = stmt.options(selectinload(getattr(self.model, rel)))
304
+
305
+ if relations:
306
+ opts = self._build_relation_loaders(self.model, relations)
307
+ stmt = stmt.options(*opts)
308
+
335
309
  result = await self.session.execute(stmt)
336
310
  self._reset_state()
337
311
  return result.scalars().first()
338
312
 
339
313
 
340
- async def last(self, load_relations: Optional[Sequence[str]] = None):
314
+ async def last(self, relations: Optional[Sequence[str]] = None):
341
315
  self._ensure_filtered()
342
316
  stmt = select(self.model).filter(and_(*self.filters))
343
317
  order = self._order_by or [self.model.id.desc()]
344
- stmt = stmt.order_by(*order[::-1]) # reverse for LAST
345
- if load_relations:
346
- for rel in load_relations:
347
- stmt = stmt.options(selectinload(getattr(self.model, rel)))
318
+ stmt = stmt.order_by(*order[::-1]) # reverse for last
319
+
320
+ if relations:
321
+ opts = self._build_relation_loaders(self.model, relations)
322
+ stmt = stmt.options(*opts)
323
+
348
324
  result = await self.session.execute(stmt)
349
325
  self._reset_state()
350
326
  return result.scalars().first()
@@ -359,14 +335,14 @@ class BaseManager(Generic[ModelType]):
359
335
  result = await self.session.execute(stmt)
360
336
  return int(result.scalar_one())
361
337
 
362
- async def paginate(self, limit=10, offset=0, load_all_relations=False):
338
+ async def paginate(self, limit: int = 10, offset: int = 0):
363
339
  self._ensure_filtered()
364
340
  stmt = select(self.model).filter(and_(*self.filters))
365
341
  if self._order_by:
366
342
  stmt = stmt.order_by(*self._order_by)
367
343
  stmt = stmt.limit(limit).offset(offset)
368
344
  try:
369
- return await self._execute_query(stmt, load_all_relations)
345
+ return await self._execute_query(stmt)
370
346
  finally:
371
347
  self._reset_state()
372
348
 
@@ -384,34 +360,25 @@ class BaseManager(Generic[ModelType]):
384
360
  return await self._smart_commit(instance)
385
361
 
386
362
  @with_transaction_error_handling
387
- async def lazy_save(self, instance: ModelType, load_relations: Sequence[str] = None) -> Optional[ModelType]:
363
+ async def lazy_save(self, instance: ModelType, relations: list[str] | None = None) -> ModelType:
388
364
  self.session.add(instance)
389
- await self.session.flush()
390
- await self._smart_commit(instance)
365
+ await self.session.commit()
391
366
 
392
- if load_relations is None:
367
+ if relations is None:
368
+ from sqlalchemy.inspection import inspect
393
369
  mapper = inspect(self.model)
394
- load_relations = [rel.key for rel in mapper.relationships]
370
+ relations = [r.key for r in mapper.relationships]
395
371
 
396
- if not load_relations:
372
+ if not relations:
397
373
  return instance
398
374
 
399
375
  stmt = select(self.model).filter_by(id=instance.id)
400
-
401
- for rel in load_relations:
402
- stmt = stmt.options(selectinload(getattr(self.model, rel)))
403
-
376
+ stmt = stmt.options(*self._build_relation_loaders(self.model, relations))
404
377
  result = await self.session.execute(stmt)
405
- loaded_instance = result.scalar_one_or_none()
406
-
407
- if loaded_instance is None:
408
- raise NotFoundException(
409
- message="Object saved but could not be retrieved with relationships",
410
- status=404,
411
- code="0001",
412
- )
413
-
414
- return loaded_instance
378
+ loaded = result.scalar_one_or_none()
379
+ if loaded is None:
380
+ raise NotFoundException("Could not reload after save", 404, "0001")
381
+ return loaded
415
382
 
416
383
  @with_transaction_error_handling
417
384
  async def update(self, instance: ModelType, **fields):
@@ -463,19 +430,12 @@ class BaseManager(Generic[ModelType]):
463
430
  self._reset_state()
464
431
  return result.rowcount
465
432
 
466
- async def get(
467
- self,
468
- pk: Union[str, int, uuid.UUID],
469
- load_relations: Optional[Sequence[str]] = None,
470
- ):
433
+ async def get(self, pk: Union[str, int, uuid.UUID], relations: Optional[Sequence[str]] = None) -> Any:
471
434
  stmt = select(self.model).filter_by(id=pk)
472
- if load_relations:
473
- for rel in load_relations:
474
- loader = (
475
- self._loader_from_path(rel) if "." in rel
476
- else selectinload(getattr(self.model, rel))
477
- )
478
- stmt = stmt.options(loader)
435
+ if relations:
436
+ opts = self._build_relation_loaders(self.model, relations)
437
+ stmt = stmt.options(*opts)
438
+
479
439
  result = await self.session.execute(stmt)
480
440
  instance = result.scalar_one_or_none()
481
441
  if instance is None:
@@ -527,13 +487,9 @@ class BaseManager(Generic[ModelType]):
527
487
 
528
488
  return self
529
489
 
530
- def model_to_dict(self, instance: ModelType, exclude: set[str] | None = None):
490
+ def model_to_dict(self, instance: ModelType, exclude: set[str] = None) -> dict:
531
491
  exclude = exclude or set()
532
- return {
533
- col.key: getattr(instance, col.key)
534
- for col in inspect(instance).mapper.column_attrs
535
- if col.key not in exclude
536
- }
492
+ return {k: getattr(instance, k) for k in self._column_keys if k not in exclude}
537
493
 
538
494
  def _ensure_filtered(self):
539
495
  if not self._filtered:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rb-commons
3
- Version: 0.5.28
3
+ Version: 0.6.1
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
@@ -13,7 +13,7 @@ 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=bY-yz4clB5lnV3xIGp5HyC4WbTL9HT7KBWQz_cwegDo,20029
16
+ rb_commons/orm/managers.py,sha256=KgvG-GCvjzzcySr-w23Rp0CtUjJwkNx8eNNDobDrW8c,18130
17
17
  rb_commons/orm/services.py,sha256=71eRcJ4TxZvzNz-hLXo12X4U7PGK54ZfbLAb27AjZi8,1589
18
18
  rb_commons/permissions/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
19
19
  rb_commons/permissions/role_permissions.py,sha256=4dV89z6ggzLqCCiFYlMp7kQVJRESu6MHpkT5ZNjLo6A,1096
@@ -22,7 +22,7 @@ rb_commons/schemes/jwt.py,sha256=ZKLJ5D3fcEmEKySjzbxEgUcza4K-oPoHr14_Z0r9Yic,249
22
22
  rb_commons/schemes/pagination.py,sha256=8VZW1wZGJIPR9jEBUgppZUoB4uqP8ORudHkMwvEJSxg,1866
23
23
  rb_commons/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
24
24
  rb_commons/utils/media.py,sha256=J2Zi0J28DhcVQVzt-myNNVuzj9Msaetul53VjZtdDdc,820
25
- rb_commons-0.5.28.dist-info/METADATA,sha256=YzqneH6c0aE667Pvsa2Z3PnlRL5ji0XMfVmy9mtsBIE,6571
26
- rb_commons-0.5.28.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
27
- rb_commons-0.5.28.dist-info/top_level.txt,sha256=HPx_WAYo3_fbg1WCeGHsz3wPGio1ucbnrlm2lmqlJog,11
28
- rb_commons-0.5.28.dist-info/RECORD,,
25
+ rb_commons-0.6.1.dist-info/METADATA,sha256=9Cd09KreAXl2XMYtjnScVxKU6C4Crmp-B3EBmZOpoRE,6570
26
+ rb_commons-0.6.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
27
+ rb_commons-0.6.1.dist-info/top_level.txt,sha256=HPx_WAYo3_fbg1WCeGHsz3wPGio1ucbnrlm2lmqlJog,11
28
+ rb_commons-0.6.1.dist-info/RECORD,,