Skip to content

Quickstart

This page takes you from zero to persisting objects. We start with no backend at all, then bind to SQLAlchemy.

Without a backend (NoOpMateria)

The default NoOpMateria is active automatically — define transmuters and use them like regular Pydantic models, no setup required. Great for tests and prototyping.

from arcanus.base import BaseTransmuter, Identity
from arcanus.association import Relation, RelationCollection, Relationship, Relationships
from pydantic import Field
from typing import Annotated, Optional

class Author(BaseTransmuter):
    id: Annotated[Optional[int], Identity] = Field(default=None, frozen=True)
    name: str

    books: RelationCollection["Book"] = Relationships()

class Book(BaseTransmuter):
    id: Annotated[Optional[int], Identity] = Field(default=None, frozen=True)
    title: str
    author_id: int | None = None

    author: Relation[Author] = Relationship()

author = Author(id=1, name="Isaac Asimov")
book = Book(id=1, title="Foundation", author=Relation(author))

print(book.author.value.name)   # Isaac Asimov
print(list(author.books))       # [Book(...)]

With SQLAlchemy

1. Define ORM models

Your SQLAlchemy models are plain, untouched SQLAlchemy:

from sqlalchemy import ForeignKey, Integer, String
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship

class Base(DeclarativeBase): ...

class AuthorModel(Base):
    __tablename__ = "authors"
    id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
    name: Mapped[str] = mapped_column(String(100))
    books: Mapped[list["BookModel"]] = relationship(back_populates="author")

class BookModel(Base):
    __tablename__ = "books"
    id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
    title: Mapped[str] = mapped_column(String(200))
    author_id: Mapped[int] = mapped_column(ForeignKey(AuthorModel.id))
    author: Mapped[AuthorModel] = relationship(back_populates="books")

2. Bind transmuters with bless()

from arcanus.base import BaseTransmuter, Identity
from arcanus.association import Relation, RelationCollection, Relationship, Relationships
from arcanus.materia.sqlalchemy import SqlalchemyMateria
from pydantic import Field
from typing import Annotated, Optional

materia = SqlalchemyMateria()

@materia.bless(AuthorModel)
class Author(BaseTransmuter):
    id: Annotated[Optional[int], Identity] = Field(default=None, frozen=True)
    name: str
    books: RelationCollection["Book"] = Relationships()

@materia.bless(BookModel)
class Book(BaseTransmuter):
    id: Annotated[Optional[int], Identity] = Field(default=None, frozen=True)
    title: str
    author_id: int | None = None
    author: Relation[Author] = Relationship()

3. Use the arcanus Session

Important

Use arcanus.materia.sqlalchemy.Session (not SQLAlchemy's native Session). The arcanus session automatically "blesses" ORM rows into transmuters as they come out of queries.

from sqlalchemy import create_engine
from arcanus.materia.sqlalchemy import Session

engine = create_engine("sqlite://")
Base.metadata.create_all(engine)

Create

Adding the book cascades to its author. After a flush, server-generated ids are synced back onto the transmuters automatically.

with Session(engine) as session:
    author = Author(name="Isaac Asimov")
    book = Book(title="Foundation", author=Relation(author))

    session.add(book)            # adding the book also adds its author
    session.flush()              # INSERT; server-generated ids synced to the transmuters
    session.commit()
    print(book.id)               # e.g. 1

Read

Every result is a transmuter, not a raw ORM row. Reads are plain SQLAlchemy — session.get and session.execute(select(...)) — and typed column references (Author["name"]) drop straight into a select.

from sqlalchemy import select

with Session(engine) as session:
    author = session.get_one(Author, 1)             # by primary key (raises if missing)

    author = session.execute(                        # by filter
        select(Author).filter_by(name="Isaac Asimov")
    ).scalar_one()

    authors = session.execute(                       # many, with filter + ordering + paging
        select(Author)
        .where(Author["name"].like("Isaac%"))
        .order_by(Author["name"].asc())
        .limit(10)
    ).scalars().all()

    books = session.execute(
        select(Book).where(Book["title"].like("Found%"))
    ).scalars().all()

Typed shortcuts

For common reads, Arcanus also adds typed helpers to the session — one, first, list, count, bulk, partitions — so the above can be one-liners. See Session Helpers.

Related rows load on access and keep their identity.

with Session(engine) as session:
    author = session.get_one(Author, 1)
    for book in author.books:                # loads on access
        assert book.author.value is author   # same instance — identity preserved

Update & delete

Mutating a transmuter syncs to its ORM row in place — no model_dump() round-trip.

with Session(engine) as session:
    book = session.get_one(Book, 1)
    book.title = "Foundation (Revised)"      # synced to the ORM row
    session.commit()

    session.delete(book)                     # follows your ORM cascade rules
    session.commit()

Partial models at the boundary

Generated .Create / .Update models validate incomplete payloads; shell() / absorb() move between a partial and a full transmuter.

Experimental — for API boundaries (e.g. FastAPI)

The .Create / .Update partials are experimental (the generated surface may change). They exist for request/response boundaries — validating untrusted, incomplete bodies in a FastAPI handler and the like. If you're just working with objects in memory, you don't need them: construct and mutate the transmuter directly.

with Session(engine) as session:
    author = Author.shell(Author.Create(name="New Author"))   # Create excludes the id
    session.add(author)
    session.flush()                                           # id synced back automatically

    author.absorb(Author.Update(name="Renamed"))              # applies only what's set
    session.commit()

Async

Swap Session for AsyncSession and await the I/O — the API is otherwise identical. Awaiting a relationship triggers its lazy load. See Lifecycle & Async.

from sqlalchemy.ext.asyncio import create_async_engine
from arcanus.materia.sqlalchemy import AsyncSession

async with AsyncSession(create_async_engine("sqlite+aiosqlite://")) as session:
    author = await session.get_one(Author, 1)
    books = await author.books          # awaits the lazy load → list[Book]

    session.add(Book(title="Async Book", author=Relation(author)))
    await session.commit()

Next

  • Usage — the full guide, grouped by materia: relationships, loading, querying, and partial schemas in depth.
  • The Materia System — what bless() does and the design philosophy.
  • API Reference.