Working with MongoDB

MongoDB stores JSON-like documents in collections, and one MongoClient per process holds the connection pool.

Syntaxconst users = client.db('app').collection('users'); await users.findOne({ email })

MongoDB stores documents rather than rows. A document is close to a JavaScript object: named fields, nested objects, arrays. Documents live in a collection, and two documents in the same collection need not have the same fields, because the database enforces no schema unless you ask it to. Whatever shape you write is the shape you read back.

That suits data you load and save whole, such as an order with its line items. It suits you less when the same facts are queried from many directions and joined, because there is no join in the SQL sense, only $lookup inside an aggregation.

How it works

Run npm install mongodb and create one MongoClient for the whole process. The client is a pool, like pg's, and its maxPoolSize defaults to 100.

const client = new MongoClient(process.env.MONGO_URL);
const users = client.db('app').collection('users');
const { insertedId } = await users.insertOne({ email, tags: [] });
const user = await users.findOne({ _id: insertedId });
const res = await users.updateOne({ _id: insertedId }, { $set: { email: '[email protected]' } });
const admins = await users.find({ tags: 'admin' }).toArray();

insertOne resolves to a result carrying insertedId. updateOne resolves to one carrying matchedCount and modifiedCount. $set changes only the fields named in it and leaves the rest of the document alone.

What trips people up

An update that matches nothing is not an error: the promise resolves with matchedCount of 0, so check it. And _id is an ObjectId, not a string, so an id taken from a URL has to be wrapped in new ObjectId(id) before it will match anything.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Store an order together with its line items as a single document loaded in one read.
  • Keep per-user settings whose fields differ between plans without a migration for each one.
  • Reuse one MongoClient across every request so the process holds a single connection pool.
  • Turn a 24-character id from a URL into an ObjectId before querying by _id.

More examples

One client for the process, with indexes created at startup

Creating the client inside a request handler is the classic MongoDB mistake: each one opens its own pool of up to maxPoolSize connections. Declaring indexes next to the collection keeps them in version control rather than in someone's shell history.

Example · javascript
// mongo.js
import { MongoClient } from 'mongodb';

const client = new MongoClient(process.env.MONGO_URL, {
  maxPoolSize: 20, // the default is 100, which is a lot per process
});

// connect() is optional, since the first operation connects on its own.
// Calling it at startup turns a bad URL into an immediate failure.
await client.connect();

export const db = client.db('app');
export const users = db.collection('users');

// Indexes are declared in code and created once, at boot. createIndex on an
// index that already exists does nothing, so this is safe to repeat.
await users.createIndex({ email: 1 }, { unique: true });
await users.createIndex({ createdAt: -1 });

Reading an id from a URL, and reading a page of documents

ObjectId.isValid before the constructor turns a malformed URL into a 404 instead of a 500. Use toArray for a page you are about to send as JSON, and for await when the result set is too big to hold in memory at once.

Example · javascript
import { ObjectId } from 'mongodb';
import { users } from './mongo.js';

async function getUser(idFromUrl) {
  // A 24-character hex string is not an ObjectId until you make it one,
  // and an invalid string makes the constructor throw.
  if (!ObjectId.isValid(idFromUrl)) return null;
  return users.findOne({ _id: new ObjectId(idFromUrl) });
}

async function recentUsers(limit = 20) {
  // find() builds a cursor; the chained calls are sent with the query.
  const cursor = users
    .find({ active: true }, { projection: { email: 1, createdAt: 1 } })
    .sort({ createdAt: -1 })
    .limit(limit);

  return cursor.toArray(); // without this you return the cursor itself
}

async function eachUser(onUser) {
  // for await streams the documents instead of buffering them all.
  for await (const user of users.find({ active: true })) {
    await onUser(user);
  }
}

Updating fields without overwriting the document

The difference between updateOne with $set and replaceOne is the one that destroys data: passing a plain object where an update document was expected drops every field you left out. Keep $set for partial edits and reserve replaceOne for the rare whole-document write.

Example · javascript
import { users } from './mongo.js';

async function changeEmail(id, email) {
  const res = await users.updateOne(
    { _id: id },
    { $set: { email, updatedAt: new Date() } },
  );
  // matchedCount 0: no such user. modifiedCount 0 with matchedCount 1:
  // the document already had that email, which is not an error.
  if (res.matchedCount === 0) throw new Error('no such user');
  return res.modifiedCount === 1;
}

async function addTag(id, tag) {
  // $addToSet appends to the array only if it is not already there.
  await users.updateOne({ _id: id }, { $addToSet: { tags: tag } });
}

async function replaceEverything(id, doc) {
  // No operator means replacement: every field not in doc is gone.
  // This is almost never what you wanted.
  await users.replaceOne({ _id: id }, doc);
}

Discussion

  • Be the first to comment on this lesson.