python-general-be-lib 0.7.2__tar.gz → 0.8.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.8.1/PKG-INFO +74 -0
- python_general_be_lib-0.8.1/README.md +39 -0
- python_general_be_lib-0.8.1/general/exception/access_exceptions.py +23 -0
- python_general_be_lib-0.8.1/general/exception/crud_exceptions.py +90 -0
- python_general_be_lib-0.8.1/general/exception/exception_interface.py +38 -0
- {python_general_be_lib-0.7.2 → python_general_be_lib-0.8.1}/general/interface/base/declarative_base.py +15 -5
- {python_general_be_lib-0.7.2 → python_general_be_lib-0.8.1}/general/interface/metadata/crud_metadata.py +11 -2
- {python_general_be_lib-0.7.2 → python_general_be_lib-0.8.1}/general/interface/metadata/geom_metadata.py +4 -1
- {python_general_be_lib-0.7.2 → python_general_be_lib-0.8.1}/general/interface/repository/crud_repository.py +107 -27
- {python_general_be_lib-0.7.2 → python_general_be_lib-0.8.1}/general/interface/repository/geometry_repository.py +20 -23
- {python_general_be_lib-0.7.2 → python_general_be_lib-0.8.1}/general/interface/repository/handler/base_handler.py +45 -6
- python_general_be_lib-0.8.1/general/interface/repository/handler/ilike_handler.py +105 -0
- python_general_be_lib-0.8.1/general/interface/repository/handler/interval_handler.py +143 -0
- python_general_be_lib-0.8.1/general/interface/repository/handler/json_handler.py +93 -0
- python_general_be_lib-0.8.1/general/interface/repository/handler/json_ilike_handler.py +100 -0
- python_general_be_lib-0.8.1/general/interface/repository/handler/or_handler.py +103 -0
- {python_general_be_lib-0.7.2 → python_general_be_lib-0.8.1}/general/interface/repository/handler/statement_manager.py +61 -10
- {python_general_be_lib-0.7.2 → python_general_be_lib-0.8.1}/general/interface/repository/many_to_many_repository.py +10 -0
- {python_general_be_lib-0.7.2 → python_general_be_lib-0.8.1}/general/interface/repository/view_repository.py +10 -6
- {python_general_be_lib-0.7.2 → python_general_be_lib-0.8.1}/general/logger.py +45 -12
- {python_general_be_lib-0.7.2 → python_general_be_lib-0.8.1}/pyproject.toml +12 -1
- python_general_be_lib-0.8.1/python_general_be_lib.egg-info/PKG-INFO +74 -0
- {python_general_be_lib-0.7.2 → python_general_be_lib-0.8.1}/python_general_be_lib.egg-info/SOURCES.txt +9 -1
- {python_general_be_lib-0.7.2 → python_general_be_lib-0.8.1}/python_general_be_lib.egg-info/requires.txt +3 -0
- {python_general_be_lib-0.7.2 → python_general_be_lib-0.8.1}/setup.py +1 -1
- python_general_be_lib-0.8.1/tests/test_caricamento_parziale.py +236 -0
- python_general_be_lib-0.8.1/tests/test_compiled_cache.py +167 -0
- python_general_be_lib-0.8.1/tests/test_logger.py +365 -0
- python_general_be_lib-0.8.1/tests/test_pipeline_handler.py +358 -0
- python_general_be_lib-0.8.1/tests/test_query_count.py +275 -0
- python_general_be_lib-0.8.1/tests/test_smoke_crud.py +304 -0
- python_general_be_lib-0.8.1/tests/test_sql_snapshot.py +320 -0
- python_general_be_lib-0.8.1/tests/test_view_repository.py +271 -0
- python_general_be_lib-0.7.2/PKG-INFO +0 -33
- python_general_be_lib-0.7.2/README.md +0 -1
- python_general_be_lib-0.7.2/general/exception/access_exceptions.py +0 -11
- python_general_be_lib-0.7.2/general/exception/crud_exceptions.py +0 -37
- python_general_be_lib-0.7.2/general/exception/exception_interface.py +0 -10
- python_general_be_lib-0.7.2/general/interface/repository/handler/ilike_handler.py +0 -90
- python_general_be_lib-0.7.2/general/interface/repository/handler/interval_handler.py +0 -92
- python_general_be_lib-0.7.2/general/interface/repository/handler/json_handler.py +0 -71
- python_general_be_lib-0.7.2/general/interface/repository/handler/json_ilike_handler.py +0 -104
- python_general_be_lib-0.7.2/general/interface/repository/handler/or_handler.py +0 -90
- python_general_be_lib-0.7.2/python_general_be_lib.egg-info/PKG-INFO +0 -33
- {python_general_be_lib-0.7.2 → python_general_be_lib-0.8.1}/LICENSE +0 -0
- {python_general_be_lib-0.7.2 → python_general_be_lib-0.8.1}/general/__init__.py +0 -0
- {python_general_be_lib-0.7.2 → python_general_be_lib-0.8.1}/general/exception/__init__.py +0 -0
- {python_general_be_lib-0.7.2 → python_general_be_lib-0.8.1}/general/interface/base/__init__.py +0 -0
- {python_general_be_lib-0.7.2 → python_general_be_lib-0.8.1}/general/interface/base/base_model.py +0 -0
- {python_general_be_lib-0.7.2 → python_general_be_lib-0.8.1}/general/interface/metadata/__init__.py +0 -0
- {python_general_be_lib-0.7.2 → python_general_be_lib-0.8.1}/general/interface/repository/__init__.py +0 -0
- {python_general_be_lib-0.7.2 → python_general_be_lib-0.8.1}/general/interface/repository/handler/__init__.py +0 -0
- {python_general_be_lib-0.7.2 → python_general_be_lib-0.8.1}/general/paginator_dto.py +0 -0
- {python_general_be_lib-0.7.2 → python_general_be_lib-0.8.1}/python_general_be_lib.egg-info/dependency_links.txt +0 -0
- {python_general_be_lib-0.7.2 → python_general_be_lib-0.8.1}/python_general_be_lib.egg-info/top_level.txt +0 -0
- {python_general_be_lib-0.7.2 → python_general_be_lib-0.8.1}/setup.cfg +0 -0
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: python-general-be-lib
|
|
3
|
+
Version: 0.8.1
|
|
4
|
+
Summary: General purpose backend library — SQLAlchemy CRUD/Geometry repositories, FastAPI exceptions, Pydantic base models, logger utilities.
|
|
5
|
+
Author-email: Andrea Di Placido <a.diplacido@arpes.it>, "Arpes S.r.l." <it.admin@arpes.it>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/Arpes-IS/python-general-be-lib
|
|
8
|
+
Project-URL: Repository, https://github.com/Arpes-IS/python-general-be-lib
|
|
9
|
+
Keywords: backend,sqlalchemy,fastapi,pydantic,repository,crud,geoalchemy2
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
16
|
+
Classifier: Topic :: Database
|
|
17
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
18
|
+
Requires-Python: >=3.12
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
License-File: LICENSE
|
|
21
|
+
Requires-Dist: fastapi>=0.100.0
|
|
22
|
+
Requires-Dist: pydantic>=2.0.0
|
|
23
|
+
Requires-Dist: sqlalchemy>=2.0.0
|
|
24
|
+
Requires-Dist: geoalchemy2>=0.14.0
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: pytest>=7; extra == "dev"
|
|
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"
|
|
31
|
+
Requires-Dist: build; extra == "dev"
|
|
32
|
+
Requires-Dist: twine; extra == "dev"
|
|
33
|
+
Dynamic: license-file
|
|
34
|
+
Dynamic: requires-python
|
|
35
|
+
|
|
36
|
+
# python-general-be-lib
|
|
37
|
+
|
|
38
|
+
Libreria backend general purpose per progetti Python: repository CRUD e spaziali
|
|
39
|
+
su **SQLAlchemy** / **GeoAlchemy2**, eccezioni **FastAPI**, modelli base
|
|
40
|
+
**Pydantic** e utility di logging.
|
|
41
|
+
|
|
42
|
+
- Distribuzione PyPI: `python-general-be-lib`
|
|
43
|
+
- Pacchetto importabile: `general`
|
|
44
|
+
- Python richiesto: **3.12+**
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
pip install python-general-be-lib
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
from sqlalchemy import create_engine
|
|
52
|
+
|
|
53
|
+
from general.interface.repository.crud_repository import CrudRepository
|
|
54
|
+
|
|
55
|
+
engine = create_engine("postgresql://user:pass@localhost/db")
|
|
56
|
+
repo = CrudRepository(entity=User, model=UserModel, engine=engine)
|
|
57
|
+
|
|
58
|
+
repo.insert(models=[UserModel(name="Mario", email="mario@example.com")])
|
|
59
|
+
repo.find(active=True, order_by="name", asc=True)
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
---
|
|
63
|
+
|
|
64
|
+
## Cosa contiene
|
|
65
|
+
|
|
66
|
+
| Area | Componenti |
|
|
67
|
+
|---|---|
|
|
68
|
+
| Repository ORM | `CrudRepository`, `GeometryRepository`, `ManyToManyRepository`, `ViewRepository` |
|
|
69
|
+
| Layer Core (senza ORM) | `CrudMetadata`, `GeomMetadata` — CRUD e query spaziali su `Table` riflesse |
|
|
70
|
+
| Motore di filtraggio | `StatementManager` + `BaseHandler`, `JsonHandler`, `ILikeHandler`, `JsonILikeHandler`, `OrHandler`, `IntervalHandler` |
|
|
71
|
+
| Basi | `Base` (declarative base estesa), `ExtBaseModel`, `CamelExtBaseModel` |
|
|
72
|
+
| Paginazione | `PaginatorRequestModel`, `PaginatorResponseModel` |
|
|
73
|
+
| Eccezioni | `ExceptionInterface` e sottoclassi HTTP (400/401/403/404/422/500/503) |
|
|
74
|
+
| Logging | `init_logger`, decorator `@log` |
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# python-general-be-lib
|
|
2
|
+
|
|
3
|
+
Libreria backend general purpose per progetti Python: repository CRUD e spaziali
|
|
4
|
+
su **SQLAlchemy** / **GeoAlchemy2**, eccezioni **FastAPI**, modelli base
|
|
5
|
+
**Pydantic** e utility di logging.
|
|
6
|
+
|
|
7
|
+
- Distribuzione PyPI: `python-general-be-lib`
|
|
8
|
+
- Pacchetto importabile: `general`
|
|
9
|
+
- Python richiesto: **3.12+**
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pip install python-general-be-lib
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
from sqlalchemy import create_engine
|
|
17
|
+
|
|
18
|
+
from general.interface.repository.crud_repository import CrudRepository
|
|
19
|
+
|
|
20
|
+
engine = create_engine("postgresql://user:pass@localhost/db")
|
|
21
|
+
repo = CrudRepository(entity=User, model=UserModel, engine=engine)
|
|
22
|
+
|
|
23
|
+
repo.insert(models=[UserModel(name="Mario", email="mario@example.com")])
|
|
24
|
+
repo.find(active=True, order_by="name", asc=True)
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
## Cosa contiene
|
|
30
|
+
|
|
31
|
+
| Area | Componenti |
|
|
32
|
+
|---|---|
|
|
33
|
+
| Repository ORM | `CrudRepository`, `GeometryRepository`, `ManyToManyRepository`, `ViewRepository` |
|
|
34
|
+
| Layer Core (senza ORM) | `CrudMetadata`, `GeomMetadata` — CRUD e query spaziali su `Table` riflesse |
|
|
35
|
+
| Motore di filtraggio | `StatementManager` + `BaseHandler`, `JsonHandler`, `ILikeHandler`, `JsonILikeHandler`, `OrHandler`, `IntervalHandler` |
|
|
36
|
+
| Basi | `Base` (declarative base estesa), `ExtBaseModel`, `CamelExtBaseModel` |
|
|
37
|
+
| Paginazione | `PaginatorRequestModel`, `PaginatorResponseModel` |
|
|
38
|
+
| Eccezioni | `ExceptionInterface` e sottoclassi HTTP (400/401/403/404/422/500/503) |
|
|
39
|
+
| Logging | `init_logger`, decorator `@log` |
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Eccezioni HTTP per autenticazione e autorizzazione.
|
|
2
|
+
|
|
3
|
+
A differenza di ``crud_exceptions``, queste eccezioni non sono sollevate
|
|
4
|
+
automaticamente dalla libreria: sono a disposizione dei layer di autenticazione
|
|
5
|
+
e autorizzazione dei progetti che la usano.
|
|
6
|
+
|
|
7
|
+
Il package ``general.exception`` non re-esporta questi simboli: vanno importati
|
|
8
|
+
dal modulo, ``from general.exception.access_exceptions import ForbiddenException``.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from .exception_interface import ExceptionInterface
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ForbiddenException(ExceptionInterface):
|
|
15
|
+
"""403 — Utente autenticato ma senza i permessi per la risorsa richiesta."""
|
|
16
|
+
status_code = 403
|
|
17
|
+
default_message = "Risorsa non accessibile da questo utente"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class UnauthorizedException(ExceptionInterface):
|
|
21
|
+
"""401 — Richiesta priva di credenziali valide."""
|
|
22
|
+
status_code = 401
|
|
23
|
+
default_message = "Non autorizzato"
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Eccezioni HTTP per le operazioni di persistenza.
|
|
2
|
+
|
|
3
|
+
Sono le eccezioni su cui ``CrudRepository._handle_exception`` mappa gli errori
|
|
4
|
+
SQLAlchemy:
|
|
5
|
+
|
|
6
|
+
- ``OperationalError`` → ``ServiceUnavailableException`` (503)
|
|
7
|
+
- ``NoResultFound`` → ``EntityNotFoundException`` (404)
|
|
8
|
+
- ``DataError`` | ``IntegrityError`` → ``BadRequestException`` (400)
|
|
9
|
+
|
|
10
|
+
Il package ``general.exception`` non re-esporta questi simboli: vanno importati
|
|
11
|
+
dal modulo, ``from general.exception.crud_exceptions import BadRequestException``.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from .exception_interface import ExceptionInterface
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class BadRequestException(ExceptionInterface):
|
|
18
|
+
"""400 — Dati della richiesta non validi o vincolo di integrità violato.
|
|
19
|
+
|
|
20
|
+
Sollevata al posto di ``DataError`` e ``IntegrityError`` (es. violazione di
|
|
21
|
+
una foreign key, di un unique o di un NOT NULL).
|
|
22
|
+
"""
|
|
23
|
+
status_code = 400
|
|
24
|
+
default_message = "Richiesta non corretta"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class EntityNotFoundException(ExceptionInterface):
|
|
28
|
+
"""404 — Nessun record corrisponde ai criteri richiesti.
|
|
29
|
+
|
|
30
|
+
Sollevata al posto di ``NoResultFound``.
|
|
31
|
+
"""
|
|
32
|
+
status_code = 404
|
|
33
|
+
default_message = "Dato non trovato"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ServiceUnavailableException(ExceptionInterface):
|
|
37
|
+
"""503 — Database non raggiungibile.
|
|
38
|
+
|
|
39
|
+
Sollevata al posto di ``OperationalError`` (connessione caduta, pool esaurito,
|
|
40
|
+
server non in ascolto).
|
|
41
|
+
"""
|
|
42
|
+
status_code = 503
|
|
43
|
+
default_message = "Servizio momentaneamente non disponibile, riprovare più tardi"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class UnprocessableEntityException(ExceptionInterface):
|
|
47
|
+
"""422 — Entità sintatticamente valida ma non processabile.
|
|
48
|
+
|
|
49
|
+
Non è sollevata automaticamente dai repository: è a disposizione dei service
|
|
50
|
+
per gli errori di validazione applicativa.
|
|
51
|
+
"""
|
|
52
|
+
status_code = 422
|
|
53
|
+
default_message = "Entità non processabile"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class HasNoAttributeException(ExceptionInterface):
|
|
57
|
+
"""400 — Colonna o attributo non presente sull'entità.
|
|
58
|
+
|
|
59
|
+
Sollevata quando un nome di colonna passato a ``columns``, ``exclude_columns``
|
|
60
|
+
o ``order_by`` non esiste sull'entità.
|
|
61
|
+
|
|
62
|
+
Example:
|
|
63
|
+
>>> raise HasNoAttributeException("cognome")
|
|
64
|
+
# detail: "Richiesta non corretta - Attributo cognome non presente"
|
|
65
|
+
"""
|
|
66
|
+
status_code = 400
|
|
67
|
+
default_message = "Richiesta non corretta"
|
|
68
|
+
|
|
69
|
+
def __init__(self, attr: str = ""):
|
|
70
|
+
"""Compone il dettaglio in base alla presenza del nome dell'attributo.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
attr (str): Nome dell'attributo mancante. Se omesso, viene usato un
|
|
74
|
+
messaggio generico che non identifica la colonna.
|
|
75
|
+
"""
|
|
76
|
+
if attr:
|
|
77
|
+
super().__init__(custom_message=f"Attributo {attr} non presente", )
|
|
78
|
+
else:
|
|
79
|
+
super().__init__(custom_message="Alcuni attributi non sono presenti per questa entità", )
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class InternalServerException(ExceptionInterface):
|
|
83
|
+
"""500 — Errore interno non previsto.
|
|
84
|
+
|
|
85
|
+
Usata come fallback dal decorator ``@log`` per ogni eccezione che non sia già
|
|
86
|
+
una ``HTTPException``, e da ``CrudMetadata.get_table`` per una tabella
|
|
87
|
+
inesistente.
|
|
88
|
+
"""
|
|
89
|
+
status_code = 500
|
|
90
|
+
default_message = "Impossibile gestire la richiesta al momento"
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from fastapi import HTTPException
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class ExceptionInterface(HTTPException):
|
|
5
|
+
"""Interfaccia base per tutte le eccezioni della libreria.
|
|
6
|
+
|
|
7
|
+
Estende ``fastapi.HTTPException`` spostando status code e messaggio su
|
|
8
|
+
**attributi di classe**: ogni sottoclasse dichiara il proprio ``status_code``
|
|
9
|
+
e ``default_message`` e può quindi essere sollevata senza argomenti.
|
|
10
|
+
|
|
11
|
+
Sollevata dentro un endpoint FastAPI, viene serializzata automaticamente nella
|
|
12
|
+
risposta di errore con lo status code dichiarato.
|
|
13
|
+
|
|
14
|
+
Attributes:
|
|
15
|
+
status_code (int): Status code HTTP della risposta.
|
|
16
|
+
default_message (str): Messaggio di default usato come ``detail``.
|
|
17
|
+
|
|
18
|
+
Example:
|
|
19
|
+
>>> class TeapotException(ExceptionInterface):
|
|
20
|
+
... status_code = 418
|
|
21
|
+
... default_message = "Sono una teiera"
|
|
22
|
+
>>> raise TeapotException("niente caffè")
|
|
23
|
+
# detail: "Sono una teiera - niente caffè"
|
|
24
|
+
"""
|
|
25
|
+
status_code: int
|
|
26
|
+
default_message: str
|
|
27
|
+
|
|
28
|
+
def __init__(self, custom_message: str = None):
|
|
29
|
+
"""Compone il ``detail`` a partire dal messaggio di default e da quello custom.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
custom_message (str, optional): Dettaglio aggiuntivo, accodato al
|
|
33
|
+
``default_message`` nella forma
|
|
34
|
+
``"{default_message} - {custom_message}"``. Se omesso, il ``detail``
|
|
35
|
+
è il solo ``default_message``.
|
|
36
|
+
"""
|
|
37
|
+
detail = f"{self.default_message} - {custom_message}" if custom_message else self.default_message
|
|
38
|
+
super().__init__(status_code=self.status_code, detail=detail)
|
|
@@ -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,16 @@ class Base:
|
|
|
44
44
|
if not default_values:
|
|
45
45
|
default_values = dict()
|
|
46
46
|
mapper: Mapper = inspect(cls)
|
|
47
|
+
|
|
48
|
+
column_properties = [prop for prop in mapper.column_attrs if include_only is None or prop.key in include_only]
|
|
49
|
+
|
|
47
50
|
model_name = f"{cls.__name__}Model" if not model_name else model_name
|
|
48
51
|
fields = dict()
|
|
49
|
-
field_values = {
|
|
52
|
+
field_values = {prop.key: None if full_optional else ... for prop in column_properties}
|
|
50
53
|
field_values.update(default_values)
|
|
51
|
-
for
|
|
52
|
-
|
|
54
|
+
for prop in column_properties:
|
|
55
|
+
col = prop.columns[0]
|
|
56
|
+
field_name = prop.key
|
|
53
57
|
if isinstance(col.type, Geometry):
|
|
54
58
|
field_type = Optional[WKBElement] if col.nullable or full_optional else WKBElement
|
|
55
59
|
else:
|
|
@@ -111,6 +115,9 @@ class Base:
|
|
|
111
115
|
def retrieve_mapper_entity(entity_name: str):
|
|
112
116
|
"""Cerca e restituisce una sottoclasse di ``Base`` per nome della classe.
|
|
113
117
|
|
|
118
|
+
Itera su ``Base.__subclasses__()``: trova quindi solo le sottoclassi **dirette**
|
|
119
|
+
di ``Base``, non quelle annidate in ulteriori livelli di ereditarietà.
|
|
120
|
+
|
|
114
121
|
Args:
|
|
115
122
|
entity_name (str): Nome della classe ORM da cercare (es. ``"User"``).
|
|
116
123
|
|
|
@@ -129,6 +136,9 @@ def retrieve_mapper_entity(entity_name: str):
|
|
|
129
136
|
def retrieve_mapper_from_table(table_name: str):
|
|
130
137
|
"""Cerca e restituisce una sottoclasse di ``Base`` per nome della tabella.
|
|
131
138
|
|
|
139
|
+
Itera su ``Base.__subclasses__()``: trova quindi solo le sottoclassi **dirette**
|
|
140
|
+
di ``Base``, non quelle annidate in ulteriori livelli di ereditarietà.
|
|
141
|
+
|
|
132
142
|
Args:
|
|
133
143
|
table_name (str): Nome della tabella nel database (es. ``"users"``).
|
|
134
144
|
|
|
@@ -23,6 +23,14 @@ class CrudMetadata:
|
|
|
23
23
|
chiusura; altrimenti la connessione viene aperta, committata e chiusa
|
|
24
24
|
internamente.
|
|
25
25
|
|
|
26
|
+
Differenze rispetto a ``CrudRepository``:
|
|
27
|
+
- **non** usa la pipeline degli handler: i filtri passano direttamente da
|
|
28
|
+
``_eq_where``, quindi non c'è supporto per JSON, ILIKE, intervalli o alias;
|
|
29
|
+
- le eccezioni SQLAlchemy **non** vengono mappate su eccezioni HTTP: vengono
|
|
30
|
+
loggate e rilanciate tal quali (vedi ``_handle_exception``);
|
|
31
|
+
- la serializzazione avviene con ``parsing_model(**row)`` sui mapping delle
|
|
32
|
+
righe, non con ``model_validate`` su entità ORM.
|
|
33
|
+
|
|
26
34
|
Attributes:
|
|
27
35
|
schema (str | None): Nome dello schema di default per le tabelle.
|
|
28
36
|
metadata (MetaData): Contenitore SQLAlchemy delle tabelle riflesse/definite.
|
|
@@ -483,9 +491,10 @@ class CrudMetadata:
|
|
|
483
491
|
def _eq_where(self, col: Column, condition, neq: bool = False):
|
|
484
492
|
"""Costruisce la singola condizione WHERE per una colonna.
|
|
485
493
|
|
|
486
|
-
Logica applicata:
|
|
494
|
+
Logica applicata (stessa di ``BaseHandler._handle``):
|
|
495
|
+
- Colonna ``ARRAY`` + ``list`` → ``col && ARRAY[values]`` (overlap)
|
|
496
|
+
- Colonna ``ARRAY`` + scalare → ``value = ANY(col)``
|
|
487
497
|
- ``list`` → ``col IN (values)``
|
|
488
|
-
- Tipo ``ARRAY`` → ``value = ANY(col)``
|
|
489
498
|
- Scalare → ``col == value``
|
|
490
499
|
|
|
491
500
|
Args:
|
|
@@ -19,7 +19,10 @@ class GeomMetadata(CrudMetadata):
|
|
|
19
19
|
della tabella.
|
|
20
20
|
|
|
21
21
|
Gestisce la riproiezione automatica tra SRID diversi e il buffering opzionale
|
|
22
|
-
della geometria di input.
|
|
22
|
+
della geometria di input. Il parametro ``tolerance`` è sempre un **buffer**
|
|
23
|
+
(``ST_Buffer``) applicato alla geometria di input: a differenza di
|
|
24
|
+
``GeometryRepository.intersects``, che con ``tolerance > 0`` passa a
|
|
25
|
+
``ST_DWithin``, qui il predicato resta ``ST_Intersects``.
|
|
23
26
|
|
|
24
27
|
Example:
|
|
25
28
|
>>> meta = GeomMetadata(engine=engine, schema_name="public")
|
|
@@ -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,23 @@ 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
|
+
ordinate = [prop.key for prop in inspect(entity).column_attrs if prop.key in columns]
|
|
45
|
+
digest = hashlib.sha1("|".join(ordinate).encode()).hexdigest()[:8]
|
|
46
|
+
return entity.to_pydantic(
|
|
47
|
+
model_name=f"{entity.__name__}_{digest}",
|
|
48
|
+
full_optional=True,
|
|
49
|
+
include_only=ordinate,
|
|
50
|
+
)
|
|
51
|
+
|
|
34
52
|
|
|
35
53
|
class CrudRepository[Entity: Base | Type[Base]]:
|
|
36
54
|
"""Repository ORM generico con operazioni CRUD complete su una singola entità SQLAlchemy.
|
|
@@ -109,6 +127,17 @@ class CrudRepository[Entity: Base | Type[Base]]:
|
|
|
109
127
|
|
|
110
128
|
# -------------------------
|
|
111
129
|
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _retrieve_parsing_model(self, columns=None, exclude_columns=None):
|
|
134
|
+
selected_columns = set(columns) if columns else set(self.entity.__mapper__.attrs.keys()) # o columns_names corretto
|
|
135
|
+
if exclude_columns:
|
|
136
|
+
selected_columns -= set(exclude_columns)
|
|
137
|
+
if columns is None and not exclude_columns:
|
|
138
|
+
return self.model
|
|
139
|
+
return _reduced_model(self.entity, frozenset(selected_columns))
|
|
140
|
+
|
|
112
141
|
def _handle_exception(self, session: Session, e: Exception):
|
|
113
142
|
"""Chiude la sessione, logga l'errore e rilancia un'eccezione HTTP-friendly.
|
|
114
143
|
|
|
@@ -179,8 +208,9 @@ class CrudRepository[Entity: Base | Type[Base]]:
|
|
|
179
208
|
|
|
180
209
|
Args:
|
|
181
210
|
*columns (str): Nomi delle colonne da verificare.
|
|
182
|
-
entity (Type[Base]
|
|
183
|
-
|
|
211
|
+
entity (Type[Base]): Entità su cui verificare. Obbligatoria nei fatti:
|
|
212
|
+
pur avendo default ``None``, il metodo non applica nessun fallback
|
|
213
|
+
a ``self.entity`` e con ``None`` solleva ``AttributeError``.
|
|
184
214
|
|
|
185
215
|
Returns:
|
|
186
216
|
bool: ``True`` se tutte le colonne sono presenti, ``False`` altrimenti.
|
|
@@ -210,9 +240,9 @@ class CrudRepository[Entity: Base | Type[Base]]:
|
|
|
210
240
|
stmt = select(entity)
|
|
211
241
|
if columns:
|
|
212
242
|
entity_columns = {column for column in columns if column not in self.relationship_attributes}
|
|
213
|
-
stmt = stmt.options(load_only(*[getattr(
|
|
243
|
+
stmt = stmt.options(load_only(*[getattr(entity, column) for column in entity_columns]))
|
|
214
244
|
if exclude_columns:
|
|
215
|
-
stmt = stmt.options(defer(
|
|
245
|
+
stmt = stmt.options(*[defer(getattr(entity, column)) for column in exclude_columns])
|
|
216
246
|
except AttributeError:
|
|
217
247
|
raise HasNoAttributeException()
|
|
218
248
|
return stmt
|
|
@@ -301,6 +331,13 @@ class CrudRepository[Entity: Base | Type[Base]]:
|
|
|
301
331
|
list[Entity]: Lista di modelli serializzati (o entità grezze se
|
|
302
332
|
sessione esterna).
|
|
303
333
|
|
|
334
|
+
Raises:
|
|
335
|
+
HasNoAttributeException: Se una colonna in ``columns``,
|
|
336
|
+
``exclude_columns`` o ``order_by`` non esiste sull'entità.
|
|
337
|
+
|
|
338
|
+
Note:
|
|
339
|
+
Essendo una lettura pura non esegue nessun commit (``commit=False``).
|
|
340
|
+
|
|
304
341
|
Example:
|
|
305
342
|
>>> repo.find(active=True, limit=10)
|
|
306
343
|
>>> repo.find(where={"role": ["admin", "editor"]})
|
|
@@ -310,6 +347,7 @@ class CrudRepository[Entity: Base | Type[Base]]:
|
|
|
310
347
|
keep_open = False if session is None else True
|
|
311
348
|
session = self.open_session if session is None else session
|
|
312
349
|
entities = self._find(session=session, where=where, columns=columns, exclude_columns=exclude_columns, limit=limit, order_by=order_by, asc=asc, **kwhere)
|
|
350
|
+
parsing_model = parsing_model if parsing_model is not None else self._retrieve_parsing_model(columns=columns, exclude_columns=exclude_columns)
|
|
313
351
|
return self._return(session=session, entities=entities, keep_open=keep_open, commit=False, parsing_model=parsing_model)
|
|
314
352
|
|
|
315
353
|
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,
|
|
@@ -575,47 +613,67 @@ class CrudRepository[Entity: Base | Type[Base]]:
|
|
|
575
613
|
return count_
|
|
576
614
|
|
|
577
615
|
def _count(self, session: Session, where: dict[str, Any] = None, **kwhere):
|
|
578
|
-
pk = self.entity.primary_key()[0]
|
|
579
616
|
if where is None:
|
|
580
617
|
where = kwhere if kwhere else {}
|
|
581
|
-
stmt = select(count(
|
|
618
|
+
stmt = select(count()).select_from(self.entity)
|
|
582
619
|
stmt = self.stmt_manager.prepare_statement(stmt=stmt, **where)
|
|
583
620
|
return session.execute(stmt).scalar()
|
|
584
621
|
|
|
585
|
-
def _preconf_count(self, session: Session, where: Optional[ColumnElement[Any]]):
|
|
586
|
-
"""Conta i record
|
|
622
|
+
def _preconf_count(self, session: Session, where: Optional[list[ColumnElement[Any]]]):
|
|
623
|
+
"""Conta i record a partire da condizioni WHERE già costruite.
|
|
587
624
|
|
|
588
|
-
Usato
|
|
589
|
-
|
|
625
|
+
Usato da ``_paging_find`` quando il totale non è ricavabile dalla pagina
|
|
626
|
+
appena letta: riusa la lista di condizioni prodotta dalla pipeline degli
|
|
627
|
+
handler, così i filtri vengono tradotti una sola volta per chiamata.
|
|
628
|
+
|
|
629
|
+
Conta con ``COUNT(*)`` e non ``COUNT(pk)``: evita il controllo NOT NULL per
|
|
630
|
+
riga a parità di piano di esecuzione. Il ``select_from`` è indispensabile —
|
|
631
|
+
``select(count())`` da solo compilerebbe in un ``SELECT count(*)`` **senza
|
|
632
|
+
FROM**, che restituisce sempre 1.
|
|
590
633
|
|
|
591
634
|
Args:
|
|
592
635
|
session (Session): Sessione corrente.
|
|
593
|
-
where (ColumnElement):
|
|
636
|
+
where (list[ColumnElement] | None): Condizioni già costruite, come le
|
|
637
|
+
restituisce ``StatementManager.conditions``. Una lista vuota o
|
|
638
|
+
``None`` conta tutte le righe della tabella.
|
|
594
639
|
|
|
595
640
|
Returns:
|
|
596
641
|
int: Numero totale di record corrispondenti.
|
|
597
642
|
"""
|
|
598
|
-
|
|
599
|
-
stmt = select(count(pk))
|
|
643
|
+
stmt = select(count()).select_from(self.entity)
|
|
600
644
|
if where is not None:
|
|
601
|
-
stmt = stmt.where(where)
|
|
645
|
+
stmt = stmt.where(*where)
|
|
602
646
|
return session.execute(stmt).scalar()
|
|
603
647
|
|
|
604
648
|
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,
|
|
605
649
|
parsing_model: Type[BaseModel] = None, **kwhere) -> PaginatorResponseModel[Entity]:
|
|
606
650
|
"""Esegue una ricerca paginata con supporto a ordinamento e offset.
|
|
607
651
|
|
|
608
|
-
Calcola il numero totale di pagine
|
|
609
|
-
|
|
652
|
+
Calcola il numero totale di pagine e restituisce solo la pagina richiesta.
|
|
653
|
+
|
|
654
|
+
La query dei dati viene eseguita **per prima**, così il totale può essere
|
|
655
|
+
ricavato dalle righe ottenute ed evitare del tutto la ``COUNT``:
|
|
656
|
+
|
|
657
|
+
- ``limit=0`` (nessuna paginazione) → il totale è il numero di righe;
|
|
658
|
+
- pagina non piena in prima pagina → il totale è il numero di righe;
|
|
659
|
+
- pagina non piena oltre la prima → il totale è ``(page-1)*limit + righe``;
|
|
660
|
+
- pagina **piena**, oppure vuota oltre la prima → il totale non è
|
|
661
|
+
ricavabile e viene eseguita la ``COUNT`` (``_preconf_count``), che riusa le
|
|
662
|
+
condizioni già costruite dalla pipeline degli handler.
|
|
663
|
+
|
|
664
|
+
Il valore derivato è esatto, non stimato: il ``tot_pages`` restituito è
|
|
665
|
+
identico a quello che darebbe la ``COUNT`` in tutti i casi.
|
|
610
666
|
|
|
611
667
|
Args:
|
|
612
668
|
session (Session, optional): Sessione esterna.
|
|
613
669
|
where (dict[str, Any], optional): Filtri come dizionario.
|
|
614
670
|
columns (list[str], optional): Colonne da includere.
|
|
615
671
|
exclude_columns (list[str], optional): Colonne da escludere.
|
|
616
|
-
limit (int): Numero di elementi per pagina. Default: 0 (nessun limite
|
|
672
|
+
limit (int): Numero di elementi per pagina. Default: 0 (nessun limite,
|
|
673
|
+
quindi una sola pagina).
|
|
617
674
|
page (int): Numero di pagina richiesta (1-based). Default: 1.
|
|
618
|
-
order_by (str, optional): Colonna per l'ordinamento.
|
|
675
|
+
order_by (str, optional): Colonna per l'ordinamento. Se ``None``, viene
|
|
676
|
+
usata la prima colonna della chiave primaria.
|
|
619
677
|
asc (bool): Ordinamento crescente se ``True``. Default: ``True``.
|
|
620
678
|
parsing_model (Type[BaseModel], optional): Modello alternativo per
|
|
621
679
|
la serializzazione.
|
|
@@ -633,25 +691,34 @@ class CrudRepository[Entity: Base | Type[Base]]:
|
|
|
633
691
|
keep_open = False if session is None else True
|
|
634
692
|
session = self.open_session if session is None else session
|
|
635
693
|
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)
|
|
694
|
+
parsing_model = parsing_model if parsing_model is not None else self._retrieve_parsing_model(columns=columns, exclude_columns=exclude_columns)
|
|
636
695
|
if not keep_open:
|
|
637
|
-
paginated.data = _list_adapter(parsing_model
|
|
696
|
+
paginated.data = _list_adapter(parsing_model).validate_python(paginated.data, from_attributes=True)
|
|
638
697
|
session.close()
|
|
639
698
|
return paginated
|
|
640
699
|
|
|
641
700
|
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):
|
|
642
701
|
if where is None:
|
|
643
702
|
where = kwhere if kwhere else {}
|
|
644
|
-
stmt = self._select(entity=self.entity, columns=columns, exclude_columns=exclude_columns)
|
|
645
|
-
stmt = self.stmt_manager.prepare_statement(stmt=stmt, **where)
|
|
646
|
-
|
|
647
|
-
row_numbers = self._preconf_count(session=session, where=stmt.whereclause)
|
|
648
703
|
|
|
704
|
+
stmt = self._select(entity=self.entity, columns=columns, exclude_columns=exclude_columns)
|
|
705
|
+
conditions = self.stmt_manager.conditions(where=where)
|
|
649
706
|
order_by = order_by or self.entity.primary_key()[0].name
|
|
707
|
+
if conditions:
|
|
708
|
+
stmt = stmt.where(*conditions)
|
|
650
709
|
stmt = self.stmt_manager.limit_offset_condition(stmt=stmt, limit=limit, page=page)
|
|
651
710
|
stmt = self.stmt_manager.order_by_condition(stmt=stmt, order_by=order_by, asc=asc)
|
|
652
|
-
|
|
653
711
|
entities = self.execute(session, stmt)
|
|
654
|
-
|
|
712
|
+
|
|
713
|
+
len_entities = len(entities)
|
|
714
|
+
if limit == 0 or (len_entities < limit and page == 1):
|
|
715
|
+
count_ = len_entities
|
|
716
|
+
elif limit > len_entities > 0 and page > 1:
|
|
717
|
+
count_ = (page - 1) * limit + len_entities
|
|
718
|
+
else:
|
|
719
|
+
count_ = self._preconf_count(session=session, where=conditions)
|
|
720
|
+
|
|
721
|
+
return self.parse_pagination(limit=limit, page=page, order_by=order_by, count=count_, asc=asc, data=entities)
|
|
655
722
|
|
|
656
723
|
def parse_pagination(self, limit: int, page: int, order_by: str, asc: bool, count: int, data: Sequence[Row] | Sequence[Row | RowMapping] | Sequence[RowMapping]) -> PaginatorResponseModel:
|
|
657
724
|
"""Costruisce il ``PaginatorResponseModel`` dai dati di paginazione.
|
|
@@ -681,8 +748,8 @@ class CrudRepository[Entity: Base | Type[Base]]:
|
|
|
681
748
|
"""Coppie distinte ``(key, value)`` per i record che soddisfano ``where``.
|
|
682
749
|
|
|
683
750
|
Calcola i valori distinti lato DB con ``SELECT DISTINCT`` invece di caricare
|
|
684
|
-
tutte le righe e dedupplicare in Python
|
|
685
|
-
|
|
751
|
+
tutte le righe e dedupplicare in Python. Tipicamente usato per popolare le
|
|
752
|
+
opzioni di un filtro lato client. Per le colonne ``ARRAY`` srotola in
|
|
686
753
|
parallelo chiave ed etichetta con ``unnest`` (lateral implicito).
|
|
687
754
|
|
|
688
755
|
Args:
|
|
@@ -694,6 +761,14 @@ class CrudRepository[Entity: Base | Type[Base]]:
|
|
|
694
761
|
|
|
695
762
|
Returns:
|
|
696
763
|
list[Row]: Coppie ``(key, value)`` distinte.
|
|
764
|
+
|
|
765
|
+
Note:
|
|
766
|
+
A differenza degli altri metodi restituisce ``Row`` (tuple) e non modelli
|
|
767
|
+
Pydantic: non passa da ``_return`` e non accetta un ``parsing_model``.
|
|
768
|
+
|
|
769
|
+
Example:
|
|
770
|
+
>>> repo.distinct_pairs("id_operatore", "operatore", where={"id_azienda": 3})
|
|
771
|
+
[(1, 'Rossi'), (2, 'Bianchi')]
|
|
697
772
|
"""
|
|
698
773
|
if where is None:
|
|
699
774
|
where = kwhere if kwhere else {}
|
|
@@ -723,6 +798,11 @@ class CrudRepository[Entity: Base | Type[Base]]:
|
|
|
723
798
|
|
|
724
799
|
In caso di errore, delega la gestione a ``_handle_exception``.
|
|
725
800
|
|
|
801
|
+
Poiché usa ``.scalars()``, di ogni riga viene restituita solo la **prima**
|
|
802
|
+
colonna: statement arricchiti con ``add_columns`` perdono le colonne
|
|
803
|
+
aggiuntive. Per leggerle serve eseguire lo statement direttamente sulla
|
|
804
|
+
sessione (vedi ``distinct_pairs``, che usa ``session.execute(...).all()``).
|
|
805
|
+
|
|
726
806
|
Args:
|
|
727
807
|
session (Session): Sessione su cui eseguire lo statement.
|
|
728
808
|
stmt (Select | Insert | Update | Delete): Statement da eseguire.
|