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.
@@ -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)
@@ -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