Skip to content

Commit 50a20a7

Browse files
committed
feat: add includeTotalCount to pagination
1 parent 0b4f87d commit 50a20a7

11 files changed

Lines changed: 295 additions & 2 deletions

packages/entity-database-adapter-knex-testing-utils/src/StubPostgresDatabaseAdapter.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,4 +298,12 @@ export class StubPostgresDatabaseAdapter<
298298
objectCollection.splice(objectIndex, 1);
299299
return 1;
300300
}
301+
302+
protected async fetchCountBySQLFragmentInternalAsync(
303+
_queryInterface: any,
304+
_tableName: string,
305+
_sqlFragment: any,
306+
): Promise<number> {
307+
throw new Error('SQL fragments not supported for StubDatabaseAdapter');
308+
}
301309
}

packages/entity-database-adapter-knex-testing-utils/src/__tests__/StubPostgresDatabaseAdapter-test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -520,6 +520,19 @@ describe(StubPostgresDatabaseAdapter, () => {
520520
});
521521
});
522522

523+
describe('fetchCountBySQLFragmentAsync', () => {
524+
it('throws because it is unsupported', async () => {
525+
const queryContext = instance(mock(EntityQueryContext));
526+
const databaseAdapter = new StubPostgresDatabaseAdapter<TestFields, 'customIdField'>(
527+
testEntityConfiguration,
528+
new Map(),
529+
);
530+
await expect(
531+
databaseAdapter.fetchCountBySQLFragmentAsync(queryContext, sql``),
532+
).rejects.toThrow();
533+
});
534+
});
535+
523536
describe('insertAsync', () => {
524537
it('inserts a record', async () => {
525538
const queryContext = instance(mock(EntityQueryContext));

packages/entity-database-adapter-knex/src/AuthorizationResultBasedKnexEntityLoader.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,13 @@ interface EntityLoaderBasePaginationArgs<
9797
* Cursor fields to use for pagination. If not provided, the order by fields will be used as the cursor fields.
9898
*/
9999
cursorFields?: readonly (keyof TFields)[];
100+
101+
/**
102+
* Whether to include the total count of matching entities in the result.
103+
* When true, uses a window function to count all matching rows, which may impact performance on large datasets.
104+
* The total count is available in the connection's totalCount field.
105+
*/
106+
includeTotal?: boolean;
100107
}
101108

102109
/**
@@ -309,6 +316,7 @@ export class AuthorizationResultBasedKnexEntityLoader<
309316
return {
310317
edges,
311318
pageInfo,
319+
...(pageResult.totalCount !== undefined && { totalCount: pageResult.totalCount }),
312320
};
313321
}
314322
}

packages/entity-database-adapter-knex/src/BasePostgresEntityDatabaseAdapter.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,23 @@ export abstract class BasePostgresEntityDatabaseAdapter<
268268
querySelectionModifiers: TableQuerySelectionModifiersWithOrderByFragment,
269269
): Promise<object[]>;
270270

271+
async fetchCountBySQLFragmentAsync(
272+
queryContext: EntityQueryContext,
273+
sqlFragment: SQLFragment,
274+
): Promise<number> {
275+
return await this.fetchCountBySQLFragmentInternalAsync(
276+
queryContext.getQueryInterface(),
277+
this.entityConfiguration.tableName,
278+
sqlFragment,
279+
);
280+
}
281+
282+
protected abstract fetchCountBySQLFragmentInternalAsync(
283+
queryInterface: Knex,
284+
tableName: string,
285+
sqlFragment: SQLFragment,
286+
): Promise<number>;
287+
271288
private convertToTableQueryModifiersWithOrderByRaw(
272289
querySelectionModifiers: PostgresQuerySelectionModifiersWithOrderByRaw<TFields>,
273290
): TableQuerySelectionModifiersWithOrderByRaw {

packages/entity-database-adapter-knex/src/EnforcingKnexEntityLoader.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,7 @@ export class EnforcingKnexEntityLoader<
238238
return {
239239
edges,
240240
pageInfo: pageResult.pageInfo,
241+
...(pageResult.totalCount !== undefined && { totalCount: pageResult.totalCount }),
241242
};
242243
}
243244
}

packages/entity-database-adapter-knex/src/PostgresEntityDatabaseAdapter.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,20 @@ export class PostgresEntityDatabaseAdapter<
224224
return await wrapNativePostgresCallAsync(() => query);
225225
}
226226

227+
protected async fetchCountBySQLFragmentInternalAsync(
228+
queryInterface: Knex,
229+
tableName: string,
230+
sqlFragment: SQLFragment,
231+
): Promise<number> {
232+
const query = queryInterface
233+
.count('* as count')
234+
.from(tableName)
235+
.whereRaw(sqlFragment.sql, sqlFragment.getKnexBindings())
236+
.first();
237+
const result = await wrapNativePostgresCallAsync(() => query);
238+
return parseInt(result?.count ?? '0', 10);
239+
}
240+
227241
protected async insertInternalAsync(
228242
queryInterface: Knex,
229243
tableName: string,

packages/entity-database-adapter-knex/src/__integration-tests__/PostgresEntityIntegration-test.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1774,5 +1774,93 @@ describe('postgres entity integration', () => {
17741774
expect(page2.edges).toHaveLength(1);
17751775
expect(page2.pageInfo.hasNextPage).toBe(false);
17761776
});
1777+
1778+
it('includes total count when includeTotal is true', async () => {
1779+
const vc = new ViewerContext(createKnexIntegrationTestEntityCompanionProvider(knexInstance));
1780+
1781+
await PostgresTestEntity.dropPostgresTableAsync(knexInstance);
1782+
await PostgresTestEntity.createOrTruncatePostgresTableAsync(knexInstance);
1783+
1784+
// Create test data
1785+
const names = ['Alice', 'Bob', 'Charlie', 'David', 'Eve', 'Frank', 'Grace', 'Henry'];
1786+
for (let i = 0; i < names.length; i++) {
1787+
await PostgresTestEntity.creator(vc)
1788+
.setField('name', names[i]!)
1789+
.setField('hasACat', i % 2 === 0)
1790+
.setField('hasADog', i % 3 === 0)
1791+
.createAsync();
1792+
}
1793+
1794+
// Test includeTotal with no filter
1795+
const pageWithTotal = await PostgresTestEntity.knexLoader(vc).loadPageBySQLAsync({
1796+
first: 3,
1797+
orderBy: [{ fieldName: 'name', order: OrderByOrdering.ASCENDING }],
1798+
includeTotal: true,
1799+
});
1800+
1801+
expect(pageWithTotal.edges).toHaveLength(3);
1802+
expect(pageWithTotal.totalCount).toBe(8);
1803+
expect(pageWithTotal.edges[0]?.node.getField('name')).toBe('Alice');
1804+
expect(pageWithTotal.edges[1]?.node.getField('name')).toBe('Bob');
1805+
expect(pageWithTotal.edges[2]?.node.getField('name')).toBe('Charlie');
1806+
1807+
// Test includeTotal with where clause
1808+
const pageWithFilter = await PostgresTestEntity.knexLoader(vc).loadPageBySQLAsync({
1809+
first: 2,
1810+
where: sql`has_a_cat = ${true}`,
1811+
orderBy: [{ fieldName: 'name', order: OrderByOrdering.ASCENDING }],
1812+
includeTotal: true,
1813+
});
1814+
1815+
expect(pageWithFilter.edges).toHaveLength(2);
1816+
expect(pageWithFilter.totalCount).toBe(4); // Alice, Charlie, Eve, Grace have cats
1817+
expect(pageWithFilter.edges[0]?.node.getField('name')).toBe('Alice');
1818+
expect(pageWithFilter.edges[1]?.node.getField('name')).toBe('Charlie');
1819+
1820+
// Test that totalCount is not affected by pagination cursor
1821+
const nextPageWithTotal = await PostgresTestEntity.knexLoader(vc).loadPageBySQLAsync({
1822+
first: 2,
1823+
after: pageWithFilter.pageInfo.endCursor!,
1824+
where: sql`has_a_cat = ${true}`,
1825+
orderBy: [{ fieldName: 'name', order: OrderByOrdering.ASCENDING }],
1826+
includeTotal: true,
1827+
});
1828+
1829+
expect(nextPageWithTotal.edges).toHaveLength(2);
1830+
expect(nextPageWithTotal.totalCount).toBe(4); // Total count remains the same
1831+
expect(nextPageWithTotal.edges[0]?.node.getField('name')).toBe('Eve');
1832+
expect(nextPageWithTotal.edges[1]?.node.getField('name')).toBe('Grace');
1833+
1834+
// Test backward pagination with includeTotal
1835+
const backwardPageWithTotal = await PostgresTestEntity.knexLoader(vc).loadPageBySQLAsync({
1836+
last: 2,
1837+
orderBy: [{ fieldName: 'name', order: OrderByOrdering.ASCENDING }],
1838+
includeTotal: true,
1839+
});
1840+
1841+
expect(backwardPageWithTotal.edges).toHaveLength(2);
1842+
expect(backwardPageWithTotal.totalCount).toBe(8);
1843+
expect(backwardPageWithTotal.edges[0]?.node.getField('name')).toBe('Grace');
1844+
expect(backwardPageWithTotal.edges[1]?.node.getField('name')).toBe('Henry');
1845+
1846+
// Test that totalCount is undefined when includeTotal is false
1847+
const pageWithoutTotal = await PostgresTestEntity.knexLoader(vc).loadPageBySQLAsync({
1848+
first: 3,
1849+
orderBy: [{ fieldName: 'name', order: OrderByOrdering.ASCENDING }],
1850+
includeTotal: false,
1851+
});
1852+
1853+
expect(pageWithoutTotal.edges).toHaveLength(3);
1854+
expect(pageWithoutTotal.totalCount).toBeUndefined();
1855+
1856+
// Test that totalCount is undefined when includeTotal is not specified
1857+
const pageDefaultNoTotal = await PostgresTestEntity.knexLoader(vc).loadPageBySQLAsync({
1858+
first: 3,
1859+
orderBy: [{ fieldName: 'name', order: OrderByOrdering.ASCENDING }],
1860+
});
1861+
1862+
expect(pageDefaultNoTotal.edges).toHaveLength(3);
1863+
expect(pageDefaultNoTotal.totalCount).toBeUndefined();
1864+
});
17771865
});
17781866
});

packages/entity-database-adapter-knex/src/__tests__/BasePostgresEntityDatabaseAdapter-test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,14 @@ class TestEntityDatabaseAdapter extends BasePostgresEntityDatabaseAdapter<
126126
): Promise<number> {
127127
return this.deleteCount;
128128
}
129+
130+
protected async fetchCountBySQLFragmentInternalAsync(
131+
_queryInterface: any,
132+
_tableName: string,
133+
_sqlFragment: any,
134+
): Promise<number> {
135+
return 0;
136+
}
129137
}
130138

131139
describe(BasePostgresEntityDatabaseAdapter, () => {

packages/entity-database-adapter-knex/src/__tests__/fixtures/StubPostgresDatabaseAdapter.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,4 +299,12 @@ export class StubPostgresDatabaseAdapter<
299299
objectCollection.splice(objectIndex, 1);
300300
return 1;
301301
}
302+
303+
protected async fetchCountBySQLFragmentInternalAsync(
304+
_queryInterface: any,
305+
_tableName: string,
306+
_sqlFragment: any,
307+
): Promise<number> {
308+
throw new Error('SQL fragments not supported for StubDatabaseAdapter');
309+
}
302310
}

packages/entity-database-adapter-knex/src/internal/EntityKnexDataManager.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ interface BasePaginationArgs<TFields extends Record<string, any>> {
2727
where?: SQLFragment;
2828
orderBy?: PostgresOrderByClause<TFields>[];
2929
cursorFields?: readonly (keyof TFields)[];
30+
includeTotal?: boolean;
3031
}
3132

3233
/**
@@ -80,6 +81,11 @@ export interface PageInfo {
8081
export interface Connection<TNode> {
8182
edges: Edge<TNode>[];
8283
pageInfo: PageInfo;
84+
/**
85+
* Total count of all matching entities (not just those in the current page).
86+
* Only present when includeTotal is true in the request.
87+
*/
88+
totalCount?: number;
8389
}
8490

8591
/**
@@ -204,7 +210,7 @@ export class EntityKnexDataManager<
204210
}
205211

206212
const isForward = 'first' in args;
207-
const { where, orderBy, cursorFields } = args;
213+
const { where, orderBy, cursorFields, includeTotal } = args;
208214

209215
let limit: number;
210216
let cursor: string | undefined;
@@ -248,7 +254,8 @@ export class EntityKnexDataManager<
248254
}));
249255

250256
// Fetch data with limit + 1 to check for more pages
251-
const fieldObjects = await timeAndLogLoadEventAsync(
257+
// When includeTotal is true, also run a count query in parallel
258+
const fetchPromise = timeAndLogLoadEventAsync(
252259
this.metricsAdapter,
253260
EntityMetricsLoadType.LOAD_PAGE,
254261
this.entityClassName,
@@ -260,6 +267,13 @@ export class EntityKnexDataManager<
260267
}),
261268
);
262269

270+
const [fieldObjects, totalCount] = await Promise.all([
271+
fetchPromise,
272+
includeTotal
273+
? this.databaseAdapter.fetchCountBySQLFragmentAsync(queryContext, where ?? sql`1 = 1`)
274+
: Promise.resolve(undefined),
275+
]);
276+
263277
// Process results
264278
const hasMore = fieldObjects.length > limit;
265279
const pageFieldObjects = hasMore ? fieldObjects.slice(0, limit) : [...fieldObjects];
@@ -284,6 +298,7 @@ export class EntityKnexDataManager<
284298
return {
285299
edges,
286300
pageInfo,
301+
...(totalCount !== undefined && { totalCount }),
287302
};
288303
}
289304

0 commit comments

Comments
 (0)