Skip to content

Commit eeb9ba0

Browse files
committed
support cursor pagination on data endpoint
1 parent 6f84a7e commit eeb9ba0

19 files changed

Lines changed: 1311 additions & 81 deletions

src/controllers/consumer-v2.ts

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE } from '../utils/page-defaults';
1717
import { clamp } from '../utils/clamp';
1818
import { ConsumerRevisionDTO } from '../dtos/consumer-revision-dto';
1919
import {
20+
BuildDataQueryResult,
2021
buildDataQuery,
2122
sendCsv,
2223
sendExcel,
@@ -157,8 +158,16 @@ export const getPublishedDatasetData = async (req: Request, res: Response, next:
157158
? await QueryStoreRepository.getById(filterId)
158159
: await QueryStoreRepository.getByRequest(dataset.id, publishedRevision.id, dataOptions);
159160

160-
const query = await buildDataQuery(queryStore, pageOptions);
161-
await sendFormattedResponse(query, queryStore, pageOptions, res);
161+
// ensurePublishedDataset loads a lean dataset (no factTable). buildDataQuery
162+
// needs the fact-table columns to resolve a deterministic sort plan for
163+
// both cursor-mode and offset-mode pagination, so reload with factTable
164+
// when it isn't already present.
165+
const datasetForBuild = dataset.factTable
166+
? dataset
167+
: await PublishedDatasetRepository.getById(dataset.id, { factTable: true });
168+
169+
const buildResult = await buildDataQuery(queryStore, pageOptions, datasetForBuild);
170+
await sendFormattedResponse(buildResult, queryStore, pageOptions, res);
162171
} catch (err) {
163172
if (res.headersSent) {
164173
logger.error(err, 'Error detected fetching data after headers already sent');
@@ -521,29 +530,30 @@ export const getPublicationHistory = async (_req: Request, res: Response): Promi
521530
};
522531

523532
export const sendFormattedResponse = async (
524-
query: string,
533+
buildResult: BuildDataQueryResult,
525534
queryStore: QueryStore,
526535
pageOptions: PageOptions,
527536
res: Response
528537
): Promise<void> => {
538+
const sql = buildResult.sql;
529539
switch (pageOptions.format) {
530540
case OutputFormats.Frontend:
531541
// consumer view: load via PublishedDatasetRepository (consumer pool, published-only)
532542
return sendFrontendView(
533-
query,
543+
buildResult,
534544
queryStore,
535545
pageOptions,
536546
res,
537547
PublishedDatasetRepository.getById.bind(PublishedDatasetRepository)
538548
);
539549
case OutputFormats.Csv:
540-
return sendCsv(query, queryStore, res);
550+
return sendCsv(sql, queryStore, res);
541551
case OutputFormats.Excel:
542-
return sendExcel(query, queryStore, res);
552+
return sendExcel(sql, queryStore, res);
543553
case OutputFormats.Json:
544-
return sendJson(query, queryStore, res);
554+
return sendJson(sql, queryStore, res);
545555
case OutputFormats.Html:
546-
return sendHtml(query, queryStore, res);
556+
return sendHtml(sql, queryStore, res);
547557
default:
548558
res.status(400).json({ error: 'Format not supported' });
549559
}

src/controllers/dataset.ts

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,14 @@ import { QueryStoreRepository } from '../repositories/query-store';
6868
import { parsePageOptions } from '../utils/parse-page-options';
6969
import { resolvePreviewRevisionId } from '../utils/revision';
7070
import { OutputFormats } from '../enums/output-formats';
71-
import { buildDataQuery, sendCsv, sendExcel, sendFrontendView, sendJson } from '../services/consumer-view-v2';
71+
import {
72+
BuildDataQueryResult,
73+
buildDataQuery,
74+
sendCsv,
75+
sendExcel,
76+
sendFrontendView,
77+
sendJson
78+
} from '../services/consumer-view-v2';
7279
import { QueryStore } from '../entities/query-store';
7380
import { PageOptions } from '../interfaces/page-options';
7481
import { rebuildAllFilterTablesForRevisions, rebuildCubesForRevisions } from '../services/revision';
@@ -317,8 +324,8 @@ export const datasetPreview = async (req: Request, res: Response, next: NextFunc
317324
? await QueryStoreRepository.getById(filterId)
318325
: await QueryStoreRepository.getByRequest(dataset.id, previewRevisionId, dataOptions);
319326

320-
const query = await buildDataQuery(queryStore, pageOptions);
321-
await sendFormattedResponse(query, queryStore, pageOptions, res);
327+
const buildResult = await buildDataQuery(queryStore, pageOptions, dataset);
328+
await sendFormattedResponse(buildResult, queryStore, pageOptions, res);
322329
} catch (err) {
323330
if (err instanceof NotFoundException || err instanceof BadRequestException) {
324331
return next(err);
@@ -329,21 +336,28 @@ export const datasetPreview = async (req: Request, res: Response, next: NextFunc
329336
};
330337

331338
export const sendFormattedResponse = async (
332-
query: string,
339+
buildResult: BuildDataQueryResult,
333340
queryStore: QueryStore,
334341
pageOptions: PageOptions,
335342
res: Response
336343
): Promise<void> => {
344+
const sql = buildResult.sql;
337345
switch (pageOptions.format) {
338346
case OutputFormats.Frontend:
339347
// publisher preview: load via DatasetRepository so drafts resolve and we stay on the publisher pool
340-
return sendFrontendView(query, queryStore, pageOptions, res, DatasetRepository.getById.bind(DatasetRepository));
348+
return sendFrontendView(
349+
buildResult,
350+
queryStore,
351+
pageOptions,
352+
res,
353+
DatasetRepository.getById.bind(DatasetRepository)
354+
);
341355
case OutputFormats.Csv:
342-
return sendCsv(query, queryStore, res);
356+
return sendCsv(sql, queryStore, res);
343357
case OutputFormats.Excel:
344-
return sendExcel(query, queryStore, res);
358+
return sendExcel(sql, queryStore, res);
345359
case OutputFormats.Json:
346-
return sendJson(query, queryStore, res);
360+
return sendJson(sql, queryStore, res);
347361
default:
348362
res.status(400).json({ error: 'Format not supported' });
349363
}

src/interfaces/page-options.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,8 @@ export interface PageOptions {
99
locale: Locale;
1010
y?: string[] | string;
1111
x?: string[] | string;
12+
// Opaque keyset-pagination cursor. When supplied, the caller is opting in
13+
// to cursor-based pagination and page_number is ignored beyond its default
14+
// of 1. Mutually exclusive with non-default page_number.
15+
cursor?: string;
1216
}

src/routes/consumer/v2/api.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -371,7 +371,11 @@ publicApiV2Router.get(
371371
specified by the required <code>format</code> parameter. <code>csv</code> and <code>xlsx</code>
372372
return the full dataset as a streamed file attachment. <code>json</code>, <code>frontend</code>
373373
and <code>html</code> return paginated responses (default <code>page_size</code> 100, max 10,000).
374-
To apply filters, first create a filter via POST /{dataset_id}/data, then use
374+
<br><br>Pagination supports two modes: <code>page_number</code>-based (OFFSET) for shallow paging,
375+
and opaque <code>cursor</code>-based (keyset) for efficient deep paging. The response carries both
376+
<code>next_cursor</code> and <code>prev_cursor</code> in <code>page_info</code> so clients can
377+
switch into cursor mode at any point. The two modes are mutually exclusive within a single request.
378+
<br><br>To apply filters, first create a filter via POST /{dataset_id}/data, then use
375379
GET /{dataset_id}/data/{filter_id}."
376380
#swagger.autoQuery = false
377381
#swagger.parameters['$ref'] = [
@@ -380,7 +384,8 @@ publicApiV2Router.get(
380384
'#/components/parameters/output_format',
381385
'#/components/parameters/page_number',
382386
'#/components/parameters/page_size',
383-
'#/components/parameters/sort_by'
387+
'#/components/parameters/sort_by',
388+
'#/components/parameters/cursor'
384389
]
385390
#swagger.responses[200] = {
386391
description: 'A JSON array of data row objects',
@@ -405,7 +410,8 @@ publicApiV2Router.get(
405410
chosen options for a specific filter ID. The required <code>format</code> parameter selects the response shape:
406411
<code>csv</code> and <code>xlsx</code> stream the full filtered dataset, while <code>json</code>,
407412
<code>frontend</code> and <code>html</code> return paginated responses (default <code>page_size</code> 100,
408-
max 10,000)."
413+
max 10,000). Both <code>page_number</code> and opaque <code>cursor</code> pagination are supported (mutually
414+
exclusive within a single request) — see GET /{dataset_id}/data for details."
409415
#swagger.autoQuery = false
410416
#swagger.parameters['$ref'] = [
411417
'#/components/parameters/language',
@@ -414,6 +420,7 @@ publicApiV2Router.get(
414420
'#/components/parameters/page_number',
415421
'#/components/parameters/page_size',
416422
'#/components/parameters/sort_by',
423+
'#/components/parameters/cursor',
417424
'#/components/parameters/filter_id'
418425
]
419426
#swagger.responses[200] = {

src/routes/consumer/v2/openapi-cy.json

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,9 @@
291291
},
292292
{
293293
"$ref": "#/components/parameters/sort_by"
294+
},
295+
{
296+
"$ref": "#/components/parameters/cursor"
294297
}
295298
],
296299
"responses": {
@@ -374,6 +377,9 @@
374377
{
375378
"$ref": "#/components/parameters/sort_by"
376379
},
380+
{
381+
"$ref": "#/components/parameters/cursor"
382+
},
377383
{
378384
"$ref": "#/components/parameters/filter_id"
379385
}
@@ -572,6 +578,16 @@
572578
},
573579
"example": "title:asc,last_updated_at:desc"
574580
},
581+
"cursor": {
582+
"name": "cursor",
583+
"in": "query",
584+
"description": "Opaque keyset cursor returned in <code>page_info.next_cursor</code> / <code>prev_cursor</code>. Pass it back to fetch the next (or previous) slice of rows without paying the OFFSET cost. Cursors are bound to a specific dataset revision, language and sort order — they become invalid if any of those change, and the server will return <code>400</code> in that case. Mutually exclusive with <code>page_number</code> > 1.",
585+
"required": false,
586+
"schema": {
587+
"type": "string",
588+
"maxLength": 2048
589+
}
590+
},
575591
"filter": {
576592
"name": "filter",
577593
"in": "query",

src/routes/consumer/v2/openapi-en.json

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -272,7 +272,7 @@
272272
"Data"
273273
],
274274
"summary": "Get paginated data for a dataset",
275-
"description": "Returns rows for the latest published revision in the format specified by the required <code>format</code> parameter. <code>csv</code> and <code>xlsx</code> return the full dataset as a streamed file attachment. <code>json</code>, <code>frontend</code> and <code>html</code> return paginated responses (default <code>page_size</code> 100, max 10,000). To apply filters, first create a filter via POST /{dataset_id}/data, then use GET /{dataset_id}/data/{filter_id}.",
275+
"description": "Returns rows for the latest published revision in the format specified by the required <code>format</code> parameter. <code>csv</code> and <code>xlsx</code> return the full dataset as a streamed file attachment. <code>json</code>, <code>frontend</code> and <code>html</code> return paginated responses (default <code>page_size</code> 100, max 10,000). <br><br>Pagination supports two modes: <code>page_number</code>-based (OFFSET) for shallow paging, and opaque <code>cursor</code>-based (keyset) for efficient deep paging. The response carries both <code>next_cursor</code> and <code>prev_cursor</code> in <code>page_info</code> so clients can switch into cursor mode at any point. The two modes are mutually exclusive within a single request. <br><br>To apply filters, first create a filter via POST /{dataset_id}/data, then use GET /{dataset_id}/data/{filter_id}.",
276276
"parameters": [
277277
{
278278
"$ref": "#/components/parameters/language"
@@ -291,6 +291,9 @@
291291
},
292292
{
293293
"$ref": "#/components/parameters/sort_by"
294+
},
295+
{
296+
"$ref": "#/components/parameters/cursor"
294297
}
295298
],
296299
"responses": {
@@ -354,7 +357,7 @@
354357
"Data"
355358
],
356359
"summary": "Get a filtered data table for a dataset",
357-
"description": "Returns current data for a published dataset, filtered and displayed according to the chosen options for a specific filter ID. The required <code>format</code> parameter selects the response shape: <code>csv</code> and <code>xlsx</code> stream the full filtered dataset, while <code>json</code>, <code>frontend</code> and <code>html</code> return paginated responses (default <code>page_size</code> 100, max 10,000).",
360+
"description": "Returns current data for a published dataset, filtered and displayed according to the chosen options for a specific filter ID. The required <code>format</code> parameter selects the response shape: <code>csv</code> and <code>xlsx</code> stream the full filtered dataset, while <code>json</code>, <code>frontend</code> and <code>html</code> return paginated responses (default <code>page_size</code> 100, max 10,000). Both <code>page_number</code> and opaque <code>cursor</code> pagination are supported (mutually exclusive within a single request) — see GET /{dataset_id}/data for details.",
358361
"parameters": [
359362
{
360363
"$ref": "#/components/parameters/language"
@@ -374,6 +377,9 @@
374377
{
375378
"$ref": "#/components/parameters/sort_by"
376379
},
380+
{
381+
"$ref": "#/components/parameters/cursor"
382+
},
377383
{
378384
"$ref": "#/components/parameters/filter_id"
379385
}
@@ -572,6 +578,16 @@
572578
},
573579
"example": "title:asc,last_updated_at:desc"
574580
},
581+
"cursor": {
582+
"name": "cursor",
583+
"in": "query",
584+
"description": "Opaque keyset cursor returned in <code>page_info.next_cursor</code> / <code>prev_cursor</code>. Pass it back to fetch the next (or previous) slice of rows without paying the OFFSET cost. Cursors are bound to a specific dataset revision, language and sort order — they become invalid if any of those change, and the server will return <code>400</code> in that case. Mutually exclusive with <code>page_number</code> > 1.",
585+
"required": false,
586+
"schema": {
587+
"type": "string",
588+
"maxLength": 2048
589+
}
590+
},
575591
"filter": {
576592
"name": "filter",
577593
"in": "query",

src/routes/consumer/v2/schema.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,18 @@ export const schemaV2 = {
9494
schema: { type: 'string' },
9595
example: 'title:asc,last_updated_at:desc'
9696
},
97+
cursor: {
98+
name: 'cursor',
99+
in: 'query',
100+
description:
101+
`Opaque keyset cursor returned in <code>page_info.next_cursor</code> / <code>prev_cursor</code>. ` +
102+
`Pass it back to fetch the next (or previous) slice of rows without paying the OFFSET cost. ` +
103+
`Cursors are bound to a specific dataset revision, language and sort order — they become invalid ` +
104+
`if any of those change, and the server will return <code>400</code> in that case. Mutually exclusive ` +
105+
`with <code>page_number</code> > 1.`,
106+
required: false,
107+
schema: { type: 'string', maxLength: 2048 }
108+
},
97109
filter: {
98110
name: 'filter',
99111
in: 'query',

0 commit comments

Comments
 (0)