python-general-be-lib 0.10.0.post2__tar.gz → 0.11.0__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 (75) hide show
  1. {python_general_be_lib-0.10.0.post2 → python_general_be_lib-0.11.0}/PKG-INFO +1 -1
  2. {python_general_be_lib-0.10.0.post2 → python_general_be_lib-0.11.0}/general/interface/base/declarative_base.py +12 -8
  3. {python_general_be_lib-0.10.0.post2 → python_general_be_lib-0.11.0}/general/interface/metadata/crud_metadata.py +303 -51
  4. {python_general_be_lib-0.10.0.post2 → python_general_be_lib-0.11.0}/general/interface/metadata/geom_metadata.py +59 -5
  5. {python_general_be_lib-0.10.0.post2 → python_general_be_lib-0.11.0}/general/interface/repository/crud_repository.py +296 -42
  6. {python_general_be_lib-0.10.0.post2 → python_general_be_lib-0.11.0}/general/interface/repository/geometry_repository.py +6 -8
  7. {python_general_be_lib-0.10.0.post2 → python_general_be_lib-0.11.0}/general/interface/repository/handler/__init__.py +1 -1
  8. {python_general_be_lib-0.10.0.post2 → python_general_be_lib-0.11.0}/general/interface/repository/handler/handler.py +5 -24
  9. {python_general_be_lib-0.10.0.post2 → python_general_be_lib-0.11.0}/general/interface/repository/handler/statement_manager.py +2 -20
  10. {python_general_be_lib-0.10.0.post2 → python_general_be_lib-0.11.0}/general/interface/repository/many_to_many_repository.py +59 -12
  11. {python_general_be_lib-0.10.0.post2 → python_general_be_lib-0.11.0}/pyproject.toml +1 -1
  12. {python_general_be_lib-0.10.0.post2 → python_general_be_lib-0.11.0}/python_general_be_lib.egg-info/PKG-INFO +1 -1
  13. {python_general_be_lib-0.10.0.post2 → python_general_be_lib-0.11.0}/python_general_be_lib.egg-info/SOURCES.txt +7 -0
  14. python_general_be_lib-0.11.0/tests/test_execute_contratto.py +102 -0
  15. python_general_be_lib-0.11.0/tests/test_exists.py +87 -0
  16. python_general_be_lib-0.11.0/tests/test_find_one.py +107 -0
  17. python_general_be_lib-0.11.0/tests/test_get.py +165 -0
  18. python_general_be_lib-0.11.0/tests/test_many_to_many.py +522 -0
  19. python_general_be_lib-0.11.0/tests/test_metadata_geom.py +153 -0
  20. python_general_be_lib-0.11.0/tests/test_metadata_scorciatoie.py +197 -0
  21. {python_general_be_lib-0.10.0.post2 → python_general_be_lib-0.11.0}/tests/test_mixin.py +7 -3
  22. {python_general_be_lib-0.10.0.post2 → python_general_be_lib-0.11.0}/tests/test_negazione.py +2 -2
  23. {python_general_be_lib-0.10.0.post2 → python_general_be_lib-0.11.0}/tests/test_pipeline_handler.py +29 -186
  24. {python_general_be_lib-0.10.0.post2 → python_general_be_lib-0.11.0}/tests/test_query_count.py +15 -3
  25. {python_general_be_lib-0.10.0.post2 → python_general_be_lib-0.11.0}/tests/test_smoke_crud.py +41 -0
  26. {python_general_be_lib-0.10.0.post2 → python_general_be_lib-0.11.0}/tests/test_softdelete.py +11 -2
  27. {python_general_be_lib-0.10.0.post2 → python_general_be_lib-0.11.0}/tests/test_sql_snapshot.py +38 -1
  28. {python_general_be_lib-0.10.0.post2 → python_general_be_lib-0.11.0}/tests/test_to_pydantic.py +31 -0
  29. {python_general_be_lib-0.10.0.post2 → python_general_be_lib-0.11.0}/LICENSE +0 -0
  30. {python_general_be_lib-0.10.0.post2 → python_general_be_lib-0.11.0}/README.md +0 -0
  31. {python_general_be_lib-0.10.0.post2 → python_general_be_lib-0.11.0}/general/__init__.py +0 -0
  32. {python_general_be_lib-0.10.0.post2 → python_general_be_lib-0.11.0}/general/context.py +0 -0
  33. {python_general_be_lib-0.10.0.post2 → python_general_be_lib-0.11.0}/general/exception/__init__.py +0 -0
  34. {python_general_be_lib-0.10.0.post2 → python_general_be_lib-0.11.0}/general/exception/access_exceptions.py +0 -0
  35. {python_general_be_lib-0.10.0.post2 → python_general_be_lib-0.11.0}/general/exception/crud_exceptions.py +0 -0
  36. {python_general_be_lib-0.10.0.post2 → python_general_be_lib-0.11.0}/general/exception/exception_interface.py +0 -0
  37. {python_general_be_lib-0.10.0.post2 → python_general_be_lib-0.11.0}/general/interface/__init__.py +0 -0
  38. {python_general_be_lib-0.10.0.post2 → python_general_be_lib-0.11.0}/general/interface/base/__init__.py +0 -0
  39. {python_general_be_lib-0.10.0.post2 → python_general_be_lib-0.11.0}/general/interface/base/base_model.py +0 -0
  40. {python_general_be_lib-0.10.0.post2 → python_general_be_lib-0.11.0}/general/interface/metadata/__init__.py +0 -0
  41. {python_general_be_lib-0.10.0.post2 → python_general_be_lib-0.11.0}/general/interface/mixin/__init__.py +0 -0
  42. {python_general_be_lib-0.10.0.post2 → python_general_be_lib-0.11.0}/general/interface/mixin/audit_mixin.py +0 -0
  43. {python_general_be_lib-0.10.0.post2 → python_general_be_lib-0.11.0}/general/interface/mixin/softdelete_mixin.py +0 -0
  44. {python_general_be_lib-0.10.0.post2 → python_general_be_lib-0.11.0}/general/interface/mixin/timestamp_mixin.py +0 -0
  45. {python_general_be_lib-0.10.0.post2 → python_general_be_lib-0.11.0}/general/interface/repository/__init__.py +0 -0
  46. {python_general_be_lib-0.10.0.post2 → python_general_be_lib-0.11.0}/general/interface/repository/handler/base_handler.py +0 -0
  47. {python_general_be_lib-0.10.0.post2 → python_general_be_lib-0.11.0}/general/interface/repository/handler/ilike_handler.py +0 -0
  48. {python_general_be_lib-0.10.0.post2 → python_general_be_lib-0.11.0}/general/interface/repository/handler/interval_handler.py +0 -0
  49. {python_general_be_lib-0.10.0.post2 → python_general_be_lib-0.11.0}/general/interface/repository/handler/json_handler.py +0 -0
  50. {python_general_be_lib-0.10.0.post2 → python_general_be_lib-0.11.0}/general/interface/repository/handler/json_ilike_handler.py +0 -0
  51. {python_general_be_lib-0.10.0.post2 → python_general_be_lib-0.11.0}/general/interface/repository/handler/or_handler.py +0 -0
  52. {python_general_be_lib-0.10.0.post2 → python_general_be_lib-0.11.0}/general/interface/repository/handler/soft_delete_handler.py +0 -0
  53. {python_general_be_lib-0.10.0.post2 → python_general_be_lib-0.11.0}/general/interface/repository/view_repository.py +0 -0
  54. {python_general_be_lib-0.10.0.post2 → python_general_be_lib-0.11.0}/general/logger.py +0 -0
  55. {python_general_be_lib-0.10.0.post2 → python_general_be_lib-0.11.0}/general/paginator_dto.py +0 -0
  56. {python_general_be_lib-0.10.0.post2 → python_general_be_lib-0.11.0}/general/py.typed +0 -0
  57. {python_general_be_lib-0.10.0.post2 → python_general_be_lib-0.11.0}/python_general_be_lib.egg-info/dependency_links.txt +0 -0
  58. {python_general_be_lib-0.10.0.post2 → python_general_be_lib-0.11.0}/python_general_be_lib.egg-info/requires.txt +0 -0
  59. {python_general_be_lib-0.10.0.post2 → python_general_be_lib-0.11.0}/python_general_be_lib.egg-info/top_level.txt +0 -0
  60. {python_general_be_lib-0.10.0.post2 → python_general_be_lib-0.11.0}/setup.cfg +0 -0
  61. {python_general_be_lib-0.10.0.post2 → python_general_be_lib-0.11.0}/tests/test_caricamento_parziale.py +0 -0
  62. {python_general_be_lib-0.10.0.post2 → python_general_be_lib-0.11.0}/tests/test_compiled_cache.py +0 -0
  63. {python_general_be_lib-0.10.0.post2 → python_general_be_lib-0.11.0}/tests/test_entity_has_columns.py +0 -0
  64. {python_general_be_lib-0.10.0.post2 → python_general_be_lib-0.11.0}/tests/test_has_columns.py +0 -0
  65. {python_general_be_lib-0.10.0.post2 → python_general_be_lib-0.11.0}/tests/test_import_pubblici.py +0 -0
  66. {python_general_be_lib-0.10.0.post2 → python_general_be_lib-0.11.0}/tests/test_logger.py +0 -0
  67. {python_general_be_lib-0.10.0.post2 → python_general_be_lib-0.11.0}/tests/test_metadata_eccezioni.py +0 -0
  68. {python_general_be_lib-0.10.0.post2 → python_general_be_lib-0.11.0}/tests/test_metadata_paginazione.py +0 -0
  69. {python_general_be_lib-0.10.0.post2 → python_general_be_lib-0.11.0}/tests/test_metadata_pipeline.py +0 -0
  70. {python_general_be_lib-0.10.0.post2 → python_general_be_lib-0.11.0}/tests/test_ordinamento.py +0 -0
  71. {python_general_be_lib-0.10.0.post2 → python_general_be_lib-0.11.0}/tests/test_retrieve_mapper.py +0 -0
  72. {python_general_be_lib-0.10.0.post2 → python_general_be_lib-0.11.0}/tests/test_to_dict.py +0 -0
  73. {python_general_be_lib-0.10.0.post2 → python_general_be_lib-0.11.0}/tests/test_upsert_chiavi.py +0 -0
  74. {python_general_be_lib-0.10.0.post2 → python_general_be_lib-0.11.0}/tests/test_valori_falsy.py +0 -0
  75. {python_general_be_lib-0.10.0.post2 → python_general_be_lib-0.11.0}/tests/test_view_repository.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-general-be-lib
3
- Version: 0.10.0.post2
3
+ Version: 0.11.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
@@ -8,6 +8,8 @@ from pydantic import create_model, ConfigDict
8
8
  from sqlalchemy.inspection import inspect
9
9
  from sqlalchemy.orm import Mapper, as_declarative
10
10
 
11
+ from ...exception.crud_exceptions import HasNoAttributeException
12
+
11
13
 
12
14
  @as_declarative()
13
15
  class Base:
@@ -18,7 +20,8 @@ class Base:
18
20
  """
19
21
 
20
22
  @classmethod
21
- 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):
23
+ def to_pydantic(cls, model_name: str = None, full_optional: bool = False, default_values: dict[str, Any] = None, include_only: Sequence[str] = None,
24
+ exclude: Sequence[str] = None, **create_model_kwargs):
22
25
  """Generate a Pydantic model dynamically from the entity columns.
23
26
 
24
27
  Iterates over the ORM-mapped columns and builds the model fields honouring the
@@ -38,6 +41,9 @@ class Base:
38
41
  overriding the inferred defaults.
39
42
  include_only (Sequence[str], optional): Attribute names to restrict the model to.
40
43
  When ``None``, every mapped column is included.
44
+ exclude (Sequence[str], optional): Attribute names to leave out, applied after
45
+ ``include_only``: the two intersect, one choosing the set and the other
46
+ removing from it.
41
47
  **create_model_kwargs: Additional arguments passed to ``pydantic.create_model``.
42
48
 
43
49
  Returns:
@@ -49,14 +55,12 @@ class Base:
49
55
  if not default_values:
50
56
  default_values = dict()
51
57
  mapper: Mapper = inspect(cls)
58
+ for names in (include_only, exclude):
59
+ unknown = [name for name in names or () if name not in mapper.columns]
60
+ if unknown:
61
+ raise HasNoAttributeException(attr=", ".join(unknown))
52
62
 
53
- # ``mapper.columns`` è indicizzato per **nome di attributo** e non forza la
54
- # configurazione dei mapper: ``column_attrs``/``attrs``/``relationships`` la
55
- # forzerebbero, e chiamare ``to_pydantic`` a import-time — il pattern
56
- # documentato — romperebbe le relationship con forward-ref non ancora
57
- # risolvibili (stessa ragione per cui ``relationship_attributes`` è lazy).
58
- selected = [(name, column) for name, column in mapper.columns.items()
59
- if include_only is None or name in include_only]
63
+ selected = [(name, column) for name, column in mapper.columns.items() if (include_only is None or name in include_only) and (exclude is None or name not in exclude)]
60
64
 
61
65
  model_name = f"{cls.__name__}Model" if not model_name else model_name
62
66
  fields = dict()
@@ -4,11 +4,11 @@ import logging
4
4
  from typing import Optional, Any, Type, Iterable, Sequence
5
5
 
6
6
  from pydantic import BaseModel
7
- from sqlalchemy import MetaData, Engine, Column, Table, inspect, Connection, select, RowMapping, CursorResult
7
+ from sqlalchemy import ARRAY, MetaData, Engine, Column, Table, func, inspect, Connection, literal, select, Row, RowMapping, CursorResult
8
8
  from sqlalchemy.exc import NoSuchTableError
9
9
  from sqlalchemy.sql.functions import count
10
10
 
11
- from ...exception.crud_exceptions import HasNoAttributeException, InternalServerException, raise_http_exception
11
+ from ...exception.crud_exceptions import BadRequestException, EntityNotFoundException, HasNoAttributeException, InternalServerException, raise_http_exception
12
12
  from ...interface.repository.handler.base_handler import BaseHandler
13
13
  from ...interface.repository.handler.statement_manager import StatementManager
14
14
  from ...paginator_dto import PaginatorResponseModel, parse_pagination
@@ -295,10 +295,142 @@ class CrudMetadata:
295
295
  where = kwhere if kwhere else {}
296
296
  manager = self._statement_manager(table)
297
297
  stmt = self._select(table=table, columns=columns)
298
- stmt = self._add_orders_by_condition(stmt, table, orders_by)
298
+ stmt = self._add_orders_by_condition(stmt=stmt, table=table, orders_by=orders_by)
299
299
  stmt = manager.prepare_statement(stmt=stmt, **where)
300
- stmt = self._add_limit_offset_condition(stmt, limit)
301
- return self.execute(connection=connection, stmt=stmt, mapping=True)
300
+ stmt = manager.limit_offset_condition(stmt=stmt, limit=limit)
301
+
302
+ return self.execute(connection=connection, stmt=stmt).mappings().all()
303
+
304
+ def get(self, from_table: str, pk: Any, connection: Connection = None, columns: list[str] = None,
305
+ parsing_model: Type[BaseModel] | BaseModel = None, strict: bool = False, **kwhere):
306
+ """Return the row with the given primary key, or nothing.
307
+
308
+ Counterpart of ``CrudRepository.get`` without its identity map, which belongs to the
309
+ ORM session and has no equivalent on a ``Connection``: here the lookup is always a
310
+ filtered ``SELECT``, so the method is ``find_one`` on the primary key of the
311
+ reflected table. The keys of a dictionary ``pk`` are therefore **column names** and
312
+ not Python attribute names, as everywhere on this layer.
313
+
314
+ Args:
315
+ from_table (str): Name of the table.
316
+ pk (Any): Primary key value. A scalar, or a tuple for a composite key, or a
317
+ dictionary ``{column: value}``.
318
+ connection (Connection, optional): External connection. When ``None``, one is
319
+ opened and closed at the end.
320
+ columns (list[str], optional): Columns to select.
321
+ parsing_model (Type[BaseModel], optional): Model to serialize the row with.
322
+ strict (bool): If ``True``, raise ``EntityNotFoundException`` when no row
323
+ matches instead of returning ``None``. Default: ``False``.
324
+ **kwhere: Additional filters, going through the handler pipeline registered for
325
+ the table.
326
+
327
+ Returns:
328
+ BaseModel | RowMapping | None: The row, serialized when a ``parsing_model`` is
329
+ given, or ``None`` when nothing matches and ``strict`` is not set.
330
+
331
+ Raises:
332
+ BadRequestException: If the table has no primary key, or the number of values in
333
+ ``pk`` does not match its columns.
334
+ HasNoAttributeException: If a dictionary ``pk`` is missing a primary key column.
335
+ EntityNotFoundException: If no row matches and ``strict=True``.
336
+ """
337
+ keep_open = False if connection is None else True
338
+ connection = self.open_connection if connection is None else connection
339
+ table = self.get_table(name=from_table)
340
+ result = self._get(table=table, connection=connection, pk=pk, columns=columns, **kwhere)
341
+ rows = self._return(connection=connection, result=result, keep_open=keep_open, commit=False, parsing_model=parsing_model)
342
+ if rows:
343
+ return rows[0]
344
+ if strict:
345
+ raise EntityNotFoundException()
346
+ return None
347
+
348
+ def _get(self, table: Table, connection: Connection, pk: Any, columns: list[str] = None, **kwhere):
349
+ values = self._pk_values(table=table, pk=pk)
350
+ manager = self._statement_manager(table)
351
+ conditions = list(manager.conditions(where=dict(kwhere)))
352
+ stmt = self._select(table=table, columns=columns)
353
+ stmt = stmt.where(*[column == value for column, value in zip(table.primary_key.columns, values)], *conditions)
354
+ return self.execute(connection=connection, stmt=stmt).mappings().all()
355
+
356
+ def _pk_values(self, table: Table, pk: Any) -> tuple:
357
+ """Normalize a primary key into the tuple of its values, in column order.
358
+
359
+ Accepts a scalar for a single-column key, a tuple or a list for a composite one, and
360
+ a dictionary keyed by **column names**. A string is never unpacked, so a textual
361
+ primary key stays one value.
362
+
363
+ Args:
364
+ table (Table): Reflected table the primary key is read from.
365
+ pk (Any): Primary key as a scalar, a sequence or a dictionary.
366
+
367
+ Returns:
368
+ tuple: Values of the primary key columns, in the order of the columns.
369
+
370
+ Raises:
371
+ BadRequestException: If the table has no primary key, or the number of values
372
+ does not match its columns.
373
+ HasNoAttributeException: If a dictionary does not carry every primary key
374
+ column.
375
+ """
376
+ names = [column.name for column in table.primary_key.columns]
377
+ if not names:
378
+ raise BadRequestException(custom_message=f"la tabella {table.name} non ha una chiave primaria")
379
+
380
+ if isinstance(pk, dict):
381
+ missing = [name for name in names if name not in pk]
382
+ if missing:
383
+ raise HasNoAttributeException(attr=", ".join(missing))
384
+ return tuple(pk[name] for name in names)
385
+
386
+ values = tuple(pk) if isinstance(pk, (tuple, list)) else (pk,)
387
+ if len(values) != len(names):
388
+ raise BadRequestException(custom_message=f"la chiave primaria di {table.name} ha {len(names)} colonne, ricevuti {len(values)} valori")
389
+ return values
390
+
391
+ def find_one(self, from_table: str, connection: Connection = None, where: dict[str, Any] = None, columns: list[str] = None,
392
+ parsing_model: Type[BaseModel] | BaseModel = None, orders_by: dict[str, bool] = None, strict: bool = False, **kwhere):
393
+ """Return the first row matching the given criteria, or nothing.
394
+
395
+ Same read as ``find`` with ``limit=1``, returning one row instead of a sequence of
396
+ one. Without ``orders_by`` the row is unspecified, a ``LIMIT`` without ``ORDER BY``
397
+ having no defined order, and no default ordering is applied — the same choice as on
398
+ the ORM layer. It is not ``one()``: several matching rows are neither detected nor
399
+ complained about.
400
+
401
+ Args:
402
+ from_table (str): Name of the table.
403
+ connection (Connection, optional): External connection. When ``None``, one is
404
+ opened and closed at the end.
405
+ where (dict[str, Any], optional): Filters as a dictionary. Takes precedence over
406
+ ``**kwhere``.
407
+ columns (list[str], optional): Columns to select.
408
+ parsing_model (Type[BaseModel], optional): Model to serialize the row with.
409
+ orders_by (dict[str, bool], optional): Ordering as ``{column: ascending}``,
410
+ deciding which row comes first.
411
+ strict (bool): If ``True``, raise ``EntityNotFoundException`` when no row
412
+ matches instead of returning ``None``. Default: ``False``.
413
+ **kwhere: Additional filters as keyword arguments.
414
+
415
+ Returns:
416
+ BaseModel | RowMapping | None: The row, serialized when a ``parsing_model`` is
417
+ given, or ``None`` when nothing matches and ``strict`` is not set.
418
+
419
+ Raises:
420
+ EntityNotFoundException: If no row matches and ``strict=True``.
421
+ HasNoAttributeException: If a column in ``columns`` or ``orders_by`` does not
422
+ exist on the table.
423
+ """
424
+ keep_open = False if connection is None else True
425
+ connection = self.open_connection if connection is None else connection
426
+ table = self.get_table(name=from_table)
427
+ result = self._find(table=table, connection=connection, where=where, columns=columns, limit=1, orders_by=orders_by, **kwhere)
428
+ rows = self._return(connection=connection, result=result, keep_open=keep_open, commit=False, parsing_model=parsing_model)
429
+ if rows:
430
+ return rows[0]
431
+ if strict:
432
+ raise EntityNotFoundException()
433
+ return None
302
434
 
303
435
  def insert(self, from_table: str, connection: Connection = None, models: list[Type[BaseModel] | BaseModel] = None, values: list[dict[str, Any]] = None, returning: bool = True,
304
436
  parsing_model: Type[BaseModel] | BaseModel = None):
@@ -347,7 +479,8 @@ class CrudMetadata:
347
479
  if values:
348
480
  stmt = table.insert().values(values)
349
481
  stmt = stmt if not returning else stmt.returning(*table.c)
350
- return self.execute(connection=connection, stmt=stmt, mapping=True)
482
+ result = self.execute(connection=connection, stmt=stmt)
483
+ return result.mappings().all() if result.returns_rows else []
351
484
  else:
352
485
  return []
353
486
 
@@ -393,7 +526,8 @@ class CrudMetadata:
393
526
  stmt = self._statement_manager(table).prepare_statement(stmt=stmt, **where)
394
527
  stmt = stmt.values(**kwargs)
395
528
  stmt = stmt if not returning else stmt.returning(*table.c)
396
- return self.execute(connection=connection, stmt=stmt, mapping=True)
529
+ result = self.execute(connection=connection, stmt=stmt)
530
+ return result.mappings().all() if result.returns_rows else []
397
531
 
398
532
  def delete(self, from_table: str, connection: Connection = None, where: dict[str, Any] = None, **kwhere):
399
533
  """Delete the rows matching the given criteria.
@@ -437,40 +571,41 @@ class CrudMetadata:
437
571
  where = kwhere if kwhere else {}
438
572
  stmt = table.delete()
439
573
  stmt = self._statement_manager(table).prepare_statement(stmt=stmt, **where)
440
- return self.execute(connection=connection, stmt=stmt).rowcount
574
+ result = self.execute(connection=connection, stmt=stmt)
575
+ return result.rowcount
441
576
 
442
577
  # --------------
443
578
 
444
- def execute(self, connection: Connection, stmt, mapping: bool = False):
445
- """Execute a statement on the connection, handling the exceptions.
579
+ def execute(self, connection: Connection, stmt) -> CursorResult:
580
+ """Execute a statement on the connection and return the result as it is.
446
581
 
447
- A statement returning no rows — a DML without ``RETURNING`` — yields an empty list
448
- when ``mapping=True`` instead of raising: reading its rows would fail **after** the
449
- write has already happened, so a retry would duplicate it.
582
+ The method owns the error handling and nothing else: every exception goes to
583
+ ``_handle_exception``, which maps the error to an HTTP status through the same table
584
+ the ORM layer uses. Reading the result is left to the caller, which is the only one
585
+ knowing the shape it needs — ``.mappings().all()`` for rows, ``.scalar()`` for an
586
+ aggregate, ``.rowcount`` for a DML without ``RETURNING``. The result is bound to the
587
+ connection, so it has to be consumed before the connection is closed. Unlike the ORM
588
+ layer, ``Connection.execute`` always returns a ``CursorResult``: ``returns_rows``
589
+ tells a statement that produced rows from one that did not, and the callers building
590
+ an ``INSERT`` or an ``UPDATE`` with an optional ``RETURNING`` use it, because reading
591
+ the rows of a DML without it would fail **after** the write has already happened.
450
592
 
451
593
  Args:
452
594
  connection (Connection): Connection to execute the statement on.
453
595
  stmt: SQLAlchemy Core statement to execute.
454
- mapping (bool): If ``True``, return the rows as mappings through
455
- ``result.mappings().all()``; otherwise return the raw ``CursorResult``.
456
- Default: ``False``.
457
596
 
458
597
  Returns:
459
- Sequence[RowMapping] | CursorResult: Rows as mappings when ``mapping=True``,
460
- the raw result otherwise.
598
+ CursorResult: Result of the execution, not consumed.
461
599
 
462
600
  Raises:
463
- Exception: On error during the execution, through ``_handle_exception``.
601
+ ServiceUnavailableException | EntityNotFoundException | BadRequestException:
602
+ On SQLAlchemy errors.
464
603
  """
465
604
  try:
466
605
  result = connection.execute(stmt)
467
606
  except Exception as e:
468
607
  self._handle_exception(connection, e)
469
608
  else:
470
- if mapping:
471
- # ``returns_rows`` è falso su un DML senza RETURNING: leggerne le righe
472
- # solleverebbe ``ResourceClosedError`` **dopo** che la scrittura è avvenuta
473
- result = result.mappings().all() if result.returns_rows else []
474
609
  return result
475
610
 
476
611
  def _get_table(self, name: str):
@@ -569,7 +704,147 @@ class CrudMetadata:
569
704
  where = kwhere if kwhere else {}
570
705
  stmt = select(count()).select_from(table)
571
706
  stmt = self._statement_manager(table).prepare_statement(stmt=stmt, **where)
572
- return self.execute(connection=connection, stmt=stmt).scalar()
707
+ result = self.execute(connection=connection, stmt=stmt)
708
+ return result.scalar()
709
+
710
+ def exists(self, from_table: str, connection: Connection = None, where: dict[str, Any] = None, **kwhere) -> bool:
711
+ """Tell whether at least one row matches the given criteria.
712
+
713
+ Runs a ``SELECT 1 ... LIMIT 1``, so the database stops at the first matching row
714
+ instead of counting every one of them, which is what answering the same question
715
+ with ``count() > 0`` costs. The filters go through the handler pipeline registered
716
+ for the table.
717
+
718
+ Args:
719
+ from_table (str): Name of the table.
720
+ connection (Connection, optional): External connection. When ``None``, one is
721
+ opened and closed at the end.
722
+ where (dict[str, Any], optional): Filters as a dictionary.
723
+ **kwhere: Filters as keyword arguments.
724
+
725
+ Returns:
726
+ bool: ``True`` if at least one row matches, ``False`` otherwise.
727
+ """
728
+ keep_open = False if connection is None else True
729
+ connection = self.open_connection if connection is None else connection
730
+ table = self.get_table(name=from_table)
731
+ exists_ = self._exists(table=table, connection=connection, where=where, **kwhere)
732
+ if not keep_open:
733
+ connection.close()
734
+ return exists_
735
+
736
+ def _exists(self, table: Table, connection: Connection, where: dict[str, Any] = None, **kwhere) -> bool:
737
+ if where is None:
738
+ where = kwhere if kwhere else {}
739
+ stmt = select(literal(1)).select_from(table).limit(1)
740
+ stmt = self._statement_manager(table).prepare_statement(stmt=stmt, **where)
741
+ result = self.execute(connection=connection, stmt=stmt)
742
+ return result.first() is not None
743
+
744
+ def distinct_values(self, from_table: str, column: str, connection: Connection = None, where: dict[str, Any] = None, **kwhere) -> list[Any]:
745
+ """Return the distinct values of one column among the rows matching ``where``.
746
+
747
+ The deduplication runs on the database side with a ``SELECT DISTINCT``, and on an
748
+ ``ARRAY`` column the elements are unrolled with ``unnest`` first, so what comes back
749
+ are the distinct **elements** and not the distinct arrays. The values are plain and
750
+ not serialized: the method takes no ``parsing_model``.
751
+
752
+ The order is whatever the database returns, a ``SELECT DISTINCT`` without
753
+ ``ORDER BY`` having none, and a ``NULL`` in the column is a value like the others
754
+ and comes back as ``None`` — on an ``ARRAY`` column it does not, because unrolling
755
+ a ``NULL`` array yields no element at all.
756
+
757
+ Args:
758
+ from_table (str): Name of the table.
759
+ column (str): Column to read the distinct values of.
760
+ connection (Connection, optional): External connection. When ``None``, one is
761
+ opened and closed at the end.
762
+ where (dict[str, Any], optional): Filters as a dictionary.
763
+ **kwhere: Filters as keyword arguments.
764
+
765
+ Returns:
766
+ list[Any]: Distinct values of the column.
767
+
768
+ Raises:
769
+ KeyError: If ``column`` is not a column of the table.
770
+ """
771
+ keep_open = False if connection is None else True
772
+ connection = self.open_connection if connection is None else connection
773
+ table = self.get_table(name=from_table)
774
+ values = self._distinct_values(table=table, connection=connection, column=column, where=where, **kwhere)
775
+ if not keep_open:
776
+ connection.close()
777
+ return values
778
+
779
+ def _distinct_values(self, table: Table, connection: Connection, column: str, where: dict[str, Any] = None, **kwhere) -> list[Any]:
780
+ if where is None:
781
+ where = kwhere if kwhere else {}
782
+ value_col = table.c[column]
783
+ if isinstance(value_col.type, ARRAY):
784
+ unrolled = func.unnest(value_col).table_valued("v", joins_implicitly=True).render_derived()
785
+ stmt = select(unrolled.c.v).select_from(table, unrolled).distinct()
786
+ else:
787
+ stmt = select(value_col).distinct()
788
+ stmt = self._statement_manager(table).prepare_statement(stmt=stmt, **where)
789
+ result = self.execute(connection=connection, stmt=stmt)
790
+ return result.scalars().all()
791
+
792
+ def distinct_pairs(self, from_table: str, key: str, value: str, connection: Connection = None, where: dict[str, Any] = None, **kwhere) -> list[Row]:
793
+ """Return the distinct ``(key, value)`` pairs among the rows matching ``where``.
794
+
795
+ Counterpart of ``CrudRepository.distinct_pairs``, typically to populate the options
796
+ of a client-side filter: the deduplication runs on the database side with a
797
+ ``SELECT DISTINCT`` instead of loading every row and deduplicating in Python. When
798
+ **both** columns are ``ARRAY`` they are unrolled in parallel with ``unnest``; when
799
+ only the key is, it is unrolled on its own and the label is read from the row,
800
+ because a multi-argument ``unnest`` requires every argument to be an array.
801
+
802
+ Unlike everything else on this layer it returns positional ``Row`` tuples and not
803
+ ``RowMapping``, and takes no ``parsing_model``: the names of the two columns are not
804
+ the same on the two paths — the real column names on a scalar key, ``k`` and ``v``
805
+ on an ``ARRAY`` one, which is how the table-valued function names them — so reading
806
+ them by name would work on one path and break on the other. The position is the only
807
+ stable access, exactly as on the ORM layer.
808
+
809
+ Args:
810
+ from_table (str): Name of the table.
811
+ key (str): Key column.
812
+ value (str): Label column.
813
+ connection (Connection, optional): External connection. When ``None``, one is
814
+ opened and closed at the end.
815
+ where (dict[str, Any], optional): Filters as a dictionary.
816
+ **kwhere: Filters as keyword arguments.
817
+
818
+ Returns:
819
+ list[Row]: Distinct ``(key, value)`` pairs.
820
+
821
+ Raises:
822
+ KeyError: If ``key`` or ``value`` is not a column of the table.
823
+ """
824
+ keep_open = False if connection is None else True
825
+ connection = self.open_connection if connection is None else connection
826
+ table = self.get_table(name=from_table)
827
+ pairs = self._distinct_pairs(table=table, connection=connection, key=key, value=value, where=where, **kwhere)
828
+ if not keep_open:
829
+ connection.close()
830
+ return pairs
831
+
832
+ def _distinct_pairs(self, table: Table, connection: Connection, key: str, value: str, where: dict[str, Any] = None, **kwhere) -> list[Row]:
833
+ if where is None:
834
+ where = kwhere if kwhere else {}
835
+ key_col, value_col = table.c[key], table.c[value]
836
+ if isinstance(key_col.type, ARRAY) and isinstance(value_col.type, ARRAY):
837
+ unrolled = func.unnest(key_col, value_col).table_valued("k", "v", joins_implicitly=True).render_derived()
838
+ stmt = select(unrolled.c.k, unrolled.c.v).select_from(table, unrolled).distinct()
839
+ elif isinstance(key_col.type, ARRAY):
840
+ # ``unnest`` a più argomenti pretende che siano **tutti** array
841
+ unrolled = func.unnest(key_col).table_valued("k", joins_implicitly=True).render_derived()
842
+ stmt = select(unrolled.c.k, value_col).select_from(table, unrolled).distinct()
843
+ else:
844
+ stmt = select(key_col, value_col).distinct()
845
+ stmt = self._statement_manager(table).prepare_statement(stmt=stmt, **where)
846
+ result = self.execute(connection=connection, stmt=stmt)
847
+ return result.all()
573
848
 
574
849
  def paging_find(self, from_table: str, connection: Connection = None, where: dict[str, Any] = None, columns: list[str] = None, limit: int = 0, page: int = 1,
575
850
  parsing_model: Type[BaseModel] = None, orders_by: dict[str, bool] = None, **kwhere) -> PaginatorResponseModel:
@@ -637,14 +912,12 @@ class CrudMetadata:
637
912
  manager = self._statement_manager(table)
638
913
  stmt = self._select(table=table, columns=columns)
639
914
  stmt = self._add_orders_by_condition(stmt, table, orders_by)
640
- stmt = self._add_limit_offset_condition(stmt, limit, page)
641
- # copia: ``conditions`` consuma le chiavi con ``pop`` e il ``where`` è del chiamante
915
+ stmt = manager.limit_offset_condition(stmt=stmt, limit=limit,page=page)
642
916
  conditions = manager.conditions(where=dict(where))
643
917
  if conditions:
644
918
  stmt = stmt.where(*conditions)
645
- stmt = manager.options(stmt=stmt)
646
919
 
647
- rows = self.execute(connection=connection, stmt=stmt, mapping=True)
920
+ rows = self.execute(connection=connection, stmt=stmt).mappings().all()
648
921
 
649
922
  read = len(rows)
650
923
  if not limit or (read < limit and page == 1):
@@ -671,7 +944,8 @@ class CrudMetadata:
671
944
  stmt = select(count()).select_from(table)
672
945
  if where:
673
946
  stmt = stmt.where(*where)
674
- return self.execute(connection=connection, stmt=stmt).scalar()
947
+ result = self.execute(connection=connection, stmt=stmt)
948
+ return result.scalar()
675
949
 
676
950
  def _add_orders_by_condition(self, stmt, table: Table, orders_by: dict[str, bool] = None):
677
951
  """Add one ORDER BY clause per entry of ``orders_by``, in insertion order.
@@ -692,25 +966,3 @@ class CrudMetadata:
692
966
  for order_by, asc in (orders_by or {}).items():
693
967
  stmt = manager.order_by_condition(stmt=stmt, order_by=order_by, asc=asc)
694
968
  return stmt
695
-
696
- def _add_limit_offset_condition(self, stmt, limit: int, page: int = None):
697
- """Add the LIMIT and OFFSET clauses to the statement for pagination.
698
-
699
- Nothing is applied when ``limit`` is 0 or negative. The offset is computed as
700
- ``(page - 1) * limit``.
701
-
702
- Args:
703
- stmt: Statement LIMIT/OFFSET apply to.
704
- limit (int): Maximum number of rows. 0 means no limit.
705
- page (int, optional): Page number, 1-based. If ``None`` or <= 0, no offset is
706
- applied.
707
-
708
- Returns:
709
- Statement with LIMIT and/or OFFSET applied.
710
- """
711
- if limit and limit > 0:
712
- stmt = stmt.limit(limit)
713
- if page and page > 0:
714
- offset = (page - 1) * limit
715
- stmt = stmt.offset(offset)
716
- return stmt
@@ -13,7 +13,8 @@ class GeomMetadata(CrudMetadata):
13
13
  """Core repository with PostGIS spatial operations on reflected tables.
14
14
 
15
15
  Extends ``CrudMetadata``, that is SQLAlchemy Core without the ORM, adding
16
- intersection and containment geographic queries through GeoAlchemy2. Unlike
16
+ intersection, containment and nearest-neighbour geographic queries through
17
+ GeoAlchemy2. Unlike
17
18
  ``GeometryRepository``, the geometric column and its SRID are not fixed on the class
18
19
  but passed as arguments to every method, together with the table name.
19
20
 
@@ -113,8 +114,8 @@ class GeomMetadata(CrudMetadata):
113
114
 
114
115
  stmt = stmt.where(*eq_where)
115
116
 
116
- stmt = self._add_limit_offset_condition(stmt, limit)
117
- return self.execute(connection=connection, stmt=stmt, mapping=True)
117
+ stmt = manager.limit_offset_condition(stmt=stmt, limit=limit)
118
+ return self.execute(connection=connection, stmt=stmt).mappings().all()
118
119
 
119
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,
120
121
  where: dict[str, Any] = None, limit: int = 0, contained: bool = False, geom_srid: int = 4326, parsing_model: Type[BaseModel] | BaseModel = None):
@@ -176,6 +177,8 @@ class GeomMetadata(CrudMetadata):
176
177
  if where is None:
177
178
  where = {}
178
179
 
180
+ manager = self._statement_manager(table)
181
+
179
182
  stmt = self._select(table, columns)
180
183
  eq_where = list(self._statement_manager(table).conditions(where=dict(where)))
181
184
 
@@ -185,8 +188,59 @@ class GeomMetadata(CrudMetadata):
185
188
  eq_where.append(geom_condition)
186
189
 
187
190
  stmt = stmt.where(*eq_where)
188
- stmt = self._add_limit_offset_condition(stmt, limit)
189
- return self.execute(connection=connection, stmt=stmt, mapping=True)
191
+ stmt = manager.limit_offset_condition(stmt=stmt, limit=limit)
192
+ return self.execute(connection=connection, stmt=stmt).mappings().all()
193
+
194
+ def nearest(self, from_table: str, geom: WKBElement, polygon_column_name: str, polygon_column_srid: int, connection: Connection = None, columns: list[str] = None,
195
+ limit: int = 1, where: dict[str, Any] = None, geom_srid: int = 4326, parsing_model: Type[BaseModel] | BaseModel = None):
196
+ """Return the rows closest to the given geometry, ordered by distance.
197
+
198
+ Counterpart of ``GeometryRepository.nearest`` on a reflected table. The ordering
199
+ uses the KNN ``<->`` operator (``distance_centroid``) instead of ``ST_Distance``:
200
+ with a ``LIMIT`` set, PostGIS can serve the nearest-neighbour search through the
201
+ GiST spatial index, avoiding the distance computation on every row. Only the rows
202
+ are returned, without the distance value, which is used for the database-side
203
+ ordering alone.
204
+
205
+ Args:
206
+ from_table (str): Name of the table.
207
+ geom (WKBElement): Reference geometry for the proximity computation.
208
+ polygon_column_name (str): Name of the geometric column of the table.
209
+ polygon_column_srid (int): SRID of the geometric column.
210
+ connection (Connection, optional): External connection. When ``None``, one is
211
+ opened and closed at the end.
212
+ columns (list[str], optional): Columns to select.
213
+ limit (int): Number of closest rows to return. Default: 1.
214
+ where (dict[str, Any], optional): Additional filters, going through the handler
215
+ pipeline registered for the table.
216
+ geom_srid (int): SRID of the given geometry. Default: 4326.
217
+ parsing_model (Type[BaseModel], optional): Model to serialize the rows with.
218
+
219
+ Returns:
220
+ list[BaseModel] | Sequence[RowMapping]: Rows from the closest to the farthest.
221
+
222
+ Raises:
223
+ HasNoAttributeException: If a column in ``columns`` does not exist on the table.
224
+ """
225
+ keep_open = False if connection is None else True
226
+ connection = self.open_connection if connection is None else connection
227
+ table = self.get_table(name=from_table)
228
+ polygon_column = table.c[polygon_column_name]
229
+ result = self._nearest(table=table, connection=connection, polygon_column=polygon_column, polygon_column_srid=polygon_column_srid,
230
+ geom=geom, columns=columns, limit=limit, where=where, geom_srid=geom_srid)
231
+ return self._return(connection=connection, result=result, keep_open=keep_open, commit=False, parsing_model=parsing_model)
232
+
233
+ def _nearest(self, table: Table, geom: WKBElement, polygon_column: Column, polygon_column_srid: int, connection: Connection = None, columns: list[str] = None,
234
+ limit: int = 1, where: dict[str, Any] = None, geom_srid: int = 4326):
235
+ if where is None:
236
+ where = {}
237
+ manager = self._statement_manager(table)
238
+ stmt = self._select(table, columns)
239
+ geom_condition = self._geom_condition(geom=geom, geom_srid=geom_srid, polygon_column_srid=polygon_column_srid)
240
+ stmt = manager.prepare_statement(stmt=stmt, **where)
241
+ stmt = stmt.order_by(polygon_column.distance_centroid(geom_condition))
242
+ stmt = manager.limit_offset_condition(stmt=stmt, limit=limit)
243
+ return self.execute(connection=connection, stmt=stmt).mappings().all()
190
244
 
191
245
  def _geom_condition(self, geom: WKBElement, polygon_column_srid: int = 4326, geom_srid: int = 4326, buffer: float = 0):
192
246
  """Prepare the geometry for the queries applying reprojection and buffer.