python-general-be-lib 0.8.0__tar.gz → 0.8.2__tar.gz

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