Documents (NoSQL)
Articulate models normally sit on a table. A Document model sits on a
collection instead: MongoDB in production, an in-process store in tests. The
model surface does not change — casts, accessors, scopes, soft deletes, events,
observers, serialization, and factories all work exactly as they do over SQL.
What changes is that there is no migration, no schema, and no joins.
from almasix.orm import Document, HasFactory, SoftDeletes, relation
class Article(HasFactory, SoftDeletes, Document): connection = "mongodb" collection = "articles"
fillable = ("title", "body", "tags", "author_id") casts = {"published": "bool"}
indexes = ({"keys": [("title", 1)], "unique": True},)article = await Article.create(title="Notes", tags=["math"])await Article.query().where("tags", "all", ["math"]).order_by_desc("created_at").get()Laravel has no first-party NoSQL support; the reference point for parity here
is the community mongodb/laravel-mongodb package, which Almasix follows in
spirit — a model that behaves like every other model — while spelling things
the way the rest of Almasix does.
Configuring a store
Section titled “Configuring a store”Document connections live in config/database.py beside the SQL ones, told
apart by their driver:
config = { "connections": { "mongodb": { "driver": "mongodb", "dsn": env("MONGODB_DSN", ""), "host": env("MONGODB_HOST", "127.0.0.1"), "port": env("MONGODB_PORT", 27017), "database": env("MONGODB_DATABASE", "almasix"), "username": env("MONGODB_USERNAME", ""), "password": env("MONGODB_PASSWORD", ""), }, "memory": {"driver": "memory"}, },}The mongodb driver needs Motor:
pip install "almasix[mongodb]"The memory driver needs nothing. It keeps documents in the running process
and implements the same semantics, which makes it the document equivalent of
an in-memory SQLite database: good for tests, demos, and a laptop with no
server running.
DB resolves both kinds and refuses to confuse them:
from almasix.orm import get_manager
get_manager().store("mongodb") # a DocumentStoreget_manager().connection("sqlite") # a SQL Connectionget_manager().is_document("mongodb") # Trueget_manager().connection("mongodb") # ConnectionError_: reach it with store()A document’s key is _id, a string, and the store generates it — so
primary_key = "_id", incrementing = False, and key_type = "string" are
the defaults on Document. Set _id yourself before saving and that value is
kept. The rest of Articulate’s key handling (find, where_key, route model
binding, HasUuids) is unchanged.
Querying
Section titled “Querying”Document.query() returns a DocumentBuilder, which spells everything the
SQL builder spells for the operations a collection can answer:
await Article.query().where("views", ">", 100).count()await Article.query().where_in("status", ["draft", "review"]).get()await Article.query().where_null("deleted_at").order_by("created_at").limit(10).get()await Article.query().where("author.city", "Nairobi").get() # dotted pathsawait Article.query().paginate(15, page=2)await Article.query().sum("views")await Article.query().chunk(100, handle)Four filters exist because documents do, and SQL has no use for them:
| Method | Matches |
|---|---|
where_regex("title", "^No") |
a field against a regular expression |
where_exists_field("subtitle") |
documents that carry the field at all — missing is not null |
where_all("tags", ["a", "b"]) |
an array field containing every value |
where_size("tags", 3) |
an array field of exactly that length |
where_raw() takes a filter Almasix did not write. Hand it a mapping and it
goes to the engine untouched; hand it a callable and the memory store evaluates
it in Python, which is how a test keeps working without Mongo:
await Article.query().where_raw({"$text": {"$search": "engines"}}).get()await Article.query().where_raw(lambda row: row["views"] > 100).get()For anything past the builder, raw_aggregate() runs a native pipeline:
await Article.query().raw_aggregate([ {"$match": {"published": True}}, {"$group": {"_id": "$author_id", "views": {"$sum": "$views"}}},])What a store will not do
Section titled “What a store will not do”A document store has no joins, no GROUP BY, and no SQL to write. Rather than
quietly returning something else, those calls raise UnsupportedQueryError
and say what to reach for instead:
Article.query().join("authors", ...) # UnsupportedQueryErrorArticle.query().group_by("author_id") # → use raw_aggregate()Article.query().where_column("a", "b") # → use raw_aggregate() and $exprRelationships
Section titled “Relationships”References work unmodified, because a reference is a key lookup and a key lookup does not care what stores the row. A document can point at another document, or at a SQL model, in either direction:
class Article(Document): @relation def author(self): return self.belongs_to(User, "author_id", "id") # User is a SQL modelEager loading, with_count, and lazy loading all work:
await Article.query().with_("author").with_count("comments").get()Embedded documents
Section titled “Embedded documents”The relation SQL has no answer for is the child stored inside the parent.
An EmbeddedDocument has no key and no collection; it is a value with
behaviour:
from almasix.orm import Document, EmbeddedDocument, relation
class Address(EmbeddedDocument): fields = ("city", "country") # empty means anything goes
class Author(Document): @relation def address(self): return self.embeds_one(Address)
@relation def tags(self): return self.embeds_many(Tag)await author.get_relation("address").create(city="Nairobi", country="KE")
address = author.get_relation("address").get()address.city = "Mombasa"await address.save() # writes itself back into the parent document
tags = author.get_relation("tags")await tags.create(name="math")await tags.create_many([{"name": "engines"}])tags.where(name="math") # filtered in memory; they are already hereawait tags.delete_where(name="math")Both relations write the field on the parent, so an embed is saved by saving the document it lives in — there is nowhere else for it to go.
Indexes
Section titled “Indexes”A collection needs no migration; it appears on first write. Indexes are worth declaring, and they live on the model:
class Article(Document): indexes = ( {"keys": [("slug", 1)], "unique": True}, {"keys": [("author_id", 1), ("created_at", -1)], "name": "author_recent"}, )smith documents:index # create them for every document modelsmith documents:index --pretend # say what would be createdsmith documents:show # collections, counts, and indexesawait Article.sync_indexes() does the same thing from code.
Factories, soft deletes, and the rest
Section titled “Factories, soft deletes, and the rest”Nothing is special-cased. Add HasFactory and the factory writes documents;
add SoftDeletes and deleted_at filters the collection the same way it
filters a table:
await Article.factory().count(3).create()await article.delete() # softawait Article.with_trashed().count()await article.force_delete()Generating one
Section titled “Generating one”smith make:document Article # app/models/article.pysmith make:document Article --factory # and database/factories/article_factory.pysmith make:document Address --embed # an EmbeddedDocumentDifferences from Laravel
Section titled “Differences from Laravel”Laravel ships no NoSQL support, so this is Almasix’s own surface. Two things
are worth naming for anyone arriving from laravel-mongodb:
- Transactions. Mongo transactions need a replica set, and Almasix does not
pretend a single node has them.
DB.transaction()covers SQL connections. _idis a string. Almasix hands back the store’s key as it is, rather than wrapping it in an ObjectId type an application would then have to know about.