Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add totalCount field to plural connection type #954

Open
wants to merge 8 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/neat-trains-crash.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@ponder/core": patch
---

Added totalCount field to plural query connection type
8 changes: 7 additions & 1 deletion docs/pages/docs/indexing/create-update-records.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -337,17 +339,21 @@ 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

<Callout type="warning" emoji="⚠️">Using the `withTotalCount` option may degrade query performance</Callout>

| name | type | |
| :---------- | :------------------------------- | :----------------------------------------------------------------- |
| **where** | `WhereInput \| undefined{:ts}` | Filter for records matching a set of criteria |
| **orderBy** | `OrderByInput \| undefined{:ts}` | Sort records by a column (default: `{ id: "asc" }{:ts}`) |
| **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

Expand Down
3 changes: 2 additions & 1 deletion docs/pages/docs/query/graphql.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<div className="code-columns">
```ts filename="ponder.schema.ts"
Expand Down Expand Up @@ -264,6 +264,7 @@ type Pet {
type PetPage {
items: [Pet!]!
pageInfo: PageInfo!
totalCount: Int
}
```

Expand Down
96 changes: 68 additions & 28 deletions packages/core/src/indexing-store/readonly.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import {

const DEFAULT_LIMIT = 50 as const;

type ResultWithCount = { ponder_totalCount?: number };

export const getReadonlyStore = ({
encoding,
schema,
Expand All @@ -33,13 +35,7 @@ export const getReadonlyStore = ({
db: HeadlessKysely<any>;
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 () => {
Expand Down Expand Up @@ -68,28 +64,50 @@ export const getReadonlyStore = ({
before = null,
after = null,
limit = DEFAULT_LIMIT,
withTotalCount = false,
}: {
tableName: string;
where?: WhereInput<any>;
orderBy?: OrderByInput<any>;
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) =>
buildWhereConditions({ eb, where, table, encoding }),
);
}

query = query.selectAll();

const orderByConditions = buildOrderByConditions({ orderBy, table });
for (const [column, direction] of orderByConditions) {
query = query.orderBy(
Expand Down Expand Up @@ -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();
Expand All @@ -143,7 +162,13 @@ export const getReadonlyStore = ({

return {
items: records,
pageInfo: { hasNextPage, hasPreviousPage, startCursor, endCursor },
pageInfo: {
hasNextPage,
hasPreviousPage,
startCursor,
endCursor,
},
totalCount,
};
}

Expand All @@ -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 {
Expand All @@ -179,6 +205,7 @@ export const getReadonlyStore = ({
startCursor,
endCursor,
},
totalCount,
};
}

Expand Down Expand Up @@ -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.
Expand All @@ -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 {
Expand All @@ -254,6 +287,7 @@ export const getReadonlyStore = ({
startCursor,
endCursor,
},
totalCount,
};
}

Expand Down Expand Up @@ -289,7 +323,13 @@ export const getReadonlyStore = ({

return {
items: records,
pageInfo: { hasNextPage, hasPreviousPage, startCursor, endCursor },
pageInfo: {
hasNextPage,
hasPreviousPage,
startCursor,
endCursor,
},
totalCount,
};
}
});
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/indexing-store/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export type ReadonlyStore = {
before?: string | null;
after?: string | null;
limit?: number;
withTotalCount?: boolean;
}): Promise<{
items: UserRecord[];
pageInfo: {
Expand All @@ -28,6 +29,7 @@ export type ReadonlyStore = {
hasNextPage: boolean;
hasPreviousPage: boolean;
};
totalCount?: number;
}>;
};

Expand Down
8 changes: 7 additions & 1 deletion packages/core/src/server/graphql/entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ const GraphQLPageInfo = new GraphQLObjectType({
hasPreviousPage: { type: new GraphQLNonNull(GraphQLBoolean) },
startCursor: { type: GraphQLString },
endCursor: { type: GraphQLString },
totalCount: { type: GraphQLInt },
},
});

Expand Down Expand Up @@ -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] = {
Expand Down Expand Up @@ -179,6 +184,7 @@ export const buildEntityTypes = ({
),
},
pageInfo: { type: new GraphQLNonNull(GraphQLPageInfo) },
totalCount: { type: GraphQLInt },
}),
});
}
Expand Down
6 changes: 5 additions & 1 deletion packages/core/src/server/graphql/plural.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { hasSubfield } from "@/utils/hasSubfield.js";
import {
type GraphQLFieldConfig,
type GraphQLFieldResolver,
Expand Down Expand Up @@ -30,7 +31,7 @@ export const buildPluralField = ({
entityPageType: GraphQLObjectType;
entityFilterType: GraphQLInputObjectType;
}): GraphQLFieldConfig<Parent, Context> => {
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) : {};
Expand All @@ -39,13 +40,16 @@ export const buildPluralField = ({
? { [orderBy]: orderDirection || "asc" }
: undefined;

const withTotalCount = hasSubfield(info, ["totalCount"]);

return await context.store.findMany({
tableName,
where: whereObject,
orderBy: orderByObject,
limit,
before,
after,
withTotalCount,
});
};

Expand Down
53 changes: 53 additions & 0 deletions packages/core/src/utils/hasSubfield.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
Loading
Loading