python-general-be-lib 0.12.0__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.
- {python_general_be_lib-0.12.0 → python_general_be_lib-0.12.2}/PKG-INFO +1 -1
- {python_general_be_lib-0.12.0 → python_general_be_lib-0.12.2}/general/interface/metadata/crud_metadata.py +16 -8
- {python_general_be_lib-0.12.0 → python_general_be_lib-0.12.2}/general/interface/repository/crud_repository.py +60 -20
- {python_general_be_lib-0.12.0 → python_general_be_lib-0.12.2}/general/paginator_dto.py +30 -4
- {python_general_be_lib-0.12.0 → python_general_be_lib-0.12.2}/pyproject.toml +1 -1
- {python_general_be_lib-0.12.0 → python_general_be_lib-0.12.2}/python_general_be_lib.egg-info/PKG-INFO +1 -1
- {python_general_be_lib-0.12.0 → python_general_be_lib-0.12.2}/python_general_be_lib.egg-info/SOURCES.txt +1 -0
- {python_general_be_lib-0.12.0 → python_general_be_lib-0.12.2}/tests/test_metadata_paginazione.py +74 -0
- {python_general_be_lib-0.12.0 → python_general_be_lib-0.12.2}/tests/test_ordinamento.py +158 -15
- python_general_be_lib-0.12.2/tests/test_rollback.py +85 -0
- {python_general_be_lib-0.12.0 → python_general_be_lib-0.12.2}/tests/test_sql_snapshot.py +3 -1
- {python_general_be_lib-0.12.0 → python_general_be_lib-0.12.2}/LICENSE +0 -0
- {python_general_be_lib-0.12.0 → python_general_be_lib-0.12.2}/README.md +0 -0
- {python_general_be_lib-0.12.0 → python_general_be_lib-0.12.2}/general/__init__.py +0 -0
- {python_general_be_lib-0.12.0 → python_general_be_lib-0.12.2}/general/auth/__init__.py +0 -0
- {python_general_be_lib-0.12.0 → python_general_be_lib-0.12.2}/general/auth/jwt_model.py +0 -0
- {python_general_be_lib-0.12.0 → python_general_be_lib-0.12.2}/general/auth/key_provider.py +0 -0
- {python_general_be_lib-0.12.0 → python_general_be_lib-0.12.2}/general/auth/keycloak_auth.py +0 -0
- {python_general_be_lib-0.12.0 → python_general_be_lib-0.12.2}/general/auth/middleware.py +0 -0
- {python_general_be_lib-0.12.0 → python_general_be_lib-0.12.2}/general/context.py +0 -0
- {python_general_be_lib-0.12.0 → python_general_be_lib-0.12.2}/general/exception/__init__.py +0 -0
- {python_general_be_lib-0.12.0 → python_general_be_lib-0.12.2}/general/exception/access_exceptions.py +0 -0
- {python_general_be_lib-0.12.0 → python_general_be_lib-0.12.2}/general/exception/crud_exceptions.py +0 -0
- {python_general_be_lib-0.12.0 → python_general_be_lib-0.12.2}/general/exception/exception_interface.py +0 -0
- {python_general_be_lib-0.12.0 → python_general_be_lib-0.12.2}/general/interface/__init__.py +0 -0
- {python_general_be_lib-0.12.0 → python_general_be_lib-0.12.2}/general/interface/base/__init__.py +0 -0
- {python_general_be_lib-0.12.0 → python_general_be_lib-0.12.2}/general/interface/base/base_model.py +0 -0
- {python_general_be_lib-0.12.0 → python_general_be_lib-0.12.2}/general/interface/base/declarative_base.py +0 -0
- {python_general_be_lib-0.12.0 → python_general_be_lib-0.12.2}/general/interface/metadata/__init__.py +0 -0
- {python_general_be_lib-0.12.0 → python_general_be_lib-0.12.2}/general/interface/metadata/geom_metadata.py +0 -0
- {python_general_be_lib-0.12.0 → python_general_be_lib-0.12.2}/general/interface/mixin/__init__.py +0 -0
- {python_general_be_lib-0.12.0 → python_general_be_lib-0.12.2}/general/interface/mixin/audit_mixin.py +0 -0
- {python_general_be_lib-0.12.0 → python_general_be_lib-0.12.2}/general/interface/mixin/softdelete_mixin.py +0 -0
- {python_general_be_lib-0.12.0 → python_general_be_lib-0.12.2}/general/interface/mixin/timestamp_mixin.py +0 -0
- {python_general_be_lib-0.12.0 → python_general_be_lib-0.12.2}/general/interface/repository/__init__.py +0 -0
- {python_general_be_lib-0.12.0 → python_general_be_lib-0.12.2}/general/interface/repository/geometry_repository.py +0 -0
- {python_general_be_lib-0.12.0 → python_general_be_lib-0.12.2}/general/interface/repository/handler/__init__.py +0 -0
- {python_general_be_lib-0.12.0 → python_general_be_lib-0.12.2}/general/interface/repository/handler/base_handler.py +0 -0
- {python_general_be_lib-0.12.0 → python_general_be_lib-0.12.2}/general/interface/repository/handler/handler.py +0 -0
- {python_general_be_lib-0.12.0 → python_general_be_lib-0.12.2}/general/interface/repository/handler/ilike_handler.py +0 -0
- {python_general_be_lib-0.12.0 → python_general_be_lib-0.12.2}/general/interface/repository/handler/interval_handler.py +0 -0
- {python_general_be_lib-0.12.0 → python_general_be_lib-0.12.2}/general/interface/repository/handler/json_handler.py +0 -0
- {python_general_be_lib-0.12.0 → python_general_be_lib-0.12.2}/general/interface/repository/handler/json_ilike_handler.py +0 -0
- {python_general_be_lib-0.12.0 → python_general_be_lib-0.12.2}/general/interface/repository/handler/or_handler.py +0 -0
- {python_general_be_lib-0.12.0 → python_general_be_lib-0.12.2}/general/interface/repository/handler/soft_delete_handler.py +0 -0
- {python_general_be_lib-0.12.0 → python_general_be_lib-0.12.2}/general/interface/repository/handler/statement_manager.py +0 -0
- {python_general_be_lib-0.12.0 → python_general_be_lib-0.12.2}/general/interface/repository/many_to_many_repository.py +0 -0
- {python_general_be_lib-0.12.0 → python_general_be_lib-0.12.2}/general/interface/repository/view_repository.py +0 -0
- {python_general_be_lib-0.12.0 → python_general_be_lib-0.12.2}/general/logger.py +0 -0
- {python_general_be_lib-0.12.0 → python_general_be_lib-0.12.2}/general/py.typed +0 -0
- {python_general_be_lib-0.12.0 → python_general_be_lib-0.12.2}/general/utils/__init__.py +0 -0
- {python_general_be_lib-0.12.0 → python_general_be_lib-0.12.2}/general/utils/consts.py +0 -0
- {python_general_be_lib-0.12.0 → python_general_be_lib-0.12.2}/python_general_be_lib.egg-info/dependency_links.txt +0 -0
- {python_general_be_lib-0.12.0 → python_general_be_lib-0.12.2}/python_general_be_lib.egg-info/requires.txt +0 -0
- {python_general_be_lib-0.12.0 → python_general_be_lib-0.12.2}/python_general_be_lib.egg-info/top_level.txt +0 -0
- {python_general_be_lib-0.12.0 → python_general_be_lib-0.12.2}/setup.cfg +0 -0
- {python_general_be_lib-0.12.0 → python_general_be_lib-0.12.2}/tests/test_auth_chiavi.py +0 -0
- {python_general_be_lib-0.12.0 → python_general_be_lib-0.12.2}/tests/test_auth_fastapi.py +0 -0
- {python_general_be_lib-0.12.0 → python_general_be_lib-0.12.2}/tests/test_auth_token.py +0 -0
- {python_general_be_lib-0.12.0 → python_general_be_lib-0.12.2}/tests/test_caricamento_parziale.py +0 -0
- {python_general_be_lib-0.12.0 → python_general_be_lib-0.12.2}/tests/test_compiled_cache.py +0 -0
- {python_general_be_lib-0.12.0 → python_general_be_lib-0.12.2}/tests/test_costo_import.py +0 -0
- {python_general_be_lib-0.12.0 → python_general_be_lib-0.12.2}/tests/test_engine_defaults.py +0 -0
- {python_general_be_lib-0.12.0 → python_general_be_lib-0.12.2}/tests/test_entity_has_columns.py +0 -0
- {python_general_be_lib-0.12.0 → python_general_be_lib-0.12.2}/tests/test_execute_contratto.py +0 -0
- {python_general_be_lib-0.12.0 → python_general_be_lib-0.12.2}/tests/test_exists.py +0 -0
- {python_general_be_lib-0.12.0 → python_general_be_lib-0.12.2}/tests/test_find_one.py +0 -0
- {python_general_be_lib-0.12.0 → python_general_be_lib-0.12.2}/tests/test_get.py +0 -0
- {python_general_be_lib-0.12.0 → python_general_be_lib-0.12.2}/tests/test_has_columns.py +0 -0
- {python_general_be_lib-0.12.0 → python_general_be_lib-0.12.2}/tests/test_import_pubblici.py +0 -0
- {python_general_be_lib-0.12.0 → python_general_be_lib-0.12.2}/tests/test_logger.py +0 -0
- {python_general_be_lib-0.12.0 → python_general_be_lib-0.12.2}/tests/test_many_to_many.py +0 -0
- {python_general_be_lib-0.12.0 → python_general_be_lib-0.12.2}/tests/test_metadata_eccezioni.py +0 -0
- {python_general_be_lib-0.12.0 → python_general_be_lib-0.12.2}/tests/test_metadata_geom.py +0 -0
- {python_general_be_lib-0.12.0 → python_general_be_lib-0.12.2}/tests/test_metadata_pipeline.py +0 -0
- {python_general_be_lib-0.12.0 → python_general_be_lib-0.12.2}/tests/test_metadata_scorciatoie.py +0 -0
- {python_general_be_lib-0.12.0 → python_general_be_lib-0.12.2}/tests/test_mixin.py +0 -0
- {python_general_be_lib-0.12.0 → python_general_be_lib-0.12.2}/tests/test_negazione.py +0 -0
- {python_general_be_lib-0.12.0 → python_general_be_lib-0.12.2}/tests/test_pipeline_handler.py +0 -0
- {python_general_be_lib-0.12.0 → python_general_be_lib-0.12.2}/tests/test_query_count.py +0 -0
- {python_general_be_lib-0.12.0 → python_general_be_lib-0.12.2}/tests/test_retrieve_mapper.py +0 -0
- {python_general_be_lib-0.12.0 → python_general_be_lib-0.12.2}/tests/test_smoke_crud.py +0 -0
- {python_general_be_lib-0.12.0 → python_general_be_lib-0.12.2}/tests/test_softdelete.py +0 -0
- {python_general_be_lib-0.12.0 → python_general_be_lib-0.12.2}/tests/test_to_dict.py +0 -0
- {python_general_be_lib-0.12.0 → python_general_be_lib-0.12.2}/tests/test_to_pydantic.py +0 -0
- {python_general_be_lib-0.12.0 → python_general_be_lib-0.12.2}/tests/test_upsert_chiavi.py +0 -0
- {python_general_be_lib-0.12.0 → python_general_be_lib-0.12.2}/tests/test_valori_falsy.py +0 -0
- {python_general_be_lib-0.12.0 → 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.
|
|
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.
|
|
869
|
-
|
|
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
|
|
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
|
-
|
|
915
|
-
|
|
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)
|
|
@@ -169,14 +169,23 @@ class CrudRepository[Entity: Base | Type[Base]]:
|
|
|
169
169
|
return _reduced_model(self.entity, frozenset(selected_columns))
|
|
170
170
|
|
|
171
171
|
def _handle_exception(self, session: Session, e: Exception):
|
|
172
|
-
"""
|
|
172
|
+
"""Roll back and close the session, log the error and raise an HTTP-friendly exception.
|
|
173
173
|
|
|
174
174
|
An ``OperationalError`` becomes a ``ServiceUnavailableException``, a
|
|
175
175
|
``NoResultFound`` an ``EntityNotFoundException``, a ``DataError`` or an
|
|
176
176
|
``IntegrityError`` a ``BadRequestException``; any other exception is re-raised as is.
|
|
177
177
|
|
|
178
|
+
``close`` alone already rolls back the database transaction. What the explicit
|
|
179
|
+
``rollback`` adds is the expiry of the ORM instances: an entity still held by the
|
|
180
|
+
caller raises ``DetachedInstanceError`` on access instead of exposing values that
|
|
181
|
+
were flushed but never committed. It runs on every error, a failed SELECT included,
|
|
182
|
+
since on PostgreSQL that aborts the transaction as well. An external session is
|
|
183
|
+
rolled back and closed too, so the caller's transaction is lost. A failure of the
|
|
184
|
+
rollback itself is logged and does not replace ``e``, and the session is closed
|
|
185
|
+
anyway.
|
|
186
|
+
|
|
178
187
|
Args:
|
|
179
|
-
session (Session): Session to close.
|
|
188
|
+
session (Session): Session to roll back and close.
|
|
180
189
|
e (Exception): Caught exception to handle.
|
|
181
190
|
|
|
182
191
|
Raises:
|
|
@@ -184,6 +193,11 @@ class CrudRepository[Entity: Base | Type[Base]]:
|
|
|
184
193
|
EntityNotFoundException: If no record matches.
|
|
185
194
|
BadRequestException: If the data is malformed or violates a constraint.
|
|
186
195
|
"""
|
|
196
|
+
try:
|
|
197
|
+
session.rollback()
|
|
198
|
+
except Exception:
|
|
199
|
+
# un rollback fallito non deve sostituire l'errore originale, né saltare la close
|
|
200
|
+
logging.exception("Rollback failed")
|
|
187
201
|
session.close()
|
|
188
202
|
logging.error(e)
|
|
189
203
|
raise_http_exception(e)
|
|
@@ -195,12 +209,13 @@ class CrudRepository[Entity: Base | Type[Base]]:
|
|
|
195
209
|
session (Session): Session to commit.
|
|
196
210
|
|
|
197
211
|
Raises:
|
|
198
|
-
ServiceUnavailableException | BadRequestException: On error during the commit
|
|
212
|
+
ServiceUnavailableException | BadRequestException: On error during the commit,
|
|
213
|
+
after rolling back and closing the session.
|
|
199
214
|
"""
|
|
200
215
|
try:
|
|
201
216
|
session.commit()
|
|
202
217
|
except Exception as e:
|
|
203
|
-
self._handle_exception(session, e)
|
|
218
|
+
self._handle_exception(session=session, e=e)
|
|
204
219
|
|
|
205
220
|
def _flush(self, session: Session):
|
|
206
221
|
"""Flush the session, handling the possible exceptions.
|
|
@@ -214,12 +229,13 @@ class CrudRepository[Entity: Base | Type[Base]]:
|
|
|
214
229
|
session (Session): Session to flush.
|
|
215
230
|
|
|
216
231
|
Raises:
|
|
217
|
-
ServiceUnavailableException | BadRequestException: On error during the flush
|
|
232
|
+
ServiceUnavailableException | BadRequestException: On error during the flush,
|
|
233
|
+
after rolling back and closing the session.
|
|
218
234
|
"""
|
|
219
235
|
try:
|
|
220
236
|
session.flush()
|
|
221
237
|
except Exception as e:
|
|
222
|
-
self._handle_exception(session, e)
|
|
238
|
+
self._handle_exception(session=session, e=e)
|
|
223
239
|
|
|
224
240
|
def _entity_has_columns(self, *columns: str, entity: Type[Base] | Base = None) -> bool:
|
|
225
241
|
"""Check that every given name is a column of the entity.
|
|
@@ -441,7 +457,7 @@ class CrudRepository[Entity: Base | Type[Base]]:
|
|
|
441
457
|
try:
|
|
442
458
|
entity = session.get(self.entity, values)
|
|
443
459
|
except Exception as e:
|
|
444
|
-
self._handle_exception(session, e)
|
|
460
|
+
self._handle_exception(session=session, e=e)
|
|
445
461
|
else:
|
|
446
462
|
return [entity] if entity is not None else []
|
|
447
463
|
|
|
@@ -607,7 +623,7 @@ class CrudRepository[Entity: Base | Type[Base]]:
|
|
|
607
623
|
try:
|
|
608
624
|
session.add_all(entities)
|
|
609
625
|
except Exception as e:
|
|
610
|
-
self._handle_exception(session, e)
|
|
626
|
+
self._handle_exception(session=session, e=e)
|
|
611
627
|
return self._return(session=session, entities=entities, keep_open=keep_open, commit=True, parsing_model=parsing_model)
|
|
612
628
|
|
|
613
629
|
def update(self, session: Session = None, where: dict[str, Any] = None, returning: bool = True, parsing_model: Type[BaseModel] | BaseModel = None, **kwargs) -> list[Entity] | list[BaseModel]:
|
|
@@ -869,22 +885,25 @@ class CrudRepository[Entity: Base | Type[Base]]:
|
|
|
869
885
|
therefore a single page.
|
|
870
886
|
page (int): Requested page number, 1-based. Default: 1.
|
|
871
887
|
order_by (str, optional): Column to order by. When ``None``, the first column of
|
|
872
|
-
the primary key is used
|
|
888
|
+
the primary key is used, followed by the other ones if the key is
|
|
889
|
+
composite.
|
|
873
890
|
asc (bool): Ascending order if ``True``. Default: ``True``.
|
|
874
891
|
parsing_model (Type[BaseModel], optional): Alternative model for the
|
|
875
892
|
serialization.
|
|
876
893
|
orders_by (dict[str, bool], optional): Ordering on several columns, as
|
|
877
894
|
``{column: ascending}``, with the clauses following the insertion order of
|
|
878
895
|
the dictionary. Takes precedence over ``order_by``/``asc``. The primary key
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
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.
|
|
882
900
|
**kwhere: Filters as keyword arguments.
|
|
883
901
|
|
|
884
902
|
Returns:
|
|
885
903
|
PaginatorResponseModel[Entity]: Object carrying ``data``, ``tot_pages``,
|
|
886
904
|
``page``, ``limit``, ``orders_by``, plus ``order_by`` and ``asc`` holding
|
|
887
|
-
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.
|
|
888
907
|
"""
|
|
889
908
|
keep_open = False if session is None else True
|
|
890
909
|
session = self.open_session if session is None else session
|
|
@@ -897,15 +916,36 @@ class CrudRepository[Entity: Base | Type[Base]]:
|
|
|
897
916
|
return paginated
|
|
898
917
|
|
|
899
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
|
+
"""
|
|
900
939
|
if where is None:
|
|
901
940
|
where = kwhere if kwhere else {}
|
|
941
|
+
primary_key = [column.key for column in self.entity.primary_key()]
|
|
902
942
|
if not orders_by:
|
|
903
|
-
orders_by = {
|
|
943
|
+
orders_by = {primary_key[0]: True}
|
|
904
944
|
|
|
905
945
|
stmt = self._select(entity=self.entity, columns=columns, exclude_columns=exclude_columns)
|
|
906
946
|
stmt = self.stmt_manager.limit_offset_condition(stmt=stmt, limit=limit, page=page)
|
|
907
947
|
|
|
908
|
-
for order_by, asc in orders_by.items():
|
|
948
|
+
for order_by, asc in _with_tiebreaker(orders_by, primary_key).items():
|
|
909
949
|
stmt = self.stmt_manager.order_by_condition(stmt=stmt, order_by=order_by, asc=asc)
|
|
910
950
|
|
|
911
951
|
conditions = self.stmt_manager.conditions(where=where)
|
|
@@ -1049,9 +1089,9 @@ class CrudRepository[Entity: Base | Type[Base]]:
|
|
|
1049
1089
|
"""Execute a SQLAlchemy statement and return the result as it is.
|
|
1050
1090
|
|
|
1051
1091
|
The method owns the error handling and nothing else: every exception goes to
|
|
1052
|
-
``_handle_exception``, which closes the session and maps the error
|
|
1053
|
-
status. Reading the result is left to the caller, which is the only one
|
|
1054
|
-
shape it needs — ``.scalars().all()`` for entities, ``.scalar()`` for an aggregate,
|
|
1092
|
+
``_handle_exception``, which rolls back and closes the session and maps the error
|
|
1093
|
+
to an HTTP status. Reading the result is left to the caller, which is the only one
|
|
1094
|
+
knowing the shape it needs — ``.scalars().all()`` for entities, ``.scalar()`` for an aggregate,
|
|
1055
1095
|
``.all()`` for the multi-column rows of a statement built with ``add_columns``,
|
|
1056
1096
|
``.rowcount`` for a DML without ``RETURNING``. The result is bound to the session,
|
|
1057
1097
|
so it has to be consumed before the session is closed. ``Session.execute`` returns
|
|
@@ -1076,6 +1116,6 @@ class CrudRepository[Entity: Base | Type[Base]]:
|
|
|
1076
1116
|
try:
|
|
1077
1117
|
result = session.execute(statement=stmt, params=values)
|
|
1078
1118
|
except Exception as e:
|
|
1079
|
-
self._handle_exception(session, e)
|
|
1119
|
+
self._handle_exception(session=session, e=e)
|
|
1080
1120
|
else:
|
|
1081
1121
|
return result
|
|
@@ -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
|
|
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.
|
|
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
|
|
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.
|
|
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.
|
|
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
|
{python_general_be_lib-0.12.0 → python_general_be_lib-0.12.2}/tests/test_metadata_paginazione.py
RENAMED
|
@@ -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.
|
|
14
|
-
|
|
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
|
|
245
|
-
"""
|
|
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
|
-
|
|
248
|
-
|
|
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
|
-
|
|
254
|
-
|
|
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
|
-
|
|
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
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Rollback di ``CrudRepository._handle_exception``.
|
|
2
|
+
|
|
3
|
+
``Session.close()`` annulla già la transazione sul database: quello che il
|
|
4
|
+
``rollback()`` esplicito aggiunge è la scadenza delle istanze ORM. Senza, un'entità
|
|
5
|
+
rimasta in mano al chiamante resta leggibile con i valori del ``flush`` mai
|
|
6
|
+
confermati; con, la lettura alza ``DetachedInstanceError``. Il rollback gira su ogni
|
|
7
|
+
errore, SELECT compresa, e un suo fallimento non deve coprire l'errore originale.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
|
|
14
|
+
import pytest
|
|
15
|
+
from sqlalchemy import select, text
|
|
16
|
+
from sqlalchemy.engine import Engine
|
|
17
|
+
from sqlalchemy.exc import IntegrityError, ProgrammingError
|
|
18
|
+
from sqlalchemy.orm import Session
|
|
19
|
+
from sqlalchemy.orm.exc import DetachedInstanceError
|
|
20
|
+
|
|
21
|
+
from general.exception.crud_exceptions import BadRequestException, ServiceUnavailableException
|
|
22
|
+
from general.interface.repository.crud_repository import CrudRepository
|
|
23
|
+
from models import Prodotto, ProdottoModel, prodotto_repository
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@pytest.fixture
|
|
27
|
+
def repo(engine: Engine) -> CrudRepository:
|
|
28
|
+
repository = prodotto_repository(engine)
|
|
29
|
+
repository.insert(models=[ProdottoModel(id=1, codice="pulito")])
|
|
30
|
+
return repository
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_errore_di_scrittura_fa_scadere_le_entita_della_sessione_esterna(engine: Engine, repo: CrudRepository) -> None:
|
|
34
|
+
"""Dopo un ``IntegrityError`` l'entità modificata non espone più il valore mai confermato."""
|
|
35
|
+
sessione = Session(engine)
|
|
36
|
+
entita = sessione.get(Prodotto, 1)
|
|
37
|
+
entita.codice = "sporco"
|
|
38
|
+
|
|
39
|
+
with pytest.raises(BadRequestException):
|
|
40
|
+
repo.insert(session=sessione, models=[ProdottoModel(id=1, codice="duplicato")])
|
|
41
|
+
|
|
42
|
+
with pytest.raises(DetachedInstanceError):
|
|
43
|
+
_ = entita.codice
|
|
44
|
+
assert repo.get(1).codice == "pulito"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_anche_una_select_fallita_fa_scadere_le_entita(engine: Engine, repo: CrudRepository) -> None:
|
|
48
|
+
"""Su PostgreSQL una SELECT fallita blocca la transazione: il rollback vale anche lì."""
|
|
49
|
+
sessione = Session(engine)
|
|
50
|
+
entita = sessione.get(Prodotto, 1)
|
|
51
|
+
entita.codice = "sporco"
|
|
52
|
+
sessione.flush()
|
|
53
|
+
|
|
54
|
+
# SQLite la segnala come OperationalError (503), PostgreSQL come ProgrammingError (non mappato)
|
|
55
|
+
with pytest.raises((ServiceUnavailableException, ProgrammingError)):
|
|
56
|
+
repo.execute(sessione, select(Prodotto).where(text("colonna_inesistente = 1")))
|
|
57
|
+
|
|
58
|
+
with pytest.raises(DetachedInstanceError):
|
|
59
|
+
_ = entita.codice
|
|
60
|
+
assert repo.get(1).codice == "pulito"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class _SessioneConRollbackRotto:
|
|
64
|
+
"""Sessione finta il cui ``rollback`` fallisce, per verificare cosa succede dopo."""
|
|
65
|
+
|
|
66
|
+
def __init__(self) -> None:
|
|
67
|
+
self.chiusa = False
|
|
68
|
+
|
|
69
|
+
def rollback(self) -> None:
|
|
70
|
+
raise RuntimeError("rollback rotto")
|
|
71
|
+
|
|
72
|
+
def close(self) -> None:
|
|
73
|
+
self.chiusa = True
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_un_rollback_fallito_non_copre_l_errore_originale(sqlite_engine: Engine, caplog) -> None:
|
|
77
|
+
"""L'errore originale resta quello mappato, la sessione viene chiusa e il guasto registrato."""
|
|
78
|
+
repository = prodotto_repository(sqlite_engine)
|
|
79
|
+
sessione = _SessioneConRollbackRotto()
|
|
80
|
+
|
|
81
|
+
with caplog.at_level(logging.ERROR), pytest.raises(BadRequestException):
|
|
82
|
+
repository._handle_exception(session=sessione, e=IntegrityError("s", None, Exception()))
|
|
83
|
+
|
|
84
|
+
assert sessione.chiusa is True
|
|
85
|
+
assert any(record.exc_info and isinstance(record.exc_info[1], RuntimeError) for record in caplog.records)
|
|
@@ -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"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_general_be_lib-0.12.0 → python_general_be_lib-0.12.2}/general/exception/access_exceptions.py
RENAMED
|
File without changes
|
{python_general_be_lib-0.12.0 → python_general_be_lib-0.12.2}/general/exception/crud_exceptions.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_general_be_lib-0.12.0 → python_general_be_lib-0.12.2}/general/interface/base/__init__.py
RENAMED
|
File without changes
|
{python_general_be_lib-0.12.0 → python_general_be_lib-0.12.2}/general/interface/base/base_model.py
RENAMED
|
File without changes
|
|
File without changes
|
{python_general_be_lib-0.12.0 → python_general_be_lib-0.12.2}/general/interface/metadata/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{python_general_be_lib-0.12.0 → python_general_be_lib-0.12.2}/general/interface/mixin/__init__.py
RENAMED
|
File without changes
|
{python_general_be_lib-0.12.0 → python_general_be_lib-0.12.2}/general/interface/mixin/audit_mixin.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_general_be_lib-0.12.0 → python_general_be_lib-0.12.2}/tests/test_caricamento_parziale.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_general_be_lib-0.12.0 → python_general_be_lib-0.12.2}/tests/test_entity_has_columns.py
RENAMED
|
File without changes
|
{python_general_be_lib-0.12.0 → python_general_be_lib-0.12.2}/tests/test_execute_contratto.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_general_be_lib-0.12.0 → python_general_be_lib-0.12.2}/tests/test_metadata_eccezioni.py
RENAMED
|
File without changes
|
|
File without changes
|
{python_general_be_lib-0.12.0 → python_general_be_lib-0.12.2}/tests/test_metadata_pipeline.py
RENAMED
|
File without changes
|
{python_general_be_lib-0.12.0 → python_general_be_lib-0.12.2}/tests/test_metadata_scorciatoie.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_general_be_lib-0.12.0 → python_general_be_lib-0.12.2}/tests/test_pipeline_handler.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|