rb-commons 0.7.14__py3-none-any.whl → 0.7.15__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.
@@ -43,6 +43,16 @@ def query_mutator(func: F) -> F:
43
43
  return wrapper
44
44
 
45
45
 
46
+ AGG_MAP: dict[str, Callable[[Any], Any]] = {
47
+ "sum": func.sum,
48
+ "avg": func.avg,
49
+ "mean": func.avg,
50
+ "min": func.min,
51
+ "max": func.max,
52
+ "count": func.count,
53
+ "first": lambda c: c,
54
+ }
55
+
46
56
  class BaseManager(Generic[ModelType]):
47
57
  model: Type[ModelType]
48
58
 
@@ -525,27 +535,116 @@ class BaseManager(Generic[ModelType]):
525
535
  self._filtered = True
526
536
  return self
527
537
 
528
- @query_mutator
529
- def sort_by(self, tokens: Sequence[str]) -> "BaseManager[ModelType]":
538
+ def _infer_default_agg(self, column) -> str:
539
+ try:
540
+ from sqlalchemy import Integer, BigInteger, SmallInteger, Float, Numeric
541
+ if hasattr(column, "type") and isinstance(column.type, (Integer, BigInteger, SmallInteger, Float, Numeric)):
542
+ return "sum"
543
+ except Exception:
544
+ pass
545
+ return "max"
546
+ def _order_expr_for_path(self, token: str):
530
547
  """
531
- Dynamically apply ORDER BY clauses based on a list of "field" or "-field" tokens.
532
- Unknown fields are collected for Python-side sorting later.
548
+ token grammar:
549
+ [-]<path>[:<agg>][!first|!last]
550
+ <path> := "field" | "relation__field" (one hop)
551
+ <agg> := sum|avg|min|max|count|first (required for uselist=True; optional otherwise)
552
+ Examples:
553
+ "category__title"
554
+ "-reviews__rating:avg!last"
555
+ "stocks__sold:sum"
533
556
  """
534
- self._invalid_sort_tokens = []
535
- self._order_by = []
536
- model = self.model
537
557
 
538
- for tok in tokens:
539
- if not tok:
540
- continue
541
- direction = desc if tok.startswith("-") else asc
542
- name = tok.lstrip("-")
543
- col = getattr(model, name, None)
558
+ # strip leading '-' (handled by caller), and parse nulls placement
559
+ core = token.lstrip("-")
560
+ nulls_placement = None
561
+ if core.endswith("!first"):
562
+ core, nulls_placement = core[:-6], "first"
563
+ elif core.endswith("!last"):
564
+ core, nulls_placement = core[:-5], "last"
565
+
566
+ # split aggregate suffix if present
567
+ if ":" in core:
568
+ path, agg_name = core.split(":", 1)
569
+ agg_name = agg_name.lower().strip()
570
+ else:
571
+ path, agg_name = core, None
572
+
573
+ # base column on the model (no relation hop)
574
+ if "__" not in path and "." not in path:
575
+ col = getattr(self.model, path, None)
544
576
  if col is None:
545
- self._invalid_sort_tokens.append(tok)
546
- continue
547
- self._order_by.append(direction(col))
577
+ raise ValueError(f"Invalid order_by field '{path}' for {self.model.__name__}")
578
+ expr = col
579
+ if nulls_placement == "first":
580
+ expr = expr.nullsfirst()
581
+ elif nulls_placement == "last":
582
+ expr = expr.nullslast()
583
+ return expr
584
+
585
+ # relation hop (exactly one)
586
+ parts = re.split(r"\.|\_\_", path)
587
+ if len(parts) != 2:
588
+ raise ValueError(f"Only one relation hop supported in order_by: {path!r}")
589
+
590
+ rel_name, col_name = parts
591
+ rel_attr = getattr(self.model, rel_name, None)
592
+ if rel_attr is None or not hasattr(rel_attr, "property"):
593
+ raise ValueError(f"Invalid relationship '{rel_name}' on {self.model.__name__}")
594
+
595
+ target_mapper = rel_attr.property.mapper
596
+ target_cls = target_mapper.class_
597
+ target_col = getattr(target_cls, col_name, None)
598
+ if target_col is None:
599
+ raise ValueError(f"Invalid column '{col_name}' on related model {target_cls.__name__}")
600
+
601
+ primaryjoin = rel_attr.property.primaryjoin
602
+ uselist = rel_attr.property.uselist
603
+
604
+ # One-to-many (or many-to-many via association): require aggregate (or infer)
605
+ if uselist:
606
+ agg_name = agg_name or self._infer_default_agg(target_col)
607
+ agg_fn = AGG_MAP.get(agg_name)
608
+ if agg_fn is None:
609
+ raise ValueError(f"Unsupported aggregate '{agg_name}' in order_by for {path!r}")
610
+
611
+ # SELECT agg(related.col) WHERE primaryjoin (correlated)
612
+ subq = (
613
+ select(agg_fn(target_col))
614
+ .where(primaryjoin)
615
+ .correlate(self.model) # tie to outer row
616
+ .scalar_subquery()
617
+ )
618
+ expr = subq
548
619
 
620
+ else:
621
+ if agg_name and agg_name != "first":
622
+ agg_fn = AGG_MAP.get(agg_name)
623
+ if agg_fn is None:
624
+ raise ValueError(f"Unsupported aggregate '{agg_name}' in order_by for {path!r}")
625
+ select_expr = agg_fn(target_col)
626
+ else:
627
+ select_expr = target_col
628
+
629
+ sub = select(select_expr).where(primaryjoin).correlate(self.model)
630
+ if agg_name == "first":
631
+ sub = sub.limit(1)
632
+ expr = sub.scalar_subquery()
633
+
634
+ if nulls_placement == "first":
635
+ expr = expr.nullsfirst()
636
+ elif nulls_placement == "last":
637
+ expr = expr.nullslast()
638
+
639
+ return expr
640
+
641
+ @query_mutator
642
+ def sort_by(self, tokens):
643
+ self._order_by = []
644
+ for tok in tokens or []:
645
+ direction = desc if tok.startswith("-") else asc
646
+ name = tok.lstrip("-")
647
+ self._order_by.append(direction(self._order_expr_for_path(name)))
549
648
  return self
550
649
 
551
650
  def model_to_dict(self, instance: ModelType, exclude: set[str] = None) -> dict:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rb-commons
3
- Version: 0.7.14
3
+ Version: 0.7.15
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
@@ -14,7 +14,7 @@ rb_commons/http/exceptions.py,sha256=EGRMr1cRgiJ9Q2tkfANbf0c6-zzXf1CD6J3cmCaT_FA
14
14
  rb_commons/orm/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
15
15
  rb_commons/orm/enum.py,sha256=PRNSuP1X7ADJW1snnxRGcHj9mxE3JaO-a2AyaHmB6es,380
16
16
  rb_commons/orm/exceptions.py,sha256=1aMctiEwrPjyehoXVX1l6ML5ZOhmDkmBISzlTD5ey1Y,509
17
- rb_commons/orm/managers.py,sha256=BrpMvOkRoAPKtUV1SfakXMYd73-RhqIaoj1Wmru1gAs,20319
17
+ rb_commons/orm/managers.py,sha256=wo6fq85kxGAzzd3DQfveg-yyCLtDuFOibq6isFNbFc4,24161
18
18
  rb_commons/orm/querysets.py,sha256=Q4iY_TKZItpNnrbCAvlSwyCxJikhaX5qVF3Y2rVgAYQ,1666
19
19
  rb_commons/orm/services.py,sha256=71eRcJ4TxZvzNz-hLXo12X4U7PGK54ZfbLAb27AjZi8,1589
20
20
  rb_commons/permissions/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -24,7 +24,7 @@ rb_commons/schemes/jwt.py,sha256=ZKLJ5D3fcEmEKySjzbxEgUcza4K-oPoHr14_Z0r9Yic,249
24
24
  rb_commons/schemes/pagination.py,sha256=8VZW1wZGJIPR9jEBUgppZUoB4uqP8ORudHkMwvEJSxg,1866
25
25
  rb_commons/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
26
26
  rb_commons/utils/media.py,sha256=pwwGyImI5BK-NCJkX0Q6w2Nm-QL9_CCQC7B7O7wz38I,1493
27
- rb_commons-0.7.14.dist-info/METADATA,sha256=e_AOmEqnBAOeEs8njJyzP-ge1IJTFW5_JrHmZHTUPjA,6571
28
- rb_commons-0.7.14.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
29
- rb_commons-0.7.14.dist-info/top_level.txt,sha256=HPx_WAYo3_fbg1WCeGHsz3wPGio1ucbnrlm2lmqlJog,11
30
- rb_commons-0.7.14.dist-info/RECORD,,
27
+ rb_commons-0.7.15.dist-info/METADATA,sha256=sKv6SUepDqAr5CzE7OC4UD6D6VFjH-GlwoiAQzJm95o,6571
28
+ rb_commons-0.7.15.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
29
+ rb_commons-0.7.15.dist-info/top_level.txt,sha256=HPx_WAYo3_fbg1WCeGHsz3wPGio1ucbnrlm2lmqlJog,11
30
+ rb_commons-0.7.15.dist-info/RECORD,,