Skip to content

Commit 1d18b95

Browse files
mcheshkovmarianore-muttdata
authored andcommitted
feat: PatchMeasure member expression (cube-js#9218)
Add new member expression: `PatchMeasure`. It allows to change aggregation type and add measure filters for existing measure. This is to allow queries like `MIN(avgMeasure)` and `SUM(CASE dim = 'foo' WHEN TRUE THEN sumMeasure ELSE NULL END)` in SQL API. Under the hood * SQL API now allows mismatched aggregation functions and aggregation on top of `CASE` in SQL pushdown rules, and rewrites that to special expression `__patch_measure(measure_column, 'new_agg_type', boolean_expression)` * This expression is syntactic-only, it should never execute, instead it would be handled by wrapper SQL generation * Generated member expression is passed to JS-side * `api-gateway` evaluates it from JSON from JS functions * `BaseQuery` and `BaseMeasure` now can handle new form: generate patched measure definition and use original measure during traversing and collecting Supporting changes * Changed rewrites for usual measures in SQL pushdown from just `measure_column` to `MEASURE(measure_column)`, to make rewrites uniform * Turn member expressions to tagged union in JSON representation
1 parent 9e08ce0 commit 1d18b95

File tree

27 files changed

+1868
-461
lines changed

27 files changed

+1868
-461
lines changed

packages/cubejs-api-gateway/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
"@cubejs-backend/native": "1.2.30",
3131
"@cubejs-backend/shared": "1.2.30",
3232
"@ungap/structured-clone": "^0.3.4",
33+
"assert-never": "^1.4.0",
3334
"body-parser": "^1.19.0",
3435
"chrono-node": "^2.6.2",
3536
"express": "^4.21.1",

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

Lines changed: 64 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
/* eslint-disable no-restricted-syntax */
22
import * as stream from 'stream';
3+
import { assertNever } from 'assert-never';
34
import jwt, { Algorithm as JWTAlgorithm } from 'jsonwebtoken';
45
import R from 'ramda';
56
import bodyParser from 'body-parser';
@@ -83,6 +84,7 @@ import {
8384
normalizeQueryCancelPreAggregations,
8485
normalizeQueryPreAggregationPreview,
8586
normalizeQueryPreAggregations,
87+
parseInputMemberExpression,
8688
preAggsJobsRequestSchema,
8789
remapToQueryAdapterFormat,
8890
} from './query';
@@ -1392,30 +1394,46 @@ class ApiGateway {
13921394
}
13931395

13941396
private parseMemberExpression(memberExpression: string): string | ParsedMemberExpression {
1395-
try {
1396-
if (memberExpression.startsWith('{')) {
1397-
const obj = JSON.parse(memberExpression);
1398-
const args = obj.cube_params;
1399-
args.push(`return \`${obj.expr}\``);
1397+
if (memberExpression.startsWith('{')) {
1398+
const obj = parseInputMemberExpression(JSON.parse(memberExpression));
1399+
let expression: ParsedMemberExpression['expression'];
1400+
switch (obj.expr.type) {
1401+
case 'SqlFunction':
1402+
expression = [
1403+
...obj.expr.cubeParams,
1404+
`return \`${obj.expr.sql}\``,
1405+
];
1406+
break;
1407+
case 'PatchMeasure':
1408+
expression = {
1409+
type: 'PatchMeasure',
1410+
sourceMeasure: obj.expr.sourceMeasure,
1411+
replaceAggregationType: obj.expr.replaceAggregationType,
1412+
addFilters: obj.expr.addFilters.map(filter => [
1413+
...filter.cubeParams,
1414+
`return \`${filter.sql}\``,
1415+
]),
1416+
};
1417+
break;
1418+
default:
1419+
assertNever(obj.expr);
1420+
}
14001421

1401-
const groupingSet = obj.grouping_set ? {
1402-
groupType: obj.grouping_set.group_type,
1403-
id: obj.grouping_set.id,
1404-
subId: obj.grouping_set.sub_id ? obj.grouping_set.sub_id : undefined
1405-
} : undefined;
1422+
const groupingSet = obj.groupingSet ? {
1423+
groupType: obj.groupingSet.groupType,
1424+
id: obj.groupingSet.id,
1425+
subId: obj.groupingSet.subId ? obj.groupingSet.subId : undefined
1426+
} : undefined;
14061427

1407-
return {
1408-
cubeName: obj.cube_name,
1409-
name: obj.alias,
1410-
expressionName: obj.alias,
1411-
expression: args,
1412-
definition: memberExpression,
1413-
groupingSet,
1414-
};
1415-
} else {
1416-
return memberExpression;
1417-
}
1418-
} catch {
1428+
return {
1429+
cubeName: obj.cubeName,
1430+
name: obj.alias,
1431+
expressionName: obj.alias,
1432+
expression,
1433+
definition: memberExpression,
1434+
groupingSet,
1435+
};
1436+
} else {
14191437
return memberExpression;
14201438
}
14211439
}
@@ -1433,14 +1451,31 @@ class ApiGateway {
14331451
};
14341452
}
14351453

1436-
private evalMemberExpression(memberExpression: MemberExpression | ParsedMemberExpression): string | MemberExpression {
1437-
const expression = Array.isArray(memberExpression.expression) ?
1438-
Function.constructor.apply(null, memberExpression.expression) : memberExpression.expression;
1454+
private evalMemberExpression(memberExpression: MemberExpression | ParsedMemberExpression): MemberExpression | ParsedMemberExpression {
1455+
if (typeof memberExpression.expression === 'function') {
1456+
return memberExpression;
1457+
}
14391458

1440-
return {
1441-
...memberExpression,
1442-
expression,
1443-
};
1459+
if (Array.isArray(memberExpression.expression)) {
1460+
return {
1461+
...memberExpression,
1462+
expression: Function.constructor.apply(null, memberExpression.expression),
1463+
};
1464+
}
1465+
1466+
if (memberExpression.expression.type === 'PatchMeasure') {
1467+
return {
1468+
...memberExpression,
1469+
expression: {
1470+
...memberExpression.expression,
1471+
addFilters: memberExpression.expression.addFilters.map(filter => ({
1472+
sql: Function.constructor.apply(null, filter),
1473+
})),
1474+
}
1475+
};
1476+
}
1477+
1478+
throw new Error(`Unexpected member expression to evaluate: ${memberExpression}`);
14441479
}
14451480

14461481
public async sqlGenerators({ context, res }: { context: RequestContext, res: ResponseResultFn }) {

packages/cubejs-api-gateway/src/query.js

Lines changed: 73 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,11 +39,31 @@ const getPivotQuery = (queryType, queries) => {
3939
return pivotQuery;
4040
};
4141

42+
const parsedPatchMeasureFilterExpression = Joi.array().items(Joi.string());
43+
44+
const evaluatedPatchMeasureFilterExpression = Joi.object().keys({
45+
sql: Joi.func().required(),
46+
});
47+
48+
const parsedPatchMeasureExpression = Joi.object().keys({
49+
type: Joi.valid('PatchMeasure').required(),
50+
sourceMeasure: Joi.string().required(),
51+
replaceAggregationType: Joi.string().allow(null).required(),
52+
addFilters: Joi.array().items(parsedPatchMeasureFilterExpression).required(),
53+
});
54+
55+
const evaluatedPatchMeasureExpression = parsedPatchMeasureExpression.keys({
56+
addFilters: Joi.array().items(evaluatedPatchMeasureFilterExpression).required(),
57+
});
58+
4259
const id = Joi.string().regex(/^[a-zA-Z0-9_]+\.[a-zA-Z0-9_]+$/);
4360
const idOrMemberExpressionName = Joi.string().regex(/^[a-zA-Z0-9_]+\.[a-zA-Z0-9_]+$|^[a-zA-Z0-9_]+$/);
4461
const dimensionWithTime = Joi.string().regex(/^[a-zA-Z0-9_]+\.[a-zA-Z0-9_]+(\.[a-zA-Z0-9_]+)?$/);
4562
const parsedMemberExpression = Joi.object().keys({
46-
expression: Joi.array().items(Joi.string()).min(1).required(),
63+
expression: Joi.alternatives(
64+
Joi.array().items(Joi.string()).min(1),
65+
parsedPatchMeasureExpression,
66+
).required(),
4767
cubeName: Joi.string().required(),
4868
name: Joi.string().required(),
4969
expressionName: Joi.string(),
@@ -55,7 +75,43 @@ const parsedMemberExpression = Joi.object().keys({
5575
})
5676
});
5777
const memberExpression = parsedMemberExpression.keys({
58-
expression: Joi.func().required(),
78+
expression: Joi.alternatives(
79+
Joi.func().required(),
80+
evaluatedPatchMeasureExpression,
81+
).required(),
82+
});
83+
84+
const inputSqlFunction = Joi.object().keys({
85+
cubeParams: Joi.array().items(Joi.string()).required(),
86+
sql: Joi.string().required(),
87+
});
88+
89+
// This should be aligned with cubesql side
90+
const inputMemberExpressionSqlFunction = inputSqlFunction.keys({
91+
type: Joi.valid('SqlFunction').required(),
92+
});
93+
94+
// This should be aligned with cubesql side
95+
const inputMemberExpressionPatchMeasure = Joi.object().keys({
96+
type: Joi.valid('PatchMeasure').required(),
97+
sourceMeasure: Joi.string().required(),
98+
replaceAggregationType: Joi.string().allow(null).required(),
99+
addFilters: Joi.array().items(inputSqlFunction).required(),
100+
});
101+
102+
// This should be aligned with cubesql side
103+
const inputMemberExpression = Joi.object().keys({
104+
cubeName: Joi.string().required(),
105+
alias: Joi.string().required(),
106+
expr: Joi.alternatives(
107+
inputMemberExpressionSqlFunction,
108+
inputMemberExpressionPatchMeasure,
109+
),
110+
groupingSet: Joi.object().keys({
111+
groupType: Joi.valid('Rollup', 'Cube').required(),
112+
id: Joi.number().required(),
113+
subId: Joi.number().allow(null),
114+
}).allow(null)
59115
});
60116

61117
const operators = [
@@ -215,6 +271,20 @@ const normalizeQueryFilters = (filter) => (
215271
})
216272
);
217273

274+
/**
275+
* Parse incoming member expression
276+
* @param {unknown} expression
277+
* @throws {import('./UserError').UserError}
278+
* @returns {import('./types/query').InputMemberExpression}
279+
*/
280+
function parseInputMemberExpression(expression) {
281+
const { error } = inputMemberExpression.validate(expression);
282+
if (error) {
283+
throw new UserError(`Invalid member expression format: ${error.message || error.toString()}`);
284+
}
285+
return expression;
286+
}
287+
218288
/**
219289
* Normalize incoming network query.
220290
* @param {Query} query
@@ -384,5 +454,6 @@ export {
384454
normalizeQueryPreAggregations,
385455
normalizeQueryPreAggregationPreview,
386456
normalizeQueryCancelPreAggregations,
457+
parseInputMemberExpression,
387458
remapToQueryAdapterFormat,
388459
};

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

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,14 +38,34 @@ type LogicalOrFilter = {
3838
or: (QueryFilter | LogicalAndFilter)[]
3939
};
4040

41+
export type GroupingSetType = 'Rollup' | 'Cube';
42+
4143
type GroupingSet = {
42-
groupType: string,
44+
groupType: GroupingSetType,
4345
id: number,
4446
subId?: null | number
4547
};
4648

49+
export type EvalPatchMeasureFilterExpression = {
50+
sql: Function,
51+
};
52+
53+
export type PatchMeasureExpression = {
54+
type: 'PatchMeasure',
55+
sourceMeasure: string,
56+
replaceAggregationType: string | null,
57+
addFilters: Array<Array<string>>,
58+
};
59+
60+
export type EvalPatchMeasureExpression = {
61+
type: 'PatchMeasure',
62+
sourceMeasure: string,
63+
replaceAggregationType: string | null,
64+
addFilters: Array<EvalPatchMeasureFilterExpression>,
65+
};
66+
4767
type ParsedMemberExpression = {
48-
expression: string[];
68+
expression: string[] | PatchMeasureExpression;
4969
cubeName: string;
5070
name: string;
5171
expressionName: string;
@@ -54,7 +74,33 @@ type ParsedMemberExpression = {
5474
};
5575

5676
type MemberExpression = Omit<ParsedMemberExpression, 'expression'> & {
57-
expression: Function;
77+
expression: Function | EvalPatchMeasureExpression;
78+
};
79+
80+
type InputSqlFunction = {
81+
cubeParams: Array<string>,
82+
sql: string,
83+
};
84+
85+
export type InputMemberExpressionSqlFunction = {
86+
type: 'SqlFunction'
87+
} & InputSqlFunction;
88+
89+
export type InputMemberExpressionPatchMeasure = {
90+
type: 'PatchMeasure',
91+
sourceMeasure: string,
92+
replaceAggregationType: string | null,
93+
addFilters: Array<InputSqlFunction>,
94+
};
95+
96+
export type InputMemberExpressionExpr = InputMemberExpressionSqlFunction | InputMemberExpressionPatchMeasure;
97+
98+
// This should be aligned with cubesql side
99+
export type InputMemberExpression = {
100+
cubeName: string,
101+
alias: string,
102+
expr: InputMemberExpressionExpr,
103+
groupingSet: GroupingSet | null,
58104
};
59105

60106
/**

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

Lines changed: 17 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -771,6 +771,21 @@ describe('API Gateway', () => {
771771
});
772772

773773
describe('sql api member expressions evaluations', () => {
774+
const query = {
775+
measures: [
776+
// eslint-disable-next-line no-template-curly-in-string
777+
'{"cubeName":"sales","alias":"sum_sales_line_i","expr":{"type":"SqlFunction","cubeParams":["sales"],"sql":"SUM(${sales.line_items_price})"},"groupingSet":null}'
778+
],
779+
dimensions: [
780+
// eslint-disable-next-line no-template-curly-in-string
781+
'{"cubeName":"sales","alias":"users_age","expr":{"type":"SqlFunction","cubeParams":["sales"],"sql":"${sales.users_age}"},"groupingSet":null}',
782+
// eslint-disable-next-line no-template-curly-in-string
783+
'{"cubeName":"sales","alias":"cast_sales_users","expr":{"type":"SqlFunction","cubeParams":["sales"],"sql":"CAST(${sales.users_first_name} AS TEXT)"},"groupingSet":null}'
784+
],
785+
segments: [],
786+
order: []
787+
};
788+
774789
test('throw error if expressions are not allowed', async () => {
775790
const { apiGateway } = await createApiGateway();
776791
const request: QueryRequest = {
@@ -779,20 +794,7 @@ describe('API Gateway', () => {
779794
const errorMessage = message as { error: string };
780795
expect(errorMessage.error).toEqual('Error: Expressions are not allowed in this context');
781796
},
782-
query: {
783-
measures: [
784-
// eslint-disable-next-line no-template-curly-in-string
785-
'{"cube_name":"sales","alias":"sum_sales_line_i","cube_params":["sales"],"expr":"SUM(${sales.line_items_price})","grouping_set":null}'
786-
],
787-
dimensions: [
788-
// eslint-disable-next-line no-template-curly-in-string
789-
'{"cube_name":"sales","alias":"users_age","cube_params":["sales"],"expr":"${sales.users_age}","grouping_set":null}',
790-
// eslint-disable-next-line no-template-curly-in-string
791-
'{"cube_name":"sales","alias":"cast_sales_users","cube_params":["sales"],"expr":"CAST(${sales.users_first_name} AS TEXT)","grouping_set":null}'
792-
],
793-
segments: [],
794-
order: []
795-
},
797+
query,
796798
expressionParams: [],
797799
exportAnnotatedSql: true,
798800
memberExpressions: false,
@@ -820,20 +822,7 @@ describe('API Gateway', () => {
820822
res(message) {
821823
expect(message.hasOwnProperty('sql')).toBe(true);
822824
},
823-
query: {
824-
measures: [
825-
// eslint-disable-next-line no-template-curly-in-string
826-
'{"cube_name":"sales","alias":"sum_sales_line_i","cube_params":["sales"],"expr":"SUM(${sales.line_items_price})","grouping_set":null}'
827-
],
828-
dimensions: [
829-
// eslint-disable-next-line no-template-curly-in-string
830-
'{"cube_name":"sales","alias":"users_age","cube_params":["sales"],"expr":"${sales.users_age}","grouping_set":null}',
831-
// eslint-disable-next-line no-template-curly-in-string
832-
'{"cube_name":"sales","alias":"cast_sales_users","cube_params":["sales"],"expr":"CAST(${sales.users_first_name} AS TEXT)","grouping_set":null}'
833-
],
834-
segments: [],
835-
order: []
836-
},
825+
query,
837826
expressionParams: [],
838827
exportAnnotatedSql: true,
839828
memberExpressions: true,

0 commit comments

Comments
 (0)