python-general-be-lib 0.9.0__tar.gz → 0.9.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.
Files changed (55) hide show
  1. {python_general_be_lib-0.9.0 → python_general_be_lib-0.9.1}/PKG-INFO +2 -1
  2. {python_general_be_lib-0.9.0 → python_general_be_lib-0.9.1}/general/interface/repository/crud_repository.py +43 -21
  3. {python_general_be_lib-0.9.0 → python_general_be_lib-0.9.1}/general/interface/repository/handler/statement_manager.py +10 -3
  4. {python_general_be_lib-0.9.0 → python_general_be_lib-0.9.1}/general/paginator_dto.py +6 -2
  5. {python_general_be_lib-0.9.0 → python_general_be_lib-0.9.1}/pyproject.toml +2 -1
  6. {python_general_be_lib-0.9.0 → python_general_be_lib-0.9.1}/python_general_be_lib.egg-info/PKG-INFO +2 -1
  7. {python_general_be_lib-0.9.0 → python_general_be_lib-0.9.1}/python_general_be_lib.egg-info/SOURCES.txt +1 -0
  8. python_general_be_lib-0.9.1/tests/test_ordinamento.py +285 -0
  9. {python_general_be_lib-0.9.0 → python_general_be_lib-0.9.1}/LICENSE +0 -0
  10. {python_general_be_lib-0.9.0 → python_general_be_lib-0.9.1}/README.md +0 -0
  11. {python_general_be_lib-0.9.0 → python_general_be_lib-0.9.1}/general/__init__.py +0 -0
  12. {python_general_be_lib-0.9.0 → python_general_be_lib-0.9.1}/general/exception/__init__.py +0 -0
  13. {python_general_be_lib-0.9.0 → python_general_be_lib-0.9.1}/general/exception/access_exceptions.py +0 -0
  14. {python_general_be_lib-0.9.0 → python_general_be_lib-0.9.1}/general/exception/crud_exceptions.py +0 -0
  15. {python_general_be_lib-0.9.0 → python_general_be_lib-0.9.1}/general/exception/exception_interface.py +0 -0
  16. {python_general_be_lib-0.9.0 → python_general_be_lib-0.9.1}/general/interface/__init__.py +0 -0
  17. {python_general_be_lib-0.9.0 → python_general_be_lib-0.9.1}/general/interface/base/__init__.py +0 -0
  18. {python_general_be_lib-0.9.0 → python_general_be_lib-0.9.1}/general/interface/base/base_model.py +0 -0
  19. {python_general_be_lib-0.9.0 → python_general_be_lib-0.9.1}/general/interface/base/declarative_base.py +0 -0
  20. {python_general_be_lib-0.9.0 → python_general_be_lib-0.9.1}/general/interface/metadata/__init__.py +0 -0
  21. {python_general_be_lib-0.9.0 → python_general_be_lib-0.9.1}/general/interface/metadata/crud_metadata.py +0 -0
  22. {python_general_be_lib-0.9.0 → python_general_be_lib-0.9.1}/general/interface/metadata/geom_metadata.py +0 -0
  23. {python_general_be_lib-0.9.0 → python_general_be_lib-0.9.1}/general/interface/repository/__init__.py +0 -0
  24. {python_general_be_lib-0.9.0 → python_general_be_lib-0.9.1}/general/interface/repository/geometry_repository.py +0 -0
  25. {python_general_be_lib-0.9.0 → python_general_be_lib-0.9.1}/general/interface/repository/handler/__init__.py +0 -0
  26. {python_general_be_lib-0.9.0 → python_general_be_lib-0.9.1}/general/interface/repository/handler/base_handler.py +0 -0
  27. {python_general_be_lib-0.9.0 → python_general_be_lib-0.9.1}/general/interface/repository/handler/handler.py +0 -0
  28. {python_general_be_lib-0.9.0 → python_general_be_lib-0.9.1}/general/interface/repository/handler/ilike_handler.py +0 -0
  29. {python_general_be_lib-0.9.0 → python_general_be_lib-0.9.1}/general/interface/repository/handler/interval_handler.py +0 -0
  30. {python_general_be_lib-0.9.0 → python_general_be_lib-0.9.1}/general/interface/repository/handler/json_handler.py +0 -0
  31. {python_general_be_lib-0.9.0 → python_general_be_lib-0.9.1}/general/interface/repository/handler/json_ilike_handler.py +0 -0
  32. {python_general_be_lib-0.9.0 → python_general_be_lib-0.9.1}/general/interface/repository/handler/or_handler.py +0 -0
  33. {python_general_be_lib-0.9.0 → python_general_be_lib-0.9.1}/general/interface/repository/many_to_many_repository.py +0 -0
  34. {python_general_be_lib-0.9.0 → python_general_be_lib-0.9.1}/general/interface/repository/view_repository.py +0 -0
  35. {python_general_be_lib-0.9.0 → python_general_be_lib-0.9.1}/general/logger.py +0 -0
  36. {python_general_be_lib-0.9.0 → python_general_be_lib-0.9.1}/general/py.typed +0 -0
  37. {python_general_be_lib-0.9.0 → python_general_be_lib-0.9.1}/python_general_be_lib.egg-info/dependency_links.txt +0 -0
  38. {python_general_be_lib-0.9.0 → python_general_be_lib-0.9.1}/python_general_be_lib.egg-info/requires.txt +0 -0
  39. {python_general_be_lib-0.9.0 → python_general_be_lib-0.9.1}/python_general_be_lib.egg-info/top_level.txt +0 -0
  40. {python_general_be_lib-0.9.0 → python_general_be_lib-0.9.1}/setup.cfg +0 -0
  41. {python_general_be_lib-0.9.0 → python_general_be_lib-0.9.1}/tests/test_caricamento_parziale.py +0 -0
  42. {python_general_be_lib-0.9.0 → python_general_be_lib-0.9.1}/tests/test_compiled_cache.py +0 -0
  43. {python_general_be_lib-0.9.0 → python_general_be_lib-0.9.1}/tests/test_entity_has_columns.py +0 -0
  44. {python_general_be_lib-0.9.0 → python_general_be_lib-0.9.1}/tests/test_has_columns.py +0 -0
  45. {python_general_be_lib-0.9.0 → python_general_be_lib-0.9.1}/tests/test_import_pubblici.py +0 -0
  46. {python_general_be_lib-0.9.0 → python_general_be_lib-0.9.1}/tests/test_logger.py +0 -0
  47. {python_general_be_lib-0.9.0 → python_general_be_lib-0.9.1}/tests/test_negazione.py +0 -0
  48. {python_general_be_lib-0.9.0 → python_general_be_lib-0.9.1}/tests/test_pipeline_handler.py +0 -0
  49. {python_general_be_lib-0.9.0 → python_general_be_lib-0.9.1}/tests/test_query_count.py +0 -0
  50. {python_general_be_lib-0.9.0 → python_general_be_lib-0.9.1}/tests/test_smoke_crud.py +0 -0
  51. {python_general_be_lib-0.9.0 → python_general_be_lib-0.9.1}/tests/test_sql_snapshot.py +0 -0
  52. {python_general_be_lib-0.9.0 → python_general_be_lib-0.9.1}/tests/test_to_dict.py +0 -0
  53. {python_general_be_lib-0.9.0 → python_general_be_lib-0.9.1}/tests/test_to_pydantic.py +0 -0
  54. {python_general_be_lib-0.9.0 → python_general_be_lib-0.9.1}/tests/test_valori_falsy.py +0 -0
  55. {python_general_be_lib-0.9.0 → python_general_be_lib-0.9.1}/tests/test_view_repository.py +0 -0
@@ -1,11 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-general-be-lib
3
- Version: 0.9.0
3
+ Version: 0.9.1
4
4
  Summary: General purpose backend library — SQLAlchemy CRUD/Geometry repositories, FastAPI exceptions, Pydantic base models, logger utilities.
5
5
  Author-email: Andrea Di Placido <a.diplacido@arpes.it>, "Arpes S.r.l." <it.admin@arpes.it>
6
6
  License: MIT
7
7
  Project-URL: Homepage, https://github.com/Arpes-IS/python-general-be-lib
8
8
  Project-URL: Repository, https://github.com/Arpes-IS/python-general-be-lib
9
+ Project-URL: Changelog, https://github.com/Arpes-IS/python-general-be-lib/blob/main/CHANGELOG.md
9
10
  Keywords: backend,sqlalchemy,fastapi,pydantic,repository,crud,geoalchemy2
10
11
  Classifier: Development Status :: 3 - Alpha
11
12
  Classifier: Intended Audience :: Developers
@@ -317,7 +317,7 @@ class CrudRepository[Entity: Base | Type[Base]]:
317
317
  return True
318
318
 
319
319
  def find(self, session: Session = None, where: dict[str, Any] = None, columns: list[str] = None, exclude_columns: list[str] = None, limit: int = 0, parsing_model: Type[BaseModel] = None, order_by: str = None,
320
- asc: bool = None, **kwhere) -> list[Entity]:
320
+ asc: bool = None, orders_by: dict[str, bool] = None, **kwhere) -> list[Entity]:
321
321
  """Find the records matching the given criteria.
322
322
 
323
323
  The filters can be passed as a ``where`` dictionary or as keyword arguments. Being a
@@ -337,6 +337,11 @@ class CrudRepository[Entity: Base | Type[Base]]:
337
337
  ``None``, no ordering is applied.
338
338
  asc (bool, optional): Ordering direction, ``True`` for ASC and ``False`` for
339
339
  DESC. When ``None``, the default ordering is DESC.
340
+ orders_by (dict[str, bool], optional): Ordering on several columns, as
341
+ ``{column: ascending}``. The clauses follow the insertion order of the
342
+ dictionary. Takes precedence over ``order_by``/``asc``. The keys are
343
+ database column names, unlike ``columns``, which takes Python attribute
344
+ names.
340
345
  **kwhere: Additional filters as keyword arguments.
341
346
 
342
347
  Returns:
@@ -344,22 +349,26 @@ class CrudRepository[Entity: Base | Type[Base]]:
344
349
  given.
345
350
 
346
351
  Raises:
347
- HasNoAttributeException: If a column in ``columns``, ``exclude_columns`` or
348
- ``order_by`` does not exist on the entity.
352
+ HasNoAttributeException: If a column in ``columns``, ``exclude_columns``,
353
+ ``order_by`` or ``orders_by`` does not exist on the entity.
349
354
  """
350
355
 
351
356
  keep_open = False if session is None else True
352
357
  session = self.open_session if session is None else session
353
- entities = self._find(session=session, where=where, columns=columns, exclude_columns=exclude_columns, limit=limit, order_by=order_by, asc=asc, **kwhere)
358
+ if order_by is not None and orders_by is None:
359
+ orders_by = {order_by: asc if asc is not None else False}
360
+ entities = self._find(session=session, where=where, columns=columns, exclude_columns=exclude_columns, limit=limit, orders_by=orders_by, **kwhere)
354
361
  parsing_model = parsing_model if parsing_model is not None else self._retrieve_parsing_model(columns=columns, exclude_columns=exclude_columns)
355
362
  return self._return(session=session, entities=entities, keep_open=keep_open, commit=False, parsing_model=parsing_model)
356
363
 
357
- def _find(self, session: Session, where: dict[str, Any] = None, columns: list[str] = None, exclude_columns: list[str] = None, limit: int = 0, order_by: str = None,
358
- asc: bool = None, **kwhere):
364
+ def _find(self, session: Session, where: dict[str, Any] = None, columns: list[str] = None, exclude_columns: list[str] = None, limit: int = 0, orders_by: dict[str, bool] = None, **kwhere):
359
365
  if where is None:
360
366
  where = kwhere if kwhere else {}
367
+ if orders_by is None:
368
+ orders_by = {}
361
369
  stmt = self._select(entity=self.entity, columns=columns, exclude_columns=exclude_columns)
362
- stmt = self.stmt_manager.order_by_condition(stmt=stmt, order_by=order_by, asc=asc)
370
+ for order_by, asc in orders_by.items():
371
+ stmt = self.stmt_manager.order_by_condition(stmt=stmt, order_by=order_by, asc=asc)
363
372
  stmt = self.stmt_manager.limit_offset_condition(stmt=stmt, limit=limit)
364
373
 
365
374
  stmt = self.stmt_manager.prepare_statement(stmt=stmt, **where)
@@ -626,7 +635,7 @@ class CrudRepository[Entity: Base | Type[Base]]:
626
635
  return session.execute(stmt).scalar()
627
636
 
628
637
  def paging_find(self, session: Session = None, where: dict[str, Any] = None, columns: list[str] = None, exclude_columns: list[str] = None, limit: int = 0, page: int = 1, order_by: str = None, asc: bool = True,
629
- parsing_model: Type[BaseModel] = None, **kwhere) -> PaginatorResponseModel[Entity]:
638
+ parsing_model: Type[BaseModel] = None, orders_by: dict[str, bool] = None, **kwhere) -> PaginatorResponseModel[Entity]:
630
639
  """Run a paginated search with ordering and offset support.
631
640
 
632
641
  Computes the total number of pages and returns the requested page only. The data
@@ -650,30 +659,40 @@ class CrudRepository[Entity: Base | Type[Base]]:
650
659
  asc (bool): Ascending order if ``True``. Default: ``True``.
651
660
  parsing_model (Type[BaseModel], optional): Alternative model for the
652
661
  serialization.
662
+ orders_by (dict[str, bool], optional): Ordering on several columns, as
663
+ ``{column: ascending}``, with the clauses following the insertion order of
664
+ the dictionary. Takes precedence over ``order_by``/``asc``. The primary key
665
+ is **not** appended as a tie-breaker, so an ordering on a non-unique column
666
+ does not give a total order and two pages can overlap: pass the key
667
+ explicitly as the last entry when the ordering must be stable.
653
668
  **kwhere: Filters as keyword arguments.
654
669
 
655
670
  Returns:
656
671
  PaginatorResponseModel[Entity]: Object carrying ``data``, ``tot_pages``,
657
- ``page``, ``limit``, ``order_by`` and ``asc``.
672
+ ``page``, ``limit``, ``orders_by``, plus ``order_by`` and ``asc`` holding
673
+ the first pair of the ordering.
658
674
  """
659
675
  keep_open = False if session is None else True
660
676
  session = self.open_session if session is None else session
661
- paginated = self._paging_find(session=session, where=where, columns=columns, exclude_columns=exclude_columns, limit=limit, page=page, order_by=order_by, asc=asc, **kwhere)
677
+ orders_by = orders_by if orders_by else {order_by or self.entity.primary_key()[0].key: asc if asc is not None else False}
678
+ paginated = self._paging_find(session=session, where=where, columns=columns, exclude_columns=exclude_columns, limit=limit, page=page, orders_by=orders_by, **kwhere)
662
679
  parsing_model = parsing_model if parsing_model is not None else self._retrieve_parsing_model(columns=columns, exclude_columns=exclude_columns)
663
680
  if not keep_open:
664
681
  paginated.data = _list_adapter(parsing_model).validate_python(paginated.data, from_attributes=True)
665
682
  session.close()
666
683
  return paginated
667
684
 
668
- def _paging_find(self, session: Session, where: dict[str, Any] = None, columns: list[str] = None, exclude_columns: list[str] = None, limit: int = 0, page: int = 1, order_by: str = None, asc: bool = True, **kwhere):
685
+ def _paging_find(self, session: Session, where: dict[str, Any] = None, columns: list[str] = None, exclude_columns: list[str] = None, limit: int = 0, page: int = 1, orders_by: dict[str, bool] = None, **kwhere):
669
686
  if where is None:
670
687
  where = kwhere if kwhere else {}
688
+ if not orders_by:
689
+ orders_by = {self.entity.primary_key()[0].key: True}
671
690
 
672
691
  stmt = self._select(entity=self.entity, columns=columns, exclude_columns=exclude_columns)
673
692
  stmt = self.stmt_manager.limit_offset_condition(stmt=stmt, limit=limit, page=page)
674
693
 
675
- order_by = order_by or self.entity.primary_key()[0].name
676
- stmt = self.stmt_manager.order_by_condition(stmt=stmt, order_by=order_by, asc=asc)
694
+ for order_by, asc in orders_by.items():
695
+ stmt = self.stmt_manager.order_by_condition(stmt=stmt, order_by=order_by, asc=asc)
677
696
 
678
697
  conditions = self.stmt_manager.conditions(where=where)
679
698
  if conditions:
@@ -690,20 +709,22 @@ class CrudRepository[Entity: Base | Type[Base]]:
690
709
  else:
691
710
  count_ = self._preconf_count(session=session, where=conditions)
692
711
 
693
- return self.parse_pagination(limit=limit, page=page, order_by=order_by, count=count_, asc=asc, data=entities)
712
+ return self.parse_pagination(limit=limit, page=page, orders_by=orders_by, count=count_, data=entities)
694
713
 
695
- def parse_pagination(self, limit: int, page: int, order_by: str, asc: bool, count: int, data: Sequence[Row] | Sequence[Row | RowMapping] | Sequence[RowMapping]) -> PaginatorResponseModel:
714
+ def parse_pagination(self, limit: int, page: int, orders_by: dict[str, bool], count: int, data: Sequence[Row] | Sequence[Row | RowMapping] | Sequence[RowMapping]) -> PaginatorResponseModel:
696
715
  """Build the ``PaginatorResponseModel`` from the pagination data.
697
716
 
698
717
  Computes the total number of pages. When ``limit`` is 0, meaning no limit, the
699
- length of the data is used as the effective limit; when ``order_by`` is not given,
700
- the primary key is used as the default ordering field.
718
+ length of the data is used as the effective limit. The response carries the whole
719
+ ``orders_by`` plus its first pair as ``order_by``/``asc``, which are the fields the
720
+ clients read as the main ordering criterion.
701
721
 
702
722
  Args:
703
723
  limit (int): Number of items per page.
704
724
  page (int): Current page.
705
- order_by (str): Ordering field used.
706
- asc (bool): Ordering direction.
725
+ orders_by (dict[str, bool]): Ordering applied, as ``{column: ascending}``. Its
726
+ first pair fills ``order_by``/``asc``; when empty both stay ``None``,
727
+ because no ordering was applied.
707
728
  count (int): Total number of filtered records.
708
729
  data (Sequence): Entities of the current page.
709
730
 
@@ -711,10 +732,11 @@ class CrudRepository[Entity: Base | Type[Base]]:
711
732
  PaginatorResponseModel: Complete paginated response.
712
733
  """
713
734
  limit = limit if limit else len(data)
714
- order_by = order_by if order_by else self.entity.primary_key()[0].name
735
+ order_by = next(iter(orders_by), None) if orders_by else None
736
+ asc = orders_by.get(order_by) if order_by is not None else None
715
737
  tot_pages = math.ceil(count / limit) if count and limit > 0 else 1
716
738
 
717
- return PaginatorResponseModel(limit=limit, page=page, order_by=order_by, asc=asc, tot_pages=tot_pages, data=data)
739
+ return PaginatorResponseModel(limit=limit, page=page, order_by=order_by, asc=asc, tot_pages=tot_pages, orders_by=orders_by, data=data)
718
740
 
719
741
  def distinct_pairs(self, key: str, value: str, session: Session = None, where: dict[str, Any] = None, **kwhere) -> list[Row] | None:
720
742
  """Return the distinct ``(key, value)`` pairs of the records matching ``where``.
@@ -113,12 +113,19 @@ class StatementManager:
113
113
  return stmt
114
114
 
115
115
  def order_by_condition(self, stmt: Select, order_by: str, asc: bool):
116
- """Add the ORDER BY clause to the statement.
116
+ """Add one ORDER BY clause to the statement.
117
+
118
+ Adds a single criterion and can be called repeatedly to build an ordering on
119
+ several columns: ``Select.order_by`` appends, so the clauses keep the order of the
120
+ calls. The column name is resolved against the table columns, so a name that is
121
+ not a column cannot reach the SQL.
117
122
 
118
123
  Args:
119
124
  stmt (Select): Select statement to add the ordering to.
120
- order_by (str): Name of the column to order by.
121
- asc (bool): If ``True``, ascending order (ASC); if ``False``, descending (DESC).
125
+ order_by (str): Database name of the column to order by. Falsy values leave the
126
+ statement untouched.
127
+ asc (bool): If ``True``, ascending order (ASC); if ``False`` or ``None``,
128
+ descending (DESC).
122
129
 
123
130
  Returns:
124
131
  Select: Statement with the ORDER BY clause added.
@@ -18,12 +18,12 @@ class PaginatorBaseModel(ExtBaseModel):
18
18
  page (int): Current page number, 1-based. Alias: ``numeroPagina``.
19
19
  limit (int | None): Number of items per page. Alias: ``numeroElementi``.
20
20
  order_by (str | None): Field used for the ordering. Alias: ``campoOrdinamento``.
21
- asc (bool): Ascending order if ``True``. Alias: ``crescente``.
21
+ asc (bool | None): Ascending order if ``True``. Alias: ``crescente``.
22
22
  """
23
23
  page: int = Field(alias="numeroPagina")
24
24
  limit: Optional[int] = Field(default=None, alias="numeroElementi")
25
25
  order_by: Optional[str] = Field(default=None, alias="campoOrdinamento")
26
- asc: bool = Field(alias="crescente")
26
+ asc: Optional[bool] = Field(default=None, alias="crescente")
27
27
 
28
28
 
29
29
  class PaginatorRequestModel(PaginatorBaseModel):
@@ -45,8 +45,12 @@ class PaginatorResponseModel[ResponseType](PaginatorBaseModel):
45
45
  Attributes:
46
46
  tot_pages (int): Total number of pages, computed by the repository.
47
47
  Alias: ``pagineTotali``.
48
+ orders_by (dict[str, bool] | None): Whole ordering applied, as
49
+ ``{column: ascending}``. The inherited ``order_by``/``asc`` carry its first
50
+ pair, which is the main criterion. Alias: ``campiOrdinamento``.
48
51
  data (list[ResponseType]): Items of the current page.
49
52
  """
50
53
 
51
54
  tot_pages: int = Field(alias="pagineTotali")
55
+ orders_by: Optional[dict[str, bool]] = Field(default=None, alias="campiOrdinamento")
52
56
  data: list[ResponseType]
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-general-be-lib"
7
- version = "0.9.0"
7
+ version = "0.9.1"
8
8
  description = "General purpose backend library — SQLAlchemy CRUD/Geometry repositories, FastAPI exceptions, Pydantic base models, logger utilities."
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -45,6 +45,7 @@ dev = [
45
45
  [project.urls]
46
46
  Homepage = "https://github.com/Arpes-IS/python-general-be-lib"
47
47
  Repository = "https://github.com/Arpes-IS/python-general-be-lib"
48
+ Changelog = "https://github.com/Arpes-IS/python-general-be-lib/blob/main/CHANGELOG.md"
48
49
 
49
50
  # PEP 561: il marker `py.typed` va spedito dentro il pacchetto, altrimenti i type
50
51
  # checker ignorano la libreria installata (e mypy segnala un errore di import).
@@ -1,11 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-general-be-lib
3
- Version: 0.9.0
3
+ Version: 0.9.1
4
4
  Summary: General purpose backend library — SQLAlchemy CRUD/Geometry repositories, FastAPI exceptions, Pydantic base models, logger utilities.
5
5
  Author-email: Andrea Di Placido <a.diplacido@arpes.it>, "Arpes S.r.l." <it.admin@arpes.it>
6
6
  License: MIT
7
7
  Project-URL: Homepage, https://github.com/Arpes-IS/python-general-be-lib
8
8
  Project-URL: Repository, https://github.com/Arpes-IS/python-general-be-lib
9
+ Project-URL: Changelog, https://github.com/Arpes-IS/python-general-be-lib/blob/main/CHANGELOG.md
9
10
  Keywords: backend,sqlalchemy,fastapi,pydantic,repository,crud,geoalchemy2
10
11
  Classifier: Development Status :: 3 - Alpha
11
12
  Classifier: Intended Audience :: Developers
@@ -42,6 +42,7 @@ tests/test_has_columns.py
42
42
  tests/test_import_pubblici.py
43
43
  tests/test_logger.py
44
44
  tests/test_negazione.py
45
+ tests/test_ordinamento.py
45
46
  tests/test_pipeline_handler.py
46
47
  tests/test_query_count.py
47
48
  tests/test_smoke_crud.py
@@ -0,0 +1,285 @@
1
+ """Ordinamento multiplo con ``orders_by`` (feature 2.3 di ``.claude/piano-feature.md``).
2
+
3
+ Prima esisteva un solo ``order_by: str`` più un ``asc: bool``, quindi le tabelle
4
+ multi-colonna delle UI non erano servibili. Ora ``orders_by`` è un
5
+ ``dict[str, bool]``: la chiave è la colonna, il valore la direzione, e l'ordine delle
6
+ clausole segue l'ordine di inserimento del dizionario — garantito dal linguaggio.
7
+
8
+ I test coprono tre cose in proporzioni diverse:
9
+
10
+ 1. la **retro-compatibilità** di ``order_by``/``asc``, che è dove un difetto passa
11
+ inosservato: chi passa solo ``order_by`` non guarda l'SQL generato;
12
+ 2. il nuovo ``orders_by``, incluso quale chiave finisce nella risposta paginata;
13
+ 3. due **limiti noti** che restano aperti, fissati come caratterizzazione perché
14
+ correggerli cambia i risultati e non deve avvenire per caso.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from typing import Optional
20
+
21
+ import pytest
22
+ from sqlalchemy import Column, Engine, Integer, String, insert
23
+
24
+ from general.exception.crud_exceptions import HasNoAttributeException
25
+ from general.interface.base.base_model import ExtBaseModel
26
+ from general.interface.base.declarative_base import Base
27
+ from general.interface.repository.crud_repository import CrudRepository
28
+ from models import Utente, utente_repository
29
+ from support.sql import captured_sql
30
+
31
+
32
+ class UtenteChiaveEsplicita(Base):
33
+ """Entità la cui PK ha ``key`` diverso da ``name``.
34
+
35
+ ``primary_key()[0].name`` vale ``ID_LEGACY`` mentre ``Table.c`` è indicizzato per
36
+ ``key``, cioè ``id``: usare il nome invece della chiave fa fallire l'ordinamento
37
+ di default, e quindi **qualunque** ``find()`` su questa entità.
38
+ """
39
+
40
+ __tablename__ = "utente_chiave_esplicita"
41
+
42
+ id = Column("ID_LEGACY", Integer, primary_key=True, key="id")
43
+ nome = Column(String(50))
44
+
45
+
46
+ class UtenteChiaveEsplicitaModel(ExtBaseModel):
47
+ id: Optional[int] = None
48
+ nome: Optional[str] = None
49
+
50
+
51
+ @pytest.fixture
52
+ def repo(sqlite_engine: Engine) -> CrudRepository:
53
+ """Repository su ``Utente`` con qualche riga, per gli ordinamenti sui dati."""
54
+ with sqlite_engine.begin() as connessione:
55
+ connessione.execute(
56
+ insert(Utente.__table__),
57
+ [
58
+ {"id": 1, "nome": "a", "ruolo": "user", "eta": 30, "attivo": True},
59
+ {"id": 2, "nome": "b", "ruolo": "admin", "eta": 30, "attivo": True},
60
+ {"id": 3, "nome": "c", "ruolo": "user", "eta": 10, "attivo": True},
61
+ ],
62
+ )
63
+ return utente_repository(sqlite_engine)
64
+
65
+
66
+ def clausola(funzione, **kwargs) -> str:
67
+ """Ritorna la sola ``ORDER BY`` dello statement generato, senza toccare il database."""
68
+ sql = captured_sql(funzione, **kwargs)[0]
69
+ if "ORDER BY" not in sql:
70
+ return "-- NIENTE --"
71
+ return sql.split("ORDER BY")[1].split("LIMIT")[0].strip()
72
+
73
+
74
+ # ------------------------------------------------------- retro-compatibilità
75
+
76
+
77
+ @pytest.mark.parametrize(
78
+ "kwargs, atteso",
79
+ [
80
+ ({}, "-- NIENTE --"),
81
+ ({"order_by": "nome"}, "utente.nome DESC"),
82
+ ({"order_by": "nome", "asc": True}, "utente.nome ASC"),
83
+ ({"order_by": "nome", "asc": False}, "utente.nome DESC"),
84
+ ({"asc": True}, "-- NIENTE --"),
85
+ ],
86
+ ids=["niente", "solo_order_by", "asc_true", "asc_false", "solo_asc"],
87
+ )
88
+ def test_find_conserva_il_comportamento_di_order_by(repo, kwargs: dict, atteso: str) -> None:
89
+ """``order_by``/``asc`` devono comportarsi esattamente come prima di ``orders_by``.
90
+
91
+ Due caselle di questa tabella hanno già fallito durante l'implementazione:
92
+
93
+ - ``find()`` senza argomenti ha per un momento emesso ``ORDER BY pk DESC``, dove
94
+ prima non emetteva nulla — 16 snapshot SQL l'hanno segnalato;
95
+ - ``find(order_by="nome")`` **senza** ``asc`` ha smesso di ordinare, perché la
96
+ conversione verso ``orders_by`` richiedeva che entrambi fossero valorizzati.
97
+ Quello non l'ha segnalato nessun test, ed è il caso peggiore: ``asc=None``
98
+ significa DESC, quindi l'ordinamento sparisce in silenzio.
99
+ """
100
+ assert clausola(repo.find, **kwargs) == atteso
101
+
102
+
103
+ def test_paging_find_conserva_il_suo_default_ascendente(repo) -> None:
104
+ """``paging_find`` ordina per PK ascendente quando non gli si dice altro.
105
+
106
+ Diverso da ``find``, che senza argomenti non ordina affatto: la paginazione ha
107
+ bisogno di un ordine, una lista no.
108
+ """
109
+ assert clausola(repo.paging_find, limit=5, page=1) == "utente.id ASC"
110
+ assert clausola(repo.paging_find, limit=5, page=1, order_by="nome") == "utente.nome ASC"
111
+
112
+
113
+ # ---------------------------------------------------------------- orders_by
114
+
115
+
116
+ def test_orders_by_genera_le_clausole_nell_ordine_del_dizionario(repo) -> None:
117
+ """L'ordine di inserimento del dizionario è l'ordine delle clausole.
118
+
119
+ È la ragione per cui un ``dict`` è una struttura adeguata: in Python l'ordine di
120
+ inserimento è garantito dal linguaggio, non un dettaglio di implementazione.
121
+ """
122
+ assert clausola(repo.find, orders_by={"ruolo": True, "eta": False, "id": True}) == (
123
+ "utente.ruolo ASC, utente.eta DESC, utente.id ASC"
124
+ )
125
+ assert clausola(repo.find, orders_by={"eta": False, "ruolo": True}) == (
126
+ "utente.eta DESC, utente.ruolo ASC"
127
+ )
128
+
129
+
130
+ def test_orders_by_ha_la_precedenza_su_order_by(repo) -> None:
131
+ """Passando entrambi vince ``orders_by``, come il ``where`` vince su ``**kwhere``."""
132
+ assert clausola(repo.find, order_by="nome", asc=True, orders_by={"eta": False}) == "utente.eta DESC"
133
+
134
+
135
+ def test_orders_by_ordina_davvero_le_righe(repo) -> None:
136
+ """L'ordinamento multiplo sui dati, non solo nell'SQL.
137
+
138
+ ``ruolo`` ascendente mette gli ``admin`` prima degli ``user``; a parità di ruolo
139
+ ``eta`` discendente mette il trentenne prima del decenne.
140
+ """
141
+ assert [u.id for u in repo.find(orders_by={"ruolo": True, "eta": False})] == [2, 1, 3]
142
+ assert [u.id for u in repo.find(orders_by={"ruolo": True, "eta": True})] == [2, 3, 1]
143
+
144
+
145
+ def test_una_colonna_inesistente_viene_rifiutata(repo) -> None:
146
+ """La validazione contro le colonne reali valeva già per ``order_by`` e resta.
147
+
148
+ Nessuna interpolazione testuale: il nome passa da ``Table.c[...]``, quindi una
149
+ stringa arbitraria non può raggiungere l'SQL.
150
+ """
151
+ with pytest.raises(HasNoAttributeException):
152
+ repo.find(orders_by={"colonna_mai_esistita": True})
153
+ with pytest.raises(HasNoAttributeException):
154
+ repo.find(orders_by={"id; drop table utente; --": True})
155
+
156
+
157
+ # ------------------------------------------------- risposta paginata e effetti
158
+
159
+
160
+ def test_la_risposta_riporta_la_prima_chiave_con_il_suo_valore(repo) -> None:
161
+ """``PaginatorResponseModel`` ha un solo ``order_by``: deve essere il principale.
162
+
163
+ Una prima versione usava ``popitem()``, che preleva dalla **coda**: la risposta
164
+ dichiarava l'ordinamento secondario. Peggio, ``next(iter(orders_by))`` itera le
165
+ **chiavi**, quindi ``order_by, asc = next(iter(...))`` spacchettava la stringa —
166
+ con la PK ``id`` restituiva silenziosamente ``order_by='i', asc='d'``, e con
167
+ qualunque nome più lungo sollevava ``ValueError``.
168
+ """
169
+ risposta = repo.paging_find(orders_by={"ruolo": True, "eta": False}, limit=5, page=1)
170
+ assert risposta.order_by == "ruolo"
171
+ assert risposta.asc is True
172
+
173
+
174
+ def test_il_dizionario_del_chiamante_non_viene_mutato(repo) -> None:
175
+ """Regressione: la prima versione consumava una chiave per chiamata.
176
+
177
+ ``orders_by.popitem()`` rimuoveva l'ultima voce dal dizionario **del chiamante**,
178
+ quindi riusare lo stesso dizionario per due pagine dava due ordinamenti diversi.
179
+ """
180
+ ordinamento = {"ruolo": True, "eta": False}
181
+ repo.paging_find(orders_by=ordinamento, limit=5, page=1)
182
+ repo.paging_find(orders_by=ordinamento, limit=5, page=2)
183
+ assert ordinamento == {"ruolo": True, "eta": False}
184
+
185
+
186
+ def test_funziona_su_una_entita_con_pk_key_diverso_da_name(sqlite_engine: Engine) -> None:
187
+ """Regressione: l'ordinamento di default deve usare ``key``, non ``name``.
188
+
189
+ ``Table.c`` è indicizzato per ``key``; usare ``name`` faceva fallire con
190
+ ``HasNoAttributeException`` **qualunque** chiamata a ``find()`` o
191
+ ``paging_find()`` su questa entità, anche senza argomenti.
192
+ """
193
+ UtenteChiaveEsplicita.__table__.create(sqlite_engine, checkfirst=True)
194
+ repository = CrudRepository(entity=UtenteChiaveEsplicita, model=UtenteChiaveEsplicitaModel, engine=sqlite_engine)
195
+
196
+ assert repository.find() == []
197
+ assert repository.paging_find(limit=5, page=1).order_by == "id"
198
+
199
+
200
+ def test_un_dizionario_vuoto_ricade_sul_default(repo) -> None:
201
+ """``orders_by={}`` non deve sollevare.
202
+
203
+ Il metodo pubblico lo tratta come assente; il privato aveva una guardia
204
+ ``is None`` che non lo catturava, e la lettura della prima chiave sollevava prima
205
+ ``KeyError`` e poi ``StopIteration``.
206
+ """
207
+ with repo.open_session as sessione:
208
+ risultato = repo._paging_find(session=sessione, orders_by={}, limit=5, page=1)
209
+ assert risultato.order_by == "id"
210
+
211
+
212
+ # ------------------------------------------------------------- limiti noti
213
+
214
+
215
+ def test_la_risposta_riporta_anche_l_ordinamento_completo(repo) -> None:
216
+ """``PaginatorResponseModel.orders_by`` porta tutte le coppie, con alias italiano.
217
+
218
+ ``order_by``/``asc`` restano per i client esistenti e riportano il criterio
219
+ principale; ``orders_by`` serve a chi deve ricostruire l'ordinamento completo, per
220
+ esempio per rimandarlo indietro alla pagina successiva.
221
+ """
222
+ risposta = repo.paging_find(orders_by={"ruolo": True, "eta": False}, limit=5, page=1)
223
+ assert risposta.orders_by == {"ruolo": True, "eta": False}
224
+
225
+ serializzata = risposta.model_dump(by_alias=True)
226
+ assert serializzata["campiOrdinamento"] == {"ruolo": True, "eta": False}
227
+ assert serializzata["campoOrdinamento"] == "ruolo"
228
+
229
+
230
+ def test_parse_pagination_senza_ordinamento_non_ne_dichiara_uno(repo) -> None:
231
+ """Con un ``orders_by`` vuoto la risposta non deve inventare un ordinamento.
232
+
233
+ ``parse_pagination`` è pubblico: prima ``next(iter({}))`` sollevava
234
+ ``StopIteration``, che dentro un generatore verrebbe interpretata come fine
235
+ dell'iterazione invece di propagarsi. Ora ``order_by``/``asc`` restano ``None``,
236
+ che è la risposta vera — nessun ordinamento è stato applicato.
237
+ """
238
+ risposta = repo.parse_pagination(limit=10, page=1, orders_by={}, count=0, data=[])
239
+ assert risposta.order_by is None
240
+ assert risposta.asc is None
241
+ assert risposta.orders_by == {}
242
+
243
+
244
+ def test_la_paginazione_non_appende_la_pk_come_discriminante(repo) -> None:
245
+ """Caratterizzazione: con un ordinamento esplicito non c'è ordine totale.
246
+
247
+ ``LIMIT/OFFSET`` su una chiave non univoca non garantisce la stabilità fra due
248
+ pagine: il database può restituire le righe a parità di chiave in ordine diverso e
249
+ l'OFFSET taglia in un punto diverso. Misurato su PostgreSQL con 2.000 righe e una
250
+ colonna dal valore costante: su 10 pagine da 20, **9 righe su 200 comparivano due
251
+ volte** e altre nove mai.
252
+
253
+ Il giorno in cui si deciderà di appendere la PK automaticamente, questo test
254
+ **deve** fallire: è il segnale che il comportamento è cambiato di proposito.
255
+ """
256
+ assert clausola(repo.paging_find, orders_by={"ruolo": True}, limit=20, page=2) == "utente.ruolo ASC"
257
+
258
+
259
+ @pytest.mark.pg
260
+ def test_i_null_finiscono_in_posizioni_opposte_sui_due_backend(pg_engine: Engine) -> None:
261
+ """Caratterizzazione: manca ``nulls_last``, e i due database non concordano.
262
+
263
+ Con ``asc=True`` SQLite mette i ``NULL`` **primi** e PostgreSQL **ultimi**; con
264
+ ``asc=False`` è l'inverso. Lo stesso codice ordina in modo opposto sui due backend
265
+ che la libreria supporta. Quando arriverà ``nulls_last`` questo test va aggiornato.
266
+ """
267
+ with pg_engine.begin() as connessione:
268
+ connessione.execute(
269
+ insert(Utente.__table__),
270
+ [
271
+ {"id": 1, "nome": "a", "eta": 30, "attivo": True},
272
+ {"id": 2, "nome": "b", "eta": None, "attivo": True},
273
+ {"id": 3, "nome": "c", "eta": 10, "attivo": True},
274
+ ],
275
+ )
276
+ repository = utente_repository(pg_engine)
277
+ assert [u.id for u in repository.find(orders_by={"eta": True})] == [3, 1, 2], "PostgreSQL: NULL ultimo"
278
+ assert [u.id for u in repository.find(orders_by={"eta": False})] == [2, 1, 3], "PostgreSQL: NULL primo"
279
+
280
+
281
+ def test_i_null_su_sqlite_vanno_dalla_parte_opposta(repo) -> None:
282
+ """L'altra metà del test precedente, su SQLite: ``asc=True`` mette i ``NULL`` primi."""
283
+ with repo.engine.begin() as connessione:
284
+ connessione.execute(insert(Utente.__table__), [{"id": 9, "nome": "z", "eta": None, "attivo": True}])
285
+ assert [u.id for u in repo.find(orders_by={"eta": True})] == [9, 3, 1, 2], "SQLite: NULL primo"