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