Skip to content

Commit 56aeab5

Browse files
committed
feat: add includeTotalCount to pagination
1 parent 351382a commit 56aeab5

14 files changed

Lines changed: 708 additions & 16 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: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -520,6 +520,32 @@ 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+
536+
describe('fetchManyBySQLFragmentWithCountAsync', () => {
537+
it('throws because it is unsupported', async () => {
538+
const queryContext = instance(mock(EntityQueryContext));
539+
const databaseAdapter = new StubPostgresDatabaseAdapter<TestFields, 'customIdField'>(
540+
testEntityConfiguration,
541+
new Map(),
542+
);
543+
await expect(
544+
databaseAdapter.fetchManyBySQLFragmentWithCountAsync(queryContext, sql``, {}),
545+
).rejects.toThrow();
546+
});
547+
});
548+
523549
describe('insertAsync', () => {
524550
it('inserts a record', async () => {
525551
const queryContext = instance(mock(EntityQueryContext));

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,17 @@ interface EntityLoaderBasePaginationArgs<
9292
* Order the entities by specified columns and orders. If ID field is not included in the orderBy, it will be automatically included as the last orderBy field to ensure stable pagination.
9393
*/
9494
orderBy?: EntityLoaderOrderByClause<TFields, TSelectedFields>[];
95+
96+
/**
97+
* Whether to calculate and include the Connection totalCount field containing the total count of entities matching the where SQLFragment.
98+
*
99+
* Note that this may be an expensive operation, especially for large datasets, as it may require an additional SQL query with a COUNT(*) aggregation.
100+
* It is recommended to only set this to true when necessary for the client application, such as when implementing pagination UIs that need to know the total number of pages.
101+
*
102+
* When true and cursor is non-null, the total count is calculated by running a separate COUNT(*) query with the same WHERE clause.
103+
* When true and cursor is null, the total count is calculated by running the main query with a window function to count all matching rows, which is slightly faster but still expensive.
104+
*/
105+
includeTotal?: boolean;
95106
}
96107

97108
/**
@@ -304,6 +315,7 @@ export class AuthorizationResultBasedKnexEntityLoader<
304315
return {
305316
edges,
306317
pageInfo,
318+
...(pageResult.totalCount !== undefined && { totalCount: pageResult.totalCount }),
307319
};
308320
}
309321
}

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

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

271+
/**
272+
* Fetch total count of objects matching the SQL fragment.
273+
*
274+
* Note that this may be an expensive operation, especially for large datasets, as it does a full query with a COUNT(*) aggregation.
275+
*
276+
* @param queryContext - query context with which to perform the fetch
277+
* @param sqlFragment - SQLFragment for the WHERE clause of the query
278+
* @returns total count of objects matching the SQL fragment
279+
*/
280+
async fetchCountBySQLFragmentAsync(
281+
queryContext: EntityQueryContext,
282+
sqlFragment: SQLFragment,
283+
): Promise<number> {
284+
return await this.fetchCountBySQLFragmentInternalAsync(
285+
queryContext.getQueryInterface(),
286+
this.entityConfiguration.tableName,
287+
sqlFragment,
288+
);
289+
}
290+
291+
protected abstract fetchCountBySQLFragmentInternalAsync(
292+
queryInterface: Knex,
293+
tableName: string,
294+
sqlFragment: SQLFragment,
295+
): Promise<number>;
296+
297+
/**
298+
* Fetch many objects matching the SQL fragment along with the total count using a window function.
299+
* This is more efficient than running separate fetch and count queries when both are needed.
300+
*
301+
* @param queryContext - query context with which to perform the fetch
302+
* @param sqlFragment - SQLFragment for the WHERE clause of the query
303+
* @param querySelectionModifiers - limit, offset, and orderByFragment for the query
304+
* @returns Object containing both the results array and the total count
305+
*/
306+
async fetchManyBySQLFragmentWithCountAsync(
307+
queryContext: EntityQueryContext,
308+
sqlFragment: SQLFragment,
309+
querySelectionModifiers: PostgresQuerySelectionModifiersWithOrderByFragment<TFields>,
310+
): Promise<{ results: readonly Readonly<TFields>[]; totalCount: number }> {
311+
const { results, totalCount } = await this.fetchManyBySQLFragmentWithCountInternalAsync(
312+
queryContext.getQueryInterface(),
313+
this.entityConfiguration.tableName,
314+
sqlFragment,
315+
this.convertToTableQueryModifiersWithOrderByFragment(querySelectionModifiers),
316+
);
317+
318+
return {
319+
results: results.map((result) =>
320+
transformDatabaseObjectToFields(this.entityConfiguration, this.fieldTransformerMap, result),
321+
),
322+
totalCount,
323+
};
324+
}
325+
326+
protected abstract fetchManyBySQLFragmentWithCountInternalAsync(
327+
queryInterface: Knex,
328+
tableName: string,
329+
sqlFragment: SQLFragment,
330+
querySelectionModifiers: TableQuerySelectionModifiersWithOrderByFragment,
331+
): Promise<{ results: object[]; totalCount: number }>;
332+
271333
private convertToTableQueryModifiersWithOrderByRaw(
272334
querySelectionModifiers: PostgresQuerySelectionModifiersWithOrderByRaw<TFields>,
273335
): 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: 95 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -212,16 +212,107 @@ export class PostgresEntityDatabaseAdapter<
212212
sqlFragment: SQLFragment,
213213
querySelectionModifiers: TableQuerySelectionModifiersWithOrderByFragment,
214214
): Promise<object[]> {
215-
let query = queryInterface
216-
.select()
215+
const { results } = await this.fetchManyBySQLFragmentWithOptionalCountInternalAsync(
216+
queryInterface,
217+
tableName,
218+
sqlFragment,
219+
querySelectionModifiers,
220+
false,
221+
);
222+
return results;
223+
}
224+
225+
protected async fetchCountBySQLFragmentInternalAsync(
226+
queryInterface: Knex,
227+
tableName: string,
228+
sqlFragment: SQLFragment,
229+
): Promise<number> {
230+
const query = queryInterface
231+
.count('* as count')
217232
.from(tableName)
218-
.whereRaw(sqlFragment.sql, sqlFragment.getKnexBindings());
233+
.whereRaw(sqlFragment.sql, sqlFragment.getKnexBindings())
234+
.first();
235+
const result = await wrapNativePostgresCallAsync(() => query);
236+
return parseInt(result?.count ?? '0', 10);
237+
}
238+
239+
/**
240+
* Fetches paginated results with total count using a window function.
241+
* This executes a single query that returns both the result set and the total count,
242+
* avoiding the need for two separate queries.
243+
*
244+
* The query uses COUNT(*) OVER() to calculate the total count as a window function,
245+
* which runs over the entire result set before LIMIT/OFFSET are applied.
246+
*/
247+
protected async fetchManyBySQLFragmentWithCountInternalAsync(
248+
queryInterface: Knex,
249+
tableName: string,
250+
sqlFragment: SQLFragment,
251+
querySelectionModifiers: TableQuerySelectionModifiersWithOrderByFragment,
252+
): Promise<{ results: object[]; totalCount: number }> {
253+
return await this.fetchManyBySQLFragmentWithOptionalCountInternalAsync(
254+
queryInterface,
255+
tableName,
256+
sqlFragment,
257+
querySelectionModifiers,
258+
true,
259+
);
260+
}
261+
262+
private async fetchManyBySQLFragmentWithOptionalCountInternalAsync(
263+
queryInterface: Knex,
264+
tableName: string,
265+
sqlFragment: SQLFragment,
266+
querySelectionModifiers: TableQuerySelectionModifiersWithOrderByFragment,
267+
includeTotalCountViaWindowFunction: true,
268+
): Promise<{ results: object[]; totalCount: number }>;
269+
private async fetchManyBySQLFragmentWithOptionalCountInternalAsync(
270+
queryInterface: Knex,
271+
tableName: string,
272+
sqlFragment: SQLFragment,
273+
querySelectionModifiers: TableQuerySelectionModifiersWithOrderByFragment,
274+
includeTotalCountViaWindowFunction: false,
275+
): Promise<{ results: object[]; totalCount: undefined }>;
276+
private async fetchManyBySQLFragmentWithOptionalCountInternalAsync(
277+
queryInterface: Knex,
278+
tableName: string,
279+
sqlFragment: SQLFragment,
280+
querySelectionModifiers: TableQuerySelectionModifiersWithOrderByFragment,
281+
includeTotalCountViaWindowFunction: boolean,
282+
): Promise<{ results: object[]; totalCount: number | undefined }> {
283+
// Build the base query with window function for total count
284+
let query = queryInterface.select('*');
285+
if (includeTotalCountViaWindowFunction) {
286+
query = query.select(queryInterface.raw('COUNT(*) OVER() as __total_count'));
287+
}
288+
query = query.from(tableName).whereRaw(sqlFragment.sql, sqlFragment.getKnexBindings());
289+
290+
// Apply order by modifiers
219291
query = this.applyQueryModifiersToQuery(query, querySelectionModifiers);
220292
const { orderByFragment } = querySelectionModifiers;
221293
if (orderByFragment !== undefined) {
222294
query = query.orderByRaw(orderByFragment.sql, orderByFragment.getKnexBindings());
223295
}
224-
return await wrapNativePostgresCallAsync(() => query);
296+
297+
const rows = await wrapNativePostgresCallAsync(() => query);
298+
299+
if (!includeTotalCountViaWindowFunction) {
300+
return { results: rows, totalCount: undefined };
301+
}
302+
303+
// If no rows, return empty results with 0 count
304+
if (rows.length === 0) {
305+
return { results: [], totalCount: 0 };
306+
}
307+
308+
// Extract total count from the first row and remove it from all rows
309+
const totalCount = parseInt(rows[0].__total_count ?? '0', 10);
310+
const results = rows.map((row) => {
311+
const { __total_count, ...rest } = row;
312+
return rest;
313+
});
314+
315+
return { results, totalCount };
225316
}
226317

227318
protected async insertInternalAsync(

0 commit comments

Comments
 (0)