python-general-be-lib 0.8.0__tar.gz → 0.8.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.8.0 → python_general_be_lib-0.8.2}/PKG-INFO +4 -38
- {python_general_be_lib-0.8.0 → python_general_be_lib-0.8.2}/README.md +1 -38
- {python_general_be_lib-0.8.0 → python_general_be_lib-0.8.2}/general/interface/base/declarative_base.py +13 -5
- {python_general_be_lib-0.8.0 → python_general_be_lib-0.8.2}/general/interface/repository/crud_repository.py +80 -23
- {python_general_be_lib-0.8.0 → python_general_be_lib-0.8.2}/general/interface/repository/geometry_repository.py +6 -7
- {python_general_be_lib-0.8.0 → python_general_be_lib-0.8.2}/general/interface/repository/handler/base_handler.py +32 -5
- python_general_be_lib-0.8.2/general/interface/repository/handler/ilike_handler.py +105 -0
- python_general_be_lib-0.8.2/general/interface/repository/handler/interval_handler.py +143 -0
- {python_general_be_lib-0.8.0 → python_general_be_lib-0.8.2}/general/interface/repository/handler/json_handler.py +29 -15
- python_general_be_lib-0.8.2/general/interface/repository/handler/json_ilike_handler.py +100 -0
- python_general_be_lib-0.8.2/general/interface/repository/handler/or_handler.py +103 -0
- {python_general_be_lib-0.8.0 → python_general_be_lib-0.8.2}/general/interface/repository/handler/statement_manager.py +50 -12
- {python_general_be_lib-0.8.0 → python_general_be_lib-0.8.2}/general/interface/repository/view_repository.py +10 -6
- {python_general_be_lib-0.8.0 → python_general_be_lib-0.8.2}/general/logger.py +39 -13
- {python_general_be_lib-0.8.0 → python_general_be_lib-0.8.2}/pyproject.toml +12 -1
- {python_general_be_lib-0.8.0 → python_general_be_lib-0.8.2}/python_general_be_lib.egg-info/PKG-INFO +4 -38
- {python_general_be_lib-0.8.0 → python_general_be_lib-0.8.2}/python_general_be_lib.egg-info/SOURCES.txt +9 -1
- {python_general_be_lib-0.8.0 → python_general_be_lib-0.8.2}/python_general_be_lib.egg-info/requires.txt +3 -0
- {python_general_be_lib-0.8.0 → python_general_be_lib-0.8.2}/setup.py +1 -1
- python_general_be_lib-0.8.2/tests/test_caricamento_parziale.py +284 -0
- python_general_be_lib-0.8.2/tests/test_compiled_cache.py +167 -0
- python_general_be_lib-0.8.2/tests/test_logger.py +365 -0
- python_general_be_lib-0.8.2/tests/test_pipeline_handler.py +358 -0
- python_general_be_lib-0.8.2/tests/test_query_count.py +275 -0
- python_general_be_lib-0.8.2/tests/test_smoke_crud.py +304 -0
- python_general_be_lib-0.8.2/tests/test_sql_snapshot.py +320 -0
- python_general_be_lib-0.8.2/tests/test_view_repository.py +271 -0
- python_general_be_lib-0.8.0/general/interface/repository/handler/ilike_handler.py +0 -90
- python_general_be_lib-0.8.0/general/interface/repository/handler/interval_handler.py +0 -122
- python_general_be_lib-0.8.0/general/interface/repository/handler/json_ilike_handler.py +0 -128
- python_general_be_lib-0.8.0/general/interface/repository/handler/or_handler.py +0 -103
- {python_general_be_lib-0.8.0 → python_general_be_lib-0.8.2}/LICENSE +0 -0
- {python_general_be_lib-0.8.0 → python_general_be_lib-0.8.2}/general/__init__.py +0 -0
- {python_general_be_lib-0.8.0 → python_general_be_lib-0.8.2}/general/exception/__init__.py +0 -0
- {python_general_be_lib-0.8.0 → python_general_be_lib-0.8.2}/general/exception/access_exceptions.py +0 -0
- {python_general_be_lib-0.8.0 → python_general_be_lib-0.8.2}/general/exception/crud_exceptions.py +0 -0
- {python_general_be_lib-0.8.0 → python_general_be_lib-0.8.2}/general/exception/exception_interface.py +0 -0
- {python_general_be_lib-0.8.0 → python_general_be_lib-0.8.2}/general/interface/base/__init__.py +0 -0
- {python_general_be_lib-0.8.0 → python_general_be_lib-0.8.2}/general/interface/base/base_model.py +0 -0
- {python_general_be_lib-0.8.0 → python_general_be_lib-0.8.2}/general/interface/metadata/__init__.py +0 -0
- {python_general_be_lib-0.8.0 → python_general_be_lib-0.8.2}/general/interface/metadata/crud_metadata.py +0 -0
- {python_general_be_lib-0.8.0 → python_general_be_lib-0.8.2}/general/interface/metadata/geom_metadata.py +0 -0
- {python_general_be_lib-0.8.0 → python_general_be_lib-0.8.2}/general/interface/repository/__init__.py +0 -0
- {python_general_be_lib-0.8.0 → python_general_be_lib-0.8.2}/general/interface/repository/handler/__init__.py +0 -0
- {python_general_be_lib-0.8.0 → python_general_be_lib-0.8.2}/general/interface/repository/many_to_many_repository.py +0 -0
- {python_general_be_lib-0.8.0 → python_general_be_lib-0.8.2}/general/paginator_dto.py +0 -0
- {python_general_be_lib-0.8.0 → python_general_be_lib-0.8.2}/python_general_be_lib.egg-info/dependency_links.txt +0 -0
- {python_general_be_lib-0.8.0 → python_general_be_lib-0.8.2}/python_general_be_lib.egg-info/top_level.txt +0 -0
- {python_general_be_lib-0.8.0 → python_general_be_lib-0.8.2}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-general-be-lib
|
|
3
|
-
Version: 0.8.
|
|
3
|
+
Version: 0.8.2
|
|
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
|
|
@@ -25,6 +25,9 @@ Requires-Dist: geoalchemy2>=0.14.0
|
|
|
25
25
|
Provides-Extra: dev
|
|
26
26
|
Requires-Dist: pytest>=7; extra == "dev"
|
|
27
27
|
Requires-Dist: pytest-cov; extra == "dev"
|
|
28
|
+
Requires-Dist: pytest-benchmark>=5; extra == "dev"
|
|
29
|
+
Requires-Dist: testcontainers[postgres]>=4; extra == "dev"
|
|
30
|
+
Requires-Dist: psycopg[binary]>=3; extra == "dev"
|
|
28
31
|
Requires-Dist: build; extra == "dev"
|
|
29
32
|
Requires-Dist: twine; extra == "dev"
|
|
30
33
|
Dynamic: license-file
|
|
@@ -69,40 +72,3 @@ repo.find(active=True, order_by="name", asc=True)
|
|
|
69
72
|
| Paginazione | `PaginatorRequestModel`, `PaginatorResponseModel` |
|
|
70
73
|
| Eccezioni | `ExceptionInterface` e sottoclassi HTTP (400/401/403/404/422/500/503) |
|
|
71
74
|
| Logging | `init_logger`, decorator `@log` |
|
|
72
|
-
|
|
73
|
-
---
|
|
74
|
-
|
|
75
|
-
## Documentazione
|
|
76
|
-
|
|
77
|
-
Il codice della libreria vive nella directory `general/`, che è un **git
|
|
78
|
-
submodule** ([novatellus/shared-be-libs](https://github.com/novatellus/shared-be-libs)).
|
|
79
|
-
Questo repository esterno è il wrapper di packaging e pubblicazione.
|
|
80
|
-
|
|
81
|
-
- **[general/README.md](general/README.md)** — panoramica, quickstart, contratto
|
|
82
|
-
di sessione, handler, operazioni spaziali
|
|
83
|
-
- **[general/DOCS.md](general/DOCS.md)** — riferimento completo di ogni metodo
|
|
84
|
-
|
|
85
|
-
Dopo il clone, inizializza il submodule:
|
|
86
|
-
|
|
87
|
-
```bash
|
|
88
|
-
git submodule update --init --recursive
|
|
89
|
-
```
|
|
90
|
-
|
|
91
|
-
---
|
|
92
|
-
|
|
93
|
-
## Build e pubblicazione
|
|
94
|
-
|
|
95
|
-
La versione è definita in **entrambi** `setup.py` e `pyproject.toml`: vanno
|
|
96
|
-
aggiornati insieme. I messaggi di commit sono prefissati con la nuova versione
|
|
97
|
-
(es. `0.7.2 OrHandler e distinct_pairs`).
|
|
98
|
-
|
|
99
|
-
```bash
|
|
100
|
-
python3 setup.py sdist bdist_wheel # build di sdist + wheel in dist/
|
|
101
|
-
python3 -m twine upload dist/* # pubblicazione su PyPI
|
|
102
|
-
```
|
|
103
|
-
|
|
104
|
-
---
|
|
105
|
-
|
|
106
|
-
## Licenza
|
|
107
|
-
|
|
108
|
-
MIT — vedi [LICENSE](LICENSE).
|
|
@@ -36,41 +36,4 @@ repo.find(active=True, order_by="name", asc=True)
|
|
|
36
36
|
| Basi | `Base` (declarative base estesa), `ExtBaseModel`, `CamelExtBaseModel` |
|
|
37
37
|
| Paginazione | `PaginatorRequestModel`, `PaginatorResponseModel` |
|
|
38
38
|
| Eccezioni | `ExceptionInterface` e sottoclassi HTTP (400/401/403/404/422/500/503) |
|
|
39
|
-
| Logging | `init_logger`, decorator `@log` |
|
|
40
|
-
|
|
41
|
-
---
|
|
42
|
-
|
|
43
|
-
## Documentazione
|
|
44
|
-
|
|
45
|
-
Il codice della libreria vive nella directory `general/`, che è un **git
|
|
46
|
-
submodule** ([novatellus/shared-be-libs](https://github.com/novatellus/shared-be-libs)).
|
|
47
|
-
Questo repository esterno è il wrapper di packaging e pubblicazione.
|
|
48
|
-
|
|
49
|
-
- **[general/README.md](general/README.md)** — panoramica, quickstart, contratto
|
|
50
|
-
di sessione, handler, operazioni spaziali
|
|
51
|
-
- **[general/DOCS.md](general/DOCS.md)** — riferimento completo di ogni metodo
|
|
52
|
-
|
|
53
|
-
Dopo il clone, inizializza il submodule:
|
|
54
|
-
|
|
55
|
-
```bash
|
|
56
|
-
git submodule update --init --recursive
|
|
57
|
-
```
|
|
58
|
-
|
|
59
|
-
---
|
|
60
|
-
|
|
61
|
-
## Build e pubblicazione
|
|
62
|
-
|
|
63
|
-
La versione è definita in **entrambi** `setup.py` e `pyproject.toml`: vanno
|
|
64
|
-
aggiornati insieme. I messaggi di commit sono prefissati con la nuova versione
|
|
65
|
-
(es. `0.7.2 OrHandler e distinct_pairs`).
|
|
66
|
-
|
|
67
|
-
```bash
|
|
68
|
-
python3 setup.py sdist bdist_wheel # build di sdist + wheel in dist/
|
|
69
|
-
python3 -m twine upload dist/* # pubblicazione su PyPI
|
|
70
|
-
```
|
|
71
|
-
|
|
72
|
-
---
|
|
73
|
-
|
|
74
|
-
## Licenza
|
|
75
|
-
|
|
76
|
-
MIT — vedi [LICENSE](LICENSE).
|
|
39
|
+
| Logging | `init_logger`, decorator `@log` |
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
__all__ = ["Base", "retrieve_mapper_entity", "retrieve_mapper_from_table"]
|
|
2
2
|
|
|
3
|
-
from typing import Optional, Any
|
|
3
|
+
from typing import Optional, Any, Sequence
|
|
4
4
|
|
|
5
5
|
from geoalchemy2 import Geometry, WKBElement
|
|
6
6
|
from pydantic import create_model
|
|
@@ -17,7 +17,7 @@ class Base:
|
|
|
17
17
|
"""
|
|
18
18
|
|
|
19
19
|
@classmethod
|
|
20
|
-
def to_pydantic(cls, model_name: str = None, full_optional: bool = False, default_values: dict[str, Any] = None, **create_model_kwargs):
|
|
20
|
+
def to_pydantic(cls, model_name: str = None, full_optional: bool = False, default_values: dict[str, Any] = None, include_only: Sequence[str] = None, **create_model_kwargs):
|
|
21
21
|
"""Genera dinamicamente un modello Pydantic a partire dalle colonne dell'entità.
|
|
22
22
|
|
|
23
23
|
Itera sulle colonne mappate dall'ORM e costruisce i campi del modello
|
|
@@ -44,12 +44,20 @@ class Base:
|
|
|
44
44
|
if not default_values:
|
|
45
45
|
default_values = dict()
|
|
46
46
|
mapper: Mapper = inspect(cls)
|
|
47
|
+
|
|
48
|
+
# ``mapper.columns`` è indicizzato per **nome di attributo** e non forza la
|
|
49
|
+
# configurazione dei mapper: ``column_attrs``/``attrs``/``relationships`` la
|
|
50
|
+
# forzerebbero, e chiamare ``to_pydantic`` a import-time — il pattern
|
|
51
|
+
# documentato — romperebbe le relationship con forward-ref non ancora
|
|
52
|
+
# risolvibili (stessa ragione per cui ``relationship_attributes`` è lazy).
|
|
53
|
+
selected = [(name, column) for name, column in mapper.columns.items()
|
|
54
|
+
if include_only is None or name in include_only]
|
|
55
|
+
|
|
47
56
|
model_name = f"{cls.__name__}Model" if not model_name else model_name
|
|
48
57
|
fields = dict()
|
|
49
|
-
field_values = {
|
|
58
|
+
field_values = {name: None if full_optional else ... for name, _ in selected}
|
|
50
59
|
field_values.update(default_values)
|
|
51
|
-
for col in
|
|
52
|
-
field_name = col.name
|
|
60
|
+
for field_name, col in selected:
|
|
53
61
|
if isinstance(col.type, Geometry):
|
|
54
62
|
field_type = Optional[WKBElement] if col.nullable or full_optional else WKBElement
|
|
55
63
|
else:
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
__all__ = ["CrudRepository"]
|
|
2
2
|
|
|
3
|
+
import hashlib
|
|
3
4
|
import logging
|
|
4
5
|
import math
|
|
5
6
|
from functools import lru_cache
|
|
@@ -31,6 +32,25 @@ def _list_adapter(model: Type[BaseModel]) -> TypeAdapter:
|
|
|
31
32
|
"""
|
|
32
33
|
return TypeAdapter(list[model])
|
|
33
34
|
|
|
35
|
+
@lru_cache(maxsize=None)
|
|
36
|
+
def _reduced_model(entity: Type[Base], columns: frozenset[str]) -> Type[BaseModel]:
|
|
37
|
+
"""Modello ridotto alle colonne indicate, costruito una volta per combinazione.
|
|
38
|
+
|
|
39
|
+
Cachato per (entità, colonne) perché `create_model` produce una classe nuova a
|
|
40
|
+
ogni chiamata: senza memoizzazione `_list_adapter` — che esiste per non
|
|
41
|
+
ricostruire il TypeAdapter — mancherebbe il bersaglio a ogni richiesta, e il suo
|
|
42
|
+
`maxsize=None` tratterebbe una voce per classe generata.
|
|
43
|
+
"""
|
|
44
|
+
# ``mapper.columns`` è indicizzato per nome di attributo e non forza la
|
|
45
|
+
# configurazione dei mapper (a differenza di ``column_attrs``/``attrs``).
|
|
46
|
+
ordinate = [name for name in inspect(entity).columns.keys() if name in columns]
|
|
47
|
+
digest = hashlib.sha1("|".join(ordinate).encode()).hexdigest()[:8]
|
|
48
|
+
return entity.to_pydantic(
|
|
49
|
+
model_name=f"{entity.__name__}_{digest}",
|
|
50
|
+
full_optional=True,
|
|
51
|
+
include_only=ordinate,
|
|
52
|
+
)
|
|
53
|
+
|
|
34
54
|
|
|
35
55
|
class CrudRepository[Entity: Base | Type[Base]]:
|
|
36
56
|
"""Repository ORM generico con operazioni CRUD complete su una singola entità SQLAlchemy.
|
|
@@ -109,6 +129,17 @@ class CrudRepository[Entity: Base | Type[Base]]:
|
|
|
109
129
|
|
|
110
130
|
# -------------------------
|
|
111
131
|
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _retrieve_parsing_model(self, columns=None, exclude_columns=None):
|
|
136
|
+
selected_columns = set(columns) if columns else set(inspect(self.entity).columns.keys())
|
|
137
|
+
if exclude_columns:
|
|
138
|
+
selected_columns -= set(exclude_columns)
|
|
139
|
+
if columns is None and not exclude_columns:
|
|
140
|
+
return self.model
|
|
141
|
+
return _reduced_model(self.entity, frozenset(selected_columns))
|
|
142
|
+
|
|
112
143
|
def _handle_exception(self, session: Session, e: Exception):
|
|
113
144
|
"""Chiude la sessione, logga l'errore e rilancia un'eccezione HTTP-friendly.
|
|
114
145
|
|
|
@@ -211,9 +242,9 @@ class CrudRepository[Entity: Base | Type[Base]]:
|
|
|
211
242
|
stmt = select(entity)
|
|
212
243
|
if columns:
|
|
213
244
|
entity_columns = {column for column in columns if column not in self.relationship_attributes}
|
|
214
|
-
stmt = stmt.options(load_only(*[getattr(
|
|
245
|
+
stmt = stmt.options(load_only(*[getattr(entity, column) for column in entity_columns]))
|
|
215
246
|
if exclude_columns:
|
|
216
|
-
stmt = stmt.options(defer(
|
|
247
|
+
stmt = stmt.options(*[defer(getattr(entity, column)) for column in exclude_columns])
|
|
217
248
|
except AttributeError:
|
|
218
249
|
raise HasNoAttributeException()
|
|
219
250
|
return stmt
|
|
@@ -318,6 +349,7 @@ class CrudRepository[Entity: Base | Type[Base]]:
|
|
|
318
349
|
keep_open = False if session is None else True
|
|
319
350
|
session = self.open_session if session is None else session
|
|
320
351
|
entities = self._find(session=session, where=where, columns=columns, exclude_columns=exclude_columns, limit=limit, order_by=order_by, asc=asc, **kwhere)
|
|
352
|
+
parsing_model = parsing_model if parsing_model is not None else self._retrieve_parsing_model(columns=columns, exclude_columns=exclude_columns)
|
|
321
353
|
return self._return(session=session, entities=entities, keep_open=keep_open, commit=False, parsing_model=parsing_model)
|
|
322
354
|
|
|
323
355
|
def _find(self, session: Session, where: dict[str, Any] = None, columns: list[str] = None, exclude_columns: list[str] = None, limit: int = 0, order_by: str = None,
|
|
@@ -583,40 +615,56 @@ class CrudRepository[Entity: Base | Type[Base]]:
|
|
|
583
615
|
return count_
|
|
584
616
|
|
|
585
617
|
def _count(self, session: Session, where: dict[str, Any] = None, **kwhere):
|
|
586
|
-
pk = self.entity.primary_key()[0]
|
|
587
618
|
if where is None:
|
|
588
619
|
where = kwhere if kwhere else {}
|
|
589
|
-
stmt = select(count(
|
|
620
|
+
stmt = select(count()).select_from(self.entity)
|
|
590
621
|
stmt = self.stmt_manager.prepare_statement(stmt=stmt, **where)
|
|
591
622
|
return session.execute(stmt).scalar()
|
|
592
623
|
|
|
593
|
-
def _preconf_count(self, session: Session, where: Optional[ColumnElement[Any]]):
|
|
594
|
-
"""Conta i record
|
|
624
|
+
def _preconf_count(self, session: Session, where: Optional[list[ColumnElement[Any]]]):
|
|
625
|
+
"""Conta i record a partire da condizioni WHERE già costruite.
|
|
595
626
|
|
|
596
|
-
Usato
|
|
597
|
-
|
|
627
|
+
Usato da ``_paging_find`` quando il totale non è ricavabile dalla pagina
|
|
628
|
+
appena letta: riusa la lista di condizioni prodotta dalla pipeline degli
|
|
629
|
+
handler, così i filtri vengono tradotti una sola volta per chiamata.
|
|
630
|
+
|
|
631
|
+
Conta con ``COUNT(*)`` e non ``COUNT(pk)``: evita il controllo NOT NULL per
|
|
632
|
+
riga a parità di piano di esecuzione. Il ``select_from`` è indispensabile —
|
|
633
|
+
``select(count())`` da solo compilerebbe in un ``SELECT count(*)`` **senza
|
|
634
|
+
FROM**, che restituisce sempre 1.
|
|
598
635
|
|
|
599
636
|
Args:
|
|
600
637
|
session (Session): Sessione corrente.
|
|
601
|
-
where (ColumnElement):
|
|
638
|
+
where (list[ColumnElement] | None): Condizioni già costruite, come le
|
|
639
|
+
restituisce ``StatementManager.conditions``. Una lista vuota o
|
|
640
|
+
``None`` conta tutte le righe della tabella.
|
|
602
641
|
|
|
603
642
|
Returns:
|
|
604
643
|
int: Numero totale di record corrispondenti.
|
|
605
644
|
"""
|
|
606
|
-
|
|
607
|
-
stmt = select(count(pk))
|
|
645
|
+
stmt = select(count()).select_from(self.entity)
|
|
608
646
|
if where is not None:
|
|
609
|
-
stmt = stmt.where(where)
|
|
647
|
+
stmt = stmt.where(*where)
|
|
610
648
|
return session.execute(stmt).scalar()
|
|
611
649
|
|
|
612
650
|
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,
|
|
613
651
|
parsing_model: Type[BaseModel] = None, **kwhere) -> PaginatorResponseModel[Entity]:
|
|
614
652
|
"""Esegue una ricerca paginata con supporto a ordinamento e offset.
|
|
615
653
|
|
|
616
|
-
Calcola il numero totale di pagine
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
654
|
+
Calcola il numero totale di pagine e restituisce solo la pagina richiesta.
|
|
655
|
+
|
|
656
|
+
La query dei dati viene eseguita **per prima**, così il totale può essere
|
|
657
|
+
ricavato dalle righe ottenute ed evitare del tutto la ``COUNT``:
|
|
658
|
+
|
|
659
|
+
- ``limit=0`` (nessuna paginazione) → il totale è il numero di righe;
|
|
660
|
+
- pagina non piena in prima pagina → il totale è il numero di righe;
|
|
661
|
+
- pagina non piena oltre la prima → il totale è ``(page-1)*limit + righe``;
|
|
662
|
+
- pagina **piena**, oppure vuota oltre la prima → il totale non è
|
|
663
|
+
ricavabile e viene eseguita la ``COUNT`` (``_preconf_count``), che riusa le
|
|
664
|
+
condizioni già costruite dalla pipeline degli handler.
|
|
665
|
+
|
|
666
|
+
Il valore derivato è esatto, non stimato: il ``tot_pages`` restituito è
|
|
667
|
+
identico a quello che darebbe la ``COUNT`` in tutti i casi.
|
|
620
668
|
|
|
621
669
|
Args:
|
|
622
670
|
session (Session, optional): Sessione esterna.
|
|
@@ -645,25 +693,34 @@ class CrudRepository[Entity: Base | Type[Base]]:
|
|
|
645
693
|
keep_open = False if session is None else True
|
|
646
694
|
session = self.open_session if session is None else session
|
|
647
695
|
paginated = self._paging_find(session=session, where=where, columns=columns, exclude_columns=exclude_columns, limit=limit, page=page, order_by=order_by, asc=asc, **kwhere)
|
|
696
|
+
parsing_model = parsing_model if parsing_model is not None else self._retrieve_parsing_model(columns=columns, exclude_columns=exclude_columns)
|
|
648
697
|
if not keep_open:
|
|
649
|
-
paginated.data = _list_adapter(parsing_model
|
|
698
|
+
paginated.data = _list_adapter(parsing_model).validate_python(paginated.data, from_attributes=True)
|
|
650
699
|
session.close()
|
|
651
700
|
return paginated
|
|
652
701
|
|
|
653
702
|
def _paging_find(self, session: Session, where: dict[str, Any] = None, columns: list[str] = None, exclude_columns: list[str] = None, limit: int = 0, page: int = 1, order_by: str = None, asc: bool = True, **kwhere):
|
|
654
703
|
if where is None:
|
|
655
704
|
where = kwhere if kwhere else {}
|
|
656
|
-
stmt = self._select(entity=self.entity, columns=columns, exclude_columns=exclude_columns)
|
|
657
|
-
stmt = self.stmt_manager.prepare_statement(stmt=stmt, **where)
|
|
658
|
-
|
|
659
|
-
row_numbers = self._preconf_count(session=session, where=stmt.whereclause)
|
|
660
705
|
|
|
706
|
+
stmt = self._select(entity=self.entity, columns=columns, exclude_columns=exclude_columns)
|
|
707
|
+
conditions = self.stmt_manager.conditions(where=where)
|
|
661
708
|
order_by = order_by or self.entity.primary_key()[0].name
|
|
709
|
+
if conditions:
|
|
710
|
+
stmt = stmt.where(*conditions)
|
|
662
711
|
stmt = self.stmt_manager.limit_offset_condition(stmt=stmt, limit=limit, page=page)
|
|
663
712
|
stmt = self.stmt_manager.order_by_condition(stmt=stmt, order_by=order_by, asc=asc)
|
|
664
|
-
|
|
665
713
|
entities = self.execute(session, stmt)
|
|
666
|
-
|
|
714
|
+
|
|
715
|
+
len_entities = len(entities)
|
|
716
|
+
if limit == 0 or (len_entities < limit and page == 1):
|
|
717
|
+
count_ = len_entities
|
|
718
|
+
elif limit > len_entities > 0 and page > 1:
|
|
719
|
+
count_ = (page - 1) * limit + len_entities
|
|
720
|
+
else:
|
|
721
|
+
count_ = self._preconf_count(session=session, where=conditions)
|
|
722
|
+
|
|
723
|
+
return self.parse_pagination(limit=limit, page=page, order_by=order_by, count=count_, asc=asc, data=entities)
|
|
667
724
|
|
|
668
725
|
def parse_pagination(self, limit: int, page: int, order_by: str, asc: bool, count: int, data: Sequence[Row] | Sequence[Row | RowMapping] | Sequence[RowMapping]) -> PaginatorResponseModel:
|
|
669
726
|
"""Costruisce il ``PaginatorResponseModel`` dai dati di paginazione.
|
|
@@ -76,6 +76,7 @@ class GeometryRepository[Entity: Base | Type[Base]](CrudRepository[Entity]):
|
|
|
76
76
|
keep_open = False if session is None else True
|
|
77
77
|
session = self.open_session if session is None else session
|
|
78
78
|
entities = self._intersects(session=session, geom=geom, tolerance=tolerance, where=where, not_where=not_where, columns=columns, exclude_columns=exclude_columns, limit=limit, geom_srid=geom_srid)
|
|
79
|
+
parsing_model = parsing_model if parsing_model is not None else self._retrieve_parsing_model(columns=columns, exclude_columns=exclude_columns)
|
|
79
80
|
return self._return(session=session, entities=entities, keep_open=keep_open, commit=False, parsing_model=parsing_model)
|
|
80
81
|
|
|
81
82
|
def _intersects(self, session: Session, geom: WKBElement, tolerance: float = 0.0, columns: list[str] = None, exclude_columns: list[str] = None, where: dict[str, Any] = None, not_where: dict[str, Any] = None,
|
|
@@ -88,12 +89,9 @@ class GeometryRepository[Entity: Base | Type[Base]](CrudRepository[Entity]):
|
|
|
88
89
|
stmt = self._select(entity=self.entity, columns=columns, exclude_columns=exclude_columns)
|
|
89
90
|
|
|
90
91
|
intersecting_geom = self._geom_condition(geom=geom, geom_srid=geom_srid)
|
|
91
|
-
|
|
92
|
-
stmt = self.stmt_manager.prepare_statement(stmt=stmt, **where)
|
|
93
|
-
stmt = self.stmt_manager.prepare_statement(stmt=stmt, neq=True, **not_where)
|
|
94
|
-
|
|
95
92
|
geom_condition = self.geom_column.ST_DWithin(intersecting_geom, tolerance) if tolerance else self.geom_column.ST_Intersects(intersecting_geom)
|
|
96
|
-
|
|
93
|
+
|
|
94
|
+
stmt = stmt.where(*self.stmt_manager.conditions(where=where), *self.stmt_manager.conditions(neq=True, where=not_where), geom_condition)
|
|
97
95
|
stmt = self.stmt_manager.limit_offset_condition(stmt=stmt, limit=limit)
|
|
98
96
|
|
|
99
97
|
return self.execute(session, stmt)
|
|
@@ -131,6 +129,7 @@ class GeometryRepository[Entity: Base | Type[Base]](CrudRepository[Entity]):
|
|
|
131
129
|
keep_open = False if session is None else True
|
|
132
130
|
session = self.open_session if session is None else session
|
|
133
131
|
entities = self._contains(session=session, geom=geom, tolerance=tolerance, where=where, contained=contained, columns=columns, exclude_columns=exclude_columns, limit=limit, geom_srid=geom_srid)
|
|
132
|
+
parsing_model = parsing_model if parsing_model is not None else self._retrieve_parsing_model(columns=columns, exclude_columns=exclude_columns)
|
|
134
133
|
return self._return(session=session, entities=entities, keep_open=keep_open, commit=False, parsing_model=parsing_model)
|
|
135
134
|
|
|
136
135
|
def _contains(self, session: Session, geom: WKBElement, tolerance: float = 0.0, columns: list[str] = None, exclude_columns: list[str] = None, where: dict[str, Any] = None, limit: int = 0, contained: bool = False,
|
|
@@ -139,12 +138,11 @@ class GeometryRepository[Entity: Base | Type[Base]](CrudRepository[Entity]):
|
|
|
139
138
|
where = {}
|
|
140
139
|
|
|
141
140
|
stmt = self._select(entity=self.entity, columns=columns, exclude_columns=exclude_columns)
|
|
142
|
-
stmt = self.stmt_manager.prepare_statement(stmt=stmt, **where)
|
|
143
141
|
|
|
144
142
|
geom_condition = self._geom_condition(geom=geom, geom_srid=geom_srid, buffer=tolerance)
|
|
145
143
|
geom_condition = self.geom_column.ST_Contains(geom_condition) if not contained else geom_condition.ST_Contains(self.geom_column)
|
|
146
144
|
|
|
147
|
-
stmt = stmt.where(geom_condition)
|
|
145
|
+
stmt = stmt.where(*self.stmt_manager.conditions(where=where), geom_condition)
|
|
148
146
|
|
|
149
147
|
stmt = self.stmt_manager.limit_offset_condition(stmt=stmt, limit=limit)
|
|
150
148
|
return self.execute(session, stmt)
|
|
@@ -183,6 +181,7 @@ class GeometryRepository[Entity: Base | Type[Base]](CrudRepository[Entity]):
|
|
|
183
181
|
keep_open = False if session is None else True
|
|
184
182
|
session = self.open_session if session is None else session
|
|
185
183
|
entities = self._nearest(session=session, geom=geom, where=where, columns=columns, exclude_columns=exclude_columns, limit=limit, geom_srid=geom_srid)
|
|
184
|
+
parsing_model = parsing_model if parsing_model is not None else self._retrieve_parsing_model(columns=columns, exclude_columns=exclude_columns)
|
|
186
185
|
return self._return(session=session, entities=entities, keep_open=keep_open, commit=False, parsing_model=parsing_model)
|
|
187
186
|
|
|
188
187
|
def _nearest(self, session: Session, geom: WKBElement, columns: list[str] = None, exclude_columns: list[str] = None, limit: int = 1, where: dict[str, Any] = None, geom_srid: int = 4326):
|
|
@@ -2,7 +2,7 @@ __all__ = ["BaseHandler"]
|
|
|
2
2
|
|
|
3
3
|
from typing import Any
|
|
4
4
|
|
|
5
|
-
from sqlalchemy import Select, Update, Delete, Column, not_, ARRAY, type_coerce
|
|
5
|
+
from sqlalchemy import Select, Update, Delete, Column, ColumnElement, not_, ARRAY, type_coerce
|
|
6
6
|
from sqlalchemy.sql.base import ReadOnlyColumnCollection
|
|
7
7
|
|
|
8
8
|
|
|
@@ -40,10 +40,13 @@ class BaseHandler:
|
|
|
40
40
|
self.array_columns = {column.name for column in columns if isinstance(column.type, ARRAY)}
|
|
41
41
|
|
|
42
42
|
def handle(self, stmt: Select | Update | Delete, neq: bool = False, where: dict[str, Any] = None):
|
|
43
|
-
"""Applica
|
|
43
|
+
"""Applica allo statement le condizioni prodotte da ``conditions``.
|
|
44
44
|
|
|
45
|
-
|
|
46
|
-
|
|
45
|
+
Adattatore fra il protocollo degli handler (che costruisce condizioni) e uno
|
|
46
|
+
statement: raccoglie tutte le condizioni e le aggiunge con **una sola**
|
|
47
|
+
chiamata a ``.where()``. Nella pipeline normale è ``StatementManager`` a
|
|
48
|
+
occuparsene, accorpando anche le condizioni degli altri handler; questo
|
|
49
|
+
metodo serve a usare un handler in isolamento.
|
|
47
50
|
|
|
48
51
|
Args:
|
|
49
52
|
stmt (Select | Update | Delete): Statement SQLAlchemy su cui applicare i filtri.
|
|
@@ -59,9 +62,33 @@ class BaseHandler:
|
|
|
59
62
|
KeyError: Se una chiave del ``where`` non corrisponde a nessuna colonna,
|
|
60
63
|
ovvero se nessun handler precedente l'ha consumata.
|
|
61
64
|
"""
|
|
62
|
-
|
|
65
|
+
conditions = self.conditions(neq=neq, where=where)
|
|
66
|
+
stmt = stmt.where(*conditions)
|
|
63
67
|
return stmt
|
|
64
68
|
|
|
69
|
+
def conditions(self, neq: bool = False, where: dict[str, Any] = None) -> list[ColumnElement[Any]]:
|
|
70
|
+
"""Costruisce una condizione per ogni coppia ``{colonna: valore}`` rimasta in ``where``.
|
|
71
|
+
|
|
72
|
+
Essendo l'ultimo handler della pipeline, ``BaseHandler`` non ha successori e
|
|
73
|
+
quindi — a differenza degli handler specializzati — **non rimuove** le chiavi
|
|
74
|
+
dal dizionario: le consuma tutte trattandole come colonne reali. Ogni chiave
|
|
75
|
+
che nessun handler precedente ha rivendicato e che non è una colonna provoca
|
|
76
|
+
perciò un ``KeyError``.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
neq (bool): Se ``True``, ogni condizione viene negata con ``NOT``.
|
|
80
|
+
Default: ``False``.
|
|
81
|
+
where (dict[str, Any]): Filtri ``{nome_colonna: valore}``.
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
list[ColumnElement]: Una condizione per ogni chiave, nell'ordine del
|
|
85
|
+
dizionario.
|
|
86
|
+
|
|
87
|
+
Raises:
|
|
88
|
+
KeyError: Se una chiave non corrisponde a nessuna colonna della tabella.
|
|
89
|
+
"""
|
|
90
|
+
return [self._handle(column=self.columns[column], condition=condition, neq=neq) for column, condition in where.items()]
|
|
91
|
+
|
|
65
92
|
def _handle(self, column: Column, condition: Any, neq: bool = False):
|
|
66
93
|
"""Costruisce la singola condizione WHERE per una colonna.
|
|
67
94
|
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
__all__ = ["ILikeHandler"]
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from sqlalchemy import Column, ColumnElement, or_
|
|
6
|
+
from sqlalchemy.sql.base import ReadOnlyColumnCollection
|
|
7
|
+
|
|
8
|
+
from .base_handler import BaseHandler
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ILikeHandler(BaseHandler):
|
|
12
|
+
"""Handler per ricerche case-insensitive (ILIKE) su più colonne tramite un alias.
|
|
13
|
+
|
|
14
|
+
Permette di esporre un singolo parametro di ricerca testuale (es. ``"search"``)
|
|
15
|
+
che viene applicato come ``ILIKE '%value%'`` su un insieme di colonne con ``OR``.
|
|
16
|
+
|
|
17
|
+
Attributes:
|
|
18
|
+
ilike_aliases (list[str]): Chiavi nel dizionario ``where`` che attivano
|
|
19
|
+
la ricerca ILIKE (es. ``["search", "q"]``).
|
|
20
|
+
ilike_attributes (list[Column]): Colonne su cui applicare ILIKE, già
|
|
21
|
+
risolte in fase di inizializzazione dai nomi passati al costruttore.
|
|
22
|
+
|
|
23
|
+
Example:
|
|
24
|
+
>>> handler = ILikeHandler(
|
|
25
|
+
... columns=User.columns(),
|
|
26
|
+
... ilike_aliases=["search"],
|
|
27
|
+
... ilike_attributes=["name", "surname"]
|
|
28
|
+
... )
|
|
29
|
+
>>> repo.find(where={"search": "mario"})
|
|
30
|
+
# WHERE name ILIKE '%mario%' OR surname ILIKE '%mario%'
|
|
31
|
+
"""
|
|
32
|
+
__slots__ = "ilike_aliases", "ilike_attributes"
|
|
33
|
+
ilike_aliases: list[str]
|
|
34
|
+
ilike_attributes: list[Column]
|
|
35
|
+
|
|
36
|
+
def __init__(self, columns: ReadOnlyColumnCollection[str, Column[Any]], ilike_aliases: list[str] = None, ilike_attributes: list[str] = None):
|
|
37
|
+
"""Inizializza l'handler risolvendo subito gli ``ilike_attributes`` in colonne.
|
|
38
|
+
|
|
39
|
+
Gli attributi vengono tradotti una volta sola in oggetti ``Column``, così a
|
|
40
|
+
ogni richiesta non si paga la risoluzione per nome: è la parte di
|
|
41
|
+
precalcolo dell'handler.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
columns (ReadOnlyColumnCollection): Colonne della tabella.
|
|
45
|
+
ilike_aliases (list[str], optional): Chiavi alias che attivano la ricerca.
|
|
46
|
+
Default: lista vuota.
|
|
47
|
+
ilike_attributes (list[str], optional): Colonne su cui applicare ILIKE.
|
|
48
|
+
Default: lista vuota.
|
|
49
|
+
|
|
50
|
+
Raises:
|
|
51
|
+
KeyError: Se uno degli ``ilike_attributes`` non è una colonna della tabella.
|
|
52
|
+
La risoluzione avviene qui, quindi l'errore è immediato e non alla
|
|
53
|
+
prima query.
|
|
54
|
+
"""
|
|
55
|
+
super().__init__(columns)
|
|
56
|
+
self.ilike_aliases = ilike_aliases if ilike_aliases is not None else []
|
|
57
|
+
self.ilike_attributes = [self.columns[ilike_attribute] for ilike_attribute in ilike_attributes] if ilike_attributes is not None else []
|
|
58
|
+
|
|
59
|
+
def conditions(self, neq: bool = False, where: dict[str, Any] = None) -> list[ColumnElement[Any]]:
|
|
60
|
+
"""Costruisce una condizione ``OR`` di ``ILIKE`` per ogni alias valorizzato in ``where``.
|
|
61
|
+
|
|
62
|
+
Per ogni alias in ``ilike_aliases`` estrae il valore dal ``where``
|
|
63
|
+
**rimuovendolo** (anche quando non produce nessuna condizione, vedi sotto) e
|
|
64
|
+
genera un ``ILIKE '%valore%'`` su tutte le colonne di ``ilike_attributes``,
|
|
65
|
+
unite da ``OR``. Ogni alias produce una condizione a sé, quindi più alias
|
|
66
|
+
valorizzati si combinano in ``AND``.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
neq (bool): Ignorato. La negazione non è implementata: con ``neq=True``
|
|
70
|
+
viene generata comunque la condizione **positiva**. Riguarda il
|
|
71
|
+
``not_where`` di ``GeometryRepository``.
|
|
72
|
+
where (dict[str, Any]): Filtri. Gli alias gestiti vengono rimossi in-place.
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
list[ColumnElement]: Una condizione per ogni alias valorizzato.
|
|
76
|
+
|
|
77
|
+
Note:
|
|
78
|
+
I valori falsy (``None``, ``""``, ``0``) non producono nessuna condizione,
|
|
79
|
+
ma la chiave viene **comunque rimossa** dal ``where``: una ricerca con la
|
|
80
|
+
stringa vuota equivale a nessun filtro.
|
|
81
|
+
"""
|
|
82
|
+
conditions = []
|
|
83
|
+
for alias in self.ilike_aliases:
|
|
84
|
+
condition = where.pop(alias, None)
|
|
85
|
+
if condition:
|
|
86
|
+
or_conditions = [self._handle(column=ilike_column, condition=condition, neq=neq) for ilike_column in self.ilike_attributes]
|
|
87
|
+
conditions.append(or_(*or_conditions))
|
|
88
|
+
return conditions
|
|
89
|
+
|
|
90
|
+
def _handle(self, column: Column, condition: Any, neq: bool = False):
|
|
91
|
+
"""Costruisce l'``ILIKE`` su una singola colonna, con il valore fra due ``%``.
|
|
92
|
+
|
|
93
|
+
Sovrascrive la logica di ``BaseHandler._handle`` (uguaglianza / ``IN`` /
|
|
94
|
+
``ARRAY``): qui il confronto è sempre una ricerca testuale parziale.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
column (Column): Colonna (o espressione, es. una chiave JSON già estratta
|
|
98
|
+
da ``JsonILikeHandler``) su cui applicare l'``ILIKE``.
|
|
99
|
+
condition (Any): Valore da cercare; viene interpolato in ``%valore%``.
|
|
100
|
+
neq (bool): Ignorato, vedi ``conditions``.
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
ColumnElement: Espressione ``column ILIKE '%valore%'``.
|
|
104
|
+
"""
|
|
105
|
+
return column.ilike(f'%{condition}%')
|