Skip to content

Queries

Queries are immutable and chainable. Nothing is sent until you await a terminal method or iterate.

from typing import Annotated

from mongospecs import Deferred, Q, QueryError, Registry, SpecConfig, trusted
from mongospecs.msgspec import Spec


class Article(Spec):
    __spec__ = SpecConfig(collection="articles")
    title: str = ""
    views: int = 0
    tags: list[str] = []
    body: Annotated[str, Deferred] = ""


registry = await Registry("mongodb://localhost/app", models=[Article]).open()
await Article.insert_many([Article(title=f"post {i}", views=i * 10, tags=["a"] if i % 2 else ["b"]) for i in range(10)])

Conditions

Q.field builds conditions. Field names are checked against the model and values are cast to the field's type, so a typo is an error instead of a query that silently matches nothing.

popular = await Article.find(Q.views >= 50, Q.title.prefix("post")).sort("-views").limit(3)
assert [a.views for a in popular] == [90, 80, 70]

either = await Article.find((Q.views < 10) | (Q.views > 80)).sort("views").all()
assert [a.views for a in either] == [0, 90]

assert await Article.count(Q.tags == "a") == 5  # a scalar against a list field means "contains"
assert await Article.exists(~Q.title.prefix("post")) is False

try:
    Article.find(Q.titel == "x").compile()
except QueryError as exc:
    assert "no field 'titel'" in str(exc)

Operators: == != > >= < <=, .in_(), .nin(), .all(), .exists(), .size(), .prefix(), .contains(), .regex(), .elem_match(), combined with &, |, ~ or And/Or/Nor.

Plain dicts work for equality. Anything containing a MongoDB operator must be wrapped in trusted(), which marks it as written by you rather than built from user input.

assert await Article.count({"title": "post 3"}) == 1
assert await Article.count(trusted({"views": {"$mod": [20, 0]}})) == 5

Filter values are cast to the field's type the same way on every backend: a value of the right type is used as it is, and text that reads as the type is accepted (Q.age == "12" matches the number 12). Anything else raises QueryError, as do an empty Or()/Nor(), a non-text argument to prefix()/contains(), and a sort key that is not a field path. Values given to update() and to a model's constructor follow the model library's own rules instead (pydantic coerces "7" to 7; msgspec and attrs do not).

Results

By default one stored document that does not fit the model fails the whole read with ValidationError. find().on_invalid("skip") leaves such documents out, and find().on_invalid("raw") returns them in place as plain dicts so they can be found and repaired.

first = await Article.find(Q.views > 0).sort("views").first()  # a document, or None
one = await Article.find(Q.title == "post 4").one()  # raises DocumentNotFound if missing
by_id = await Article.get(one.id)
assert first.views == 10 and by_id.title == "post 4"

async for article in Article.find().sort("-views").limit(2):
    assert article.views >= 80

titles = [a["title"] for a in await Article.find().raw().limit(2)]  # plain dicts
Mode Returns Tracked for save()
default model instances yes
.lean() model instances no (read-only)
.raw() dicts as stored no

Projection

select("a", "b") includes, select("-a") excludes, select("+a") adds a Deferred field. A document loaded with a projection only saves the fields that were loaded.

partial = await Article.find().select("title").first()
assert partial.title and partial.views == 0  # not loaded: the default, not the stored value

Pagination

Cursor pagination is the default and stays stable while documents are inserted. Offset pagination and totals are available when you need them.

Cursor pages are sorted by your keys and then by _id, so that the order is total. Give the query an index that ends in the id (Index("views", "id")); with an index on the key alone MongoDB has to sort in memory.

page = await Article.find().sort("-views").page(4)
assert [a.views for a in page] == [90, 80, 70, 60] and page.has_next

following = await Article.find().sort("-views").page(4, after=page.next_cursor)
assert [a.views for a in following] == [50, 40, 30, 20]

third = await Article.find().sort("views").page(4, number=3, total=True)
assert [a.views for a in third] == [80, 90] and third.total == 10

Named scopes

@Article.scope
def trending(query, minimum=50):
    return query.filter(Q.views >= minimum)


assert await Article.find().trending(80).count() == 2

Escape hatches

Model.aggregate(pipeline, as_=...) runs a raw pipeline (scopes still apply) and Model.collection() returns the driver collection.

rows = await Article.aggregate([{"$group": {"_id": None, "views": {"$sum": "$views"}}}])
assert rows[0]["views"] == 450

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