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) + ); + }); +}