Skip to content

CRUD & Relationships

This page uses the SQLAlchemy materia and the Author / Book / Category models from the setup. It expands the backend-free relationships and partial schemas with persistence. Every object that goes in or comes out of the session is a transmuter — a validated Pydantic object wrapping its ORM row — so you read and write the same object you query.

Lifecycle follows SQLAlchemy

Under the SQLAlchemy materia, a transmuter's lifecycle is its ORM row's lifecycle: it becomes pending on add, persistent on flush/commit, and is detached/expired exactly when the session says so. There is no separate "save the Pydantic object" step. See object lifecycle.

Create

Construct a transmuter, add it, and flush to hit the database. Server-generated values (such as an autoincrement id) are written to the row at flush time and synced back onto the transmuter automatically — read them straight after the flush, no manual step.

with Session(engine) as session:
    author = Author(name="Isaac Asimov")
    session.add(author)
    session.flush()             # INSERT; id assigned on the row and synced to `author`
    print(author.id)            # e.g. 1
    session.commit()

What syncs automatically — and what still needs refresh()

An after_flush hook re-validates each freshly inserted transmuter against its row, so the autoincrement id (and any value the INSERT returns) appears without a manual step — including the implicit flush inside commit(). Updates are not re-validated: values you set are already on the transmuter via write-through, so only an insert's server-assigned values need syncing. Loaded relationships are re-deferred by the insert re-validation but reload without a query, since the row stays loaded.

The autoincrement key arrives via cursor.lastrowid on every backend. Other server-side columns (a server_default, a server onupdate) only ride along when the dialect fetches them at flush — via RETURNING (PostgreSQL/SQLite) or eager_defaults. For a server-side onupdate, or any value the database changed after the row was expired, fall back to session.refresh(author). author.revalidate() is the same full re-validation done by hand.

Read

Reads are plain SQLAlchemy — session.get for primary keys, session.execute(select(...)) for everything else — only the results come back as transmuters. Typed column references (Author["name"]) drop straight into a select:

from sqlalchemy import func, select

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

    # Single row by filter
    author = session.execute(
        select(Author).filter_by(name="Isaac Asimov")
    ).scalar_one()
    maybe = session.execute(
        select(Author).filter_by(name="Nobody")
    ).scalar_one_or_none()

    # First row under an ordering
    newest = session.execute(
        select(Author).order_by(Author["id"].desc())
    ).scalars().first()

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

    # Count matching rows
    total = session.execute(
        select(func.count()).select_from(Author).where(Author["name"].like("Isaac%"))
    ).scalar_one()

Typed shortcuts for these reads

Arcanus adds typed helpers that collapse the patterns above into one call — one, one_or_none, first, list, count — plus bulk (several by primary key, order preserved) and partitions (chunked streaming), which have no tidy native one-liner. See Session Helpers. Author["name"], the operators, and ordering are covered in Querying.

Update

Mutate the transmuter and commit. Writes sync to the wrapped ORM row in place — no model_dump(), no re-construction.

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

For set-based updates, use a SQLAlchemy update() with RETURNING; the returned rows come back as transmuters:

from sqlalchemy import update

stmt = (
    update(Book)
    .where(Book["author_id"] == 1)
    .values(title="Reissue")
    .returning(Book)
)
reissued = session.execute(stmt).scalars().all()
session.commit()

Delete

with Session(engine) as session:
    author = session.get_one(Author, 1)
    session.delete(author)        # related rows follow your ORM cascade rules
    session.commit()

Bulk delete with RETURNING works the same way:

from sqlalchemy import delete

stmt = delete(Book).where(Book["author_id"] == 1).returning(Book)
deleted = session.execute(stmt).scalars().all()
session.commit()

Relationship CRUD

Relationships are first-class transmuter fields. You assign and mutate them with the same objects you query, and Arcanus cascades them into the session the way SQLAlchemy does. See Relationships for every association type.

Many-to-one — assign a parent

Relation[T] holds a single related transmuter. Adding the child cascades the parent in:

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()
    author.revalidate()
    book.revalidate()
    session.commit()

    # Reassign later
    other = Author(name="Arthur C. Clarke")
    book.author.value = other
    session.commit()

One-to-many — manage a collection

RelationCollection[T] behaves like a list. Append, extend, remove, pop, clear:

with Session(engine) as session:
    author = session.get_one(Author, 1)

    author.books.append(Book(title="I, Robot"))
    author.books.extend([Book(title="Caves of Steel"), Book(title="The Gods Themselves")])

    for book in author.books:                 # loads on access
        assert book.author.value is author    # back-reference, same instance

    author.books.remove(author.books[0])
    session.commit()

Many-to-many — both sides are collections

A secondary relationship is just a collection on each side; assign in whichever direction reads best:

with Session(engine) as session:
    book = session.get_one(Book, 1)
    sci_fi = Category(name="Science Fiction")

    book.categories.append(sci_fi)            # link from the book side
    # sci_fi.books.append(book)               # …or from the category side — same link

    session.commit()

Partial models for API boundaries

Every transmuter exposes generated Create and Update partials, plus shell() and absorb() to move between a partial and a full transmuter. These are ideal at HTTP boundaries where you accept untrusted, incomplete payloads. For a full FastAPI walkthrough — CRUD, filtering, and pagination — see Working with FastAPI.

# Create: excludes *generated* identities (the server assigns them); a natural/composite
# key declared Identity(server_side=False) stays in Create for the client to supply.
payload = Author.Create(name="New Author")
author = Author.shell(payload)        # a full transmuter, not yet persisted

with Session(engine) as session:
    session.add(author)
    session.flush()
    author.revalidate()
    session.commit()

# Update: excludes frozen (write-once) fields, applies only what's set
with Session(engine) as session:
    author = session.get_one(Author, 1)
    author.absorb(Author.Update(name="Renamed Author"))
    session.commit()

The read/response shape is the transmuter itself — there is no separate Read partial. A field marked Field(exclude=True) (e.g. a password) is dropped from model_dump() (and any serialized response) while staying a real, writable, attribute-readable field in the backend. See Partial Schemas for the full set of pydantic-native flags.

Serializing relationships & reference cycles

model_dump() (and model_dump_json()) include whatever relationships are loaded. Bidirectional relationships are inherently cyclic — a parent's children point back at the parent, and a back-reference resolves to the same instance (book.author.value is author). After a flush the inserted rows are revalidated so server-assigned values sync back, which marks the relationships on both sides as loaded — and a finite JSON tree cannot represent that cycle.

Arcanus handles it with tree projection: while dumping it tracks the transmuters on the current serialization stack, and when a relationship points back at one that is already an ancestor it cuts that edge instead of recursing into it.

with Session(engine) as session:
    author = Author(name="Isaac Asimov")
    author.books.append(Book(title="Foundation"))
    session.add(author)
    session.flush()                       # both directions now loaded

    author.model_dump()
    # {
    #     "id": 1, "name": "Isaac Asimov",
    #     "books": [
    #         {"id": 1, "title": "Foundation",
    #          "author_id": 1,     # foreign-key scalar — kept
    #          "author": None},    # relationship back-edge — cut
    #     ],
    # }

The cut is lossless in practice: only the relationship object is dropped, while the foreign-key scalar (author_id) still serializes, so the parent stays identifiable. It is ancestor-scoped, not a global de-duplication — an object reached twice without forming a cycle (a "diamond") is serialized in full both times; only an edge that would close a cycle is cut. For collections, the cyclic element is dropped from the list or dict.

Excluding a relationship explicitly

To drop a relationship from the output regardless of cycles, use Pydantic's standard excludeauthor.model_dump(exclude={"books": {"__all__": {"author"}}}).

Async

Swap Session for AsyncSession and await the I/O — the API is otherwise identical. Awaiting a relationship triggers its lazy load; see Loading Strategies and 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()