Skip to content

Hooks and plugins

mongospecs has the same four kinds of middleware as Mongoose, with typed signatures.

Kind Where Runs for
Document hooks @Model.before("insert" \| "save" \| "delete"), @Model.after(...) operations on documents you hold: insert, save, delete, insert_many, bulk_save
Command and filter rewriting Plugin.on_insert, on_update, on_filter, on_delete every write and read, including filter-based ones
Pipeline rewriting Plugin.on_pipeline Model.aggregate
Bulk hooks Plugin.before_bulk, after_bulk insert_many, bulk_write, bulk_save
from mongospecs import Plugin, Q, Registry, SpecConfig
from mongospecs.msgspec import Spec


class Note(Spec):
    __spec__ = SpecConfig(collection="notes")
    text: str = ""
    slug: str = ""


@Note.before("insert")
async def slugify(ctx, note):
    note.slug = note.text.lower().replace(" ", "-")

Hooks receive an OpCtx (the session, registry and model) and the document. They run in registration order and the first exception stops the operation.

Document hooks run wherever the documents are in your hands, which includes insert_many and bulk_save (the before hooks for every document ahead of the one round trip, the after hooks for the documents that were written). They do not run for filter-based writes such as Note.find(...).update(...), Model.update(id, ...) or bulk_write: no documents are loaded. Plugins still rewrite those commands, which is how timestamps, revisions and scopes keep working there.

Writing a plugin

A plugin overrides only what it needs. This one confines every model of a registry to one tenant.

class Tenant(Plugin):
    name = "tenant"

    def __init__(self, tenant_id):
        self.tenant_id = tenant_id

    def on_insert(self, ctx, doc):
        doc["tenant_id"] = self.tenant_id

    def on_filter(self, ctx, flt):
        return {"tenant_id": self.tenant_id}


registry = await Registry("mongodb://localhost/app", models=[Note], plugins=[Tenant("acme")]).open()
note = await Note(text="Hello World").insert()
assert note.slug == "hello-world"

await Note.collection().insert_one({"text": "someone else's", "tenant_id": "other"})
assert [n.text for n in await Note.find()] == ["Hello World"]
assert await Note.find().update(text="changed") == 1  # the other tenant's note is untouched
assert await Note.find().unscoped("tenant").count() == 2

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

Plugins attach in three ways: by a field marker (the built-in timestamps, soft delete and revisions), per model with SpecConfig(plugins=[...]), or to every model with Registry(plugins=[...]).