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