vention-storage 0.6.0__py3-none-any.whl → 0.6.3__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.
- storage/accessor.py +126 -17
- storage/crud_service.py +220 -18
- storage/database.py +3 -9
- storage/database_service.py +4 -12
- storage/hooks.py +3 -9
- storage/io_helpers.py +7 -21
- storage/utils.py +1 -3
- storage/vention_communication.py +69 -8
- {vention_storage-0.6.0.dist-info → vention_storage-0.6.3.dist-info}/METADATA +126 -5
- vention_storage-0.6.3.dist-info/RECORD +14 -0
- vention_storage-0.6.0.dist-info/RECORD +0 -14
- {vention_storage-0.6.0.dist-info → vention_storage-0.6.3.dist-info}/WHEEL +0 -0
storage/accessor.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
from contextlib import contextmanager
|
|
4
|
+
from functools import reduce
|
|
4
5
|
from typing import (
|
|
5
6
|
Any,
|
|
6
7
|
Callable,
|
|
@@ -15,6 +16,7 @@ from typing import (
|
|
|
15
16
|
)
|
|
16
17
|
|
|
17
18
|
from sqlmodel import SQLModel, Session, select
|
|
19
|
+
from sqlmodel.sql.expression import SelectOfScalar
|
|
18
20
|
|
|
19
21
|
from storage.auditor import audit_operation
|
|
20
22
|
from storage import database
|
|
@@ -25,6 +27,48 @@ from storage.utils import ModelType, utcnow, Operation
|
|
|
25
27
|
WriteResult = TypeVar("WriteResult")
|
|
26
28
|
|
|
27
29
|
|
|
30
|
+
def _apply_conditions(statement: SelectOfScalar[ModelType], conditions: tuple[Any, ...]) -> SelectOfScalar[ModelType]:
|
|
31
|
+
"""Apply filter conditions to a select statement."""
|
|
32
|
+
return reduce(lambda stmt, cond: stmt.where(cond), conditions, statement)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _apply_soft_delete_filter(
|
|
36
|
+
statement: SelectOfScalar[ModelType],
|
|
37
|
+
model: Type[ModelType],
|
|
38
|
+
has_soft_delete: bool,
|
|
39
|
+
include_deleted: bool,
|
|
40
|
+
) -> SelectOfScalar[ModelType]:
|
|
41
|
+
"""Apply soft delete filter if applicable."""
|
|
42
|
+
if not has_soft_delete or include_deleted:
|
|
43
|
+
return statement
|
|
44
|
+
return statement.where(getattr(model, "deleted_at").is_(None))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _apply_ordering(
|
|
48
|
+
statement: SelectOfScalar[ModelType],
|
|
49
|
+
model: Type[ModelType],
|
|
50
|
+
order_by: Optional[str],
|
|
51
|
+
order_desc: bool,
|
|
52
|
+
) -> SelectOfScalar[ModelType]:
|
|
53
|
+
"""Apply ordering to a select statement."""
|
|
54
|
+
if not order_by:
|
|
55
|
+
return statement
|
|
56
|
+
field = getattr(model, order_by, None)
|
|
57
|
+
if field is None:
|
|
58
|
+
raise ValueError(f"Field '{order_by}' not found on {model.__name__}")
|
|
59
|
+
return statement.order_by(field.desc() if order_desc else field)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _apply_pagination(
|
|
63
|
+
statement: SelectOfScalar[ModelType],
|
|
64
|
+
limit: Optional[int],
|
|
65
|
+
offset: int,
|
|
66
|
+
) -> SelectOfScalar[ModelType]:
|
|
67
|
+
"""Apply pagination to a select statement."""
|
|
68
|
+
limited = statement.limit(limit) if limit is not None else statement
|
|
69
|
+
return limited.offset(offset) if offset > 0 else limited
|
|
70
|
+
|
|
71
|
+
|
|
28
72
|
class ModelAccessor(Generic[ModelType]):
|
|
29
73
|
"""
|
|
30
74
|
Accessor for a single SQLModel type with:
|
|
@@ -47,6 +91,8 @@ class ModelAccessor(Generic[ModelType]):
|
|
|
47
91
|
self._hooks: HookRegistry[ModelType] = HookRegistry()
|
|
48
92
|
self._has_soft_delete = hasattr(model, "deleted_at")
|
|
49
93
|
self._enable_auditing = enable_auditing
|
|
94
|
+
# Field proxy for type-safe filtering: accessor.where.field_name
|
|
95
|
+
self.where = model
|
|
50
96
|
|
|
51
97
|
# ---------- Hook decorators ----------
|
|
52
98
|
def before_insert(self) -> Callable[[HookFn[ModelType]], HookFn[ModelType]]:
|
|
@@ -73,9 +119,7 @@ class ModelAccessor(Generic[ModelType]):
|
|
|
73
119
|
with database.use_session(session):
|
|
74
120
|
self._hooks.emit(event, session=session, instance=instance)
|
|
75
121
|
|
|
76
|
-
def _audit_create_operation(
|
|
77
|
-
self, *, session: Session, instance: ModelType, actor: str
|
|
78
|
-
) -> None:
|
|
122
|
+
def _audit_create_operation(self, *, session: Session, instance: ModelType, actor: str) -> None:
|
|
79
123
|
"""Audit a create operation."""
|
|
80
124
|
if not self._enable_auditing:
|
|
81
125
|
return
|
|
@@ -90,7 +134,7 @@ class ModelAccessor(Generic[ModelType]):
|
|
|
90
134
|
)
|
|
91
135
|
|
|
92
136
|
def _run_write(self, fn: Callable[[Session], WriteResult]) -> WriteResult:
|
|
93
|
-
"""Run a write
|
|
137
|
+
"""Run a write operation using the current session if present, else open a transaction."""
|
|
94
138
|
existing = database.CURRENT_SESSION.get()
|
|
95
139
|
if existing is not None:
|
|
96
140
|
return fn(existing)
|
|
@@ -119,12 +163,83 @@ class ModelAccessor(Generic[ModelType]):
|
|
|
119
163
|
return None
|
|
120
164
|
return cast(ModelType, obj)
|
|
121
165
|
|
|
122
|
-
def all(
|
|
123
|
-
|
|
166
|
+
def all(
|
|
167
|
+
self,
|
|
168
|
+
*,
|
|
169
|
+
include_deleted: bool = False,
|
|
170
|
+
limit: Optional[int] = None,
|
|
171
|
+
offset: int = 0,
|
|
172
|
+
order_by: Optional[str] = None,
|
|
173
|
+
order_desc: bool = False,
|
|
174
|
+
) -> List[ModelType]:
|
|
175
|
+
"""Get all models with optional pagination and sorting.
|
|
176
|
+
|
|
177
|
+
Args:
|
|
178
|
+
include_deleted: Whether to include soft-deleted records
|
|
179
|
+
limit: Maximum number of records to return (None = no limit)
|
|
180
|
+
offset: Number of records to skip (for pagination)
|
|
181
|
+
order_by: Field name to sort by (must exist on model)
|
|
182
|
+
order_desc: If True, sort descending; otherwise ascending
|
|
183
|
+
|
|
184
|
+
Returns:
|
|
185
|
+
List of model instances
|
|
186
|
+
|
|
187
|
+
Raises:
|
|
188
|
+
ValueError: If order_by field doesn't exist on the model
|
|
189
|
+
"""
|
|
124
190
|
with self._read_session() as session:
|
|
125
191
|
statement = select(self.model)
|
|
126
|
-
|
|
127
|
-
|
|
192
|
+
statement = _apply_soft_delete_filter(statement, self.model, self._has_soft_delete, include_deleted)
|
|
193
|
+
statement = _apply_ordering(statement, self.model, order_by, order_desc)
|
|
194
|
+
statement = _apply_pagination(statement, limit, offset)
|
|
195
|
+
|
|
196
|
+
return cast(List[ModelType], session.exec(statement).all())
|
|
197
|
+
|
|
198
|
+
def find(
|
|
199
|
+
self,
|
|
200
|
+
*conditions: Any,
|
|
201
|
+
include_deleted: bool = False,
|
|
202
|
+
limit: Optional[int] = None,
|
|
203
|
+
offset: int = 0,
|
|
204
|
+
order_by: Optional[str] = None,
|
|
205
|
+
order_desc: bool = False,
|
|
206
|
+
) -> List[ModelType]:
|
|
207
|
+
"""Find records matching all given filter conditions.
|
|
208
|
+
|
|
209
|
+
Args:
|
|
210
|
+
*conditions: SQLAlchemy filter conditions using accessor.where.field_name
|
|
211
|
+
include_deleted: Whether to include soft-deleted records
|
|
212
|
+
limit: Maximum number of records to return (None = no limit)
|
|
213
|
+
offset: Number of records to skip (for pagination)
|
|
214
|
+
order_by: Field name to sort by (must exist on model)
|
|
215
|
+
order_desc: If True, sort descending; otherwise ascending
|
|
216
|
+
|
|
217
|
+
Returns:
|
|
218
|
+
List of model instances matching all conditions
|
|
219
|
+
|
|
220
|
+
Example:
|
|
221
|
+
# Find active users over 18
|
|
222
|
+
users = user_accessor.find(
|
|
223
|
+
user_accessor.where.age >= 18,
|
|
224
|
+
user_accessor.where.status == "active",
|
|
225
|
+
limit=10
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
# String operations
|
|
229
|
+
users = user_accessor.find(
|
|
230
|
+
user_accessor.where.name.contains("Smith")
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
Raises:
|
|
234
|
+
ValueError: If order_by field doesn't exist on the model
|
|
235
|
+
"""
|
|
236
|
+
with self._read_session() as session:
|
|
237
|
+
statement = select(self.model)
|
|
238
|
+
statement = _apply_conditions(statement, conditions)
|
|
239
|
+
statement = _apply_soft_delete_filter(statement, self.model, self._has_soft_delete, include_deleted)
|
|
240
|
+
statement = _apply_ordering(statement, self.model, order_by, order_desc)
|
|
241
|
+
statement = _apply_pagination(statement, limit, offset)
|
|
242
|
+
|
|
128
243
|
return cast(List[ModelType], session.exec(statement).all())
|
|
129
244
|
|
|
130
245
|
# ---------- Writes ----------
|
|
@@ -227,9 +342,7 @@ class ModelAccessor(Generic[ModelType]):
|
|
|
227
342
|
return self._run_write(write_operation)
|
|
228
343
|
|
|
229
344
|
# ---------- Batch helpers ----------
|
|
230
|
-
def insert_many(
|
|
231
|
-
self, objs: Sequence[ModelType], *, actor: str = "internal"
|
|
232
|
-
) -> List[ModelType]:
|
|
345
|
+
def insert_many(self, objs: Sequence[ModelType], *, actor: str = "internal") -> List[ModelType]:
|
|
233
346
|
"""Insert multiple models."""
|
|
234
347
|
|
|
235
348
|
def write_operation(session: Session) -> List[ModelType]:
|
|
@@ -257,9 +370,7 @@ class ModelAccessor(Generic[ModelType]):
|
|
|
257
370
|
if obj is None:
|
|
258
371
|
continue
|
|
259
372
|
self._emit("before_delete", session=session, instance=obj)
|
|
260
|
-
op_name, before_payload, after_payload = _soft_or_hard_delete(
|
|
261
|
-
session, obj
|
|
262
|
-
)
|
|
373
|
+
op_name, before_payload, after_payload = _soft_or_hard_delete(session, obj)
|
|
263
374
|
if self._enable_auditing:
|
|
264
375
|
audit_operation(
|
|
265
376
|
session=session,
|
|
@@ -277,9 +388,7 @@ class ModelAccessor(Generic[ModelType]):
|
|
|
277
388
|
return self._run_write(write_operation)
|
|
278
389
|
|
|
279
390
|
|
|
280
|
-
def _soft_or_hard_delete(
|
|
281
|
-
session: Session, instance: SQLModel
|
|
282
|
-
) -> tuple[Operation, dict[str, Any], dict[str, Any] | None]:
|
|
391
|
+
def _soft_or_hard_delete(session: Session, instance: SQLModel) -> tuple[Operation, dict[str, Any], dict[str, Any] | None]:
|
|
283
392
|
"""Soft delete if model defines `deleted_at`, else hard delete."""
|
|
284
393
|
before_payload = instance.model_dump()
|
|
285
394
|
if hasattr(instance, "deleted_at"):
|
storage/crud_service.py
CHANGED
|
@@ -1,12 +1,87 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
|
-
from
|
|
2
|
+
from enum import Enum
|
|
3
|
+
from typing import Any, Callable, Dict, List, Optional, TypedDict, Union
|
|
3
4
|
|
|
4
5
|
from sqlalchemy.exc import DataError, IntegrityError, StatementError
|
|
5
6
|
from storage.accessor import ModelAccessor
|
|
6
7
|
from storage.utils import ModelType
|
|
7
8
|
|
|
8
9
|
|
|
9
|
-
__all__ = ["CrudService", "CrudError"]
|
|
10
|
+
__all__ = ["CrudService", "CrudError", "FilterOperation", "Filter"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
# ---------------------------------------------------------
|
|
14
|
+
# Filter Types
|
|
15
|
+
# ---------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class FilterOperation(str, Enum):
|
|
19
|
+
"""
|
|
20
|
+
Supported filter operations for find_records queries.
|
|
21
|
+
|
|
22
|
+
Comparison operators:
|
|
23
|
+
EQUALS: Equal (field == value)
|
|
24
|
+
NOT_EQUALS: Not equal (field != value)
|
|
25
|
+
GREATER_THAN: Greater than (field > value)
|
|
26
|
+
GREATER_THAN_OR_EQUALS: Greater than or equal (field >= value)
|
|
27
|
+
LESS_THAN: Less than (field < value)
|
|
28
|
+
LESS_THAN_OR_EQUALS: Less than or equal (field <= value)
|
|
29
|
+
|
|
30
|
+
Collection operators:
|
|
31
|
+
IN: Value in list (field IN [values])
|
|
32
|
+
NOT_IN: Value not in list (field NOT IN [values])
|
|
33
|
+
|
|
34
|
+
String operators:
|
|
35
|
+
CONTAINS: Field contains substring
|
|
36
|
+
STARTS_WITH: Field starts with prefix
|
|
37
|
+
ENDS_WITH: Field ends with suffix
|
|
38
|
+
LIKE: Case-insensitive pattern match (SQL ILIKE)
|
|
39
|
+
|
|
40
|
+
Null checks:
|
|
41
|
+
IS_NULL: Field is None
|
|
42
|
+
IS_NOT_NULL: Field is not None
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
# Comparison operators
|
|
46
|
+
EQUALS = "eq"
|
|
47
|
+
NOT_EQUALS = "ne"
|
|
48
|
+
GREATER_THAN = "gt"
|
|
49
|
+
GREATER_THAN_OR_EQUALS = "gte"
|
|
50
|
+
LESS_THAN = "lt"
|
|
51
|
+
LESS_THAN_OR_EQUALS = "lte"
|
|
52
|
+
|
|
53
|
+
# Collection operators
|
|
54
|
+
IN = "in"
|
|
55
|
+
NOT_IN = "not_in"
|
|
56
|
+
|
|
57
|
+
# String operators
|
|
58
|
+
CONTAINS = "contains"
|
|
59
|
+
STARTS_WITH = "startswith"
|
|
60
|
+
ENDS_WITH = "endswith"
|
|
61
|
+
LIKE = "like"
|
|
62
|
+
|
|
63
|
+
# Null checks
|
|
64
|
+
IS_NULL = "is_null"
|
|
65
|
+
IS_NOT_NULL = "is_not_null"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class _FilterRequired(TypedDict):
|
|
69
|
+
"""Required keys for Filter."""
|
|
70
|
+
|
|
71
|
+
field: str
|
|
72
|
+
operation: Union[FilterOperation, str]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class Filter(_FilterRequired, total=False):
|
|
76
|
+
"""Type definition for filter dictionaries used in find_records.
|
|
77
|
+
|
|
78
|
+
Attributes:
|
|
79
|
+
field: The name of the model field to filter on (required)
|
|
80
|
+
operation: The filter operation to apply - use FilterOperation enum for autocompletion (required)
|
|
81
|
+
value: The value to compare against (optional for IS_NULL/IS_NOT_NULL)
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
value: Any
|
|
10
85
|
|
|
11
86
|
|
|
12
87
|
# ---------------------------------------------------------
|
|
@@ -55,6 +130,23 @@ class CrudService:
|
|
|
55
130
|
by both FastAPI routers (REST) and RPC bundles (ConnectRPC).
|
|
56
131
|
"""
|
|
57
132
|
|
|
133
|
+
_FILTER_OPERATIONS: Dict[FilterOperation, Callable[[Any, Any], Any]] = {
|
|
134
|
+
FilterOperation.EQUALS: lambda field, value: field == value,
|
|
135
|
+
FilterOperation.NOT_EQUALS: lambda field, value: field != value,
|
|
136
|
+
FilterOperation.GREATER_THAN: lambda field, value: field > value,
|
|
137
|
+
FilterOperation.GREATER_THAN_OR_EQUALS: lambda field, value: field >= value,
|
|
138
|
+
FilterOperation.LESS_THAN: lambda field, value: field < value,
|
|
139
|
+
FilterOperation.LESS_THAN_OR_EQUALS: lambda field, value: field <= value,
|
|
140
|
+
FilterOperation.IN: lambda field, value: field.in_(value),
|
|
141
|
+
FilterOperation.NOT_IN: lambda field, value: field.notin_(value),
|
|
142
|
+
FilterOperation.CONTAINS: lambda field, value: field.contains(value),
|
|
143
|
+
FilterOperation.STARTS_WITH: lambda field, value: field.startswith(value),
|
|
144
|
+
FilterOperation.ENDS_WITH: lambda field, value: field.endswith(value),
|
|
145
|
+
FilterOperation.LIKE: lambda field, value: field.ilike(value),
|
|
146
|
+
FilterOperation.IS_NULL: lambda field, _: field.is_(None),
|
|
147
|
+
FilterOperation.IS_NOT_NULL: lambda field, _: field.isnot(None),
|
|
148
|
+
}
|
|
149
|
+
|
|
58
150
|
def __init__(
|
|
59
151
|
self,
|
|
60
152
|
accessor: ModelAccessor[ModelType],
|
|
@@ -66,32 +158,146 @@ class CrudService:
|
|
|
66
158
|
|
|
67
159
|
# ---------------- READS ----------------
|
|
68
160
|
|
|
69
|
-
def list_records(
|
|
70
|
-
|
|
71
|
-
|
|
161
|
+
def list_records(
|
|
162
|
+
self,
|
|
163
|
+
*,
|
|
164
|
+
include_deleted: bool = False,
|
|
165
|
+
limit: Optional[int] = None,
|
|
166
|
+
offset: int = 0,
|
|
167
|
+
order_by: Optional[str] = None,
|
|
168
|
+
order_desc: bool = False,
|
|
169
|
+
) -> List[ModelType]:
|
|
170
|
+
"""Return records with optional pagination and sorting.
|
|
171
|
+
|
|
172
|
+
Args:
|
|
173
|
+
include_deleted: Whether to include soft-deleted records
|
|
174
|
+
limit: Maximum number of records to return (None = no limit)
|
|
175
|
+
offset: Number of records to skip (for pagination)
|
|
176
|
+
order_by: Field name to sort by (must exist on model)
|
|
177
|
+
order_desc: If True, sort descending; otherwise ascending
|
|
178
|
+
|
|
179
|
+
Returns:
|
|
180
|
+
List of model instances
|
|
181
|
+
|
|
182
|
+
Raises:
|
|
183
|
+
ValidationError: If order_by field doesn't exist on the model
|
|
184
|
+
"""
|
|
185
|
+
try:
|
|
186
|
+
return self.accessor.all(
|
|
187
|
+
include_deleted=include_deleted,
|
|
188
|
+
limit=limit,
|
|
189
|
+
offset=offset,
|
|
190
|
+
order_by=order_by,
|
|
191
|
+
order_desc=order_desc,
|
|
192
|
+
)
|
|
193
|
+
except ValueError as e:
|
|
194
|
+
raise ValidationError(str(e)) from e
|
|
72
195
|
|
|
73
196
|
def get_record(
|
|
74
197
|
self,
|
|
75
198
|
record_id: int,
|
|
76
199
|
*,
|
|
77
200
|
include_deleted: bool = False,
|
|
78
|
-
) -> ModelType: # type: ignore[type-var]
|
|
201
|
+
) -> ModelType: # type: ignore[type-var,misc]
|
|
79
202
|
"""Retrieve a single record by ID."""
|
|
80
203
|
obj = self.accessor.get(record_id, include_deleted=include_deleted)
|
|
81
204
|
if not obj:
|
|
82
205
|
raise NotFoundError()
|
|
83
206
|
return obj
|
|
84
207
|
|
|
208
|
+
def find_records(
|
|
209
|
+
self,
|
|
210
|
+
filters: List[Filter],
|
|
211
|
+
*,
|
|
212
|
+
include_deleted: bool = False,
|
|
213
|
+
limit: Optional[int] = None,
|
|
214
|
+
offset: int = 0,
|
|
215
|
+
order_by: Optional[str] = None,
|
|
216
|
+
order_desc: bool = False,
|
|
217
|
+
) -> List[ModelType]:
|
|
218
|
+
"""Find records matching filter conditions.
|
|
219
|
+
|
|
220
|
+
Args:
|
|
221
|
+
filters: List of Filter dicts with 'field', 'operation' (use FilterOperation enum), and 'value' keys
|
|
222
|
+
include_deleted: Whether to include soft-deleted records
|
|
223
|
+
limit: Maximum number of records to return (None = no limit)
|
|
224
|
+
offset: Number of records to skip (for pagination)
|
|
225
|
+
order_by: Field name to sort by (must exist on model)
|
|
226
|
+
order_desc: If True, sort descending; otherwise ascending
|
|
227
|
+
|
|
228
|
+
Returns:
|
|
229
|
+
List of model instances matching all conditions
|
|
230
|
+
|
|
231
|
+
Raises:
|
|
232
|
+
ValidationError: If filter is invalid or field doesn't exist
|
|
233
|
+
|
|
234
|
+
Example:
|
|
235
|
+
>>> filters = [
|
|
236
|
+
... {"field": "name", "operation": FilterOperation.LIKE, "value": "alice%"},
|
|
237
|
+
... {"field": "age", "operation": FilterOperation.GREATER_THAN_OR_EQUALS, "value": 18},
|
|
238
|
+
... ]
|
|
239
|
+
>>> results = crud.find_records(filters)
|
|
240
|
+
"""
|
|
241
|
+
try:
|
|
242
|
+
conditions = []
|
|
243
|
+
for filter_dict in filters:
|
|
244
|
+
field_name = filter_dict["field"]
|
|
245
|
+
operation = filter_dict["operation"]
|
|
246
|
+
value = filter_dict.get("value")
|
|
247
|
+
|
|
248
|
+
field = getattr(self.accessor.where, field_name, None)
|
|
249
|
+
if field is None:
|
|
250
|
+
raise ValidationError(f"Field '{field_name}' not found on {self.accessor.model.__name__}")
|
|
251
|
+
|
|
252
|
+
condition = self._apply_filter_operation(field, operation, value)
|
|
253
|
+
conditions.append(condition)
|
|
254
|
+
|
|
255
|
+
return self.accessor.find(
|
|
256
|
+
*conditions,
|
|
257
|
+
include_deleted=include_deleted,
|
|
258
|
+
limit=limit,
|
|
259
|
+
offset=offset,
|
|
260
|
+
order_by=order_by,
|
|
261
|
+
order_desc=order_desc,
|
|
262
|
+
)
|
|
263
|
+
except (ValueError, KeyError, AttributeError) as e:
|
|
264
|
+
raise ValidationError(str(e)) from e
|
|
265
|
+
|
|
266
|
+
def _apply_filter_operation(self, field: Any, operation: Union[FilterOperation, str], value: Any) -> Any:
|
|
267
|
+
"""Apply a filter operation to a field.
|
|
268
|
+
|
|
269
|
+
Args:
|
|
270
|
+
field: SQLAlchemy field/column
|
|
271
|
+
operation: Operation (use FilterOperation enum for type safety)
|
|
272
|
+
value: Value to compare against
|
|
273
|
+
|
|
274
|
+
Returns:
|
|
275
|
+
SQLAlchemy BinaryExpression representing the condition
|
|
276
|
+
|
|
277
|
+
Raises:
|
|
278
|
+
ValidationError: If operation is not supported
|
|
279
|
+
"""
|
|
280
|
+
# Normalize string to enum if needed
|
|
281
|
+
try:
|
|
282
|
+
operation_enum = FilterOperation(operation) if isinstance(operation, str) else operation
|
|
283
|
+
except ValueError:
|
|
284
|
+
supported = ", ".join(filter_operation.value for filter_operation in FilterOperation)
|
|
285
|
+
raise ValidationError(f"Unsupported filter operation '{operation}'. Supported: {supported}")
|
|
286
|
+
|
|
287
|
+
operation_fn = self._FILTER_OPERATIONS[operation_enum]
|
|
288
|
+
try:
|
|
289
|
+
return operation_fn(field, value)
|
|
290
|
+
except (TypeError, AttributeError) as error:
|
|
291
|
+
raise ValidationError(f"Failed to apply '{operation}' operation: {str(error)}") from error
|
|
292
|
+
|
|
85
293
|
# ---------------- WRITES ----------------
|
|
86
294
|
|
|
87
|
-
def create_record(self, payload: Dict[str, Any], actor: str) -> ModelType: # type: ignore[type-var]
|
|
295
|
+
def create_record(self, payload: Dict[str, Any], actor: str) -> ModelType: # type: ignore[type-var,misc]
|
|
88
296
|
"""Insert a new record into the database."""
|
|
89
297
|
if self.max_records is not None:
|
|
90
298
|
total = len(self.accessor.all(include_deleted=True))
|
|
91
299
|
if total >= self.max_records:
|
|
92
|
-
raise ConflictError(
|
|
93
|
-
f"Max {self.max_records} records allowed for {self.accessor.component}"
|
|
94
|
-
)
|
|
300
|
+
raise ConflictError(f"Max {self.max_records} records allowed for {self.accessor.component}")
|
|
95
301
|
|
|
96
302
|
obj = self.accessor.model(**payload)
|
|
97
303
|
try:
|
|
@@ -105,16 +311,14 @@ class CrudService:
|
|
|
105
311
|
record_id: int,
|
|
106
312
|
payload: Dict[str, Any],
|
|
107
313
|
actor: str,
|
|
108
|
-
) -> ModelType: # type: ignore[type-var]
|
|
314
|
+
) -> ModelType: # type: ignore[type-var,misc]
|
|
109
315
|
"""Upsert a record (PUT semantics)."""
|
|
110
316
|
existed = self.accessor.get(record_id, include_deleted=True) is not None
|
|
111
317
|
|
|
112
318
|
if not existed and self.max_records is not None:
|
|
113
319
|
total = len(self.accessor.all(include_deleted=True))
|
|
114
320
|
if total >= self.max_records:
|
|
115
|
-
raise ConflictError(
|
|
116
|
-
f"Max {self.max_records} records allowed for {self.accessor.component}"
|
|
117
|
-
)
|
|
321
|
+
raise ConflictError(f"Max {self.max_records} records allowed for {self.accessor.component}")
|
|
118
322
|
|
|
119
323
|
payload_no_id = {k: v for k, v in payload.items() if k != "id"}
|
|
120
324
|
obj = self.accessor.model(id=record_id, **payload_no_id)
|
|
@@ -132,7 +336,7 @@ class CrudService:
|
|
|
132
336
|
if not ok:
|
|
133
337
|
raise NotFoundError()
|
|
134
338
|
|
|
135
|
-
def restore_record(self, record_id: int, actor: str) -> ModelType: # type: ignore[type-var]
|
|
339
|
+
def restore_record(self, record_id: int, actor: str) -> ModelType: # type: ignore[type-var,misc]
|
|
136
340
|
"""Restore a soft-deleted record (if supported).
|
|
137
341
|
|
|
138
342
|
Returns the restored model instance. If the record was already
|
|
@@ -140,9 +344,7 @@ class CrudService:
|
|
|
140
344
|
"""
|
|
141
345
|
model_cls = self.accessor.model
|
|
142
346
|
if not hasattr(model_cls, "deleted_at"):
|
|
143
|
-
raise UnsupportedError(
|
|
144
|
-
f"{self.accessor.component} does not support soft delete/restore"
|
|
145
|
-
)
|
|
347
|
+
raise UnsupportedError(f"{self.accessor.component} does not support soft delete/restore")
|
|
146
348
|
|
|
147
349
|
obj = self.accessor.get(record_id, include_deleted=True)
|
|
148
350
|
if not obj:
|
storage/database.py
CHANGED
|
@@ -19,9 +19,7 @@ __all__ = [
|
|
|
19
19
|
|
|
20
20
|
_DATABASE_URL = os.getenv("VENTION_STORAGE_DATABASE_URL", "sqlite:///./storage.db")
|
|
21
21
|
_ENGINE: Optional[Engine] = None
|
|
22
|
-
CURRENT_SESSION: ContextVar[Optional[Session]] = ContextVar(
|
|
23
|
-
"VENTION_STORAGE_CURRENT_SESSION", default=None
|
|
24
|
-
)
|
|
22
|
+
CURRENT_SESSION: ContextVar[Optional[Session]] = ContextVar("VENTION_STORAGE_CURRENT_SESSION", default=None)
|
|
25
23
|
|
|
26
24
|
|
|
27
25
|
def set_database_url(url: str) -> None:
|
|
@@ -31,9 +29,7 @@ def set_database_url(url: str) -> None:
|
|
|
31
29
|
"""
|
|
32
30
|
global _DATABASE_URL, _ENGINE
|
|
33
31
|
if _ENGINE is not None:
|
|
34
|
-
raise RuntimeError(
|
|
35
|
-
"Database engine already initialized. Call set_database_url() before first use."
|
|
36
|
-
)
|
|
32
|
+
raise RuntimeError("Database engine already initialized. Call set_database_url() before first use.")
|
|
37
33
|
_DATABASE_URL = url
|
|
38
34
|
|
|
39
35
|
|
|
@@ -57,9 +53,7 @@ def get_engine() -> Engine:
|
|
|
57
53
|
"""Return a singleton SQLAlchemy engine (created on first use)."""
|
|
58
54
|
global _ENGINE
|
|
59
55
|
if _ENGINE is None:
|
|
60
|
-
connect_args = (
|
|
61
|
-
{"check_same_thread": False} if _DATABASE_URL.startswith("sqlite") else {}
|
|
62
|
-
)
|
|
56
|
+
connect_args = {"check_same_thread": False} if _DATABASE_URL.startswith("sqlite") else {}
|
|
63
57
|
_ENGINE = create_engine(_DATABASE_URL, echo=False, connect_args=connect_args)
|
|
64
58
|
_attach_sqlite_pragmas(_ENGINE)
|
|
65
59
|
return _ENGINE
|
storage/database_service.py
CHANGED
|
@@ -119,9 +119,7 @@ class DatabaseService:
|
|
|
119
119
|
if until is not None:
|
|
120
120
|
statement = statement.where(AuditLog.timestamp < until)
|
|
121
121
|
|
|
122
|
-
statement = (
|
|
123
|
-
statement.order_by(desc(AuditLog.timestamp)).offset(offset).limit(limit)
|
|
124
|
-
)
|
|
122
|
+
statement = statement.order_by(desc(AuditLog.timestamp)).offset(offset).limit(limit)
|
|
125
123
|
results = session.exec(statement).all()
|
|
126
124
|
return list(results)
|
|
127
125
|
|
|
@@ -135,10 +133,7 @@ class DatabaseService:
|
|
|
135
133
|
try:
|
|
136
134
|
from sqlalchemy_schemadisplay import create_schema_graph
|
|
137
135
|
except Exception as e:
|
|
138
|
-
raise DependencyError(
|
|
139
|
-
"sqlalchemy-schemadisplay is required. Install with: "
|
|
140
|
-
"pip install sqlalchemy-schemadisplay"
|
|
141
|
-
) from e
|
|
136
|
+
raise DependencyError("sqlalchemy-schemadisplay is required. Install with: pip install sqlalchemy-schemadisplay") from e
|
|
142
137
|
|
|
143
138
|
try:
|
|
144
139
|
graph = create_schema_graph(
|
|
@@ -156,8 +151,7 @@ class DatabaseService:
|
|
|
156
151
|
msg = str(e).lower()
|
|
157
152
|
if "executable" in msg or "dot not found" in msg or "graphviz" in msg:
|
|
158
153
|
raise DependencyError(
|
|
159
|
-
"Graphviz is required to render the diagram. "
|
|
160
|
-
"Install it via `brew install graphviz` or `apt-get install graphviz`."
|
|
154
|
+
"Graphviz is required to render the diagram. Install it via `brew install graphviz` or `apt-get install graphviz`."
|
|
161
155
|
) from e
|
|
162
156
|
raise OperationalError(f"Failed to generate schema diagram: {e}") from e
|
|
163
157
|
|
|
@@ -209,9 +203,7 @@ class DatabaseService:
|
|
|
209
203
|
db_dir = Path(path).resolve().parent
|
|
210
204
|
|
|
211
205
|
try:
|
|
212
|
-
tmp_path, total = io_helpers.save_bytes_to_temp(
|
|
213
|
-
file_bytes, db_dir, filename=filename
|
|
214
|
-
)
|
|
206
|
+
tmp_path, total = io_helpers.save_bytes_to_temp(file_bytes, db_dir, filename=filename)
|
|
215
207
|
except Exception as e:
|
|
216
208
|
raise OperationalError(f"Failed to save upload: {e}") from e
|
|
217
209
|
|
storage/hooks.py
CHANGED
|
@@ -24,13 +24,9 @@ class HookRegistry(Generic[ModelRecord]):
|
|
|
24
24
|
"""Lightweight per-accessor registry for lifecycle hooks."""
|
|
25
25
|
|
|
26
26
|
def __init__(self) -> None:
|
|
27
|
-
self._hooks: DefaultDict[HookEvent, List[HookFn[ModelRecord]]] = defaultdict(
|
|
28
|
-
list
|
|
29
|
-
)
|
|
27
|
+
self._hooks: DefaultDict[HookEvent, List[HookFn[ModelRecord]]] = defaultdict(list)
|
|
30
28
|
|
|
31
|
-
def decorator(
|
|
32
|
-
self, event: HookEvent
|
|
33
|
-
) -> Callable[[HookFn[ModelRecord]], HookFn[ModelRecord]]:
|
|
29
|
+
def decorator(self, event: HookEvent) -> Callable[[HookFn[ModelRecord]], HookFn[ModelRecord]]:
|
|
34
30
|
"""Return a decorator that registers a function for `event`."""
|
|
35
31
|
|
|
36
32
|
def deco(fn: HookFn[ModelRecord]) -> HookFn[ModelRecord]:
|
|
@@ -39,9 +35,7 @@ class HookRegistry(Generic[ModelRecord]):
|
|
|
39
35
|
|
|
40
36
|
return deco
|
|
41
37
|
|
|
42
|
-
def emit(
|
|
43
|
-
self, event: HookEvent, *, session: Session, instance: ModelRecord
|
|
44
|
-
) -> None:
|
|
38
|
+
def emit(self, event: HookEvent, *, session: Session, instance: ModelRecord) -> None:
|
|
45
39
|
"""Invoke all hooks registered for `event`."""
|
|
46
40
|
for fn in self._hooks.get(event, []):
|
|
47
41
|
fn(session, instance)
|
storage/io_helpers.py
CHANGED
|
@@ -45,15 +45,11 @@ def write_table_csv_buffer(session: Session, table: Table) -> io.StringIO:
|
|
|
45
45
|
"""SELECT * and return CSV (header + rows) in a StringIO."""
|
|
46
46
|
buffer = io.StringIO(newline="")
|
|
47
47
|
columns = list(table.columns)
|
|
48
|
-
writer = csv.DictWriter(
|
|
49
|
-
buffer, fieldnames=[column.name for column in columns], extrasaction="ignore"
|
|
50
|
-
)
|
|
48
|
+
writer = csv.DictWriter(buffer, fieldnames=[column.name for column in columns], extrasaction="ignore")
|
|
51
49
|
writer.writeheader()
|
|
52
50
|
result = session.exec(select(table))
|
|
53
51
|
for row in result:
|
|
54
|
-
writer.writerow(
|
|
55
|
-
{column.name: to_primitive(row._mapping[column]) for column in columns}
|
|
56
|
-
)
|
|
52
|
+
writer.writerow({column.name: to_primitive(row._mapping[column]) for column in columns})
|
|
57
53
|
return buffer
|
|
58
54
|
|
|
59
55
|
|
|
@@ -87,9 +83,7 @@ def build_backup_bytes(db_path: str) -> bytes:
|
|
|
87
83
|
Build a consistent .sqlite backup and return bytes.
|
|
88
84
|
"""
|
|
89
85
|
db_dir = Path(db_path).resolve().parent
|
|
90
|
-
with tempfile.NamedTemporaryFile(
|
|
91
|
-
prefix="backup-", suffix=".sqlite", dir=db_dir, delete=False
|
|
92
|
-
) as tmp:
|
|
86
|
+
with tempfile.NamedTemporaryFile(prefix="backup-", suffix=".sqlite", dir=db_dir, delete=False) as tmp:
|
|
93
87
|
tmp_path = Path(tmp.name)
|
|
94
88
|
|
|
95
89
|
try:
|
|
@@ -130,9 +124,7 @@ def validate_sqlite_file(path: Path, *, run_integrity_check: bool = True) -> Non
|
|
|
130
124
|
row = connection.execute("PRAGMA integrity_check;").fetchone()
|
|
131
125
|
ok = row and str(row[0]).lower() == "ok"
|
|
132
126
|
if not ok:
|
|
133
|
-
raise ValueError(
|
|
134
|
-
f"Integrity check failed: {row[0] if row else 'unknown'}"
|
|
135
|
-
)
|
|
127
|
+
raise ValueError(f"Integrity check failed: {row[0] if row else 'unknown'}")
|
|
136
128
|
finally:
|
|
137
129
|
connection.close()
|
|
138
130
|
except ValueError:
|
|
@@ -142,9 +134,7 @@ def validate_sqlite_file(path: Path, *, run_integrity_check: bool = True) -> Non
|
|
|
142
134
|
|
|
143
135
|
|
|
144
136
|
def save_upload_to_temp(file: UploadFile, dest_dir: Path) -> tuple[Path, int]:
|
|
145
|
-
with tempfile.NamedTemporaryFile(
|
|
146
|
-
prefix="restore-", suffix=".sqlite", dir=dest_dir, delete=False
|
|
147
|
-
) as tmp:
|
|
137
|
+
with tempfile.NamedTemporaryFile(prefix="restore-", suffix=".sqlite", dir=dest_dir, delete=False) as tmp:
|
|
148
138
|
tmp_path = Path(tmp.name)
|
|
149
139
|
total = 0
|
|
150
140
|
while True:
|
|
@@ -160,13 +150,9 @@ def save_upload_to_temp(file: UploadFile, dest_dir: Path) -> tuple[Path, int]:
|
|
|
160
150
|
return tmp_path, total
|
|
161
151
|
|
|
162
152
|
|
|
163
|
-
def save_bytes_to_temp(
|
|
164
|
-
file_bytes: bytes, dest_dir: Path, *, filename: str = "upload.sqlite"
|
|
165
|
-
) -> tuple[Path, int]:
|
|
153
|
+
def save_bytes_to_temp(file_bytes: bytes, dest_dir: Path, *, filename: str = "upload.sqlite") -> tuple[Path, int]:
|
|
166
154
|
"""Save bytes to a temporary file in the destination directory."""
|
|
167
|
-
with tempfile.NamedTemporaryFile(
|
|
168
|
-
prefix="restore-", suffix=".sqlite", dir=dest_dir, delete=False
|
|
169
|
-
) as tmp:
|
|
155
|
+
with tempfile.NamedTemporaryFile(prefix="restore-", suffix=".sqlite", dir=dest_dir, delete=False) as tmp:
|
|
170
156
|
tmp_path = Path(tmp.name)
|
|
171
157
|
tmp.write(file_bytes)
|
|
172
158
|
total = len(file_bytes)
|
storage/utils.py
CHANGED
|
@@ -28,9 +28,7 @@ def parse_audit_operation(operation: Optional[str]) -> Optional[Operation]:
|
|
|
28
28
|
return None
|
|
29
29
|
|
|
30
30
|
if operation not in get_args(Operation):
|
|
31
|
-
raise ValueError(
|
|
32
|
-
f"Invalid operation: {operation}. Must be one of {get_args(Operation)}"
|
|
33
|
-
)
|
|
31
|
+
raise ValueError(f"Invalid operation: {operation}. Must be one of {get_args(Operation)}")
|
|
34
32
|
|
|
35
33
|
return operation # type: ignore
|
|
36
34
|
|
storage/vention_communication.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
|
-
from typing import Any, Dict, List, Optional, Sequence, Type
|
|
2
|
+
from typing import Any, Dict, List, Optional, Sequence, Type, cast
|
|
3
3
|
|
|
4
|
-
from pydantic import BaseModel, create_model
|
|
4
|
+
from pydantic import BaseModel, Field, create_model
|
|
5
5
|
|
|
6
6
|
from communication.entries import RpcBundle, ActionEntry
|
|
7
7
|
from communication.errors import ConnectError
|
|
@@ -10,6 +10,8 @@ from storage.accessor import ModelAccessor
|
|
|
10
10
|
from storage.crud_service import (
|
|
11
11
|
CrudService,
|
|
12
12
|
CrudError,
|
|
13
|
+
Filter,
|
|
14
|
+
FilterOperation,
|
|
13
15
|
NotFoundError,
|
|
14
16
|
ConflictError,
|
|
15
17
|
ValidationError,
|
|
@@ -25,8 +27,17 @@ from storage.utils import parse_audit_operation, parse_audit_datetime
|
|
|
25
27
|
__all__ = ["build_storage_rpc_bundle"]
|
|
26
28
|
|
|
27
29
|
|
|
30
|
+
def _convert_filters_to_dicts(filters: List["FilterCondition"]) -> List[Filter]:
|
|
31
|
+
"""Convert FilterCondition Pydantic models to Filter TypedDicts for CrudService."""
|
|
32
|
+
return cast(List[Filter], [f.model_dump() for f in filters])
|
|
33
|
+
|
|
34
|
+
|
|
28
35
|
class CrudListRequest(BaseModel):
|
|
29
36
|
include_deleted: bool = False
|
|
37
|
+
limit: Optional[int] = Field(default=None, gt=0)
|
|
38
|
+
offset: int = Field(default=0, ge=0)
|
|
39
|
+
order_by: Optional[str] = None
|
|
40
|
+
order_desc: bool = False
|
|
30
41
|
|
|
31
42
|
|
|
32
43
|
class CrudGetRequest(BaseModel):
|
|
@@ -44,6 +55,21 @@ class CrudRestoreRequest(BaseModel):
|
|
|
44
55
|
actor: str
|
|
45
56
|
|
|
46
57
|
|
|
58
|
+
class FilterCondition(BaseModel):
|
|
59
|
+
field: str
|
|
60
|
+
operation: FilterOperation
|
|
61
|
+
value: Optional[Any] = None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class CrudFindRequest(BaseModel):
|
|
65
|
+
filters: List[FilterCondition] = []
|
|
66
|
+
include_deleted: bool = False
|
|
67
|
+
limit: Optional[int] = Field(default=None, gt=0)
|
|
68
|
+
offset: int = Field(default=0, ge=0)
|
|
69
|
+
order_by: Optional[str] = None
|
|
70
|
+
order_desc: bool = False
|
|
71
|
+
|
|
72
|
+
|
|
47
73
|
class AuditQueryRequest(BaseModel):
|
|
48
74
|
component: Optional[str] = None
|
|
49
75
|
record_id: Optional[int] = None
|
|
@@ -51,8 +77,8 @@ class AuditQueryRequest(BaseModel):
|
|
|
51
77
|
operation: Optional[str] = None
|
|
52
78
|
since: Optional[str] = None
|
|
53
79
|
until: Optional[str] = None
|
|
54
|
-
limit: Optional[int] = None
|
|
55
|
-
offset: int = 0
|
|
80
|
+
limit: Optional[int] = Field(default=None, gt=0)
|
|
81
|
+
offset: int = Field(default=0, ge=0)
|
|
56
82
|
|
|
57
83
|
|
|
58
84
|
class FileResponse(BaseModel):
|
|
@@ -186,7 +212,13 @@ def build_storage_rpc_bundle(
|
|
|
186
212
|
) -> BaseModel:
|
|
187
213
|
try:
|
|
188
214
|
records: List[BaseModel] = list(
|
|
189
|
-
_crud.list_records(
|
|
215
|
+
_crud.list_records(
|
|
216
|
+
include_deleted=req.include_deleted,
|
|
217
|
+
limit=req.limit,
|
|
218
|
+
offset=req.offset,
|
|
219
|
+
order_by=req.order_by,
|
|
220
|
+
order_desc=req.order_desc,
|
|
221
|
+
)
|
|
190
222
|
)
|
|
191
223
|
return _Resp(records=records)
|
|
192
224
|
except CrudError as e:
|
|
@@ -201,6 +233,37 @@ def build_storage_rpc_bundle(
|
|
|
201
233
|
)
|
|
202
234
|
)
|
|
203
235
|
|
|
236
|
+
# ---------------- Find ----------------
|
|
237
|
+
async def find_records(
|
|
238
|
+
req: CrudFindRequest,
|
|
239
|
+
_crud: CrudService = crud,
|
|
240
|
+
_Resp: Type[BaseModel] = ListResponse,
|
|
241
|
+
) -> BaseModel:
|
|
242
|
+
try:
|
|
243
|
+
filter_dicts = _convert_filters_to_dicts(req.filters)
|
|
244
|
+
records: List[BaseModel] = list(
|
|
245
|
+
_crud.find_records(
|
|
246
|
+
filters=filter_dicts,
|
|
247
|
+
include_deleted=req.include_deleted,
|
|
248
|
+
limit=req.limit,
|
|
249
|
+
offset=req.offset,
|
|
250
|
+
order_by=req.order_by,
|
|
251
|
+
order_desc=req.order_desc,
|
|
252
|
+
)
|
|
253
|
+
)
|
|
254
|
+
return _Resp(records=records)
|
|
255
|
+
except CrudError as e:
|
|
256
|
+
raise _to_rpc(e)
|
|
257
|
+
|
|
258
|
+
bundle.actions.append(
|
|
259
|
+
ActionEntry(
|
|
260
|
+
name=f"{model_name}_FindRecords",
|
|
261
|
+
func=find_records,
|
|
262
|
+
input_type=CrudFindRequest,
|
|
263
|
+
output_type=ListResponse,
|
|
264
|
+
)
|
|
265
|
+
)
|
|
266
|
+
|
|
204
267
|
# ---------------- Get ----------------
|
|
205
268
|
async def get_record(
|
|
206
269
|
req: CrudGetRequest,
|
|
@@ -208,9 +271,7 @@ def build_storage_rpc_bundle(
|
|
|
208
271
|
_Resp: Type[BaseModel] = GetResponse,
|
|
209
272
|
) -> BaseModel:
|
|
210
273
|
try:
|
|
211
|
-
rec: BaseModel = _crud.get_record(
|
|
212
|
-
req.record_id, include_deleted=req.include_deleted
|
|
213
|
-
)
|
|
274
|
+
rec: BaseModel = _crud.get_record(req.record_id, include_deleted=req.include_deleted)
|
|
214
275
|
return _Resp(record=rec)
|
|
215
276
|
except CrudError as e:
|
|
216
277
|
raise _to_rpc(e)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: vention-storage
|
|
3
|
-
Version: 0.6.
|
|
3
|
+
Version: 0.6.3
|
|
4
4
|
Summary: A framework for storing and managing component and application data for machine apps.
|
|
5
5
|
License: Proprietary
|
|
6
6
|
Author: VentionCo
|
|
@@ -8,9 +8,26 @@ Requires-Python: >=3.10,<3.11
|
|
|
8
8
|
Classifier: License :: Other/Proprietary License
|
|
9
9
|
Classifier: Programming Language :: Python :: 3
|
|
10
10
|
Classifier: Programming Language :: Python :: 3.10
|
|
11
|
-
Requires-Dist:
|
|
12
|
-
Requires-Dist:
|
|
13
|
-
Requires-Dist:
|
|
11
|
+
Requires-Dist: annotated-doc (==0.0.4) ; python_version == "3.10"
|
|
12
|
+
Requires-Dist: annotated-types (==0.7.0) ; python_version == "3.10"
|
|
13
|
+
Requires-Dist: anyio (==4.12.1) ; python_version == "3.10"
|
|
14
|
+
Requires-Dist: click (==8.3.1) ; python_version == "3.10"
|
|
15
|
+
Requires-Dist: colorama (==0.4.6) ; python_version == "3.10" and platform_system == "Windows"
|
|
16
|
+
Requires-Dist: exceptiongroup (==1.3.1) ; python_version == "3.10"
|
|
17
|
+
Requires-Dist: fastapi (==0.121.1) ; python_version == "3.10"
|
|
18
|
+
Requires-Dist: greenlet (==3.3.1) ; python_version == "3.10" and (platform_machine == "aarch64" or platform_machine == "ppc64le" or platform_machine == "x86_64" or platform_machine == "amd64" or platform_machine == "AMD64" or platform_machine == "win32" or platform_machine == "WIN32")
|
|
19
|
+
Requires-Dist: h11 (==0.16.0) ; python_version == "3.10"
|
|
20
|
+
Requires-Dist: idna (==3.11) ; python_version == "3.10"
|
|
21
|
+
Requires-Dist: pydantic (==2.12.5) ; python_version == "3.10"
|
|
22
|
+
Requires-Dist: pydantic-core (==2.41.5) ; python_version == "3.10"
|
|
23
|
+
Requires-Dist: python-multipart (==0.0.20) ; python_version == "3.10"
|
|
24
|
+
Requires-Dist: sqlalchemy (==2.0.46) ; python_version == "3.10"
|
|
25
|
+
Requires-Dist: sqlmodel (==0.0.27) ; python_version == "3.10"
|
|
26
|
+
Requires-Dist: starlette (==0.49.3) ; python_version == "3.10"
|
|
27
|
+
Requires-Dist: typing-extensions (==4.15.0) ; python_version == "3.10"
|
|
28
|
+
Requires-Dist: typing-inspection (==0.4.2) ; python_version == "3.10"
|
|
29
|
+
Requires-Dist: uvicorn (==0.35.0) ; python_version == "3.10"
|
|
30
|
+
Requires-Dist: vention-communication (==0.3.0) ; python_version == "3.10"
|
|
14
31
|
Description-Content-Type: text/markdown
|
|
15
32
|
|
|
16
33
|
# Vention Storage
|
|
@@ -207,8 +224,47 @@ user_accessor.delete(user.id, actor="admin")
|
|
|
207
224
|
|
|
208
225
|
# Restore (for soft-deleted models)
|
|
209
226
|
user_accessor.restore(user.id, actor="admin")
|
|
210
|
-
```
|
|
211
227
|
|
|
228
|
+
# Find users by exact match
|
|
229
|
+
users = user_accessor.find(user_accessor.where.email == "alice@example.com")
|
|
230
|
+
|
|
231
|
+
# Multiple conditions (AND logic)
|
|
232
|
+
users = user_accessor.find(
|
|
233
|
+
user_accessor.where.name == "Alice",
|
|
234
|
+
user_accessor.where.email == "alice@example.com"
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
# Comparison operators
|
|
238
|
+
adults = user_accessor.find(user_accessor.where.age >= 18)
|
|
239
|
+
recent = user_accessor.find(user_accessor.where.created_at > cutoff_date)
|
|
240
|
+
|
|
241
|
+
# String operations
|
|
242
|
+
smiths = user_accessor.find(user_accessor.where.name.contains("Smith"))
|
|
243
|
+
gmail_users = user_accessor.find(user_accessor.where.email.endswith("@gmail.com"))
|
|
244
|
+
search = user_accessor.find(user_accessor.where.name.ilike("%alice%")) # case-insensitive
|
|
245
|
+
|
|
246
|
+
# Collection check
|
|
247
|
+
admins = user_accessor.find(user_accessor.where.role.in_(["admin", "superadmin"]))
|
|
248
|
+
|
|
249
|
+
# Null checks
|
|
250
|
+
unverified = user_accessor.find(user_accessor.where.verified_at.is_(None))
|
|
251
|
+
verified = user_accessor.find(user_accessor.where.verified_at.isnot(None))
|
|
252
|
+
|
|
253
|
+
# With pagination and sorting
|
|
254
|
+
page = user_accessor.find(
|
|
255
|
+
user_accessor.where.status == "active",
|
|
256
|
+
limit=10,
|
|
257
|
+
offset=20,
|
|
258
|
+
order_by="created_at",
|
|
259
|
+
order_desc=True
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
# Include soft-deleted records
|
|
263
|
+
all_users = user_accessor.find(
|
|
264
|
+
user_accessor.where.role == "admin",
|
|
265
|
+
include_deleted=True
|
|
266
|
+
)
|
|
267
|
+
```
|
|
212
268
|
|
|
213
269
|
### Using ConnectRPC Client
|
|
214
270
|
|
|
@@ -278,8 +334,73 @@ export async function listUsers() {
|
|
|
278
334
|
});
|
|
279
335
|
return res.records;
|
|
280
336
|
}
|
|
337
|
+
|
|
338
|
+
// Find by exact match
|
|
339
|
+
export async function findUserByEmail(email: string) {
|
|
340
|
+
const res = await client.usersFindRecords({
|
|
341
|
+
filters: [
|
|
342
|
+
{ field: "email", operation: "eq", value: email }
|
|
343
|
+
]
|
|
344
|
+
});
|
|
345
|
+
return res.records;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Find with multiple conditions (AND logic)
|
|
349
|
+
export async function findActiveAdmins() {
|
|
350
|
+
const res = await client.usersFindRecords({
|
|
351
|
+
filters: [
|
|
352
|
+
{ field: "role", operation: "eq", value: "admin" },
|
|
353
|
+
{ field: "age", operation: "gte", value: "18" }
|
|
354
|
+
]
|
|
355
|
+
});
|
|
356
|
+
return res.records;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Find with null checks
|
|
360
|
+
export async function findUnverifiedUsers() {
|
|
361
|
+
const res = await client.usersFindRecords({
|
|
362
|
+
filters: [
|
|
363
|
+
{ field: "verified_at", operation: "is_null" }
|
|
364
|
+
]
|
|
365
|
+
});
|
|
366
|
+
return res.records;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Complex query example
|
|
370
|
+
export async function findRecentPremiumUsers(cutoffDate: string) {
|
|
371
|
+
const res = await client.usersFindRecords({
|
|
372
|
+
filters: [
|
|
373
|
+
{ field: "subscription", operation: "in", value: ["premium", "enterprise"] },
|
|
374
|
+
{ field: "created_at", operation: "gte", value: cutoffDate },
|
|
375
|
+
{ field: "email_verified", operation: "is_not_null" }
|
|
376
|
+
],
|
|
377
|
+
limit: 50,
|
|
378
|
+
orderBy: "created_at",
|
|
379
|
+
orderDesc: true
|
|
380
|
+
});
|
|
381
|
+
return res.records;
|
|
382
|
+
}
|
|
281
383
|
```
|
|
282
384
|
|
|
385
|
+
### Filter Operations Reference
|
|
386
|
+
|
|
387
|
+
| Operation | Description |
|
|
388
|
+
|----------------|--------------------------------------|
|
|
389
|
+
| `eq` | Exact match |
|
|
390
|
+
| `ne` | Not equal to value |
|
|
391
|
+
| `gt` | Greater than value |
|
|
392
|
+
| `gte` | Greater than or equal |
|
|
393
|
+
| `lt` | Less than value |
|
|
394
|
+
| `lte` | Less than or equal |
|
|
395
|
+
| `in` | Value in array |
|
|
396
|
+
| `not_in` | Value not in array |
|
|
397
|
+
| `contains` | Field contains substring |
|
|
398
|
+
| `starts_with` | Field starts with prefix |
|
|
399
|
+
| `ends_with` | Field ends with suffix |
|
|
400
|
+
| `like` | Case-insensitive pattern match |
|
|
401
|
+
| `is_null` | Field is null (no value needed) |
|
|
402
|
+
| `is_not_null` | Field is not null (no value needed) |
|
|
403
|
+
|
|
283
404
|
|
|
284
405
|
## 📖 API Reference
|
|
285
406
|
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
storage/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
storage/accessor.py,sha256=7rm1B57kLKb5-1ou6dOlq8Bvl7MQuPpJdtPnn-8uQyk,15076
|
|
3
|
+
storage/auditor.py,sha256=tsvsb9qlHdcknY5OAXRZBMcN4nFDKtN3Ln1LfgozJrw,2090
|
|
4
|
+
storage/bootstrap.py,sha256=rZBXQF4M-Lm4Rw2T74M4-swMBgAWEts16v9mG64t2hM,846
|
|
5
|
+
storage/crud_service.py,sha256=w-OFFh9fcHmEt0As7jsCcIWZhheg8jcnVapOoaqeNAU,12794
|
|
6
|
+
storage/database.py,sha256=5Ird_CNwikMe07CBhDANrKJN-SJGR2ESOSB0RT6JvKs,3132
|
|
7
|
+
storage/database_service.py,sha256=DqDA-xwCuJ5FHimBjb0PXvffLC970kIZcusxGFyIr-E,7734
|
|
8
|
+
storage/hooks.py,sha256=ks8M8Lv7dIJsuetEJ91c1SdnvmF4RCF8d5suCiZRddA,1248
|
|
9
|
+
storage/io_helpers.py,sha256=ADjdQsNSPXfgg2-7-r5utdPDr_k0f605mcavHQLQP_Y,5406
|
|
10
|
+
storage/utils.py,sha256=JfZV3wYqg6miAIs1M4QGmIMpZCE4nMi6IrLdDDc-b5Q,1298
|
|
11
|
+
storage/vention_communication.py,sha256=diwFarPhaE9w2CxqiqAWK0KE9I8hg-2qSjXkPvC1WjI,17577
|
|
12
|
+
vention_storage-0.6.3.dist-info/METADATA,sha256=EkkAGYcflPTeAt3sKsTqsIAVySjX5RpNurzoVuJOnI0,14702
|
|
13
|
+
vention_storage-0.6.3.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
|
|
14
|
+
vention_storage-0.6.3.dist-info/RECORD,,
|
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
storage/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
-
storage/accessor.py,sha256=QgLnju-31jBprLtFMyAy8duKZKAshO8Rngd5cBjduhY,10879
|
|
3
|
-
storage/auditor.py,sha256=tsvsb9qlHdcknY5OAXRZBMcN4nFDKtN3Ln1LfgozJrw,2090
|
|
4
|
-
storage/bootstrap.py,sha256=rZBXQF4M-Lm4Rw2T74M4-swMBgAWEts16v9mG64t2hM,846
|
|
5
|
-
storage/crud_service.py,sha256=9XUkOjiw16O9UzRYPBUV1NYUu34-pEbYTp310xopgkU,5281
|
|
6
|
-
storage/database.py,sha256=BGRnlH0qBVnYffp0F1TCWGb6OS8cdlMmEJ1-LeHH4oc,3184
|
|
7
|
-
storage/database_service.py,sha256=pUhS3LfSswIfr7wlIcUbO3uVuT_GzfKh4QwOuQrwf1k,7868
|
|
8
|
-
storage/hooks.py,sha256=rMK8R-VO0B7caZn9HtTlcetx0KQMvsl1Aq-t9mbFA4Q,1298
|
|
9
|
-
storage/io_helpers.py,sha256=oTyVXRdD4eVuqpIOFNh_4ULqiNJ46pNCT38he1dzMBg,5528
|
|
10
|
-
storage/utils.py,sha256=AV8d1WQmdBLAnpoGJX3c8th5_mYFAei6d_ZxDG5QOAE,1320
|
|
11
|
-
storage/vention_communication.py,sha256=sBtwA5SLBSq0-fnQgwL1UmQwloSSnbv-pS2Iiv32o3g,15421
|
|
12
|
-
vention_storage-0.6.0.dist-info/METADATA,sha256=xWgWEcwA-9z4zVJepXBTTUFmhngRQan7fkxuLV_uAtk,9858
|
|
13
|
-
vention_storage-0.6.0.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
|
|
14
|
-
vention_storage-0.6.0.dist-info/RECORD,,
|
|
File without changes
|