sapling-db 0.1.0__tar.gz
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.
- sapling_db-0.1.0/PKG-INFO +62 -0
- sapling_db-0.1.0/README.md +43 -0
- sapling_db-0.1.0/pyproject.toml +78 -0
- sapling_db-0.1.0/src/sapling/__init__.py +16 -0
- sapling_db-0.1.0/src/sapling/backends/__init__.py +5 -0
- sapling_db-0.1.0/src/sapling/backends/base.py +57 -0
- sapling_db-0.1.0/src/sapling/backends/memory.py +105 -0
- sapling_db-0.1.0/src/sapling/backends/sqlite.py +282 -0
- sapling_db-0.1.0/src/sapling/database.py +394 -0
- sapling_db-0.1.0/src/sapling/document.py +31 -0
- sapling_db-0.1.0/src/sapling/errors.py +6 -0
- sapling_db-0.1.0/src/sapling/py.typed +0 -0
- sapling_db-0.1.0/src/sapling/settings.py +53 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: sapling-db
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Add your description here
|
|
5
|
+
Author: Justin Chapman
|
|
6
|
+
Author-email: Justin Chapman <commonmodestudio@gmail.com>
|
|
7
|
+
Classifier: Development Status :: 3 - Alpha
|
|
8
|
+
Classifier: Intended Audience :: Developers
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
14
|
+
Classifier: Typing :: Typed
|
|
15
|
+
Requires-Dist: pydantic>=2.12.5
|
|
16
|
+
Requires-Dist: pydantic-settings>=2.12.0
|
|
17
|
+
Requires-Python: >=3.12
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# sapling
|
|
21
|
+
|
|
22
|
+
[](https://github.com/astral-sh/ruff)
|
|
23
|
+
[](https://pypi.org/project/sapling-db/)
|
|
24
|
+
[](LICENSE)
|
|
25
|
+
[](https://python.org)
|
|
26
|
+
[](https://github.com/thejchap/sapling/actions/workflows/ci.yml)
|
|
27
|
+
|
|
28
|
+
## installation
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
pip install sapling-db
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## overview
|
|
35
|
+
|
|
36
|
+
simple, zero-setup persistence for pydantic models
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
from pydantic import BaseModel
|
|
40
|
+
from sapling import Database
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class User(BaseModel):
|
|
44
|
+
name: str
|
|
45
|
+
email: str
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
db = Database()
|
|
49
|
+
|
|
50
|
+
with db.transaction() as txn:
|
|
51
|
+
user = User(name="alice", email="alice@example.com")
|
|
52
|
+
doc = txn.put(User, "123", user)
|
|
53
|
+
txn.fetch(User, "123")
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
**features:**
|
|
57
|
+
|
|
58
|
+
- **fully typed** - complete type safety with ide autocomplete and type checking
|
|
59
|
+
- **zero setup** - works out of the box with no configuration
|
|
60
|
+
- **pydantic native** - designed specifically for pydantic models
|
|
61
|
+
- **fastapi ready** - seamless dependency injection for request-scoped transactions
|
|
62
|
+
- **sqlite backed** - solid, battle-tested storage
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# sapling
|
|
2
|
+
|
|
3
|
+
[](https://github.com/astral-sh/ruff)
|
|
4
|
+
[](https://pypi.org/project/sapling-db/)
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
[](https://python.org)
|
|
7
|
+
[](https://github.com/thejchap/sapling/actions/workflows/ci.yml)
|
|
8
|
+
|
|
9
|
+
## installation
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
pip install sapling-db
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## overview
|
|
16
|
+
|
|
17
|
+
simple, zero-setup persistence for pydantic models
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
from pydantic import BaseModel
|
|
21
|
+
from sapling import Database
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class User(BaseModel):
|
|
25
|
+
name: str
|
|
26
|
+
email: str
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
db = Database()
|
|
30
|
+
|
|
31
|
+
with db.transaction() as txn:
|
|
32
|
+
user = User(name="alice", email="alice@example.com")
|
|
33
|
+
doc = txn.put(User, "123", user)
|
|
34
|
+
txn.fetch(User, "123")
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
**features:**
|
|
38
|
+
|
|
39
|
+
- **fully typed** - complete type safety with ide autocomplete and type checking
|
|
40
|
+
- **zero setup** - works out of the box with no configuration
|
|
41
|
+
- **pydantic native** - designed specifically for pydantic models
|
|
42
|
+
- **fastapi ready** - seamless dependency injection for request-scoped transactions
|
|
43
|
+
- **sqlite backed** - solid, battle-tested storage
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "sapling-db"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Add your description here"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [{ name = "Justin Chapman", email = "commonmodestudio@gmail.com" }]
|
|
7
|
+
requires-python = ">=3.12"
|
|
8
|
+
dependencies = ["pydantic>=2.12.5", "pydantic-settings>=2.12.0"]
|
|
9
|
+
classifiers = [
|
|
10
|
+
"Development Status :: 3 - Alpha",
|
|
11
|
+
"Intended Audience :: Developers",
|
|
12
|
+
"License :: OSI Approved :: MIT License",
|
|
13
|
+
"Programming Language :: Python :: 3",
|
|
14
|
+
"Programming Language :: Python :: 3.12",
|
|
15
|
+
"Programming Language :: Python :: 3.13",
|
|
16
|
+
"Programming Language :: Python :: 3.14",
|
|
17
|
+
"Typing :: Typed",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
[build-system]
|
|
21
|
+
requires = ["uv_build>=0.8.4,<0.9.0"]
|
|
22
|
+
build-backend = "uv_build"
|
|
23
|
+
|
|
24
|
+
[tool.uv.build-backend]
|
|
25
|
+
module-name = "sapling"
|
|
26
|
+
|
|
27
|
+
[dependency-groups]
|
|
28
|
+
dev = [
|
|
29
|
+
"fastapi>=0.125.0",
|
|
30
|
+
"httpx>=0.28.1",
|
|
31
|
+
"pre-commit>=4.0.1",
|
|
32
|
+
"tryke @ git+https://github.com/thejchap/tryke",
|
|
33
|
+
"ruff>=0.13.0",
|
|
34
|
+
"ty>=0.0.4",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
[tool.ruff]
|
|
38
|
+
line-length = 88
|
|
39
|
+
indent-width = 4
|
|
40
|
+
target-version = "py312"
|
|
41
|
+
|
|
42
|
+
[tool.ruff.lint.per-file-ignores]
|
|
43
|
+
"tests/*.py" = ["ANN201", "PLR2004"]
|
|
44
|
+
"src/sapling/backends/sqlite.py" = ["S608"]
|
|
45
|
+
|
|
46
|
+
[tool.ruff.lint.pycodestyle]
|
|
47
|
+
max-line-length = 88
|
|
48
|
+
|
|
49
|
+
[tool.ruff.lint]
|
|
50
|
+
select = ["ALL"]
|
|
51
|
+
ignore = [
|
|
52
|
+
# may cause issues with formatter
|
|
53
|
+
"COM812",
|
|
54
|
+
# incompatible with D213
|
|
55
|
+
"D212",
|
|
56
|
+
# incompatible with D211
|
|
57
|
+
"D203",
|
|
58
|
+
# missing docstring
|
|
59
|
+
"D100",
|
|
60
|
+
"D101",
|
|
61
|
+
"D102",
|
|
62
|
+
"D103",
|
|
63
|
+
"D104",
|
|
64
|
+
"D107",
|
|
65
|
+
# TODOs
|
|
66
|
+
"FIX002",
|
|
67
|
+
"FIX003",
|
|
68
|
+
"TD003",
|
|
69
|
+
"ERA001",
|
|
70
|
+
# too many arguments
|
|
71
|
+
"PLR0913",
|
|
72
|
+
# causes error
|
|
73
|
+
"ISC001",
|
|
74
|
+
]
|
|
75
|
+
fixable = ["ALL"]
|
|
76
|
+
|
|
77
|
+
[tool.ruff.format]
|
|
78
|
+
docstring-code-format = true
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""sapling - simple persistence for pydantic models."""
|
|
2
|
+
|
|
3
|
+
from .backends.memory import MemoryBackend
|
|
4
|
+
from .backends.sqlite import SQLiteBackend
|
|
5
|
+
from .database import Database
|
|
6
|
+
from .document import Document
|
|
7
|
+
from .settings import SaplingSettings, get_sapling_settings
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"Database",
|
|
11
|
+
"Document",
|
|
12
|
+
"MemoryBackend",
|
|
13
|
+
"SQLiteBackend",
|
|
14
|
+
"SaplingSettings",
|
|
15
|
+
"get_sapling_settings",
|
|
16
|
+
]
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from typing import TYPE_CHECKING, Self
|
|
5
|
+
|
|
6
|
+
if TYPE_CHECKING:
|
|
7
|
+
from contextlib import AbstractContextManager
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel
|
|
10
|
+
|
|
11
|
+
from sapling.database import Document
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Backend(ABC):
|
|
15
|
+
"""abstract base class for storage backends."""
|
|
16
|
+
|
|
17
|
+
@abstractmethod
|
|
18
|
+
def get[T: BaseModel](
|
|
19
|
+
self, model_class: type[T], model_id: str
|
|
20
|
+
) -> Document[T] | None: ...
|
|
21
|
+
|
|
22
|
+
@abstractmethod
|
|
23
|
+
def put[T: BaseModel](
|
|
24
|
+
self, model_class: type[T], model_id: str, model: T
|
|
25
|
+
) -> Document[T]: ...
|
|
26
|
+
|
|
27
|
+
@abstractmethod
|
|
28
|
+
def fetch[T: BaseModel](
|
|
29
|
+
self, model_class: type[T], model_id: str
|
|
30
|
+
) -> Document[T]: ...
|
|
31
|
+
|
|
32
|
+
@abstractmethod
|
|
33
|
+
def delete(self, model_class: type[BaseModel], model_id: str) -> None: ...
|
|
34
|
+
|
|
35
|
+
@abstractmethod
|
|
36
|
+
def all[T: BaseModel](self, model_class: type[T]) -> list[Document[T]]: ...
|
|
37
|
+
|
|
38
|
+
@abstractmethod
|
|
39
|
+
def get_many[T: BaseModel](
|
|
40
|
+
self, model_class: type[T], model_ids: list[str]
|
|
41
|
+
) -> list[Document[T] | None]: ...
|
|
42
|
+
|
|
43
|
+
@abstractmethod
|
|
44
|
+
def delete_many(
|
|
45
|
+
self, model_class: type[BaseModel], model_ids: list[str]
|
|
46
|
+
) -> None: ...
|
|
47
|
+
|
|
48
|
+
@abstractmethod
|
|
49
|
+
def put_many[T: BaseModel](
|
|
50
|
+
self, model_class: type[T], models: list[tuple[str, T]]
|
|
51
|
+
) -> list[Document[T]]: ...
|
|
52
|
+
|
|
53
|
+
@abstractmethod
|
|
54
|
+
def initialize(self) -> None: ...
|
|
55
|
+
|
|
56
|
+
@abstractmethod
|
|
57
|
+
def transaction(self) -> AbstractContextManager[Self]: ...
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from contextlib import contextmanager
|
|
4
|
+
from typing import TYPE_CHECKING, Self
|
|
5
|
+
|
|
6
|
+
from sapling.backends.base import Backend
|
|
7
|
+
from sapling.document import Document
|
|
8
|
+
from sapling.errors import NotFoundError
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from collections.abc import Generator
|
|
12
|
+
|
|
13
|
+
from pydantic import BaseModel
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class MemoryBackend(Backend):
|
|
17
|
+
"""
|
|
18
|
+
in-memory storage backend.
|
|
19
|
+
|
|
20
|
+
stores documents in python dict, no persistence.
|
|
21
|
+
useful for testing and development.
|
|
22
|
+
|
|
23
|
+
Example:
|
|
24
|
+
```python
|
|
25
|
+
backend = MemoryBackend()
|
|
26
|
+
db = Database(backend=backend)
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(self) -> None:
|
|
32
|
+
self._store: dict[tuple[str, str], dict] = {}
|
|
33
|
+
|
|
34
|
+
def initialize(self) -> None:
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
@contextmanager
|
|
38
|
+
def transaction(self) -> Generator[Self]:
|
|
39
|
+
yield self
|
|
40
|
+
|
|
41
|
+
def get[T: BaseModel](
|
|
42
|
+
self, model_class: type[T], model_id: str
|
|
43
|
+
) -> Document[T] | None:
|
|
44
|
+
key = (model_class.__name__, model_id)
|
|
45
|
+
if data := self._store.get(key):
|
|
46
|
+
model = model_class.model_validate(data["model"])
|
|
47
|
+
return Document(
|
|
48
|
+
model=model,
|
|
49
|
+
model_id=data["model_id"],
|
|
50
|
+
model_class=data["model_class"],
|
|
51
|
+
)
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
def put[T: BaseModel](
|
|
55
|
+
self, model_class: type[T], model_id: str, model: T
|
|
56
|
+
) -> Document[T]:
|
|
57
|
+
key = (model_class.__name__, model_id)
|
|
58
|
+
data = {
|
|
59
|
+
"model_class": model_class.__name__,
|
|
60
|
+
"model_id": model_id,
|
|
61
|
+
"model": model.model_dump(),
|
|
62
|
+
}
|
|
63
|
+
self._store[key] = data
|
|
64
|
+
return Document(
|
|
65
|
+
model=model,
|
|
66
|
+
model_id=model_id,
|
|
67
|
+
model_class=model_class.__name__,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
def fetch[T: BaseModel](self, model_class: type[T], model_id: str) -> Document[T]:
|
|
71
|
+
if document := self.get(model_class=model_class, model_id=model_id):
|
|
72
|
+
return document
|
|
73
|
+
raise NotFoundError
|
|
74
|
+
|
|
75
|
+
def delete(self, model_class: type[BaseModel], model_id: str) -> None:
|
|
76
|
+
key = (model_class.__name__, model_id)
|
|
77
|
+
self._store.pop(key, None)
|
|
78
|
+
|
|
79
|
+
def all[T: BaseModel](self, model_class: type[T]) -> list[Document[T]]:
|
|
80
|
+
results = []
|
|
81
|
+
for (stored_class, _), data in self._store.items():
|
|
82
|
+
if stored_class == model_class.__name__:
|
|
83
|
+
model = model_class.model_validate(data["model"])
|
|
84
|
+
results.append(
|
|
85
|
+
Document(
|
|
86
|
+
model=model,
|
|
87
|
+
model_id=data["model_id"],
|
|
88
|
+
model_class=data["model_class"],
|
|
89
|
+
)
|
|
90
|
+
)
|
|
91
|
+
return results
|
|
92
|
+
|
|
93
|
+
def get_many[T: BaseModel](
|
|
94
|
+
self, model_class: type[T], model_ids: list[str]
|
|
95
|
+
) -> list[Document[T] | None]:
|
|
96
|
+
return [self.get(model_class, model_id) for model_id in model_ids]
|
|
97
|
+
|
|
98
|
+
def delete_many(self, model_class: type[BaseModel], model_ids: list[str]) -> None:
|
|
99
|
+
for model_id in model_ids:
|
|
100
|
+
self.delete(model_class, model_id)
|
|
101
|
+
|
|
102
|
+
def put_many[T: BaseModel](
|
|
103
|
+
self, model_class: type[T], models: list[tuple[str, T]]
|
|
104
|
+
) -> list[Document[T]]:
|
|
105
|
+
return [self.put(model_class, model_id, model) for model_id, model in models]
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import sqlite3
|
|
5
|
+
import threading
|
|
6
|
+
from contextlib import contextmanager
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import TYPE_CHECKING, Any, Self, override
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel
|
|
11
|
+
|
|
12
|
+
from sapling.backends.base import Backend
|
|
13
|
+
from sapling.document import Document
|
|
14
|
+
from sapling.errors import NotFoundError
|
|
15
|
+
from sapling.settings import IsolationLevel, SaplingSettings, get_sapling_settings
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from collections.abc import Generator
|
|
19
|
+
|
|
20
|
+
logging.basicConfig(level=logging.INFO)
|
|
21
|
+
LOGGER = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
_CONNECTION_NOT_INITIALIZED = "Connection not initialized"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class SQLiteBackend(Backend):
|
|
27
|
+
"""
|
|
28
|
+
sqlite-based storage backend.
|
|
29
|
+
|
|
30
|
+
provides persistent storage using sqlite database.
|
|
31
|
+
supports both file-based and in-memory modes.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
settings: sapling settings instance. if not provided, uses global settings
|
|
35
|
+
from environment variables.
|
|
36
|
+
|
|
37
|
+
Example:
|
|
38
|
+
```python
|
|
39
|
+
# use global settings from environment
|
|
40
|
+
backend = SQLiteBackend()
|
|
41
|
+
|
|
42
|
+
# use custom settings
|
|
43
|
+
from sapling import SaplingSettings
|
|
44
|
+
|
|
45
|
+
settings = SaplingSettings(
|
|
46
|
+
sqlite_path="/path/to/db.sqlite",
|
|
47
|
+
sqlite_timeout=10.0,
|
|
48
|
+
)
|
|
49
|
+
backend = SQLiteBackend(settings=settings)
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def __init__(self, settings: SaplingSettings | None = None) -> None:
|
|
55
|
+
settings = settings if settings is not None else get_sapling_settings()
|
|
56
|
+
self.path: str = settings.sqlite_path
|
|
57
|
+
self.timeout: float = settings.sqlite_timeout
|
|
58
|
+
self.detect_types: int = settings.sqlite_detect_types
|
|
59
|
+
self.isolation_level: IsolationLevel = settings.sqlite_isolation_level
|
|
60
|
+
self.check_same_thread: bool = settings.sqlite_check_same_thread
|
|
61
|
+
self.cached_statements: int = settings.sqlite_cached_statements
|
|
62
|
+
self.uri: bool = settings.sqlite_uri
|
|
63
|
+
self._conn: sqlite3.Connection | None = None
|
|
64
|
+
self._initialized: bool = False
|
|
65
|
+
self._init_lock: threading.Lock = threading.Lock()
|
|
66
|
+
|
|
67
|
+
@override
|
|
68
|
+
def initialize(self) -> None:
|
|
69
|
+
with self._init_lock:
|
|
70
|
+
if self._initialized:
|
|
71
|
+
return
|
|
72
|
+
if self.path != ":memory:":
|
|
73
|
+
db_dir = Path(self.path).parent
|
|
74
|
+
db_dir.mkdir(parents=True, exist_ok=True)
|
|
75
|
+
self._conn = sqlite3.connect(
|
|
76
|
+
self.path,
|
|
77
|
+
timeout=self.timeout,
|
|
78
|
+
detect_types=self.detect_types,
|
|
79
|
+
isolation_level=self.isolation_level,
|
|
80
|
+
check_same_thread=self.check_same_thread,
|
|
81
|
+
cached_statements=self.cached_statements,
|
|
82
|
+
uri=self.uri,
|
|
83
|
+
)
|
|
84
|
+
self._init_schema()
|
|
85
|
+
self._initialized = True
|
|
86
|
+
|
|
87
|
+
@contextmanager
|
|
88
|
+
def transaction(self) -> Generator[Self]:
|
|
89
|
+
if not self._conn:
|
|
90
|
+
raise ValueError(_CONNECTION_NOT_INITIALIZED)
|
|
91
|
+
with self._conn:
|
|
92
|
+
yield self
|
|
93
|
+
|
|
94
|
+
def _init_schema(self) -> None:
|
|
95
|
+
if self._conn is None:
|
|
96
|
+
raise ValueError(_CONNECTION_NOT_INITIALIZED)
|
|
97
|
+
self._conn.set_trace_callback(LOGGER.debug)
|
|
98
|
+
with self._conn:
|
|
99
|
+
self._conn.execute(
|
|
100
|
+
"""\
|
|
101
|
+
CREATE TABLE IF NOT EXISTS document (
|
|
102
|
+
model_class VARCHAR,
|
|
103
|
+
model_id CHARACTER(26),
|
|
104
|
+
model BLOB,
|
|
105
|
+
PRIMARY KEY (model_class, model_id)
|
|
106
|
+
);
|
|
107
|
+
""".strip()
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
def _row_to_document[T: BaseModel](
|
|
111
|
+
self, model_class: type[T], cursor: sqlite3.Cursor, row: tuple[Any]
|
|
112
|
+
) -> Document[T]:
|
|
113
|
+
fields = [column[0] for column in cursor.description]
|
|
114
|
+
raw = dict(zip(fields, row, strict=False))
|
|
115
|
+
model = model_class.model_validate_json(raw["model"])
|
|
116
|
+
return Document(
|
|
117
|
+
model=model,
|
|
118
|
+
model_id=raw["model_id"],
|
|
119
|
+
model_class=raw["model_class"],
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
def get[T: BaseModel](
|
|
123
|
+
self, model_class: type[T], model_id: str
|
|
124
|
+
) -> Document[T] | None:
|
|
125
|
+
if self._conn is None:
|
|
126
|
+
raise ValueError(_CONNECTION_NOT_INITIALIZED)
|
|
127
|
+
cursor = self._conn.execute(
|
|
128
|
+
"""\
|
|
129
|
+
SELECT
|
|
130
|
+
model_class,
|
|
131
|
+
model_id,
|
|
132
|
+
model
|
|
133
|
+
FROM document
|
|
134
|
+
WHERE
|
|
135
|
+
model_class = :model_class
|
|
136
|
+
AND model_id = :model_id
|
|
137
|
+
LIMIT 1
|
|
138
|
+
;
|
|
139
|
+
""".strip(),
|
|
140
|
+
{"model_class": model_class.__name__, "model_id": str(model_id)},
|
|
141
|
+
)
|
|
142
|
+
row = cursor.fetchone()
|
|
143
|
+
if row:
|
|
144
|
+
return self._row_to_document(model_class, cursor, row)
|
|
145
|
+
return None
|
|
146
|
+
|
|
147
|
+
def put[T: BaseModel](
|
|
148
|
+
self, model_class: type[T], model_id: str, model: T
|
|
149
|
+
) -> Document[T]:
|
|
150
|
+
if self._conn is None:
|
|
151
|
+
raise ValueError(_CONNECTION_NOT_INITIALIZED)
|
|
152
|
+
cursor = self._conn.execute(
|
|
153
|
+
"""\
|
|
154
|
+
INSERT OR REPLACE INTO document VALUES (
|
|
155
|
+
:model_class,
|
|
156
|
+
:model_id,
|
|
157
|
+
:model
|
|
158
|
+
) RETURNING
|
|
159
|
+
model_class,
|
|
160
|
+
model_id,
|
|
161
|
+
model
|
|
162
|
+
;
|
|
163
|
+
""".strip(),
|
|
164
|
+
{
|
|
165
|
+
"model_class": model_class.__name__,
|
|
166
|
+
"model_id": model_id,
|
|
167
|
+
"model": model.model_dump_json(),
|
|
168
|
+
},
|
|
169
|
+
)
|
|
170
|
+
row = cursor.fetchone()
|
|
171
|
+
return self._row_to_document(model_class, cursor, row)
|
|
172
|
+
|
|
173
|
+
def fetch[T: BaseModel](self, model_class: type[T], model_id: str) -> Document[T]:
|
|
174
|
+
if document := self.get(model_class=model_class, model_id=model_id):
|
|
175
|
+
return document
|
|
176
|
+
raise NotFoundError
|
|
177
|
+
|
|
178
|
+
def delete(self, model_class: type[BaseModel], model_id: str) -> None:
|
|
179
|
+
if self._conn is None:
|
|
180
|
+
raise ValueError(_CONNECTION_NOT_INITIALIZED)
|
|
181
|
+
self._conn.execute(
|
|
182
|
+
"""\
|
|
183
|
+
DELETE
|
|
184
|
+
FROM document
|
|
185
|
+
WHERE
|
|
186
|
+
model_class = :model_class
|
|
187
|
+
AND model_id = :model_id
|
|
188
|
+
;
|
|
189
|
+
""".strip(),
|
|
190
|
+
{"model_class": model_class.__name__, "model_id": str(model_id)},
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
def all[T: BaseModel](self, model_class: type[T]) -> list[Document[T]]:
|
|
194
|
+
if self._conn is None:
|
|
195
|
+
raise ValueError(_CONNECTION_NOT_INITIALIZED)
|
|
196
|
+
cursor = self._conn.execute(
|
|
197
|
+
"""\
|
|
198
|
+
SELECT
|
|
199
|
+
model_class,
|
|
200
|
+
model_id,
|
|
201
|
+
model
|
|
202
|
+
FROM document
|
|
203
|
+
WHERE
|
|
204
|
+
model_class = :model_class
|
|
205
|
+
;
|
|
206
|
+
""".strip(),
|
|
207
|
+
{"model_class": model_class.__name__},
|
|
208
|
+
)
|
|
209
|
+
return [
|
|
210
|
+
self._row_to_document(model_class, cursor, row) for row in cursor.fetchall()
|
|
211
|
+
]
|
|
212
|
+
|
|
213
|
+
def get_many[T: BaseModel](
|
|
214
|
+
self, model_class: type[T], model_ids: list[str]
|
|
215
|
+
) -> list[Document[T] | None]:
|
|
216
|
+
if not model_ids:
|
|
217
|
+
return []
|
|
218
|
+
if self._conn is None:
|
|
219
|
+
raise ValueError(_CONNECTION_NOT_INITIALIZED)
|
|
220
|
+
placeholders = ",".join("?" * len(model_ids))
|
|
221
|
+
cursor = self._conn.execute(
|
|
222
|
+
f"""\
|
|
223
|
+
SELECT
|
|
224
|
+
model_class,
|
|
225
|
+
model_id,
|
|
226
|
+
model
|
|
227
|
+
FROM document
|
|
228
|
+
WHERE
|
|
229
|
+
model_class = ?
|
|
230
|
+
AND model_id IN ({placeholders})
|
|
231
|
+
;
|
|
232
|
+
""".strip(),
|
|
233
|
+
[model_class.__name__, *model_ids],
|
|
234
|
+
)
|
|
235
|
+
results_dict = {
|
|
236
|
+
row[1]: self._row_to_document(model_class, cursor, row)
|
|
237
|
+
for row in cursor.fetchall()
|
|
238
|
+
}
|
|
239
|
+
return [results_dict.get(model_id) for model_id in model_ids]
|
|
240
|
+
|
|
241
|
+
def delete_many(self, model_class: type[BaseModel], model_ids: list[str]) -> None:
|
|
242
|
+
if not model_ids:
|
|
243
|
+
return
|
|
244
|
+
if self._conn is None:
|
|
245
|
+
raise ValueError(_CONNECTION_NOT_INITIALIZED)
|
|
246
|
+
placeholders = ",".join("?" * len(model_ids))
|
|
247
|
+
self._conn.execute(
|
|
248
|
+
f"""\
|
|
249
|
+
DELETE
|
|
250
|
+
FROM document
|
|
251
|
+
WHERE
|
|
252
|
+
model_class = ?
|
|
253
|
+
AND model_id IN ({placeholders})
|
|
254
|
+
;
|
|
255
|
+
""".strip(),
|
|
256
|
+
[model_class.__name__, *model_ids],
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
def put_many[T: BaseModel](
|
|
260
|
+
self, model_class: type[T], models: list[tuple[str, T]]
|
|
261
|
+
) -> list[Document[T]]:
|
|
262
|
+
if not models:
|
|
263
|
+
return []
|
|
264
|
+
if self._conn is None:
|
|
265
|
+
raise ValueError(_CONNECTION_NOT_INITIALIZED)
|
|
266
|
+
values_placeholders = ",".join("(?, ?, ?)" * len(models))
|
|
267
|
+
flat_values = [
|
|
268
|
+
val
|
|
269
|
+
for model_id, model in models
|
|
270
|
+
for val in (model_class.__name__, model_id, model.model_dump_json())
|
|
271
|
+
]
|
|
272
|
+
cursor = self._conn.execute(
|
|
273
|
+
f"""\
|
|
274
|
+
INSERT OR REPLACE INTO document VALUES {values_placeholders}
|
|
275
|
+
RETURNING model_class, model_id, model
|
|
276
|
+
;
|
|
277
|
+
""".strip(),
|
|
278
|
+
flat_values,
|
|
279
|
+
)
|
|
280
|
+
return [
|
|
281
|
+
self._row_to_document(model_class, cursor, row) for row in cursor.fetchall()
|
|
282
|
+
]
|
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import tempfile
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel
|
|
8
|
+
from tryke import describe, expect, test
|
|
9
|
+
|
|
10
|
+
from sapling import MemoryBackend, SQLiteBackend
|
|
11
|
+
from sapling.errors import NotFoundError
|
|
12
|
+
from sapling.settings import SaplingSettings
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from collections.abc import Generator, Iterator
|
|
16
|
+
from contextlib import AbstractContextManager
|
|
17
|
+
from types import TracebackType
|
|
18
|
+
|
|
19
|
+
from sapling.backends.base import Backend
|
|
20
|
+
from sapling.document import Document
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class _TransactionWrapper:
|
|
24
|
+
"""
|
|
25
|
+
Wrapper for backend transactions.
|
|
26
|
+
|
|
27
|
+
works as both context managers and generators.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, backend_transaction_cm: AbstractContextManager[Backend]) -> None:
|
|
31
|
+
self._backend_txn_cm: AbstractContextManager[Backend] = backend_transaction_cm
|
|
32
|
+
|
|
33
|
+
def __enter__(self) -> Backend:
|
|
34
|
+
return self._backend_txn_cm.__enter__()
|
|
35
|
+
|
|
36
|
+
def __exit__(
|
|
37
|
+
self,
|
|
38
|
+
exc_type: type[BaseException] | None,
|
|
39
|
+
exc_value: BaseException | None,
|
|
40
|
+
traceback: TracebackType | None,
|
|
41
|
+
) -> bool | None:
|
|
42
|
+
return self._backend_txn_cm.__exit__(exc_type, exc_value, traceback)
|
|
43
|
+
|
|
44
|
+
def __call__(self) -> Iterator[Backend]:
|
|
45
|
+
return iter(self)
|
|
46
|
+
|
|
47
|
+
def __iter__(self) -> Generator[Backend]:
|
|
48
|
+
with self._backend_txn_cm as txn:
|
|
49
|
+
yield txn
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class Database:
|
|
53
|
+
"""
|
|
54
|
+
main interface for sapling persistence.
|
|
55
|
+
|
|
56
|
+
provides crud operations with transaction management.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
backend: storage backend (defaults to SQLiteBackend)
|
|
60
|
+
initialize: whether to initialize backend immediately
|
|
61
|
+
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
def __init__(
|
|
65
|
+
self, backend: Backend | None = None, *, initialize: bool = True
|
|
66
|
+
) -> None:
|
|
67
|
+
self._backend: Backend = backend or SQLiteBackend()
|
|
68
|
+
if initialize:
|
|
69
|
+
self._backend.initialize()
|
|
70
|
+
|
|
71
|
+
def initialize(self) -> None:
|
|
72
|
+
"""
|
|
73
|
+
Initialize backend (idempotent - safe to call multiple times).
|
|
74
|
+
|
|
75
|
+
use this when `initialize=False` was passed to Database constructor.
|
|
76
|
+
"""
|
|
77
|
+
self._backend.initialize()
|
|
78
|
+
|
|
79
|
+
def transaction(self) -> _TransactionWrapper:
|
|
80
|
+
"""
|
|
81
|
+
Create transaction context for multiple operations.
|
|
82
|
+
|
|
83
|
+
commits on success, rolls back on exception.
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
context manager yielding backend instance
|
|
87
|
+
|
|
88
|
+
Example:
|
|
89
|
+
```python
|
|
90
|
+
with db.transaction() as txn:
|
|
91
|
+
txn.put(User, "1", user1)
|
|
92
|
+
txn.put(User, "2", user2)
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
"""
|
|
96
|
+
return _TransactionWrapper(self._backend.transaction())
|
|
97
|
+
|
|
98
|
+
def transaction_dependency(self) -> Generator[Backend]:
|
|
99
|
+
"""
|
|
100
|
+
Fastapi dependency for request-scoped transactions.
|
|
101
|
+
|
|
102
|
+
use with Depends() for automatic transaction management.
|
|
103
|
+
|
|
104
|
+
Yields:
|
|
105
|
+
backend instance for crud operations
|
|
106
|
+
|
|
107
|
+
Example:
|
|
108
|
+
```python
|
|
109
|
+
@app.post("/users/{user_id}")
|
|
110
|
+
def create(
|
|
111
|
+
user_id: str,
|
|
112
|
+
user: User,
|
|
113
|
+
txn: Annotated[Backend, Depends(db.transaction_dependency)],
|
|
114
|
+
):
|
|
115
|
+
return txn.put(User, user_id, user)
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
"""
|
|
119
|
+
with self._backend.transaction() as txn:
|
|
120
|
+
yield txn
|
|
121
|
+
|
|
122
|
+
def get[T: BaseModel](
|
|
123
|
+
self, model_class: type[T], model_id: str
|
|
124
|
+
) -> Document[T] | None:
|
|
125
|
+
"""
|
|
126
|
+
Retrieve document by id, returns None if not found.
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
model_class: pydantic model class
|
|
130
|
+
model_id: document identifier
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
document if found, None otherwise
|
|
134
|
+
|
|
135
|
+
"""
|
|
136
|
+
with self.transaction() as txn:
|
|
137
|
+
return txn.get(model_class, model_id)
|
|
138
|
+
|
|
139
|
+
def put[T: BaseModel](
|
|
140
|
+
self, model_class: type[T], model_id: str, model: T
|
|
141
|
+
) -> Document[T]:
|
|
142
|
+
"""
|
|
143
|
+
Insert or update document.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
model_class: pydantic model class
|
|
147
|
+
model_id: document identifier
|
|
148
|
+
model: pydantic model instance
|
|
149
|
+
|
|
150
|
+
Returns:
|
|
151
|
+
persisted document
|
|
152
|
+
|
|
153
|
+
"""
|
|
154
|
+
with self.transaction() as txn:
|
|
155
|
+
return txn.put(model_class, model_id, model)
|
|
156
|
+
|
|
157
|
+
def fetch[T: BaseModel](self, model_class: type[T], model_id: str) -> Document[T]:
|
|
158
|
+
"""
|
|
159
|
+
Retrieve document by id, raises if not found.
|
|
160
|
+
|
|
161
|
+
Args:
|
|
162
|
+
model_class: pydantic model class
|
|
163
|
+
model_id: document identifier
|
|
164
|
+
|
|
165
|
+
Returns:
|
|
166
|
+
document
|
|
167
|
+
|
|
168
|
+
Raises:
|
|
169
|
+
NotFoundError: document does not exist
|
|
170
|
+
|
|
171
|
+
"""
|
|
172
|
+
with self.transaction() as txn:
|
|
173
|
+
return txn.fetch(model_class, model_id)
|
|
174
|
+
|
|
175
|
+
def delete(self, model_class: type[BaseModel], model_id: str) -> None:
|
|
176
|
+
"""
|
|
177
|
+
Delete document by id.
|
|
178
|
+
|
|
179
|
+
idempotent - no error if document doesn't exist.
|
|
180
|
+
|
|
181
|
+
Args:
|
|
182
|
+
model_class: pydantic model class
|
|
183
|
+
model_id: document identifier
|
|
184
|
+
|
|
185
|
+
"""
|
|
186
|
+
with self.transaction() as txn:
|
|
187
|
+
return txn.delete(model_class, model_id)
|
|
188
|
+
|
|
189
|
+
def all[T: BaseModel](self, model_class: type[T]) -> list[Document[T]]:
|
|
190
|
+
"""
|
|
191
|
+
Retrieve all documents of given model class.
|
|
192
|
+
|
|
193
|
+
Args:
|
|
194
|
+
model_class: pydantic model class
|
|
195
|
+
|
|
196
|
+
Returns:
|
|
197
|
+
list of documents (empty if none exist)
|
|
198
|
+
|
|
199
|
+
"""
|
|
200
|
+
with self.transaction() as txn:
|
|
201
|
+
return txn.all(model_class)
|
|
202
|
+
|
|
203
|
+
def get_many[T: BaseModel](
|
|
204
|
+
self, model_class: type[T], model_ids: list[str]
|
|
205
|
+
) -> list[Document[T] | None]:
|
|
206
|
+
"""
|
|
207
|
+
Retrieve multiple documents by ids, preserving order.
|
|
208
|
+
|
|
209
|
+
Args:
|
|
210
|
+
model_class: pydantic model class
|
|
211
|
+
model_ids: list of document identifiers
|
|
212
|
+
|
|
213
|
+
Returns:
|
|
214
|
+
list of documents (None for missing ids)
|
|
215
|
+
|
|
216
|
+
"""
|
|
217
|
+
with self.transaction() as txn:
|
|
218
|
+
return txn.get_many(model_class, model_ids)
|
|
219
|
+
|
|
220
|
+
def delete_many(self, model_class: type[BaseModel], model_ids: list[str]) -> None:
|
|
221
|
+
"""
|
|
222
|
+
Delete multiple documents by ids.
|
|
223
|
+
|
|
224
|
+
idempotent - no error if documents don't exist.
|
|
225
|
+
|
|
226
|
+
Args:
|
|
227
|
+
model_class: pydantic model class
|
|
228
|
+
model_ids: list of document identifiers
|
|
229
|
+
|
|
230
|
+
"""
|
|
231
|
+
with self.transaction() as txn:
|
|
232
|
+
return txn.delete_many(model_class, model_ids)
|
|
233
|
+
|
|
234
|
+
def put_many[T: BaseModel](
|
|
235
|
+
self, model_class: type[T], models: list[tuple[str, T]]
|
|
236
|
+
) -> list[Document[T]]:
|
|
237
|
+
"""
|
|
238
|
+
Insert or update multiple documents.
|
|
239
|
+
|
|
240
|
+
Args:
|
|
241
|
+
model_class: pydantic model class
|
|
242
|
+
models: list of (model_id, model) tuples
|
|
243
|
+
|
|
244
|
+
Returns:
|
|
245
|
+
list of persisted documents
|
|
246
|
+
|
|
247
|
+
"""
|
|
248
|
+
with self.transaction() as txn:
|
|
249
|
+
return txn.put_many(model_class, models)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
class _TestModel(BaseModel):
|
|
253
|
+
hello: str = "world"
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
@test
|
|
257
|
+
def test_basic() -> None:
|
|
258
|
+
db = Database()
|
|
259
|
+
hello = _TestModel()
|
|
260
|
+
with db.transaction() as txn:
|
|
261
|
+
pk = "hello"
|
|
262
|
+
record = txn.put(_TestModel, pk, hello)
|
|
263
|
+
expect(record.model_id).to_equal(pk)
|
|
264
|
+
maybe_record = txn.get(_TestModel, pk)
|
|
265
|
+
expect(maybe_record).to_be_truthy()
|
|
266
|
+
record = txn.fetch(_TestModel, pk)
|
|
267
|
+
txn.delete(_TestModel, pk)
|
|
268
|
+
expect(lambda: txn.fetch(_TestModel, pk)).to_raise(NotFoundError)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
@test
|
|
272
|
+
def test_all_method() -> None:
|
|
273
|
+
db = Database()
|
|
274
|
+
with db.transaction() as txn:
|
|
275
|
+
txn.put(_TestModel, "1", _TestModel(hello="one"))
|
|
276
|
+
txn.put(_TestModel, "2", _TestModel(hello="two"))
|
|
277
|
+
txn.put(_TestModel, "3", _TestModel(hello="three"))
|
|
278
|
+
|
|
279
|
+
all_hellos = txn.all(_TestModel)
|
|
280
|
+
expect(all_hellos).to_have_length(3)
|
|
281
|
+
expect({h.model_id for h in all_hellos}).to_equal({"1", "2", "3"})
|
|
282
|
+
expect({h.model.hello for h in all_hellos}).to_equal({"one", "two", "three"})
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
@test
|
|
286
|
+
def test_all_empty() -> None:
|
|
287
|
+
db = Database()
|
|
288
|
+
with db.transaction() as txn:
|
|
289
|
+
all_hellos = txn.all(_TestModel)
|
|
290
|
+
expect(all_hellos).to_equal([])
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
@test
|
|
294
|
+
def test_backend_all_method() -> None:
|
|
295
|
+
backend = SQLiteBackend()
|
|
296
|
+
db = Database(backend=backend)
|
|
297
|
+
|
|
298
|
+
with db.transaction() as txn:
|
|
299
|
+
txn.put(_TestModel, "a", _TestModel(hello="alpha"))
|
|
300
|
+
txn.put(_TestModel, "b", _TestModel(hello="beta"))
|
|
301
|
+
|
|
302
|
+
all_docs = txn.all(_TestModel)
|
|
303
|
+
expect(all_docs).to_have_length(2)
|
|
304
|
+
expect({d.model_id for d in all_docs}).to_equal({"a", "b"})
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
@test
|
|
308
|
+
def test_memory_backend() -> None:
|
|
309
|
+
backend = MemoryBackend()
|
|
310
|
+
db = Database(backend=backend)
|
|
311
|
+
|
|
312
|
+
with db.transaction() as txn:
|
|
313
|
+
txn.put(_TestModel, "test", _TestModel(hello="world"))
|
|
314
|
+
doc = txn.fetch(_TestModel, "test")
|
|
315
|
+
expect(doc.model.hello).to_equal("world")
|
|
316
|
+
|
|
317
|
+
txn.put(_TestModel, "1", _TestModel(hello="one"))
|
|
318
|
+
txn.put(_TestModel, "2", _TestModel(hello="two"))
|
|
319
|
+
|
|
320
|
+
all_docs = txn.all(_TestModel)
|
|
321
|
+
expect(all_docs).to_have_length(3)
|
|
322
|
+
expect({d.model_id for d in all_docs}).to_equal({"test", "1", "2"})
|
|
323
|
+
|
|
324
|
+
txn.delete(_TestModel, "test")
|
|
325
|
+
expect(txn.get(_TestModel, "test")).to_be_none()
|
|
326
|
+
|
|
327
|
+
expect(lambda: txn.fetch(_TestModel, "test")).to_raise(NotFoundError)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
with describe("sqlite"):
|
|
331
|
+
|
|
332
|
+
@test
|
|
333
|
+
def test_sqlite_backend_memory() -> None:
|
|
334
|
+
backend = SQLiteBackend()
|
|
335
|
+
db = Database(backend=backend)
|
|
336
|
+
with db.transaction() as txn:
|
|
337
|
+
txn.put(_TestModel, "test", _TestModel(hello="world"))
|
|
338
|
+
doc = txn.fetch(_TestModel, "test")
|
|
339
|
+
expect(doc.model.hello).to_equal("world")
|
|
340
|
+
|
|
341
|
+
@test
|
|
342
|
+
def test_sqlite_backend_file() -> None:
|
|
343
|
+
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
344
|
+
db_path = Path(tmp_dir) / "test.db"
|
|
345
|
+
settings = SaplingSettings(sqlite_path=str(db_path))
|
|
346
|
+
backend = SQLiteBackend(settings=settings)
|
|
347
|
+
db = Database(backend=backend)
|
|
348
|
+
|
|
349
|
+
with db.transaction() as txn:
|
|
350
|
+
txn.put(_TestModel, "persistent", _TestModel(hello="saved"))
|
|
351
|
+
|
|
352
|
+
settings2 = SaplingSettings(sqlite_path=str(db_path))
|
|
353
|
+
db2 = Database(backend=SQLiteBackend(settings=settings2))
|
|
354
|
+
with db2.transaction() as txn:
|
|
355
|
+
doc = txn.fetch(_TestModel, "persistent")
|
|
356
|
+
expect(doc.model.hello).to_equal("saved")
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
with describe("initialization"):
|
|
360
|
+
|
|
361
|
+
@test
|
|
362
|
+
def test_deferred_initialization() -> None:
|
|
363
|
+
backend = SQLiteBackend()
|
|
364
|
+
db = Database(backend=backend, initialize=False)
|
|
365
|
+
|
|
366
|
+
db.initialize()
|
|
367
|
+
|
|
368
|
+
with db.transaction() as txn:
|
|
369
|
+
txn.put(_TestModel, "test", _TestModel(hello="world"))
|
|
370
|
+
doc = txn.fetch(_TestModel, "test")
|
|
371
|
+
expect(doc.model.hello).to_equal("world")
|
|
372
|
+
|
|
373
|
+
@test
|
|
374
|
+
def test_idempotent_initialization() -> None:
|
|
375
|
+
backend = SQLiteBackend()
|
|
376
|
+
db = Database(backend=backend, initialize=False)
|
|
377
|
+
|
|
378
|
+
db.initialize()
|
|
379
|
+
db.initialize()
|
|
380
|
+
db.initialize()
|
|
381
|
+
|
|
382
|
+
with db.transaction() as txn:
|
|
383
|
+
txn.put(_TestModel, "test", _TestModel(hello="world"))
|
|
384
|
+
|
|
385
|
+
@test
|
|
386
|
+
def test_uninitialized_error() -> None:
|
|
387
|
+
backend = SQLiteBackend()
|
|
388
|
+
db = Database(backend=backend, initialize=False)
|
|
389
|
+
|
|
390
|
+
def try_uninitialized() -> None:
|
|
391
|
+
with db.transaction() as txn:
|
|
392
|
+
txn.put(_TestModel, "test", _TestModel(hello="world"))
|
|
393
|
+
|
|
394
|
+
expect(try_uninitialized).to_raise(ValueError, match="not initialized")
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, ConfigDict
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Document[T: BaseModel](BaseModel):
|
|
7
|
+
"""
|
|
8
|
+
container for persisted pydantic models.
|
|
9
|
+
|
|
10
|
+
documents wrap model data with persistence metadata, keeping pydantic
|
|
11
|
+
models pure (no database-specific fields).
|
|
12
|
+
|
|
13
|
+
Attributes:
|
|
14
|
+
model: the pydantic model instance
|
|
15
|
+
model_id: unique identifier
|
|
16
|
+
model_class: fully qualified model class name
|
|
17
|
+
|
|
18
|
+
Example:
|
|
19
|
+
```python
|
|
20
|
+
user = User(name="alice", email="alice@example.com")
|
|
21
|
+
doc = txn.put(User, "user_1", user)
|
|
22
|
+
reveal_type(doc) # Document[User]
|
|
23
|
+
print(document.model.name) # "alice"
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
model_config: ConfigDict = ConfigDict(frozen=True) # pyright: ignore[reportIncompatibleVariableOverride]
|
|
29
|
+
model: T
|
|
30
|
+
model_id: str
|
|
31
|
+
model_class: str
|
|
File without changes
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
from functools import cache
|
|
2
|
+
from typing import ClassVar, Literal
|
|
3
|
+
|
|
4
|
+
from pydantic import Field
|
|
5
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
6
|
+
|
|
7
|
+
type IsolationLevel = Literal["DEFERRED", "IMMEDIATE", "EXCLUSIVE"] | None
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class SaplingSettings(BaseSettings):
|
|
11
|
+
"""
|
|
12
|
+
sapling configuration settings.
|
|
13
|
+
|
|
14
|
+
all settings can be configured via environment variables with
|
|
15
|
+
the SAPLING_ prefix (e.g., SAPLING_SQLITE_PATH, SAPLING_SQLITE_TIMEOUT).
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
model_config: ClassVar[SettingsConfigDict] = SettingsConfigDict(
|
|
19
|
+
env_prefix="SAPLING_",
|
|
20
|
+
)
|
|
21
|
+
sqlite_path: str = Field(
|
|
22
|
+
default=":memory:",
|
|
23
|
+
description='database file path, or ":memory:" for in-memory',
|
|
24
|
+
)
|
|
25
|
+
sqlite_timeout: float = Field(
|
|
26
|
+
default=5.0,
|
|
27
|
+
description="seconds to wait before raising exception if database is locked",
|
|
28
|
+
)
|
|
29
|
+
sqlite_detect_types: int = Field(
|
|
30
|
+
default=0,
|
|
31
|
+
description="control type detection for non-native sqlite types",
|
|
32
|
+
)
|
|
33
|
+
sqlite_isolation_level: IsolationLevel = Field(
|
|
34
|
+
default="DEFERRED",
|
|
35
|
+
description="transaction isolation: DEFERRED, IMMEDIATE, EXCLUSIVE, or None",
|
|
36
|
+
)
|
|
37
|
+
sqlite_check_same_thread: bool = Field(
|
|
38
|
+
default=False,
|
|
39
|
+
description="if True, only creating thread may use connection",
|
|
40
|
+
)
|
|
41
|
+
sqlite_cached_statements: int = Field(
|
|
42
|
+
default=128,
|
|
43
|
+
description="number of statements to cache internally",
|
|
44
|
+
)
|
|
45
|
+
sqlite_uri: bool = Field(
|
|
46
|
+
default=False,
|
|
47
|
+
description="if True, interpret path as URI with query string",
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@cache
|
|
52
|
+
def get_sapling_settings() -> SaplingSettings:
|
|
53
|
+
return SaplingSettings()
|