Skip to content

Relations

from typing import Annotated, Any

from mongospecs import Backref, Index, NotLoadedError, OnDelete, Q, Ref, Registry, SpecConfig
from mongospecs.msgspec import Spec


class Author(Spec):
    __spec__ = SpecConfig(collection="authors")
    name: str = ""
    books: Annotated[list[Any], Backref("author", model="Book")] = []
    book_count: Annotated[int, Backref("author", model="Book", count=True)] = 0


class Book(Spec):
    __spec__ = SpecConfig(collection="books", indexes=[Index("author")])
    title: str = ""
    author: Annotated[Ref[Author] | None, OnDelete("cascade")] = None
    editors: list[Ref[Author]] = []


registry = await Registry("mongodb://localhost/app", models=[Author, Book]).open()
ann = await Author(name="Ann").insert()
bob = await Author(name="Bob").insert()
await Book.insert_many([Book(title="One", author=ann, editors=[bob]), Book(title="Two", author=ann)])

References

Ref[T] is stored as the target's id. It always has .id; the document is available after include() or fetch().

book = await Book.find(Q.title == "One").one()
assert book.author.id == ann.id and not book.author.loaded
try:
    book.author.doc
except NotLoadedError:
    pass
assert (await book.author.fetch()).name == "Ann"

assert await Book.count(Q.author == ann) == 2  # documents and ids both work in conditions

include()

books = await Book.find().sort("title").include("author").include("editors")
assert books[0].author.doc.name == "Ann"
assert [e.doc.name for e in books[0].editors] == ["Bob"]

Options per relation: match, select, sort, limit (overall), per_parent_limit, and nested paths such as include("author.books").

include() can be served by one extra query per relation (strategy="batch", the default) or by a $lookup stage in a single pipeline (strategy="lookup"). Both return the same result; which is faster depends on fan-out and topology, see Benchmarks.

same = await Book.find().sort("title").include("author", strategy="lookup")
assert same[0].author.doc.name == "Ann"

Reverse relations

A Backref field is never stored. It is filled only when included; with count=True it holds the number of referencing documents.

author = await Author.find(Q.name == "Ann").include("books", sort=["title"]).include("book_count").one()
assert [b.title for b in author.books] == ["One", "Two"] and author.book_count == 2

Cascades

OnDelete on a reference says what happens to the documents holding it when the target is deleted: "cascade" deletes them, "nullify" clears the field, "pull" removes the id from a list. The delete and its cascades run in one transaction.

await ann.delete()
assert await Book.count() == 0

await registry.client.drop_database("app")
await registry.close()

Also available: polymorphic references (Ref[Post | Video]), dict[str, Ref[T]], Model.find(...).graph("parent") for $graphLookup over a self-reference, and a.find(...).union(b.find(...)) for $unionWith across collections.