Guides

Relations & populate

Resolve related documents in a single request with declared relationships and the populate query parameter - permission-scoped joins on top of MongoDB, no second database.

Reference fields

A field of type reference on a collection stores the _id of a document in another collection in the same project - a foreign key. For example, a posts collection can have an author reference field pointing at an authors collection. By default a read returns the raw id in that field.

Declared relationships

For richer relations - one-to-many, many-to-one, one-to-one, and many-to-many, cascade behavior when the target is deleted, and multi-hop populate - declare a Relationship between two collections instead of relying on a bare reference field:

bash
POST /api/projects/{projectId}/relationships
Content-Type: application/json

{
  "sourceCollection": "posts",
  "targetCollection": "comments",
  "field": "postId",
  "type": "one-to-many",
  "onDelete": "cascade"
}

type is one of one-to-one, one-to-many, many-to-one, or many-to-many. field is the physical id field the relationship is built on: for many-to-one/one-to-one it is a plain id stored on the source document (e.g. authorId on posts); for one-to-many it is the id stored on the target document that points back at the source (e.g. postId on comments); for many-to-many it is an array of ids stored on the source document. onDelete controls what happens to referencing documents when the target is deleted - restrict (default, blocks the delete), cascade (deletes the referencing documents too), set-null (clears the dangling reference), or no-action.

A relationship can also be locked down further than the target collection's own read permission, with allowedRoles - an optional list of roles, custom roles, "authenticated", "public", or "apikey" tokens. When set, only a caller matching one of those tokens can populate the field, even if they could otherwise read the target collection directly - useful for gating a sensitive lookup (payroll, PII) behind a named subset of roles.

Populating references

Pass populate a comma-separated list of fields to resolve. For a raw reference field, name the field directly. For a declared relationship, use the relationship's field (many-to-one/one-to-one/many-to-many) or, for a one-to-many relationship populated from its source side, the target collection's slug (e.g. ?populate=comments on posts).

bash
# One reference
GET /api/data/projects/{projectId}/collections/{collectionId}/data?populate=author

# Several at once
GET /api/data/projects/{projectId}/collections/{collectionId}/data?populate=author,category

# Also works on a single document
GET /api/data/projects/{projectId}/collections/{collectionId}/data/{documentId}?populate=author

# Multi-hop, through a chain of declared relationships
GET /api/data/projects/{projectId}/collections/{collectionId}/data?populate=comments.author

Without populate, the reference field is the raw id:

json
{
  "_id": "665f0a...c31",
  "title": "Hello world",
  "author": "665e91...a02"
}

With ?populate=author, it is resolved into the referenced document:

json
{
  "_id": "665f0a...c31",
  "title": "Hello world",
  "author": {
    "_id": "665e91...a02",
    "name": "Ada Lovelace",
    "email": "ada@example.com"
  }
}

Filtering on a related field

A declared relationship can also be filtered through, not just populated. Pass a dotted query key naming the relationship then a field on the target document, and only documents whose related record matches are returned - this works for chains of relationships too:

bash
# Posts whose author's name is "John"
GET /api/data/projects/{projectId}/collections/posts/data?authorId.name=John

# Posts with a comment whose author is in Nigeria
GET /api/data/projects/{projectId}/collections/posts/data?comments.authorId.country=Nigeria

A relationship-field filter is only honored when the same path would also be honored as a populate path - a filter can never traverse a relationship that populate itself would reject (permission restriction, unknown field, or a broken chain).

Populate respects permissions

populate is never a way around access control. A referenced document is included only if the caller is allowed to read it in the referenced collection - the same collection permissions and row-level conditions that apply to a direct read of that collection are enforced on the populated lookup, at every hop of a multi-hop chain. If the caller has no read access to the referenced collection, the specific referenced row is outside their row-level scope, or the relationship declares an allowedRoles restriction the caller doesn't satisfy, the field resolves to null (single) or [] (list) rather than leaking a document they could not have fetched directly.

A reference is also returned as null when it was never set, or when its stored value does not match an existing document (for example an integer or UUID foreign key carried over from an imported relational database that has no corresponding record).

Orphan detection and repair

GET /api/projects/{projectId}/relationships/orphans scans every declared relationship for foreign-key values with no matching target document and reports per-relationship counts and samples. POST /api/projects/{projectId}/relationships/{relationshipId}/repair-orphans is a dry run by default - it reports what would change without mutating any document; pass {"confirm": true} to actually apply the relationship's onDelete policy to the orphaned references (cascade deletes them, set-null clears them - restrict/no-action have no automatic repair).

SDK

populate is a query parameter on the data list and single-document read endpoints, so the SDK forwards it exactly like sort and filter. The same read that lists documents returns the populated shape when you include it:

javascript
// List with a reference resolved
const { data: posts } = await data.listData(projectId, collectionId, undefined, undefined, undefined, undefined, {
  params: { populate: "author,category" },
});

// Single document with a reference resolved
const { data: post } = await data.getData(projectId, collectionId, documentId, {
  params: { populate: "author" },
});

You can also pass fields the same way to return only a projection of each document. The TypeScript SDK also ships a fluent query builder, MudbaseQuery, so populate, sort, and the rest of a read compose as chained calls instead of a raw params object:

javascript
import { MudbaseQuery } from "mudbase-sdk";

const mudbase = new MudbaseQuery({ projectId, configuration });
const { data: posts } = await mudbase
  .from("posts")
  .populate("author", "comments")
  .sort("-createdAt")
  .page(1)
  .limit(20)
  .execute();

Managing relationships from the SDK

MudbaseRelationships, also in the TypeScript SDK, wraps the declare/list/read/update/delete endpoints described above under "Declared relationships", so a relationship can be managed from application code instead of a raw REST call. Creating, updating, and deleting a relationship requires the caller to be a project owner or admin:

javascript
import { MudbaseRelationships } from "mudbase-sdk";

const relationships = new MudbaseRelationships({ projectId, configuration });

const relationship = await relationships.create({
  sourceCollection: "posts",
  targetCollection: "comments",
  field: "postId",
  type: "one-to-many",
  onDelete: "cascade",
});

await relationships.list({ sourceCollection: "posts" });
await relationships.get(relationship._id);
await relationships.update(relationship._id, { onDelete: "set-null" });
await relationships.delete(relationship._id);

Typed populate

populate resolves a reference into a full document at the wire level, but a plain MudbaseQuery read still types each document's fields as they are declared on the base collection - a populated field is not automatically widened from a raw id to the resolved type. typedCollection adds that narrowing: declare a base document type and a companion type mapping each relationship field to what it resolves to, and .populate() widens the result type to match, per field populated:

javascript
import { MudbaseQuery, typedCollection, type Populate } from "mudbase-sdk";

interface Author {
  _id: string;
  name: string;
}

interface Post {
  _id: string;
  title: string;
  author: string; // raw foreign key until populated
}

// Maps each of Post's relationship fields to what it resolves to once populated.
interface PostRelations {
  author: Author;
}

const mudbase = new MudbaseQuery({ projectId, configuration });
const posts = typedCollection<Post, PostRelations>(mudbase, "posts");

const { data } = await posts.populate("author").sort("-createdAt").execute();
data[0].author.name; // typed as Author, not the raw foreign-key string

type PostWithAuthor = Populate<Post, PostRelations, "author">;

This is type narrowing only - it does not change which fields the server actually resolves, or which permissions apply. A dot-path key for a multi-hop populate (e.g. "comments.author") is passed through to the server unsplit and is not individually checked against the relations type.

Note
Prefer a typed, single-request read across several related collections? The GraphQL API is generated automatically from your project's collections and declared relationships, and enforces the exact same permissions as populate.
Chat with us