GraphQL
Ponder automatically generates a GraphQL API based on the tables, views, and relations defined in ponder.schema.ts.
Setup
Enable on the server
To enable the GraphQL API, register the graphql Hono middleware.
import { db } from "ponder:api";
import schema from "ponder:schema";
import { Hono } from "hono";
import { graphql } from "ponder";
const app = new Hono();
app.use("/graphql", graphql({ db, schema }));
export default app;The graphql() middleware includes a GraphiQL interface in response to GET requests (you can visit it in your browser). GraphiQL is a useful tool to explore your schema and test queries.
Schema generation
The GraphQL schema includes singular and a plural query fields based on the tables and views defined in ponder.schema.ts.
| Type | Singular field | Plural field |
|---|---|---|
onchainTable | Yes | Yes (offset and cursor pagination) |
onchainView | No | Yes (offset pagination only) |
For example, if your schema contains a person table, the GraphQL schema will include a person and a persons field on the root Query type. The singular query field returns a single record (or null) and the plural query field returns a page of records.
import { onchainTable } from "ponder";
export const person = onchainTable("person", (t) => ({
id: t.integer().primaryKey(),
name: t.text().notNull(),
age: t.integer(),
}));Filtering
Use the where argument to filter for records that match certain criteria. The where argument type includes filter options for every column defined on a table. Here are the filter options available for each column type.
| Filter option | Available for column types | Include records where {column}... |
|---|---|---|
{column} | All | equals the value |
{column}_not | All | does not equal the value |
{column}_in | All primitives and enums | is one of the values |
{column}_not_in | All primitives and enums | is not one of the values |
{column}_gt | Numeric primitives | is greater than the value |
{column}_lt | Numeric primitives | is less than the value |
{column}_gte | Numeric primitives | is greater than or equal to the value |
{column}_lte | Numeric primitives | is less than or equal to the value |
{column}_contains | String primitives | contains the substring |
{column}_not_contains | String primitives | does not contain the substring |
{column}_starts_with | String primitives | starts with the substring |
{column}_not_starts_with | String primitives | does not start with the substring |
{column}_ends_with | String primitives | ends with the substring |
{column}_not_ends_with | String primitives | does not end with the substring |
{column}_has | Lists of primitives and enums | has the value as an element |
{column}_not_has | Lists of primitives and enums | does not have the value as an element |
You can compose filters using the AND and OR operators. These special fields accept an array of filter objects.
Examples
For the following examples, assume these records exist in your database.
[
{ "id": 1, "name": "Barry", "age": 57 },
{ "id": 2, "name": "Lucile", "age": 32 },
{ "id": 3, "name": "Sally", "age": 22 },
{ "id": 4, "name": "Pablo", "age": 71 },
]Get all person records with an age greater than 32.
query {
persons(where: { age_gt: 32 }) {
name
age
}
}Get all person records with a name that does not end with "y" and an age greater than 60. Note that when you include multiple filter conditions, they are combined with a logical AND.
query {
persons(
where: {
AND: [
{ name_not_ends_with: "y" },
{ age_gte: 60 }
]
}
) {
name
age
}
}Get all person records with a name that contains "ll" or an age greater than or equal to 50. In this case, we use the special OR operator to combine multiple filter conditions.
query {
persons(
where: {
OR: [
{ name_contains: "ll" },
{ age_gte: 50 }
]
}
) {
name
age
}
}Sorting
Use the orderBy and orderDirection arguments to sort the result by a column. By default, the result is sorted by the primary key column(s) in ascending order.
| Option | Default |
|---|---|
orderBy | Primary key column(s) |
orderDirection | "asc" |
Examples
query {
persons(orderBy: "age", orderDirection: "desc") {
name
age
}
}Pagination
Ponder supports both cursor and offset pagination through a unified interface on each plural query field and many() relationship field.
Here are some trade-offs to consider when choosing between cursor and offset pagination.
| Complexity | Query performance | Jump to any page | |
|---|---|---|---|
| Cursor pagination | More complex | Consistently fast | Not supported |
| Offset pagination | Simple | Slow for large result sets | Supported |
Page
Each plural field and many() relationship field returns a Page type which includes a list of items, a PageInfo object, and the total count of records that match the query.
import { onchainTable } from "ponder";
export const pet = onchainTable("pet", (t) => ({
id: t.text().primaryKey(),
name: t.text().notNull(),
}));Page info
The PageInfo object contains information about the position of the current page within the result set.
| name | type | |
|---|---|---|
| startCursor | String | Cursor of the first record in items |
| endCursor | String | Cursor of the last record in items |
| hasPreviousPage | Boolean! | Whether there are more records before this page |
| hasNextPage | Boolean! | Whether there are more records after this page |
The PageInfo type is the same for both pagination strategies, but the startCursor and endCursor values will always be null when using offset pagination.
Total count
The totalCount field returns the number of records present in the database that match the specified query. The value is the same regardless of the current pagination position and the limit argument. Only the where argument, or changes to the underlying dataset, can change the value of totalCount.
Cursor pagination
Cursor pagination works by taking the startCursor or endCursor value from the previous page's PageInfo and passing it as the before or after argument in the next query.
Cursor values
Each cursor value is an opaque string that encodes the position of a record in the result set.
- Cursor values should not be decoded or manipulated by the client. The only valid use of a cursor value is an argument, e.g.
after: previousPage.endCursor. - Cursor pagination works with any filter and sort criteria. However, do not change the filter or sort criteria between paginated requests. This will cause validation errors or incorrect results.
Examples
For the following examples, assume that these records exist in your database.
[
{ "id": 1, "name": "Barry", "age": 57 },
{ "id": 2, "name": "Lucile", "age": 32 },
{ "id": 3, "name": "Sally", "age": 22 },
{ "id": 4, "name": "Pablo", "age": 71 },
]First, make a request without specifying any pagination options. The items list will contain the first n=limit records that match the filter and sort criteria.
query {
persons(orderBy: "age", orderDirection: "asc", limit: 2) {
items {
name
age
}
pageInfo {
startCursor
endCursor
hasPreviousPage
hasNextPage
}
totalCount
}
}To paginate forwards, pass pageInfo.endCursor from the previous request as the after option in the next request.
query {
persons(
orderBy: "age",
orderDirection: "asc",
limit: 2,
after: "Mxhc3NDb3JlLTA="
) {
items {
name
age
}
pageInfo {
startCursor
endCursor
hasPreviousPage
hasNextPage
}
totalCount
}
}To paginate backwards, pass pageInfo.startCursor from the previous request as the before option in the next request.
query {
persons(
orderBy: "age",
orderDirection: "asc",
limit: 2,
before: "MxhcdoP9CVBhY"
) {
items {
name
age
}
pageInfo {
startCursor
endCursor
hasPreviousPage
hasNextPage
}
totalCount
}
}Offset pagination
Offset pagination works by increasing the offset argument with each successive query to "skip" rows that have already been fetched.
Examples
For the following examples, assume that these records exist in your database.
[
{ "id": 1, "name": "Barry", "age": 57 },
{ "id": 2, "name": "Lucile", "age": 32 },
{ "id": 3, "name": "Sally", "age": 22 },
{ "id": 4, "name": "Pablo", "age": 71 },
]First, make a request without specifying any pagination options. The items list will contain the first n=limit records that match the filter and sort criteria.
query {
persons(orderBy: "age", orderDirection: "asc", limit: 2) {
items {
name
age
}
pageInfo {
hasPreviousPage
hasNextPage
}
totalCount
}
}To paginate forwards, increase the offset argument by the limit value from the previous request.
query {
persons(
orderBy: "age",
orderDirection: "asc",
limit: 2,
offset: 2
) {
items {
name
age
}
pageInfo {
hasPreviousPage
hasNextPage
}
totalCount
}
}To jump to a specific page, calculate the offset as (pageNumber - 1) * limit. For example, to get page 2 with a limit of 2, use offset: 2.
query {
persons(
orderBy: "age",
orderDirection: "asc",
limit: 2,
offset: 2 # Page 2: (2 - 1) * 2 = 2
) {
items {
name
age
}
pageInfo {
hasPreviousPage
hasNextPage
}
totalCount
}
}You can also use offset pagination with filtering. The offset and limit are applied after the where filter.
query {
persons(
where: { age_gt: 30 },
orderBy: "age",
orderDirection: "asc",
limit: 1,
offset: 1
) {
items {
name
age
}
pageInfo {
hasPreviousPage
hasNextPage
}
totalCount
}
}Relationship fields
The GraphQL schema includes a relationship field for each one or many relation defined in your schema. One-to-many fields are very similar to the top-level plural query field, except they are automatically filtered by the parent row ID.
import { onchainTable, relations } from "ponder";
export const pet = onchainTable("pet", (t) => ({
id: t.text().primaryKey(),
name: t.text().notNull(),
ownerId: t.integer().notNull(),
}));
export const petRelations = relations(pet, ({ one }) => ({
owner: one(person, { fields: [pet.ownerId], references: [person.id] }),
}));
export const person = onchainTable("person", (t) => ({
id: t.integer().primaryKey(),
}));
export const personRelations = relations(person, ({ many }) => ({
dogs: many(pet),
}));Performance tips
Here are a few tips for speeding up slow GraphQL queries.
- Limit query depth: Each layer of depth in a GraphQL query introduces at least one additional sequential database query. Avoid queries that are more than 2 layers deep.
- Use pagination: Use cursor-based pagination to fetch records in smaller, more manageable chunks. This can help reduce the load on the database.
- Consider database indexes: Consider creating database indexes to speed up filters, joins, and sort conditions. Keep in mind that relations do not automatically create indexes. Read more.
- Enable horizontal scaling: If the GraphQL API is struggling to keep up with request volume, consider spreading the load across multiple instances. Read more.