Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ callback? | [LoadMethodCallback](#loadmethodcallback)‹[CubeSqlResult](#cubesql
> **cubeSqlStream**(**sqlQuery**: string, **options?**: [CubeSqlOptions](#cubesqloptions)): *AsyncGenerator‹CubeSqlStreamChunk›*

Same as [`cubeSql`](#cubesql), but yields the results as they stream in. Each
chunk is either `{ type: 'schema', schema, lastRefreshTime? }`,
chunk is either `{ type: 'schema', schema, lastRefreshTime?, external?, usedPreAggregations? }`,
`{ type: 'data', data }` (one row), or `{ type: 'error', error }`.

```js
Expand Down Expand Up @@ -919,11 +919,13 @@ Name | Type | Optional? | Description |

### `CubeSqlResult`

Name | Type |
------ | ------ |
schema | `{ name: string, column_type: string, format?: string }[]` |
data | `(string \| number \| boolean \| null)[][]` |
lastRefreshTime? | string |
Name | Type | Description |
------ | ------ | ------ |
schema | `{ name: string, column_type: string, format?: string }[]` | - |
data | `(string \| number \| boolean \| null)[][]` | - |
lastRefreshTime? | string | - |
external? | boolean | Whether the result was served from the external (pre-aggregation) store. Only reported when true. |
usedPreAggregations? | `Record<string, { preAggregationId?: string, lastUpdatedAt?: number, type: string }>` | Pre-aggregations this result was served from, keyed by pre-aggregation table name. Absent when the query hit none, and on deployments older than the field. Entries also carry `targetTableName` in dev mode and for the Playground. |

### `DateRange`

Expand Down
53 changes: 44 additions & 9 deletions packages/cubejs-client-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ import {
Query,
QueryOrder,
QueryType,
TransformedQuery
TransformedQuery,
UsedPreAggregation
} from './types.js';

export type LoadMethodCallback<T> = (error: Error | null, resultSet: T) => void;
Expand Down Expand Up @@ -141,17 +142,51 @@ export type CubeSqlSchemaColumn = {
format?: DimensionFormat | MeasureFormat;
};

/**
* Metadata the SQL API reports alongside the schema, describing the result as a
* whole rather than its columns. Optional throughout: a deployment older than the
* field, or a query that hit no pre-aggregation, simply omits it.
*/
export type CubeSqlResultMetadata = {
lastRefreshTime?: string;
/**
* Whether the result was served from the external (pre-aggregation) store.
* Only ever reported as `true`; absent means "not external, or not reported".
*/
external?: boolean;
/**
* Pre-aggregations this result was served from, keyed by pre-aggregation table
* name. Absent when the query hit none. Carries identity only, so a client can
* match a result to the pre-aggregation build behind it.
*/
usedPreAggregations?: Record<string, UsedPreAggregation>;
};

export type CubeSqlResult = {
schema: CubeSqlSchemaColumn[];
data: (string | number | boolean | null)[][];
lastRefreshTime?: string;
};
} & CubeSqlResultMetadata;

/**
* Pick the result-level metadata out of a parsed SQL API schema line.
*
* Must cover every result-level field the writer puts on that line
* (`node_export.rs`), and must leave an absent field absent rather than set it to
* an explicit `undefined`. Shared by all three emitters — `cubeSql`, and
* `cubeSqlStream` for both its per-chunk and trailing-buffer paths.
*/
function pickCubeSqlResultMetadata(parsed: any): CubeSqlResultMetadata {
return {
...(parsed.lastRefreshTime ? { lastRefreshTime: parsed.lastRefreshTime } : {}),
...(parsed.external ? { external: parsed.external } : {}),
...(parsed.usedPreAggregations ? { usedPreAggregations: parsed.usedPreAggregations } : {}),
};
}

export type CubeSqlStreamChunk = {
export type CubeSqlStreamChunk = ({
type: 'schema';
schema: CubeSqlSchemaColumn[];
lastRefreshTime?: string;
} | {
} & CubeSqlResultMetadata) | {
type: 'data';
data: (string | number | boolean | null)[];
} | {
Expand Down Expand Up @@ -864,7 +899,7 @@ class CubeApi {
return {
schema: parsedSchema.schema,
data: rows,
...(parsedSchema.lastRefreshTime ? { lastRefreshTime: parsedSchema.lastRefreshTime } : {}),
...pickCubeSqlResultMetadata(parsedSchema),
};
},
options,
Expand Down Expand Up @@ -914,7 +949,7 @@ class CubeApi {
yield {
type: 'schema' as const,
schema: parsed.schema,
...(parsed.lastRefreshTime ? { lastRefreshTime: parsed.lastRefreshTime } : {}),
...pickCubeSqlResultMetadata(parsed),
};
} else if (parsed.data) {
yield {
Expand Down Expand Up @@ -945,7 +980,7 @@ class CubeApi {
yield {
type: 'schema' as const,
schema: parsed.schema,
...(parsed.lastRefreshTime ? { lastRefreshTime: parsed.lastRefreshTime } : {}),
...pickCubeSqlResultMetadata(parsed),
};
} else if (parsed.data) {
yield {
Expand Down
124 changes: 124 additions & 0 deletions packages/cubejs-client-core/test/CubeApi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -418,6 +418,27 @@ describe('CubeApi cubeSql', () => {
JSON.stringify({ data: [['Shipped', '45102']] }),
].join('\n');

// The SQL API reports the pre-aggregations behind a result next to
// `lastRefreshTime` on the schema line, so a client can match the result to the
// build behind it (CORE-664).
const cubeSqlResponseBodyWithPreAggregations = [
JSON.stringify({
schema: [
{ name: 'status', column_type: 'String' },
],
lastRefreshTime: '2026-02-24T00:34:01.594Z',
external: true,
usedPreAggregations: {
'dev_pre_aggregations.orders_main': {
preAggregationId: 'Orders.main',
lastUpdatedAt: 1771893241594,
type: 'rollup',
},
},
}),
JSON.stringify({ data: [['Active']] }),
].join('\n');

const cubeSqlResponseBodyNoRefreshTime = [
JSON.stringify({
schema: [
Expand Down Expand Up @@ -616,6 +637,109 @@ describe('CubeApi cubeSql', () => {
// requestStream's query-string builder, so it never reaches the wire.
expect(requestStreamSpy.mock.calls[0]?.[1]?.params?.timezone).toBeUndefined();
});

test('should parse usedPreAggregations from response', async () => {
vi.spyOn(HttpTransport.prototype, 'request').mockImplementation(() => ({
subscribe: (cb) => Promise.resolve(cb({
status: 200,
text: () => Promise.resolve(JSON.stringify({ error: cubeSqlResponseBodyWithPreAggregations })),
} as any,
async () => undefined as any))
}));

const cubeApi = new CubeApi('token', {
apiUrl: 'http://localhost:4000/cubejs-api/v1',
});

const res = await cubeApi.cubeSql('SELECT status FROM orders');
expect(res.usedPreAggregations).toEqual({
'dev_pre_aggregations.orders_main': {
preAggregationId: 'Orders.main',
lastUpdatedAt: 1771893241594,
type: 'rollup',
},
});
// The metadata fields are independent: reading one must not drop the others.
expect(res.lastRefreshTime).toBe('2026-02-24T00:34:01.594Z');
expect(res.external).toBe(true);
expect(res.data).toEqual([['Active']]);
});

test('should omit usedPreAggregations when the query hit no pre-aggregation', async () => {
vi.spyOn(HttpTransport.prototype, 'request').mockImplementation(() => ({
subscribe: (cb) => Promise.resolve(cb({
status: 200,
text: () => Promise.resolve(JSON.stringify({ error: cubeSqlResponseBodyNoRefreshTime })),
} as any,
async () => undefined as any))
}));

const cubeApi = new CubeApi('token', {
apiUrl: 'http://localhost:4000/cubejs-api/v1',
});

const res = await cubeApi.cubeSql('SELECT status FROM users');
expect(res.usedPreAggregations).toBeUndefined();
expect(res.external).toBeUndefined();
// Absent must stay ABSENT, not become an explicit `undefined` key.
expect('usedPreAggregations' in res).toBe(false);
expect('external' in res).toBe(false);
});

test('should emit usedPreAggregations on the stream schema chunk', async () => {
vi.spyOn(HttpTransport.prototype, 'requestStream').mockImplementation(() => ({
stream: async () => (async function* generate() {
yield new TextEncoder().encode(`${cubeSqlResponseBodyWithPreAggregations}\n`);
}()),
}));

const cubeApi = new CubeApi('token', {
apiUrl: 'http://localhost:4000/cubejs-api/v1',
});

const chunks: any[] = [];
for await (const chunk of cubeApi.cubeSqlStream('SELECT status FROM orders')) {
chunks.push(chunk);
}

const schemaChunk = chunks.find((chunk) => chunk.type === 'schema');
expect(schemaChunk?.usedPreAggregations).toEqual({
'dev_pre_aggregations.orders_main': {
preAggregationId: 'Orders.main',
lastUpdatedAt: 1771893241594,
type: 'rollup',
},
});
expect(schemaChunk?.lastRefreshTime).toBe('2026-02-24T00:34:01.594Z');
});

test('should emit usedPreAggregations when the schema arrives in the trailing buffer', async () => {
// No newline after the schema line, so it is only flushed by the
// end-of-stream drain — a second, easily-forgotten copy of the same spread.
vi.spyOn(HttpTransport.prototype, 'requestStream').mockImplementation(() => ({
stream: async () => (async function* generate() {
yield new TextEncoder().encode(cubeSqlResponseBodyWithPreAggregations.split('\n')[0]);
}()),
}));

const cubeApi = new CubeApi('token', {
apiUrl: 'http://localhost:4000/cubejs-api/v1',
});

const chunks: any[] = [];
for await (const chunk of cubeApi.cubeSqlStream('SELECT status FROM orders')) {
chunks.push(chunk);
}

const schemaChunk = chunks.find((chunk) => chunk.type === 'schema');
expect(schemaChunk?.usedPreAggregations).toEqual({
'dev_pre_aggregations.orders_main': {
preAggregationId: 'Orders.main',
lastUpdatedAt: 1771893241594,
type: 'rollup',
},
});
});
});

describe('CubeApi with baseRequestId', () => {
Expand Down
Loading