Models¶
A model is a class from the library you already use, with the Spec base of that backend.
Everything else in mongospecs is imported from mongospecs and is the same for all three.
from datetime import datetime
from typing import Annotated
from mongospecs import OID, CreatedAt, Deferred, Immutable, Index, Policy, Registry, SpecConfig, UpdatedAt
from mongospecs.msgspec import Spec # or: from mongospecs.pydantic import Spec / mongospecs.attrs
class User(Spec):
__spec__ = SpecConfig(collection="users", indexes=[Index("email", unique=True)])
email: Annotated[str, Immutable]
name: str = ""
password_hash: Annotated[str | None, Deferred, Policy(private=True)] = None
created_at: Annotated[datetime | None, CreatedAt] = None
updated_at: Annotated[datetime | None, UpdatedAt] = None
- Every document has an
idattribute, stored as_id. Its type isOID | Noneunless you redeclare it (id: str | None = Nonefor string ids). __spec__is assigned without an annotation. It holds the collection name (default: the class name), indexes, and per-model options.- With attrs, decorate each model with
@attrs.define(kw_only=True). - A field whose default is
Noneis not stored while it isNone: the key is left out of the document, and a document without the key reads back asNone. Setting such a field toNoneremoves the key ($unset). This is what makesIndex("email", unique=True, sparse=True)work for documents without an email: MongoDB indexes an explicitnull, so two stored nulls would collide.Q.email == Nonestill finds them (it matches a missing key), andExists(Q.email)means "has a value". A field with any other default storesNoneasnull. - A field may not be named after part of the document API (
count,find,get,update,delete,save,insert,scope,watch,changes,is_new, ... the full list ismongospecs.meta.RESERVED_FIELD_NAMES); it would replace that method on the class. Doing so raisesConfigurationErrorwhen the model is first used. Rename the attribute and keep the stored key:total: Annotated[int, Alias("count")] = 0.
None: left out, or stored as null¶
A field whose default is None is left out of the document while it is None. Where other
readers of the collection need the key to be present (BI tools, a $jsonSchema validator,
{field: {$type: "null"}} queries), store an explicit null instead:
class Contact(Spec):
__spec__ = SpecConfig(none="omit") # the default; "store" flips it for the model
nick: str | None = None # left out while None
email: Annotated[str | None, StoreNone] = None # stored as null
# on a model with none="store": Annotated[str | None, OmitNone] opts one field out
Left out (default, OmitNone) |
Stored (StoreNone, none="store") |
|
|---|---|---|
A new document with None |
no key | key: null |
doc.field = None; save() and update(field=None) |
$unset |
$set: null |
A document created by upsert() |
no key | key: null |
Q.field == None |
matches | matches |
Exists(Q.field), ?field[exists]=true |
"has a value" | "the key is present", null included |
Unset("field") |
removes the key | removes the key |
- The setting changes what is written, never what can be read. A stored
nullunder the default and a missing key underStoreNoneboth load asNonewithchanges() == {}, and saving such a document sends nothing for that field. To convert a collection, run oneupdate_many. - Embedded models follow their own field markers. They omit by default; an embedded model may
carry
__spec__ = SpecConfig(none="store")to change that for its fields. - A field that stores
nullcannot be a key of a unique sparse index: a sparse index still indexesnull, so only one document could be without a value. This raisesConfigurationErrorwhen the model is first used. Use a partial index:Index("email", unique=True, partial={"email": {"$type": "string"}}). - HTTP responses are the same in both modes (
Noneisnullin JSON). APATCHwithnullfollows the field's mode.
Binding and lifecycle¶
Models do nothing until they are bound to an open registry. Constructing a registry does no I/O;
async with opens it; closing is final.
registry = Registry("mongodb://localhost/app", models=[User])
await registry.open()
await registry.apply_indexes()
ann = await User(email="ann@example.com", name="Ann").insert()
assert isinstance(ann.id, OID.__origin__) and ann.created_at is not None
Operations on a registry that is not open fail immediately with RegistryClosedError; nothing is
queued.
Field markers¶
Markers go in typing.Annotated, so one declaration works on every backend.
| Marker | Effect |
|---|---|
CreatedAt, UpdatedAt |
Timestamps on every write path, including bulk and filter-based updates |
DeletedAt |
Soft delete: delete() sets it, reads exclude documents where it is set |
Revision |
Optimistic locking; the value doubles as an HTTP ETag |
Immutable |
Cannot change after insert |
Deferred |
Not loaded unless asked for with select("+name") |
Alias("x") |
Stored under a shorter name (top-level fields) |
Policy(...) |
How the field may be used from outside (read by mongospecs-web) |
Backref(...), OnDelete(...), To(...) |
Relations, see Relations |
loaded = await User.get(ann.id)
assert loaded.password_hash is None # Deferred: not fetched
with_hash = await User.find().select("+password_hash").one()
assert with_hash.email == "ann@example.com"
Existing classes¶
SpecAdapter turns a class you already have into a document class without touching it.
import msgspec
from mongospecs.msgspec import SpecAdapter
class Dragon(msgspec.Struct, kw_only=True):
name: str
breed: str = "unknown"
DragonDoc = SpecAdapter(Dragon, collection="dragons")
registry.bind(DragonDoc)
burt = await DragonDoc(name="Burt").insert()
assert isinstance(burt, Dragon) and burt.id is not None
Single-collection inheritance¶
Set discriminator on the base; subclasses share the collection and are picked on read.
class Animal(Spec):
__spec__ = SpecConfig(collection="animals", discriminator="kind")
name: str = ""
class Cat(Animal):
lives: int = 9
registry.bind(Animal, Cat)
await Cat(name="Tom").insert()
(animal,) = await Animal.find()
assert type(animal) is Cat
Documents written by older code¶
upcast runs on each raw document before it is decoded, so renamed or added fields can be
migrated lazily. Fields the model does not know are ignored on read and left alone on write.
def upcast(doc):
if "full_name" in doc:
doc["name"] = doc.pop("full_name")
return doc
class Person(Spec):
__spec__ = SpecConfig(collection="people", upcast=upcast)
name: str
registry.bind(Person)
await Person.collection().insert_one({"full_name": "Old Shape", "legacy_flag": True})
person = await Person.find().one()
assert person.name == "Old Shape"
await registry.close()