Manual contentsDatabaseBrowse 136 chapters
Manual 36 min read

MongoDB

MongoDB stores documents instead of rows: each record is a BSON document with nested values, arrays, and embedded documents. Suprnova talks to it through the official mongodb driver, behind the database-mongodb feature. The Mongo facade answers the connection, its database, and typed collections, the way Laravel's mongodb connection does with the laravel-mongodb package.

use suprnova::Mongo;
use suprnova::bson::{Document, doc};

Mongo::ping().await?;
let users = Mongo::collection::<Document>("users")?;
let ada = users.find_one(doc! { "name": "Ada" }).await?;

Installation

The MongoDB backend is off by default, because the driver is a large dependency tree that an SQL-only application never needs. To turn it on, add the database-mongodb feature to the suprnova dependency:

[dependencies]
suprnova = { git = "https://github.com/eas4ai/suprnova.git", tag = "v4.5.1", features = ["database-mongodb"] }

The feature brings the mongodb driver and the bson crate. You don't add either to your own Cargo.toml: Suprnova re-exports BSON as suprnova::bson and the driver's types under suprnova::mongodb, at the versions it links. Without the feature, neither crate is in your build.

You need a MongoDB server. For local work, the MongoDB Community Server runs on Linux, macOS, and Windows, or in Docker:

docker run -d --name mongodb -p 27017:27017 mongo:8

Configuration

Two variables configure the connection, the same ones Laravel's MongoDB page uses:

MONGODB_URI="mongodb://127.0.0.1:27017"
MONGODB_DATABASE="shop"

MONGODB_URI is the connection string. It starts with mongodb://, or with mongodb+srv:// for a MongoDB Atlas cluster:

MONGODB_URI="mongodb+srv://<username>:<password>@<cluster>.mongodb.net/?retryWrites=true&w=majority"
MONGODB_DATABASE="shop"

MONGODB_DATABASE names the database your application uses. When it's unset, Suprnova takes the database from the URI's path, so mongodb://127.0.0.1:27017/shop alone also selects shop. MONGODB_DATABASE wins when both name one.

Pool and timeout options go in the URI's query string, as the driver reads them: maxPoolSize, minPoolSize, maxIdleTimeMS, maxConnecting, connectTimeoutMS, and serverSelectionTimeoutMS, among others. For example, mongodb://127.0.0.1:27017/?maxPoolSize=20 caps each server's pool at 20 connections.

Suprnova checks the configuration before it uses it, without contacting the server:

  • An unset or blank MONGODB_URI is an error that names MONGODB_URI.
  • A value that isn't a MongoDB connection string is an error that names MONGODB_URI and gives the driver's reason.
  • A missing database, or a name MongoDB refuses (empty, longer than 63 bytes, or holding /, \, ., a space, ", or $), is an error that names MONGODB_DATABASE.

None of these is a panic, and no error or Debug output shows the URI's password or query string.

Configure the connection in code

To configure the connection in code, build a MongoConfig and register it in your configuration. The builder takes the URI, the database, and the pool options, and it falls back to MONGODB_URI and MONGODB_DATABASE for what you don't set:

use std::time::Duration;
use suprnova::{Config, MongoConfig};

pub fn register() -> Result<(), suprnova::FrameworkError> {
    Config::register(
        MongoConfig::builder()
            .uri("mongodb://127.0.0.1:27017")
            .database("shop")
            .max_pool_size(50)
            .min_pool_size(5)
            .max_idle_time(Duration::from_secs(300))
            .max_connecting(4)
            .connect_timeout(Duration::from_secs(5))
            .server_selection_timeout(Duration::from_secs(5))
            .build()?,
    );
    Ok(())
}

A pool option you set in the builder wins over the same option in the URI. build refuses a max_pool_size or max_connecting of 0 and a min_pool_size above max_pool_size, naming the option.

MongoConfig::from_env() is the builder with nothing set: the two variables and nothing else.

Named connections

The connection that MONGODB_URI configures is named mongodb, as in Laravel's config/database.php; DEFAULT_MONGO_CONNECTION holds the name. To reach a second server or database, add a named connection to the builder:

use suprnova::{Config, MongoConfig, MongoConnectionConfig};

let analytics = MongoConnectionConfig {
    max_pool_size: Some(5),
    ..MongoConnectionConfig::new("mongodb://analytics.internal:27017", "events")
};

Config::register(
    MongoConfig::builder()
        .connection("analytics", analytics)
        .build()?,
);

A connection can't be named mongodb or have an empty name, and an error about a named connection names it.

Connect at boot

The server registers the connections in the container when it boots, if MONGODB_URI is set or a MongoConfig is registered. The queue, schedule, and workflow workers and the console register them the same way. It happens before the cache and the queue boot, so the drivers that run on MongoDB find the connection. With neither set, nothing is registered and the boot goes on.

The driver connects lazily. Registering a connection opens no socket, so a MongoDB server that is down doesn't stop your application from booting. Instead, the first call that needs the server fails: Mongo::ping() returns a FrameworkError, and so does a driver operation such as find_one once ? converts its error. The one exception is a mongodb+srv:// URI: the driver looks up its DNS records when the connection is registered, because it needs them to know the hosts.

A MONGODB_URI that is set but invalid stops the boot of the server and the workers with the error that names it. The console reports it and goes on, so a command that doesn't use MongoDB still runs.

Your application's bootstrap hook runs before the boot registers the connections. To use MongoDB there, for example to create indexes, register the connections yourself first:

use suprnova::Mongo;

pub async fn register() -> Result<(), suprnova::FrameworkError> {
    // Reads the registered `MongoConfig`, or `MONGODB_URI` and
    // `MONGODB_DATABASE` when none is registered.
    Mongo::init().await?;
    Ok(())
}

Mongo::init_with(config) registers the connections of a MongoConfig you pass. Both replace connections registered before. The boot keeps connections that your application registered first.

The driver runs its pool and server monitors as Tokio tasks, so a connection is opened from inside your application's runtime. Opening one anywhere else is an error, not a panic.

The Mongo facade

The facade answers the connections the boot registered:

Call Returns
Mongo::connection() The mongodb connection, a MongoConnection.
Mongo::connection_named(name) The connection name. "mongodb" is the default one.
Mongo::database() The default connection's database, a suprnova::mongodb::Database.
Mongo::collection::<T>(name) A typed suprnova::mongodb::Collection<T> in that database.
Mongo::ping() Ok(()) when the server answers ping.

Every call returns a Result. With no connection registered, the error says why: MONGODB_URI is unset or invalid, the database name from MONGODB_DATABASE or the URI is missing or invalid, or MONGODB_URI is set or a MongoConfig is registered while the boot hasn't run yet in this process. An unknown name in connection_named is an error that names it.

A MongoConnection has the same methods for one connection, plus name() and client(), the driver's client for other databases, sessions, and transactions:

use suprnova::Mongo;
use suprnova::bson::Document;

let analytics = Mongo::connection_named("analytics")?;
analytics.ping().await?;
let events = analytics.collection::<Document>("page_views");
let other = analytics.client().database("archive");

Typed collections

A collection reads and writes your own types through serde. Nothing is sent to the server until an operation runs:

use serde::{Deserialize, Serialize};
use suprnova::Mongo;
use suprnova::bson::doc;

#[derive(Debug, Serialize, Deserialize)]
struct Note {
    title: String,
    pages: i32,
}

let notes = Mongo::collection::<Note>("notes")?;
notes.insert_one(Note { title: "Draft".into(), pages: 3 }).await?;
let draft = notes.find_one(doc! { "title": "Draft" }).await?;

The collection is the driver's own type, so every driver operation works on it: find, aggregate, update_many, create_index, and the rest. For the driver's options, cursors, and other types, use suprnova::mongodb::driver, which is the whole mongodb crate.

A driver operation returns the driver's error. In code that returns Result<_, FrameworkError>, ? converts it, and the driver's error stays the source:

use suprnova::bson::{Document, doc};
use suprnova::{FrameworkError, Mongo};

async fn count_users() -> Result<u64, FrameworkError> {
    let users = Mongo::collection::<Document>("users")?;
    Ok(users.count_documents(doc! {}).await?)
}

Health checks

Mongo::ping() sends ping to the server. A server that can't be reached fails after the server selection timeout, 30 seconds unless the URI or the builder sets another. The error names the connection and keeps the driver's error as its source, which FrameworkError::external_source returns.

Models

A document model is a struct that #[suprnova::document] stores in a collection. It has the Eloquent calls of an SQL model: create, find, find_or_fail, all, query, save, update, delete, fresh, and refresh. The calls come from the DocumentModel trait, so import it where you call them:

use suprnova::bson::doc;
use suprnova::{DocumentModel, FrameworkError};

#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct Address {
    pub street: String,
    pub city: String,
}

#[suprnova::document(
    collection = "users",
    fillable = ["name", "email", "password", "tags", "addresses"],
    hidden = ["password"],
    soft_deletes
)]
pub struct User {
    pub name: String,
    pub email: String,
    pub password: Option<String>,
    pub tags: Vec<String>,
    #[embeds_many]
    pub addresses: Vec<Address>,
    pub created_at: Option<suprnova::bson::DateTime>,
    pub updated_at: Option<suprnova::bson::DateTime>,
    pub deleted_at: Option<suprnova::bson::DateTime>,
}

async fn example() -> Result<(), FrameworkError> {
    let user = User::create(doc! {
        "name": "Ada",
        "email": "ada@example.com",
        "tags": ["math"],
    })
    .await?;

    let found = User::find(user.id).await?;
    let user = user.update(doc! { "name": "Ada Lovelace" }).await?;
    user.delete().await?;
    Ok(())
}

The macro derives Clone and Debug, and it implements serde's Serialize for the struct, so don't derive those three yourself. The model's events need the first two, and the third honours hidden and visible.

Collection and connection

collection = "..." names the collection. Without it, the collection is the plural snake-case name of the struct, so BlogPost is stored in blog_posts. connection = "..." stores the model on a named connection instead of the default mongodb one.

Keys

Every document has a key, which MongoDB stores as _id. Without a primary_key, the key is the field id. A struct without an id field gets one: pub id: ObjectId, which the model generates when you make or create it. You read it as user.id, and User::find takes it.

To use another field as the key, name it with primary_key. That field is stored as _id, and you give its value when you create a document:

#[suprnova::document(collection = "products", primary_key = "sku", fillable = ["sku", "title"])]
pub struct Product {
    pub sku: String,
    pub title: String,
}

let anvil = Product::create(doc! { "sku": "A-1", "title": "Anvil" }).await?;
let same = Product::find("A-1").await?;

A key of type ObjectId, String, i64, or i32 works out of the box. For another type, implement DocumentKey: it says how a route parameter names a key and whether the model can generate one.

In queries, the key's field name means _id, so User::query().where_("id", "=", id) matches the stored _id.

Mass assignment

fillable and guarded take the same lists as on an SQL model, and the same rules apply: create, update, make, and fill drop a field the guard refuses, or return an error when you turned on prevent_silently_discarding_attributes(true). Without either list, the guard refuses the key. suprnova::eloquent::unguarded turns the guard off for one task, as it does for SQL models.

A field that the struct doesn't declare is an error that names it, since the model has nowhere to keep its value. A value of the wrong type, such as a string for an i64 field, is an error that names the field, and nothing is written.

make(attrs) builds a model without storing it, and fill(attrs) sets fields on one. Call save to store either.

Field types and casts

Each field is stored through serde, so strings, numbers, booleans, Vecs, nested structs, and bson::Document values keep their BSON types. A unit enum is stored as its serde name, which is a string. Three field types get the BSON type made for them:

Field type Stored as Cast
bson::DateTime BSON datetime none needed
chrono::DateTime<Utc> BSON datetime AsBsonDateTime
rust_decimal::Decimal Decimal128 AsDecimal128

The macro selects the cast for a chrono::DateTime<Utc> or a Decimal field, and for an Option of one. A BSON datetime holds milliseconds, so a chrono time with finer precision reads back truncated to the millisecond. Both casts also read a string, which is how a date or a decimal arrives in attributes you build with doc!.

To store a field another way, write a type that implements DocumentCast<FieldType> and name it in casts, as on an SQL model:

#[suprnova::document(casts = { colour = AsHexColour })]
pub struct Theme {
    pub colour: Colour,
}

When a stored document lacks a field, an Option field reads as None and a Vec field reads as empty. Any other missing field is an error that names it.

Timestamps

When the struct has both created_at and updated_at, the model manages them as BSON datetimes. create sets both, and so does a save that inserts the document. Every write through the model sets updated_at: save, update, restore, a soft delete, increment, decrement, push, push_unique, and pull, and the embedded relations' save, save_many, destroy, clear, and delete. Each call sets it in the same write as its change.

A struct with only one of the two fields fails to compile. timestamps = true on a struct without the fields also fails to compile. To turn the timestamps off, set timestamps = false. To rename the fields, set created_at = "..." and updated_at = "...".

Soft deletes

With soft_deletes, delete sets the deleted_at field instead of removing the document, and queries leave such trashed documents out. The field must be an optional date-time. To use another field, set soft_deletes_column = "...".

user.delete().await?;                           // sets deleted_at
let trashed = User::only_trashed().get().await?;
let everyone = User::with_trashed().get().await?;

let user = trashed.into_vec().remove(0).restore().await?;
user.force_delete().await?;                     // removes the document

restore on a model without soft deletes is an error that names restore.

Embedded documents

Mark a Vec<T> field #[embeds_many], or an Option<T> field #[embeds_one], to store T values inside the document. T is a plain struct that implements serde's Serialize and Deserialize. The model reads and writes the field like any other, and it gains a method with the field's name that answers the relation:

let mut user = User::find_or_fail(id).await?;
user.addresses()
    .save(&Address { street: "2 Engine Row".into(), city: "Leeds".into() })
    .await?;
user.addresses().destroy(&old_address).await?;

An embeds_many relation has save, save_many, destroy, and clear. An embeds_one relation has save and delete. Each call writes to the server and reloads the model from the result.

Array and counter operators

These calls change one field on the server and reload the model. On a model with timestamps, every call also sets updated_at in the same write:

Call Operator
push(field, value) $push: append value, and set updated_at
push_unique(field, value) $addToSet: append unless present, and set updated_at
pull(field, value) $pull: remove every equal element, and set updated_at
increment(field, by) $inc, and set updated_at
decrement(field, by) $inc with -by, and set updated_at
user.push("tags", "rust").await?;
user.pull("tags", "math").await?;
user.increment("logins", 1).await?;

field can be a dotted path into an embedded document. A field the model doesn't declare, or the key, is an error that names it, and so is an increment amount that isn't an integer or a float.

Events and observers

Document models fire the lifecycle events of SQL models, in Laravel's order: Saving, then Creating or Updating, the write, Created or Updated, and Saved. SQL models in Suprnova fire Creating before Saving; document models keep Laravel's order. delete fires Deleting, Trashed when the model soft deletes, and Deleted. restore fires Restoring, the update's events, and Restored. Queries fire Retrieving and Retrieved. Each event is a generic type in suprnova::mongodb::events, such as Created<User>.

A listener on Saving, Creating, Updating, Deleting, or Restoring can cancel the write: the call returns a 400 Bad Request error with the listener's reason, and nothing is written. The first three carry the attributes about to be written as a bson::Document, which the listener can change.

To collect the callbacks in one place, implement DocumentObserver and register it with #[suprnova::observer], as for an SQL model:

use suprnova::bson::Document;
use suprnova::{DocumentObserver, EventResult};

pub struct UserObserver;

#[suprnova::observer(User)]
#[suprnova::async_trait]
impl DocumentObserver<User> for UserObserver {
    async fn creating(&self, attrs: &mut Document) -> EventResult {
        attrs.insert("tags", vec!["new"]);
        EventResult::ok()
    }
}

User::observe(UserObserver).await registers an observer by hand instead.

Serialization

The model serializes as its fields: the key under its field name, an ObjectId as its hex string, a datetime as RFC 3339 text, and a Decimal128 as its decimal text. hidden = [...] leaves fields out, and visible = [...] writes only the fields it lists. So a handler can answer a model as JSON:

let json = serde_json::to_value(&user)?;
// {"id":"6523...","name":"Ada","email":"ada@example.com","tags":["math"],...}

For the stored form, call to_document(), which writes the key as _id.

Route binding

A document model binds from a route parameter by its key, like an SQL model. A parameter that isn't a valid key, such as text that isn't an ObjectId, binds nothing, so the route answers 404 Not Found without a query. A route with with_trashed() also binds trashed documents. A parameter with a binding field, such as {user:email}, matches that field as text.

Why Suprnova diverges

  • Calls through a trait. Laravel models extend MongoDB\Laravel\Eloquent\Model. A Suprnova document model implements the DocumentModel trait, so you import the trait to call create or find.
  • Declared fields. A Laravel MongoDB model stores any attribute you set. A Suprnova model stores the fields its struct declares, so an attribute that names no field is an error instead of an extra stored field.
  • Embedded relations by field. Laravel declares embedsMany in a relation method. Suprnova marks the field, so the embedded documents are part of the struct and read with it.

Queries

A model's query() returns a DocumentQuery, the builder for its collection. Chain conditions and finish with a call that runs the query:

use suprnova::{Direction, DocumentModel};

let adults = User::query()
    .where_("age", ">=", 18)
    .where_in("role", ["admin", "editor"])
    .order_by("name", Direction::Asc)
    .skip(10)
    .take(5)
    .get()
    .await?;

The method is where_ because where is a Rust keyword.

Conditions

Call Matches
where_(field, op, value) field compared with value
or_where(field, op, value) the same, joined with OR
where_in(field, values) field equal to one of values ($in)
where_not_in(field, values) field equal to none of values ($nin)
where_null(field) field null or missing
where_not_null(field) field present and not null
where_between(field, low..=high) field from low to high, both included
where_date(field, date) a datetime field on the UTC day date
where_exists(field) field present, null or not
where_raw(filter) a filter in MongoDB's query language

op is one of =, != (or <>), <, <=, >, >=, like, and not like. like takes an SQL pattern, where % matches any run of characters and _ matches one character. It matches the whole value and ignores case, so where_("name", "like", "jo%") matches Joan and john.

Conditions join with AND. or_where starts a new group, and the conditions after it join that group. AND binds tighter than OR, as in SQL, so this query matches people over 60, and admins in the core team:

let people = User::query()
    .where_("age", ">", 60)
    .or_where("role", "=", "admin")
    .where_("team", "=", "core")
    .get()
    .await?;

A field can be a dotted path into an embedded document, such as addresses.city. where_raw takes its filter as written, so in it the key is _id. = compares the whole value, so a document such as {"$ne": null} or a regular expression you pass to where_ matches only a field that holds that value; send query operators through where_raw. An unknown operator, or like with a value that isn't a string, makes the query an error that names where_.

Order, offset, limit, and projection

order_by(field, direction) sorts, and later calls sort the ties of earlier ones. skip(n) and take(n) set the offset and the limit. project(fields) reads only those fields of each document. A projected query hydrates a model only when its other fields are Option or Vec fields, so read projected documents with get_documents(), which answers them as bson::Document values.

Reading

Call Returns
get() the matching models, as a Collection
get_documents() the matching documents, unread
first() the first match, or None
count() the number of matches
exists() whether anything matches
pluck(field) the value of field in each match
distinct(field) the distinct values of field
sum(field), avg(field), min(field), max(field) one value, or None when nothing matches
paginate(per_page, page) a LengthAwarePaginator with the total
simple_paginate(per_page, page) a Paginator without a total

The page is 1-based, and your handler passes it. For example, read it from the query string:

use suprnova::Context;

let page = Context::query_param("page")
    .and_then(|page| page.parse().ok())
    .unwrap_or(1);
let users = User::query()
    .order_by("name", Direction::Asc)
    .paginate(15, page)
    .await?;

Grouping and aggregates

group_by(fields) groups the matches. Add aggregates to it, then call get(), which answers one bson::Document per group: the group fields and the aggregates. count() adds count. sum(field), avg(field), min(field), and max(field) add sum_<field>, avg_<field>, min_<field>, and max_<field>:

let per_role = User::query()
    .where_("active", "=", true)
    .group_by(["role"])
    .count()
    .avg("age")
    .order_by("count", Direction::Desc)
    .get()
    .await?;
// [{ "role": "user", "count": 12, "avg_age": 31.5 }, ...]

The order, skip, and take apply to the groups. group_by with no fields puts every match in one group.

Writing

These calls write to every matching document and return how many changed:

Call Writes
update(doc) the fields of doc with $set, or doc as update operators
increment(field, by), decrement(field, by) $inc
push(field, value), pull(field, value) $push, $pull
unset(fields) $unset
delete() removes the matches, or trashes them when the model soft deletes
force_delete() removes the matches, trashed or not

update with plain fields leaves the other fields as they are. With update operators, such as doc! { "$inc": { "views": 1 } }, it sends them as they are. update, increment, and decrement also set updated_at when the model manages timestamps. These calls don't fire model events, as Laravel's query updates don't.

upsert(values, unique_by) inserts each value, or updates the document whose unique_by fields equal the value's:

User::query()
    .upsert(
        vec![doc! { "email": "ada@example.com", "name": "Ada" }],
        &["email"],
    )
    .await?;

Rendering without a server

to_filter() answers the parts of the find command the builder sends: the filter, the sort, the skip, the limit, and the projection. to_pipeline() answers the same query as aggregation stages. A group's to_pipeline() answers its $group stage and the rest. Use them to check a query in a test:

let rendered = User::query()
    .where_("age", ">=", 18)
    .where_in("role", ["a", "b"])
    .to_filter()?;
assert_eq!(rendered.filter, doc! { "age": { "$gte": 18 }, "role": { "$in": ["a", "b"] } });

On a soft-deleting model, the filter includes the condition that leaves trashed documents out.

Chains that can't run

A chain that MongoDB can't run as written is an error that names the call, not a query that matches more or less than you asked for. The builder checks before it contacts the server:

  • A write after skip or take: MongoDB writes every matching document.
  • upsert after a condition, an order, skip, or take: it matches each value by its unique_by fields.
  • upsert of a value without its unique_by fields, or without a key the model can't generate.
  • distinct(field) ordered by another field, which the distinct values don't carry.
  • update that mixes operators and fields, or that writes the key.
  • A one-value aggregate such as sum with a projection.

Why Suprnova diverges

  • Explicit page. Laravel's paginate reads the page from the request. paginate(per_page, page) takes the page as an argument, so you can call it outside a request.
  • Writes take no limit. Laravel MongoDB accepts a limit on some writes, such as take(1) before a delete. Suprnova refuses skip and take on every write, so a write never matches more or less than its conditions say. To write some of the matches, select their keys with pluck and write by them.
  • Aggregates by name. Laravel's grouped aggregates come back as aggregate. Suprnova names each one after its function and field, so one group can carry several.

Relations

Document models relate to each other the way SQL models do, and to SQL models across the two stores. Declare relations in relations = { ... } on #[suprnova::document], with the grammar of #[suprnova::model]:

use suprnova::bson::oid::ObjectId;

#[suprnova::document(
    collection = "users",
    fillable = ["name"],
    relations = {
        posts: HasMany<Post>,
        profile: HasOne<Profile>,
        roles: BelongsToMany<Role>,
    }
)]
pub struct User {
    pub name: String,
    pub role_ids: Vec<ObjectId>,
}

#[suprnova::document(
    collection = "posts",
    fillable = ["user_id", "title"],
    relations = { user: BelongsTo<User> }
)]
pub struct Post {
    pub user_id: ObjectId,
    pub title: String,
}

A document relates with these kinds:

Kind Relates to Method answers
HasOne<D> one document of D whose foreign key holds this key HasOneDocument<D>
HasMany<D> the documents of D whose foreign key holds this key HasManyDocuments<D>
BelongsTo<D> the document of D this foreign key names BelongsToDocument<D>
BelongsToMany<D> documents of D, each side keeping the other's keys in an array BelongsToManyDocuments<Self, D>
BelongsToModel<M> the SQL model M this foreign key names BelongsToModel<M>

Each relation's keys default to Laravel's names. Name others with options:

Kind Default Options
HasOne, HasMany <parent>_id on the related document, holding this key fk names the related field; lk names this document's field instead of the key
BelongsTo, BelongsToModel <related>_id on this document, holding the related key fk names this document's field; lk names the related field or column instead of the key
BelongsToMany <related>_ids on this document and <this>_ids on the related one pivot_related_key names this document's array; pivot_foreign_key names the related array

A document with relations gets an __eager field beside its own fields. It holds what with loaded, and the collection never stores it.

Read a relation

Each relation adds a method of its name. The method sends no query. Its get reads the related documents, and its query answers the DocumentQuery, so you can add conditions first:

let posts = user.posts().get().await?;
let drafts = user.posts().query().where_("published", "=", false).get().await?;
let count = user.posts().count().await?;
let author = post.user().get().await?;

HasMany and BelongsToMany have get, first, count, and exists. HasOne and BelongsTo have get, which answers an Option. A BelongsTo whose foreign key is null answers None without a query.

create on a HasMany or a HasOne creates a related document with the foreign key set to the parent's key. The model's guard applies to the attributes you pass, and the relation's key always wins:

let post = user.posts().create(doc! { "title": "Hello" }).await?;
assert_eq!(post.user_id, user.id);

Eager loading

with loads relations for every document a query reads, with one query per relation however many documents there are. Read a loaded relation with <name>_loaded(), which answers None when no query loaded it:

let users = User::query().with(["posts", "profile"]).get().await?;
for user in users.iter() {
    let posts = user.posts_loaded().unwrap_or_default();
    let profile = user.profile_loaded();
}

A dotted path loads a relation of the related documents, with one more query per step: with(["posts.user"]) loads each post's user. get, first, paginate, and simple_paginate load the relations. relation_loaded("posts") answers whether a query loaded one. A name the model doesn't declare is an error that names it, before any query runs.

Many-to-many

A BelongsToMany keeps no pivot collection. Each side keeps the keys of the other in an array, as Laravel MongoDB stores it: a user's role_ids holds its roles, and each role's user_ids holds its users. The relation reads the related side's array, so declare it on both models to read it from both:

#[suprnova::document(
    collection = "roles",
    fillable = ["name"],
    relations = { users: BelongsToMany<User> }
)]
pub struct Role {
    pub name: String,
    pub user_ids: Vec<ObjectId>,
}

attach, detach, detach_all, and sync write both arrays and answer the parent as the server holds it after. Assign the answer to keep the parent's own array current:

let user = user.roles().attach([editor.id, admin.id]).await?;
let users = editor.users().get().await?;
let user = user.roles().detach([admin.id]).await?;
let user = user.roles().sync([editor.id]).await?;

The parent is written first, so a parent that no longer exists fails the call before any related document changes. Without a transaction, the two writes are separate. Each one is idempotent, so calling attach again completes one that failed between them.

Relations across stores

An SQL model relates to documents with HasManyDocuments<D> and HasOneDocument<D>, and a document relates to an SQL model with BelongsToModel<M>. Each relation reads through its own store: the documents from their collection, the SQL model through its connection.

#[suprnova::model(table = "accounts", relations = {
    notes: HasManyDocuments<Note>,
    setting: HasOneDocument<Setting>,
})]
pub struct Account {
    pub id: i64,
    pub name: String,
}

#[suprnova::document(
    collection = "notes",
    fillable = ["account_id", "body"],
    relations = { account: BelongsToModel<Account> }
)]
pub struct Note {
    pub account_id: i64,
    pub body: String,
}

let notes = account.notes().get().await?;
let owner = note.account().get().await?;
let accounts = Account::query().with(["notes"]).with_count(["notes"]).get().await?;
let notes = Note::query().with(["account"]).get().await?;

On the SQL side, with and with_count read the documents with one query, and <name>_loaded() and <name>_count() answer None until a query loads them. A dotted path crosses the stores both ways, such as with(["notes.account"]). A document's BelongsToModel query is the SQL model's Builder, so its global scopes and soft deletes apply. An SQL model's relations to documents honour lazy-loading prevention, as its SQL relations do.

These calls don't reach across the stores, and each one is an error that says so:

  • with_where on a relation to documents. Add the conditions to the relation's query() instead.
  • with_sum, with_avg, with_min, and with_max over documents. Aggregate the relation's query() instead.

Key types

The key a relation compares and the field that holds it must have types that can match: the same type, the field holding it in an Option, or a Vec of keys for a many-to-many array. Each relation checks this when it compiles. A pair that can't match fails the build with an error that names both models:

error[E0277]: `Account` and `Note` cannot relate: the key `i64` cannot match `ObjectId`

The key and foreign key fields must be visible where the relation is declared.

Why Suprnova diverges

  • Key types checked at build time. Laravel compares keys when the query runs, so a foreign key of the wrong type answers no related documents. Suprnova refuses the relation when it compiles.
  • Writes answer the parent. Laravel's attach changes the parent model in place. Suprnova's attach, detach, and sync answer the parent as stored after the write, so assign it.
  • BelongsTo keys follow the model. Laravel names a belongsTo foreign key after the relation method. Suprnova names it after the related model, user_id for BelongsTo<User>, as its SQL BelongsTo does. Name another with fk.
  • No existence queries across stores. has and where_has render SQL, which doesn't reach a collection. Read the keys you need with pluck and filter with where_in.
  • Loaded relations belong to the instance. A call that reloads a document from the server, such as update or refresh, answers it without the relations with loaded.

Queue

Set QUEUE_DRIVER=mongodb to keep queued jobs in MongoDB. The boot builds three stores on the default connection:

  • MongoQueueDriver over the jobs collection.
  • MongoFailedJobStore over failed_jobs, which becomes the failed-job store, as QUEUE_DRIVER=database makes its table the store.
  • MongoBatchRepository over job_batches, which becomes the batch repository unless your bootstrap installed one.
QUEUE_DRIVER=mongodb
MONGODB_URI="mongodb://127.0.0.1:27017"
MONGODB_DATABASE="shop"

Run workers with queue:work as for any other driver. The collections need no migration: each store creates its indexes on its first operation.

A job is one document with Laravel's fields: queue, payload, attempts, reserved_at, available_at, and created_at, the last three as epoch seconds. Two fields of Suprnova's own hold the reservation: token, which the worker settles the job with, and reserved_until, when the reservation lapses.

A worker reserves a job with one findOneAndUpdate. It picks the earliest available job on the worker's queues that no live reservation holds, sets the reservation, and adds 1 to attempts. The server applies the update to one document atomically, so two workers never reserve the same job. A job whose worker died is reserved again once reserved_until passes, and that delivery counts as an attempt.

available_at holds whole seconds. A job due when you push it is available at once. A delayed job rounds its time up, so a worker never reserves it early; it can start up to a second late.

QUEUE_DRIVER=mongodb uses the default connection and Laravel's collection names. Each store also takes a connection and a collection of your choice, for code that drives a store itself, such as a test or a worker you start with run_worker:

use std::sync::Arc;
use suprnova::{Mongo, MongoBatchRepository, MongoFailedJobStore, MongoQueueDriver};

let reporting = Mongo::connection_named("reporting")?;
let jobs = Arc::new(MongoQueueDriver::with_collection(&reporting, "report_jobs")?);
let failed = MongoFailedJobStore::with_collection(&reporting, "report_failed_jobs")?;
let batches = MongoBatchRepository::new(&reporting);

A batch is one document. Each job that settles updates it once, moving pending_jobs and failed_jobs with $inc and adding the job to settled_job_ids, so jobs that finish at the same moment never lose a count and a job delivered twice counts once. The then, catch, and finally callbacks run once: the first worker to set finished_at runs them.

Cache

Set CACHE_DRIVER=mongodb to keep the cache in MongoDB. The boot builds MongoCache on the default connection, over the cache collection for entries and cache_locks for locks, with the CACHE_PREFIX and CACHE_DEFAULT_TTL of the other drivers.

CACHE_DRIVER=mongodb
CACHE_PREFIX=shop-cache-

The Cache facade works as with any driver:

use std::time::Duration;
use suprnova::Cache;

Cache::put("report:today", &42, Some(Duration::from_secs(60))).await?;
Cache::increment("visits", 1).await?;
let added = Cache::add("slot:9", &"taken", Some(Duration::from_secs(30))).await?;

An entry is one document: _id (the prefix and the key), value, expires_at, and tags. A value that is a whole number is stored as a BSON integer, so increment adds to it on the server with $inc in one findOneAndUpdate, and two increments at once never lose a step. Any other value is stored as its JSON text.

Both collections have a TTL index on expires_at, which the store creates on its first operation, so the server removes expired entries and locks. The server's sweep runs once a minute, so every read also compares expires_at with the time now and never returns an expired entry.

Cache::add is one upsert that matches only an expired entry: it inserts a missing key, replaces an expired one, and fails on a live one, so two callers never both add. Tags are an array on the entry, and Cache::flush_tags deletes every entry under the store's prefix whose tags hold one of the tags. A lock is one document whose _id is the key: acquiring it is an upsert that fails while another owner's lock is live, and releasing or refreshing it checks the owner's token.

To use other collections or another connection, build the store and bind it in your bootstrap:

use std::sync::Arc;
use suprnova::{App, CacheConfig, CacheStore, Mongo, MongoCache};

pub async fn register() -> Result<(), suprnova::FrameworkError> {
    Mongo::init().await?;
    let store = MongoCache::with_collections(
        &Mongo::connection()?,
        &CacheConfig::from_env()?,
        "app_cache",
        "app_cache_locks",
    )?;
    App::bind::<dyn CacheStore>(Arc::new(store));
    Ok(())
}

Session

Set SESSION_DRIVER=mongodb to keep sessions in MongoDB. The session middleware then stores them with MongoSessionDriver, in the collection SESSION_TABLE names (sessions by default), on the MongoDB connection SESSION_CONNECTION names or the default one.

SESSION_DRIVER=mongodb
SESSION_LIFETIME=120

Your bootstrap builds the middleware as it does for the database driver:

use suprnova::{SessionConfig, SessionMiddleware, global_middleware};

pub async fn register() {
    global_middleware!(SessionMiddleware::install(SessionConfig::from_env()).await);
}

The middleware is built before the boot registers the MongoDB connections, so the driver looks the connection up on each call.

A session is one document: session_id, user_id (the default guard's user), guards (each other guard signed in, with its user), payload (the session data and the CSRF token), and last_activity. The store creates a unique index on session_id on its first operation.

destroy_for_user and the other revocations find sessions by user_id and guards, and delete them one at a time until none is left, so a session signed in during the revocation goes too. A session a request loaded and a revocation then deleted isn't written back. gc deletes the sessions whose last activity is older than the lifetime, and the session middleware's collector calls it every SESSION_GC_INTERVAL seconds.

The two-factor promotion renames the pending session to its new id with one update of one document, so the old id stops working and the new one holds the session in the same step.

Why Suprnova diverges

  • No default URI. Laravel's configuration falls back to mongodb://localhost:27017 and the laravel_app database. Suprnova has no fallback: an unset MONGODB_URI is an error that names it, so an application never writes to a server it wasn't pointed at.
  • A facade of its own. Laravel reaches MongoDB through DB::connection('mongodb'). Suprnova's DB facade is SQL only, so MongoDB has the Mongo facade, and its default connection keeps Laravel's name, mongodb.
  • Configuration in code. Laravel lists connections and driver options in config/database.php. Suprnova reads the default connection from the environment and takes named connections and pool options from MongoConfig::builder().
  • Only the worker that holds a job settles it. Laravel's database queue deletes a finished job by its id, so a worker whose reservation lapsed can delete a job another worker now runs. Suprnova's driver keeps a token and a reserved_until beside Laravel's fields, and settles a job by its token.
  • A release doesn't spend an attempt. Laravel counts each reservation as an attempt and keeps the count when a job is released. Suprnova gives the delivery back, as its other drivers do.
  • Batch settlements count once. Laravel's batch repository moves the counters on every settlement, so a job delivered twice counts twice. Suprnova records the settled jobs on the batch and moves the counters in the same update, so a redelivery changes nothing. MongoDB's 16 MiB document limit bounds a batch at about 300,000 jobs.
  • A chained job's successor is pushed before the acknowledgement. Without a transaction, which a standalone server lacks, the driver can't enqueue the successor and drop the finished job in one step. The worker pushes first, as it does on Redis, so a crash between the two runs the finished job again rather than losing the rest of the chain.
  • Sessions are keyed by a session_id field, not _id. A document's _id can't change, and the two-factor promotion has to give a session a new id atomically, so the id is a field with a unique index.

Next