python-general-be-lib 0.9.1__tar.gz → 0.10.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.

Potentially problematic release.


This version of python-general-be-lib might be problematic. Click here for more details.

Files changed (68) hide show
  1. {python_general_be_lib-0.9.1 → python_general_be_lib-0.10.0}/PKG-INFO +1 -1
  2. python_general_be_lib-0.10.0/general/context.py +31 -0
  3. {python_general_be_lib-0.9.1 → python_general_be_lib-0.10.0}/general/exception/__init__.py +6 -3
  4. {python_general_be_lib-0.9.1 → python_general_be_lib-0.10.0}/general/exception/crud_exceptions.py +37 -4
  5. {python_general_be_lib-0.9.1 → python_general_be_lib-0.10.0}/general/interface/base/declarative_base.py +36 -13
  6. {python_general_be_lib-0.9.1 → python_general_be_lib-0.10.0}/general/interface/metadata/crud_metadata.py +259 -48
  7. {python_general_be_lib-0.9.1 → python_general_be_lib-0.10.0}/general/interface/metadata/geom_metadata.py +6 -3
  8. python_general_be_lib-0.10.0/general/interface/mixin/__init__.py +20 -0
  9. python_general_be_lib-0.10.0/general/interface/mixin/audit_mixin.py +34 -0
  10. python_general_be_lib-0.10.0/general/interface/mixin/softdelete_mixin.py +43 -0
  11. python_general_be_lib-0.10.0/general/interface/mixin/timestamp_mixin.py +35 -0
  12. {python_general_be_lib-0.9.1 → python_general_be_lib-0.10.0}/general/interface/repository/crud_repository.py +36 -28
  13. {python_general_be_lib-0.9.1 → python_general_be_lib-0.10.0}/general/interface/repository/handler/__init__.py +2 -0
  14. python_general_be_lib-0.10.0/general/interface/repository/handler/soft_delete_handler.py +83 -0
  15. {python_general_be_lib-0.9.1 → python_general_be_lib-0.10.0}/general/paginator_dto.py +31 -2
  16. {python_general_be_lib-0.9.1 → python_general_be_lib-0.10.0}/pyproject.toml +1 -1
  17. {python_general_be_lib-0.9.1 → python_general_be_lib-0.10.0}/python_general_be_lib.egg-info/PKG-INFO +1 -1
  18. {python_general_be_lib-0.9.1 → python_general_be_lib-0.10.0}/python_general_be_lib.egg-info/SOURCES.txt +13 -0
  19. {python_general_be_lib-0.9.1 → python_general_be_lib-0.10.0}/tests/test_import_pubblici.py +3 -0
  20. python_general_be_lib-0.10.0/tests/test_metadata_eccezioni.py +142 -0
  21. python_general_be_lib-0.10.0/tests/test_metadata_paginazione.py +202 -0
  22. python_general_be_lib-0.10.0/tests/test_metadata_pipeline.py +200 -0
  23. python_general_be_lib-0.10.0/tests/test_mixin.py +217 -0
  24. python_general_be_lib-0.10.0/tests/test_retrieve_mapper.py +222 -0
  25. python_general_be_lib-0.10.0/tests/test_softdelete.py +291 -0
  26. python_general_be_lib-0.10.0/tests/test_upsert_chiavi.py +184 -0
  27. {python_general_be_lib-0.9.1 → python_general_be_lib-0.10.0}/LICENSE +0 -0
  28. {python_general_be_lib-0.9.1 → python_general_be_lib-0.10.0}/README.md +0 -0
  29. {python_general_be_lib-0.9.1 → python_general_be_lib-0.10.0}/general/__init__.py +0 -0
  30. {python_general_be_lib-0.9.1 → python_general_be_lib-0.10.0}/general/exception/access_exceptions.py +0 -0
  31. {python_general_be_lib-0.9.1 → python_general_be_lib-0.10.0}/general/exception/exception_interface.py +0 -0
  32. {python_general_be_lib-0.9.1 → python_general_be_lib-0.10.0}/general/interface/__init__.py +0 -0
  33. {python_general_be_lib-0.9.1 → python_general_be_lib-0.10.0}/general/interface/base/__init__.py +0 -0
  34. {python_general_be_lib-0.9.1 → python_general_be_lib-0.10.0}/general/interface/base/base_model.py +0 -0
  35. {python_general_be_lib-0.9.1 → python_general_be_lib-0.10.0}/general/interface/metadata/__init__.py +0 -0
  36. {python_general_be_lib-0.9.1 → python_general_be_lib-0.10.0}/general/interface/repository/__init__.py +0 -0
  37. {python_general_be_lib-0.9.1 → python_general_be_lib-0.10.0}/general/interface/repository/geometry_repository.py +0 -0
  38. {python_general_be_lib-0.9.1 → python_general_be_lib-0.10.0}/general/interface/repository/handler/base_handler.py +0 -0
  39. {python_general_be_lib-0.9.1 → python_general_be_lib-0.10.0}/general/interface/repository/handler/handler.py +0 -0
  40. {python_general_be_lib-0.9.1 → python_general_be_lib-0.10.0}/general/interface/repository/handler/ilike_handler.py +0 -0
  41. {python_general_be_lib-0.9.1 → python_general_be_lib-0.10.0}/general/interface/repository/handler/interval_handler.py +0 -0
  42. {python_general_be_lib-0.9.1 → python_general_be_lib-0.10.0}/general/interface/repository/handler/json_handler.py +0 -0
  43. {python_general_be_lib-0.9.1 → python_general_be_lib-0.10.0}/general/interface/repository/handler/json_ilike_handler.py +0 -0
  44. {python_general_be_lib-0.9.1 → python_general_be_lib-0.10.0}/general/interface/repository/handler/or_handler.py +0 -0
  45. {python_general_be_lib-0.9.1 → python_general_be_lib-0.10.0}/general/interface/repository/handler/statement_manager.py +0 -0
  46. {python_general_be_lib-0.9.1 → python_general_be_lib-0.10.0}/general/interface/repository/many_to_many_repository.py +0 -0
  47. {python_general_be_lib-0.9.1 → python_general_be_lib-0.10.0}/general/interface/repository/view_repository.py +0 -0
  48. {python_general_be_lib-0.9.1 → python_general_be_lib-0.10.0}/general/logger.py +0 -0
  49. {python_general_be_lib-0.9.1 → python_general_be_lib-0.10.0}/general/py.typed +0 -0
  50. {python_general_be_lib-0.9.1 → python_general_be_lib-0.10.0}/python_general_be_lib.egg-info/dependency_links.txt +0 -0
  51. {python_general_be_lib-0.9.1 → python_general_be_lib-0.10.0}/python_general_be_lib.egg-info/requires.txt +0 -0
  52. {python_general_be_lib-0.9.1 → python_general_be_lib-0.10.0}/python_general_be_lib.egg-info/top_level.txt +0 -0
  53. {python_general_be_lib-0.9.1 → python_general_be_lib-0.10.0}/setup.cfg +0 -0
  54. {python_general_be_lib-0.9.1 → python_general_be_lib-0.10.0}/tests/test_caricamento_parziale.py +0 -0
  55. {python_general_be_lib-0.9.1 → python_general_be_lib-0.10.0}/tests/test_compiled_cache.py +0 -0
  56. {python_general_be_lib-0.9.1 → python_general_be_lib-0.10.0}/tests/test_entity_has_columns.py +0 -0
  57. {python_general_be_lib-0.9.1 → python_general_be_lib-0.10.0}/tests/test_has_columns.py +0 -0
  58. {python_general_be_lib-0.9.1 → python_general_be_lib-0.10.0}/tests/test_logger.py +0 -0
  59. {python_general_be_lib-0.9.1 → python_general_be_lib-0.10.0}/tests/test_negazione.py +0 -0
  60. {python_general_be_lib-0.9.1 → python_general_be_lib-0.10.0}/tests/test_ordinamento.py +0 -0
  61. {python_general_be_lib-0.9.1 → python_general_be_lib-0.10.0}/tests/test_pipeline_handler.py +0 -0
  62. {python_general_be_lib-0.9.1 → python_general_be_lib-0.10.0}/tests/test_query_count.py +0 -0
  63. {python_general_be_lib-0.9.1 → python_general_be_lib-0.10.0}/tests/test_smoke_crud.py +0 -0
  64. {python_general_be_lib-0.9.1 → python_general_be_lib-0.10.0}/tests/test_sql_snapshot.py +0 -0
  65. {python_general_be_lib-0.9.1 → python_general_be_lib-0.10.0}/tests/test_to_dict.py +0 -0
  66. {python_general_be_lib-0.9.1 → python_general_be_lib-0.10.0}/tests/test_to_pydantic.py +0 -0
  67. {python_general_be_lib-0.9.1 → python_general_be_lib-0.10.0}/tests/test_valori_falsy.py +0 -0
  68. {python_general_be_lib-0.9.1 → python_general_be_lib-0.10.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.9.1
3
+ Version: 0.10.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
@@ -0,0 +1,31 @@
1
+ """Context of the operation in progress.
2
+
3
+ Values the library **reads** and the consumer sets, kept in ``ContextVar`` objects rather
4
+ than in globals: the value is per task, so concurrent requests served by the same process
5
+ do not overwrite each other.
6
+
7
+ The module imports nothing from the library and nothing from SQLAlchemy, so a middleware
8
+ can set the context without paying for the data access layer.
9
+ """
10
+
11
+ __all__ = ["current_user"]
12
+
13
+ from contextvars import ContextVar
14
+ from typing import Optional
15
+
16
+ current_user: ContextVar[Optional[str]] = ContextVar("current_user", default=None)
17
+ """Identifier of the user the current operation is attributed to.
18
+
19
+ Read by ``AuditMixin`` to fill ``create_uid``/``write_uid``. Setting it is up to the
20
+ consumer, typically in the authentication middleware::
21
+
22
+ from general.context import current_user
23
+
24
+ @app.middleware("http")
25
+ async def track_user(request, call_next):
26
+ current_user.set(request.state.user_id)
27
+ return await call_next(request)
28
+
29
+ Left unset it holds ``None``, which is what ends up in the columns for the operations
30
+ having no user behind them: scheduled jobs, migrations, a console session.
31
+ """
@@ -2,9 +2,11 @@
2
2
 
3
3
  They all extend ``ExceptionInterface``, which in turn extends
4
4
  ``fastapi.HTTPException``: raised inside an endpoint they are automatically
5
- serialized into the response with their own status code. The mapping applied by
6
- ``CrudRepository._handle_exception`` to the SQLAlchemy errors is documented in
7
- ``crud_exceptions``.
5
+ serialized into the response with their own status code.
6
+
7
+ ``raise_http_exception`` is the mapping the data access layers apply to the SQLAlchemy
8
+ errors, shared by ``CrudRepository`` and ``CrudMetadata`` so that the same error gets the
9
+ same status on both.
8
10
  """
9
11
 
10
12
  from .access_exceptions import *
@@ -12,6 +14,7 @@ from .crud_exceptions import *
12
14
  from .exception_interface import *
13
15
 
14
16
  __all__ = [
17
+ "raise_http_exception",
15
18
  "ExceptionInterface",
16
19
  "BadRequestException",
17
20
  "EntityNotFoundException",
@@ -1,12 +1,14 @@
1
1
  """HTTP exceptions for the persistence operations.
2
2
 
3
- These are the exceptions ``CrudRepository._handle_exception`` maps the SQLAlchemy
4
- errors onto: ``OperationalError`` to ``ServiceUnavailableException`` (503),
5
- ``NoResultFound`` to ``EntityNotFoundException`` (404), ``DataError`` and
6
- ``IntegrityError`` to ``BadRequestException`` (400).
3
+ These are the exceptions the data access layers map the SQLAlchemy errors onto:
4
+ ``OperationalError`` to ``ServiceUnavailableException`` (503), ``NoResultFound`` to
5
+ ``EntityNotFoundException`` (404), ``DataError`` and ``IntegrityError`` to
6
+ ``BadRequestException`` (400). The mapping itself lives in ``raise_http_exception``, so
7
+ that ``CrudRepository`` and ``CrudMetadata`` share one table instead of two copies.
7
8
  """
8
9
 
9
10
  __all__ = [
11
+ "raise_http_exception",
10
12
  "BadRequestException",
11
13
  "EntityNotFoundException",
12
14
  "ServiceUnavailableException",
@@ -15,6 +17,8 @@ __all__ = [
15
17
  "InternalServerException",
16
18
  ]
17
19
 
20
+ from sqlalchemy.exc import DataError, IntegrityError, NoResultFound, OperationalError
21
+
18
22
  from .exception_interface import ExceptionInterface
19
23
 
20
24
 
@@ -87,3 +91,32 @@ class InternalServerException(ExceptionInterface):
87
91
  """
88
92
  status_code = 500
89
93
  default_message = "Impossibile gestire la richiesta al momento"
94
+
95
+
96
+ def raise_http_exception(e: Exception):
97
+ """Re-raise a SQLAlchemy error as the matching HTTP exception.
98
+
99
+ Translates the error families the data access layers can attribute to a specific
100
+ HTTP status: anything else is re-raised unchanged, because guessing a status for an
101
+ unknown error hides the cause. In particular a ``ProgrammingError`` — a malformed
102
+ statement or a missing relation — stays as it is and surfaces as a 500, which is the
103
+ honest answer for a defect that is not the client's fault.
104
+
105
+ Args:
106
+ e (Exception): Caught exception.
107
+
108
+ Raises:
109
+ ServiceUnavailableException: On ``OperationalError``, database unreachable.
110
+ EntityNotFoundException: On ``NoResultFound``.
111
+ BadRequestException: On ``DataError`` or ``IntegrityError``.
112
+ Exception: The original exception, for every other type.
113
+ """
114
+ match e:
115
+ case OperationalError():
116
+ raise ServiceUnavailableException()
117
+ case NoResultFound():
118
+ raise EntityNotFoundException()
119
+ case DataError() | IntegrityError():
120
+ raise BadRequestException()
121
+ case _:
122
+ raise e
@@ -132,10 +132,23 @@ def _attributes_names(entity: type) -> tuple[str, ...]:
132
132
 
133
133
 
134
134
  def retrieve_mapper_entity(entity_name: str):
135
- """Look up a subclass of ``Base`` by class name.
135
+ """Look up a mapped class by class name.
136
136
 
137
- Iterates over ``Base.__subclasses__()``, so it only finds the **direct** subclasses
138
- of ``Base`` and not the ones nested in further inheritance levels.
137
+ Scans ``Base.registry.mappers``, which holds every mapped class of the registry
138
+ regardless of how deep in the inheritance it sits: an entity behind an abstract
139
+ intermediate base, or one in single table inheritance, is found as well. Iterating
140
+ ``Base.__subclasses__()`` instead — as this did — only reaches the **direct**
141
+ subclasses, and left those two invisible.
142
+
143
+ Reading the collection does **not** trigger ``configure_mappers()``, so the lookup
144
+ stays usable while the entities referenced by name in a ``relationship`` are still
145
+ being imported.
146
+
147
+ The class name is not unique within a registry: two entities declared in different
148
+ packages can share it, and since the collection of mappers is a ``frozenset`` the one
149
+ returned would then depend on the iteration order. Reaching every level of the
150
+ inheritance made the case more likely, not less — before, the classes nested deeper
151
+ were invisible. A duplicate name is a project-level problem this function cannot solve.
139
152
 
140
153
  Args:
141
154
  entity_name (str): Name of the ORM class to look for, such as ``"User"``.
@@ -143,23 +156,33 @@ def retrieve_mapper_entity(entity_name: str):
143
156
  Returns:
144
157
  Type[Base] | None: The matching ORM class, or ``None`` when not found.
145
158
  """
146
- for mapper in Base.__subclasses__():
147
- if mapper.__name__ == entity_name:
148
- return mapper
159
+ for mapper in Base.registry.mappers:
160
+ if mapper.class_.__name__ == entity_name:
161
+ return mapper.class_
162
+ return None
149
163
 
150
164
 
151
165
  def retrieve_mapper_from_table(table_name: str):
152
- """Look up a subclass of ``Base`` by table name.
166
+ """Look up a mapped class by table name.
153
167
 
154
- Iterates over ``Base.__subclasses__()``, so it only finds the **direct** subclasses
155
- of ``Base`` and not the ones nested in further inheritance levels.
168
+ Scans ``Base.registry.mappers`` like ``retrieve_mapper_entity``, and returns the
169
+ **root of the hierarchy** for the table. The distinction matters under single table
170
+ inheritance, where several classes share one table and would all match: the root is
171
+ the deterministic answer, and the collection of mappers is a ``frozenset``, so
172
+ returning the first match found would depend on the iteration order.
173
+
174
+ Only mapped classes are in the registry, so an abstract intermediate base is excluded
175
+ by construction. Scanning ``Base.__subclasses__()`` instead — as this did — reached
176
+ those bases, and since they declare no ``__tablename__`` a single ``__abstract__``
177
+ class in the project raised ``AttributeError`` on **every** lookup.
156
178
 
157
179
  Args:
158
180
  table_name (str): Name of the table in the database, such as ``"users"``.
159
181
 
160
182
  Returns:
161
- Type[Base] | None: The matching ORM class, or ``None`` when not found.
183
+ Type[Base] | None: The class the table is mapped on, or ``None`` when not found.
162
184
  """
163
- for mapper in Base.__subclasses__():
164
- if mapper.__tablename__ == table_name:
165
- return mapper
185
+ for mapper in Base.registry.mappers:
186
+ if mapper.local_table is not None and mapper.local_table.name == table_name:
187
+ return mapper.base_mapper.class_
188
+ return None
@@ -4,10 +4,14 @@ 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, not_, ARRAY, type_coerce
7
+ from sqlalchemy import MetaData, Engine, Column, Table, inspect, Connection, select, RowMapping, CursorResult
8
8
  from sqlalchemy.exc import NoSuchTableError
9
+ from sqlalchemy.sql.functions import count
9
10
 
10
- from ...exception.crud_exceptions import HasNoAttributeException, InternalServerException
11
+ from ...exception.crud_exceptions import HasNoAttributeException, InternalServerException, raise_http_exception
12
+ from ...interface.repository.handler.base_handler import BaseHandler
13
+ from ...interface.repository.handler.statement_manager import StatementManager
14
+ from ...paginator_dto import PaginatorResponseModel, parse_pagination
11
15
 
12
16
 
13
17
  class CrudMetadata:
@@ -20,12 +24,13 @@ class CrudMetadata:
20
24
  (``keep_open=True``) the raw results are returned with no commit and no close,
21
25
  otherwise the connection is opened, committed and closed internally.
22
26
 
23
- Three things set it apart from ``CrudRepository``: it does not use the handler
24
- pipeline, the filters going straight through ``_eq_where``, so there is no support
25
- for JSON, ILIKE, intervals or aliases; the SQLAlchemy exceptions are logged and
26
- re-raised as they are instead of being mapped onto HTTP exceptions; and the
27
- serialization happens through ``parsing_model(**row)`` on the row mappings rather
28
- than through ``model_validate`` on ORM entities.
27
+ The filters go through the same handler pipeline as ``CrudRepository``, built per
28
+ table from the columns of the reflected ``Table``: the ``BaseHandler`` alone by
29
+ default, plus whatever ``register_handlers`` adds for that table. The SQLAlchemy
30
+ exceptions are mapped onto HTTP exceptions with the same table, through
31
+ ``raise_http_exception``. What still sets this layer apart is the serialization, which
32
+ happens through ``parsing_model(**row)`` on the row mappings rather than through
33
+ ``model_validate`` on ORM entities.
29
34
 
30
35
  Attributes:
31
36
  schema (str | None): Name of the default schema of the tables.
@@ -33,8 +38,10 @@ class CrudMetadata:
33
38
  engine (Engine): SQLAlchemy Engine instance connected to the database.
34
39
  engine_inspection (Inspector): Inspector used for the introspections, such as
35
40
  the presence of a table.
41
+ statement_managers (dict[str, StatementManager]): Handler pipeline per table
42
+ name, built on first use and replaced by ``register_handlers``.
36
43
  """
37
- __slots__ = "schema", "metadata", "engine", "engine_inspection"
44
+ __slots__ = "schema", "metadata", "engine", "engine_inspection", "statement_managers"
38
45
 
39
46
  @property
40
47
  def open_connection(self):
@@ -60,6 +67,7 @@ class CrudMetadata:
60
67
  self.metadata = MetaData(schema_name) if schema_name else MetaData()
61
68
  self.engine = engine
62
69
  self.engine_inspection = inspect(self.engine)
70
+ self.statement_managers = {}
63
71
  if reflect:
64
72
  self.metadata.reflect(bind=self.engine, schema=schema_name)
65
73
 
@@ -68,6 +76,13 @@ class CrudMetadata:
68
76
  def _handle_exception(self, connection: Connection, e: Exception, to_raise: bool = True):
69
77
  """Close the connection, log the error and re-raise it when required.
70
78
 
79
+ The mapping is the same one ``CrudRepository`` applies, through the shared
80
+ ``raise_http_exception``: an ``OperationalError`` becomes a 503, a
81
+ ``NoResultFound`` a 404, a ``DataError`` or an ``IntegrityError`` a 400, and any
82
+ other error is re-raised unchanged. Until this layer re-raised every SQLAlchemy
83
+ error as it was, so a constraint violation on a reflected table reached the client
84
+ as an unhandled 500 instead of the 400 the ORM layer returns for the same error.
85
+
71
86
  Args:
72
87
  connection (Connection): Connection to close.
73
88
  e (Exception): Caught exception.
@@ -75,12 +90,15 @@ class CrudMetadata:
75
90
  Default: ``True``.
76
91
 
77
92
  Raises:
78
- Exception: The original exception, when ``to_raise=True``.
93
+ ServiceUnavailableException: On ``OperationalError``, database unreachable.
94
+ EntityNotFoundException: On ``NoResultFound``.
95
+ BadRequestException: On ``DataError`` or ``IntegrityError``.
96
+ Exception: The original exception, for every other type.
79
97
  """
80
98
  connection.close()
81
99
  logging.error(e)
82
100
  if to_raise:
83
- raise e
101
+ raise_http_exception(e)
84
102
 
85
103
  def commit(self, connection: Connection):
86
104
  """Commit the connection, handling the possible exceptions.
@@ -96,6 +114,50 @@ class CrudMetadata:
96
114
  except Exception as e:
97
115
  self._handle_exception(connection, e)
98
116
 
117
+ def register_handlers(self, from_table: str, handlers: list[BaseHandler]) -> StatementManager:
118
+ """Configure the specialized handlers for one table.
119
+
120
+ Without registration the pipeline of a table holds the ``BaseHandler`` alone,
121
+ which covers equality, ``IN`` and the ``ARRAY`` operators — the whole filtering
122
+ this layer used to support. Registering ``ILikeHandler``, ``JsonHandler``,
123
+ ``IntervalHandler`` or ``OrHandler`` extends it exactly as on ``CrudRepository``:
124
+ the handler interface works on the column collection of a reflected ``Table``
125
+ with no adaptation, JSON columns included, since reflection resolves a JSONB
126
+ column to ``python_type == dict``.
127
+
128
+ The registration is per table and per instance, and replaces any previous one.
129
+
130
+ Args:
131
+ from_table (str): Name of the table the handlers apply to.
132
+ handlers (list[BaseHandler]): Handlers to run before the base one, in order.
133
+
134
+ Returns:
135
+ StatementManager: The pipeline just registered.
136
+
137
+ Raises:
138
+ HasNoAttributeException: If the table does not exist.
139
+ """
140
+ table = self._get_table(name=from_table)
141
+ manager = StatementManager(handlers=handlers, base_handler=BaseHandler(table.c))
142
+ self.statement_managers[table.name] = manager
143
+ return manager
144
+
145
+ def _statement_manager(self, table: Table) -> StatementManager:
146
+ """Return the pipeline of a table, building a base-only one on first use.
147
+
148
+ Args:
149
+ table (Table): Table to get the pipeline for.
150
+
151
+ Returns:
152
+ StatementManager: Registered pipeline, or one holding only the
153
+ ``BaseHandler`` built from the columns of the table.
154
+ """
155
+ manager = self.statement_managers.get(table.name)
156
+ if manager is None:
157
+ manager = StatementManager(base_handler=BaseHandler(table.c))
158
+ self.statement_managers[table.name] = manager
159
+ return manager
160
+
99
161
  def has_columns(self, *columns: str, table: Table) -> bool:
100
162
  """Check that every given name is a column of the table.
101
163
 
@@ -189,7 +251,8 @@ class CrudMetadata:
189
251
  table.drop(self.engine)
190
252
  self.metadata.remove(table=table)
191
253
 
192
- 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):
254
+ 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,
255
+ orders_by: dict[str, bool] = None, **kwhere):
193
256
  """Find the rows of the table matching the given criteria.
194
257
 
195
258
  Args:
@@ -210,10 +273,10 @@ class CrudMetadata:
210
273
  keep_open = False if connection is None else True
211
274
  connection = self.open_connection if connection is None else connection
212
275
  table = self.get_table(name=from_table)
213
- result = self._find(table=table, connection=connection, where=where, columns=columns, limit=limit, **kwhere)
276
+ result = self._find(table=table, connection=connection, where=where, columns=columns, limit=limit, orders_by=orders_by, **kwhere)
214
277
  return self._return(connection=connection, result=result, keep_open=keep_open, commit=False, parsing_model=parsing_model)
215
278
 
216
- def _find(self, table: Table, connection: Connection, where: dict[str, Any] = None, columns: list[str] = None, limit: int = 0, **kwhere):
279
+ def _find(self, table: Table, connection: Connection, where: dict[str, Any] = None, columns: list[str] = None, limit: int = 0, orders_by: dict[str, bool] = None, **kwhere):
217
280
  """Build and execute the SELECT statement with filters and limit.
218
281
 
219
282
  Args:
@@ -230,8 +293,10 @@ class CrudMetadata:
230
293
  """
231
294
  if where is None:
232
295
  where = kwhere if kwhere else {}
296
+ manager = self._statement_manager(table)
233
297
  stmt = self._select(table=table, columns=columns)
234
- stmt = stmt.where(*[self._eq_where(table.c[column], condition) for column, condition in where.items()])
298
+ stmt = self._add_orders_by_condition(stmt, table, orders_by)
299
+ stmt = manager.prepare_statement(stmt=stmt, **where)
235
300
  stmt = self._add_limit_offset_condition(stmt, limit)
236
301
  return self.execute(connection=connection, stmt=stmt, mapping=True)
237
302
 
@@ -281,7 +346,7 @@ class CrudMetadata:
281
346
  """
282
347
  if values:
283
348
  stmt = table.insert().values(values)
284
- stmt = stmt if not returning else stmt.returning()
349
+ stmt = stmt if not returning else stmt.returning(*table.c)
285
350
  return self.execute(connection=connection, stmt=stmt, mapping=True)
286
351
  else:
287
352
  return []
@@ -325,9 +390,9 @@ class CrudMetadata:
325
390
  if where is None:
326
391
  where = {}
327
392
  stmt = table.update()
328
- stmt = stmt.where(*[self._eq_where(table.c[column], condition) for column, condition in where.items()])
393
+ stmt = self._statement_manager(table).prepare_statement(stmt=stmt, **where)
329
394
  stmt = stmt.values(**kwargs)
330
- stmt = stmt if not returning else stmt.returning()
395
+ stmt = stmt if not returning else stmt.returning(*table.c)
331
396
  return self.execute(connection=connection, stmt=stmt, mapping=True)
332
397
 
333
398
  def delete(self, from_table: str, connection: Connection = None, where: dict[str, Any] = None, **kwhere):
@@ -371,7 +436,7 @@ class CrudMetadata:
371
436
  if where is None:
372
437
  where = kwhere if kwhere else {}
373
438
  stmt = table.delete()
374
- stmt = stmt.where(*[self._eq_where(table.c[column], condition) for column, condition in where.items()])
439
+ stmt = self._statement_manager(table).prepare_statement(stmt=stmt, **where)
375
440
  return self.execute(connection=connection, stmt=stmt).rowcount
376
441
 
377
442
  # --------------
@@ -379,6 +444,10 @@ class CrudMetadata:
379
444
  def execute(self, connection: Connection, stmt, mapping: bool = False):
380
445
  """Execute a statement on the connection, handling the exceptions.
381
446
 
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.
450
+
382
451
  Args:
383
452
  connection (Connection): Connection to execute the statement on.
384
453
  stmt: SQLAlchemy Core statement to execute.
@@ -399,7 +468,9 @@ class CrudMetadata:
399
468
  self._handle_exception(connection, e)
400
469
  else:
401
470
  if mapping:
402
- result = result.mappings().all()
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 []
403
474
  return result
404
475
 
405
476
  def _get_table(self, name: str):
@@ -454,6 +525,174 @@ class CrudMetadata:
454
525
  """
455
526
  return self.engine_inspection.has_table(name, self.schema)
456
527
 
528
+ def count(self, from_table: str, connection: Connection = None, where: dict[str, Any] = None, **kwhere) -> int:
529
+ """Count the rows matching the given criteria.
530
+
531
+ Args:
532
+ from_table (str): Name of the table to count on.
533
+ connection (Connection, optional): External connection. When ``None``, one is
534
+ opened and closed at the end.
535
+ where (dict[str, Any], optional): Filters as a dictionary. Takes precedence
536
+ over ``**kwhere``.
537
+ **kwhere: Additional filters as keyword arguments.
538
+
539
+ Returns:
540
+ int: Number of matching rows.
541
+
542
+ Raises:
543
+ HasNoAttributeException: If the table does not exist.
544
+ """
545
+ keep_open = connection is not None
546
+ connection = self.open_connection if connection is None else connection
547
+ table = self.get_table(name=from_table)
548
+ total = self._count(table=table, connection=connection, where=where, **kwhere)
549
+ if not keep_open:
550
+ connection.close()
551
+ return total
552
+
553
+ def _count(self, table: Table, connection: Connection, where: dict[str, Any] = None, **kwhere) -> int:
554
+ """Run the ``COUNT`` with the filters applied.
555
+
556
+ The ``select_from`` is mandatory: ``select(count())`` alone compiles into a
557
+ ``SELECT count(*)`` with no FROM clause, which always returns 1.
558
+
559
+ Args:
560
+ table (Table): Table to count on.
561
+ connection (Connection): Connection to run the statement on.
562
+ where (dict[str, Any], optional): Filters. When ``None``, ``**kwhere`` is used.
563
+ **kwhere: Additional filters as keyword arguments.
564
+
565
+ Returns:
566
+ int: Number of matching rows.
567
+ """
568
+ if where is None:
569
+ where = kwhere if kwhere else {}
570
+ stmt = select(count()).select_from(table)
571
+ stmt = self._statement_manager(table).prepare_statement(stmt=stmt, **where)
572
+ return self.execute(connection=connection, stmt=stmt).scalar()
573
+
574
+ 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
+ parsing_model: Type[BaseModel] = None, orders_by: dict[str, bool] = None, **kwhere) -> PaginatorResponseModel:
576
+ """Run a paginated search on a reflected table.
577
+
578
+ Mirrors ``CrudRepository.paging_find``, including the derived total: the data query
579
+ runs **first**, so that when the page itself tells how many rows there are the
580
+ ``COUNT`` is skipped entirely. The derived value is exact, not estimated.
581
+
582
+ Args:
583
+ from_table (str): Name of the table to search on.
584
+ connection (Connection, optional): External connection. When ``None``, one is
585
+ opened and closed at the end.
586
+ where (dict[str, Any], optional): Filters as a dictionary.
587
+ columns (list[str], optional): Columns to select.
588
+ limit (int): Number of rows per page. Default: 0, meaning no limit and
589
+ therefore a single page.
590
+ page (int): Requested page number, 1-based. Default: 1.
591
+ parsing_model (Type[BaseModel], optional): Model each row is serialized with.
592
+ orders_by (dict[str, bool], optional): Ordering, as ``{column: ascending}``,
593
+ with the clauses following the insertion order of the dictionary. When
594
+ omitted no ordering is applied, and without a total order two pages can
595
+ overlap.
596
+ **kwhere: Additional filters as keyword arguments.
597
+
598
+ Returns:
599
+ PaginatorResponseModel: Object carrying ``data``, ``tot_pages``, ``page``,
600
+ ``limit`` and the ordering.
601
+
602
+ Raises:
603
+ HasNoAttributeException: If the table or one of the columns does not exist.
604
+ """
605
+ keep_open = connection is not None
606
+ connection = self.open_connection if connection is None else connection
607
+ table = self.get_table(name=from_table)
608
+ paginated = self._paging_find(table=table, connection=connection, where=where, columns=columns, limit=limit, page=page, orders_by=orders_by, **kwhere)
609
+ if not keep_open:
610
+ if parsing_model is not None:
611
+ paginated.data = [parsing_model(**row) for row in paginated.data]
612
+ connection.close()
613
+ return paginated
614
+
615
+ def _paging_find(self, table: Table, connection: Connection, where: dict[str, Any] = None, columns: list[str] = None, limit: int = 0, page: int = 1,
616
+ orders_by: dict[str, bool] = None, **kwhere) -> PaginatorResponseModel:
617
+ """Read the page and derive the total, running the ``COUNT`` only when needed.
618
+
619
+ Args:
620
+ table (Table): Table to search on.
621
+ connection (Connection): Connection to run the statements on.
622
+ where (dict[str, Any], optional): Filters. When ``None``, ``**kwhere`` is used.
623
+ columns (list[str], optional): Columns to select.
624
+ limit (int): Rows per page. 0 means no limit.
625
+ page (int): Page number, 1-based.
626
+ orders_by (dict[str, bool], optional): Ordering to apply.
627
+ **kwhere: Additional filters as keyword arguments.
628
+
629
+ Returns:
630
+ PaginatorResponseModel: Page and total, with the raw rows as ``data``.
631
+ """
632
+ if where is None:
633
+ where = kwhere if kwhere else {}
634
+ if orders_by is None:
635
+ orders_by = {}
636
+
637
+ manager = self._statement_manager(table)
638
+ stmt = self._select(table=table, columns=columns)
639
+ 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
642
+ conditions = manager.conditions(where=dict(where))
643
+ if conditions:
644
+ stmt = stmt.where(*conditions)
645
+ stmt = manager.options(stmt=stmt)
646
+
647
+ rows = self.execute(connection=connection, stmt=stmt, mapping=True)
648
+
649
+ read = len(rows)
650
+ if not limit or (read < limit and page == 1):
651
+ total = read
652
+ elif limit > read > 0 and page > 1:
653
+ total = (page - 1) * limit + read
654
+ else:
655
+ total = self._preconf_count(table=table, connection=connection, where=conditions)
656
+
657
+ return parse_pagination(limit=limit, page=page, orders_by=orders_by, count=total, data=rows)
658
+
659
+ def _preconf_count(self, table: Table, connection: Connection, where: Optional[list] = None) -> int:
660
+ """Count from already built WHERE conditions, reusing the ones of the page query.
661
+
662
+ Args:
663
+ table (Table): Table to count on.
664
+ connection (Connection): Connection to run the statement on.
665
+ where (list | None): Conditions as ``StatementManager.conditions`` returns
666
+ them. An empty list or ``None`` counts every row.
667
+
668
+ Returns:
669
+ int: Number of matching rows.
670
+ """
671
+ stmt = select(count()).select_from(table)
672
+ if where:
673
+ stmt = stmt.where(*where)
674
+ return self.execute(connection=connection, stmt=stmt).scalar()
675
+
676
+ def _add_orders_by_condition(self, stmt, table: Table, orders_by: dict[str, bool] = None):
677
+ """Add one ORDER BY clause per entry of ``orders_by``, in insertion order.
678
+
679
+ Args:
680
+ stmt: Statement to add the ordering to.
681
+ table (Table): Table the columns belong to.
682
+ orders_by (dict[str, bool], optional): ``{column: ascending}``. Nothing is
683
+ applied when empty or ``None``.
684
+
685
+ Returns:
686
+ Statement with the ORDER BY clauses applied.
687
+
688
+ Raises:
689
+ HasNoAttributeException: If one of the columns does not exist on the table.
690
+ """
691
+ manager = self._statement_manager(table)
692
+ for order_by, asc in (orders_by or {}).items():
693
+ stmt = manager.order_by_condition(stmt=stmt, order_by=order_by, asc=asc)
694
+ return stmt
695
+
457
696
  def _add_limit_offset_condition(self, stmt, limit: int, page: int = None):
458
697
  """Add the LIMIT and OFFSET clauses to the statement for pagination.
459
698
 
@@ -475,31 +714,3 @@ class CrudMetadata:
475
714
  offset = (page - 1) * limit
476
715
  stmt = stmt.offset(offset)
477
716
  return stmt
478
-
479
- def _eq_where(self, col: Column, condition, neq: bool = False):
480
- """Build the single WHERE condition for one column.
481
-
482
- The logic matches ``BaseHandler._handle``: an ``ARRAY`` column compared to a list
483
- yields an overlap (``&&``), compared to a scalar an ``= ANY``; a list yields an
484
- ``IN`` and a scalar an equality.
485
-
486
- Args:
487
- col (Column): Column the condition applies to.
488
- condition: Value or list of values to compare.
489
- neq (bool): If ``True``, negate the condition with ``NOT``. Default: ``False``.
490
-
491
- Returns:
492
- ColumnElement: SQLAlchemy boolean expression.
493
- """
494
- is_array = isinstance(col.type, ARRAY)
495
- if is_array and isinstance(condition, list):
496
- eq_where = col.op("&&")(type_coerce(condition, col.type))
497
- elif isinstance(condition, list):
498
- eq_where = col.in_(condition)
499
- elif is_array:
500
- eq_where = condition == col.any_()
501
- else:
502
- eq_where = col == condition
503
- if neq:
504
- eq_where = not_(eq_where)
505
- return eq_where
@@ -101,8 +101,11 @@ class GeomMetadata(CrudMetadata):
101
101
  if intersection_area:
102
102
  stmt = stmt.add_columns(polygon_column.ST_Intersection(intersecting_geom).ST_Transform(3857).ST_Area().label("intersection_area"))
103
103
 
104
- eq_where = [self._eq_where(table.c[column], condition) for column, condition in where.items()]
105
- eq_where.extend([self._eq_where(table.c[column], condition, True) for column, condition in not_where.items()])
104
+ manager = self._statement_manager(table)
105
+ # ``conditions`` consuma le chiavi con ``pop``: il dizionario va copiato, altrimenti
106
+ # un chiamante che riusa i propri filtri li perde dopo la prima chiamata
107
+ eq_where = list(manager.conditions(where=dict(where)))
108
+ eq_where.extend(manager.conditions(neq=True, where=dict(not_where)))
106
109
 
107
110
  geom_condition = polygon_column.ST_Intersects(intersecting_geom)
108
111
 
@@ -174,7 +177,7 @@ class GeomMetadata(CrudMetadata):
174
177
  where = {}
175
178
 
176
179
  stmt = self._select(table, columns)
177
- eq_where = [self._eq_where(table.c[column], condition) for column, condition in where.items()]
180
+ eq_where = list(self._statement_manager(table).conditions(where=dict(where)))
178
181
 
179
182
  geom_condition = self._geom_condition(geom=geom, geom_srid=geom_srid, polygon_column_srid=polygon_column_srid, buffer=tolerance)
180
183
  geom_condition = polygon_column.ST_Contains(geom_condition) if not contained else geom_condition.ST_Contains(polygon_column)