Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,8 @@ indent-width = 4
target-version = "py312"

[tool.ruff.lint.per-file-ignores]
"tests/*.py" = ["ANN201", "PLR2004", "PT028"]
"src/sapling/backends/sqlite.py" = ["S608"]
"src/sapling/database.py" = ["PT028"]

[tool.ruff.lint.flake8-bugbear]
extend-immutable-calls = ["tryke.Depends"]
Expand Down
33 changes: 33 additions & 0 deletions src/sapling/backends/memory.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
from contextlib import contextmanager
from typing import TYPE_CHECKING, Self

from tryke_guard import __TRYKE_TESTING__

from sapling.backends.base import Backend
from sapling.document import Document
from sapling.errors import NotFoundError
Expand Down Expand Up @@ -103,3 +105,34 @@ def put_many[T: BaseModel](
self, model_class: type[T], models: list[tuple[str, T]]
) -> list[Document[T]]:
return [self.put(model_class, model_id, model) for model_id, model in models]


if __TRYKE_TESTING__:
from pydantic import BaseModel as _BaseModel
from tryke import describe, expect, test

with describe("memory backend"):

class _TestModel(_BaseModel):
hello: str = "world"

@test
def test_memory_backend() -> None:
backend = MemoryBackend()
backend.initialize()
with backend.transaction() as txn:
txn.put(_TestModel, "test", _TestModel(hello="world"))
doc = txn.fetch(_TestModel, "test")
expect(doc.model.hello).to_equal("world")

txn.put(_TestModel, "1", _TestModel(hello="one"))
txn.put(_TestModel, "2", _TestModel(hello="two"))

all_docs = txn.all(_TestModel)
expect(all_docs).to_have_length(3)
expect({d.model_id for d in all_docs}).to_equal({"test", "1", "2"})

txn.delete(_TestModel, "test")
expect(txn.get(_TestModel, "test")).to_be_none()

expect(lambda: txn.fetch(_TestModel, "test")).to_raise(NotFoundError)
50 changes: 50 additions & 0 deletions src/sapling/backends/sqlite.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from typing import TYPE_CHECKING, Any, Self, override

from pydantic import BaseModel
from tryke_guard import __TRYKE_TESTING__

from sapling.backends.base import Backend
from sapling.document import Document
Expand Down Expand Up @@ -280,3 +281,52 @@ def put_many[T: BaseModel](
return [
self._row_to_document(model_class, cursor, row) for row in cursor.fetchall()
]


if __TRYKE_TESTING__:
import tempfile
from pathlib import Path

from tryke import describe, expect, test

with describe("sqlite backend"):

class _TestModel(BaseModel):
hello: str = "world"

@test
def test_sqlite_backend_memory() -> None:
backend = SQLiteBackend()
backend.initialize()
with backend.transaction() as txn:
txn.put(_TestModel, "test", _TestModel(hello="world"))
doc = txn.fetch(_TestModel, "test")
expect(doc.model.hello).to_equal("world")

@test
def test_sqlite_backend_file() -> None:
with tempfile.TemporaryDirectory() as tmp_dir:
db_path = Path(tmp_dir) / "test.db"
settings = SaplingSettings(sqlite_path=str(db_path))
backend = SQLiteBackend(settings=settings)
backend.initialize()
with backend.transaction() as txn:
txn.put(_TestModel, "persistent", _TestModel(hello="saved"))

settings2 = SaplingSettings(sqlite_path=str(db_path))
backend2 = SQLiteBackend(settings=settings2)
backend2.initialize()
with backend2.transaction() as txn:
doc = txn.fetch(_TestModel, "persistent")
expect(doc.model.hello).to_equal("saved")

@test
def test_backend_all_method() -> None:
backend = SQLiteBackend()
backend.initialize()
with backend.transaction() as txn:
txn.put(_TestModel, "a", _TestModel(hello="alpha"))
txn.put(_TestModel, "b", _TestModel(hello="beta"))
all_docs = txn.all(_TestModel)
expect(all_docs).to_have_length(2)
expect({d.model_id for d in all_docs}).to_equal({"a", "b"})
178 changes: 166 additions & 12 deletions src/sapling/database.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,13 @@
from __future__ import annotations

from typing import TYPE_CHECKING
from collections.abc import Generator, Iterator
from contextlib import AbstractContextManager
from types import TracebackType

from pydantic import BaseModel
from tryke_guard import __TRYKE_TESTING__

from sapling import SQLiteBackend

if TYPE_CHECKING:
from collections.abc import Generator, Iterator
from contextlib import AbstractContextManager
from types import TracebackType

from sapling.backends.base import Backend
from sapling.document import Document
from sapling.backends.base import Backend
from sapling.backends.sqlite import SQLiteBackend
from sapling.document import Document


class _TransactionWrapper:
Expand Down Expand Up @@ -242,3 +237,162 @@ def put_many[T: BaseModel](
"""
with self.transaction() as txn:
return txn.put_many(model_class, models)


if __TRYKE_TESTING__:
from contextlib import contextmanager
from typing import Annotated

from fastapi import Depends as FastAPIDepends
from fastapi import FastAPI, status
from fastapi.testclient import TestClient
from tryke import Depends, describe, expect, fixture, test

from sapling.errors import NotFoundError

with describe("database"):

class _TestModel(BaseModel):
hello: str = "world"

@fixture
def database() -> Database:
return Database()

@fixture
def transaction(db: Database = Depends(database)) -> Generator[Backend]:
with db.transaction() as txn:
yield txn

@test
def test_basic(txn: Backend = Depends(transaction)) -> None:
hello = _TestModel()
pk = "hello"
record = txn.put(_TestModel, pk, hello)
expect(record.model_id).to_equal(pk)
maybe_record = txn.get(_TestModel, pk)
expect(maybe_record).to_be_truthy()
_record = txn.fetch(_TestModel, pk)
txn.delete(_TestModel, pk)
expect(lambda: txn.fetch(_TestModel, pk)).to_raise(NotFoundError)

@test
def test_all_method(txn: Backend = Depends(transaction)) -> None:
txn.put(_TestModel, "1", _TestModel(hello="one"))
txn.put(_TestModel, "2", _TestModel(hello="two"))
txn.put(_TestModel, "3", _TestModel(hello="three"))
all_hellos = txn.all(_TestModel)
expect(all_hellos).to_have_length(3)
expect({h.model_id for h in all_hellos}).to_equal({"1", "2", "3"})
expect({h.model.hello for h in all_hellos}).to_equal(
{"one", "two", "three"}
)

@test
def test_all_empty(txn: Backend = Depends(transaction)) -> None:
all_hellos = txn.all(_TestModel)
expect(all_hellos).to_equal([])

with describe("initialization"):

@test
def test_deferred_initialization() -> None:
db = Database(backend=SQLiteBackend(), initialize=False)
db.initialize()
with db.transaction() as txn:
txn.put(_TestModel, "test", _TestModel(hello="world"))
doc = txn.fetch(_TestModel, "test")
expect(doc.model.hello).to_equal("world")

@test
def test_idempotent_initialization() -> None:
db = Database(backend=SQLiteBackend(), initialize=False)
db.initialize()
db.initialize()
db.initialize()
with db.transaction() as txn:
txn.put(_TestModel, "test", _TestModel(hello="world"))

@test
def test_uninitialized_error() -> None:
db = Database(backend=SQLiteBackend(), initialize=False)

def try_uninitialized() -> None:
with db.transaction() as txn:
txn.put(_TestModel, "test", _TestModel(hello="world"))

expect(try_uninitialized).to_raise(ValueError, match="not initialized")

with describe("fastapi"):

class User(BaseModel):
name: str
email: str

@contextmanager
def _client() -> Generator[TestClient]:
app = FastAPI(debug=True)
db = Database()

@app.post("/users/{user_id}")
def create_user(
user_id: str,
user: User,
txn: Annotated[Backend, FastAPIDepends(db.transaction_dependency)],
) -> dict:
doc = txn.put(User, user_id, user)
return {"id": doc.model_id, "user": doc.model.model_dump()}

@app.get("/users/{user_id}")
def get_user(
user_id: str,
txn: Annotated[Backend, FastAPIDepends(db.transaction_dependency)],
) -> dict:
doc = txn.fetch(User, user_id)
return doc.model.model_dump()

@app.delete("/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_user(
user_id: str,
txn: Annotated[Backend, FastAPIDepends(db.transaction_dependency)],
) -> None:
txn.delete(User, user_id)

@app.get("/users/{user_id}/error")
def get_user_with_error(
user_id: str,
txn: Annotated[Backend, FastAPIDepends(db.transaction_dependency)],
) -> dict:
txn.put(User, user_id, User(name="test", email="test@example.com"))
raise ValueError

with TestClient(app, raise_server_exceptions=False) as client:
yield client

@test
def test_create_user() -> None:
with _client() as client:
response = client.post(
"/users/user1",
json={"name": "alice", "email": "alice@example.com"},
)
expect(response.status_code).to_equal(status.HTTP_200_OK)
data = response.json()
expect(data["id"]).to_equal("user1")
expect(data["user"]["name"]).to_equal("alice")

@test
def test_get_nonexistent_user_raises_not_found() -> None:
with _client() as client:
response = client.get("/users/nonexistent")
expect(response.status_code).to_equal(
status.HTTP_500_INTERNAL_SERVER_ERROR
)

@test
def test_error_in_route_returns_500() -> None:
with _client() as client:
response = client.get("/users/user3/error")
expect(response.status_code).to_equal(
status.HTTP_500_INTERNAL_SERVER_ERROR
)
Empty file removed tests/__init__.py
Empty file.
Loading
Loading