python-general-be-lib 0.9.1__py3-none-any.whl → 0.10.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.

Potentially problematic release.


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

general/context.py ADDED
@@ -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)
@@ -0,0 +1,20 @@
1
+ """Domain mixins for the ORM entities.
2
+
3
+ Each mixin adds a group of columns an entity would otherwise redeclare, and works on the
4
+ write paths of the library without any change to the repositories: the timestamps come
5
+ from ``server_default``/``onupdate``, the audit columns from a Python-side default reading
6
+ the ``current_user`` context variable of ``general.context``.
7
+
8
+ They are plain classes holding ``Column`` objects, so they are mixed in next to ``Base``
9
+ with no ``declared_attr``.
10
+ """
11
+
12
+ from .audit_mixin import *
13
+ from .softdelete_mixin import *
14
+ from .timestamp_mixin import *
15
+
16
+ __all__ = [
17
+ "TimestampMixin",
18
+ "AuditMixin",
19
+ "SoftDeleteMixin",
20
+ ]
@@ -0,0 +1,34 @@
1
+ __all__ = ["AuditMixin"]
2
+
3
+ from sqlalchemy import Column, String
4
+
5
+ from ...context import current_user
6
+
7
+
8
+ class AuditMixin:
9
+ """Mixin recording which user created and last updated a row.
10
+
11
+ Meant to be mixed into a ``Base`` subclass together with the entity columns::
12
+
13
+ class Article(Base, AuditMixin):
14
+ __tablename__ = "article"
15
+ id = Column(Integer, primary_key=True)
16
+
17
+ Both values are read from the ``current_user`` context variable of ``general.context``,
18
+ which the consumer sets. The defaults are **Python-side**, evaluated at execution: they
19
+ therefore apply to every write path of the library, the bulk ``insert`` included, and
20
+ not to a raw SQL statement run outside it.
21
+
22
+ ``create_uid`` is written on insert and never touched again, because ``onupdate`` only
23
+ applies to ``write_uid``: an update changes who last touched the row without
24
+ rewriting who created it.
25
+
26
+ Attributes:
27
+ create_uid (Column): User the insert is attributed to. ``None`` when
28
+ ``current_user`` is unset.
29
+ write_uid (Column): User the last update through the library is attributed to. On
30
+ insert it holds the same value as ``create_uid``.
31
+ """
32
+
33
+ create_uid = Column(String, default=lambda: current_user.get(), nullable=True)
34
+ write_uid = Column(String, default=lambda: current_user.get(), onupdate=lambda: current_user.get(), nullable=True)
@@ -0,0 +1,43 @@
1
+ __all__ = ["SoftDeleteMixin"]
2
+
3
+ from sqlalchemy import Column, DateTime, Boolean, String
4
+
5
+ from ...context import current_user
6
+
7
+
8
+ class SoftDeleteMixin:
9
+ """Mixin adding the logical deletion instant to an entity.
10
+
11
+ Meant to be mixed into a ``Base`` subclass together with the entity columns::
12
+
13
+ class Article(Base, SoftDeleteMixin):
14
+ __tablename__ = "article"
15
+ id = Column(Integer, primary_key=True)
16
+
17
+ The columns alone change nothing: a row is deleted when ``deleted`` is true, and it is
18
+ ``SoftDeleteHandler`` — registered among the handlers of the repository — that keeps
19
+ those rows out of the queries. Without that handler the columns are inert and every row
20
+ stays visible.
21
+
22
+ ``deleted`` is nullable and filled by a Python-side default, so a row written in raw
23
+ SQL leaves it ``NULL``: the handler therefore filters on ``IS NOT true``, which keeps
24
+ both ``false`` and ``NULL``, and not on ``= false``, which would hide a live row.
25
+
26
+ This mixin does **not** turn ``CrudRepository.delete`` into a logical deletion: that
27
+ method still removes the row. Marking a row as deleted means writing the columns,
28
+ ``update(where={...}, deleted=True, delete_date=..., delete_uid=...)``, and restoring it
29
+ means writing ``deleted=False`` with ``include_deleted=True`` in the filters, so that
30
+ the handler lets the row through.
31
+
32
+ Attributes:
33
+ deleted (Column): Deletion flag. ``False`` on insert, ``True`` once the row is
34
+ logically deleted, ``NULL`` on the rows written outside SQLAlchemy.
35
+ delete_uid (Column): User the deletion is attributed to. Its default fires **on
36
+ insert**, so today it holds a value on live rows too: a deletion is an UPDATE
37
+ like any other, and there is no default that could fire only then.
38
+ delete_date (Column): Instant of the deletion, written by the caller.
39
+ """
40
+
41
+ deleted = Column(Boolean, default=False)
42
+ delete_uid = Column(String, default=lambda: current_user.get(), onupdate=lambda: current_user.get(), nullable=True)
43
+ delete_date = Column(DateTime, nullable=True, default=None)
@@ -0,0 +1,35 @@
1
+ __all__ = ["TimestampMixin"]
2
+
3
+ from sqlalchemy import Column, DateTime, func
4
+
5
+
6
+ class TimestampMixin:
7
+ """Mixin adding the creation and last-update timestamps to an entity.
8
+
9
+ Meant to be mixed into a ``Base`` subclass, before or after it, since the columns are
10
+ plain ``Column`` objects and need no ``declared_attr``::
11
+
12
+ class Article(Base, TimestampMixin):
13
+ __tablename__ = "article"
14
+ id = Column(Integer, primary_key=True)
15
+
16
+ Both timestamps are timezone aware: on PostgreSQL they become
17
+ ``TIMESTAMP WITH TIME ZONE``, on the backends that ignore the flag the column is a
18
+ plain timestamp and the value is the one the database produces.
19
+
20
+ ``create_date`` is filled by a **server-side** default, so it is correct even for rows
21
+ inserted outside the library, by a migration or by hand. ``write_date`` is refreshed by
22
+ ``onupdate``, which SQLAlchemy adds to the UPDATE statement it builds: it therefore
23
+ covers ``CrudRepository.update`` and ``upsert``, and does **not** cover a raw SQL
24
+ UPDATE run outside the library — that would need a database trigger, which a mixin
25
+ cannot install.
26
+
27
+ Attributes:
28
+ create_date (Column): Insert instant, from the ``now()`` of the database. Not null.
29
+ write_date (Column): Instant of the last update through the library. Not null: on
30
+ insert it holds the creation instant, so the two are equal until the first
31
+ update.
32
+ """
33
+
34
+ create_date = Column(DateTime, server_default=func.now(), nullable=False)
35
+ write_date = Column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False)
@@ -2,23 +2,21 @@ __all__ = ["CrudRepository"]
2
2
 
3
3
  import hashlib
4
4
  import logging
5
- import math
6
5
  from functools import lru_cache
7
6
  from typing import Any, Type, Sequence, Optional, Mapping
8
7
 
9
8
  from pydantic import BaseModel, TypeAdapter
10
9
  from sqlalchemy import select, delete, update, insert, tuple_, Row, RowMapping, ColumnElement, Select, Insert, Update, Delete, func, ARRAY
11
10
  from sqlalchemy.engine.base import Engine
12
- from sqlalchemy.exc import DataError, IntegrityError, NoResultFound, OperationalError
13
11
  from sqlalchemy.inspection import inspect
14
12
  from sqlalchemy.orm import Session, load_only, defer
15
13
  from sqlalchemy.sql.functions import count
16
14
 
17
15
  from .handler.base_handler import BaseHandler
18
16
  from .handler.statement_manager import StatementManager
19
- from ...exception.crud_exceptions import HasNoAttributeException, EntityNotFoundException, ServiceUnavailableException, BadRequestException
17
+ from ...exception.crud_exceptions import HasNoAttributeException, raise_http_exception
20
18
  from ...interface.base.declarative_base import Base
21
- from ...paginator_dto import PaginatorResponseModel
19
+ from ...paginator_dto import PaginatorResponseModel, parse_pagination
22
20
 
23
21
 
24
22
  @lru_cache(maxsize=None)
@@ -39,6 +37,27 @@ def _list_adapter(model: Type[BaseModel]) -> TypeAdapter:
39
37
  return TypeAdapter(list[model])
40
38
 
41
39
 
40
+ @lru_cache(maxsize=None)
41
+ def _pk_attributes(entity: Type[Base]) -> tuple[str, ...]:
42
+ """Python attribute names of the primary key columns, in column order.
43
+
44
+ ``primary_key()`` returns ``Column`` objects, whose ``name`` and ``key`` are the
45
+ database name: on an entity declaring an explicit column name they differ from the
46
+ Python attribute, which is the name both the ORM entities and the Pydantic models
47
+ expose. The mapping goes through ``mapper.columns``, which is indexed by attribute
48
+ name and does not force ``configure_mappers()``.
49
+
50
+ Args:
51
+ entity (Type[Base]): ORM class to inspect.
52
+
53
+ Returns:
54
+ tuple[str, ...]: Attribute names of the primary key, in the same order as
55
+ ``entity.primary_key()``.
56
+ """
57
+ attribute_by_column = {column: name for name, column in inspect(entity).columns.items()}
58
+ return tuple(attribute_by_column.get(column, column.key) for column in entity.primary_key())
59
+
60
+
42
61
  @lru_cache(maxsize=None)
43
62
  def _reduced_model(entity: Type[Base], columns: frozenset[str]) -> Type[BaseModel]:
44
63
  """Build a model restricted to the given columns, once per combination.
@@ -160,15 +179,7 @@ class CrudRepository[Entity: Base | Type[Base]]:
160
179
  """
161
180
  session.close()
162
181
  logging.error(e)
163
- match e:
164
- case OperationalError():
165
- raise ServiceUnavailableException()
166
- case NoResultFound():
167
- raise EntityNotFoundException()
168
- case DataError() | IntegrityError():
169
- raise BadRequestException()
170
- case _:
171
- raise e
182
+ raise_http_exception(e)
172
183
 
173
184
  def commit(self, session: Session):
174
185
  """Commit the session, handling the possible exceptions.
@@ -535,7 +546,7 @@ class CrudRepository[Entity: Base | Type[Base]]:
535
546
 
536
547
  # Chiave primaria (eventualmente composta) di ogni modello, oppure None
537
548
  # se non tutte le colonne PK sono valorizzate (→ candidato all'insert).
538
- model_keys = [self._pk_key(model, pk_columns) for model in models]
549
+ model_keys = [self._pk_key(model) for model in models]
539
550
 
540
551
  # Un'unica query per recuperare tutte le entità esistenti che corrispondono
541
552
  # alle PK candidate, indicizzate per chiave PK in una mappa in memoria.
@@ -544,7 +555,7 @@ class CrudRepository[Entity: Base | Type[Base]]:
544
555
  if candidate_keys:
545
556
  stmt = select(self.entity).where(tuple_(*pk_columns).in_(candidate_keys))
546
557
  for entity in self.execute(session=session, stmt=stmt):
547
- existing[self._pk_key(entity, pk_columns)] = entity
558
+ existing[self._pk_key(entity)] = entity
548
559
 
549
560
  # Ripartizione insert/update in base alla presenza nella mappa.
550
561
  inserts = []
@@ -564,23 +575,25 @@ class CrudRepository[Entity: Base | Type[Base]]:
564
575
 
565
576
  return result
566
577
 
567
- def _pk_key(self, obj: Type[BaseModel] | BaseModel | Base, pk_columns) -> tuple | None:
578
+ def _pk_key(self, obj: Type[BaseModel] | BaseModel | Base) -> tuple | None:
568
579
  """Extract the possibly composite primary key from a model or an entity.
569
580
 
570
581
  Reads the values of the primary key columns in order and returns them as a tuple,
571
- usable both as a map key and for the ``tuple_(...).in_(...)`` filter.
582
+ usable both as a map key and for the ``tuple_(...).in_(...)`` filter. The values are
583
+ read by **Python attribute** name, the one both the ORM entities and the Pydantic
584
+ models expose, and a value counts as missing only when it is ``None``: a key equal
585
+ to ``0`` or to the empty string is a valid key, and treating it as missing made
586
+ ``upsert`` insert a row that already existed.
572
587
 
573
588
  Args:
574
589
  obj: Pydantic model or ORM entity to read the primary key from.
575
- pk_columns: Columns making up the primary key, typically
576
- ``self.entity.primary_key()``.
577
590
 
578
591
  Returns:
579
592
  tuple | None: Primary key values in column order, or ``None`` when at least one
580
- value is missing.
593
+ value is ``None``, which marks the model as a candidate for the insert.
581
594
  """
582
- values = tuple(getattr(obj, column.name, None) for column in pk_columns)
583
- return values if all(values) else None
595
+ values = tuple(getattr(obj, attribute, None) for attribute in _pk_attributes(self.entity))
596
+ return values if all(value is not None for value in values) else None
584
597
 
585
598
  def count(self, session: Session = None, where: dict[str, Any] = None, **kwhere):
586
599
  """Count the records matching the given criteria.
@@ -731,12 +744,7 @@ class CrudRepository[Entity: Base | Type[Base]]:
731
744
  Returns:
732
745
  PaginatorResponseModel: Complete paginated response.
733
746
  """
734
- limit = limit if limit else len(data)
735
- order_by = next(iter(orders_by), None) if orders_by else None
736
- asc = orders_by.get(order_by) if order_by is not None else None
737
- tot_pages = math.ceil(count / limit) if count and limit > 0 else 1
738
-
739
- return PaginatorResponseModel(limit=limit, page=page, order_by=order_by, asc=asc, tot_pages=tot_pages, orders_by=orders_by, data=data)
747
+ return parse_pagination(limit=limit, page=page, orders_by=orders_by, count=count, data=data)
740
748
 
741
749
  def distinct_pairs(self, key: str, value: str, session: Session = None, where: dict[str, Any] = None, **kwhere) -> list[Row] | None:
742
750
  """Return the distinct ``(key, value)`` pairs of the records matching ``where``.
@@ -12,6 +12,7 @@ from .interval_handler import *
12
12
  from .json_handler import *
13
13
  from .json_ilike_handler import *
14
14
  from .or_handler import *
15
+ from .soft_delete_handler import *
15
16
  from .statement_manager import *
16
17
 
17
18
  __all__ = [
@@ -22,5 +23,6 @@ __all__ = [
22
23
  "JsonILikeHandler",
23
24
  "OrHandler",
24
25
  "IntervalHandler",
26
+ "SoftDeleteHandler",
25
27
  "StatementManager",
26
28
  ]
@@ -0,0 +1,83 @@
1
+ __all__ = ["SoftDeleteHandler"]
2
+
3
+ from typing import Any
4
+
5
+ from sqlalchemy import Column, ColumnElement
6
+ from sqlalchemy.sql.base import ReadOnlyColumnCollection
7
+
8
+ from .base_handler import BaseHandler
9
+
10
+
11
+ class SoftDeleteHandler(BaseHandler):
12
+ """Handler keeping the logically deleted rows out of the queries.
13
+
14
+ Companion of ``SoftDeleteMixin``: it adds ``deleted IS NOT true`` to every statement
15
+ going through the pipeline, so ``find``, ``paging_find``, ``count``, ``update`` and
16
+ ``delete`` only see the live rows. Unlike the other specialized handlers the condition
17
+ does not come from a value of the ``where``: it is a policy, applied even when no
18
+ filter is passed at all.
19
+
20
+ The form of the condition is ``IS NOT true`` and not ``= false`` on purpose. The flag
21
+ is nullable and filled by a Python-side default, which SQLAlchemy applies to its own
22
+ statements but not to raw SQL: a row written by a migration or from a console leaves
23
+ the column ``NULL``, and ``deleted = false`` would evaluate to ``NULL`` on it, hiding a
24
+ live row. ``IS NOT true`` keeps both ``false`` and ``NULL``, and compiles on both
25
+ supported backends.
26
+
27
+ The alias in ``include_alias`` is the opt-out: a true value returns the deleted rows
28
+ too, which is what a restore or an audit view needs. The value is read as a boolean, so
29
+ ``False``, ``0`` and ``None`` all leave the filter in place — the alias is a switch, not
30
+ a value to compare, and this is the one handler where the library rule «only ``None``
31
+ means no filter» does not apply.
32
+
33
+ Attributes:
34
+ deleted_column (Column): Column holding the deletion flag, resolved at
35
+ initialization.
36
+ include_alias (str): Key of the ``where`` that disables the filter.
37
+ """
38
+ __slots__ = "deleted_column", "include_alias"
39
+ deleted_column: Column
40
+ include_alias: str
41
+
42
+ def __init__(self, columns: ReadOnlyColumnCollection[str, Column[Any]], deleted_column: str = "deleted", include_alias: str = "include_deleted"):
43
+ """Initialize the handler resolving the deletion column right away.
44
+
45
+ Args:
46
+ columns (ReadOnlyColumnCollection): Columns of the table.
47
+ deleted_column (str): Name of the column holding the deletion flag.
48
+ Default: ``"deleted"``.
49
+ include_alias (str): Key of the ``where`` disabling the filter.
50
+ Default: ``"include_deleted"``.
51
+
52
+ Raises:
53
+ KeyError: If ``deleted_column`` is not a column of the table. The resolution
54
+ happens here, so a wrong name fails at construction and not at the first
55
+ query.
56
+ """
57
+ super().__init__(columns)
58
+ self.deleted_column = self.columns[deleted_column]
59
+ self.include_alias = include_alias
60
+
61
+ def conditions(self, neq: bool = False, where: dict[str, Any] = None) -> list[ColumnElement[Any]]:
62
+ """Return ``deleted IS NOT true`` unless the alias asks for the deleted rows too.
63
+
64
+ The alias is always removed from the ``where``, even when it does not change the
65
+ outcome: a key left behind would reach ``BaseHandler``, which raises ``KeyError``
66
+ on a name that is not a column.
67
+
68
+ Args:
69
+ neq (bool): When ``True`` nothing is returned. The negation is applied to the
70
+ filters of the caller, and this condition is not one of them: negating it
71
+ would return **only** the deleted rows, the opposite of the policy. On the
72
+ spatial methods, which ask for the conditions twice — once positive and
73
+ once negated — this also avoids emitting the filter twice.
74
+ where (dict[str, Any]): Filters. The alias is removed in-place.
75
+
76
+ Returns:
77
+ list[ColumnElement]: One condition, or none when the deleted rows are wanted
78
+ or when ``neq`` is set.
79
+ """
80
+ include_deleted = where.pop(self.include_alias, None) if where is not None else None
81
+ if neq or include_deleted:
82
+ return []
83
+ return [self.deleted_column.isnot(True)]
general/paginator_dto.py CHANGED
@@ -1,6 +1,7 @@
1
- __all__ = ["PaginatorRequestModel", "PaginatorResponseModel"]
1
+ __all__ = ["PaginatorRequestModel", "PaginatorResponseModel", "parse_pagination"]
2
2
 
3
- from typing import Optional
3
+ import math
4
+ from typing import Any, Optional, Sequence
4
5
 
5
6
  from pydantic import Field
6
7
 
@@ -54,3 +55,31 @@ class PaginatorResponseModel[ResponseType](PaginatorBaseModel):
54
55
  tot_pages: int = Field(alias="pagineTotali")
55
56
  orders_by: Optional[dict[str, bool]] = Field(default=None, alias="campiOrdinamento")
56
57
  data: list[ResponseType]
58
+
59
+
60
+ def parse_pagination(limit: int, page: int, orders_by: dict[str, bool], count: int, data: Sequence[Any]) -> PaginatorResponseModel:
61
+ """Build the ``PaginatorResponseModel`` from the pagination data.
62
+
63
+ Shared by the ORM and the metadata layer, so that the two produce the same response
64
+ shape from the same inputs. When ``limit`` is 0, meaning no limit, the length of the
65
+ data is used as the effective limit. The response carries the whole ``orders_by`` plus
66
+ its first pair as ``order_by``/``asc``, which are the fields the clients read as the
67
+ main ordering criterion; with an empty ``orders_by`` both stay ``None``, because no
68
+ ordering was applied.
69
+
70
+ Args:
71
+ limit (int): Number of items per page.
72
+ page (int): Current page.
73
+ orders_by (dict[str, bool]): Ordering applied, as ``{column: ascending}``.
74
+ count (int): Total number of filtered records.
75
+ data (Sequence): Rows of the current page.
76
+
77
+ Returns:
78
+ PaginatorResponseModel: Complete paginated response.
79
+ """
80
+ limit = limit if limit else len(data)
81
+ order_by = next(iter(orders_by), None) if orders_by else None
82
+ asc = orders_by.get(order_by) if order_by is not None else None
83
+ tot_pages = math.ceil(count / limit) if count and limit > 0 else 1
84
+
85
+ return PaginatorResponseModel(limit=limit, page=page, order_by=order_by, asc=asc, tot_pages=tot_pages, orders_by=orders_by, data=data)
@@ -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
@@ -1,24 +1,29 @@
1
1
  general/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ general/context.py,sha256=ArxisBIbmij72IWKQwBfJBhmAucUGlMnEPRQnYl1A6k,1165
2
3
  general/logger.py,sha256=P-7-BLtO1XC2m43wXrnJcTakr2iZBE0gUkMVppJ9exE,4506
3
- general/paginator_dto.py,sha256=ug2KJMPQRgxcwTu8-MvuLi7MwvmkHDG9cHwfp6EzqIg,2267
4
+ general/paginator_dto.py,sha256=DB2hfcrckuEiHEIORLT4EgCcnX4AYYKGlHRr-l-Q1Cw,3741
4
5
  general/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
- general/exception/__init__.py,sha256=3o5KaoCwA020aG0GIR0jOA4t_ibOBiji0xMxZx4uNug,745
6
+ general/exception/__init__.py,sha256=MZpSDUKeopct7ZqMd1g4XlNS8sM9mmXIlHTIYPOhu9c,849
6
7
  general/exception/access_exceptions.py,sha256=tNIBjxM0-5iSOXDc7Il01eb36mEk-POQGVaHhCpG278,744
7
- general/exception/crud_exceptions.py,sha256=LGfQ8iTj9Sh8LVLId5HfSrc2UgH26CehKRT9aanZAiY,2950
8
+ general/exception/crud_exceptions.py,sha256=JZs3dXBOp0dYZ5yfNBbMSd2xoLTCMxXxz1myt46Ql2g,4351
8
9
  general/exception/exception_interface.py,sha256=k7KreAYMwKPQQghkCissdu3ASZZqrRFWM1lAGzeR8sM,1294
9
10
  general/interface/__init__.py,sha256=G11sI1Oxtb-Z_-ORAuKS_Kkdwopt1uXmEXYtZEJYiLs,445
10
11
  general/interface/base/__init__.py,sha256=fA1hdSY5TX6k_UpvA0eAfYeNWkXY6fPrHBqZVIdf6PY,270
11
12
  general/interface/base/base_model.py,sha256=aIzrRZVUytmbgmEH1bya29rVFOm-Njinn_JXkgkBrr8,2575
12
- general/interface/base/declarative_base.py,sha256=dmzvJLEL6qO4zmwjxLWOcqQmutlDC3pwCfxe1UtKGEE,7180
13
+ general/interface/base/declarative_base.py,sha256=4kT7gSHZufV514VG_r8vJm2irDZDYJaGc4L_nO_fTNA,8743
13
14
  general/interface/metadata/__init__.py,sha256=pXKYY4uO92eQI3gtuG4FKZbBi76gu-VJLxaAy-emeP8,474
14
- general/interface/metadata/crud_metadata.py,sha256=i6ldkyUqjHPerninf--sQlGyUm18FgzIBmDq55UYz0g,22819
15
- general/interface/metadata/geom_metadata.py,sha256=thfVc0hyKgkTFIQrRaX6khf_NbEHyEkc-zUA7nO8bI8,11918
15
+ general/interface/metadata/crud_metadata.py,sha256=VG3qzYlH9j3nmfZql1cRejxmLHBS-GE03XW6ZNtacQI,33689
16
+ general/interface/metadata/geom_metadata.py,sha256=gzeFkC9qyV_GXxtAaKmA9dka0DaC-gqk2sqo9KJkFxM,12042
17
+ general/interface/mixin/__init__.py,sha256=M5-e6T_R63Utwh3_WcmoE3wanwIZjNjbgLYrrFlSpTA,659
18
+ general/interface/mixin/audit_mixin.py,sha256=N2JDD9JxXDMeBqu0yAlhVWFOK3EeUA2b215hPvXytUU,1421
19
+ general/interface/mixin/softdelete_mixin.py,sha256=526luzM4DBCorcq99kZ6BYqtQKKJCpnxX9rSEigfyIU,2148
20
+ general/interface/mixin/timestamp_mixin.py,sha256=TMetFs3zgI7YanckFnfycpNGn-ZbptwweKdOxe125O0,1641
16
21
  general/interface/repository/__init__.py,sha256=eWoNy35LVu6sDrK5ju7VAeoclknKO0-yTgjVckJpi8s,321
17
- general/interface/repository/crud_repository.py,sha256=CH05WG6ZKkDpS5DEB_718AoRqJUS47CMXM65bI-1nCE,40032
22
+ general/interface/repository/crud_repository.py,sha256=pA3zNvZuLa7Ddeq1j8vNjPNc4KFWAL1-RwEuyc5C0oE,40543
18
23
  general/interface/repository/geometry_repository.py,sha256=dB1AmZyyi5cWEzl-_4-2eOXXex-rQsa_cu6bl85wtNE,11252
19
24
  general/interface/repository/many_to_many_repository.py,sha256=wl-K0n-rwPvIDpa_LfoBq1iHUnC6QvSLm-uwyKA4taw,8574
20
25
  general/interface/repository/view_repository.py,sha256=wxDGw7kenuVrDd9nfh7-omw2m9Jex5WzF_lhEiqPoA0,7902
21
- general/interface/repository/handler/__init__.py,sha256=7EIi-ydRFK39MLyMqAHp6l7gO_g6vg1iOZzAQQDUz94,650
26
+ general/interface/repository/handler/__init__.py,sha256=ZOJfvJennTeJjkl0z5a0kOO8G5nDvNXXYDJUIjbN1zY,710
22
27
  general/interface/repository/handler/base_handler.py,sha256=aVT6s60rsdZUqQlKWlUyuiGgVxzFLJCb7uztn859sL4,3363
23
28
  general/interface/repository/handler/handler.py,sha256=2ASmD8XkOLaE7-jCfTNwGHEwXOhBKEc_dfXpIYMuxN4,3221
24
29
  general/interface/repository/handler/ilike_handler.py,sha256=OM3klDlx7ixqWF7kklWj8kbgVY_5ZNitcQ0usGnQP_0,4015
@@ -26,9 +31,10 @@ general/interface/repository/handler/interval_handler.py,sha256=4TtEcpzUFZeyB-yi
26
31
  general/interface/repository/handler/json_handler.py,sha256=f_nGemsxTPJ3W0xkU99sCFA0EVo1xa8p803NyjcGJT4,4414
27
32
  general/interface/repository/handler/json_ilike_handler.py,sha256=icfA4dO8E_W_KUEG5HB1nLVI1jfjxzkG8ZJ-IaCd4kY,3530
28
33
  general/interface/repository/handler/or_handler.py,sha256=UOjIh77H4y-x434YGLYRfVIXKnod1M5XPiMf71V2JTg,3137
34
+ general/interface/repository/handler/soft_delete_handler.py,sha256=1WD1n6q5plyJ0RDniJcEwNlic1WJqXvSlCj99VLc_3A,4100
29
35
  general/interface/repository/handler/statement_manager.py,sha256=dPG-HZy0jVM3Y4ZnCp2QPY4gLgt-rWSzOvY1_UmotcI,7333
30
- python_general_be_lib-0.9.1.dist-info/licenses/LICENSE,sha256=iUaO1XZyB9P3Tmog0OILuTisP6vXGe3QKz-4yRTxOFk,1069
31
- python_general_be_lib-0.9.1.dist-info/METADATA,sha256=J4CIjmhDsD6SN26tAfFUvtbd2eQOggjhucncPn6Sn6c,3409
32
- python_general_be_lib-0.9.1.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
33
- python_general_be_lib-0.9.1.dist-info/top_level.txt,sha256=tTZePW8_CNUqSgKFd2SEH72ZbnhS0OYjRsgcv0ikSFY,8
34
- python_general_be_lib-0.9.1.dist-info/RECORD,,
36
+ python_general_be_lib-0.10.0.dist-info/licenses/LICENSE,sha256=iUaO1XZyB9P3Tmog0OILuTisP6vXGe3QKz-4yRTxOFk,1069
37
+ python_general_be_lib-0.10.0.dist-info/METADATA,sha256=yUNc1zJR-Nih7Iz9nVpD8jncus9MkL-fzihP8psvVfU,3410
38
+ python_general_be_lib-0.10.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
39
+ python_general_be_lib-0.10.0.dist-info/top_level.txt,sha256=tTZePW8_CNUqSgKFd2SEH72ZbnhS0OYjRsgcv0ikSFY,8
40
+ python_general_be_lib-0.10.0.dist-info/RECORD,,