muscles-data-sql 2.0.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.
- muscles/data/sql/__init__.py +114 -0
- muscles/data/sql/cli.py +76 -0
- muscles/data/sql/config.py +50 -0
- muscles/data/sql/connections.py +105 -0
- muscles/data/sql/data_adapter.py +71 -0
- muscles/data/sql/drivers.py +52 -0
- muscles/data/sql/engine.py +19 -0
- muscles/data/sql/inspect.py +26 -0
- muscles/data/sql/mapping.py +71 -0
- muscles/data/sql/migration_lock.py +53 -0
- muscles/data/sql/model_mapper.py +30 -0
- muscles/data/sql/native.py +131 -0
- muscles/data/sql/native_migrations.py +200 -0
- muscles/data/sql/providers.py +30 -0
- muscles/data/sql/query.py +33 -0
- muscles/data/sql/repository.py +438 -0
- muscles/data/sql/sqlalchemy_compat.py +18 -0
- muscles/data/sql/uow.py +47 -0
- muscles_data_sql-2.0.0.dist-info/METADATA +223 -0
- muscles_data_sql-2.0.0.dist-info/RECORD +23 -0
- muscles_data_sql-2.0.0.dist-info/WHEEL +5 -0
- muscles_data_sql-2.0.0.dist-info/entry_points.txt +2 -0
- muscles_data_sql-2.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from muscles.data.policies import (
|
|
6
|
+
DataOperationContext,
|
|
7
|
+
DataOperationKind,
|
|
8
|
+
DataOperationRequest,
|
|
9
|
+
DataPolicyPipeline,
|
|
10
|
+
DataPredicate,
|
|
11
|
+
DataPredicateOperator,
|
|
12
|
+
)
|
|
13
|
+
from muscles.data.errors import DataPolicyDeniedError
|
|
14
|
+
|
|
15
|
+
from .query import FilterClause, JoinClause, QuerySpec
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class SqlRepository:
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
session,
|
|
22
|
+
table,
|
|
23
|
+
*,
|
|
24
|
+
policies: DataPolicyPipeline | None = None,
|
|
25
|
+
context: DataOperationContext | None = None,
|
|
26
|
+
):
|
|
27
|
+
self.session = session
|
|
28
|
+
self.table = table
|
|
29
|
+
self.policies = policies
|
|
30
|
+
self.context = context or DataOperationContext()
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def target(self) -> str:
|
|
34
|
+
return str(getattr(self.table, "fullname", self.table.name))
|
|
35
|
+
|
|
36
|
+
def explain(
|
|
37
|
+
self,
|
|
38
|
+
kind: DataOperationKind,
|
|
39
|
+
*,
|
|
40
|
+
filters: tuple[DataPredicate, ...] = (),
|
|
41
|
+
values: dict | None = None,
|
|
42
|
+
):
|
|
43
|
+
"""Describe policy constraints for a repository operation without SQL I/O."""
|
|
44
|
+
filter_items = tuple(filters)
|
|
45
|
+
request = DataOperationRequest(
|
|
46
|
+
kind=kind,
|
|
47
|
+
target=self.target,
|
|
48
|
+
context=self.context,
|
|
49
|
+
filters=filter_items,
|
|
50
|
+
values=dict(values or {}),
|
|
51
|
+
)
|
|
52
|
+
predicates = self._policy_predicates(request)
|
|
53
|
+
return {
|
|
54
|
+
"operation": kind.value,
|
|
55
|
+
"target": self.target,
|
|
56
|
+
"filters": tuple(
|
|
57
|
+
{"field": item.field, "operator": item.operator.value}
|
|
58
|
+
for item in (*filter_items, *predicates)
|
|
59
|
+
),
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
def _policy_predicates(self, request: DataOperationRequest) -> tuple[DataPredicate, ...]:
|
|
63
|
+
if self.policies is None:
|
|
64
|
+
return ()
|
|
65
|
+
return self.policies.evaluate(request)
|
|
66
|
+
|
|
67
|
+
def _request(self, kind, *, filters=(), values=None) -> DataOperationRequest:
|
|
68
|
+
return DataOperationRequest(
|
|
69
|
+
kind=kind,
|
|
70
|
+
target=self.target,
|
|
71
|
+
context=self.context,
|
|
72
|
+
filters=tuple(self._as_data_predicate(item) for item in filters),
|
|
73
|
+
values=dict(values or {}),
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
@staticmethod
|
|
77
|
+
def _as_data_predicate(item) -> DataPredicate:
|
|
78
|
+
if isinstance(item, DataPredicate):
|
|
79
|
+
return item
|
|
80
|
+
return DataPredicate(
|
|
81
|
+
field=item.field,
|
|
82
|
+
operator=DataPredicateOperator(item.op.lower()),
|
|
83
|
+
value=item.value,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
def _assert_values_allowed(self, predicates: tuple[DataPredicate, ...], values: dict, *, require_fields: bool):
|
|
87
|
+
for predicate in predicates:
|
|
88
|
+
if predicate.field not in values:
|
|
89
|
+
if require_fields:
|
|
90
|
+
raise DataPolicyDeniedError(
|
|
91
|
+
f"Create data must provide policy field '{predicate.field}'"
|
|
92
|
+
)
|
|
93
|
+
continue
|
|
94
|
+
if not _matches_predicate(values[predicate.field], predicate):
|
|
95
|
+
raise DataPolicyDeniedError(
|
|
96
|
+
f"Write violates a data policy for field '{predicate.field}'"
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
def _append_policy_filters(self, spec: QuerySpec, predicates: tuple[DataPredicate, ...]) -> QuerySpec:
|
|
100
|
+
if not predicates:
|
|
101
|
+
return spec
|
|
102
|
+
return QuerySpec(
|
|
103
|
+
filters=list(spec.filters) + [
|
|
104
|
+
FilterClause(field=item.field, op=item.operator.value, value=item.value)
|
|
105
|
+
for item in predicates
|
|
106
|
+
],
|
|
107
|
+
or_filters=list(spec.or_filters),
|
|
108
|
+
joins=list(spec.joins),
|
|
109
|
+
select_columns=list(spec.select_columns),
|
|
110
|
+
order_by=list(spec.order_by),
|
|
111
|
+
group_by=list(spec.group_by),
|
|
112
|
+
having=list(spec.having),
|
|
113
|
+
limit=spec.limit,
|
|
114
|
+
offset=spec.offset,
|
|
115
|
+
distinct=spec.distinct,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
def create(self, data: dict):
|
|
119
|
+
return self._create(data)
|
|
120
|
+
|
|
121
|
+
def _create(self, data: dict, *, extra_predicates: tuple[DataPredicate, ...] = ()):
|
|
122
|
+
from sqlalchemy import insert
|
|
123
|
+
|
|
124
|
+
predicates = self._policy_predicates(
|
|
125
|
+
self._request(DataOperationKind.CREATE, values=data)
|
|
126
|
+
)
|
|
127
|
+
predicates = (*predicates, *extra_predicates)
|
|
128
|
+
self._assert_values_allowed(predicates, data, require_fields=True)
|
|
129
|
+
result = self.session.execute(insert(self.table).values(**data))
|
|
130
|
+
return result.inserted_primary_key
|
|
131
|
+
|
|
132
|
+
def get(self, pk):
|
|
133
|
+
from sqlalchemy import select
|
|
134
|
+
|
|
135
|
+
pk_col = list(self.table.primary_key.columns)[0]
|
|
136
|
+
filters = [FilterClause(field=pk_col.name, op="eq", value=pk)]
|
|
137
|
+
predicates = self._policy_predicates(self._request(DataOperationKind.READ, filters=filters))
|
|
138
|
+
stmt = select(self.table).where(pk_col == pk)
|
|
139
|
+
for predicate in predicates:
|
|
140
|
+
stmt = stmt.where(self._build_filter_expr(FilterClause(predicate.field, predicate.operator.value, predicate.value)))
|
|
141
|
+
return self.session.execute(stmt).mappings().first()
|
|
142
|
+
|
|
143
|
+
def list(self, limit: int = 100, offset: int = 0, order_by: Any = None):
|
|
144
|
+
spec = QuerySpec(limit=limit, offset=offset, order_by=[order_by] if order_by is not None else [])
|
|
145
|
+
predicates = self._policy_predicates(self._request(DataOperationKind.READ))
|
|
146
|
+
stmt = self._build_select(self._append_policy_filters(spec, predicates))
|
|
147
|
+
return self.session.execute(stmt).mappings().all()
|
|
148
|
+
|
|
149
|
+
def find(self, filters: dict | None = None, limit: int = 100, offset: int = 0, order_by: Any = None):
|
|
150
|
+
from sqlalchemy import select
|
|
151
|
+
|
|
152
|
+
clauses = [FilterClause(field=field, op="eq", value=value) for field, value in (filters or {}).items()]
|
|
153
|
+
spec = QuerySpec(filters=clauses, limit=limit, offset=offset, order_by=[order_by] if order_by is not None else [])
|
|
154
|
+
predicates = self._policy_predicates(self._request(DataOperationKind.READ, filters=clauses))
|
|
155
|
+
stmt = self._build_select(self._append_policy_filters(spec, predicates))
|
|
156
|
+
return self.session.execute(stmt).mappings().all()
|
|
157
|
+
|
|
158
|
+
def count(self, filters: dict | None = None) -> int:
|
|
159
|
+
from sqlalchemy import func, select
|
|
160
|
+
|
|
161
|
+
stmt = select(func.count()).select_from(self.table)
|
|
162
|
+
filters = filters or {}
|
|
163
|
+
clauses = [FilterClause(field=field, op="eq", value=value) for field, value in filters.items()]
|
|
164
|
+
predicates = self._policy_predicates(self._request(DataOperationKind.READ, filters=clauses))
|
|
165
|
+
for field, value in filters.items():
|
|
166
|
+
stmt = stmt.where(getattr(self.table.c, field) == value)
|
|
167
|
+
for predicate in predicates:
|
|
168
|
+
stmt = stmt.where(self._build_filter_expr(FilterClause(predicate.field, predicate.operator.value, predicate.value)))
|
|
169
|
+
return int(self.session.execute(stmt).scalar_one())
|
|
170
|
+
|
|
171
|
+
def exists(self, filters: dict) -> bool:
|
|
172
|
+
return self.count(filters) > 0
|
|
173
|
+
|
|
174
|
+
def update(self, pk, data: dict):
|
|
175
|
+
return self._update(pk, data)
|
|
176
|
+
|
|
177
|
+
def _update(self, pk, data: dict, *, extra_predicates: tuple[DataPredicate, ...] = ()):
|
|
178
|
+
from sqlalchemy import update
|
|
179
|
+
|
|
180
|
+
pk_col = list(self.table.primary_key.columns)[0]
|
|
181
|
+
filters = [FilterClause(field=pk_col.name, op="eq", value=pk)]
|
|
182
|
+
predicates = self._policy_predicates(
|
|
183
|
+
self._request(DataOperationKind.UPDATE, filters=filters, values=data)
|
|
184
|
+
)
|
|
185
|
+
predicates = (*predicates, *extra_predicates)
|
|
186
|
+
self._assert_values_allowed(predicates, data, require_fields=False)
|
|
187
|
+
stmt = update(self.table).where(pk_col == pk)
|
|
188
|
+
for predicate in predicates:
|
|
189
|
+
stmt = stmt.where(self._build_filter_expr(FilterClause(predicate.field, predicate.operator.value, predicate.value)))
|
|
190
|
+
stmt = stmt.values(**data)
|
|
191
|
+
return self.session.execute(stmt).rowcount
|
|
192
|
+
|
|
193
|
+
def delete(self, pk):
|
|
194
|
+
from sqlalchemy import delete
|
|
195
|
+
|
|
196
|
+
pk_col = list(self.table.primary_key.columns)[0]
|
|
197
|
+
filters = [FilterClause(field=pk_col.name, op="eq", value=pk)]
|
|
198
|
+
predicates = self._policy_predicates(self._request(DataOperationKind.DELETE, filters=filters))
|
|
199
|
+
stmt = delete(self.table).where(pk_col == pk)
|
|
200
|
+
for predicate in predicates:
|
|
201
|
+
stmt = stmt.where(self._build_filter_expr(FilterClause(predicate.field, predicate.operator.value, predicate.value)))
|
|
202
|
+
return self.session.execute(stmt).rowcount
|
|
203
|
+
|
|
204
|
+
def query(self, spec: QuerySpec):
|
|
205
|
+
predicates = self._policy_predicates(
|
|
206
|
+
self._request(DataOperationKind.READ, filters=spec.filters + spec.or_filters)
|
|
207
|
+
)
|
|
208
|
+
stmt = self._build_select(self._append_policy_filters(spec, predicates))
|
|
209
|
+
return self.session.execute(stmt).mappings().all()
|
|
210
|
+
|
|
211
|
+
def first(self, spec: QuerySpec):
|
|
212
|
+
first_spec = QuerySpec(
|
|
213
|
+
filters=list(spec.filters),
|
|
214
|
+
or_filters=list(spec.or_filters),
|
|
215
|
+
joins=list(spec.joins),
|
|
216
|
+
select_columns=list(spec.select_columns),
|
|
217
|
+
order_by=list(spec.order_by),
|
|
218
|
+
group_by=list(spec.group_by),
|
|
219
|
+
having=list(spec.having),
|
|
220
|
+
limit=1,
|
|
221
|
+
offset=spec.offset,
|
|
222
|
+
distinct=spec.distinct,
|
|
223
|
+
)
|
|
224
|
+
rows = self.query(first_spec)
|
|
225
|
+
return rows[0] if rows else None
|
|
226
|
+
|
|
227
|
+
def scalar(self, spec: QuerySpec):
|
|
228
|
+
predicates = self._policy_predicates(self._request(DataOperationKind.READ, filters=spec.filters + spec.or_filters))
|
|
229
|
+
stmt = self._build_select(self._append_policy_filters(spec, predicates))
|
|
230
|
+
return self.session.execute(stmt).scalar()
|
|
231
|
+
|
|
232
|
+
def aggregate(self, spec: QuerySpec):
|
|
233
|
+
predicates = self._policy_predicates(self._request(DataOperationKind.READ, filters=spec.filters + spec.or_filters))
|
|
234
|
+
stmt = self._build_select(self._append_policy_filters(spec, predicates))
|
|
235
|
+
return self.session.execute(stmt).mappings().all()
|
|
236
|
+
|
|
237
|
+
def bulk_create(self, rows: list[dict]) -> int:
|
|
238
|
+
from sqlalchemy import insert
|
|
239
|
+
|
|
240
|
+
if not rows:
|
|
241
|
+
return 0
|
|
242
|
+
if self.policies is not None:
|
|
243
|
+
with self.session.begin_nested():
|
|
244
|
+
for row in rows:
|
|
245
|
+
self.create(row)
|
|
246
|
+
return len(rows)
|
|
247
|
+
self.session.execute(insert(self.table), rows)
|
|
248
|
+
return len(rows)
|
|
249
|
+
|
|
250
|
+
def bulk_update(self, pk_field: str, rows: list[dict]) -> int:
|
|
251
|
+
if not rows:
|
|
252
|
+
return 0
|
|
253
|
+
def apply_updates():
|
|
254
|
+
updated_count = 0
|
|
255
|
+
for row in rows:
|
|
256
|
+
pk = row.get(pk_field)
|
|
257
|
+
if pk is None:
|
|
258
|
+
continue
|
|
259
|
+
data = {k: v for k, v in row.items() if k != pk_field}
|
|
260
|
+
if not data:
|
|
261
|
+
continue
|
|
262
|
+
updated_count += int(self.update(pk, data) or 0)
|
|
263
|
+
return updated_count
|
|
264
|
+
|
|
265
|
+
if self.policies is not None:
|
|
266
|
+
with self.session.begin_nested():
|
|
267
|
+
updated = apply_updates()
|
|
268
|
+
else:
|
|
269
|
+
updated = apply_updates()
|
|
270
|
+
return updated
|
|
271
|
+
|
|
272
|
+
def upsert(self, row: dict, conflict_fields: list[str]):
|
|
273
|
+
from sqlalchemy import inspect
|
|
274
|
+
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
|
|
275
|
+
from sqlalchemy.exc import OperationalError
|
|
276
|
+
|
|
277
|
+
if not self._has_unique_conflict_constraint(conflict_fields):
|
|
278
|
+
raise ValueError(
|
|
279
|
+
"conflict_fields must match a PRIMARY KEY or UNIQUE constraint for safe upsert fallback"
|
|
280
|
+
)
|
|
281
|
+
if any(field not in row for field in conflict_fields):
|
|
282
|
+
raise ValueError("upsert requires all conflict fields in the row")
|
|
283
|
+
|
|
284
|
+
# Native upsert statements bypass policy-filtered update predicates.
|
|
285
|
+
# Under policies use the same constrained read/update/create operations
|
|
286
|
+
# as the rest of the repository instead.
|
|
287
|
+
if self.policies is not None:
|
|
288
|
+
filters = {field: row[field] for field in conflict_fields if field in row}
|
|
289
|
+
filter_clauses = [FilterClause(field=field, op="eq", value=value) for field, value in filters.items()]
|
|
290
|
+
upsert_predicates = self._policy_predicates(
|
|
291
|
+
self._request(DataOperationKind.UPSERT, filters=filter_clauses, values=row)
|
|
292
|
+
)
|
|
293
|
+
self._assert_values_allowed(upsert_predicates, row, require_fields=True)
|
|
294
|
+
existing = self.find(filters=filters, limit=1) if filters else []
|
|
295
|
+
if existing:
|
|
296
|
+
self._assert_values_allowed(upsert_predicates, dict(existing[0]), require_fields=True)
|
|
297
|
+
pk_col = list(self.table.primary_key.columns)[0].name
|
|
298
|
+
pk_val = existing[0][pk_col]
|
|
299
|
+
return self._update(
|
|
300
|
+
pk_val,
|
|
301
|
+
{k: v for k, v in row.items() if k != pk_col},
|
|
302
|
+
extra_predicates=upsert_predicates,
|
|
303
|
+
)
|
|
304
|
+
self._create(row, extra_predicates=upsert_predicates)
|
|
305
|
+
return 1
|
|
306
|
+
|
|
307
|
+
dialect_name = inspect(self.session.bind).dialect.name
|
|
308
|
+
if dialect_name == "sqlite":
|
|
309
|
+
stmt = sqlite_insert(self.table).values(**row)
|
|
310
|
+
update_data = {k: v for k, v in row.items() if k not in conflict_fields}
|
|
311
|
+
stmt = stmt.on_conflict_do_update(index_elements=conflict_fields, set_=update_data)
|
|
312
|
+
try:
|
|
313
|
+
self.session.execute(stmt)
|
|
314
|
+
return 1
|
|
315
|
+
except OperationalError:
|
|
316
|
+
# Fallback for tables where conflict_fields are not backed by unique/pk constraints.
|
|
317
|
+
pass
|
|
318
|
+
|
|
319
|
+
filters = {field: row[field] for field in conflict_fields if field in row}
|
|
320
|
+
if filters:
|
|
321
|
+
existing = self.find(filters=filters, limit=1)
|
|
322
|
+
if existing:
|
|
323
|
+
pk_col = list(self.table.primary_key.columns)[0].name
|
|
324
|
+
pk_val = existing[0][pk_col]
|
|
325
|
+
return self.update(pk_val, {k: v for k, v in row.items() if k != pk_col})
|
|
326
|
+
|
|
327
|
+
pk_col = list(self.table.primary_key.columns)[0].name
|
|
328
|
+
pk_val = row.get(pk_col)
|
|
329
|
+
if pk_val is not None and self.get(pk_val):
|
|
330
|
+
return self.update(pk_val, {k: v for k, v in row.items() if k != pk_col})
|
|
331
|
+
self.create(row)
|
|
332
|
+
return 1
|
|
333
|
+
|
|
334
|
+
def _has_unique_conflict_constraint(self, conflict_fields: list[str]) -> bool:
|
|
335
|
+
from sqlalchemy import UniqueConstraint
|
|
336
|
+
|
|
337
|
+
if not conflict_fields:
|
|
338
|
+
return False
|
|
339
|
+
conflict_set = set(conflict_fields)
|
|
340
|
+
pk_cols = {column.name for column in self.table.primary_key.columns}
|
|
341
|
+
if conflict_set == pk_cols:
|
|
342
|
+
return True
|
|
343
|
+
|
|
344
|
+
for constraint in getattr(self.table, "constraints", []):
|
|
345
|
+
columns = getattr(constraint, "columns", None)
|
|
346
|
+
if columns is None:
|
|
347
|
+
continue
|
|
348
|
+
if isinstance(constraint, UniqueConstraint) and {column.name for column in columns} == conflict_set:
|
|
349
|
+
return True
|
|
350
|
+
|
|
351
|
+
for index in getattr(self.table, "indexes", []):
|
|
352
|
+
if getattr(index, "unique", False) and {column.name for column in index.columns} == conflict_set:
|
|
353
|
+
return True
|
|
354
|
+
|
|
355
|
+
return False
|
|
356
|
+
|
|
357
|
+
def _build_select(self, spec: QuerySpec):
|
|
358
|
+
from sqlalchemy import and_, or_, select
|
|
359
|
+
|
|
360
|
+
columns = spec.select_columns or [self.table]
|
|
361
|
+
stmt = select(*columns)
|
|
362
|
+
if spec.distinct:
|
|
363
|
+
stmt = stmt.distinct()
|
|
364
|
+
|
|
365
|
+
for join in spec.joins:
|
|
366
|
+
if join.is_outer:
|
|
367
|
+
stmt = stmt.outerjoin(join.table, join.on)
|
|
368
|
+
else:
|
|
369
|
+
stmt = stmt.join(join.table, join.on)
|
|
370
|
+
|
|
371
|
+
if spec.filters:
|
|
372
|
+
stmt = stmt.where(and_(*[self._build_filter_expr(clause) for clause in spec.filters]))
|
|
373
|
+
if spec.or_filters:
|
|
374
|
+
stmt = stmt.where(or_(*[self._build_filter_expr(clause) for clause in spec.or_filters]))
|
|
375
|
+
if spec.group_by:
|
|
376
|
+
stmt = stmt.group_by(*spec.group_by)
|
|
377
|
+
if spec.having:
|
|
378
|
+
stmt = stmt.having(*spec.having)
|
|
379
|
+
if spec.order_by:
|
|
380
|
+
stmt = stmt.order_by(*spec.order_by)
|
|
381
|
+
if spec.limit is not None:
|
|
382
|
+
stmt = stmt.limit(spec.limit)
|
|
383
|
+
if spec.offset:
|
|
384
|
+
stmt = stmt.offset(spec.offset)
|
|
385
|
+
return stmt
|
|
386
|
+
|
|
387
|
+
def _build_filter_expr(self, clause: FilterClause):
|
|
388
|
+
column = getattr(self.table.c, clause.field)
|
|
389
|
+
op = clause.op.lower()
|
|
390
|
+
if op == "eq":
|
|
391
|
+
return column == clause.value
|
|
392
|
+
if op == "ne":
|
|
393
|
+
return column != clause.value
|
|
394
|
+
if op == "gt":
|
|
395
|
+
return column > clause.value
|
|
396
|
+
if op == "gte":
|
|
397
|
+
return column >= clause.value
|
|
398
|
+
if op == "lt":
|
|
399
|
+
return column < clause.value
|
|
400
|
+
if op == "lte":
|
|
401
|
+
return column <= clause.value
|
|
402
|
+
if op == "in":
|
|
403
|
+
return column.in_(clause.value or [])
|
|
404
|
+
if op == "like":
|
|
405
|
+
return column.like(clause.value)
|
|
406
|
+
if op == "is_null":
|
|
407
|
+
return column.is_(None) if clause.value else column.is_not(None)
|
|
408
|
+
raise ValueError(f"Unsupported filter operator: {clause.op}")
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _matches_predicate(actual: Any, predicate: DataPredicate) -> bool:
|
|
412
|
+
"""Evaluate supported scalar constraints before inserting/changing rows."""
|
|
413
|
+
operator = predicate.operator.value
|
|
414
|
+
expected = predicate.value
|
|
415
|
+
try:
|
|
416
|
+
if operator in ("eq", "ne") and expected is None:
|
|
417
|
+
return (actual is None) if operator == "eq" else (actual is not None)
|
|
418
|
+
if actual is None and operator != "is_null":
|
|
419
|
+
return False
|
|
420
|
+
if operator == "eq":
|
|
421
|
+
return actual == expected
|
|
422
|
+
if operator == "ne":
|
|
423
|
+
return actual != expected
|
|
424
|
+
if operator == "gt":
|
|
425
|
+
return actual is not None and actual > expected
|
|
426
|
+
if operator == "gte":
|
|
427
|
+
return actual is not None and actual >= expected
|
|
428
|
+
if operator == "lt":
|
|
429
|
+
return actual is not None and actual < expected
|
|
430
|
+
if operator == "lte":
|
|
431
|
+
return actual is not None and actual <= expected
|
|
432
|
+
if operator == "in":
|
|
433
|
+
return actual in (expected or [])
|
|
434
|
+
if operator == "is_null":
|
|
435
|
+
return (actual is None) is bool(expected)
|
|
436
|
+
except (TypeError, ValueError):
|
|
437
|
+
return False
|
|
438
|
+
raise DataPolicyDeniedError(f"Policy operator '{operator}' cannot be validated against write data")
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Small compatibility helpers for optional SQLAlchemy integrations."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
if TYPE_CHECKING:
|
|
7
|
+
from sqlalchemy.sql.elements import TextClause
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def text(statement: str) -> TextClause:
|
|
11
|
+
"""Create a SQLAlchemy text clause without importing SQLAlchemy eagerly."""
|
|
12
|
+
try:
|
|
13
|
+
from sqlalchemy import text as sqlalchemy_text
|
|
14
|
+
except ImportError as exc:
|
|
15
|
+
raise ImportError(
|
|
16
|
+
"SQLAlchemy text statements require muscles-data-sql[legacy]"
|
|
17
|
+
) from exc
|
|
18
|
+
return sqlalchemy_text(statement)
|
muscles/data/sql/uow.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class UnitOfWork:
|
|
5
|
+
def __init__(self, session_factory, *, isolation_level: str | None = None):
|
|
6
|
+
self._session_factory = session_factory
|
|
7
|
+
self._isolation_level = isolation_level
|
|
8
|
+
self.session: Any | None = None
|
|
9
|
+
|
|
10
|
+
def _require_session(self):
|
|
11
|
+
if self.session is None:
|
|
12
|
+
raise RuntimeError("UnitOfWork requires an active session; use it as a context manager first.")
|
|
13
|
+
return self.session
|
|
14
|
+
|
|
15
|
+
def __enter__(self):
|
|
16
|
+
session = self._session_factory()
|
|
17
|
+
if self._isolation_level:
|
|
18
|
+
session.connection(execution_options={"isolation_level": self._isolation_level})
|
|
19
|
+
self.session = session
|
|
20
|
+
return self
|
|
21
|
+
|
|
22
|
+
def __exit__(self, exc_type, exc, tb):
|
|
23
|
+
session = self._require_session()
|
|
24
|
+
if exc_type is None:
|
|
25
|
+
session.commit()
|
|
26
|
+
else:
|
|
27
|
+
session.rollback()
|
|
28
|
+
session.close()
|
|
29
|
+
self.session = None
|
|
30
|
+
|
|
31
|
+
def begin_nested(self):
|
|
32
|
+
return self._require_session().begin_nested()
|
|
33
|
+
|
|
34
|
+
def with_retry(self, fn, *, max_attempts: int = 3, retry_on: tuple[type[Exception], ...] = ()):
|
|
35
|
+
if max_attempts < 1:
|
|
36
|
+
raise ValueError("max_attempts must be >= 1")
|
|
37
|
+
attempt = 0
|
|
38
|
+
while True:
|
|
39
|
+
attempt += 1
|
|
40
|
+
session = self._require_session()
|
|
41
|
+
try:
|
|
42
|
+
return fn(session)
|
|
43
|
+
except Exception as exc:
|
|
44
|
+
if retry_on and isinstance(exc, retry_on) and attempt < max_attempts:
|
|
45
|
+
session.rollback()
|
|
46
|
+
continue
|
|
47
|
+
raise
|