python-general-be-lib 0.7.2__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.
Files changed (25) hide show
  1. general/exception/access_exceptions.py +12 -0
  2. general/exception/crud_exceptions.py +53 -0
  3. general/exception/exception_interface.py +28 -0
  4. general/interface/base/declarative_base.py +15 -5
  5. general/interface/metadata/crud_metadata.py +11 -2
  6. general/interface/metadata/geom_metadata.py +4 -1
  7. general/interface/repository/crud_repository.py +107 -27
  8. general/interface/repository/geometry_repository.py +20 -23
  9. general/interface/repository/handler/base_handler.py +45 -6
  10. general/interface/repository/handler/ilike_handler.py +46 -31
  11. general/interface/repository/handler/interval_handler.py +86 -35
  12. general/interface/repository/handler/json_handler.py +37 -15
  13. general/interface/repository/handler/json_ilike_handler.py +54 -58
  14. general/interface/repository/handler/or_handler.py +61 -48
  15. general/interface/repository/handler/statement_manager.py +61 -10
  16. general/interface/repository/many_to_many_repository.py +10 -0
  17. general/interface/repository/view_repository.py +10 -6
  18. general/logger.py +45 -12
  19. python_general_be_lib-0.8.1.dist-info/METADATA +74 -0
  20. python_general_be_lib-0.8.1.dist-info/RECORD +32 -0
  21. python_general_be_lib-0.7.2.dist-info/METADATA +0 -33
  22. python_general_be_lib-0.7.2.dist-info/RECORD +0 -32
  23. {python_general_be_lib-0.7.2.dist-info → python_general_be_lib-0.8.1.dist-info}/WHEEL +0 -0
  24. {python_general_be_lib-0.7.2.dist-info → python_general_be_lib-0.8.1.dist-info}/licenses/LICENSE +0 -0
  25. {python_general_be_lib-0.7.2.dist-info → python_general_be_lib-0.8.1.dist-info}/top_level.txt +0 -0
@@ -1,11 +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
+
1
11
  from .exception_interface import ExceptionInterface
2
12
 
3
13
 
4
14
  class ForbiddenException(ExceptionInterface):
15
+ """403 — Utente autenticato ma senza i permessi per la risorsa richiesta."""
5
16
  status_code = 403
6
17
  default_message = "Risorsa non accessibile da questo utente"
7
18
 
8
19
 
9
20
  class UnauthorizedException(ExceptionInterface):
21
+ """401 — Richiesta priva di credenziali valide."""
10
22
  status_code = 401
11
23
  default_message = "Non autorizzato"
@@ -1,31 +1,78 @@
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
+
1
14
  from .exception_interface import ExceptionInterface
2
15
 
3
16
 
4
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
+ """
5
23
  status_code = 400
6
24
  default_message = "Richiesta non corretta"
7
25
 
8
26
 
9
27
  class EntityNotFoundException(ExceptionInterface):
28
+ """404 — Nessun record corrisponde ai criteri richiesti.
29
+
30
+ Sollevata al posto di ``NoResultFound``.
31
+ """
10
32
  status_code = 404
11
33
  default_message = "Dato non trovato"
12
34
 
13
35
 
14
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
+ """
15
42
  status_code = 503
16
43
  default_message = "Servizio momentaneamente non disponibile, riprovare più tardi"
17
44
 
18
45
 
19
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
+ """
20
52
  status_code = 422
21
53
  default_message = "Entità non processabile"
22
54
 
23
55
 
24
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
+ """
25
66
  status_code = 400
26
67
  default_message = "Richiesta non corretta"
27
68
 
28
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
+ """
29
76
  if attr:
30
77
  super().__init__(custom_message=f"Attributo {attr} non presente", )
31
78
  else:
@@ -33,5 +80,11 @@ class HasNoAttributeException(ExceptionInterface):
33
80
 
34
81
 
35
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
+ """
36
89
  status_code = 500
37
90
  default_message = "Impossibile gestire la richiesta al momento"
@@ -2,9 +2,37 @@ from fastapi import HTTPException
2
2
 
3
3
 
4
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
+ """
5
25
  status_code: int
6
26
  default_message: str
7
27
 
8
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
+ """
9
37
  detail = f"{self.default_message} - {custom_message}" if custom_message else self.default_message
10
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 = {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:
@@ -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], optional): Entità su cui verificare. Se ``None``,
183
- usa ``self.entity``.
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(self.entity, column) for column in entity_columns]))
243
+ stmt = stmt.options(load_only(*[getattr(entity, column) for column in entity_columns]))
214
244
  if exclude_columns:
215
- 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])
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(pk))
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 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.
587
624
 
588
- Usato internamente da ``_paging_find`` per riusare la clausola WHERE
589
- 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.
590
633
 
591
634
  Args:
592
635
  session (Session): Sessione corrente.
593
- 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.
594
639
 
595
640
  Returns:
596
641
  int: Numero totale di record corrispondenti.
597
642
  """
598
- pk = self.entity.primary_key()[0]
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 in base al count reale dei record
609
- filtrati, e restituisce solo la pagina richiesta.
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 or self.model).validate_python(paginated.data, from_attributes=True)
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
- 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)
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 (usato da ``BaseService.company_filters``
685
- per popolare le opzioni dei filtri). Per le colonne ``ARRAY`` srotola in
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.
@@ -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,61 +138,59 @@ 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)
151
149
 
152
150
  def nearest(self, geom: WKBElement, session: Session = None, columns: list[str] = None, exclude_columns: list[str] = None, limit: int = 1, where: dict[str, Any] = None, geom_srid: int = 4326,
153
- distance: bool = True, parsing_model: Type[BaseModel] | BaseModel = None) -> list[Entity]:
151
+ parsing_model: Type[BaseModel] | BaseModel = None) -> list[Entity]:
154
152
  """Restituisce le entità più vicine alla geometria fornita, ordinate per distanza.
155
153
 
156
- Aggiunge una colonna ``distance`` ai risultati con la distanza calcolata
157
- tramite ``ST_Distance``. L'ordinamento usa l'operatore KNN ``<->``
158
- (``distance_centroid``) anziché ``ST_Distance``: con ``LIMIT`` impostato,
159
- PostGIS può servire la ricerca dei più vicini tramite l'indice spaziale
160
- GiST, evitando il calcolo della distanza su tutte le righe.
154
+ L'ordinamento usa l'operatore KNN ``<->`` (``distance_centroid``) anziché
155
+ ``ST_Distance``: con ``LIMIT`` impostato, PostGIS può servire la ricerca dei
156
+ più vicini tramite l'indice spaziale GiST, evitando il calcolo della
157
+ distanza su tutte le righe.
161
158
 
162
159
  Args:
163
- geom (WKBElement): Geometria di riferimento per il calcolo della distanza.
160
+ geom (WKBElement): Geometria di riferimento per il calcolo della prossimità.
164
161
  session (Session, optional): Sessione esterna.
165
162
  columns (list[str], optional): Colonne da includere.
166
163
  exclude_columns (list[str], optional): Colonne da escludere.
167
164
  limit (int): Numero di entità più vicine da restituire. Default: 1.
168
165
  where (dict[str, Any], optional): Filtri aggiuntivi.
169
166
  geom_srid (int): SRID della geometria fornita. Default: 4326.
170
- distance (bool): Se ``True``, aggiunge ai risultati la colonna ``distance``
171
- con la distanza calcolata via ``ST_Distance``. Default: ``True``.
172
- L'ordinamento per prossimità (operatore KNN ``<->``) viene applicato
173
- in ogni caso, indipendentemente da questo flag.
174
167
  parsing_model (Type[BaseModel], optional): Modello alternativo.
175
168
 
176
169
  Returns:
177
170
  list[Entity]: Entità ordinate dalla più vicina alla più lontana.
178
171
 
172
+ Note:
173
+ Restituisce solo le entità, senza il valore della distanza: la distanza
174
+ è usata unicamente per l'ordinamento lato database. Per ottenerla come
175
+ colonna serve uno statement custom, dato che ``execute`` restituisce
176
+ ``result.scalars().all()`` e terrebbe solo la prima colonna di ogni riga.
177
+
179
178
  Example:
180
179
  >>> nearest = repo.nearest(geom=my_point, limit=5)
181
180
  """
182
181
  keep_open = False if session is None else True
183
182
  session = self.open_session if session is None else session
184
- entities = self._nearest(session=session, geom=geom, where=where, columns=columns, exclude_columns=exclude_columns, limit=limit, geom_srid=geom_srid, distance=distance)
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)
185
185
  return self._return(session=session, entities=entities, keep_open=keep_open, commit=False, parsing_model=parsing_model)
186
186
 
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, distance: bool = True):
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):
188
188
  if where is None:
189
189
  where = {}
190
190
  stmt = self._select(entity=self.entity, columns=columns, exclude_columns=exclude_columns)
191
191
 
192
192
  geom_condition = self._geom_condition(geom=geom, geom_srid=geom_srid)
193
193
 
194
- if distance:
195
- stmt = stmt.add_columns(self.geom_column.ST_Distance(geom_condition).label("distance"))
196
-
197
194
  stmt = self.stmt_manager.prepare_statement(stmt=stmt, **where)
198
195
  stmt = stmt.order_by(self.geom_column.distance_centroid(geom_condition))
199
196
  stmt = self.stmt_manager.limit_offset_condition(stmt=stmt, limit=limit)