sandloom.sdk.data¶
Tables, sessions and reads: Base, TenantScoped, new_id,
CreatedAt / UpdatedAt, db(), RLS
helpers, the read planner.
Public API 1.0 (spec §2.2, Appendix C).
from sandloom.sdk.data import <name>. Signatures in a terminal: sandloom docs <name>. Beginner names are the ones generated code and the examples use; any other name is advanced (mark its import line # sandloom: advanced <reason> in generated code and examples).
Start with (beginner)¶
| Name | Kind | Summary |
|---|---|---|
Base |
class | Base class for all SQLAlchemy models. |
CreatedAt |
annotated type | |
db |
function | The session of the innermost open unit of the current task (§4.3): |
new_id |
function | A new time-ordered id (UUIDv7, RFC 9562) for a primary key: |
TenantScoped |
class | Mixin marking a model as tenant-owned (adds the indexed tenant_id). |
Advanced¶
| Name | Kind | Summary |
|---|---|---|
disable_tenant_rls |
function | Drop the Sandloom tenant policies and disable RLS on table (for downgrade). |
enable_tenant_rls |
function | Enable + FORCE PostgreSQL RLS with the Sandloom tenant policy on table. |
get_planned_reads |
function | The handle of a route DECLARED read-only (execution planner). |
get_read_only_db |
function | PROTOTYPE: request session for a route DECLARED read-only. |
get_snapshot_reads |
function | PlannedReads with ONE snapshot: REPEATABLE READ READ ONLY. |
get_uow_reads |
async function | Planned reads INSIDE the request Unit of Work (mixed routes). |
InvalidCursor |
exception | A pagination cursor that is malformed, tampered with or foreign (400). |
JoinRead |
class | A declared single-row read of entity joined to join on a FK. |
ListOperation |
class | A declared list read: entity rows, one keyset page per call. |
make_cursor |
function | A cursor starting after position (the operation's order values, in order_by + primary-key order) for these filter arguments and the b... |
make_db_dependency |
callable | Build a request dependency yielding a session for a NAMED database. |
MoneyLedger |
class | Model mixin of a money ledger: names its unique operation-key column. |
open_uow |
function | Open a unit of work on database (today db_session, §4.3): |
Page |
class | One page of a ListOperation: the DTOs and the next cursor. |
PageLimitError |
exception | limit missing, < 1 or above the operation's page cap (400). |
PlannedReads |
annotated type | |
ReadOperation |
class | A declared single-row read: entity WHERE by columns = arguments. |
Reads |
class | Handle that runs declared operations. |
session_reads |
function | Run declared operations ON session (an open Unit of Work). |
SnapshotReads |
annotated type | |
UowReads |
annotated type | |
UpdatedAt |
annotated type |
Beginner names¶
Base¶
class Base(DeclarativeBase)(**kwargs: Any) -> None
Base class for all SQLAlchemy models.
CreatedAt¶
CreatedAt = Annotated[datetime.datetime, <sqlalchemy.orm.properties.MappedColumn object>]
(no docstring)
db¶
def db() -> AsyncSession
The session of the innermost open unit of the current task (§4.3):
item = Item(title=body.title)
db().add(item)
await db().flush() # never commit(): the unit owns the transaction
Outside any unit it raises UnitOfWorkRequiredError (SANDLOOM-R002); in a
request the first call opens the lazy request unit, committed when the
handler succeeds and rolled back on an exception. A child task opens its
own unit with open_uow (SANDLOOM-R013 otherwise).
new_id¶
def new_id() -> uuid.UUID
A new time-ordered id (UUIDv7, RFC 9562) for a primary key:
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=new_id)
Ids sort in creation order: by time across processes, and strictly
increasing within one process (a counter orders a millisecond's burst).
So order_by(Model.id) is the creation order, with no tie.
TenantScoped¶
class TenantScoped
Mixin marking a model as tenant-owned (adds the indexed tenant_id).
Inheriting this both declares the column and opts the model into automatic
tenant filtering. Declare models as class Foo(TenantScoped, Base).
Advanced names¶
disable_tenant_rls¶
def disable_tenant_rls(op: _Executes, table: str, *, schema: str | None = None) -> None
Drop the Sandloom tenant policies and disable RLS on
table (for downgrade).
enable_tenant_rls¶
def enable_tenant_rls(
op: _Executes,
table: str,
*,
column: str = 'tenant_id',
schema: str | None = None,
system_access: bool = False,
system_role: str = 'sandloom_system',
) -> None
Enable + FORCE PostgreSQL RLS with the Sandloom tenant policy on table.
op is the Alembic op module (or any object with execute(str)).
column must be the table's uuid tenant column (TenantScoped
declares tenant_id). system_access=True lets the system-channel
role (system_role, default sandloom_system; it must already exist) read
and write every tenant's rows of THIS table. Re-running is safe (policies
are replaced).
get_planned_reads¶
def get_planned_reads(request: Request) -> AsyncGenerator[Any, None]
The handle of a route DECLARED read-only (execution planner).
The whole handler runs inside a read-only scope: publish_event /
record_audit / on_commit raise. Declared operations run through
reads.one(op, **key) on the planner's general or optimized executor.
get_read_only_db¶
def get_read_only_db(request: Request) -> AsyncGenerator[AsyncSession, None]
PROTOTYPE: request session for a route DECLARED read-only.
With DB_READ_ONLY_SESSIONS=true statements run in a server-enforced
READ ONLY transaction. The client policy also refuses writes, flushes,
locking reads and raw statements before sending them. PostgreSQL remains
the backstop for SELECT functions with side effects. Off (default):
exactly get_db.
get_snapshot_reads¶
def get_snapshot_reads(request: Request) -> AsyncGenerator[Any, None]
PlannedReads with ONE snapshot: REPEATABLE READ READ ONLY.
Every declared read of the route runs in the route's read unit, in one
REPEATABLE READ read-only transaction (the default PlannedReads is
READ COMMITTED: two reads may see different snapshots). Use it when the
route's reads must be mutually consistent.
get_uow_reads¶
async def get_uow_reads(request: Request, db: DbSession) -> Any
Planned reads INSIDE the request Unit of Work (mixed routes).
Depends on DbSession (the SAME session the route gets: FastAPI caches
the dependency per request), so declared operations run on the UoW's
connection and transaction with the general executor: one connection,
one tenant GUC, read-your-own-writes, and the UoW's outbox / audit /
on_commit semantics unchanged (no read-only scope is opened).
InvalidCursor¶
class InvalidCursor(ValueError)(detail: str = 'invalid pagination cursor') -> None
A pagination cursor that is malformed, tampered with or foreign (400).
JoinRead¶
class JoinRead(ReadOperation)(
entity: type,
result: type[R],
by: tuple[str, ...] = ('id',),
tenant_scoped: bool | None = None,
database: str = 'default',
name: str = '',
lock: str | None = None,
join: type | None = None,
via: str = '',
join_by: Mapping[str, str] = <factory>,
join_where: Mapping[str, Any] = <factory>,
joined_fields: Mapping[str, str] = <factory>,
) -> None
A declared single-row read of entity joined to join on a FK.
entity JOIN join ON entity.<via> = join's primary key, with
by— equality key columns ofentity(call arguments); they must coverentity's primary key or a unique constraint, so the read returns at most one row on EVERY executor.viacounts towards that key when the join side is pinned to at most one row (join_by+join_wherecover a unique key ofjoin): a read keyed only on the joined row (by=()) is allowed whenviais itself unique onentity(e.g. a one-to-one detail row of a customer found by its unique tenant id);join_by—{argument: column of join}: more equality arguments;join_where—{column of join: constant}: fixed equality filters (e.g.{"is_active": True}), sent as parameters;joined_fields—{DTO field: attribute of join}; every other DTO field is read fromentity.
via must be a column of entity with a declared ForeignKey to
join (no guessed join conditions). Tenant scoping is inferred PER
SIDE: every TenantScoped table of the join gets the tenant predicate
(and RLS). Both entities must belong to the declaring module (the import
boundary rules already forbid reaching another module's models).
ListOperation¶
class ListOperation(ReadOperation)(
entity: type,
result: type[R],
by: tuple[str, ...] = (),
tenant_scoped: bool | None = None,
database: str = 'default',
name: str = '',
lock: str | None = None,
where: Mapping[str, Any] = <factory>,
order_by: tuple[str, ...] = ('id',),
descending: bool = False,
max_limit: int | None = None,
require_index: bool = True,
join: type | None = None,
via: str = '',
join_by: Mapping[str, str] = <factory>,
join_where: Mapping[str, Any] = <factory>,
joined_fields: Mapping[str, str] = <factory>,
optional: Mapping[str, str] = <factory>,
prefix: Mapping[str, str] = <factory>,
prefix_ignore_case: bool = False,
) -> None
A declared list read: entity rows, one keyset page per call.
by— equality filter columns ofentity(call arguments; may be empty for a tenant-scoped "all rows of my tenant" list);where—{column of entity: constant}fixed filters;order_by— columns ofentity, each part of an index of its table; the primary key is appended as the tie-breaker (a total order, so keyset pages never skip or repeat a row);descendingflips the whole order;max_limit— the declaration's page cap (never abovePLANNER_LIST_MAX_LIMIT); every call passeslimit(mandatory);- optional
joinon a DECLARED foreign key, in either direction:viaonentity->join's primary key (many-to-one), orviaonjoin->entity's primary key (one-to-many:via+join_by+join_wheremust cover a unique key ofjoin, so the join never multiplies rows);join_by/join_where/joined_fieldsas forJoinRead; optional—{argument: column of entity}equality filters applied only when the call's argument is notNone(None= no filter, neverIS NULL);prefix—{argument: String column of entity}:column LIKE <argument> || '%'with the argument matched as a LITERAL prefix (its%/_/\are escaped), applied only when the argument is notNone;prefix_ignore_case=Truematches case-insensitively (ILIKE).
Optional and prefix filters are residual predicates: the covering-index
rule is about by + where (+ tenant) and the order only — a page
walks that index in order and skips non-matching rows, it never sorts.
Each combination of present filters is its own statement (at most
MAX_OPTIONAL_FILTERS optional + prefix filters per operation).
Pages are addressed by an opaque, tamper-evident cursor (HMAC-SHA256 over the operation, the filter arguments, the bound tenant and the position): the client cannot move it to another filter, tenant or position. The tenant is never read from it. Tenant scoping is inferred per side.
ListOperation.n_required¶
n_required (property)
The number of leading keys that are always applied.
ListOperation.page_cap¶
def page_cap(self) -> int
The effective page cap: the declaration's, never above the framework's.
make_cursor¶
def make_cursor(op: ListOperation[Any], position: Sequence[Any], /, **key: Any) -> str
A cursor starting after position (the operation's order values, in
order_by + primary-key order) for these filter arguments and the bound
tenant (e.g. to resume a list at a known row).
make_db_dependency¶
def make_db_dependency(
name: str,
) -> Callable[[Request], AsyncGenerator[AsyncSession, None]]
Build a request dependency yielding a session for a NAMED database.
Use this when a module's endpoints must read/write a non-default database
(e.g. "analytics"). The module references only the logical name; the
concrete engine/driver is resolved by the infrastructure registry. Events
published while this session is the active UoW go to that database's
transactional outbox.
DbSession remains the canonical dependency for the default database.
MoneyLedger¶
class MoneyLedger
Model mixin of a money ledger: names its unique operation-key column.
open_uow¶
def open_uow(database: str | None = None) -> AbstractAsyncContextManager[AsyncSession]
Open a unit of work on database (today db_session, §4.3):
async with open_uow() as session:
session.add(row) # committed when the block exits
One transaction, committed on success and rolled back on an exception, with the transactional outbox for the events it publishes.
Page¶
class Page(Generic)(items: list[R], next_cursor: str | None) -> None
One page of a ListOperation: the DTOs and the next cursor.
PageLimitError¶
class PageLimitError(ValueError)(detail: str) -> None
limit missing, < 1 or above the operation's page cap (400).
PlannedReads¶
PlannedReads = Annotated[Any, Depends(dependency=<function get_planned_reads>, use_cache=True, scope='function')]
(no docstring)
ReadOperation¶
class ReadOperation(Generic)(
entity: type,
result: type[R],
by: tuple[str, ...] = ('id',),
tenant_scoped: bool | None = None,
database: str = 'default',
name: str = '',
lock: str | None = None,
) -> None
A declared single-row read: entity WHERE by columns = arguments.
result is part of the contract: the caller always gets an instance of
it (or None), whichever executor ran. tenant_scoped is inferred from
the entity; declaring it explicitly is checked against the entity (an
operation can never opt OUT of tenant scoping of a tenant-owned entity).
Identity-hashed: one object = one declared operation.
Reads¶
class Reads(
core: CoreRuntime | None,
unit: Any = None,
*,
adapter: ExecutionAdapter | None = None,
shared: bool = False,
contract: bool = False,
isolation: Isolation = 'read_committed',
) -> None
Handle that runs declared operations.
Two modes, fixed when the handle is made:
- read-only scope (
unit is None;sandloom.core.di.PlannedReads,read_scope): consecutive OPTIMIZED reads share a lazy read-only transaction per database. A GENERAL read releases that transaction and opens an ORM read unit; subsequent reads use that unit. Stronger isolation always uses one ORM transaction. A scope never holds two connections of one pool. A handle made without a scope (shared=False,execute_one) runs every operation in its own transaction; - inside a Unit of Work (
unit= the UoW's session;sandloom.core.di.UowReads,sandloom.core.orm.session_reads): every operation runs ON that session — the UoW's connection, transaction and tenant GUC — with the GENERAL executor (ORM: autoflush + identity map, so the read sees the unit's own writes, flushed or not). The optimized executor is never used there (see the design doc, "mixed operations").
Reads.in_unit_of_work¶
in_unit_of_work (property)
Reads.one¶
async def one(self, op: ReadOperation[R], /, **key: Any) -> R | None
Run op for key (the hot path: one adapter lookup, no wrappers).
Reads.page¶
async def page(
self,
op: ListOperation[R],
/,
*,
limit: int,
cursor: str | None = None,
**key: Any,
) -> Page[R]
One keyset page of op for the filter arguments key.
limit is mandatory and capped (ListOperation.page_cap,
PageLimitError); cursor is the previous page's
next_cursor and is verified BEFORE any I/O (InvalidCursor).
Reads.aclose¶
async def aclose(self) -> None
End the scope's read units (the scope owner calls it, once).
session_reads¶
def session_reads(session: Any) -> sandloom.core.planner.Reads
Run declared operations ON session (an open Unit of Work).
Inside a DbSession route, db_session() or contract_session():
the reads share the unit's transaction, connection and tenant context
(read-your-own-writes; the general executor). Routes use
sandloom.core.di.UowReads instead.
SnapshotReads¶
SnapshotReads = Annotated[Any, Depends(dependency=<function get_snapshot_reads>, use_cache=True, scope='function')]
(no docstring)
UowReads¶
UowReads = Annotated[Any, Depends(dependency=<function get_uow_reads>, use_cache=True, scope='function')]
(no docstring)
UpdatedAt¶
UpdatedAt = Annotated[datetime.datetime, <sqlalchemy.orm.properties.MappedColumn object>]
(no docstring)