diff --git a/.changeset/neat-trains-crash.md b/.changeset/neat-trains-crash.md
new file mode 100644
index 000000000..6ecd9e6dc
--- /dev/null
+++ b/.changeset/neat-trains-crash.md
@@ -0,0 +1,5 @@
+---
+"@ponder/core": patch
+---
+
+Added totalCount field to plural query connection type
diff --git a/docs/pages/docs/indexing/create-update-records.mdx b/docs/pages/docs/indexing/create-update-records.mdx
index 8585da6fb..1aef768f8 100644
--- a/docs/pages/docs/indexing/create-update-records.mdx
+++ b/docs/pages/docs/indexing/create-update-records.mdx
@@ -3,6 +3,8 @@ title: "Create and Update Records"
description: "Learn how to create and update records in the Ponder database."
---
+import { Callout } from 'nextra/components'
+
# Create & update records
Ponder's store API is inspired by the [Prisma Client API](https://www.prisma.io/docs/reference/api-reference/prisma-client-reference#model-queries). The store supports the following methods.
@@ -337,10 +339,12 @@ const sara = await Player.findUnique({
## `findMany`
-`findMany` returns a list of records according to the filter, sort, and pagination options you provide. Note that `findMany` offers programmatic access to the functionality exposed by the autogenerated [GraphQL API](/docs/query/graphql).
+`findMany` returns a list of records according to the filter, sort, count, and pagination options you provide. Note that `findMany` offers programmatic access to the functionality exposed by the autogenerated [GraphQL API](/docs/query/graphql).
### Options
+Using the `withTotalCount` option may degrade query performance
+
| name | type | |
| :---------- | :------------------------------- | :----------------------------------------------------------------- |
| **where** | `WhereInput \| undefined{:ts}` | Filter for records matching a set of criteria |
@@ -348,6 +352,8 @@ const sara = await Player.findUnique({
| **before** | `string \| undefined{:ts}` | Return records before this cursor |
| **after** | `string \| undefined{:ts}` | Return records after this cursor |
| **limit** | `number \| undefined{:ts}` | Number of records to return (default: `50{:ts}`, max: `1000{:ts}`) |
+| **withTotalCount** | `boolean \| undefined{:ts}` | Return the total number of matching records (default: `false{:ts}`) |
+
### Returns
diff --git a/docs/pages/docs/query/graphql.mdx b/docs/pages/docs/query/graphql.mdx
index 4a0b0059b..79775504d 100644
--- a/docs/pages/docs/query/graphql.mdx
+++ b/docs/pages/docs/query/graphql.mdx
@@ -233,7 +233,7 @@ The GraphQL API supports cursor pagination using an API that's inspired by the [
### Page type
-Top-level plural query fields and `p.many()` relationship fields return a `Page` type containing a list of items and a `PageInfo` object.
+Top-level plural query fields and `p.many()` relationship fields return a `Page` type containing a list of items, a `PageInfo` object, and the `totalCount` of matching items.
```ts filename="ponder.schema.ts"
@@ -264,6 +264,7 @@ type Pet {
type PetPage {
items: [Pet!]!
pageInfo: PageInfo!
+ totalCount: Int
}
```
diff --git a/packages/core/src/indexing-store/readonly.ts b/packages/core/src/indexing-store/readonly.ts
index 790c8d1bf..bc496b9c3 100644
--- a/packages/core/src/indexing-store/readonly.ts
+++ b/packages/core/src/indexing-store/readonly.ts
@@ -20,6 +20,8 @@ import {
const DEFAULT_LIMIT = 50 as const;
+type ResultWithCount = { ponder_totalCount?: number };
+
export const getReadonlyStore = ({
encoding,
schema,
@@ -33,13 +35,7 @@ export const getReadonlyStore = ({
db: HeadlessKysely
;
common: Common;
}): ReadonlyStore => ({
- findUnique: async ({
- tableName,
- id,
- }: {
- tableName: string;
- id: UserId;
- }) => {
+ findUnique: async ({ tableName, id }: { tableName: string; id: UserId }) => {
const table = (schema[tableName] as { table: Table }).table;
return db.wrap({ method: `${tableName}.findUnique` }, async () => {
@@ -68,6 +64,7 @@ export const getReadonlyStore = ({
before = null,
after = null,
limit = DEFAULT_LIMIT,
+ withTotalCount = false,
}: {
tableName: string;
where?: WhereInput;
@@ -75,14 +72,33 @@ export const getReadonlyStore = ({
before?: string | null;
after?: string | null;
limit?: number;
+ withTotalCount?: boolean;
}) => {
const table = (schema[tableName] as { table: Table }).table;
return db.wrap({ method: `${tableName}.findMany` }, async () => {
let query = db
.withSchema(namespaceInfo.userNamespace)
- .selectFrom(tableName)
- .selectAll();
+ .selectFrom(tableName);
+
+ if (withTotalCount) {
+ query = db
+ .withSchema(namespaceInfo.userNamespace)
+ .with("ponder_totalCount", (db) => {
+ let totalCountQuery = db.selectFrom(tableName);
+
+ if (where) {
+ totalCountQuery = totalCountQuery.where((eb) =>
+ buildWhereConditions({ eb, where, table, encoding }),
+ );
+ }
+
+ return totalCountQuery.select(({ fn }) =>
+ fn.count("id").as("ponder_totalCount"),
+ );
+ })
+ .selectFrom(["ponder_totalCount", tableName]);
+ }
if (where) {
query = query.where((eb) =>
@@ -90,6 +106,8 @@ export const getReadonlyStore = ({
);
}
+ query = query.selectAll();
+
const orderByConditions = buildOrderByConditions({ orderBy, table });
for (const [column, direction] of orderByConditions) {
query = query.orderBy(
@@ -121,11 +139,12 @@ export const getReadonlyStore = ({
// Neither cursors are specified, apply the order conditions and execute.
if (after === null && before === null) {
query = query.limit(limit + 1);
- const records = await query
- .execute()
- .then((records) =>
- records.map((record) => decodeRecord({ record, table, encoding })),
- );
+ const results = await query.execute();
+ const totalCount =
+ (results.at(0) as ResultWithCount)?.ponder_totalCount || 0;
+ const records = results.map((record) =>
+ decodeRecord({ record, table, encoding }),
+ );
if (records.length === limit + 1) {
records.pop();
@@ -143,7 +162,13 @@ export const getReadonlyStore = ({
return {
items: records,
- pageInfo: { hasNextPage, hasPreviousPage, startCursor, endCursor },
+ pageInfo: {
+ hasNextPage,
+ hasPreviousPage,
+ startCursor,
+ endCursor,
+ },
+ totalCount,
};
}
@@ -164,11 +189,12 @@ export const getReadonlyStore = ({
)
.limit(limit + 2);
- const records = await query
- .execute()
- .then((records) =>
- records.map((record) => decodeRecord({ record, table, encoding })),
- );
+ const results = await query.execute();
+ const totalCount =
+ (results.at(0) as ResultWithCount)?.ponder_totalCount || 0;
+ const records = results.map((record) =>
+ decodeRecord({ record, table, encoding }),
+ );
if (records.length === 0) {
return {
@@ -179,6 +205,7 @@ export const getReadonlyStore = ({
startCursor,
endCursor,
},
+ totalCount,
};
}
@@ -211,7 +238,13 @@ export const getReadonlyStore = ({
return {
items: records,
- pageInfo: { hasNextPage, hasPreviousPage, startCursor, endCursor },
+ pageInfo: {
+ hasNextPage,
+ hasPreviousPage,
+ startCursor,
+ endCursor,
+ },
+ totalCount,
};
} else {
// User specified a 'before' cursor.
@@ -238,12 +271,12 @@ export const getReadonlyStore = ({
query = query.orderBy(column, direction);
}
- const records = await query.execute().then((records) =>
- records
- .map((record) => decodeRecord({ record, table, encoding }))
- // Reverse the records again, back to the original order.
- .reverse(),
- );
+ const results = await query.execute();
+ const totalCount =
+ (results.at(0) as ResultWithCount)?.ponder_totalCount || 0;
+ const records = results
+ .map((record) => decodeRecord({ record, table, encoding }))
+ .reverse();
if (records.length === 0) {
return {
@@ -254,6 +287,7 @@ export const getReadonlyStore = ({
startCursor,
endCursor,
},
+ totalCount,
};
}
@@ -289,7 +323,13 @@ export const getReadonlyStore = ({
return {
items: records,
- pageInfo: { hasNextPage, hasPreviousPage, startCursor, endCursor },
+ pageInfo: {
+ hasNextPage,
+ hasPreviousPage,
+ startCursor,
+ endCursor,
+ },
+ totalCount,
};
}
});
diff --git a/packages/core/src/indexing-store/store.ts b/packages/core/src/indexing-store/store.ts
index 2af76f254..b9cd5f32a 100644
--- a/packages/core/src/indexing-store/store.ts
+++ b/packages/core/src/indexing-store/store.ts
@@ -20,6 +20,7 @@ export type ReadonlyStore = {
before?: string | null;
after?: string | null;
limit?: number;
+ withTotalCount?: boolean;
}): Promise<{
items: UserRecord[];
pageInfo: {
@@ -28,6 +29,7 @@ export type ReadonlyStore = {
hasNextPage: boolean;
hasPreviousPage: boolean;
};
+ totalCount?: number;
}>;
};
diff --git a/packages/core/src/server/graphql/entity.ts b/packages/core/src/server/graphql/entity.ts
index 9c57eab74..8f8da576f 100644
--- a/packages/core/src/server/graphql/entity.ts
+++ b/packages/core/src/server/graphql/entity.ts
@@ -36,6 +36,7 @@ const GraphQLPageInfo = new GraphQLObjectType({
hasPreviousPage: { type: new GraphQLNonNull(GraphQLBoolean) },
startCursor: { type: GraphQLString },
endCursor: { type: GraphQLString },
+ totalCount: { type: GraphQLInt },
},
});
@@ -124,7 +125,11 @@ export const buildEntityTypes = ({
const ids = result.items.map((item) => item.id);
const items = await loader.loadMany(ids);
- return { items, pageInfo: result.pageInfo };
+ return {
+ items,
+ pageInfo: result.pageInfo,
+ totalCount: result.totalCount,
+ };
};
fieldConfigMap[columnName] = {
@@ -179,6 +184,7 @@ export const buildEntityTypes = ({
),
},
pageInfo: { type: new GraphQLNonNull(GraphQLPageInfo) },
+ totalCount: { type: GraphQLInt },
}),
});
}
diff --git a/packages/core/src/server/graphql/plural.ts b/packages/core/src/server/graphql/plural.ts
index 323857331..2b616c4d3 100644
--- a/packages/core/src/server/graphql/plural.ts
+++ b/packages/core/src/server/graphql/plural.ts
@@ -1,3 +1,4 @@
+import { hasSubfield } from "@/utils/hasSubfield.js";
import {
type GraphQLFieldConfig,
type GraphQLFieldResolver,
@@ -30,7 +31,7 @@ export const buildPluralField = ({
entityPageType: GraphQLObjectType;
entityFilterType: GraphQLInputObjectType;
}): GraphQLFieldConfig => {
- const resolver: PluralResolver = async (_, args, context) => {
+ const resolver: PluralResolver = async (_, args, context, info) => {
const { where, orderBy, orderDirection, before, limit, after } = args;
const whereObject = where ? buildWhereObject(where) : {};
@@ -39,6 +40,8 @@ export const buildPluralField = ({
? { [orderBy]: orderDirection || "asc" }
: undefined;
+ const withTotalCount = hasSubfield(info, ["totalCount"]);
+
return await context.store.findMany({
tableName,
where: whereObject,
@@ -46,6 +49,7 @@ export const buildPluralField = ({
limit,
before,
after,
+ withTotalCount,
});
};
diff --git a/packages/core/src/utils/hasSubfield.test.ts b/packages/core/src/utils/hasSubfield.test.ts
new file mode 100644
index 000000000..aac9c9566
--- /dev/null
+++ b/packages/core/src/utils/hasSubfield.test.ts
@@ -0,0 +1,53 @@
+import type { GraphQLResolveInfo } from "graphql";
+import { Kind } from "graphql";
+import { expect, test } from "vitest";
+import { hasSubfield } from "./hasSubfield.js";
+
+const resolveInfo: any = {
+ fieldName: "pageInfo",
+ fieldNodes: [
+ {
+ kind: Kind.FIELD,
+ name: {
+ kind: Kind.NAME,
+ value: "table",
+ },
+ selectionSet: {
+ kind: Kind.SELECTION_SET,
+ selections: [
+ {
+ kind: Kind.FIELD,
+ name: {
+ kind: Kind.NAME,
+ value: "pageInfo",
+ },
+ selectionSet: {
+ kind: Kind.SELECTION_SET,
+ selections: [
+ {
+ kind: Kind.FIELD,
+ name: {
+ kind: Kind.NAME,
+ value: "totalCount",
+ },
+ },
+ ],
+ },
+ },
+ ],
+ },
+ },
+ ],
+};
+
+test("hasSubfield detects presence of subfield in info", () => {
+ expect(
+ hasSubfield(resolveInfo as GraphQLResolveInfo, ["pageInfo", "totalCount"]),
+ ).toEqual(true);
+});
+
+test("hasSubfield detects absence of subfield in info", () => {
+ expect(
+ hasSubfield(resolveInfo as GraphQLResolveInfo, ["pageInfo", "startCursor"]),
+ ).toEqual(false);
+});
diff --git a/packages/core/src/utils/hasSubfield.ts b/packages/core/src/utils/hasSubfield.ts
new file mode 100644
index 000000000..84043c069
--- /dev/null
+++ b/packages/core/src/utils/hasSubfield.ts
@@ -0,0 +1,33 @@
+import type { FieldNode, GraphQLResolveInfo, SelectionNode } from "graphql";
+
+export function hasSubfield(
+ info: GraphQLResolveInfo,
+ pathArray: string[],
+): boolean {
+ return info.fieldNodes.some((fieldNode) =>
+ findSubfieldRecursive(fieldNode, pathArray),
+ );
+}
+
+function findSubfieldRecursive(node: FieldNode, pathArray: string[]): boolean {
+ if (pathArray.length === 0) {
+ return true;
+ }
+
+ if (!node.selectionSet) {
+ return false;
+ }
+
+ const [currentField, ...remainingPath] = pathArray;
+
+ return node.selectionSet.selections.some((selection: SelectionNode) => {
+ if (selection.kind !== "Field") {
+ return false;
+ }
+ const fieldName = selection.name.value;
+ return (
+ fieldName === currentField &&
+ findSubfieldRecursive(selection as FieldNode, remainingPath)
+ );
+ });
+}