Skip to content

Commit 05be8da

Browse files
committed
feat(api-gateway): Expose usedPreAggregations on data responses
Signed-off-by: Alex Qyoun-ae <4062971+MazterQyou@users.noreply.github.com>
1 parent 8b6e94c commit 05be8da

19 files changed

Lines changed: 604 additions & 41 deletions

File tree

docs-mintlify/api-reference/core-data.yaml

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,8 +64,9 @@ paths:
6464
description: >-
6565
Run a SQL query against the Cube [SQL API](/reference/core-data-apis/sql-api) and stream the
6666
results. The response is newline-delimited JSON: the first line carries the `schema` (column
67-
names and types) and optionally `lastRefreshTime`; each subsequent line carries a `data`
68-
chunk with one or more result rows.
67+
names and types), optionally `lastRefreshTime`, and optionally `usedPreAggregations` naming
68+
the pre-aggregations the result was served from; each subsequent line carries a `data` chunk
69+
with one or more result rows.
6970
requestBody:
7071
required: true
7172
content:
@@ -127,6 +128,10 @@ paths:
127128
lastRefreshTime:
128129
type: string
129130
format: date-time
131+
usedPreAggregations:
132+
type: object
133+
additionalProperties:
134+
type: object
130135
data:
131136
type: array
132137
items:
@@ -140,6 +145,12 @@ paths:
140145
- name: value
141146
column_type: Int64
142147
lastRefreshTime: '2025-01-13T12:00:00.000Z'
148+
usedPreAggregations:
149+
schema.orders_main20240101:
150+
preAggregationId: Orders.main
151+
targetTableName: schema.orders_main20240101_abc_def_1712
152+
lastUpdatedAt: 1712000000000
153+
type: rollup
143154
dataLine:
144155
summary: Data chunk
145156
value:
@@ -672,6 +683,27 @@ components:
672683
type: object
673684
lastRefreshTime:
674685
type: string
686+
external:
687+
type: boolean
688+
description: '`true` when the result was served from an external (CubeStore) pre-aggregation.'
689+
usedPreAggregations:
690+
type: object
691+
description: >-
692+
Pre-aggregations this result was served from, keyed by pre-aggregation table name. Each
693+
entry carries `preAggregationId`, `targetTableName`, `lastUpdatedAt` and `type`. Absent
694+
when the query hit no pre-aggregation. In dev mode and for the Playground the entries
695+
also carry `refreshKeyValues`.
696+
additionalProperties:
697+
type: object
698+
properties:
699+
preAggregationId:
700+
type: string
701+
targetTableName:
702+
type: string
703+
lastUpdatedAt:
704+
type: integer
705+
type:
706+
type: string
675707
V1LoadResultAnnotation:
676708
type: object
677709
required:

docs-mintlify/reference/core-data-apis/graphql-api/reference.mdx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@ object with query metadata:
3131
`annotation` field of the [REST API][ref-rest-api] response.
3232
- `lastRefreshTime` — an ISO 8601 timestamp indicating when the data was last
3333
refreshed.
34+
- `usedPreAggregations` — the pre-aggregations the result was served from, the
35+
same as the `usedPreAggregations` field of the [REST API][ref-rest-api]
36+
response. Absent when the query hit no pre-aggregation.
3437

3538
## `CubeQueryArgs`
3639

docs-mintlify/reference/core-data-apis/rest-api/reference.mdx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,11 @@ Response
4141
- `lastRefreshTime` - An ISO 8601 timestamp indicating when the data was last refreshed.
4242
- `external` - A boolean indicating whether the query was served from a
4343
pre-aggregation in Cube Store. Present only when the query hit a pre-aggregation.
44+
- `usedPreAggregations` - The pre-aggregations this result was served from, keyed
45+
by pre-aggregation table name. Each entry carries `preAggregationId` (the name in
46+
the data model, e.g. `Orders.main`, stable across rebuilds), `targetTableName`
47+
(the table of one specific build), `lastUpdatedAt` and `type`. Absent when the
48+
query hit no pre-aggregation.
4449
- `total` - The total number of rows returned for the query. Useful for
4550
paginating results.
4651

@@ -420,8 +425,10 @@ This endpoint is part of the [SQL API][ref-sql-api].
420425
| `x-request-id` | Custom request identifier. When provided, this ID is used to track the query through the system and can be used to [cancel the query](#base_path/v1/running-query/requestid). If not provided, a unique ID is generated automatically. | ❌ No |
421426

422427
Response: a stream of newline-delimited JSON objects. The first object contains
423-
the `schema` property with column names and types, and optionally
424-
`lastRefreshTime` indicating when the data was last refreshed.
428+
the `schema` property with column names and types, optionally
429+
`lastRefreshTime` indicating when the data was last refreshed, and optionally
430+
`usedPreAggregations` naming the pre-aggregations the result was served from
431+
(same shape as in the [JSON query](#base_path/v1/load) response).
425432
The following objects contain chunks of the result set under the `data` property.
426433
Each chunk includes one or more rows of the result set; the maximum number of rows
427434
per chunk is set by

docs-mintlify/scripts/extract-core-data.js

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,9 @@ const EXTRA_PATHS = {
5454
description:
5555
'Run a SQL query against the Cube [SQL API](/reference/core-data-apis/sql-api) ' +
5656
'and stream the results. The response is newline-delimited JSON: the first line ' +
57-
'carries the `schema` (column names and types) and optionally `lastRefreshTime`; ' +
57+
'carries the `schema` (column names and types), optionally `lastRefreshTime`, and ' +
58+
'optionally `usedPreAggregations` naming the pre-aggregations the result was served ' +
59+
'from; ' +
5860
'each subsequent line carries a `data` chunk with one or more result rows.',
5961
requestBody: {
6062
required: true,
@@ -112,6 +114,7 @@ const EXTRA_PATHS = {
112114
},
113115
},
114116
lastRefreshTime: { type: 'string', format: 'date-time' },
117+
usedPreAggregations: { type: 'object', additionalProperties: { type: 'object' } },
115118
data: { type: 'array', items: { type: 'array', items: {} } },
116119
},
117120
},
@@ -121,6 +124,14 @@ const EXTRA_PATHS = {
121124
value: {
122125
schema: [{ name: 'value', column_type: 'Int64' }],
123126
lastRefreshTime: '2025-01-13T12:00:00.000Z',
127+
usedPreAggregations: {
128+
'schema.orders_main20240101': {
129+
preAggregationId: 'Orders.main',
130+
targetTableName: 'schema.orders_main20240101_abc_def_1712',
131+
lastUpdatedAt: 1712000000000,
132+
type: 'rollup',
133+
},
134+
},
124135
},
125136
},
126137
dataLine: { summary: 'Data chunk', value: { data: [['123']] } },

packages/cubejs-api-gateway/openspec.yml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -462,6 +462,23 @@ components:
462462
type: "object"
463463
lastRefreshTime:
464464
type: "string"
465+
external:
466+
type: "boolean"
467+
description: "`true` when the result was served from an external (CubeStore) pre-aggregation."
468+
usedPreAggregations:
469+
type: "object"
470+
description: "Pre-aggregations this result was served from, keyed by pre-aggregation table name. Each entry carries `preAggregationId`, `targetTableName`, `lastUpdatedAt` and `type`. Absent when the query hit no pre-aggregation. In dev mode and for the Playground the entries also carry `refreshKeyValues`."
471+
additionalProperties:
472+
type: "object"
473+
properties:
474+
preAggregationId:
475+
type: "string"
476+
targetTableName:
477+
type: "string"
478+
lastUpdatedAt:
479+
type: "integer"
480+
type:
481+
type: "string"
465482
V1Error:
466483
type: "object"
467484
required:

packages/cubejs-api-gateway/src/gateway.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,44 @@ function systemAsyncHandler(handler: (req: Request & { context: ExtendedRequestC
132132

133133
const DEV_TOKEN_SCOPE = 'dev-token';
134134

135+
/**
136+
* Fields of `usedPreAggregations` that are safe to report to any client: the
137+
* identity of the pre-aggregation a result was served from, so the client can
138+
* match the result to a build it is watching.
139+
*
140+
* `refreshKeyValues` is deliberately left out. Those are raw rows of the
141+
* refresh key queries - typically aggregates such as `MAX(updated_at)` or
142+
* `COUNT(*)` - and a `refreshKey.sql` is often written without the security
143+
* context filtering that the cube itself applies, so the values can describe
144+
* data the caller cannot otherwise reach. The full object, including them, is
145+
* still returned in dev mode and to the Playground.
146+
*/
147+
function publicUsedPreAggregations(
148+
usedPreAggregations: Record<string, any> | undefined
149+
): Record<string, any> | undefined {
150+
// A query that hit no pre-aggregation reports nothing rather than an empty
151+
// object, so the key is simply absent from the response.
152+
if (!usedPreAggregations || Object.keys(usedPreAggregations).length === 0) {
153+
return undefined;
154+
}
155+
156+
const publicFields = ['preAggregationId', 'targetTableName', 'lastUpdatedAt', 'type'];
157+
158+
return Object.fromEntries(
159+
Object.entries(usedPreAggregations).map(([tableName, usage]) => [
160+
tableName,
161+
// Undefined fields are dropped rather than kept: the native result
162+
// pipeline deserializes a JS `undefined` into a JSON `null`, so leaving
163+
// them in would put `"preAggregationId": null` on the wire.
164+
Object.fromEntries(
165+
publicFields
166+
.filter((field) => usage?.[field] !== undefined)
167+
.map((field) => [field, usage[field]])
168+
),
169+
])
170+
);
171+
}
172+
135173
function hasDevTokenScope(securityContext: unknown): boolean {
136174
if (typeof securityContext !== 'object' || securityContext === null) {
137175
return false;
@@ -1945,6 +1983,10 @@ class ApiGateway {
19451983
const resObj = {
19461984
query: normalizedQuery,
19471985
lastRefreshTime: response.lastRefreshTime?.toISOString(),
1986+
// Identity of the pre-aggregations behind this result, so a client can
1987+
// join it to the build it is waiting on. The dev-mode block below
1988+
// replaces it with the unredacted object.
1989+
usedPreAggregations: publicUsedPreAggregations(response.usedPreAggregations),
19481990
...(
19491991
getEnv('devMode') ||
19501992
context.signedWithPlaygroundAuthSecret
@@ -2242,6 +2284,11 @@ class ApiGateway {
22422284
// otherwise the SQL API reports "unknown" for every query cubesql
22432285
// hands over as pre-generated SQL.
22442286
lastRefreshTime: response.lastRefreshTime?.toISOString(),
2287+
// Same reason as `lastRefreshTime` above: the pre-aggregation
2288+
// identity has to travel with the pushed-down result too, or a
2289+
// cubesql query that goes through pre-generated SQL can never tell
2290+
// the client which pre-aggregation it read.
2291+
usedPreAggregations: publicUsedPreAggregations(response.usedPreAggregations),
22452292
// Always false: this branch builds its sqlQuery with
22462293
// `disableExternalPreAggregations` set (above), which makes
22472294
// `externalPreAggregationQuery()` return false, and the

packages/cubejs-api-gateway/src/graphql.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -675,6 +675,7 @@ export function makeSchema(metaConfig: any): GraphQLSchema {
675675
res.extensions = {
676676
annotation: results.annotation,
677677
lastRefreshTime: results.lastRefreshTime,
678+
usedPreAggregations: results.usedPreAggregations,
678679
};
679680

680681
return results.data.map(entry => R.toPairs(entry)

packages/cubejs-api-gateway/test/index.test.ts

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1508,7 +1508,7 @@ describe('API Gateway', () => {
15081508
describe('external pre-aggregation indicator', () => {
15091509
// Helper mock that lets a test pretend the query orchestrator served
15101510
// the result from an external (CubeStore) pre-aggregation, optionally
1511-
// with the dev-only `usedPreAggregations` object as well.
1511+
// with the `usedPreAggregations` object as well.
15121512
class AdapterApiMockWithFlags extends AdapterApiMock {
15131513
public constructor(
15141514
private readonly external: boolean | undefined,
@@ -1536,16 +1536,23 @@ describe('API Gateway', () => {
15361536
.expect(200);
15371537

15381538
expect(res.body.external).toBe(false);
1539-
// Full pre-agg object stays dev/playground-only.
1539+
// No pre-aggregation was used, so there is nothing to name.
15401540
expect(res.body.usedPreAggregations).toBeUndefined();
15411541
});
15421542

1543-
test('external=true when query was served from an external pre-aggregation (no leak of names)', async () => {
1543+
// Pre-aggregation identity is reported to every API consumer so a client
1544+
// can match a result to the build it is waiting on. Refresh key values are
1545+
// not: they are rows of the refresh key queries, which are often written
1546+
// without the security context filtering the cube itself applies.
1547+
test('external=true exposes pre-aggregation identity without refresh key values', async () => {
15441548
const { app } = await createApiGateway(
15451549
new AdapterApiMockWithFlags(true, {
15461550
'Foo.fooMain': {
1551+
preAggregationId: 'Foo.fooMain',
15471552
targetTableName: 'stb_pre_aggs.foo_foo_main',
1553+
lastUpdatedAt: 1712000000000,
15481554
type: 'rollup',
1555+
refreshKeyValues: [[{ max_updated_at: '2024-01-01T00:00:00.000Z' }]],
15491556
},
15501557
}),
15511558
);
@@ -1556,16 +1563,24 @@ describe('API Gateway', () => {
15561563
.expect(200);
15571564

15581565
expect(res.body.external).toBe(true);
1559-
// Pre-aggregation names / table names must NOT be exposed to ordinary
1560-
// API consumers — only the boolean flag is safe.
1561-
expect(res.body.usedPreAggregations).toBeUndefined();
1566+
expect(res.body.usedPreAggregations).toEqual({
1567+
'Foo.fooMain': {
1568+
preAggregationId: 'Foo.fooMain',
1569+
targetTableName: 'stb_pre_aggs.foo_foo_main',
1570+
lastUpdatedAt: 1712000000000,
1571+
type: 'rollup',
1572+
},
1573+
});
15621574
});
15631575

1564-
test('usedPreAggregations is exposed under playground auth alongside external', async () => {
1576+
test('refresh key values are exposed under playground auth alongside external', async () => {
15651577
const usedPreAggregations = {
15661578
'Foo.fooMain': {
1579+
preAggregationId: 'Foo.fooMain',
15671580
targetTableName: 'stb_pre_aggs.foo_foo_main',
1581+
lastUpdatedAt: 1712000000000,
15681582
type: 'rollup',
1583+
refreshKeyValues: [[{ max_updated_at: '2024-01-01T00:00:00.000Z' }]],
15691584
},
15701585
};
15711586
const { app } = await createApiGateway(

packages/cubejs-api-gateway/test/sql-api-load.test.ts

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,27 @@ const logger = (type: any, message: any) => console.log({ type, ...message });
55

66
const LAST_REFRESH_TIME = new Date('2024-01-01T00:00:00.000Z');
77

8+
// Shape the orchestrator reports: identity plus the refresh key values, which
9+
// must not reach a regular client.
10+
const USED_PRE_AGGREGATIONS = {
11+
'schema.orders_main20240101': {
12+
preAggregationId: 'Orders.main',
13+
targetTableName: 'schema.orders_main20240101_abc_def_1712',
14+
lastUpdatedAt: 1712000000000,
15+
type: 'rollup',
16+
refreshKeyValues: [[{ max_updated_at: '2024-01-01T00:00:00.000Z' }]],
17+
},
18+
};
19+
820
class FreshnessAdapterApiMock extends AdapterApiMock {
921
public lastRefreshTime: Date | undefined;
1022

11-
public constructor(lastRefreshTime?: Date) {
23+
public usedPreAggregations: Record<string, any> | undefined;
24+
25+
public constructor(lastRefreshTime?: Date, usedPreAggregations?: Record<string, any>) {
1226
super();
1327
this.lastRefreshTime = lastRefreshTime;
28+
this.usedPreAggregations = usedPreAggregations;
1429
}
1530

1631
public async executeQuery(_query: any) {
@@ -20,6 +35,7 @@ class FreshnessAdapterApiMock extends AdapterApiMock {
2035
// Always falsy for a pushdown query — see the note in
2136
// `sqlApiLoad`. Mirrors what the orchestrator actually echoes back.
2237
external: false,
38+
usedPreAggregations: this.usedPreAggregations,
2339
};
2440
}
2541
}
@@ -88,4 +104,61 @@ describe('sqlApiLoad freshness metadata', () => {
88104
expect(response.results).toHaveLength(1);
89105
expect(response.results[0].lastRefreshTime).toBeUndefined();
90106
});
107+
108+
// Pre-aggregation identity lets a client join a chart's result to the build
109+
// it is waiting on. It has to travel with the pushed-down result too, since
110+
// that is the branch every cubesql query takes.
111+
test('pushed-down sqlQuery result carries usedPreAggregations', async () => {
112+
const response = await sqlApiLoad(
113+
new FreshnessAdapterApiMock(LAST_REFRESH_TIME, USED_PRE_AGGREGATIONS),
114+
['SELECT * FROM test', []]
115+
);
116+
117+
expect(response.results[0].usedPreAggregations).toEqual({
118+
'schema.orders_main20240101': {
119+
preAggregationId: 'Orders.main',
120+
targetTableName: 'schema.orders_main20240101_abc_def_1712',
121+
lastUpdatedAt: 1712000000000,
122+
type: 'rollup',
123+
},
124+
});
125+
});
126+
127+
// Refresh key values are rows of the refresh key queries, and a
128+
// `refreshKey.sql` is often not filtered by the security context the cube
129+
// itself applies. They stay dev-mode only.
130+
test('usedPreAggregations omits refreshKeyValues', async () => {
131+
const response = await sqlApiLoad(
132+
new FreshnessAdapterApiMock(LAST_REFRESH_TIME, USED_PRE_AGGREGATIONS),
133+
['SELECT * FROM test', []]
134+
);
135+
136+
const usage = response.results[0].usedPreAggregations['schema.orders_main20240101'];
137+
expect(usage.refreshKeyValues).toBeUndefined();
138+
});
139+
140+
// A query that hit no pre-aggregation reports nothing rather than `{}`.
141+
test('usedPreAggregations is absent when no pre-aggregation was used', async () => {
142+
const response = await sqlApiLoad(
143+
new FreshnessAdapterApiMock(LAST_REFRESH_TIME, {}),
144+
['SELECT * FROM test', []]
145+
);
146+
147+
expect(response.results[0].usedPreAggregations).toBeUndefined();
148+
});
149+
150+
// The non-pushdown branch goes through `prepareResultTransformData`, which is
151+
// also what the REST `/load` response is built from.
152+
test('regular query result carries usedPreAggregations', async () => {
153+
const response = await sqlApiLoad(
154+
new FreshnessAdapterApiMock(LAST_REFRESH_TIME, USED_PRE_AGGREGATIONS)
155+
);
156+
157+
const result = response.getResults()[0].getRootResultObject()[0];
158+
159+
expect(result.usedPreAggregations['schema.orders_main20240101'].preAggregationId)
160+
.toBe('Orders.main');
161+
expect(result.usedPreAggregations['schema.orders_main20240101'].refreshKeyValues)
162+
.toBeUndefined();
163+
});
91164
});

0 commit comments

Comments
 (0)