Skip to content
approval@dev

Guides

Queries and updates

Array paths, $elemMatch, positional operators, arrayFilters and upsert.

Rivyn's filter and update language follows MongoDB closely enough that most queries port across unchanged. This page covers the parts that are easy to get wrong.

Dotted paths reach through arrays

A dotted path does not stop at an array — it descends into every element. "punishments.id" matches when any punishment carries that id:

await users.find({ "punishments.id": 3 });
await users.find({ "punishments.oldRoles.id": "r1" }); // nested arrays too

A numeric segment is an index instead, when the field really is an array:

await users.find({ "punishments.0.active": true }); // only the first one

On an object keyed by number — a free-form map — the same segment is read as a key, so "rolesToBeWon.5" addresses the entry 5, not the sixth element of anything.

$elemMatch

A plain dotted filter checks each condition independently, so two conditions can be satisfied by two different elements. $elemMatch requires one element to satisfy all of them:

// Any punishment with id 2, and any punishment that is active — possibly different ones.
await users.find({ "punishments.id": 2, "punishments.active": true });
 
// One punishment that is both.
await users.find({ punishments: { $elemMatch: { id: 2, active: true } } });

It also works on arrays of plain values:

await users.find({ scores: { $elemMatch: { $gt: 5, $lt: 10 } } });

Negative operators

$ne and $nin mean "no value matches", so on an array path every element must satisfy them. { "punishments.id": { $ne: 1 } } excludes a user who has any punishment with id 1.

Regex flags

Flags travel in $options:

await users.find({ username: { $regex: "^ar", $options: "i" } });

$each and $addToSet

$each spreads several values into one operator. $addToSet compares by value, so objects deduplicate properly rather than by reference:

await users.updateOne({ id }, { $push: { reasons: { $each: ["spam", "raid"] } } });
 
await users.updateOne(
  { id },
  { $addToSet: { roles: { $each: [{ id: "r1" }, { id: "r2" }] } } },
);

Positional operators

$ updates the element the filter matched:

await users.updateOne(
  { id: "550", "punishments.id": 2 },
  { $set: { "punishments.$.active": false } },
);

The element is resolved from the filter's own conditions on that array, so the filter has to name it. If nothing matches, the document is left alone rather than mutated at index 0.

$[] updates every element, and $[name] updates the ones selected by arrayFilters:

await users.updateMany({}, { $set: { "punishments.$[].reviewed": true } });
 
// Rewrite a role id wherever it appears, at any depth.
await users.updateMany(
  { "punishments.oldRoles.id": oldId },
  { $set: { "punishments.$[].oldRoles.$[role].id": newId } },
  { arrayFilters: [{ "role.id": oldId }] },
);

Positional paths never create missing structure. Unlike $set on a plain path, which builds intermediate objects as needed, "missing.$.field" on a document without missing is a no-op.

Upsert

await users.updateOne(
  { id: "550" },
  { $set: { username: "arel" } },
  { upsert: true },
);

When nothing matches, the document is seeded from the filter's plain equality fields and the update is applied on top. Dotted keys expand: { "a.b": 1 } becomes { a: { b: 1 } }. Operator conditions such as { age: { $gt: 18 } } carry no single value, so they are skipped.

An upsert does not apply schema defaults — it only knows the filter and the update. If you need a new document to arrive with every nested default populated, read first and fall back to Model.create(), which runs the schema.

findOneAndUpdate accepts upsert too, and returns the created document only with returnNew: true — a document that did not exist has no "before" state to return.