Service Layer¶
SQLSpecSyncService and SQLSpecAsyncService (sqlspec.service) wrap an adapter configuration (or an already-checked-out driver session) with ergonomic helpers for single-row lookups (get_one), existence checks (exists), offset and cursor pagination (paginate, paginate_limit_offset, paginate_cursor), and atomic write blocks (begin_transaction).
Full Sync and Async Service Example¶
When constructed with config=, a service checks out a short-lived pooled session automatically for standalone read helpers (service.get_one(...), service.exists(...), service.paginate(...)) and opens an explicit commit-or-rollback session inside with service.begin_transaction() as session::
"""Use SQLSpecSyncService and SQLSpecAsyncService for short helper sessions and explicit transactions."""
import asyncio
import tempfile
from pathlib import Path
from typing import NoReturn
from sqlspec import SQLFileLoader, sql
from sqlspec.adapters.aiosqlite import AiosqliteConfig
from sqlspec.adapters.sqlite import SqliteConfig
from sqlspec.service import SQLSpecAsyncService, SQLSpecSyncService
__all__ = ("main", "run_async_service_example", "run_sync_service_example")
def run_sync_service_example(db_path: Path) -> int:
"""Execute synchronous service helpers and atomic transaction blocks."""
config = SqliteConfig(connection_config={"database": str(db_path)})
service = SQLSpecSyncService(config=config, loader=SQLFileLoader())
users = sql.select("name").from_("users")
try:
with service.begin_transaction() as session:
session.execute("CREATE TABLE users (name TEXT)")
session.execute("INSERT INTO users VALUES (:name)", name="Ada")
assert service.get_one(users) == {"name": "Ada"}
assert service.exists(users)
assert service.paginate(users).total == 1
with service.provide_session() as session:
assert service.exists(users, session=session)
assert service.get_one(users, session=session) == {"name": "Ada"}
with service.begin_transaction() as session:
session.execute("INSERT INTO users VALUES (:name)", name="Grace")
assert service.exists(sql.select("name").from_("users").where_eq("name", "Grace"))
assert service.paginate(users).total == 2
try:
with service.begin_transaction() as session:
session.execute("INSERT INTO users VALUES (:name)", name="Rolled back")
_abort_transaction()
except ValueError:
pass
total = service.paginate(users).total
assert total == 2
return total
finally:
config.close_pool()
async def run_async_service_example(db_path: Path) -> int:
"""Execute asynchronous service helpers and atomic transaction blocks."""
config = AiosqliteConfig(connection_config={"database": str(db_path)})
service = SQLSpecAsyncService(config=config, loader=SQLFileLoader())
users = sql.select("name").from_("users")
try:
async with service.begin_transaction() as session:
await session.execute("CREATE TABLE users (name TEXT)")
await session.execute("INSERT INTO users VALUES (:name)", name="Ada")
assert await service.get_one(users) == {"name": "Ada"}
assert await service.exists(users)
assert (await service.paginate(users)).total == 1
async with service.provide_session() as session:
assert await service.exists(users, session=session)
assert await service.get_one(users, session=session) == {"name": "Ada"}
async with service.begin_transaction() as session:
await session.execute("INSERT INTO users VALUES (:name)", name="Grace")
assert await service.exists(sql.select("name").from_("users").where_eq("name", "Grace"))
assert (await service.paginate(users)).total == 2
try:
async with service.begin_transaction() as session:
await session.execute("INSERT INTO users VALUES (:name)", name="Rolled back")
_abort_transaction()
except ValueError:
pass
total = (await service.paginate(users)).total
assert total == 2
return total
finally:
await config.close_pool()
async def main() -> tuple[int, int]:
"""Run both synchronous and asynchronous service examples in a temporary directory."""
with tempfile.TemporaryDirectory() as tmp_dir:
base_dir = Path(tmp_dir)
sync_total = run_sync_service_example(base_dir / "sync_users.sqlite")
async_total = await run_async_service_example(base_dir / "async_users.sqlite")
return sync_total, async_total
def _abort_transaction() -> NoReturn:
"""Raise an error inside a transaction block to demonstrate automatic rollback."""
msg = "Cancel this change"
raise ValueError(msg)
if __name__ == "__main__":
print(asyncio.run(main()))
Core Service Methods¶
| Method | Purpose |
|---|---|
get_one(statement, *filters, session=None, **params) |
Fetch a single row (or typed schema_type instance), raising NotFoundError when absent. |
exists(statement, *filters, session=None, **params) |
Return bool indicating whether at least one row matches the query. |
paginate(statement, *filters, session=None, **params) |
Dispatch automatically to limit/offset or cursor pagination based on attached filters. |
paginate_limit_offset(statement, filter, ...) |
Return an OffsetPagination envelope (items, limit, offset, total). |
paginate_cursor(statement, filter, ...) |
Return a CursorPagination envelope (items, next_cursor, previous_cursor). |
provide_session() |
Context manager yielding a pooled read/write driver session. |
begin_transaction() |
Context manager yielding a session inside an active transaction that commits on exit and rolls back on exception. |
Structuring Domain Services¶
Subclass or compose SQLSpecAsyncService (or SQLSpecSyncService) around your domain's .sql file loader so route handlers express domain operations rather than SQL mechanics:
import msgspec
from sqlspec import SQLFileLoader, sql
from sqlspec.adapters.aiosqlite import AiosqliteConfig
from sqlspec.core import LimitOffsetFilter, OffsetPagination
from sqlspec.service import SQLSpecAsyncService
class Customer(msgspec.Struct, frozen=True, gc=False):
id: int
email: str
tier: str
class CustomerService:
def __init__(self, config: AiosqliteConfig, loader: SQLFileLoader) -> None:
self._db = SQLSpecAsyncService(config=config, loader=loader)
async def get_by_email(self, email: str) -> Customer:
query = sql.select("id", "email", "tier").from_("customers").where_eq("email", email)
row = await self._db.get_one(query)
return msgspec.convert(row, type=Customer)
async def list_customers(self, *, limit: int = 25, offset: int = 0) -> OffsetPagination[dict[str, object]]:
query = sql.select("id", "email", "tier").from_("customers").order_by("id")
return await self._db.paginate_limit_offset(query, LimitOffsetFilter(limit=limit, offset=offset))
async def upgrade_tier(self, customer_id: int, new_tier: str) -> None:
async with self._db.begin_transaction() as session:
await session.execute("UPDATE customers SET tier = :tier WHERE id = :id", tier=new_tier, id=customer_id)
await session.execute(
"INSERT INTO tier_audit (customer_id, new_tier) VALUES (:id, :tier)", id=customer_id, tier=new_tier
)
Sharing an Existing Session Across Multiple Services¶
Pass session=session to get_one, exists, or paginate (or construct SQLSpecAsyncService(session)) when composing multiple domain services inside a single outer begin_transaction() block so every read and write participates in the same atomic transaction:
from sqlspec import SQLSpec
from sqlspec.adapters.sqlite import SqliteConfig
from sqlspec.service import SQLSpecSyncService
spec = SQLSpec()
config = spec.add_config(SqliteConfig(connection_config={"database": ":memory:"}))
orders_svc = SQLSpecSyncService(config=config)
ledger_svc = SQLSpecSyncService(config=config)
with orders_svc.begin_transaction() as session:
session.execute("CREATE TABLE orders (id INTEGER PRIMARY KEY, total INTEGER)")
session.execute("CREATE TABLE ledger (id INTEGER PRIMARY KEY, order_id INTEGER, amount INTEGER)")
session.execute("INSERT INTO orders (id, total) VALUES (:id, :total)", id=101, total=2500)
order = orders_svc.get_one("SELECT id, total FROM orders WHERE id = :id", session=session, id=101)
session.execute(
"INSERT INTO ledger (order_id, amount) VALUES (:order_id, :amount)", order_id=order["id"], amount=order["total"]
)
assert ledger_svc.exists("SELECT 1 FROM ledger WHERE order_id = :id", session=session, id=101)
Recap¶
- Construct
SQLSpecSyncService(config=...)orSQLSpecAsyncService(config=...)to eliminate repetitivewith config.provide_session()boilerplate on single-statement reads. - Group multi-statement domain mutations inside
service.begin_transaction()for automatic commit on success and rollback on failure. - Pass
session=sessionwhen coordinating multiple domain services within one shared transaction.