vention-storage 0.5.4__py3-none-any.whl → 0.6.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.
- storage/accessor.py +51 -38
- storage/bootstrap.py +13 -26
- storage/crud_service.py +160 -0
- storage/database_service.py +243 -0
- storage/io_helpers.py +14 -0
- storage/utils.py +26 -1
- storage/vention_communication.py +476 -0
- {vention_storage-0.5.4.dist-info → vention_storage-0.6.0.dist-info}/METADATA +107 -52
- vention_storage-0.6.0.dist-info/RECORD +14 -0
- {vention_storage-0.5.4.dist-info → vention_storage-0.6.0.dist-info}/WHEEL +1 -1
- storage/router_database.py +0 -275
- storage/router_model.py +0 -247
- vention_storage-0.5.4.dist-info/RECORD +0 -13
storage/accessor.py
CHANGED
|
@@ -29,17 +29,24 @@ class ModelAccessor(Generic[ModelType]):
|
|
|
29
29
|
"""
|
|
30
30
|
Accessor for a single SQLModel type with:
|
|
31
31
|
- strongly-typed lifecycle hooks (before/after insert/update/delete)
|
|
32
|
-
- atomic writes with auditing
|
|
32
|
+
- atomic writes with optional auditing
|
|
33
33
|
- optional soft delete (if model defines `deleted_at`)
|
|
34
34
|
- batch helpers
|
|
35
35
|
- implicit session reuse inside hooks (no .bind() needed)
|
|
36
36
|
"""
|
|
37
37
|
|
|
38
|
-
def __init__(
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
model: Type[ModelType],
|
|
41
|
+
component_name: str,
|
|
42
|
+
*,
|
|
43
|
+
enable_auditing: bool = True,
|
|
44
|
+
) -> None:
|
|
39
45
|
self.model = model
|
|
40
46
|
self.component = component_name
|
|
41
47
|
self._hooks: HookRegistry[ModelType] = HookRegistry()
|
|
42
48
|
self._has_soft_delete = hasattr(model, "deleted_at")
|
|
49
|
+
self._enable_auditing = enable_auditing
|
|
43
50
|
|
|
44
51
|
# ---------- Hook decorators ----------
|
|
45
52
|
def before_insert(self) -> Callable[[HookFn[ModelType]], HookFn[ModelType]]:
|
|
@@ -70,6 +77,8 @@ class ModelAccessor(Generic[ModelType]):
|
|
|
70
77
|
self, *, session: Session, instance: ModelType, actor: str
|
|
71
78
|
) -> None:
|
|
72
79
|
"""Audit a create operation."""
|
|
80
|
+
if not self._enable_auditing:
|
|
81
|
+
return
|
|
73
82
|
audit_operation(
|
|
74
83
|
session=session,
|
|
75
84
|
component=self.component,
|
|
@@ -150,15 +159,16 @@ class ModelAccessor(Generic[ModelType]):
|
|
|
150
159
|
self._emit("before_update", session=session, instance=merged)
|
|
151
160
|
session.flush()
|
|
152
161
|
session.refresh(merged)
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
+
if self._enable_auditing:
|
|
163
|
+
audit_operation(
|
|
164
|
+
session=session,
|
|
165
|
+
component=self.component,
|
|
166
|
+
operation="update",
|
|
167
|
+
record_id=int(getattr(merged, "id")),
|
|
168
|
+
actor=actor,
|
|
169
|
+
before=before,
|
|
170
|
+
after=merged.model_dump(),
|
|
171
|
+
)
|
|
162
172
|
self._emit("after_update", session=session, instance=merged)
|
|
163
173
|
return cast(ModelType, merged)
|
|
164
174
|
|
|
@@ -173,15 +183,16 @@ class ModelAccessor(Generic[ModelType]):
|
|
|
173
183
|
return False
|
|
174
184
|
self._emit("before_delete", session=session, instance=obj)
|
|
175
185
|
op_name, before_payload, after_payload = _soft_or_hard_delete(session, obj)
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
186
|
+
if self._enable_auditing:
|
|
187
|
+
audit_operation(
|
|
188
|
+
session=session,
|
|
189
|
+
component=self.component,
|
|
190
|
+
operation=op_name,
|
|
191
|
+
record_id=id,
|
|
192
|
+
actor=actor,
|
|
193
|
+
before=before_payload,
|
|
194
|
+
after=after_payload,
|
|
195
|
+
)
|
|
185
196
|
self._emit("after_delete", session=session, instance=obj)
|
|
186
197
|
return True
|
|
187
198
|
|
|
@@ -201,15 +212,16 @@ class ModelAccessor(Generic[ModelType]):
|
|
|
201
212
|
session.add(obj)
|
|
202
213
|
session.flush()
|
|
203
214
|
session.refresh(obj)
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
215
|
+
if self._enable_auditing:
|
|
216
|
+
audit_operation(
|
|
217
|
+
session=session,
|
|
218
|
+
component=self.component,
|
|
219
|
+
operation="restore",
|
|
220
|
+
record_id=id,
|
|
221
|
+
actor=actor,
|
|
222
|
+
before=before,
|
|
223
|
+
after=obj.model_dump(),
|
|
224
|
+
)
|
|
213
225
|
return True
|
|
214
226
|
|
|
215
227
|
return self._run_write(write_operation)
|
|
@@ -248,15 +260,16 @@ class ModelAccessor(Generic[ModelType]):
|
|
|
248
260
|
op_name, before_payload, after_payload = _soft_or_hard_delete(
|
|
249
261
|
session, obj
|
|
250
262
|
)
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
263
|
+
if self._enable_auditing:
|
|
264
|
+
audit_operation(
|
|
265
|
+
session=session,
|
|
266
|
+
component=self.component,
|
|
267
|
+
operation=op_name,
|
|
268
|
+
record_id=id_,
|
|
269
|
+
actor=actor,
|
|
270
|
+
before=before_payload,
|
|
271
|
+
after=after_payload,
|
|
272
|
+
)
|
|
260
273
|
self._emit("after_delete", session=session, instance=obj)
|
|
261
274
|
count += 1
|
|
262
275
|
return count
|
storage/bootstrap.py
CHANGED
|
@@ -1,47 +1,34 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
from typing import
|
|
4
|
-
|
|
5
|
-
from fastapi import FastAPI
|
|
3
|
+
from typing import Optional
|
|
6
4
|
from sqlmodel import SQLModel
|
|
7
5
|
|
|
8
6
|
from storage.database import get_engine, set_database_url
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
__all__ = ["bootstrap"]
|
|
12
10
|
|
|
13
11
|
|
|
14
12
|
def bootstrap(
|
|
15
|
-
app: FastAPI,
|
|
16
13
|
*,
|
|
17
|
-
accessors: Iterable[ModelAccessor[Any]],
|
|
18
14
|
database_url: Optional[str] = None,
|
|
19
15
|
create_tables: bool = True,
|
|
20
|
-
max_records_per_model: Optional[int] = 5,
|
|
21
|
-
enable_db_router: bool = True,
|
|
22
16
|
) -> None:
|
|
23
17
|
"""
|
|
24
|
-
|
|
18
|
+
Initialize the database engine and optionally create tables.
|
|
19
|
+
|
|
20
|
+
This function performs **environment setup only**:
|
|
21
|
+
- Applies a database URL override (if provided)
|
|
22
|
+
- Creates SQLModel tables (if requested)
|
|
25
23
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
- One CRUD router per registered `ModelAccessor`.
|
|
30
|
-
- The global /db router (health, audit, diagram, backup/restore) if enabled.
|
|
24
|
+
Args:
|
|
25
|
+
database_url: Optional DB URL override (e.g. sqlite:///foo.sqlite)
|
|
26
|
+
create_tables: Whether to auto-create tables using metadata.
|
|
31
27
|
"""
|
|
32
28
|
if database_url is not None:
|
|
33
29
|
set_database_url(database_url)
|
|
34
30
|
|
|
35
31
|
engine = get_engine()
|
|
32
|
+
|
|
36
33
|
if create_tables:
|
|
37
34
|
SQLModel.metadata.create_all(engine)
|
|
38
|
-
|
|
39
|
-
# Per-model CRUD routers
|
|
40
|
-
for accessor in accessors:
|
|
41
|
-
app.include_router(
|
|
42
|
-
build_crud_router(accessor, max_records=max_records_per_model)
|
|
43
|
-
)
|
|
44
|
-
|
|
45
|
-
# Global DB router (health, audit, diagram, backup/restore)
|
|
46
|
-
if enable_db_router:
|
|
47
|
-
app.include_router(build_db_router())
|
storage/crud_service.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from typing import Any, Dict, List, Optional
|
|
3
|
+
|
|
4
|
+
from sqlalchemy.exc import DataError, IntegrityError, StatementError
|
|
5
|
+
from storage.accessor import ModelAccessor
|
|
6
|
+
from storage.utils import ModelType
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
__all__ = ["CrudService", "CrudError"]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
# ---------------------------------------------------------
|
|
13
|
+
# Exceptions
|
|
14
|
+
# ---------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class CrudError(Exception):
|
|
18
|
+
"""Base class for CRUD-related errors."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, message: str, code: str = "UNKNOWN"):
|
|
21
|
+
super().__init__(message)
|
|
22
|
+
self.code = code
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class NotFoundError(CrudError):
|
|
26
|
+
def __init__(self, message: str = "Not found"):
|
|
27
|
+
super().__init__(message, code="NOT_FOUND")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ConflictError(CrudError):
|
|
31
|
+
def __init__(self, message: str):
|
|
32
|
+
super().__init__(message, code="CONFLICT")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ValidationError(CrudError):
|
|
36
|
+
def __init__(self, message: str):
|
|
37
|
+
super().__init__(message, code="VALIDATION_ERROR")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class UnsupportedError(CrudError):
|
|
41
|
+
def __init__(self, message: str):
|
|
42
|
+
super().__init__(message, code="UNSUPPORTED_OPERATION")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# ---------------------------------------------------------
|
|
46
|
+
# CRUD Service
|
|
47
|
+
# ---------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class CrudService:
|
|
51
|
+
"""
|
|
52
|
+
Transport-agnostic CRUD logic for SQLModel components.
|
|
53
|
+
|
|
54
|
+
Provides safe, actor-aware CRUD operations. Intended to be reused
|
|
55
|
+
by both FastAPI routers (REST) and RPC bundles (ConnectRPC).
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
def __init__(
|
|
59
|
+
self,
|
|
60
|
+
accessor: ModelAccessor[ModelType],
|
|
61
|
+
*,
|
|
62
|
+
max_records: Optional[int] = None,
|
|
63
|
+
) -> None:
|
|
64
|
+
self.accessor = accessor
|
|
65
|
+
self.max_records = max_records
|
|
66
|
+
|
|
67
|
+
# ---------------- READS ----------------
|
|
68
|
+
|
|
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)
|
|
72
|
+
|
|
73
|
+
def get_record(
|
|
74
|
+
self,
|
|
75
|
+
record_id: int,
|
|
76
|
+
*,
|
|
77
|
+
include_deleted: bool = False,
|
|
78
|
+
) -> ModelType: # type: ignore[type-var]
|
|
79
|
+
"""Retrieve a single record by ID."""
|
|
80
|
+
obj = self.accessor.get(record_id, include_deleted=include_deleted)
|
|
81
|
+
if not obj:
|
|
82
|
+
raise NotFoundError()
|
|
83
|
+
return obj
|
|
84
|
+
|
|
85
|
+
# ---------------- WRITES ----------------
|
|
86
|
+
|
|
87
|
+
def create_record(self, payload: Dict[str, Any], actor: str) -> ModelType: # type: ignore[type-var]
|
|
88
|
+
"""Insert a new record into the database."""
|
|
89
|
+
if self.max_records is not None:
|
|
90
|
+
total = len(self.accessor.all(include_deleted=True))
|
|
91
|
+
if total >= self.max_records:
|
|
92
|
+
raise ConflictError(
|
|
93
|
+
f"Max {self.max_records} records allowed for {self.accessor.component}"
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
obj = self.accessor.model(**payload)
|
|
97
|
+
try:
|
|
98
|
+
result = self.accessor.insert(obj, actor=actor)
|
|
99
|
+
return result
|
|
100
|
+
except (IntegrityError, DataError, StatementError) as e:
|
|
101
|
+
raise ValidationError(str(e)) from e
|
|
102
|
+
|
|
103
|
+
def update_record(
|
|
104
|
+
self,
|
|
105
|
+
record_id: int,
|
|
106
|
+
payload: Dict[str, Any],
|
|
107
|
+
actor: str,
|
|
108
|
+
) -> ModelType: # type: ignore[type-var]
|
|
109
|
+
"""Upsert a record (PUT semantics)."""
|
|
110
|
+
existed = self.accessor.get(record_id, include_deleted=True) is not None
|
|
111
|
+
|
|
112
|
+
if not existed and self.max_records is not None:
|
|
113
|
+
total = len(self.accessor.all(include_deleted=True))
|
|
114
|
+
if total >= self.max_records:
|
|
115
|
+
raise ConflictError(
|
|
116
|
+
f"Max {self.max_records} records allowed for {self.accessor.component}"
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
payload_no_id = {k: v for k, v in payload.items() if k != "id"}
|
|
120
|
+
obj = self.accessor.model(id=record_id, **payload_no_id)
|
|
121
|
+
|
|
122
|
+
try:
|
|
123
|
+
saved = self.accessor.save(obj, actor=actor)
|
|
124
|
+
except (IntegrityError, DataError, StatementError) as e:
|
|
125
|
+
raise ValidationError(str(e)) from e
|
|
126
|
+
|
|
127
|
+
return saved
|
|
128
|
+
|
|
129
|
+
def delete_record(self, record_id: int, actor: str) -> None:
|
|
130
|
+
"""Delete a record by ID (soft or hard)."""
|
|
131
|
+
ok = self.accessor.delete(record_id, actor=actor)
|
|
132
|
+
if not ok:
|
|
133
|
+
raise NotFoundError()
|
|
134
|
+
|
|
135
|
+
def restore_record(self, record_id: int, actor: str) -> ModelType: # type: ignore[type-var]
|
|
136
|
+
"""Restore a soft-deleted record (if supported).
|
|
137
|
+
|
|
138
|
+
Returns the restored model instance. If the record was already
|
|
139
|
+
not deleted, returns it unchanged.
|
|
140
|
+
"""
|
|
141
|
+
model_cls = self.accessor.model
|
|
142
|
+
if not hasattr(model_cls, "deleted_at"):
|
|
143
|
+
raise UnsupportedError(
|
|
144
|
+
f"{self.accessor.component} does not support soft delete/restore"
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
obj = self.accessor.get(record_id, include_deleted=True)
|
|
148
|
+
if not obj:
|
|
149
|
+
raise NotFoundError()
|
|
150
|
+
|
|
151
|
+
if getattr(obj, "deleted_at") is None:
|
|
152
|
+
# already restored, return as-is
|
|
153
|
+
return obj
|
|
154
|
+
|
|
155
|
+
self.accessor.restore(record_id, actor=actor)
|
|
156
|
+
# Fetch the restored record to return it
|
|
157
|
+
restored = self.accessor.get(record_id, include_deleted=False)
|
|
158
|
+
if not restored:
|
|
159
|
+
raise NotFoundError("Record not found after restore")
|
|
160
|
+
return restored
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from datetime import datetime, timezone
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any, Dict, List, Optional
|
|
5
|
+
|
|
6
|
+
from sqlmodel import SQLModel, select
|
|
7
|
+
from sqlalchemy import desc
|
|
8
|
+
|
|
9
|
+
from storage import database, io_helpers
|
|
10
|
+
from storage.auditor import AuditLog
|
|
11
|
+
from storage.utils import Operation
|
|
12
|
+
from storage.io_helpers import (
|
|
13
|
+
discover_user_tables,
|
|
14
|
+
build_export_zip_bytes,
|
|
15
|
+
db_file_path,
|
|
16
|
+
build_backup_bytes,
|
|
17
|
+
validate_sqlite_file,
|
|
18
|
+
safe_unlink,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
__all__ = ["DatabaseService", "DatabaseError"]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# ---------------------------------------------------------
|
|
26
|
+
# Exceptions (transport-agnostic)
|
|
27
|
+
# ---------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class DatabaseError(Exception):
|
|
31
|
+
"""Base class for database-level service errors."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, message: str, code: str = "UNKNOWN"):
|
|
34
|
+
super().__init__(message)
|
|
35
|
+
self.code = code
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class DependencyError(DatabaseError):
|
|
39
|
+
def __init__(self, message: str):
|
|
40
|
+
super().__init__(message, code="DEPENDENCY_ERROR")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class ValidationError(DatabaseError):
|
|
44
|
+
def __init__(self, message: str):
|
|
45
|
+
super().__init__(message, code="VALIDATION_ERROR")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class OperationalError(DatabaseError):
|
|
49
|
+
def __init__(self, message: str):
|
|
50
|
+
super().__init__(message, code="OPERATIONAL_ERROR")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# ---------------------------------------------------------
|
|
54
|
+
# Database Service
|
|
55
|
+
# ---------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class DatabaseService:
|
|
59
|
+
"""
|
|
60
|
+
Transport-agnostic database utilities.
|
|
61
|
+
|
|
62
|
+
Exposes operations like:
|
|
63
|
+
- health check
|
|
64
|
+
- audit log queries
|
|
65
|
+
- schema diagram (SVG)
|
|
66
|
+
- export.zip / backup.sqlite creation
|
|
67
|
+
- restore.sqlite upload + validation
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
def __init__(
|
|
71
|
+
self,
|
|
72
|
+
*,
|
|
73
|
+
audit_default_limit: int = 100,
|
|
74
|
+
audit_max_limit: int = 1000,
|
|
75
|
+
) -> None:
|
|
76
|
+
self.audit_default_limit = audit_default_limit
|
|
77
|
+
self.audit_max_limit = audit_max_limit
|
|
78
|
+
|
|
79
|
+
# ---------------- HEALTH ----------------
|
|
80
|
+
|
|
81
|
+
def health(self) -> Dict[str, str]:
|
|
82
|
+
"""Verify that a database engine can be initialized."""
|
|
83
|
+
_ = database.get_engine()
|
|
84
|
+
return {"status": "ok"}
|
|
85
|
+
|
|
86
|
+
# ---------------- AUDIT LOG ----------------
|
|
87
|
+
|
|
88
|
+
def read_audit(
|
|
89
|
+
self,
|
|
90
|
+
*,
|
|
91
|
+
component: Optional[str] = None,
|
|
92
|
+
record_id: Optional[int] = None,
|
|
93
|
+
actor: Optional[str] = None,
|
|
94
|
+
operation: Optional[Operation] = None,
|
|
95
|
+
since: Optional[datetime] = None,
|
|
96
|
+
until: Optional[datetime] = None,
|
|
97
|
+
limit: Optional[int] = None,
|
|
98
|
+
offset: int = 0,
|
|
99
|
+
) -> List[AuditLog]:
|
|
100
|
+
"""
|
|
101
|
+
Query the audit log table with filtering + pagination.
|
|
102
|
+
"""
|
|
103
|
+
limit = limit or self.audit_default_limit
|
|
104
|
+
if limit > self.audit_max_limit:
|
|
105
|
+
limit = self.audit_max_limit
|
|
106
|
+
|
|
107
|
+
with database.use_session() as session:
|
|
108
|
+
statement = select(AuditLog)
|
|
109
|
+
if component:
|
|
110
|
+
statement = statement.where(AuditLog.component == component)
|
|
111
|
+
if record_id is not None:
|
|
112
|
+
statement = statement.where(AuditLog.record_id == record_id)
|
|
113
|
+
if actor:
|
|
114
|
+
statement = statement.where(AuditLog.actor == actor)
|
|
115
|
+
if operation:
|
|
116
|
+
statement = statement.where(AuditLog.operation == operation)
|
|
117
|
+
if since is not None:
|
|
118
|
+
statement = statement.where(AuditLog.timestamp >= since)
|
|
119
|
+
if until is not None:
|
|
120
|
+
statement = statement.where(AuditLog.timestamp < until)
|
|
121
|
+
|
|
122
|
+
statement = (
|
|
123
|
+
statement.order_by(desc(AuditLog.timestamp)).offset(offset).limit(limit)
|
|
124
|
+
)
|
|
125
|
+
results = session.exec(statement).all()
|
|
126
|
+
return list(results)
|
|
127
|
+
|
|
128
|
+
# ---------------- DIAGRAM ----------------
|
|
129
|
+
|
|
130
|
+
def schema_diagram_svg(self) -> bytes:
|
|
131
|
+
"""
|
|
132
|
+
Generate a database schema diagram as SVG bytes.
|
|
133
|
+
Requires `sqlalchemy-schemadisplay` and Graphviz installed.
|
|
134
|
+
"""
|
|
135
|
+
try:
|
|
136
|
+
from sqlalchemy_schemadisplay import create_schema_graph
|
|
137
|
+
except Exception as e:
|
|
138
|
+
raise DependencyError(
|
|
139
|
+
"sqlalchemy-schemadisplay is required. Install with: "
|
|
140
|
+
"pip install sqlalchemy-schemadisplay"
|
|
141
|
+
) from e
|
|
142
|
+
|
|
143
|
+
try:
|
|
144
|
+
graph = create_schema_graph(
|
|
145
|
+
engine=database.get_engine(),
|
|
146
|
+
metadata=SQLModel.metadata,
|
|
147
|
+
show_datatypes=True,
|
|
148
|
+
show_indexes=False,
|
|
149
|
+
concentrate=False,
|
|
150
|
+
)
|
|
151
|
+
svg_bytes = graph.create_svg()
|
|
152
|
+
if isinstance(svg_bytes, str):
|
|
153
|
+
return svg_bytes.encode("utf-8")
|
|
154
|
+
return bytes(svg_bytes)
|
|
155
|
+
except Exception as e:
|
|
156
|
+
msg = str(e).lower()
|
|
157
|
+
if "executable" in msg or "dot not found" in msg or "graphviz" in msg:
|
|
158
|
+
raise DependencyError(
|
|
159
|
+
"Graphviz is required to render the diagram. "
|
|
160
|
+
"Install it via `brew install graphviz` or `apt-get install graphviz`."
|
|
161
|
+
) from e
|
|
162
|
+
raise OperationalError(f"Failed to generate schema diagram: {e}") from e
|
|
163
|
+
|
|
164
|
+
# ---------------- EXPORT ZIP ----------------
|
|
165
|
+
|
|
166
|
+
def export_zip(self) -> bytes:
|
|
167
|
+
"""
|
|
168
|
+
Export all user-defined tables as a ZIP archive of CSVs.
|
|
169
|
+
"""
|
|
170
|
+
try:
|
|
171
|
+
return build_export_zip_bytes(discover_user_tables())
|
|
172
|
+
except Exception as e:
|
|
173
|
+
raise OperationalError(f"Failed to build export.zip: {e}") from e
|
|
174
|
+
|
|
175
|
+
# ---------------- BACKUP SQLITE ----------------
|
|
176
|
+
|
|
177
|
+
def backup_sqlite(self) -> Dict[str, Any]:
|
|
178
|
+
"""
|
|
179
|
+
Create a consistent SQLite backup and return metadata + bytes.
|
|
180
|
+
"""
|
|
181
|
+
path = db_file_path()
|
|
182
|
+
try:
|
|
183
|
+
data = build_backup_bytes(path)
|
|
184
|
+
except Exception as e:
|
|
185
|
+
raise OperationalError(f"Failed to create backup: {e}") from e
|
|
186
|
+
|
|
187
|
+
filename = f"backup-{self._timestamp_slug()}.sqlite"
|
|
188
|
+
return {"filename": filename, "data": data}
|
|
189
|
+
|
|
190
|
+
# ---------------- RESTORE SQLITE ----------------
|
|
191
|
+
|
|
192
|
+
def restore_sqlite(
|
|
193
|
+
self,
|
|
194
|
+
file_bytes: bytes,
|
|
195
|
+
*,
|
|
196
|
+
filename: str = "upload.sqlite",
|
|
197
|
+
integrity_check: bool = True,
|
|
198
|
+
dry_run: bool = False,
|
|
199
|
+
) -> Dict[str, Any]:
|
|
200
|
+
"""
|
|
201
|
+
Restore a database from raw SQLite bytes.
|
|
202
|
+
|
|
203
|
+
Steps:
|
|
204
|
+
1. Save to a temp path
|
|
205
|
+
2. Validate header & integrity
|
|
206
|
+
3. Optionally replace existing DB atomically
|
|
207
|
+
"""
|
|
208
|
+
path = db_file_path()
|
|
209
|
+
db_dir = Path(path).resolve().parent
|
|
210
|
+
|
|
211
|
+
try:
|
|
212
|
+
tmp_path, total = io_helpers.save_bytes_to_temp(
|
|
213
|
+
file_bytes, db_dir, filename=filename
|
|
214
|
+
)
|
|
215
|
+
except Exception as e:
|
|
216
|
+
raise OperationalError(f"Failed to save upload: {e}") from e
|
|
217
|
+
|
|
218
|
+
try:
|
|
219
|
+
validate_sqlite_file(tmp_path, run_integrity_check=integrity_check)
|
|
220
|
+
except ValueError as ve:
|
|
221
|
+
safe_unlink(tmp_path)
|
|
222
|
+
raise ValidationError(str(ve)) from ve
|
|
223
|
+
except Exception as e:
|
|
224
|
+
safe_unlink(tmp_path)
|
|
225
|
+
raise ValidationError(f"Invalid SQLite file: {e}") from e
|
|
226
|
+
|
|
227
|
+
if dry_run:
|
|
228
|
+
safe_unlink(tmp_path)
|
|
229
|
+
return {"status": "ok", "restored": False, "bytes": total}
|
|
230
|
+
|
|
231
|
+
try:
|
|
232
|
+
io_helpers.atomic_replace_db(tmp_path, Path(path))
|
|
233
|
+
except Exception as e:
|
|
234
|
+
safe_unlink(tmp_path)
|
|
235
|
+
raise OperationalError(f"Failed to replace database file: {e}") from e
|
|
236
|
+
|
|
237
|
+
return {"status": "ok", "restored": True, "bytes": total}
|
|
238
|
+
|
|
239
|
+
# ---------------- INTERNAL ----------------
|
|
240
|
+
|
|
241
|
+
def _timestamp_slug(self) -> str:
|
|
242
|
+
"""UTC timestamp safe for filenames."""
|
|
243
|
+
return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
storage/io_helpers.py
CHANGED
|
@@ -25,6 +25,7 @@ __all__ = [
|
|
|
25
25
|
"db_file_path",
|
|
26
26
|
"build_backup_bytes",
|
|
27
27
|
"validate_sqlite_file",
|
|
28
|
+
"save_bytes_to_temp",
|
|
28
29
|
"safe_unlink",
|
|
29
30
|
]
|
|
30
31
|
|
|
@@ -159,6 +160,19 @@ def save_upload_to_temp(file: UploadFile, dest_dir: Path) -> tuple[Path, int]:
|
|
|
159
160
|
return tmp_path, total
|
|
160
161
|
|
|
161
162
|
|
|
163
|
+
def save_bytes_to_temp(
|
|
164
|
+
file_bytes: bytes, dest_dir: Path, *, filename: str = "upload.sqlite"
|
|
165
|
+
) -> tuple[Path, int]:
|
|
166
|
+
"""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:
|
|
170
|
+
tmp_path = Path(tmp.name)
|
|
171
|
+
tmp.write(file_bytes)
|
|
172
|
+
total = len(file_bytes)
|
|
173
|
+
return tmp_path, total
|
|
174
|
+
|
|
175
|
+
|
|
162
176
|
def atomic_replace_db(tmp_path: Path, db_path: Path) -> None:
|
|
163
177
|
database.get_engine().dispose()
|
|
164
178
|
os.replace(str(tmp_path), str(db_path))
|
storage/utils.py
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
from datetime import datetime, timezone, date, time
|
|
2
|
-
from typing import Literal, TypeVar, Any
|
|
2
|
+
from typing import Literal, TypeVar, Any, Optional, get_args
|
|
3
3
|
from sqlmodel import SQLModel
|
|
4
4
|
|
|
5
5
|
ModelType = TypeVar("ModelType", bound=SQLModel)
|
|
@@ -18,3 +18,28 @@ def to_primitive(value: Any) -> Any:
|
|
|
18
18
|
if isinstance(value, (datetime, date, time)):
|
|
19
19
|
return value.isoformat()
|
|
20
20
|
return value
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def parse_audit_operation(operation: Optional[str]) -> Optional[Operation]:
|
|
24
|
+
"""
|
|
25
|
+
Parse and validate an operation string for audit queries.
|
|
26
|
+
"""
|
|
27
|
+
if operation is None:
|
|
28
|
+
return None
|
|
29
|
+
|
|
30
|
+
if operation not in get_args(Operation):
|
|
31
|
+
raise ValueError(
|
|
32
|
+
f"Invalid operation: {operation}. Must be one of {get_args(Operation)}"
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
return operation # type: ignore
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def parse_audit_datetime(dt_str: Optional[str]) -> Optional[datetime]:
|
|
39
|
+
"""
|
|
40
|
+
Parse an ISO 8601 datetime string for audit queries.
|
|
41
|
+
Handles both 'Z' and '+00:00' timezone formats.
|
|
42
|
+
"""
|
|
43
|
+
if dt_str is None:
|
|
44
|
+
return None
|
|
45
|
+
return datetime.fromisoformat(dt_str.replace("Z", "+00:00"))
|