python-general-be-lib 0.12.1__tar.gz → 0.12.2__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 (88) hide show
  1. {python_general_be_lib-0.12.1 → python_general_be_lib-0.12.2}/PKG-INFO +1 -1
  2. {python_general_be_lib-0.12.1 → python_general_be_lib-0.12.2}/general/interface/metadata/crud_metadata.py +16 -8
  3. {python_general_be_lib-0.12.1 → python_general_be_lib-0.12.2}/general/interface/repository/crud_repository.py +32 -8
  4. {python_general_be_lib-0.12.1 → python_general_be_lib-0.12.2}/general/paginator_dto.py +30 -4
  5. {python_general_be_lib-0.12.1 → python_general_be_lib-0.12.2}/pyproject.toml +1 -1
  6. {python_general_be_lib-0.12.1 → python_general_be_lib-0.12.2}/python_general_be_lib.egg-info/PKG-INFO +1 -1
  7. {python_general_be_lib-0.12.1 → python_general_be_lib-0.12.2}/tests/test_metadata_paginazione.py +74 -0
  8. {python_general_be_lib-0.12.1 → python_general_be_lib-0.12.2}/tests/test_ordinamento.py +158 -15
  9. {python_general_be_lib-0.12.1 → python_general_be_lib-0.12.2}/tests/test_sql_snapshot.py +3 -1
  10. {python_general_be_lib-0.12.1 → python_general_be_lib-0.12.2}/LICENSE +0 -0
  11. {python_general_be_lib-0.12.1 → python_general_be_lib-0.12.2}/README.md +0 -0
  12. {python_general_be_lib-0.12.1 → python_general_be_lib-0.12.2}/general/__init__.py +0 -0
  13. {python_general_be_lib-0.12.1 → python_general_be_lib-0.12.2}/general/auth/__init__.py +0 -0
  14. {python_general_be_lib-0.12.1 → python_general_be_lib-0.12.2}/general/auth/jwt_model.py +0 -0
  15. {python_general_be_lib-0.12.1 → python_general_be_lib-0.12.2}/general/auth/key_provider.py +0 -0
  16. {python_general_be_lib-0.12.1 → python_general_be_lib-0.12.2}/general/auth/keycloak_auth.py +0 -0
  17. {python_general_be_lib-0.12.1 → python_general_be_lib-0.12.2}/general/auth/middleware.py +0 -0
  18. {python_general_be_lib-0.12.1 → python_general_be_lib-0.12.2}/general/context.py +0 -0
  19. {python_general_be_lib-0.12.1 → python_general_be_lib-0.12.2}/general/exception/__init__.py +0 -0
  20. {python_general_be_lib-0.12.1 → python_general_be_lib-0.12.2}/general/exception/access_exceptions.py +0 -0
  21. {python_general_be_lib-0.12.1 → python_general_be_lib-0.12.2}/general/exception/crud_exceptions.py +0 -0
  22. {python_general_be_lib-0.12.1 → python_general_be_lib-0.12.2}/general/exception/exception_interface.py +0 -0
  23. {python_general_be_lib-0.12.1 → python_general_be_lib-0.12.2}/general/interface/__init__.py +0 -0
  24. {python_general_be_lib-0.12.1 → python_general_be_lib-0.12.2}/general/interface/base/__init__.py +0 -0
  25. {python_general_be_lib-0.12.1 → python_general_be_lib-0.12.2}/general/interface/base/base_model.py +0 -0
  26. {python_general_be_lib-0.12.1 → python_general_be_lib-0.12.2}/general/interface/base/declarative_base.py +0 -0
  27. {python_general_be_lib-0.12.1 → python_general_be_lib-0.12.2}/general/interface/metadata/__init__.py +0 -0
  28. {python_general_be_lib-0.12.1 → python_general_be_lib-0.12.2}/general/interface/metadata/geom_metadata.py +0 -0
  29. {python_general_be_lib-0.12.1 → python_general_be_lib-0.12.2}/general/interface/mixin/__init__.py +0 -0
  30. {python_general_be_lib-0.12.1 → python_general_be_lib-0.12.2}/general/interface/mixin/audit_mixin.py +0 -0
  31. {python_general_be_lib-0.12.1 → python_general_be_lib-0.12.2}/general/interface/mixin/softdelete_mixin.py +0 -0
  32. {python_general_be_lib-0.12.1 → python_general_be_lib-0.12.2}/general/interface/mixin/timestamp_mixin.py +0 -0
  33. {python_general_be_lib-0.12.1 → python_general_be_lib-0.12.2}/general/interface/repository/__init__.py +0 -0
  34. {python_general_be_lib-0.12.1 → python_general_be_lib-0.12.2}/general/interface/repository/geometry_repository.py +0 -0
  35. {python_general_be_lib-0.12.1 → python_general_be_lib-0.12.2}/general/interface/repository/handler/__init__.py +0 -0
  36. {python_general_be_lib-0.12.1 → python_general_be_lib-0.12.2}/general/interface/repository/handler/base_handler.py +0 -0
  37. {python_general_be_lib-0.12.1 → python_general_be_lib-0.12.2}/general/interface/repository/handler/handler.py +0 -0
  38. {python_general_be_lib-0.12.1 → python_general_be_lib-0.12.2}/general/interface/repository/handler/ilike_handler.py +0 -0
  39. {python_general_be_lib-0.12.1 → python_general_be_lib-0.12.2}/general/interface/repository/handler/interval_handler.py +0 -0
  40. {python_general_be_lib-0.12.1 → python_general_be_lib-0.12.2}/general/interface/repository/handler/json_handler.py +0 -0
  41. {python_general_be_lib-0.12.1 → python_general_be_lib-0.12.2}/general/interface/repository/handler/json_ilike_handler.py +0 -0
  42. {python_general_be_lib-0.12.1 → python_general_be_lib-0.12.2}/general/interface/repository/handler/or_handler.py +0 -0
  43. {python_general_be_lib-0.12.1 → python_general_be_lib-0.12.2}/general/interface/repository/handler/soft_delete_handler.py +0 -0
  44. {python_general_be_lib-0.12.1 → python_general_be_lib-0.12.2}/general/interface/repository/handler/statement_manager.py +0 -0
  45. {python_general_be_lib-0.12.1 → python_general_be_lib-0.12.2}/general/interface/repository/many_to_many_repository.py +0 -0
  46. {python_general_be_lib-0.12.1 → python_general_be_lib-0.12.2}/general/interface/repository/view_repository.py +0 -0
  47. {python_general_be_lib-0.12.1 → python_general_be_lib-0.12.2}/general/logger.py +0 -0
  48. {python_general_be_lib-0.12.1 → python_general_be_lib-0.12.2}/general/py.typed +0 -0
  49. {python_general_be_lib-0.12.1 → python_general_be_lib-0.12.2}/general/utils/__init__.py +0 -0
  50. {python_general_be_lib-0.12.1 → python_general_be_lib-0.12.2}/general/utils/consts.py +0 -0
  51. {python_general_be_lib-0.12.1 → python_general_be_lib-0.12.2}/python_general_be_lib.egg-info/SOURCES.txt +0 -0
  52. {python_general_be_lib-0.12.1 → python_general_be_lib-0.12.2}/python_general_be_lib.egg-info/dependency_links.txt +0 -0
  53. {python_general_be_lib-0.12.1 → python_general_be_lib-0.12.2}/python_general_be_lib.egg-info/requires.txt +0 -0
  54. {python_general_be_lib-0.12.1 → python_general_be_lib-0.12.2}/python_general_be_lib.egg-info/top_level.txt +0 -0
  55. {python_general_be_lib-0.12.1 → python_general_be_lib-0.12.2}/setup.cfg +0 -0
  56. {python_general_be_lib-0.12.1 → python_general_be_lib-0.12.2}/tests/test_auth_chiavi.py +0 -0
  57. {python_general_be_lib-0.12.1 → python_general_be_lib-0.12.2}/tests/test_auth_fastapi.py +0 -0
  58. {python_general_be_lib-0.12.1 → python_general_be_lib-0.12.2}/tests/test_auth_token.py +0 -0
  59. {python_general_be_lib-0.12.1 → python_general_be_lib-0.12.2}/tests/test_caricamento_parziale.py +0 -0
  60. {python_general_be_lib-0.12.1 → python_general_be_lib-0.12.2}/tests/test_compiled_cache.py +0 -0
  61. {python_general_be_lib-0.12.1 → python_general_be_lib-0.12.2}/tests/test_costo_import.py +0 -0
  62. {python_general_be_lib-0.12.1 → python_general_be_lib-0.12.2}/tests/test_engine_defaults.py +0 -0
  63. {python_general_be_lib-0.12.1 → python_general_be_lib-0.12.2}/tests/test_entity_has_columns.py +0 -0
  64. {python_general_be_lib-0.12.1 → python_general_be_lib-0.12.2}/tests/test_execute_contratto.py +0 -0
  65. {python_general_be_lib-0.12.1 → python_general_be_lib-0.12.2}/tests/test_exists.py +0 -0
  66. {python_general_be_lib-0.12.1 → python_general_be_lib-0.12.2}/tests/test_find_one.py +0 -0
  67. {python_general_be_lib-0.12.1 → python_general_be_lib-0.12.2}/tests/test_get.py +0 -0
  68. {python_general_be_lib-0.12.1 → python_general_be_lib-0.12.2}/tests/test_has_columns.py +0 -0
  69. {python_general_be_lib-0.12.1 → python_general_be_lib-0.12.2}/tests/test_import_pubblici.py +0 -0
  70. {python_general_be_lib-0.12.1 → python_general_be_lib-0.12.2}/tests/test_logger.py +0 -0
  71. {python_general_be_lib-0.12.1 → python_general_be_lib-0.12.2}/tests/test_many_to_many.py +0 -0
  72. {python_general_be_lib-0.12.1 → python_general_be_lib-0.12.2}/tests/test_metadata_eccezioni.py +0 -0
  73. {python_general_be_lib-0.12.1 → python_general_be_lib-0.12.2}/tests/test_metadata_geom.py +0 -0
  74. {python_general_be_lib-0.12.1 → python_general_be_lib-0.12.2}/tests/test_metadata_pipeline.py +0 -0
  75. {python_general_be_lib-0.12.1 → python_general_be_lib-0.12.2}/tests/test_metadata_scorciatoie.py +0 -0
  76. {python_general_be_lib-0.12.1 → python_general_be_lib-0.12.2}/tests/test_mixin.py +0 -0
  77. {python_general_be_lib-0.12.1 → python_general_be_lib-0.12.2}/tests/test_negazione.py +0 -0
  78. {python_general_be_lib-0.12.1 → python_general_be_lib-0.12.2}/tests/test_pipeline_handler.py +0 -0
  79. {python_general_be_lib-0.12.1 → python_general_be_lib-0.12.2}/tests/test_query_count.py +0 -0
  80. {python_general_be_lib-0.12.1 → python_general_be_lib-0.12.2}/tests/test_retrieve_mapper.py +0 -0
  81. {python_general_be_lib-0.12.1 → python_general_be_lib-0.12.2}/tests/test_rollback.py +0 -0
  82. {python_general_be_lib-0.12.1 → python_general_be_lib-0.12.2}/tests/test_smoke_crud.py +0 -0
  83. {python_general_be_lib-0.12.1 → python_general_be_lib-0.12.2}/tests/test_softdelete.py +0 -0
  84. {python_general_be_lib-0.12.1 → python_general_be_lib-0.12.2}/tests/test_to_dict.py +0 -0
  85. {python_general_be_lib-0.12.1 → python_general_be_lib-0.12.2}/tests/test_to_pydantic.py +0 -0
  86. {python_general_be_lib-0.12.1 → python_general_be_lib-0.12.2}/tests/test_upsert_chiavi.py +0 -0
  87. {python_general_be_lib-0.12.1 → python_general_be_lib-0.12.2}/tests/test_valori_falsy.py +0 -0
  88. {python_general_be_lib-0.12.1 → python_general_be_lib-0.12.2}/tests/test_view_repository.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-general-be-lib
3
- Version: 0.12.1
3
+ Version: 0.12.2
4
4
  Summary: General purpose backend library — SQLAlchemy CRUD/Geometry repositories, FastAPI exceptions, Pydantic base models, Keycloak JWT auth, 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
@@ -11,7 +11,7 @@ from sqlalchemy.sql.functions import count
11
11
  from ...exception.crud_exceptions import BadRequestException, EntityNotFoundException, HasNoAttributeException, InternalServerException, raise_http_exception
12
12
  from ...interface.repository.handler.base_handler import BaseHandler
13
13
  from ...interface.repository.handler.statement_manager import StatementManager
14
- from ...paginator_dto import PaginatorResponseModel, parse_pagination
14
+ from ...paginator_dto import PaginatorResponseModel, parse_pagination, _with_tiebreaker
15
15
 
16
16
 
17
17
  class CrudMetadata:
@@ -865,14 +865,17 @@ class CrudMetadata:
865
865
  page (int): Requested page number, 1-based. Default: 1.
866
866
  parsing_model (Type[BaseModel], optional): Model each row is serialized with.
867
867
  orders_by (dict[str, bool], optional): Ordering, as ``{column: ascending}``,
868
- with the clauses following the insertion order of the dictionary. When
869
- omitted no ordering is applied, and without a total order two pages can
870
- overlap.
868
+ with the clauses following the insertion order of the dictionary. The
869
+ primary key columns missing from it are appended, ascending, as a
870
+ tie-breaker, so that two pages cannot overlap; a key column already
871
+ passed keeps its position and direction. A table without a primary key,
872
+ such as a reflected view, gets no tie-breaker. When omitted no ordering
873
+ is applied at all, and the pages are not stable.
871
874
  **kwhere: Additional filters as keyword arguments.
872
875
 
873
876
  Returns:
874
877
  PaginatorResponseModel: Object carrying ``data``, ``tot_pages``, ``page``,
875
- ``limit`` and the ordering.
878
+ ``limit`` and the ordering requested, without the tie-breaker.
876
879
 
877
880
  Raises:
878
881
  HasNoAttributeException: If the table or one of the columns does not exist.
@@ -891,6 +894,9 @@ class CrudMetadata:
891
894
  orders_by: dict[str, bool] = None, **kwhere) -> PaginatorResponseModel:
892
895
  """Read the page and derive the total, running the ``COUNT`` only when needed.
893
896
 
897
+ A non-empty ordering gets the missing primary key columns appended, ascending, so
898
+ that it is total; the response reports it as requested, without them.
899
+
894
900
  Args:
895
901
  table (Table): Table to search on.
896
902
  connection (Connection): Connection to run the statements on.
@@ -898,7 +904,8 @@ class CrudMetadata:
898
904
  columns (list[str], optional): Columns to select.
899
905
  limit (int): Rows per page. 0 means no limit.
900
906
  page (int): Page number, 1-based.
901
- orders_by (dict[str, bool], optional): Ordering to apply.
907
+ orders_by (dict[str, bool], optional): Ordering requested. When empty,
908
+ nothing is ordered.
902
909
  **kwhere: Additional filters as keyword arguments.
903
910
 
904
911
  Returns:
@@ -911,8 +918,9 @@ class CrudMetadata:
911
918
 
912
919
  manager = self._statement_manager(table)
913
920
  stmt = self._select(table=table, columns=columns)
914
- stmt = self._add_orders_by_condition(stmt, table, orders_by)
915
- stmt = manager.limit_offset_condition(stmt=stmt, limit=limit,page=page)
921
+ if orders_by:
922
+ stmt = self._add_orders_by_condition(stmt, table, _with_tiebreaker(orders_by, table.primary_key.columns.keys()))
923
+ stmt = manager.limit_offset_condition(stmt=stmt, limit=limit, page=page)
916
924
  conditions = manager.conditions(where=dict(where))
917
925
  if conditions:
918
926
  stmt = stmt.where(*conditions)
@@ -16,7 +16,7 @@ from .handler.base_handler import BaseHandler
16
16
  from .handler.statement_manager import StatementManager
17
17
  from ...exception.crud_exceptions import BadRequestException, EntityNotFoundException, HasNoAttributeException, raise_http_exception
18
18
  from ...interface.base.declarative_base import Base
19
- from ...paginator_dto import PaginatorResponseModel, parse_pagination
19
+ from ...paginator_dto import PaginatorResponseModel, parse_pagination, _with_tiebreaker
20
20
 
21
21
 
22
22
  @lru_cache(maxsize=None)
@@ -885,22 +885,25 @@ class CrudRepository[Entity: Base | Type[Base]]:
885
885
  therefore a single page.
886
886
  page (int): Requested page number, 1-based. Default: 1.
887
887
  order_by (str, optional): Column to order by. When ``None``, the first column of
888
- the primary key is used.
888
+ the primary key is used, followed by the other ones if the key is
889
+ composite.
889
890
  asc (bool): Ascending order if ``True``. Default: ``True``.
890
891
  parsing_model (Type[BaseModel], optional): Alternative model for the
891
892
  serialization.
892
893
  orders_by (dict[str, bool], optional): Ordering on several columns, as
893
894
  ``{column: ascending}``, with the clauses following the insertion order of
894
895
  the dictionary. Takes precedence over ``order_by``/``asc``. The primary key
895
- is **not** appended as a tie-breaker, so an ordering on a non-unique column
896
- does not give a total order and two pages can overlap: pass the key
897
- explicitly as the last entry when the ordering must be stable.
896
+ columns missing from the ordering are appended, ascending, as a
897
+ tie-breaker: an ordering on a non-unique column is not total, and with
898
+ ``LIMIT/OFFSET`` two pages could overlap and some rows never show up. A
899
+ key column the caller already passed keeps its position and direction.
898
900
  **kwhere: Filters as keyword arguments.
899
901
 
900
902
  Returns:
901
903
  PaginatorResponseModel[Entity]: Object carrying ``data``, ``tot_pages``,
902
904
  ``page``, ``limit``, ``orders_by``, plus ``order_by`` and ``asc`` holding
903
- the first pair of the ordering.
905
+ the first pair of the ordering. ``orders_by`` is the ordering requested,
906
+ without the appended tie-breaker.
904
907
  """
905
908
  keep_open = False if session is None else True
906
909
  session = self.open_session if session is None else session
@@ -913,15 +916,36 @@ class CrudRepository[Entity: Base | Type[Base]]:
913
916
  return paginated
914
917
 
915
918
  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):
919
+ """Read the page with a total ordering and derive the total, counting only when needed.
920
+
921
+ The primary key columns missing from ``orders_by`` are appended to the ``ORDER BY``,
922
+ ascending, so that ``LIMIT/OFFSET`` cuts every page from the same sequence of rows.
923
+ The response reports ``orders_by`` as requested, without them.
924
+
925
+ Args:
926
+ session (Session): Session to run the statements on.
927
+ where (dict[str, Any], optional): Filters. When ``None``, ``**kwhere`` is used.
928
+ columns (list[str], optional): Columns to include.
929
+ exclude_columns (list[str], optional): Columns to exclude.
930
+ limit (int): Rows per page. 0 means no limit.
931
+ page (int): Page number, 1-based.
932
+ orders_by (dict[str, bool], optional): Ordering requested, as
933
+ ``{column: ascending}``. When empty, the primary key ascending.
934
+ **kwhere: Filters as keyword arguments.
935
+
936
+ Returns:
937
+ PaginatorResponseModel: Page and total, with the raw entities as ``data``.
938
+ """
916
939
  if where is None:
917
940
  where = kwhere if kwhere else {}
941
+ primary_key = [column.key for column in self.entity.primary_key()]
918
942
  if not orders_by:
919
- orders_by = {self.entity.primary_key()[0].key: True}
943
+ orders_by = {primary_key[0]: True}
920
944
 
921
945
  stmt = self._select(entity=self.entity, columns=columns, exclude_columns=exclude_columns)
922
946
  stmt = self.stmt_manager.limit_offset_condition(stmt=stmt, limit=limit, page=page)
923
947
 
924
- for order_by, asc in orders_by.items():
948
+ for order_by, asc in _with_tiebreaker(orders_by, primary_key).items():
925
949
  stmt = self.stmt_manager.order_by_condition(stmt=stmt, order_by=order_by, asc=asc)
926
950
 
927
951
  conditions = self.stmt_manager.conditions(where=where)
@@ -1,7 +1,7 @@
1
1
  __all__ = ["PaginatorRequestModel", "PaginatorResponseModel", "parse_pagination"]
2
2
 
3
3
  import math
4
- from typing import Any, Optional, Sequence
4
+ from typing import Any, Iterable, Optional, Sequence
5
5
 
6
6
  from pydantic import Field
7
7
 
@@ -46,9 +46,11 @@ class PaginatorResponseModel[ResponseType](PaginatorBaseModel):
46
46
  Attributes:
47
47
  tot_pages (int): Total number of pages, computed by the repository.
48
48
  Alias: ``pagineTotali``.
49
- orders_by (dict[str, bool] | None): Whole ordering applied, as
49
+ orders_by (dict[str, bool] | None): Whole ordering requested, as
50
50
  ``{column: ascending}``. The inherited ``order_by``/``asc`` carry its first
51
- pair, which is the main criterion. Alias: ``campiOrdinamento``.
51
+ pair, which is the main criterion. The primary key columns that
52
+ ``paging_find`` appends as a tie-breaker are not listed: they are an
53
+ internal detail of the query. Alias: ``campiOrdinamento``.
52
54
  data (list[ResponseType]): Items of the current page.
53
55
  """
54
56
 
@@ -70,7 +72,7 @@ def parse_pagination(limit: int, page: int, orders_by: dict[str, bool], count: i
70
72
  Args:
71
73
  limit (int): Number of items per page.
72
74
  page (int): Current page.
73
- orders_by (dict[str, bool]): Ordering applied, as ``{column: ascending}``.
75
+ orders_by (dict[str, bool]): Ordering requested, as ``{column: ascending}``.
74
76
  count (int): Total number of filtered records.
75
77
  data (Sequence): Rows of the current page.
76
78
 
@@ -83,3 +85,27 @@ def parse_pagination(limit: int, page: int, orders_by: dict[str, bool], count: i
83
85
  tot_pages = math.ceil(count / limit) if count and limit > 0 else 1
84
86
 
85
87
  return PaginatorResponseModel(limit=limit, page=page, order_by=order_by, asc=asc, tot_pages=tot_pages, orders_by=orders_by, data=data)
88
+
89
+
90
+ def _with_tiebreaker(orders_by: dict[str, bool], keys: Iterable[str]) -> dict[str, bool]:
91
+ """Append to an ordering the key columns it does not contain yet, ascending.
92
+
93
+ ``LIMIT/OFFSET`` over an ordering that is not total is unstable: the database may
94
+ return the rows tied on the ordering columns in a different order from one query to
95
+ the next, so consecutive pages overlap and some rows are never shown. Appending the
96
+ primary key makes the ordering total. A key column the caller already ordered by is
97
+ left where it is and with its direction, since the ordering is total from that point
98
+ on anyway. Shared by the ORM and the metadata layer.
99
+
100
+ Args:
101
+ orders_by (dict[str, bool]): Ordering as ``{column: ascending}``. Never mutated.
102
+ keys (Iterable[str]): Names of the primary key columns, as the statement
103
+ manager resolves them. Empty when the table has no primary key, in which case
104
+ the ordering is returned as it is.
105
+
106
+ Returns:
107
+ dict[str, bool]: ``orders_by`` itself when nothing is missing, otherwise a new
108
+ dictionary with the missing key columns appended as ``True``.
109
+ """
110
+ missing = [key for key in keys if key not in orders_by]
111
+ return {**orders_by, **dict.fromkeys(missing, True)} if missing else orders_by
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-general-be-lib"
7
- version = "0.12.1"
7
+ version = "0.12.2"
8
8
  description = "General purpose backend library — SQLAlchemy CRUD/Geometry repositories, FastAPI exceptions, Pydantic base models, Keycloak JWT auth, logger utilities."
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-general-be-lib
3
- Version: 0.12.1
3
+ Version: 0.12.2
4
4
  Summary: General purpose backend library — SQLAlchemy CRUD/Geometry repositories, FastAPI exceptions, Pydantic base models, Keycloak JWT auth, 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
@@ -24,6 +24,7 @@ from general.interface.base.base_model import ExtBaseModel
24
24
  from general.interface.metadata.crud_metadata import CrudMetadata
25
25
  from general.interface.repository.handler import ILikeHandler
26
26
  from support import assert_query_count
27
+ from support.queries import count_queries
27
28
 
28
29
  RIGHE = 25
29
30
 
@@ -200,3 +201,76 @@ def test_i_filtri_del_chiamante_non_vengono_consumati(meta: CrudMetadata) -> Non
200
201
  assert filtri == atteso
201
202
  assert prima.tot_pages == seconda.tot_pages
202
203
  assert [r["id"] for r in prima.data] == [r["id"] for r in seconda.data]
204
+
205
+
206
+ # --------------------------------------------- ordine totale (0.12.2, PERF-13)
207
+
208
+
209
+ def ordine_della_pagina(meta: CrudMetadata, engine: Engine, **kwargs) -> str:
210
+ """``ORDER BY`` della query dei dati emessa da ``paging_find``, o ``-- NIENTE --``."""
211
+ meta.get_table(kwargs.get("from_table", "riga")) # riflessione fuori dalla misura
212
+ with count_queries(engine) as log:
213
+ meta.paging_find(**{"from_table": "riga", **kwargs})
214
+ sql = log.sql[0]
215
+ if "ORDER BY" not in sql:
216
+ return "-- NIENTE --"
217
+ return sql.split("ORDER BY")[1].split("LIMIT")[0].strip()
218
+
219
+
220
+ @pytest.mark.parametrize(
221
+ "orders_by, atteso",
222
+ [
223
+ ({"ruolo": True}, "riga.ruolo ASC, riga.id ASC"),
224
+ ({"ruolo": False, "id": False}, "riga.ruolo DESC, riga.id DESC"),
225
+ ({"id": False, "ruolo": True}, "riga.id DESC, riga.ruolo ASC"),
226
+ ],
227
+ ids=["appesa", "pk_in_coda_desc", "pk_in_testa_desc"],
228
+ )
229
+ def test_paging_find_appende_la_pk_come_discriminante(meta: CrudMetadata, sqlite_engine: Engine, orders_by: dict, atteso: str) -> None:
230
+ """Stessa regola del layer ORM: PK in coda e ascendente, mai duplicata."""
231
+ assert ordine_della_pagina(meta, sqlite_engine, orders_by=orders_by, limit=5, page=2) == atteso
232
+
233
+
234
+ def test_senza_orders_by_la_paginazione_non_ordina(meta: CrudMetadata, sqlite_engine: Engine) -> None:
235
+ """Il default del layer resta: nessun ordinamento chiesto, nessun ``ORDER BY`` e nessun criterio dichiarato.
236
+
237
+ Diverso dal layer ORM, che senza ordinamento pagina per PK: qui il default non
238
+ promette alcun ordine, e cambiarlo cambierebbe ``campoOrdinamento`` nella risposta.
239
+ """
240
+ assert ordine_della_pagina(meta, sqlite_engine, limit=5, page=2) == "-- NIENTE --"
241
+ assert meta.paging_find(from_table="riga", limit=5, page=1).order_by is None
242
+
243
+
244
+ def test_la_risposta_non_dichiara_la_discriminante(meta: CrudMetadata) -> None:
245
+ risposta = meta.paging_find(from_table="riga", limit=5, page=1, orders_by={"ruolo": False})
246
+ assert risposta.orders_by == {"ruolo": False}
247
+ assert (risposta.order_by, risposta.asc) == ("ruolo", False)
248
+
249
+
250
+ def test_le_pagine_su_una_colonna_ripetuta_coprono_tutte_le_righe(meta: CrudMetadata) -> None:
251
+ """``ruolo`` ha due valori su 25 righe: a pari merito decide l'id."""
252
+ viste = []
253
+ for page in range(1, 6):
254
+ viste.extend(riga["id"] for riga in meta.paging_find(from_table="riga", limit=5, page=page, orders_by={"ruolo": True}).data)
255
+
256
+ admin = [i for i in range(1, RIGHE + 1) if i % 5 == 0]
257
+ assert viste == admin + [i for i in range(1, RIGHE + 1) if i not in admin]
258
+
259
+
260
+ def test_una_vista_senza_pk_resta_con_l_ordinamento_chiesto(meta: CrudMetadata, sqlite_engine: Engine) -> None:
261
+ """Una vista riflessa non ha PK: niente da appendere, e nessun errore.
262
+
263
+ L'ordinamento resta quello chiesto, e con lui l'instabilità delle pagine a pari
264
+ merito: per una vista l'ordine totale lo deve dare il chiamante.
265
+ """
266
+ with sqlite_engine.begin() as connessione:
267
+ connessione.execute(text("drop view if exists vista_riga"))
268
+ connessione.execute(text("create view vista_riga as select id, ruolo from riga"))
269
+ try:
270
+ assert meta.get_table("vista_riga").primary_key.columns.keys() == []
271
+ assert ordine_della_pagina(meta, sqlite_engine, from_table="vista_riga", orders_by={"ruolo": True}, limit=5, page=1) == (
272
+ "vista_riga.ruolo ASC"
273
+ )
274
+ finally:
275
+ with sqlite_engine.begin() as connessione:
276
+ connessione.execute(text("drop view if exists vista_riga"))
@@ -10,8 +10,10 @@ I test coprono tre cose in proporzioni diverse:
10
10
  1. la **retro-compatibilità** di ``order_by``/``asc``, che è dove un difetto passa
11
11
  inosservato: chi passa solo ``order_by`` non guarda l'SQL generato;
12
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.
13
+ 3. l'**ordine totale** di ``paging_find`` (0.12.2, PERF-13): la PK viene appesa in
14
+ coda come discriminante, così ``LIMIT/OFFSET`` non ripete né perde righe;
15
+ 4. un **limite noto** che resta aperto (i ``NULL``), fissato come caratterizzazione
16
+ perché correggerlo cambia i risultati e non deve avvenire per caso.
15
17
  """
16
18
 
17
19
  from __future__ import annotations
@@ -19,13 +21,13 @@ from __future__ import annotations
19
21
  from typing import Optional
20
22
 
21
23
  import pytest
22
- from sqlalchemy import Column, Engine, Integer, String, insert
24
+ from sqlalchemy import Column, Engine, Integer, String, insert, select, text
23
25
 
24
26
  from general.exception.crud_exceptions import HasNoAttributeException
25
27
  from general.interface.base.base_model import ExtBaseModel
26
28
  from general.interface.base.declarative_base import Base
27
29
  from general.interface.repository.crud_repository import CrudRepository
28
- from models import Utente, utente_repository
30
+ from models import Assegnazione, AssegnazioneModel, Utente, UtenteModel, utente_repository
29
31
  from support.sql import captured_sql
30
32
 
31
33
 
@@ -48,6 +50,27 @@ class UtenteChiaveEsplicitaModel(ExtBaseModel):
48
50
  nome: Optional[str] = None
49
51
 
50
52
 
53
+ class VistaRuoli(Base):
54
+ """Entità mappata su una **vista**: la PK esiste solo nella dichiarazione ORM.
55
+
56
+ Il database non sa che ``id`` è una chiave; la libreria la legge da
57
+ ``__table__.primary_key`` come per qualunque tabella, ed è il caso delle entità su
58
+ vista dei backend che la usano.
59
+ """
60
+
61
+ __tablename__ = "vista_ruoli"
62
+
63
+ id = Column(Integer, primary_key=True)
64
+ nome = Column(String(50))
65
+ ruolo = Column(String(20))
66
+
67
+
68
+ class VistaRuoliModel(ExtBaseModel):
69
+ id: Optional[int] = None
70
+ nome: Optional[str] = None
71
+ ruolo: Optional[str] = None
72
+
73
+
51
74
  @pytest.fixture
52
75
  def repo(sqlite_engine: Engine) -> CrudRepository:
53
76
  """Repository su ``Utente`` con qualche riga, per gli ordinamenti sui dati."""
@@ -107,7 +130,7 @@ def test_paging_find_conserva_il_suo_default_ascendente(repo) -> None:
107
130
  bisogno di un ordine, una lista no.
108
131
  """
109
132
  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"
133
+ assert clausola(repo.paging_find, limit=5, page=1, order_by="nome") == "utente.nome ASC, utente.id ASC"
111
134
 
112
135
 
113
136
  # ---------------------------------------------------------------- orders_by
@@ -241,19 +264,139 @@ def test_parse_pagination_senza_ordinamento_non_ne_dichiara_uno(repo) -> None:
241
264
  assert risposta.orders_by == {}
242
265
 
243
266
 
244
- def test_la_paginazione_non_appende_la_pk_come_discriminante(repo) -> None:
245
- """Caratterizzazione: con un ordinamento esplicito non c'è ordine totale.
267
+ def test_la_risposta_non_dichiara_la_discriminante(repo) -> None:
268
+ """La PK appesa è un dettaglio della query, non dell'ordinamento chiesto.
269
+
270
+ I client leggono ``campoOrdinamento``/``crescente`` e rimandano
271
+ ``campiOrdinamento`` alla pagina successiva: devono ritrovare ciò che hanno
272
+ chiesto, come prima della 0.12.2.
273
+ """
274
+ risposta = repo.paging_find(orders_by={"ruolo": False}, limit=2, page=1)
275
+ assert risposta.orders_by == {"ruolo": False}
276
+ assert (risposta.order_by, risposta.asc) == ("ruolo", False)
277
+
278
+
279
+ # ------------------------------------------- ordine totale nella paginazione
280
+
281
+
282
+ @pytest.mark.parametrize(
283
+ "orders_by, atteso",
284
+ [
285
+ ({"ruolo": True}, "utente.ruolo ASC, utente.id ASC"),
286
+ ({"ruolo": False, "eta": False}, "utente.ruolo DESC, utente.eta DESC, utente.id ASC"),
287
+ ({"id": False}, "utente.id DESC"),
288
+ ({"ruolo": True, "id": False}, "utente.ruolo ASC, utente.id DESC"),
289
+ ({"id": False, "ruolo": True}, "utente.id DESC, utente.ruolo ASC"),
290
+ ],
291
+ ids=["appesa", "appesa_dopo_due", "pk_sola_desc", "pk_in_coda_desc", "pk_in_testa_desc"],
292
+ )
293
+ def test_la_paginazione_appende_la_pk_come_discriminante(repo, orders_by: dict, atteso: str) -> None:
294
+ """La PK va in coda, ascendente, solo se il chiamante non l'ha già messa.
295
+
296
+ Una colonna della PK già presente resta dov'è e con la sua direzione: da lì in
297
+ poi l'ordinamento è già totale, e duplicarla cambierebbe solo l'SQL.
298
+ """
299
+ assert clausola(repo.paging_find, orders_by=orders_by, limit=20, page=2) == atteso
300
+
301
+
302
+ def test_find_non_appende_la_pk(repo) -> None:
303
+ """``find`` con ``limit`` resta com'era: senza ``page`` non ci sono pagine da tenere coerenti."""
304
+ assert clausola(repo.find, orders_by={"ruolo": True}, limit=20) == "utente.ruolo ASC"
305
+
306
+
307
+ def test_pk_composta_appende_tutte_le_colonne(sqlite_engine: Engine) -> None:
308
+ """Con una PK composta servono tutte le colonne, e nel default anche le altre."""
309
+ repository = CrudRepository(entity=Assegnazione, model=AssegnazioneModel, engine=sqlite_engine)
310
+
311
+ assert clausola(repository.paging_find, limit=5, page=1) == (
312
+ "assegnazione.id_utente ASC, assegnazione.id_ruolo ASC"
313
+ )
314
+ assert clausola(repository.paging_find, orders_by={"note": False}, limit=5, page=1) == (
315
+ "assegnazione.note DESC, assegnazione.id_utente ASC, assegnazione.id_ruolo ASC"
316
+ )
317
+ assert clausola(repository.paging_find, orders_by={"id_ruolo": False}, limit=5, page=1) == (
318
+ "assegnazione.id_ruolo DESC, assegnazione.id_utente ASC"
319
+ )
320
+
321
+
322
+ def test_pk_composta_le_pagine_coprono_tutte_le_righe(sqlite_engine: Engine) -> None:
323
+ """Sui dati: ``note`` ha due soli valori, le coppie della PK decidono il resto."""
324
+ with sqlite_engine.begin() as connessione:
325
+ connessione.execute(
326
+ insert(Assegnazione.__table__),
327
+ [{"id_utente": u, "id_ruolo": r, "note": "x" if (u + r) % 2 else "y"} for u in range(1, 6) for r in range(1, 5)],
328
+ )
329
+ repository = CrudRepository(entity=Assegnazione, model=AssegnazioneModel, engine=sqlite_engine)
330
+
331
+ viste = []
332
+ for page in range(1, 5):
333
+ viste.extend((a.id_utente, a.id_ruolo, a.note) for a in repository.paging_find(orders_by={"note": True}, limit=6, page=page).data)
334
+
335
+ attese = sorted(((u, r, "x" if (u + r) % 2 else "y") for u in range(1, 6) for r in range(1, 5)), key=lambda t: (t[2], t[0], t[1]))
336
+ assert viste == attese
337
+
338
+
339
+ def test_entita_con_pk_key_diverso_da_name_riceve_la_discriminante(sqlite_engine: Engine) -> None:
340
+ """La PK si appende per ``key``, il nome con cui ``Table.c`` la risolve, non per ``name``."""
341
+ UtenteChiaveEsplicita.__table__.create(sqlite_engine, checkfirst=True)
342
+ repository = CrudRepository(entity=UtenteChiaveEsplicita, model=UtenteChiaveEsplicitaModel, engine=sqlite_engine)
343
+
344
+ assert clausola(repository.paging_find, orders_by={"nome": True}, limit=5, page=1) == (
345
+ 'utente_chiave_esplicita.nome ASC, utente_chiave_esplicita."ID_LEGACY" ASC'
346
+ )
347
+ assert repository.paging_find(orders_by={"nome": True}, limit=5, page=1).orders_by == {"nome": True}
348
+
349
+
350
+ @pytest.fixture
351
+ def vista_ruoli(sqlite_engine: Engine):
352
+ """Vista SQLite su ``utente`` con 30 righe e due soli ruoli; viene rimossa alla fine."""
353
+ with sqlite_engine.begin() as connessione:
354
+ connessione.execute(text("drop view if exists vista_ruoli"))
355
+ connessione.execute(text("create view vista_ruoli as select id, nome, ruolo from utente"))
356
+ connessione.execute(
357
+ insert(Utente.__table__),
358
+ [{"id": i, "nome": f"n{i}", "ruolo": "admin" if i % 3 == 0 else "user", "attivo": True} for i in range(1, 31)],
359
+ )
360
+ yield CrudRepository(entity=VistaRuoli, model=VistaRuoliModel, engine=sqlite_engine)
361
+ with sqlite_engine.begin() as connessione:
362
+ connessione.execute(text("drop view if exists vista_ruoli"))
363
+
364
+
365
+ def test_entita_su_vista_riceve_la_discriminante(vista_ruoli: CrudRepository) -> None:
366
+ """La PK dichiarata nell'ORM basta: il database non deve sapere che è una chiave."""
367
+ assert clausola(vista_ruoli.paging_find, orders_by={"ruolo": True}, limit=7, page=2) == (
368
+ "vista_ruoli.ruolo ASC, vista_ruoli.id ASC"
369
+ )
370
+
371
+ viste = []
372
+ for page in range(1, 6):
373
+ viste.extend(v.id for v in vista_ruoli.paging_find(orders_by={"ruolo": True}, limit=7, page=page).data)
374
+
375
+ admin = [i for i in range(1, 31) if i % 3 == 0]
376
+ assert viste == admin + [i for i in range(1, 31) if i not in admin]
377
+
246
378
 
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.
379
+ def test_le_pagine_su_una_colonna_ripetuta_non_ripetono_ne_perdono_righe(engine: Engine) -> None:
380
+ """Il difetto di PERF-13, misurato sui dati: unione delle pagine = tutte le righe, una volta.
252
381
 
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.
382
+ 2.000 righe con lo stesso ``ruolo``, sfogliate a pagine da 20. Senza discriminante
383
+ PostgreSQL restituisce le righe a pari merito in un ordine che cambia con
384
+ l'``OFFSET`` (il top-N heapsort non è stabile). Con la 0.12.1 questo test falliva
385
+ su PostgreSQL: 48 righe ripetute, e altrettante mai mostrate. SQLite non
386
+ riproduce il difetto, e lì il test passa comunque.
255
387
  """
256
- assert clausola(repo.paging_find, orders_by={"ruolo": True}, limit=20, page=2) == "utente.ruolo ASC"
388
+ with engine.begin() as connessione:
389
+ connessione.execute(insert(Utente.__table__), [{"nome": f"n{i}", "ruolo": "user", "attivo": True} for i in range(2000)])
390
+ tutti = connessione.execute(select(Utente.__table__.c.id)).scalars().all()
391
+ repository = CrudRepository(entity=Utente, model=UtenteModel, engine=engine)
392
+
393
+ viste = []
394
+ with repository.open_session as sessione:
395
+ for page in range(1, 101):
396
+ viste.extend(u.id for u in repository.paging_find(session=sessione, orders_by={"ruolo": False}, limit=20, page=page).data)
397
+
398
+ assert len(viste) == len(set(viste)), f"{len(viste) - len(set(viste))} righe ripetute"
399
+ assert sorted(viste) == sorted(tutti)
257
400
 
258
401
 
259
402
  @pytest.mark.pg
@@ -237,10 +237,12 @@ def test_paging_find_emette_dati_poi_count(ut) -> None:
237
237
  L'ordine è invertito rispetto a prima dell'intervento 1.4: serve avere le righe
238
238
  per sapere se il totale è ricavabile dalla pagina. Qui la pagina è vuota oltre la
239
239
  prima (la sessione finta non restituisce righe), quindi la COUNT viene eseguita.
240
+ Dalla 0.12.2 l'``ORDER BY`` finisce con la PK (discriminante, PERF-13); la COUNT
241
+ non ha ordinamento e resta identica.
240
242
  """
241
243
  data_sql, count_sql = captured_sql(ut.paging_find, where={"attivo": True}, limit=20, page=3, order_by="nome")
242
244
  assert data_sql == (
243
- f"{SELECT_UT} WHERE utente.attivo = true ORDER BY utente.nome ASC "
245
+ f"{SELECT_UT} WHERE utente.attivo = true ORDER BY utente.nome ASC, utente.id ASC "
244
246
  f"LIMIT %(param_1)s OFFSET %(param_2)s"
245
247
  )
246
248
  assert count_sql == "SELECT count(*) AS count_1 FROM utente WHERE utente.attivo = true"