Writes¶
from datetime import datetime
from typing import Annotated
from mongospecs import (
CreatedAt,
DeletedAt,
DocumentNotFound,
Inc,
Push,
Q,
Registry,
Revision,
SetOnInsert,
SpecConfig,
StaleDocumentError,
UpdatedAt,
UpdateOne,
)
from mongospecs.msgspec import Spec
class Article(Spec):
__spec__ = SpecConfig(collection="articles")
title: str = ""
views: int = 0
tags: list[str] = []
created_at: Annotated[datetime | None, CreatedAt] = None
updated_at: Annotated[datetime | None, UpdatedAt] = None
deleted_at: Annotated[datetime | None, DeletedAt] = None
rev: Annotated[int, Revision] = 0
registry = await Registry("mongodb://localhost/app", models=[Article]).open()
Change tracking¶
A document remembers what it looked like when it was loaded. save() sends only the difference;
an unchanged document sends nothing.
article = await Article(title="Draft").insert()
article.title = "Final"
article.tags.append("news")
assert article.changes() == {"$set": {"title": "Final", "tags": ["news"]}}
await article.save()
assert article.changes() == {} and article.rev == 1
A document you did not get from a tracked read has no snapshot: a lean() result, a
copy.copy() or a deep copy of a tracked document, or one that was unpickled. Saving one tries to
insert it and fails with a DuplicateKeyError that says so. Use a tracked read, or
Model.hydrate(raw) to adopt a raw document.
Nested documents are diffed into dotted paths. A changed list is sent whole; for element-level changes that must not overwrite concurrent writers, use the atomic operators below.
Atomic updates¶
Model.update changes one document in a single round trip and returns it. Every supplied field
is validated; unknown and immutable fields raise.
updated = await Article.update(article.id, Inc(views=1), Push(tags="featured"), title="Final v2")
assert updated.views == 1 and updated.tags == ["news", "featured"] and updated.rev == 2
assert await Article.find(Q.views >= 1).update(Inc(views=10)) == 1 # every match, no documents loaded
created = await Article.upsert(Q.title == "Landing", Inc(views=1), SetOnInsert(tags=["page"]))
assert created.views == 1 and created.created_at is not None
Operators: Set, Unset, Inc, Mul, Min, Max, Push, AddToSet, Pull, Pop,
SetOnInsert, CurrentDate; Each([...]) for several values.
Optimistic locking¶
With a Revision field, saving a stale copy fails instead of overwriting.
stale = article # loaded at rev 1, the database is now at rev 3
stale.title = "Overwrite?"
try:
await stale.save()
except StaleDocumentError:
await stale.reload()
assert stale.title == "Final v2"
A document created by upsert() starts at revision 1, one created by insert() at 0: an upsert
is an update, and every update increments the revision.
Soft delete¶
With a DeletedAt field, delete() hides the document instead of removing it.
await stale.delete()
assert await Article.get(stale.id) is None
assert await Article.find().unscoped("soft_delete").count() == 2
await Article.find(Q.id == stale.id).unscoped("soft_delete").update(deleted_at=None) # restore
await (await Article.get(stale.id)).delete(hard=True) # really remove
Bulk¶
result = await Article.insert_many([Article(title=f"bulk {i}") for i in range(3)])
assert len(result.inserted) == 3 and not result.errors
docs = await Article.find(Q.title.prefix("bulk")).all()
for doc in docs:
doc.views += 5
assert await Article.bulk_save(docs) == 3 # one round trip, deltas only
await Article.bulk_write([UpdateOne(Q.title == "bulk 0", Inc(views=1))])
assert (await Article.find(Q.title == "bulk 0").one()).views == 6
insert_many(..., ordered=False) attempts every document and reports failures per item instead of
raising on the first.
The writes in a bulk_save are independent. If some fail (a duplicate key, a revision that moved
on), the rest are still written and are in sync in memory; the error raised afterwards lists the
documents that were not written in .failed.
Model.find(...).update(...) returns the number of documents modified, as an int that also
carries .matched and .modified.
Transactions¶
registry.transaction(fn) commits when fn returns. The driver re-runs fn on transient errors,
so it must be safe to call twice. If the transaction aborts, documents written inside it are put
back to their previous in-memory state.
Transactions do not nest. MongoDB has no savepoints: a transaction started inside another is
a separate one that commits on its own and stays committed if the outer one aborts. Calling
registry.transaction() inside a running one raises TransactionError; pass the outer tx to
the code that needs it. When a separate commit is what you want (an audit record that must
survive a rollback), say so: registry.transaction(fn, independent=True).
landing = await Article.find(Q.title == "Landing").one()
async def promote(tx):
landing.tags.append("promoted")
await landing.save(session=tx)
raise RuntimeError("changed my mind")
try:
await registry.transaction(promote)
except RuntimeError:
pass
assert (await Article.get(landing.id)).tags == ["page"] # rolled back in the database
assert landing.rev == 1 and landing.changes() == {"$set": {"tags": ["page", "promoted"]}} # and in memory
Sessions are always explicit: pass session=tx to every operation inside the function. For manual
control use async with registry.session() as s, s.start_transaction(): ... (no automatic retry).
Validation¶
Every write is validated. Model.update(...) checks the values it is given; insert(), save(),
insert_many() and bulk_save() check the document as it is about to be stored, so a wrongly
typed attribute is an error at the write and not when the document is next read. validate=False
on a call, or SpecConfig(validate_writes=False) on a model, skips that check where its few
microseconds matter (see Choosing a backend).
try:
await Article.update(landing.id, title=123)
except Exception as exc:
assert type(exc).__name__ == "ValidationError"
draft = Article(title="Draft")
draft.views = "many" # msgspec does not check this
try:
await draft.insert()
except Exception as exc:
assert type(exc).__name__ == "ValidationError" and exc.errors[0]["field"] == "views"
assert draft.is_new and await Article.count(Q.title == "Draft") == 0
try:
await Article.update("0" * 24, title="nobody")
except DocumentNotFound:
pass
await registry.client.drop_database("app")
await registry.close()