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
.
You might also access documents in system tables through edges, see File Storage and Scheduled Functions for details.
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 theuser
) - The ent or
null
on the optional end of the edge (ex: given auser
, load theirprofile
)
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 theuser
) - A list of ents on the end of the multiple edges (ex: given a
user
, load all themessages
)
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:
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:
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:
import { query } from "./functions";
export const list = query({
args: {},
handler: async (ctx) => {
return await ctx.table("messages").docs();
},
});
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.