python-general-be-lib 0.8.0__py3-none-any.whl → 0.8.1__py3-none-any.whl
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.
- general/interface/base/declarative_base.py +9 -5
- general/interface/repository/crud_repository.py +78 -23
- general/interface/repository/geometry_repository.py +6 -7
- general/interface/repository/handler/base_handler.py +32 -5
- general/interface/repository/handler/ilike_handler.py +46 -31
- general/interface/repository/handler/interval_handler.py +63 -42
- general/interface/repository/handler/json_handler.py +29 -15
- general/interface/repository/handler/json_ilike_handler.py +32 -60
- general/interface/repository/handler/or_handler.py +47 -47
- general/interface/repository/handler/statement_manager.py +50 -12
- general/interface/repository/view_repository.py +10 -6
- general/logger.py +39 -13
- {python_general_be_lib-0.8.0.dist-info → python_general_be_lib-0.8.1.dist-info}/METADATA +4 -38
- {python_general_be_lib-0.8.0.dist-info → python_general_be_lib-0.8.1.dist-info}/RECORD +17 -17
- {python_general_be_lib-0.8.0.dist-info → python_general_be_lib-0.8.1.dist-info}/WHEEL +0 -0
- {python_general_be_lib-0.8.0.dist-info → python_general_be_lib-0.8.1.dist-info}/licenses/LICENSE +0 -0
- {python_general_be_lib-0.8.0.dist-info → python_general_be_lib-0.8.1.dist-info}/top_level.txt +0 -0
|
@@ -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:
|
|
@@ -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
|
|
|
@@ -211,9 +240,9 @@ class CrudRepository[Entity: Base | Type[Base]]:
|
|
|
211
240
|
stmt = select(entity)
|
|
212
241
|
if columns:
|
|
213
242
|
entity_columns = {column for column in columns if column not in self.relationship_attributes}
|
|
214
|
-
stmt = stmt.options(load_only(*[getattr(
|
|
243
|
+
stmt = stmt.options(load_only(*[getattr(entity, column) for column in entity_columns]))
|
|
215
244
|
if exclude_columns:
|
|
216
|
-
stmt = stmt.options(defer(
|
|
245
|
+
stmt = stmt.options(*[defer(getattr(entity, column)) for column in exclude_columns])
|
|
217
246
|
except AttributeError:
|
|
218
247
|
raise HasNoAttributeException()
|
|
219
248
|
return stmt
|
|
@@ -318,6 +347,7 @@ class CrudRepository[Entity: Base | Type[Base]]:
|
|
|
318
347
|
keep_open = False if session is None else True
|
|
319
348
|
session = self.open_session if session is None else session
|
|
320
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)
|
|
321
351
|
return self._return(session=session, entities=entities, keep_open=keep_open, commit=False, parsing_model=parsing_model)
|
|
322
352
|
|
|
323
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,
|
|
@@ -583,40 +613,56 @@ class CrudRepository[Entity: Base | Type[Base]]:
|
|
|
583
613
|
return count_
|
|
584
614
|
|
|
585
615
|
def _count(self, session: Session, where: dict[str, Any] = None, **kwhere):
|
|
586
|
-
pk = self.entity.primary_key()[0]
|
|
587
616
|
if where is None:
|
|
588
617
|
where = kwhere if kwhere else {}
|
|
589
|
-
stmt = select(count(
|
|
618
|
+
stmt = select(count()).select_from(self.entity)
|
|
590
619
|
stmt = self.stmt_manager.prepare_statement(stmt=stmt, **where)
|
|
591
620
|
return session.execute(stmt).scalar()
|
|
592
621
|
|
|
593
|
-
def _preconf_count(self, session: Session, where: Optional[ColumnElement[Any]]):
|
|
594
|
-
"""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.
|
|
595
624
|
|
|
596
|
-
Usato
|
|
597
|
-
|
|
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.
|
|
598
633
|
|
|
599
634
|
Args:
|
|
600
635
|
session (Session): Sessione corrente.
|
|
601
|
-
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.
|
|
602
639
|
|
|
603
640
|
Returns:
|
|
604
641
|
int: Numero totale di record corrispondenti.
|
|
605
642
|
"""
|
|
606
|
-
|
|
607
|
-
stmt = select(count(pk))
|
|
643
|
+
stmt = select(count()).select_from(self.entity)
|
|
608
644
|
if where is not None:
|
|
609
|
-
stmt = stmt.where(where)
|
|
645
|
+
stmt = stmt.where(*where)
|
|
610
646
|
return session.execute(stmt).scalar()
|
|
611
647
|
|
|
612
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,
|
|
613
649
|
parsing_model: Type[BaseModel] = None, **kwhere) -> PaginatorResponseModel[Entity]:
|
|
614
650
|
"""Esegue una ricerca paginata con supporto a ordinamento e offset.
|
|
615
651
|
|
|
616
|
-
Calcola il numero totale di pagine
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
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.
|
|
620
666
|
|
|
621
667
|
Args:
|
|
622
668
|
session (Session, optional): Sessione esterna.
|
|
@@ -645,25 +691,34 @@ class CrudRepository[Entity: Base | Type[Base]]:
|
|
|
645
691
|
keep_open = False if session is None else True
|
|
646
692
|
session = self.open_session if session is None else session
|
|
647
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)
|
|
648
695
|
if not keep_open:
|
|
649
|
-
paginated.data = _list_adapter(parsing_model
|
|
696
|
+
paginated.data = _list_adapter(parsing_model).validate_python(paginated.data, from_attributes=True)
|
|
650
697
|
session.close()
|
|
651
698
|
return paginated
|
|
652
699
|
|
|
653
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):
|
|
654
701
|
if where is None:
|
|
655
702
|
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
703
|
|
|
704
|
+
stmt = self._select(entity=self.entity, columns=columns, exclude_columns=exclude_columns)
|
|
705
|
+
conditions = self.stmt_manager.conditions(where=where)
|
|
661
706
|
order_by = order_by or self.entity.primary_key()[0].name
|
|
707
|
+
if conditions:
|
|
708
|
+
stmt = stmt.where(*conditions)
|
|
662
709
|
stmt = self.stmt_manager.limit_offset_condition(stmt=stmt, limit=limit, page=page)
|
|
663
710
|
stmt = self.stmt_manager.order_by_condition(stmt=stmt, order_by=order_by, asc=asc)
|
|
664
|
-
|
|
665
711
|
entities = self.execute(session, stmt)
|
|
666
|
-
|
|
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)
|
|
667
722
|
|
|
668
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:
|
|
669
724
|
"""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
|
|
|
@@ -2,7 +2,7 @@ __all__ = ["ILikeHandler"]
|
|
|
2
2
|
|
|
3
3
|
from typing import Any
|
|
4
4
|
|
|
5
|
-
from sqlalchemy import Column,
|
|
5
|
+
from sqlalchemy import Column, ColumnElement, or_
|
|
6
6
|
from sqlalchemy.sql.base import ReadOnlyColumnCollection
|
|
7
7
|
|
|
8
8
|
from .base_handler import BaseHandler
|
|
@@ -17,8 +17,8 @@ class ILikeHandler(BaseHandler):
|
|
|
17
17
|
Attributes:
|
|
18
18
|
ilike_aliases (list[str]): Chiavi nel dizionario ``where`` che attivano
|
|
19
19
|
la ricerca ILIKE (es. ``["search", "q"]``).
|
|
20
|
-
ilike_attributes (list[
|
|
21
|
-
|
|
20
|
+
ilike_attributes (list[Column]): Colonne su cui applicare ILIKE, già
|
|
21
|
+
risolte in fase di inizializzazione dai nomi passati al costruttore.
|
|
22
22
|
|
|
23
23
|
Example:
|
|
24
24
|
>>> handler = ILikeHandler(
|
|
@@ -31,10 +31,14 @@ class ILikeHandler(BaseHandler):
|
|
|
31
31
|
"""
|
|
32
32
|
__slots__ = "ilike_aliases", "ilike_attributes"
|
|
33
33
|
ilike_aliases: list[str]
|
|
34
|
-
ilike_attributes: list[
|
|
34
|
+
ilike_attributes: list[Column]
|
|
35
35
|
|
|
36
36
|
def __init__(self, columns: ReadOnlyColumnCollection[str, Column[Any]], ilike_aliases: list[str] = None, ilike_attributes: list[str] = None):
|
|
37
|
-
"""Inizializza l'handler
|
|
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.
|
|
38
42
|
|
|
39
43
|
Args:
|
|
40
44
|
columns (ReadOnlyColumnCollection): Colonne della tabella.
|
|
@@ -44,47 +48,58 @@ class ILikeHandler(BaseHandler):
|
|
|
44
48
|
Default: lista vuota.
|
|
45
49
|
|
|
46
50
|
Raises:
|
|
47
|
-
|
|
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.
|
|
48
54
|
"""
|
|
49
|
-
|
|
55
|
+
super().__init__(columns)
|
|
50
56
|
self.ilike_aliases = ilike_aliases if ilike_aliases is not None else []
|
|
51
|
-
self.ilike_attributes = ilike_attributes if ilike_attributes is not None else []
|
|
52
|
-
|
|
53
|
-
for ilike_attribute in self.ilike_attributes:
|
|
54
|
-
if ilike_attribute not in self.columns:
|
|
55
|
-
raise ValueError(f"{ilike_attribute} not in columns")
|
|
57
|
+
self.ilike_attributes = [self.columns[ilike_attribute] for ilike_attribute in ilike_attributes] if ilike_attributes is not None else []
|
|
56
58
|
|
|
57
|
-
def
|
|
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``.
|
|
59
61
|
|
|
60
|
-
Per ogni alias
|
|
61
|
-
|
|
62
|
-
su tutte le colonne
|
|
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``.
|
|
63
67
|
|
|
64
68
|
Args:
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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.
|
|
69
73
|
|
|
70
74
|
Returns:
|
|
71
|
-
|
|
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.
|
|
72
81
|
"""
|
|
82
|
+
conditions = []
|
|
73
83
|
for alias in self.ilike_aliases:
|
|
74
84
|
condition = where.pop(alias, None)
|
|
75
85
|
if condition:
|
|
76
|
-
|
|
77
|
-
|
|
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 ``%``.
|
|
78
92
|
|
|
79
|
-
|
|
80
|
-
|
|
93
|
+
Sovrascrive la logica di ``BaseHandler._handle`` (uguaglianza / ``IN`` /
|
|
94
|
+
``ARRAY``): qui il confronto è sempre una ricerca testuale parziale.
|
|
81
95
|
|
|
82
96
|
Args:
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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``.
|
|
86
101
|
|
|
87
102
|
Returns:
|
|
88
|
-
ColumnElement: Espressione ``
|
|
103
|
+
ColumnElement: Espressione ``column ILIKE '%valore%'``.
|
|
89
104
|
"""
|
|
90
|
-
return
|
|
105
|
+
return column.ilike(f'%{condition}%')
|
|
@@ -2,7 +2,7 @@ __all__ = ["IntervalHandler"]
|
|
|
2
2
|
|
|
3
3
|
from typing import Any
|
|
4
4
|
|
|
5
|
-
from sqlalchemy import Column,
|
|
5
|
+
from sqlalchemy import Column, ColumnElement, or_
|
|
6
6
|
from sqlalchemy.sql.base import ReadOnlyColumnCollection
|
|
7
7
|
|
|
8
8
|
from .base_handler import BaseHandler
|
|
@@ -21,11 +21,14 @@ class IntervalHandler(BaseHandler):
|
|
|
21
21
|
Se non forniti, gli alias coincidono col nome della colonna.
|
|
22
22
|
|
|
23
23
|
Quando i due alias coincidono l'handler entra in modalità ``between``: una
|
|
24
|
-
singola chiave nel ``where`` valorizza entrambi i limiti (vedi ``
|
|
24
|
+
singola chiave nel ``where`` valorizza entrambi i limiti (vedi ``conditions``).
|
|
25
25
|
|
|
26
26
|
Attributes:
|
|
27
|
-
from\\_ (
|
|
28
|
-
|
|
27
|
+
from\\_ (Column | None): Colonna su cui applicare il limite inferiore (``>=``),
|
|
28
|
+
risolta in fase di inizializzazione dal nome passato al costruttore.
|
|
29
|
+
``None`` se il limite inferiore non è configurato.
|
|
30
|
+
to\\_ (Column | None): Colonna su cui applicare il limite superiore (``<=``),
|
|
31
|
+
risolta come sopra. ``None`` se il limite superiore non è configurato.
|
|
29
32
|
from_alias (str): Chiave del ``where`` che valorizza il limite inferiore.
|
|
30
33
|
to_alias (str): Chiave del ``where`` che valorizza il limite superiore.
|
|
31
34
|
inf_is_none (bool): Se ``True``, un valore ``NULL`` nella colonna ``to_``
|
|
@@ -43,23 +46,28 @@ class IntervalHandler(BaseHandler):
|
|
|
43
46
|
# WHERE data_inizio >= '2024-01-01' AND (data_fine <= '2024-12-31' OR data_fine IS NULL)
|
|
44
47
|
"""
|
|
45
48
|
__slots__ = "from_", "to_", "inf_is_none", "from_alias", "to_alias", "between"
|
|
46
|
-
from_:
|
|
47
|
-
to_:
|
|
49
|
+
from_: Column | None
|
|
50
|
+
to_: Column | None
|
|
48
51
|
from_alias: str
|
|
49
52
|
to_alias: str
|
|
50
53
|
inf_is_none: bool
|
|
51
54
|
between: bool
|
|
52
55
|
|
|
53
56
|
def __init__(self, columns: ReadOnlyColumnCollection[str, Column[Any]], from_: Any = None, from_alias: str = None, to_: Any = None, to_alias: str = None, inf_is_none: bool = False):
|
|
54
|
-
"""Inizializza l'handler
|
|
57
|
+
"""Inizializza l'handler risolvendo subito le colonne dei limiti.
|
|
58
|
+
|
|
59
|
+
I nomi di colonna passati in ``from_``/``to_`` vengono tradotti una volta sola
|
|
60
|
+
in oggetti ``Column``, così a ogni richiesta non si paga la risoluzione per
|
|
61
|
+
nome. Gli alias restano invece stringhe: sono chiavi del dizionario ``where``,
|
|
62
|
+
non colonne.
|
|
55
63
|
|
|
56
64
|
Args:
|
|
57
65
|
columns (ReadOnlyColumnCollection): Colonne della tabella.
|
|
58
|
-
from\\_ (
|
|
66
|
+
from\\_ (str, optional): Nome della colonna per il limite inferiore
|
|
59
67
|
(condizione ``>=``). Default: ``None`` (disabilitato).
|
|
60
68
|
from_alias (str, optional): Chiave del ``where`` da cui leggere il limite
|
|
61
69
|
inferiore. Se omessa, vale ``str(from_)``.
|
|
62
|
-
to\\_ (
|
|
70
|
+
to\\_ (str, optional): Nome della colonna per il limite superiore
|
|
63
71
|
(condizione ``<=``). Default: ``None`` (disabilitato).
|
|
64
72
|
to_alias (str, optional): Chiave del ``where`` da cui leggere il limite
|
|
65
73
|
superiore. Se omessa, vale ``str(to_)``.
|
|
@@ -67,56 +75,69 @@ class IntervalHandler(BaseHandler):
|
|
|
67
75
|
condizione sul limite superiore per trattare NULL come infinito.
|
|
68
76
|
Default: ``False``.
|
|
69
77
|
|
|
78
|
+
Raises:
|
|
79
|
+
KeyError: Se ``from_`` o ``to_`` non sono colonne della tabella. La
|
|
80
|
+
risoluzione avviene qui, quindi l'errore è immediato e non alla prima
|
|
81
|
+
query.
|
|
82
|
+
|
|
70
83
|
Note:
|
|
71
84
|
Se gli alias risultanti sono uguali viene attivata la modalità
|
|
72
|
-
``between`` (attributo ``between``).
|
|
85
|
+
``between`` (attributo ``between``). Con entrambi i nomi di colonna a
|
|
86
|
+
``None`` e nessun alias esplicito i due alias valgono entrambi
|
|
87
|
+
``"None"``, quindi la modalità ``between`` risulta attiva su un handler
|
|
88
|
+
che non ha nulla da filtrare.
|
|
73
89
|
"""
|
|
74
|
-
|
|
75
|
-
self.from_ = from_
|
|
76
|
-
self.to_ = to_
|
|
90
|
+
super().__init__(columns)
|
|
91
|
+
self.from_ = self.columns[from_] if from_ else None
|
|
92
|
+
self.to_ = self.columns[to_] if to_ else None
|
|
77
93
|
self.from_alias = from_alias if from_alias else str(from_)
|
|
78
94
|
self.to_alias = to_alias if to_alias else str(to_)
|
|
79
95
|
self.inf_is_none = inf_is_none
|
|
80
96
|
self.between = True if self.from_alias == self.to_alias else False
|
|
81
97
|
|
|
82
|
-
def
|
|
83
|
-
"""
|
|
98
|
+
def conditions(self, neq: bool = False, where: dict[str, Any] = None) -> list[ColumnElement[Any]]:
|
|
99
|
+
"""Costruisce le condizioni di limite inferiore e superiore leggendole dal ``where``.
|
|
100
|
+
|
|
101
|
+
Estrae e **rimuove** in-place le chiavi ``from_alias`` e ``to_alias``. In
|
|
102
|
+
modalità ``between`` viene letta solo ``from_alias`` e il suo valore alimenta
|
|
103
|
+
entrambi i limiti.
|
|
84
104
|
|
|
85
|
-
|
|
86
|
-
dizionario ``where``, quindi aggiunge le relative condizioni allo statement.
|
|
87
|
-
In modalità ``between`` viene letta solo ``from_alias`` e il suo valore è
|
|
88
|
-
usato per entrambi i limiti.
|
|
105
|
+
Condizioni generate:
|
|
89
106
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
107
|
+
- limite inferiore: ``from_ >= valore``;
|
|
108
|
+
- limite superiore: ``to_ <= valore``, oppure
|
|
109
|
+
``(to_ <= valore OR to_ IS NULL)`` se ``inf_is_none=True``.
|
|
93
110
|
|
|
94
111
|
Args:
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
112
|
+
neq (bool): Ignorato. La negazione non è implementata: con ``neq=True``
|
|
113
|
+
vengono generate comunque le condizioni **positive**.
|
|
114
|
+
where (dict[str, Any]): Filtri. Le chiavi di intervallo vengono rimosse
|
|
115
|
+
in-place.
|
|
99
116
|
|
|
100
117
|
Returns:
|
|
101
|
-
|
|
118
|
+
list[ColumnElement]: Da zero a due condizioni, in ordine (inferiore,
|
|
119
|
+
superiore). Un estremo non configurato al costruttore
|
|
120
|
+
(``from_``/``to_`` a ``None``) non produce nulla anche se il ``where``
|
|
121
|
+
contiene il suo alias.
|
|
102
122
|
|
|
103
123
|
Note:
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
124
|
+
I valori falsy (``None``, ``0``, ``""``) non producono nessuna condizione,
|
|
125
|
+
ma la chiave viene **comunque rimossa** dal ``where``: un limite inferiore
|
|
126
|
+
pari a ``0`` non genera quindi alcun filtro.
|
|
127
|
+
|
|
128
|
+
In modalità ``between`` le condizioni restano ``from_ >= valore`` e
|
|
129
|
+
``to_ <= valore``: per ottenere la semantica "la riga è valida al valore
|
|
130
|
+
indicato" le colonne vanno mappate invertite (vedi il docstring della
|
|
131
|
+
classe).
|
|
112
132
|
"""
|
|
133
|
+
conditions = []
|
|
113
134
|
from_condition = where.pop(self.from_alias, None)
|
|
114
135
|
to_condition = where.pop(self.to_alias, None) if not self.between else from_condition
|
|
115
|
-
if from_condition:
|
|
116
|
-
|
|
117
|
-
if to_condition:
|
|
118
|
-
condition = self.
|
|
136
|
+
if from_condition and self.from_ is not None:
|
|
137
|
+
conditions.append(self.from_ >= from_condition)
|
|
138
|
+
if to_condition and self.to_ is not None:
|
|
139
|
+
condition = self.to_ <= to_condition
|
|
119
140
|
if self.inf_is_none:
|
|
120
|
-
condition = or_(condition, self.
|
|
121
|
-
|
|
122
|
-
return
|
|
141
|
+
condition = or_(condition, self.to_ == None)
|
|
142
|
+
conditions.append(condition)
|
|
143
|
+
return conditions
|