Skip to content

Commit e8b3570

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

13 files changed

Lines changed: 488 additions & 12 deletions

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

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,4 +298,21 @@ 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+
}
309+
310+
protected async fetchManyBySQLFragmentWithCountInternalAsync(
311+
_queryInterface: any,
312+
_tableName: string,
313+
_sqlFragment: SQLFragment,
314+
_querySelectionModifiers: TableQuerySelectionModifiersWithOrderByFragment,
315+
): Promise<{ results: object[]; totalCount: number }> {
316+
throw new Error('SQL fragments not supported for StubDatabaseAdapter');
317+
}
301318
}

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: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,59 @@ 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+
288+
/**
289+
* Fetch many objects matching the SQL fragment along with the total count using a window function.
290+
* This is more efficient than running separate fetch and count queries when both are needed.
291+
*
292+
* @param queryContext - query context with which to perform the fetch
293+
* @param sqlFragment - SQLFragment for the WHERE clause of the query
294+
* @param querySelectionModifiers - limit, offset, and orderByFragment for the query
295+
* @returns Object containing both the results array and the total count
296+
*/
297+
async fetchManyBySQLFragmentWithCountAsync(
298+
queryContext: EntityQueryContext,
299+
sqlFragment: SQLFragment,
300+
querySelectionModifiers: PostgresQuerySelectionModifiersWithOrderByFragment<TFields>,
301+
): Promise<{ results: readonly Readonly<TFields>[]; totalCount: number }> {
302+
const { results, totalCount } = await this.fetchManyBySQLFragmentWithCountInternalAsync(
303+
queryContext.getQueryInterface(),
304+
this.entityConfiguration.tableName,
305+
sqlFragment,
306+
this.convertToTableQueryModifiersWithOrderByFragment(querySelectionModifiers),
307+
);
308+
309+
return {
310+
results: results.map((result) =>
311+
transformDatabaseObjectToFields(this.entityConfiguration, this.fieldTransformerMap, result),
312+
),
313+
totalCount,
314+
};
315+
}
316+
317+
protected abstract fetchManyBySQLFragmentWithCountInternalAsync(
318+
queryInterface: Knex,
319+
tableName: string,
320+
sqlFragment: SQLFragment,
321+
querySelectionModifiers: TableQuerySelectionModifiersWithOrderByFragment,
322+
): Promise<{ results: object[]; totalCount: number }>;
323+
271324
private convertToTableQueryModifiersWithOrderByRaw(
272325
querySelectionModifiers: PostgresQuerySelectionModifiersWithOrderByRaw<TFields>,
273326
): 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: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,65 @@ 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+
241+
/**
242+
* Fetches paginated results with total count using a window function.
243+
* This executes a single query that returns both the result set and the total count,
244+
* avoiding the need for two separate queries.
245+
*
246+
* The query uses COUNT(*) OVER() to calculate the total count as a window function,
247+
* which runs over the entire result set before LIMIT/OFFSET are applied.
248+
*/
249+
protected async fetchManyBySQLFragmentWithCountInternalAsync(
250+
queryInterface: Knex,
251+
tableName: string,
252+
sqlFragment: SQLFragment,
253+
querySelectionModifiers: TableQuerySelectionModifiersWithOrderByFragment,
254+
): Promise<{ results: object[]; totalCount: number }> {
255+
// Build the base query with window function for total count
256+
let query = queryInterface
257+
.select('*')
258+
.select(queryInterface.raw('COUNT(*) OVER() as __total_count'))
259+
.from(tableName)
260+
.whereRaw(sqlFragment.sql, sqlFragment.getKnexBindings());
261+
262+
// Apply order by modifiers
263+
query = this.applyQueryModifiersToQuery(query, querySelectionModifiers);
264+
const { orderByFragment } = querySelectionModifiers;
265+
if (orderByFragment !== undefined) {
266+
query = query.orderByRaw(orderByFragment.sql, orderByFragment.getKnexBindings());
267+
}
268+
269+
const rows = await wrapNativePostgresCallAsync(() => query);
270+
271+
// If no rows, return empty results with 0 count
272+
if (rows.length === 0) {
273+
return { results: [], totalCount: 0 };
274+
}
275+
276+
// Extract total count from the first row and remove it from all rows
277+
const totalCount = parseInt(rows[0].__total_count ?? '0', 10);
278+
const results = rows.map((row) => {
279+
const { __total_count, ...rest } = row;
280+
return rest;
281+
});
282+
283+
return { results, totalCount };
284+
}
285+
227286
protected async insertInternalAsync(
228287
queryInterface: Knex,
229288
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: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,23 @@ 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+
}
137+
138+
protected async fetchManyBySQLFragmentWithCountInternalAsync(
139+
_queryInterface: any,
140+
_tableName: string,
141+
_sqlFragment: any,
142+
_querySelectionModifiers: any,
143+
): Promise<{ results: object[]; totalCount: number }> {
144+
return { results: this.fetchSQLFragmentResults, totalCount: 0 };
145+
}
129146
}
130147

131148
describe(BasePostgresEntityDatabaseAdapter, () => {

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

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,4 +299,21 @@ 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+
}
310+
311+
protected async fetchManyBySQLFragmentWithCountInternalAsync(
312+
_queryInterface: any,
313+
_tableName: string,
314+
_sqlFragment: SQLFragment,
315+
_querySelectionModifiers: TableQuerySelectionModifiersWithOrderByFragment,
316+
): Promise<{ results: object[]; totalCount: number }> {
317+
throw new Error('SQL fragments not supported for StubDatabaseAdapter');
318+
}
302319
}

0 commit comments

Comments
 (0)