Reading Ents

Reading Ents from the Database

Convex Ents provide a ctx.table method which replaces the built-in ctx.db object in Convex queries (opens in a new tab). The result of calling the method is a custom object which acts as a lazy Promise. If you await it, you will get a list of results. But you can instead call another method on it which will return a different lazy Promise, and so on. This enables a powerful and efficient fluent API.

Security

The Convex Ents API was designed to add an additional level of security to your backend code. The built-in ctx.db object allows reading data from and writing data to any table. Therefore in vanilla Convex you must correctly use both argument validation (opens in a new tab) and strict schema validation (opens in a new tab) to avoid exposing a function which could be misused by an attacker to act on a table it was not designed to act on.

All Convex Ents APIs require specifying a table up-front. If you need to read data from an arbitrary table, you must the use the built-in ctx.db object.

Reading a single ent by ID

const task = await ctx.table("tasks").get(taskId);
This is equivalent to the built-in:
const id = ctx.db.normalize("tasks", taskId);
if (id === null) {
  return null;
}
const task = await ctx.db.get(id);

with the addition of checking that taskId belongs to "tasks".

Reading a single ent by indexed field

const task = await ctx.table("users").get("email", "steve@apple.com");
This is equivalent to the built-in:
const task = await ctx.db
  .query("users")
  .withIndex("email", (q) => q.eq("email", "steve@apple.com"))
  .unique();

Reading a single ent via a compound index

const task = await ctx.table("users").get("nameAndRank", "Steve", 10);
This is equivalent to the built-in:
const task = await ctx.db
  .query("users")
  .withIndex("nameAndRank", (q) => q.eq("name", "Steve").eq("rank", 10))
  .unique();

Reading a single ent or throwing

The getX method (pronounced "get or throw") throws an Error if the read produced no ents:

const task = await ctx.table("tasks").getX(taskId);
const task = await ctx.table("users").getX("email", "steve@apple.com");

Reading multiple ents by IDs

Retrieve a list of ents or nulls:

const tasks = await ctx.table("tasks").getMany([taskId1, taskId2]);
This is equivalent to the built-in:
const task = await Promise.all([taskId1, taskId2].map(ctx.db.get));

Reading multiple ents by IDs or throwing

Retrieve a list of ents or throw an Error if any of the IDs didn't map to an existing ent:

const tasks = await ctx.table("tasks").getManyX([taskId1, taskId2]);

Also throws if any of the ents fail a read rule.

Listing all ents

To list all ents backed by a single table, simply await the ctx.table call:

const tasks = await ctx.table("users");
This is equivalent to the built-in:
const tasks = await ctx.db.query("users").collect();

Listing ents filtered by index

To list ents from a table and efficiently filter them by an index, pass the index name and filter callback to ctx.table:

const posts = await ctx.table("posts", "numLikes", (q) =>
  q.gt("numLikes", 100),
);
This is equivalent to the built-in:
const posts = await ctx.db
  .query("posts")
  .withIndex("numLikes", (q) => q.gt("numLikes", 100))
  .collect();

Searching ents via text search

To use text search (opens in a new tab) call the search method:

const awesomeVideoPosts = await ctx
  .table("posts")
  .search("text", (q) => q.search("text", "awesome").eq("type", "video"));
This is equivalent to the built-in:
const awesomeVideoPosts = await ctx.db
  .query("posts")
  .withSearchIndex("text", (q) =>
    q.search("text", "awesome").eq("type", "video"),
  )
  .collect();

Filtering

Use the filter method, which works the same as the built-in filter method (opens in a new tab):

const posts = await ctx
  .table("posts")
  .filter((q) => q.gt(q.field("numLikes"), 100));
This is equivalent to the built-in:
const posts = await ctx.db
  .query("posts")
  .filter((q) => q.gt(q.field("numLikes"), 100))
  .collect();

Ordering

Use the order method. The default sort is by _creationTime in ascending order (from oldest created to newest created):

const posts = await ctx.table("posts").order("desc");
This is equivalent to the built-in:
const posts = await ctx.db.query("posts").order("desc").collect();

Ordering by index

The order method takes an index name as a shortcut to sorting by a given index:

const posts = await ctx.table("posts").order("desc", "numLikes");
This is equivalent to the built-in:
const posts = await ctx
  .table("posts")
  .withIndex("numLikes")
  .order("desc")
  .collect();

Limiting results

Taking first n ents

const users = await ctx.table("users").take(5);

Loading a page of ents

The paginate method returns a page of ents. It takes the same argument object as the the built-in pagination (opens in a new tab) method.

const result = await ctx.table("posts").paginate(paginationOpts);

Finding the first ent

Useful chained, either to a ctx.table call using an index, or after filter, order, edge.

const latestUser = await ctx.table("users").order("desc").first();

Finding the first ent or throwing

The firstX method (pronounced "first or throw") throws an Error if the read produced no ents:

const latestUser = await ctx.table("users").order("desc").firstX();

Finding a unique ent

Throws if the listing produced more than 1 result, returns null if there were no results:

const counter = await ctx.table("counters").unique();

Finding a unique ent or throwing

The uniqueX method (pronounced "unique or throw") throws if the listing produced more or less than 1 result:

const counter = await ctx.table("counters").uniqueX();

Accessing system tables

System tables (opens in a new tab) can be read with the ctx.table.system method:

const filesMetadata = await ctx.table.system("_storage");
const scheduledFunctions = await ctx.table.system("_scheduled_functions");

All the previously listed methods are also supported by ctx.table.system. Edges to and from system tables are currently not supported.

Traversing edges

Convex Ents allow easily traversing edges declared in the schema with the edge method.

Traversing 1:1 edge

For 1:1 edges, the edge method returns:

  • The ent required on the other end of the edge (ex: given a profile, load the user)
  • The ent or null on the optional end of the edge (ex: given a user, load their profile)
const user = await ctx.table("profiles").getX(profileId).edge("user");
const profileOrNull = await ctx.table("users").getX(userId).edge("profile");
This is equivalent to the built-in:
const user = await ctx.db
  .query("users")
  .withIndex("profileId", (q) => q.eq("profileId", profileId))
  .unique();

The edgeX method (pronounced "edge or throw") throws if the edge does not exist:

const profile = await ctx.table("users").getX(userId).edgeX("profile");

Traversing 1:many edges

For 1:many edges, the edge method returns:

  • The ent required on the other end of the edge (ex: given a message, load the user)
  • A list of ents on the end of the multiple edges (ex: given a user, load all the messages)
const user = await ctx.table("message").getX(messageId).edge("user");
const messages = await ctx.table("users").getX(userId).edge("messages");
This is equivalent to the built-in:
const messages = await ctx.db
  .query("messages")
  .withIndex("userId", (q) => q.eq("userId", userId))
  .collect();

In the case where a list is returned, filtering, ordering and limiting results applies and can be used.

Traversing many:many edges

For many:many edges, the edge method returns a list of ents on the other end of the multiple edges:

const tags = await ctx.table("messages").getX(messageId).edge("tags");
const messages = await ctx.table("tags").getX(tagId).edge("messages");
This is equivalent to the built-in:
const tags = await Promise.all(
  (
    await ctx.db
      .query("messages_to_tags")
      .withIndex("messagesId", (q) => q.eq("messagesId", messageId))
      .collect()
  ).map((edge) => ctx.db.get(edge.tagsId)),
);

The results are ordered by _creationTime of the edge, from oldest created to newest created. The order can be changed with the order method and the results can be limited.

Testing many:many edge presence

You can efficiently test whether two ents are connected by a many:many edge using the has method:

const hasTag = ctx.table("messages").getX(messageId).edge("tags").has(tagId);
This is equivalent to the built-in:
const hasTag =
  (await ctx.db
    .query("messages_to_tags")
    .withIndex("messagesId_tagsId", (q) =>
      q.eq("messagesId", message._id).eq("tagsId", tagId),
    )
    .first()) !== null;

Retrieving related ents

Sometimes you want to retrieve both the ents from one table and the related ents from another table. Other database systems refer to these as a joins or nested reads.

Traversing edges from individual retrieved ents

All the methods for reading ents return the underlying document(s), enriched with the edge and edgeX methods. This allows traversing edges after loading the ent from the database:

const user = await ctx.table("users").firstX();
const profile = user.edgeX("profile");

Mapping ents to include edges

The map method can be used to perform arbitrary transformation for each returned ent. It can be used to add related ents via the edge and edgeX methods:

const usersWithMessages = await ctx.table("users").map(async (user) => {
  return {
    name: user.name,
    messages: await user.edge("messages").take(5),
  };
});
const usersWithProfileAndMessages = await ctx.table("users").map(async (user) => {
  const [messages, profile] = await Promise.all([
    profile: user.edgeX("profile"),
    user.edge("messages").take(5),
  ])
  return {
    name: user.name,
    profile,
    messages,
  };
});
This code is equivalent to this code using only built-in Convex and JavaScript functionality:
const usersWithMessagesAndProfile = await Promise.all(
  (await ctx.db.query("users").collect()).map(async (user) => {
    const [posts, profile] = Promise.all([
      ctx.db
        .query("messages")
        .withIndex("userId", (q) => q.eq("userId", user._id))
        .collect(),
      (async () => {
        const profile = await ctx.db
          .query("profiles")
          .withIndex("userId", (q) => q.eq("userId", user._id))
          .unique();
        if (profile === null) {
          throw new Error(
            `Edge "profile" does not exist for document with ID "${user._id}"`,
          );
        }
        return profile;
      })(),
    ]);
    return { name: user.name, posts, profile };
  }),
);

As shown in this example, map can be used to transform the results by selecting or excluding the returned fields, limiting the related ents, or requiring that related ents exist.

The map method can beused to load ents by traversing more than one edge:

const usersWithMessagesAndTags = await ctx.table("users").map(async (user) => ({
  ...user,
  messages: await user.edge("messages").map(async (message) => ({
    text: message.text,
    tags: await message.edge("tags"),
  })),
}));

Returning documents from functions

Consider the following query:

convex/messages.ts
import { query } from "./functions";
 
export const list = query({
  args: {},
  handler: async (ctx) => {
    return await ctx.table("messages");
  },
});

It returns all ents stored in the messages table. Ents include methods which are not preserved when they are returned to the client. While this works fine at runtime, TypeScript will think that the methods are still available even on the client.

Note that it is generally not a good idea to return full documents directly to clients. If you add a new field to an ent, you might not want that field to be sent to clients. For this reason, and for backwards compatibility in general, it's a good idea to pick specifically which fields to send:

convex/messages.ts
import { query } from "./functions";
 
export const list = query({
  args: {},
  handler: async (ctx) => {
    return await ctx.table("messages").map((message) => ({
      _id: message._id,
      _creationTime: message._creationTime,
      text: message.text,
      userId: message.userId,
    }));
  },
});

That said, you can easily retrieve raw documents, instead of ents with methods, from the database using the docs and doc methods:

convex/messages.ts
import { query } from "./functions";
 
export const list = query({
  args: {},
  handler: async (ctx) => {
    return await ctx.table("messages").docs();
  },
});
convex/users.ts
import { query } from "./functions";
 
export const list = query({
  args: {},
  handler: async (ctx) => {
    const usersWithMessagesAndProfile = await ctx
      .table("users")
      .map(async (user) => ({
        ...user,
        profile: await user.edgeX("profile").doc(),
      }));
  },
});

You can also call the doc method on a retrieved ent, which returns the same object, but is typed as a plain Convex document.