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.
@@ -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 = {col.name: None if full_optional else ... for col in mapper.columns}
52
+ field_values = {prop.key: None if full_optional else ... for prop in column_properties}
50
53
  field_values.update(default_values)
51
- for col in mapper.columns:
52
- field_name = col.name
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(self.entity, column) for column in entity_columns]))
243
+ stmt = stmt.options(load_only(*[getattr(entity, column) for column in entity_columns]))
215
244
  if exclude_columns:
216
- stmt = stmt.options(defer(*[getattr(self.entity, column) for column in exclude_columns]))
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(pk))
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 usando una clausola WHERE già costruita (ColumnElement).
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 internamente da ``_paging_find`` per riusare la clausola WHERE
597
- già applicata allo statement di selezione.
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): Clausola WHERE precostruita dallo statement.
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
- pk = self.entity.primary_key()[0]
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 in base al count reale dei record
617
- filtrati, e restituisce solo la pagina richiesta. Il count riusa la
618
- ``whereclause`` già costruita sullo statement di selezione
619
- (``_preconf_count``), così i filtri vengono tradotti una sola volta.
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 or self.model).validate_python(paginated.data, from_attributes=True)
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
- return self.parse_pagination(limit=limit, page=page, order_by=order_by, count=row_numbers, asc=asc, data=entities)
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
- stmt = stmt.where(geom_condition)
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 i filtri WHERE allo statement per tutte le coppie chiave-valore in ``where``.
43
+ """Applica allo statement le condizioni prodotte da ``conditions``.
44
44
 
45
- Per ogni coppia ``{colonna: valore}`` delega la costruzione della condizione
46
- a ``_handle`` e la aggiunge allo statement con ``.where()``.
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
- stmt = stmt.where(*[self._handle(column=self.columns[column], condition=condition, neq=neq) for column, condition in where.items()])
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, Select, Update, Delete, or_
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[str]): Nomi delle colonne su cui applicare ILIKE
21
- (es. ``["name", "surname", "email"]``).
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[str]
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 validando che le colonne ILIKE esistano sull'entità.
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
- ValueError: Se uno degli ``ilike_attributes`` non è presente tra le colonne.
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
- self.columns = columns
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 handle(self, stmt: Select | Update | Delete, where: dict[str, Any] = None, **kwargs):
58
- """Applica i filtri ILIKE allo statement per ogni alias trovato in ``where``.
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 presente in ``ilike_aliases`` e trovato in ``where``,
61
- estrae il valore, lo rimuove dal dizionario e aggiunge la condizione ILIKE
62
- su tutte le colonne in ``ilike_attributes`` con ``OR``.
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
- stmt (Select | Update | Delete): Statement su cui applicare i filtri.
66
- where (dict[str, Any]): Dizionario dei filtri. Gli alias ILIKE vengono
67
- estratti e rimossi in-place.
68
- **kwargs: Ignorati (compatibilità con l'interfaccia base).
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
- Select | Update | Delete: Statement con le condizioni ILIKE aggiunte.
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
- stmt = stmt.where(self._handle(alias, condition))
77
- return stmt
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
- def _handle(self, alias: str, condition: str, neq: bool = False):
80
- """Costruisce la condizione ILIKE con OR su tutte le colonne configurate.
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
- alias (str): Alias attivato (usato per identificare la ricerca, non la colonna).
84
- condition (str): Stringa da cercare (viene wrappata con ``%``).
85
- neq (bool): Non utilizzato in questa implementazione.
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 ``OR(col1 ILIKE '%val%', col2 ILIKE '%val%', ...)``.
103
+ ColumnElement: Espressione ``column ILIKE '%valore%'``.
89
104
  """
90
- return or_(*[self.columns[col].ilike(f'%{condition}%') for col in self.ilike_attributes])
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, Select, Update, Delete, or_
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 ``handle``).
24
+ singola chiave nel ``where`` valorizza entrambi i limiti (vedi ``conditions``).
25
25
 
26
26
  Attributes:
27
- from\\_ (Any): Nome della colonna su cui applicare il limite inferiore (``>=``).
28
- to\\_ (Any): Nome della colonna su cui applicare il limite superiore (``<=``).
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_: Any
47
- to_: Any
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 con le colonne dei limiti e i relativi alias.
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\\_ (Any, optional): Nome della colonna per il limite inferiore
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\\_ (Any, optional): Nome della colonna per il limite superiore
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
- self.columns = columns
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 handle(self, stmt: Select | Update | Delete, where: dict[str, Any] = None, **kwargs):
83
- """Applica le condizioni di range allo statement estraendo i valori da ``where``.
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
- Estrae e rimuove in-place le chiavi ``from_alias`` e ``to_alias`` dal
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
- I valori falsy (``None``, ``0``, ``""``) vengono ignorati: la condizione non
91
- viene aggiunta, ma la chiave è comunque rimossa dal ``where``. Un limite
92
- inferiore pari a ``0`` non produce quindi nessun filtro.
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
- stmt (Select | Update | Delete): Statement su cui applicare le condizioni.
96
- where (dict[str, Any]): Dizionario dei filtri. Le chiavi di intervallo
97
- vengono estratte e rimosse in-place.
98
- **kwargs: Ignorati (compatibilità con l'interfaccia base).
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
- Select | Update | Delete: Statement con le condizioni di range aggiunte.
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
- - Limite inferiore: ``from_ >= value``
105
- - Limite superiore: ``to_ <= value`` oppure ``(to_ <= value OR to_ IS NULL)``
106
- se ``inf_is_none=True``.
107
- - In modalità ``between`` le condizioni generate restano ``from_ >= value``
108
- e ``to_ <= value``. Per ottenere la semantica "la riga è valida al valore
109
- indicato" le colonne vanno mappate invertite, ad es. ``from_="data_fine"``
110
- e ``to_="data_inizio"`` con lo stesso alias ``"data"``, che produce
111
- ``data_fine >= value AND data_inizio <= value``.
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
- stmt = stmt.where(self.columns[self.from_] >= from_condition)
117
- if to_condition:
118
- condition = self.columns[self.to_] <= to_condition
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.columns[self.to_] == None)
121
- stmt = stmt.where(condition)
122
- return stmt
141
+ condition = or_(condition, self.to_ == None)
142
+ conditions.append(condition)
143
+ return conditions