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 +31 -0
- general/exception/__init__.py +6 -3
- general/exception/crud_exceptions.py +37 -4
- general/interface/base/declarative_base.py +36 -13
- general/interface/metadata/crud_metadata.py +259 -48
- general/interface/metadata/geom_metadata.py +6 -3
- general/interface/mixin/__init__.py +20 -0
- general/interface/mixin/audit_mixin.py +34 -0
- general/interface/mixin/softdelete_mixin.py +43 -0
- general/interface/mixin/timestamp_mixin.py +35 -0
- general/interface/repository/crud_repository.py +36 -28
- general/interface/repository/handler/__init__.py +2 -0
- general/interface/repository/handler/soft_delete_handler.py +83 -0
- general/paginator_dto.py +31 -2
- {python_general_be_lib-0.9.1.dist-info → python_general_be_lib-0.10.0.dist-info}/METADATA +1 -1
- {python_general_be_lib-0.9.1.dist-info → python_general_be_lib-0.10.0.dist-info}/RECORD +19 -13
- {python_general_be_lib-0.9.1.dist-info → python_general_be_lib-0.10.0.dist-info}/WHEEL +0 -0
- {python_general_be_lib-0.9.1.dist-info → python_general_be_lib-0.10.0.dist-info}/licenses/LICENSE +0 -0
- {python_general_be_lib-0.9.1.dist-info → python_general_be_lib-0.10.0.dist-info}/top_level.txt +0 -0
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
|
+
"""
|
general/exception/__init__.py
CHANGED
|
@@ -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.
|
|
6
|
-
|
|
7
|
-
``
|
|
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
|
|
4
|
-
|
|
5
|
-
``
|
|
6
|
-
``
|
|
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
|
|
135
|
+
"""Look up a mapped class by class name.
|
|
136
136
|
|
|
137
|
-
|
|
138
|
-
of
|
|
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.
|
|
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
|
|
166
|
+
"""Look up a mapped class by table name.
|
|
153
167
|
|
|
154
|
-
|
|
155
|
-
of
|
|
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
|
|
183
|
+
Type[Base] | None: The class the table is mapped on, or ``None`` when not found.
|
|
162
184
|
"""
|
|
163
|
-
for mapper in Base.
|
|
164
|
-
if mapper.
|
|
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
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
105
|
-
|
|
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 =
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
593
|
+
value is ``None``, which marks the model as a candidate for the insert.
|
|
581
594
|
"""
|
|
582
|
-
values = tuple(getattr(obj,
|
|
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
|
|
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
|
-
|
|
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.
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
15
|
-
general/interface/metadata/geom_metadata.py,sha256=
|
|
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=
|
|
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=
|
|
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.
|
|
31
|
-
python_general_be_lib-0.
|
|
32
|
-
python_general_be_lib-0.
|
|
33
|
-
python_general_be_lib-0.
|
|
34
|
-
python_general_be_lib-0.
|
|
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,,
|
|
File without changes
|
{python_general_be_lib-0.9.1.dist-info → python_general_be_lib-0.10.0.dist-info}/licenses/LICENSE
RENAMED
|
File without changes
|
{python_general_be_lib-0.9.1.dist-info → python_general_be_lib-0.10.0.dist-info}/top_level.txt
RENAMED
|
File without changes
|