vention-storage 0.6.0__py3-none-any.whl → 0.6.5__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 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 op using the current session if present, else open a transaction."""
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(self, *, include_deleted: bool = False) -> List[ModelType]:
123
- """Get all models."""
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
- if self._has_soft_delete and not include_deleted:
127
- statement = statement.where(getattr(self.model, "deleted_at").is_(None))
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 typing import Any, Dict, List, Optional
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(self, *, include_deleted: bool = False) -> List[ModelType]:
70
- """Return all records for this model."""
71
- return self.accessor.all(include_deleted=include_deleted)
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
@@ -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
 
@@ -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(include_deleted=req.include_deleted)
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.0
3
+ Version: 0.6.5
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: python-multipart (>=0.0.20,<0.0.21)
12
- Requires-Dist: sqlmodel (==0.0.27)
13
- Requires-Dist: vention-communication (>=0.2.2,<0.3.0)
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.5.dist-info/METADATA,sha256=Ej6fUPYV3SelyXZiIOO1yagrFmyL17ReDSd-52_RLaY,14702
13
+ vention_storage-0.6.5.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
14
+ vention_storage-0.6.5.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,,