Skip to content

Commit 2b437e1

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

14 files changed

Lines changed: 695 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: 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: 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)