python-general-be-lib 0.5.4__py3-none-any.whl → 0.6.0__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.
@@ -5,18 +5,58 @@ from typing import Optional, Any, Type, Iterable, Sequence
5
5
 
6
6
  from pydantic import BaseModel
7
7
  from sqlalchemy import MetaData, Engine, Column, Table, inspect, Connection, select, RowMapping, CursorResult, not_
8
+ from sqlalchemy.exc import NoSuchTableError
8
9
 
9
- from ...exception.crud_exceptions import HasNoAttributeException
10
+ from ...exception.crud_exceptions import HasNoAttributeException, InternalServerException
10
11
 
11
12
 
12
13
  class CrudMetadata:
14
+ """Repository CRUD basato su SQLAlchemy Core e ``MetaData``, senza ORM.
15
+
16
+ Alternativa a ``CrudRepository`` che opera su tabelle riflesse (``Table``)
17
+ e ``Connection`` invece che su entità mappate e ``Session``. Utile per
18
+ lavorare con tabelle prive di un modello ORM: lo schema viene riflesso a
19
+ runtime e le tabelle sono risolte per nome.
20
+
21
+ Le operazioni accettano una connessione esterna opzionale: se fornita
22
+ (``keep_open=True``) i risultati grezzi vengono restituiti senza commit né
23
+ chiusura; altrimenti la connessione viene aperta, committata e chiusa
24
+ internamente.
25
+
26
+ Attributes:
27
+ schema (str | None): Nome dello schema di default per le tabelle.
28
+ metadata (MetaData): Contenitore SQLAlchemy delle tabelle riflesse/definite.
29
+ engine (Engine): Istanza SQLAlchemy Engine connessa al database.
30
+ engine_inspection (Inspector): Inspector usato per le introspezioni
31
+ (es. presenza tabelle).
32
+
33
+ Example:
34
+ >>> meta = CrudMetadata(engine=engine, schema_name="public")
35
+ >>> rows = meta.find(from_table="users", where={"active": True}, limit=10)
36
+ """
13
37
  __slots__ = "schema", "metadata", "engine", "engine_inspection"
14
38
 
15
39
  @property
16
40
  def open_connection(self):
41
+ """Apre e restituisce una nuova connessione all'engine.
42
+
43
+ Returns:
44
+ Connection: Nuova connessione associata all'engine configurato.
45
+ """
17
46
  return self.engine.connect()
18
47
 
19
48
  def __init__(self, engine: Engine, schema_name: str = None, reflect: bool = True):
49
+ """Inizializza il repository con l'engine e (opzionalmente) riflette lo schema.
50
+
51
+ Args:
52
+ engine (Engine): Istanza SQLAlchemy Engine connessa al database.
53
+ schema_name (str, optional): Nome dello schema di default a cui
54
+ appartengono le tabelle. Se ``None``, viene usato lo schema di
55
+ default della connessione.
56
+ reflect (bool): Se ``True``, riflette tutte le tabelle dello schema
57
+ indicato alla costruzione (operazione potenzialmente costosa su
58
+ schemi grandi). Default: ``True``.
59
+ """
20
60
  self.schema = schema_name
21
61
  self.metadata = MetaData(schema_name) if schema_name else MetaData()
22
62
  self.engine = engine
@@ -27,21 +67,62 @@ class CrudMetadata:
27
67
  # ---------------------------
28
68
 
29
69
  def _handle_exception(self, connection: Connection, e: Exception, to_raise: bool = True):
70
+ """Chiude la connessione, logga l'errore ed eventualmente lo rilancia.
71
+
72
+ Args:
73
+ connection (Connection): Connessione da chiudere.
74
+ e (Exception): Eccezione catturata.
75
+ to_raise (bool): Se ``True``, rilancia l'eccezione dopo il logging.
76
+ Default: ``True``.
77
+
78
+ Raises:
79
+ Exception: L'eccezione originale, se ``to_raise=True``.
80
+ """
30
81
  connection.close()
31
82
  logging.error(e)
32
83
  if to_raise:
33
84
  raise e
34
85
 
35
86
  def commit(self, connection: Connection):
87
+ """Esegue il commit sulla connessione, gestendo eventuali eccezioni.
88
+
89
+ Args:
90
+ connection (Connection): Connessione su cui eseguire il commit.
91
+
92
+ Raises:
93
+ Exception: In caso di errore durante il commit (via ``_handle_exception``).
94
+ """
36
95
  try:
37
96
  connection.commit()
38
97
  except Exception as e:
39
98
  self._handle_exception(connection, e)
40
99
 
41
100
  def has_columns(self, *columns: Iterable[str], table: Table):
101
+ """Verifica che tutte le colonne specificate esistano sulla tabella.
102
+
103
+ Args:
104
+ *columns (str): Nomi delle colonne da verificare.
105
+ table (Table): Tabella su cui effettuare la verifica.
106
+
107
+ Returns:
108
+ bool: ``True`` se tutte le colonne sono presenti, ``False`` altrimenti.
109
+ """
42
110
  return all([column in [c.key for c in table.c] for column in columns])
43
111
 
44
112
  def _select(self, table: Table, columns: list[str] = None):
113
+ """Costruisce lo statement SELECT sulla tabella.
114
+
115
+ Args:
116
+ table (Table): Tabella su cui costruire il SELECT.
117
+ columns (list[str], optional): Colonne da selezionare. Se ``None``,
118
+ seleziona tutte le colonne della tabella.
119
+
120
+ Returns:
121
+ Select: Statement SELECT configurato.
122
+
123
+ Raises:
124
+ HasNoAttributeException: Se una colonna specificata non esiste sulla tabella.
125
+ """
45
126
  try:
46
127
  stmt = table.select() if not columns else select(*[table.c[column] for column in columns])
47
128
  except:
@@ -50,6 +131,25 @@ class CrudMetadata:
50
131
 
51
132
  def _return(self, connection: Connection, result: CursorResult | RowMapping | Sequence[RowMapping] | Sequence[dict] = None, keep_open: bool = False, commit: bool = True,
52
133
  parsing_model: Type[BaseModel] | BaseModel = None):
134
+ """Serializza il risultato e chiude la connessione se necessario.
135
+
136
+ Se ``keep_open=True`` (connessione esterna), restituisce il risultato
137
+ grezzo senza chiudere la connessione. Altrimenti esegue il commit (se
138
+ richiesto), serializza con il ``parsing_model`` e chiude la connessione.
139
+
140
+ Args:
141
+ connection (Connection): Connessione corrente.
142
+ result (CursorResult | RowMapping | Sequence): Righe risultanti
143
+ (tipicamente mapping ``{colonna: valore}``).
144
+ keep_open (bool): Se ``True``, non chiude la connessione e non serializza.
145
+ commit (bool): Se ``True`` e ``keep_open=False``, esegue il commit.
146
+ parsing_model (Type[BaseModel], optional): Modello Pydantic con cui
147
+ istanziare ogni riga (``parsing_model(**row)``). Se ``None``,
148
+ restituisce le righe grezze.
149
+
150
+ Returns:
151
+ list[BaseModel] | Sequence: Modelli serializzati o righe grezze.
152
+ """
53
153
  if keep_open:
54
154
  return result
55
155
  else:
@@ -60,15 +160,48 @@ class CrudMetadata:
60
160
  return result
61
161
 
62
162
  def create_table(self, name: str, columns: Iterable[Column], **kwargs):
163
+ """Crea una nuova tabella sul database.
164
+
165
+ Args:
166
+ name (str): Nome della tabella da creare.
167
+ columns (Iterable[Column]): Colonne che compongono la tabella.
168
+ **kwargs: Argomenti aggiuntivi passati al costruttore ``Table``
169
+ (es. ``schema``, vincoli, ``Index``).
170
+ """
63
171
  table = Table(name=name, metadata=self.metadata, *columns, **kwargs)
64
172
  table.create(self.engine)
65
173
 
66
174
  def drop_table(self, name: str):
175
+ """Elimina una tabella dal database e dal ``MetaData`` locale.
176
+
177
+ Args:
178
+ name (str): Nome della tabella da eliminare.
179
+ """
67
180
  table = self.get_table(name=name)
68
181
  table.drop(self.engine)
69
182
  self.metadata.remove(table=table)
70
183
 
71
184
  def find(self, from_table: str, connection: Connection = None, where: dict[str, Any] = None, columns: list[str] = None, limit: int = 0, parsing_model: Type[BaseModel] = None, **kwhere):
185
+ """Cerca le righe della tabella che corrispondono ai criteri specificati.
186
+
187
+ Args:
188
+ from_table (str): Nome della tabella su cui eseguire la ricerca.
189
+ connection (Connection, optional): Connessione esterna. Se ``None``,
190
+ ne viene aperta una nuova che viene chiusa al termine.
191
+ where (dict[str, Any], optional): Filtri come dizionario
192
+ ``{colonna: valore}``. Ha precedenza su ``**kwhere``.
193
+ columns (list[str], optional): Colonne da includere nel risultato.
194
+ limit (int): Numero massimo di righe. 0 = nessun limite. Default: 0.
195
+ parsing_model (Type[BaseModel], optional): Modello Pydantic per la
196
+ serializzazione delle righe.
197
+ **kwhere: Filtri aggiuntivi come keyword arguments.
198
+
199
+ Returns:
200
+ list[BaseModel] | Sequence[RowMapping]: Righe serializzate o mapping grezzi.
201
+
202
+ Example:
203
+ >>> meta.find(from_table="users", name="Mario", limit=10)
204
+ """
72
205
  keep_open = False if connection is None else True
73
206
  connection = self.open_connection if connection is None else connection
74
207
  table = self.get_table(name=from_table)
@@ -76,6 +209,20 @@ class CrudMetadata:
76
209
  return self._return(connection=connection, result=result, keep_open=keep_open, commit=False, parsing_model=parsing_model)
77
210
 
78
211
  def _find(self, table: Table, connection: Connection, where: dict[str, Any] = None, columns: list[str] = None, limit: int = 0, **kwhere):
212
+ """Costruisce ed esegue lo statement SELECT con filtri e limit.
213
+
214
+ Args:
215
+ table (Table): Tabella su cui eseguire la SELECT.
216
+ connection (Connection): Connessione corrente.
217
+ where (dict[str, Any], optional): Filtri ``{colonna: valore}``.
218
+ Se ``None``, usa ``**kwhere``.
219
+ columns (list[str], optional): Colonne da selezionare.
220
+ limit (int): Numero massimo di righe. Default: 0 (nessun limite).
221
+ **kwhere: Filtri aggiuntivi come keyword arguments.
222
+
223
+ Returns:
224
+ Sequence[RowMapping]: Righe risultanti come mapping.
225
+ """
79
226
  if where is None:
80
227
  where = kwhere if kwhere else {}
81
228
  stmt = self._select(table=table, columns=columns)
@@ -85,6 +232,30 @@ class CrudMetadata:
85
232
 
86
233
  def insert(self, from_table: str, connection: Connection = None, models: list[Type[BaseModel] | BaseModel] = None, values: list[dict[str, Any]] = None, returning: bool = True,
87
234
  parsing_model: Type[BaseModel] | BaseModel = None):
235
+ """Inserisce righe nella tabella a partire da modelli e/o dizionari di valori.
236
+
237
+ Le righe possono essere fornite come modelli Pydantic (``models``, da cui
238
+ si estraggono i valori con ``model_dump(exclude_none=True)``) e/o come
239
+ dizionari grezzi (``values``). Le due fonti vengono unite.
240
+
241
+ Args:
242
+ from_table (str): Nome della tabella su cui inserire.
243
+ connection (Connection, optional): Connessione esterna. Se ``None``,
244
+ ne viene aperta una nuova con commit automatico.
245
+ models (list[BaseModel], optional): Modelli Pydantic da inserire.
246
+ values (list[dict[str, Any]], optional): Righe come dizionari grezzi.
247
+ returning (bool): Se ``True``, usa ``RETURNING`` per recuperare le righe
248
+ inserite. Default: ``True``.
249
+ parsing_model (Type[BaseModel], optional): Modello per la serializzazione
250
+ del risultato.
251
+
252
+ Returns:
253
+ list[BaseModel] | Sequence[RowMapping]: Righe inserite (serializzate o grezze).
254
+
255
+ Note:
256
+ Se ``values`` è fornito, viene esteso in-place con i valori derivati
257
+ da ``models``.
258
+ """
88
259
  keep_open = False if connection is None else True
89
260
  connection = self.open_connection if connection is None else connection
90
261
  table = self.get_table(name=from_table)
@@ -94,6 +265,19 @@ class CrudMetadata:
94
265
  return self._return(connection=connection, result=result, keep_open=keep_open, commit=True, parsing_model=parsing_model)
95
266
 
96
267
  def _insert(self, table: Table, connection: Connection, values: list[dict[str, Any]] = None, returning: bool = True):
268
+ """Costruisce ed esegue lo statement INSERT con i valori forniti.
269
+
270
+ Args:
271
+ table (Table): Tabella su cui inserire.
272
+ connection (Connection): Connessione corrente.
273
+ values (list[dict[str, Any]], optional): Righe da inserire. Se vuoto,
274
+ non esegue nessuna operazione.
275
+ returning (bool): Se ``True``, usa ``RETURNING``. Default: ``True``.
276
+
277
+ Returns:
278
+ Sequence[RowMapping] | list: Righe inserite come mapping, o lista vuota
279
+ se non ci sono valori.
280
+ """
97
281
  if values:
98
282
  stmt = table.insert().values(values)
99
283
  stmt = stmt if not returning else stmt.returning()
@@ -102,6 +286,25 @@ class CrudMetadata:
102
286
  return []
103
287
 
104
288
  def update(self, from_table: str, connection: Connection = None, where: dict[str, Any] = None, returning: bool = True, parsing_model: Type[BaseModel] | BaseModel = None, **kwargs):
289
+ """Aggiorna le righe che soddisfano i criteri ``where`` con i valori in ``**kwargs``.
290
+
291
+ Args:
292
+ from_table (str): Nome della tabella da aggiornare.
293
+ connection (Connection, optional): Connessione esterna. Se ``None``,
294
+ ne viene aperta una nuova con commit automatico.
295
+ where (dict[str, Any], optional): Filtri per selezionare le righe da
296
+ aggiornare. Se ``None``, aggiorna tutte le righe (usare con cautela).
297
+ returning (bool): Se ``True``, usa ``RETURNING``. Default: ``True``.
298
+ parsing_model (Type[BaseModel], optional): Modello per la serializzazione
299
+ del risultato.
300
+ **kwargs: Valori da impostare sulle righe trovate.
301
+
302
+ Returns:
303
+ list[BaseModel] | Sequence[RowMapping]: Righe aggiornate (serializzate o grezze).
304
+
305
+ Example:
306
+ >>> meta.update(from_table="users", where={"id": 1}, name="Luigi")
307
+ """
105
308
  keep_open = False if connection is None else True
106
309
  connection = self.open_connection if connection is None else connection
107
310
  table = self.get_table(name=from_table)
@@ -109,6 +312,18 @@ class CrudMetadata:
109
312
  return self._return(connection=connection, result=result, keep_open=keep_open, commit=True, parsing_model=parsing_model)
110
313
 
111
314
  def _update(self, table: Table, connection: Connection, where: dict[str, Any] = None, returning: bool = True, **kwargs):
315
+ """Costruisce ed esegue lo statement UPDATE con filtri e valori.
316
+
317
+ Args:
318
+ table (Table): Tabella da aggiornare.
319
+ connection (Connection): Connessione corrente.
320
+ where (dict[str, Any], optional): Filtri per le righe da aggiornare.
321
+ returning (bool): Se ``True``, usa ``RETURNING``. Default: ``True``.
322
+ **kwargs: Valori da impostare sulle righe.
323
+
324
+ Returns:
325
+ Sequence[RowMapping]: Righe aggiornate come mapping.
326
+ """
112
327
  if where is None:
113
328
  where = {}
114
329
  stmt = table.update()
@@ -118,6 +333,22 @@ class CrudMetadata:
118
333
  return self.execute(connection=connection, stmt=stmt, mapping=True)
119
334
 
120
335
  def delete(self, from_table: str, connection: Connection = None, where: dict[str, Any] = None, **kwhere):
336
+ """Elimina le righe che soddisfano i criteri specificati.
337
+
338
+ Args:
339
+ from_table (str): Nome della tabella da cui eliminare.
340
+ connection (Connection, optional): Connessione esterna. Se fornita, il
341
+ commit non viene eseguito automaticamente.
342
+ where (dict[str, Any], optional): Filtri come dizionario.
343
+ Ha precedenza su ``**kwhere``.
344
+ **kwhere: Filtri come keyword arguments.
345
+
346
+ Returns:
347
+ int: Numero di righe eliminate.
348
+
349
+ Example:
350
+ >>> meta.delete(from_table="users", id=5)
351
+ """
121
352
  keep_open = False if connection is None else True
122
353
  connection = self.open_connection if connection is None else connection
123
354
  table = self.get_table(name=from_table)
@@ -130,6 +361,18 @@ class CrudMetadata:
130
361
  return num_rows
131
362
 
132
363
  def _delete(self, table: Table, connection: Connection, where: dict[str, Any] = None, **kwhere):
364
+ """Costruisce ed esegue lo statement DELETE con i filtri forniti.
365
+
366
+ Args:
367
+ table (Table): Tabella da cui eliminare.
368
+ connection (Connection): Connessione corrente.
369
+ where (dict[str, Any], optional): Filtri ``{colonna: valore}``.
370
+ Se ``None``, usa ``**kwhere``.
371
+ **kwhere: Filtri come keyword arguments.
372
+
373
+ Returns:
374
+ int: Numero di righe eliminate (``rowcount``).
375
+ """
133
376
  if where is None:
134
377
  where = kwhere if kwhere else {}
135
378
  stmt = table.delete()
@@ -139,6 +382,22 @@ class CrudMetadata:
139
382
  # --------------
140
383
 
141
384
  def execute(self, connection: Connection, stmt, mapping: bool = False):
385
+ """Esegue uno statement sulla connessione, gestendo le eccezioni.
386
+
387
+ Args:
388
+ connection (Connection): Connessione su cui eseguire lo statement.
389
+ stmt: Statement SQLAlchemy Core da eseguire.
390
+ mapping (bool): Se ``True``, restituisce le righe come mapping
391
+ (``result.mappings().all()``); altrimenti restituisce il
392
+ ``CursorResult`` grezzo. Default: ``False``.
393
+
394
+ Returns:
395
+ Sequence[RowMapping] | CursorResult: Righe come mapping se ``mapping=True``,
396
+ altrimenti il result grezzo.
397
+
398
+ Raises:
399
+ Exception: In caso di errore durante l'esecuzione (via ``_handle_exception``).
400
+ """
142
401
  try:
143
402
  result = connection.execute(stmt)
144
403
  except Exception as e:
@@ -149,6 +408,16 @@ class CrudMetadata:
149
408
  return result
150
409
 
151
410
  def _get_table(self, name: str):
411
+ """Recupera una tabella dal ``MetaData`` locale per nome.
412
+
413
+ Normalizza il nome anteponendo lo schema configurato, se necessario.
414
+
415
+ Args:
416
+ name (str): Nome della tabella (con o senza prefisso di schema).
417
+
418
+ Returns:
419
+ Table | None: La tabella se già presente nel ``MetaData``, altrimenti ``None``.
420
+ """
152
421
  if self.schema is not None:
153
422
  table_name = name if name.startswith(f"{self.schema}.") else f"{self.schema}.{name}"
154
423
  else:
@@ -156,15 +425,54 @@ class CrudMetadata:
156
425
  return self.metadata.tables.get(table_name)
157
426
 
158
427
  def get_table(self, name: str) -> Optional[Table]:
428
+ """Restituisce una tabella, riflettendola dal database se non già nota.
429
+
430
+ Cerca prima nel ``MetaData`` locale; se assente, tenta il caricamento
431
+ automatico (``autoload_with``) dal database.
432
+
433
+ Args:
434
+ name (str): Nome della tabella da recuperare.
435
+
436
+ Returns:
437
+ Optional[Table]: La tabella richiesta.
438
+
439
+ Raises:
440
+ InternalServerException: Se la tabella non esiste nel database.
441
+ """
159
442
  table = self._get_table(name=name)
160
443
  if table is None:
161
- table = Table(name, self.metadata, schema=self.schema, autoload_with=self.engine)
444
+ try:
445
+ table = Table(name, self.metadata, schema=self.schema, autoload_with=self.engine)
446
+ except NoSuchTableError:
447
+ raise InternalServerException(f"No such table {name}")
162
448
  return table
163
449
 
164
450
  def has_table(self, name: str):
451
+ """Verifica se una tabella esiste nel database.
452
+
453
+ Args:
454
+ name (str): Nome della tabella da verificare.
455
+
456
+ Returns:
457
+ bool: ``True`` se la tabella esiste nello schema configurato.
458
+ """
165
459
  return self.engine_inspection.has_table(name, self.schema)
166
460
 
167
461
  def _add_limit_offset_condition(self, stmt, limit: int, page: int = None):
462
+ """Aggiunge le clausole LIMIT e OFFSET allo statement per la paginazione.
463
+
464
+ Non applica nessuna modifica se ``limit`` è 0 o negativo.
465
+ L'offset viene calcolato come ``(page - 1) * limit``.
466
+
467
+ Args:
468
+ stmt: Statement su cui applicare LIMIT/OFFSET.
469
+ limit (int): Numero massimo di righe. 0 = nessun limite.
470
+ page (int, optional): Numero di pagina (1-based). Se ``None`` o <= 0,
471
+ non viene applicato nessun offset.
472
+
473
+ Returns:
474
+ Statement con LIMIT e/o OFFSET applicati.
475
+ """
168
476
  if limit and limit > 0:
169
477
  stmt = stmt.limit(limit)
170
478
  if page and page > 0:
@@ -173,7 +481,22 @@ class CrudMetadata:
173
481
  return stmt
174
482
 
175
483
  def _eq_where(self, col: Column, condition, neq: bool = False):
484
+ """Costruisce la singola condizione WHERE per una colonna.
485
+
486
+ Logica applicata:
487
+ - ``list`` → ``col IN (values)``
488
+ - Tipo ``ARRAY`` → ``value = ANY(col)``
489
+ - Scalare → ``col == value``
490
+
491
+ Args:
492
+ col (Column): Colonna su cui applicare la condizione.
493
+ condition: Valore o lista di valori da confrontare.
494
+ neq (bool): Se ``True``, nega la condizione (``NOT``). Default: ``False``.
495
+
496
+ Returns:
497
+ ColumnElement: Espressione booleana SQLAlchemy.
498
+ """
176
499
  eq_where = col.in_(condition) if isinstance(condition, list) else col == condition if str(col.type) != "ARRAY" else condition == col.any_()
177
500
  if neq:
178
- not_(eq_where)
501
+ eq_where = not_(eq_where)
179
502
  return eq_where
@@ -10,11 +10,57 @@ from .crud_metadata import CrudMetadata
10
10
 
11
11
 
12
12
  class GeomMetadata(CrudMetadata):
13
+ """Repository Core con operazioni spaziali PostGIS su tabelle riflesse.
14
+
15
+ Estende ``CrudMetadata`` (SQLAlchemy Core, senza ORM) aggiungendo query
16
+ geografiche di intersezione e contenimento via GeoAlchemy2. A differenza di
17
+ ``GeometryRepository``, la colonna geometrica e il suo SRID non sono fissati
18
+ sulla classe ma passati come argomenti a ciascun metodo, insieme al nome
19
+ della tabella.
20
+
21
+ Gestisce la riproiezione automatica tra SRID diversi e il buffering opzionale
22
+ della geometria di input.
23
+
24
+ Example:
25
+ >>> meta = GeomMetadata(engine=engine, schema_name="public")
26
+ >>> rows = meta.intersects(
27
+ ... from_table="zones", geom=point,
28
+ ... polygon_column_name="geom", polygon_column_srid=32632,
29
+ ... )
30
+ """
13
31
 
14
32
  def intersects(self, from_table: str, geom: WKBElement, polygon_column_name: str, polygon_column_srid: int, connection: Connection = None, tolerance: float = 0.0, columns: list[str] = None,
15
33
  where: dict[str, Any] = None, not_where: dict[str, Any] = None, limit: int = 0, geom_srid: int = 4326, parsing_model: Type[BaseModel] | BaseModel = None, intersection: bool = False,
16
34
  intersection_area: bool = False):
17
-
35
+ """Restituisce le righe la cui geometria interseca quella fornita.
36
+
37
+ Supporta buffer opzionale, filtri positivi e negativi, riproiezione
38
+ automatica e l'aggiunta facoltativa della geometria di intersezione e
39
+ della sua area.
40
+
41
+ Args:
42
+ from_table (str): Nome della tabella su cui eseguire la query.
43
+ geom (WKBElement): Geometria di riferimento per l'intersezione.
44
+ polygon_column_name (str): Nome della colonna geometrica della tabella.
45
+ polygon_column_srid (int): SRID della colonna geometrica.
46
+ connection (Connection, optional): Connessione esterna.
47
+ tolerance (float): Buffer applicato alla geometria prima dell'intersezione
48
+ (nelle unità del SRID della colonna). Default: 0.0 (nessun buffer).
49
+ columns (list[str], optional): Colonne da includere nel risultato.
50
+ where (dict[str, Any], optional): Filtri aggiuntivi positivi.
51
+ not_where (dict[str, Any], optional): Filtri aggiuntivi negativi.
52
+ limit (int): Numero massimo di righe. Default: 0 (nessun limite).
53
+ geom_srid (int): SRID della geometria fornita. Default: 4326.
54
+ parsing_model (Type[BaseModel], optional): Modello per la serializzazione.
55
+ intersection (bool): Se ``True``, aggiunge la colonna ``intersection``
56
+ con la geometria di intersezione. Default: ``False``.
57
+ intersection_area (bool): Se ``True``, aggiunge la colonna
58
+ ``intersection_area`` con l'area dell'intersezione (riproiettata in
59
+ EPSG:3857). Default: ``False``.
60
+
61
+ Returns:
62
+ list[BaseModel] | Sequence[RowMapping]: Righe che intersecano la geometria.
63
+ """
18
64
  keep_open = False if connection is None else True
19
65
  connection = self.open_connection if connection is None else connection
20
66
  table = self.get_table(name=from_table)
@@ -25,6 +71,26 @@ class GeomMetadata(CrudMetadata):
25
71
 
26
72
  def _intersects(self, table: Table, geom: WKBElement, polygon_column: Column, polygon_column_srid: int, connection: Connection = None, tolerance: float = 0.0, columns: list[str] = None, where: dict[str, Any] = None,
27
73
  not_where: dict[str, Any] = None, limit: int = 0, geom_srid: int = 4326, intersection: bool = False, intersection_area: bool = False):
74
+ """Costruisce ed esegue lo statement di intersezione spaziale.
75
+
76
+ Args:
77
+ table (Table): Tabella su cui eseguire la query.
78
+ geom (WKBElement): Geometria di riferimento.
79
+ polygon_column (Column): Colonna geometrica della tabella.
80
+ polygon_column_srid (int): SRID della colonna geometrica.
81
+ connection (Connection, optional): Connessione corrente.
82
+ tolerance (float): Buffer applicato alla geometria. Default: 0.0.
83
+ columns (list[str], optional): Colonne da selezionare.
84
+ where (dict[str, Any], optional): Filtri positivi.
85
+ not_where (dict[str, Any], optional): Filtri negativi.
86
+ limit (int): Numero massimo di righe. Default: 0.
87
+ geom_srid (int): SRID della geometria fornita. Default: 4326.
88
+ intersection (bool): Se ``True``, aggiunge la geometria di intersezione.
89
+ intersection_area (bool): Se ``True``, aggiunge l'area dell'intersezione.
90
+
91
+ Returns:
92
+ Sequence[RowMapping]: Righe risultanti come mapping.
93
+ """
28
94
  if where is None:
29
95
  where = {}
30
96
  if not_where is None:
@@ -53,7 +119,30 @@ class GeomMetadata(CrudMetadata):
53
119
 
54
120
  def contains(self, from_table: str, geom: WKBElement, polygon_column_name: str, polygon_column_srid: int, connection: Connection = None, tolerance: float = 0.0, columns: list[str] = None,
55
121
  where: dict[str, Any] = None, limit: int = 0, contained: bool = False, geom_srid: int = 4326, parsing_model: Type[BaseModel] | BaseModel = None):
56
-
122
+ """Restituisce le righe che contengono (o sono contenute in) una geometria.
123
+
124
+ Il parametro ``contained`` inverte la direzione del contenimento:
125
+ - ``False`` (default): righe la cui geometria **contiene** ``geom``.
126
+ - ``True``: righe la cui geometria è **contenuta in** ``geom``.
127
+
128
+ Args:
129
+ from_table (str): Nome della tabella su cui eseguire la query.
130
+ geom (WKBElement): Geometria di riferimento.
131
+ polygon_column_name (str): Nome della colonna geometrica della tabella.
132
+ polygon_column_srid (int): SRID della colonna geometrica.
133
+ connection (Connection, optional): Connessione esterna.
134
+ tolerance (float): Buffer applicato alla geometria. Default: 0.0.
135
+ columns (list[str], optional): Colonne da includere nel risultato.
136
+ where (dict[str, Any], optional): Filtri aggiuntivi.
137
+ limit (int): Numero massimo di righe. Default: 0.
138
+ contained (bool): Se ``True``, inverte la direzione del contenimento.
139
+ Default: ``False``.
140
+ geom_srid (int): SRID della geometria fornita. Default: 4326.
141
+ parsing_model (Type[BaseModel], optional): Modello per la serializzazione.
142
+
143
+ Returns:
144
+ list[BaseModel] | Sequence[RowMapping]: Righe che soddisfano il contenimento.
145
+ """
57
146
  keep_open = False if connection is None else True
58
147
  connection = self.open_connection if connection is None else connection
59
148
  table = self.get_table(name=from_table)
@@ -64,6 +153,24 @@ class GeomMetadata(CrudMetadata):
64
153
 
65
154
  def _contains(self, table: Table, geom: WKBElement, polygon_column: Column, polygon_column_srid: int, connection: Connection = None, tolerance: float = 0.0, columns: list[str] = None, where: dict[str, Any] = None,
66
155
  limit: int = 0, contained: bool = False, geom_srid: int = 4326):
156
+ """Costruisce ed esegue lo statement di contenimento spaziale.
157
+
158
+ Args:
159
+ table (Table): Tabella su cui eseguire la query.
160
+ geom (WKBElement): Geometria di riferimento.
161
+ polygon_column (Column): Colonna geometrica della tabella.
162
+ polygon_column_srid (int): SRID della colonna geometrica.
163
+ connection (Connection, optional): Connessione corrente.
164
+ tolerance (float): Buffer applicato alla geometria. Default: 0.0.
165
+ columns (list[str], optional): Colonne da selezionare.
166
+ where (dict[str, Any], optional): Filtri aggiuntivi.
167
+ limit (int): Numero massimo di righe. Default: 0.
168
+ contained (bool): Se ``True``, inverte la direzione del contenimento.
169
+ geom_srid (int): SRID della geometria fornita. Default: 4326.
170
+
171
+ Returns:
172
+ Sequence[RowMapping]: Righe risultanti come mapping.
173
+ """
67
174
  if columns is None:
68
175
  columns = []
69
176
  if where is None:
@@ -82,6 +189,22 @@ class GeomMetadata(CrudMetadata):
82
189
  return self.execute(connection=connection, stmt=stmt, mapping=True)
83
190
 
84
191
  def _geom_condition(self, geom: WKBElement, polygon_column_srid: int = 4326, geom_srid: int = 4326, buffer: float = 0):
192
+ """Prepara la geometria per le query applicando riproiezione e buffer.
193
+
194
+ Se l'SRID della geometria fornita differisce da quello della colonna,
195
+ applica ``ST_Transform``. Se è specificato un buffer > 0, applica
196
+ ``ST_Buffer`` dopo la riproiezione.
197
+
198
+ Args:
199
+ geom (WKBElement): Geometria di input.
200
+ polygon_column_srid (int): SRID della colonna geometrica. Default: 4326.
201
+ geom_srid (int): SRID della geometria di input. Default: 4326.
202
+ buffer (float): Distanza del buffer (nelle unità del SRID della colonna).
203
+ 0 = nessun buffer. Default: 0.
204
+
205
+ Returns:
206
+ WKBElement: Geometria pronta per l'uso nelle condizioni PostGIS.
207
+ """
85
208
  geom_condition = geom.ST_Transform(polygon_column_srid) if geom_srid != polygon_column_srid else geom
86
209
  geom_condition = geom_condition.ST_Buffer(buffer) if buffer else geom_condition
87
210
 
@@ -5,7 +5,7 @@ import math
5
5
  from typing import Any, Type, Sequence, Iterable, Optional, Mapping
6
6
 
7
7
  from pydantic import BaseModel
8
- from sqlalchemy import select, delete, update, insert, Row, RowMapping, ColumnElement, Select, Insert, Update, Delete
8
+ from sqlalchemy import select, delete, update, insert, tuple_, Row, RowMapping, ColumnElement, Select, Insert, Update, Delete
9
9
  from sqlalchemy.engine.base import Engine
10
10
  from sqlalchemy.exc import DataError, IntegrityError, NoResultFound, OperationalError
11
11
  from sqlalchemy.inspection import inspect
@@ -126,6 +126,26 @@ class CrudRepository[Entity: Base | Type[Base]]:
126
126
  except Exception as e:
127
127
  self._handle_exception(session, e)
128
128
 
129
+ def _flush(self, session: Session):
130
+ """Esegue il flush sulla sessione, gestendo eventuali eccezioni.
131
+
132
+ Invia al database le modifiche pendenti (INSERT/UPDATE) senza chiudere
133
+ la transazione, popolando i valori generati lato server (es. chiavi
134
+ primarie autoincrement e default). A differenza del commit, non scade
135
+ (expire) le istanze ORM: gli attributi restano leggibili in memoria.
136
+
137
+ Args:
138
+ session (Session): Sessione su cui eseguire il flush.
139
+
140
+ Raises:
141
+ ServiceUnavailableException | BadRequestException: In caso di errore
142
+ durante il flush.
143
+ """
144
+ try:
145
+ session.flush()
146
+ except Exception as e:
147
+ self._handle_exception(session, e)
148
+
129
149
  def _entity_has_columns(self, *columns: Iterable[str], entity: Type[Base] | Base = None):
130
150
  """Verifica che tutte le colonne specificate esistano sull'entità.
131
151
 
@@ -175,14 +195,21 @@ class CrudRepository[Entity: Base | Type[Base]]:
175
195
  """Serializza le entità e chiude la sessione se necessario.
176
196
 
177
197
  Se ``keep_open=True`` (sessione esterna), restituisce le entità grezze
178
- senza chiudere la sessione. Altrimenti esegue il commit (se richiesto),
179
- serializza con ``model_validate`` e chiude la sessione.
198
+ senza chiudere la sessione. Altrimenti, quando ``commit=True``, esegue
199
+ prima un ``flush`` (per popolare i valori generati lato server),
200
+ serializza con ``model_validate`` e solo dopo esegue il ``commit``.
201
+
202
+ L'ordine flush → serializzazione → commit evita le query N+1 di reload:
203
+ il commit con ``expire_on_commit`` di default scadrebbe le istanze ORM,
204
+ forzando un SELECT per ogni entità al successivo accesso degli attributi.
205
+ Serializzando prima del commit gli attributi sono letti dallo stato già
206
+ popolato in memoria.
180
207
 
181
208
  Args:
182
209
  session (Session): Sessione corrente.
183
210
  entities (Sequence): Entità SQLAlchemy da serializzare.
184
211
  keep_open (bool): Se ``True``, non chiude la sessione e non serializza.
185
- commit (bool): Se ``True`` e ``keep_open=False``, esegue il commit.
212
+ commit (bool): Se ``True`` e ``keep_open=False``, esegue flush + commit.
186
213
  parsing_model (Type[BaseModel], optional): Modello alternativo per la
187
214
  serializzazione. Se ``None``, usa ``self.model``.
188
215
 
@@ -192,10 +219,12 @@ class CrudRepository[Entity: Base | Type[Base]]:
192
219
  if keep_open:
193
220
  return entities
194
221
  else:
195
- if commit:
196
- self.commit(session=session)
197
222
  return_model = parsing_model or self.model
223
+ if commit:
224
+ self._flush(session=session)
198
225
  models = [return_model.model_validate(entity) for entity in entities]
226
+ if commit:
227
+ self.commit(session=session)
199
228
  session.close()
200
229
  return models
201
230
 
@@ -441,25 +470,58 @@ class CrudRepository[Entity: Base | Type[Base]]:
441
470
  def _upsert(self, session: Session, models: list[Type[BaseModel] | BaseModel] = None):
442
471
  if models is None:
443
472
  models = []
444
- updates = {}
445
- for i, model in enumerate(models):
446
- pks = {pk.name: getattr(model, pk.name) for pk in self.entity.primary_key()}
447
- if any([not value for value in pks.values()]):
448
- continue
473
+
474
+ pk_columns = self.entity.primary_key()
475
+
476
+ # Chiave primaria (eventualmente composta) di ogni modello, oppure None
477
+ # se non tutte le colonne PK sono valorizzate (→ candidato all'insert).
478
+ model_keys = [self._pk_key(model, pk_columns) for model in models]
479
+
480
+ # Un'unica query per recuperare tutte le entità esistenti che corrispondono
481
+ # alle PK candidate, indicizzate per chiave PK in una mappa in memoria.
482
+ candidate_keys = [key for key in model_keys if key is not None]
483
+ existing: dict[tuple, Base] = {}
484
+ if candidate_keys:
485
+ stmt = select(self.entity).where(tuple_(*pk_columns).in_(candidate_keys))
486
+ for entity in self.execute(session=session, stmt=stmt):
487
+ existing[self._pk_key(entity, pk_columns)] = entity
488
+
489
+ # Ripartizione insert/update in base alla presenza nella mappa.
490
+ inserts = []
491
+ to_update = []
492
+ for model, key in zip(models, model_keys):
493
+ entity = existing.get(key) if key is not None else None
494
+ if entity is not None:
495
+ to_update.append((entity, model))
449
496
  else:
450
- updates[i] = session.get(self.entity, pks)
451
- inserts = [models[i] for i in range(len(models)) if i not in updates.keys()]
497
+ inserts.append(model)
452
498
 
453
499
  result = self._insert(session=session, models=inserts)
454
- for i, entity in updates.items():
455
- if entity:
456
- for attr, value in models[i].model_dump(exclude_none=True).items():
457
- setattr(entity, attr, value)
458
-
459
- result.extend(updates.values())
500
+ for entity, model in to_update:
501
+ for attr, value in model.model_dump(exclude_none=True).items():
502
+ setattr(entity, attr, value)
503
+ result.append(entity)
460
504
 
461
505
  return result
462
506
 
507
+ def _pk_key(self, obj: Type[BaseModel] | BaseModel | Base, pk_columns) -> tuple | None:
508
+ """Estrae la chiave primaria (eventualmente composta) da un modello o entità.
509
+
510
+ Legge i valori delle colonne PK in ordine e li restituisce come tupla,
511
+ usabile come chiave di mappa e per il filtro ``tuple_(...).in_(...)``.
512
+
513
+ Args:
514
+ obj: Modello Pydantic o entità ORM da cui leggere la PK.
515
+ pk_columns: Colonne che compongono la chiave primaria
516
+ (tipicamente ``self.entity.primary_key()``).
517
+
518
+ Returns:
519
+ tuple | None: Tupla dei valori PK in ordine di colonna, oppure ``None``
520
+ se almeno un valore non è valorizzato (PK incompleta).
521
+ """
522
+ values = tuple(getattr(obj, column.name, None) for column in pk_columns)
523
+ return values if all(values) else None
524
+
463
525
  def count(self, session: Session = None, where: dict[str, Any] = None, **kwhere):
464
526
  """Conta i record che soddisfano i criteri specificati.
465
527
 
@@ -491,8 +553,7 @@ class CrudRepository[Entity: Base | Type[Base]]:
491
553
  where = kwhere if kwhere else {}
492
554
  stmt = select(count(pk))
493
555
  stmt = self.stmt_manager.prepare_statement(stmt=stmt, **where)
494
- result = session.execute(stmt).all()[0]
495
- return result[0]
556
+ return session.execute(stmt).scalar()
496
557
 
497
558
  def _preconf_count(self, session: Session, where: Optional[ColumnElement[Any]]):
498
559
  """Conta i record usando una clausola WHERE già costruita (ColumnElement).
@@ -509,9 +570,9 @@ class CrudRepository[Entity: Base | Type[Base]]:
509
570
  """
510
571
  pk = self.entity.primary_key()[0]
511
572
  stmt = select(count(pk))
512
- stmt = stmt.where(where)
513
- result = session.execute(stmt).all()[0]
514
- return result[0]
573
+ if where is not None:
574
+ stmt = stmt.where(where)
575
+ return session.execute(stmt).scalar()
515
576
 
516
577
  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,
517
578
  parsing_model: Type[BaseModel] = None, **kwhere) -> PaginatorResponseModel[Entity]:
@@ -4,7 +4,7 @@ from typing import Any, Type
4
4
 
5
5
  from geoalchemy2 import WKBElement, Geometry
6
6
  from pydantic import BaseModel
7
- from sqlalchemy import literal_column, Column
7
+ from sqlalchemy import Column
8
8
  from sqlalchemy.orm import Session
9
9
 
10
10
  from .crud_repository import CrudRepository
@@ -47,15 +47,17 @@ class GeometryRepository[Entity: Base | Type[Base]](CrudRepository[Entity]):
47
47
  limit: int = 0, geom_srid: int = 4326, parsing_model: Type[BaseModel] | BaseModel = None) -> list[Entity]:
48
48
  """Restituisce le entità la cui geometria interseca quella fornita.
49
49
 
50
- Supporta buffer opzionale, filtri aggiuntivi positivi e negativi,
51
- e riproiezione automatica se l'SRID della geometria differisce da
52
- quello della colonna.
50
+ Supporta una tolleranza di distanza opzionale, filtri aggiuntivi positivi
51
+ e negativi, e riproiezione automatica se l'SRID della geometria differisce
52
+ da quello della colonna.
53
53
 
54
54
  Args:
55
55
  geom (WKBElement): Geometria di riferimento per l'intersezione.
56
56
  session (Session, optional): Sessione esterna.
57
- tolerance (float): Buffer applicato alla geometria prima dell'intersezione
58
- (nelle unità del SRID della colonna). Default: 0.0 (nessun buffer).
57
+ tolerance (float): Distanza massima entro cui considerare le geometrie
58
+ "intersecanti" (nelle unità del SRID della colonna). Se > 0 usa
59
+ ``ST_DWithin`` (servibile dall'indice spaziale GiST); se 0.0 usa
60
+ ``ST_Intersects``. Default: 0.0.
59
61
  columns (list[str], optional): Colonne da includere nel risultato.
60
62
  exclude_columns (list[str], optional): Colonne da escludere.
61
63
  where (dict[str, Any], optional): Filtri aggiuntivi positivi.
@@ -85,12 +87,13 @@ class GeometryRepository[Entity: Base | Type[Base]](CrudRepository[Entity]):
85
87
 
86
88
  stmt = self._select(entity=self.entity, columns=columns, exclude_columns=exclude_columns)
87
89
 
88
- intersecting_geom = self._geom_condition(geom=geom, geom_srid=geom_srid, buffer=tolerance)
90
+ intersecting_geom = self._geom_condition(geom=geom, geom_srid=geom_srid)
89
91
 
90
92
  stmt = self.stmt_manager.prepare_statement(stmt=stmt, **where)
91
93
  stmt = self.stmt_manager.prepare_statement(stmt=stmt, neq=True, **not_where)
92
94
 
93
- stmt = stmt.where(self.geom_column.ST_Intersects(intersecting_geom))
95
+ 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)
94
97
  stmt = self.stmt_manager.limit_offset_condition(stmt=stmt, limit=limit)
95
98
 
96
99
  return self.execute(session, stmt)
@@ -147,11 +150,14 @@ class GeometryRepository[Entity: Base | Type[Base]](CrudRepository[Entity]):
147
150
  return self.execute(session, stmt)
148
151
 
149
152
  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,
150
- parsing_model: Type[BaseModel] | BaseModel = None) -> list[Entity]:
153
+ distance: bool = True, parsing_model: Type[BaseModel] | BaseModel = None) -> list[Entity]:
151
154
  """Restituisce le entità più vicine alla geometria fornita, ordinate per distanza.
152
155
 
153
156
  Aggiunge una colonna ``distance`` ai risultati con la distanza calcolata
154
- tramite ``ST_Distance``, e ordina i risultati in modo crescente.
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.
155
161
 
156
162
  Args:
157
163
  geom (WKBElement): Geometria di riferimento per il calcolo della distanza.
@@ -161,6 +167,10 @@ class GeometryRepository[Entity: Base | Type[Base]](CrudRepository[Entity]):
161
167
  limit (int): Numero di entità più vicine da restituire. Default: 1.
162
168
  where (dict[str, Any], optional): Filtri aggiuntivi.
163
169
  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.
164
174
  parsing_model (Type[BaseModel], optional): Modello alternativo.
165
175
 
166
176
  Returns:
@@ -171,7 +181,7 @@ class GeometryRepository[Entity: Base | Type[Base]](CrudRepository[Entity]):
171
181
  """
172
182
  keep_open = False if session is None else True
173
183
  session = self.open_session if session is None else session
174
- entities = self._nearest(session=session, geom=geom, where=where, columns=columns, exclude_columns=exclude_columns, limit=limit, geom_srid=geom_srid)
184
+ entities = self._nearest(session=session, geom=geom, where=where, columns=columns, exclude_columns=exclude_columns, limit=limit, geom_srid=geom_srid, distance=distance)
175
185
  return self._return(session=session, entities=entities, keep_open=keep_open, commit=False, parsing_model=parsing_model)
176
186
 
177
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):
@@ -181,13 +191,11 @@ class GeometryRepository[Entity: Base | Type[Base]](CrudRepository[Entity]):
181
191
 
182
192
  geom_condition = self._geom_condition(geom=geom, geom_srid=geom_srid)
183
193
 
184
- distance_col = self.geom_column.ST_Distance(geom_condition).label("distance")
185
-
186
194
  if distance:
187
- stmt = stmt.add_columns(distance_col)
195
+ stmt = stmt.add_columns(self.geom_column.ST_Distance(geom_condition).label("distance"))
188
196
 
189
197
  stmt = self.stmt_manager.prepare_statement(stmt=stmt, **where)
190
- stmt = stmt.order_by(literal_column("distance"))
198
+ stmt = stmt.order_by(self.geom_column.distance_centroid(geom_condition))
191
199
  stmt = self.stmt_manager.limit_offset_condition(stmt=stmt, limit=limit)
192
200
 
193
201
  rows = self.execute(session, stmt)
@@ -14,17 +14,23 @@ class BaseHandler:
14
14
 
15
15
  Attributes:
16
16
  columns (ReadOnlyColumnCollection): Colonne della tabella su cui operare.
17
+ array_columns (set[str]): Nomi delle colonne di tipo ``ARRAY``, pre-calcolati
18
+ in fase di inizializzazione.
17
19
  """
18
- __slots__ = "columns"
20
+ __slots__ = "columns", "array_columns"
19
21
 
20
22
  def __init__(self, columns: ReadOnlyColumnCollection[str, Column[Any]]):
21
23
  """Inizializza l'handler con la collezione di colonne dell'entità.
22
24
 
25
+ Pre-calcola una volta l'insieme dei nomi delle colonne di tipo ``ARRAY``,
26
+ evitando il confronto ``str(column.type)`` ad ogni filtro in ``_handle``.
27
+
23
28
  Args:
24
29
  columns (ReadOnlyColumnCollection): Colonne della tabella, tipicamente
25
30
  ottenute da ``Entity.__table__.c``.
26
31
  """
27
32
  self.columns = columns
33
+ self.array_columns = {column.name for column in columns if str(column.type) == "ARRAY"}
28
34
 
29
35
  def handle(self, stmt: Select | Update | Delete, neq: bool = False, where: dict[str, Any] = None):
30
36
  """Applica i filtri WHERE allo statement per tutte le coppie chiave-valore in ``where``.
@@ -62,7 +68,12 @@ class BaseHandler:
62
68
  Returns:
63
69
  ColumnElement: Espressione booleana SQLAlchemy.
64
70
  """
65
- eq_where = column.in_(condition) if isinstance(condition, list) else column == condition if str(column.type) != "ARRAY" else condition == column.any_()
71
+ if isinstance(condition, list):
72
+ eq_where = column.in_(condition)
73
+ elif column.name in self.array_columns:
74
+ eq_where = condition == column.any_()
75
+ else:
76
+ eq_where = column == condition
66
77
  if neq:
67
78
  eq_where = not_(eq_where)
68
79
  return eq_where
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-general-be-lib
3
- Version: 0.5.4
3
+ Version: 0.6.0
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
@@ -10,22 +10,22 @@ general/interface/base/__init__.py,sha256=SDlTi28M8rzJ9yiKefo5l7V3K109LnnBHW-SuZ
10
10
  general/interface/base/base_model.py,sha256=SVrYl1kSAj37yCScfO3Qe6ELyLxou65njpvbYmjTG00,2215
11
11
  general/interface/base/declarative_base.py,sha256=5hw2VpJHHebjixuI3d4wpkyAentGBGN54QXcniaoJ-A,5236
12
12
  general/interface/metadata/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
13
- general/interface/metadata/crud_metadata.py,sha256=z1fTh77K7fkhDgNus5Xrv4EYerbdlPVl4F1NLbSxqcM,8478
14
- general/interface/metadata/geom_metadata.py,sha256=CZPzlqos5XuDWn_FeKLxxYqOuGx1Bg9C0xH5-ZR0vXw,5326
13
+ general/interface/metadata/crud_metadata.py,sha256=HcnJkr33buIg8n8aWaH1Tw8QcmlyP_2Gyd1RcWCMtiE,22422
14
+ general/interface/metadata/geom_metadata.py,sha256=kc6jkWis-WPdWAP-hN6U97ENVjNjny5NE2j-llwZYOQ,11998
15
15
  general/interface/repository/__init__.py,sha256=EcRukwf61Ua-9-JfM_ingVv1wXNRV8HBrrWwjj8m23w,136
16
- general/interface/repository/crud_repository.py,sha256=wFO_OhyDDh_KxzQbO0QjACLacF83SMdbsOD2MJa6510,29210
17
- general/interface/repository/geometry_repository.py,sha256=bJVPhsr-fGhVTywdHwx9Qt_inWPHk5wqq9EFWFLfuGU,11181
16
+ general/interface/repository/crud_repository.py,sha256=kPuP_Jrguw9C_5dtn80NAVZ26gDcJnAZrOhwygWoD6Y,32095
17
+ general/interface/repository/geometry_repository.py,sha256=ULU-FnDATojHLDrObLPyAA3KErgX_tyYEeTU8h2O4Hs,11989
18
18
  general/interface/repository/many_to_many_repository.py,sha256=iEhWyD8nPZSAPL8Z2wXrK-aRBnoh3evaSN-voaf0EP0,8662
19
19
  general/interface/repository/view_repository.py,sha256=Sm591FUP0iBYlI-ULNWszD5fOvTGwXX0iYoeDbUqo34,5397
20
20
  general/interface/repository/handler/__init__.py,sha256=J-fc-5Dl_C821oy_EVRdyU-L2iXiNFriYJDbK4ODIMM,184
21
- general/interface/repository/handler/base_handler.py,sha256=YEdE-S1JnLMo0LZn_9rHjEUgDNEM-qjcbNZ_rhsBnpg,2800
21
+ general/interface/repository/handler/base_handler.py,sha256=hJkvXyVqnE2USmVWAko0Lnu02-537t4PERmBETJhXB0,3285
22
22
  general/interface/repository/handler/ilike_handler.py,sha256=_HHTvj6Tm1L-WvUy1S9faRChoRZni11i9mv76I4Xak0,3919
23
23
  general/interface/repository/handler/interval_handler.py,sha256=XxMQr6UuWnoCtbOOO7l80_Yja_aPKYtHhDfhFCRMaKg,4135
24
24
  general/interface/repository/handler/json_handler.py,sha256=7bS59seB-It5KHsWXTbHGsEvbQZ6lYyvX-d29BcRLVE,2956
25
25
  general/interface/repository/handler/json_ilike_handler.py,sha256=yDuKER3yqtnHpVWndF8PVEaqDThcq1EhZzLaqxbQ0bU,4491
26
26
  general/interface/repository/handler/statement_manager.py,sha256=sGDMU4fPkF0_9-SbDmAw8Vpel1Z7eImBs8fHO-5Lr_0,4429
27
- python_general_be_lib-0.5.4.dist-info/licenses/LICENSE,sha256=iUaO1XZyB9P3Tmog0OILuTisP6vXGe3QKz-4yRTxOFk,1069
28
- python_general_be_lib-0.5.4.dist-info/METADATA,sha256=oOe-yd6hkXQ1wumjhGiEO90OI7bRcZRJw0UlamThdaU,1384
29
- python_general_be_lib-0.5.4.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
30
- python_general_be_lib-0.5.4.dist-info/top_level.txt,sha256=tTZePW8_CNUqSgKFd2SEH72ZbnhS0OYjRsgcv0ikSFY,8
31
- python_general_be_lib-0.5.4.dist-info/RECORD,,
27
+ python_general_be_lib-0.6.0.dist-info/licenses/LICENSE,sha256=iUaO1XZyB9P3Tmog0OILuTisP6vXGe3QKz-4yRTxOFk,1069
28
+ python_general_be_lib-0.6.0.dist-info/METADATA,sha256=QKlhIJ_5M8jBwSxHTM7uorYD5q2VUTdAs43l6BSUQaM,1384
29
+ python_general_be_lib-0.6.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
30
+ python_general_be_lib-0.6.0.dist-info/top_level.txt,sha256=tTZePW8_CNUqSgKFd2SEH72ZbnhS0OYjRsgcv0ikSFY,8
31
+ python_general_be_lib-0.6.0.dist-info/RECORD,,