Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
2a733bf
refactor(cubesql): Extract find_member_in_ungrouped_scan
mcheshkov Jan 22, 2025
a90dc22
refactor(cubesql): Simplify find_member_in_ungrouped_scan
mcheshkov Jan 22, 2025
8b2841f
refactor(cubesql): Simplify is_same_agg_type
mcheshkov Jan 22, 2025
82028a1
refactor(api-gateway): Parse incoming member expressions with Joi
mcheshkov Feb 5, 2025
9ce3a90
refactor(api-gateway): Extract query in member expressions tests
mcheshkov Feb 6, 2025
0152222
refactor(api-gateway): Rename member expression fields in serialized …
mcheshkov Feb 5, 2025
b75060c
refactor(api-gateway): Turn input member expressions to tagged union
mcheshkov Feb 5, 2025
0d85333
feat(schema-compiler): Add PatchMeasure member expressions
mcheshkov Feb 3, 2025
f2882f8
feat(api-gateway): Support PatchMeasure expressions
mcheshkov Feb 5, 2025
7cf0040
fix(cubesql): Make MEASURE UDAF signature identity
mcheshkov Feb 6, 2025
e390bd6
fix(cubesql): Parse UDAF name in LogicalPlanLanguage
mcheshkov Jan 24, 2025
fde8f60
refactor(cubesql): Extract MEASURE UDAF name to const
mcheshkov Feb 6, 2025
3135b47
fix(cubesql): Allow only MEASURE UDAF in members rewrites
mcheshkov Feb 6, 2025
2f598cb
refactor(cubesql): Rewrite measure in wrapper as MEASURE(column)
mcheshkov Feb 6, 2025
ac1bf2f
refactor(cubesql): Remove unused plan argument from CubeScanWrapperNo…
mcheshkov Oct 15, 2024
6c1b6b9
refactor(cubesql): Remove unused plan argument from CubeScanWrapperNo…
mcheshkov Oct 15, 2024
0dd4d5b
refactor(cubesql): Extract remap_column_expression function
mcheshkov Feb 11, 2025
53ecebc
refactor(cubesql): Simplify order_expr handling in push-to-Cube case
mcheshkov Feb 12, 2025
0c9d5c7
feat(cubesql): Support PatchMeasure in SQL pushdown
mcheshkov Feb 11, 2025
6a16dfa
feat(cubesql): Rewrite incorrect aggregation function on measures und…
mcheshkov Feb 6, 2025
573720b
feat(cubesql): Parse NULL literal in LogicalPlanLanguage
mcheshkov Jan 24, 2025
09a07d1
refactor(cubesql): Extract WrapperRules::pushdown_measure_impl
mcheshkov Feb 7, 2025
86cb1aa
feat(cubesql): Rewrite measure filters in query as PatchMeasure
mcheshkov Feb 6, 2025
d522158
test: Add smoke tests for SQL API ad-hoc measures
mcheshkov Feb 4, 2025
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
1 change: 1 addition & 0 deletions packages/cubejs-api-gateway/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"@cubejs-backend/native": "1.2.30",
"@cubejs-backend/shared": "1.2.30",
"@ungap/structured-clone": "^0.3.4",
"assert-never": "^1.4.0",
"body-parser": "^1.19.0",
"chrono-node": "^2.6.2",
"express": "^4.21.1",
Expand Down
93 changes: 64 additions & 29 deletions packages/cubejs-api-gateway/src/gateway.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/* eslint-disable no-restricted-syntax */
import * as stream from 'stream';
import { assertNever } from 'assert-never';
import jwt, { Algorithm as JWTAlgorithm } from 'jsonwebtoken';
import R from 'ramda';
import bodyParser from 'body-parser';
Expand Down Expand Up @@ -83,6 +84,7 @@
normalizeQueryCancelPreAggregations,
normalizeQueryPreAggregationPreview,
normalizeQueryPreAggregations,
parseInputMemberExpression,
preAggsJobsRequestSchema,
remapToQueryAdapterFormat,
} from './query';
Expand Down Expand Up @@ -1392,30 +1394,46 @@
}

private parseMemberExpression(memberExpression: string): string | ParsedMemberExpression {
try {
if (memberExpression.startsWith('{')) {
const obj = JSON.parse(memberExpression);
const args = obj.cube_params;
args.push(`return \`${obj.expr}\``);
if (memberExpression.startsWith('{')) {
const obj = parseInputMemberExpression(JSON.parse(memberExpression));
let expression: ParsedMemberExpression['expression'];
switch (obj.expr.type) {
case 'SqlFunction':
expression = [
...obj.expr.cubeParams,
`return \`${obj.expr.sql}\``,
];
break;
case 'PatchMeasure':
expression = {

Check warning on line 1408 in packages/cubejs-api-gateway/src/gateway.ts

View check run for this annotation

Codecov / codecov/patch

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

Added line #L1408 was not covered by tests
type: 'PatchMeasure',
sourceMeasure: obj.expr.sourceMeasure,
replaceAggregationType: obj.expr.replaceAggregationType,
addFilters: obj.expr.addFilters.map(filter => [

Check warning on line 1412 in packages/cubejs-api-gateway/src/gateway.ts

View check run for this annotation

Codecov / codecov/patch

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

Added line #L1412 was not covered by tests
...filter.cubeParams,
`return \`${filter.sql}\``,
]),
};
break;

Check warning on line 1417 in packages/cubejs-api-gateway/src/gateway.ts

View check run for this annotation

Codecov / codecov/patch

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

Added line #L1417 was not covered by tests
default:
assertNever(obj.expr);

Check warning on line 1419 in packages/cubejs-api-gateway/src/gateway.ts

View check run for this annotation

Codecov / codecov/patch

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

Added line #L1419 was not covered by tests
}

const groupingSet = obj.grouping_set ? {
groupType: obj.grouping_set.group_type,
id: obj.grouping_set.id,
subId: obj.grouping_set.sub_id ? obj.grouping_set.sub_id : undefined
} : undefined;
const groupingSet = obj.groupingSet ? {
groupType: obj.groupingSet.groupType,
id: obj.groupingSet.id,
subId: obj.groupingSet.subId ? obj.groupingSet.subId : undefined
} : undefined;

return {
cubeName: obj.cube_name,
name: obj.alias,
expressionName: obj.alias,
expression: args,
definition: memberExpression,
groupingSet,
};
} else {
return memberExpression;
}
} catch {
return {
cubeName: obj.cubeName,
name: obj.alias,
expressionName: obj.alias,
expression,
definition: memberExpression,
groupingSet,
};
} else {
return memberExpression;
}
}
Expand All @@ -1433,14 +1451,31 @@
};
}

private evalMemberExpression(memberExpression: MemberExpression | ParsedMemberExpression): string | MemberExpression {
const expression = Array.isArray(memberExpression.expression) ?
Function.constructor.apply(null, memberExpression.expression) : memberExpression.expression;
private evalMemberExpression(memberExpression: MemberExpression | ParsedMemberExpression): MemberExpression | ParsedMemberExpression {
if (typeof memberExpression.expression === 'function') {
return memberExpression;

Check warning on line 1456 in packages/cubejs-api-gateway/src/gateway.ts

View check run for this annotation

Codecov / codecov/patch

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

Added line #L1456 was not covered by tests
}

return {
...memberExpression,
expression,
};
if (Array.isArray(memberExpression.expression)) {
return {
...memberExpression,
expression: Function.constructor.apply(null, memberExpression.expression),
};
}

if (memberExpression.expression.type === 'PatchMeasure') {
return {

Check warning on line 1467 in packages/cubejs-api-gateway/src/gateway.ts

View check run for this annotation

Codecov / codecov/patch

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

Added line #L1467 was not covered by tests
...memberExpression,
expression: {
...memberExpression.expression,
addFilters: memberExpression.expression.addFilters.map(filter => ({

Check warning on line 1471 in packages/cubejs-api-gateway/src/gateway.ts

View check run for this annotation

Codecov / codecov/patch

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

Added line #L1471 was not covered by tests
sql: Function.constructor.apply(null, filter),
})),
}
};
}

throw new Error(`Unexpected member expression to evaluate: ${memberExpression}`);

Check warning on line 1478 in packages/cubejs-api-gateway/src/gateway.ts

View check run for this annotation

Codecov / codecov/patch

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

Added line #L1478 was not covered by tests
}

public async sqlGenerators({ context, res }: { context: RequestContext, res: ResponseResultFn }) {
Expand Down
75 changes: 73 additions & 2 deletions packages/cubejs-api-gateway/src/query.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,31 @@ const getPivotQuery = (queryType, queries) => {
return pivotQuery;
};

const parsedPatchMeasureFilterExpression = Joi.array().items(Joi.string());

const evaluatedPatchMeasureFilterExpression = Joi.object().keys({
sql: Joi.func().required(),
});

const parsedPatchMeasureExpression = Joi.object().keys({
type: Joi.valid('PatchMeasure').required(),
sourceMeasure: Joi.string().required(),
replaceAggregationType: Joi.string().allow(null).required(),
addFilters: Joi.array().items(parsedPatchMeasureFilterExpression).required(),
});

const evaluatedPatchMeasureExpression = parsedPatchMeasureExpression.keys({
addFilters: Joi.array().items(evaluatedPatchMeasureFilterExpression).required(),
});

const id = Joi.string().regex(/^[a-zA-Z0-9_]+\.[a-zA-Z0-9_]+$/);
const idOrMemberExpressionName = Joi.string().regex(/^[a-zA-Z0-9_]+\.[a-zA-Z0-9_]+$|^[a-zA-Z0-9_]+$/);
const dimensionWithTime = Joi.string().regex(/^[a-zA-Z0-9_]+\.[a-zA-Z0-9_]+(\.[a-zA-Z0-9_]+)?$/);
const parsedMemberExpression = Joi.object().keys({
expression: Joi.array().items(Joi.string()).min(1).required(),
expression: Joi.alternatives(
Joi.array().items(Joi.string()).min(1),
parsedPatchMeasureExpression,
).required(),
cubeName: Joi.string().required(),
name: Joi.string().required(),
expressionName: Joi.string(),
Expand All @@ -55,7 +75,43 @@ const parsedMemberExpression = Joi.object().keys({
})
});
const memberExpression = parsedMemberExpression.keys({
expression: Joi.func().required(),
expression: Joi.alternatives(
Joi.func().required(),
evaluatedPatchMeasureExpression,
).required(),
});

const inputSqlFunction = Joi.object().keys({
cubeParams: Joi.array().items(Joi.string()).required(),
sql: Joi.string().required(),
});

// This should be aligned with cubesql side
const inputMemberExpressionSqlFunction = inputSqlFunction.keys({
type: Joi.valid('SqlFunction').required(),
});

// This should be aligned with cubesql side
const inputMemberExpressionPatchMeasure = Joi.object().keys({
type: Joi.valid('PatchMeasure').required(),
sourceMeasure: Joi.string().required(),
replaceAggregationType: Joi.string().allow(null).required(),
addFilters: Joi.array().items(inputSqlFunction).required(),
});

// This should be aligned with cubesql side
const inputMemberExpression = Joi.object().keys({
cubeName: Joi.string().required(),
alias: Joi.string().required(),
expr: Joi.alternatives(
inputMemberExpressionSqlFunction,
inputMemberExpressionPatchMeasure,
),
groupingSet: Joi.object().keys({
groupType: Joi.valid('Rollup', 'Cube').required(),
id: Joi.number().required(),
subId: Joi.number().allow(null),
}).allow(null)
});

const operators = [
Expand Down Expand Up @@ -215,6 +271,20 @@ const normalizeQueryFilters = (filter) => (
})
);

/**
* Parse incoming member expression
* @param {unknown} expression
* @throws {import('./UserError').UserError}
* @returns {import('./types/query').InputMemberExpression}
*/
function parseInputMemberExpression(expression) {
const { error } = inputMemberExpression.validate(expression);
if (error) {
throw new UserError(`Invalid member expression format: ${error.message || error.toString()}`);
}
return expression;
}

/**
* Normalize incoming network query.
* @param {Query} query
Expand Down Expand Up @@ -384,5 +454,6 @@ export {
normalizeQueryPreAggregations,
normalizeQueryPreAggregationPreview,
normalizeQueryCancelPreAggregations,
parseInputMemberExpression,
remapToQueryAdapterFormat,
};
52 changes: 49 additions & 3 deletions packages/cubejs-api-gateway/src/types/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,14 +38,34 @@ type LogicalOrFilter = {
or: (QueryFilter | LogicalAndFilter)[]
};

export type GroupingSetType = 'Rollup' | 'Cube';

type GroupingSet = {
groupType: string,
groupType: GroupingSetType,
id: number,
subId?: null | number
};

export type EvalPatchMeasureFilterExpression = {
sql: Function,
};

export type PatchMeasureExpression = {
type: 'PatchMeasure',
sourceMeasure: string,
replaceAggregationType: string | null,
addFilters: Array<Array<string>>,
};

export type EvalPatchMeasureExpression = {
type: 'PatchMeasure',
sourceMeasure: string,
replaceAggregationType: string | null,
addFilters: Array<EvalPatchMeasureFilterExpression>,
};

type ParsedMemberExpression = {
expression: string[];
expression: string[] | PatchMeasureExpression;
cubeName: string;
name: string;
expressionName: string;
Expand All @@ -54,7 +74,33 @@ type ParsedMemberExpression = {
};

type MemberExpression = Omit<ParsedMemberExpression, 'expression'> & {
expression: Function;
expression: Function | EvalPatchMeasureExpression;
};

type InputSqlFunction = {
cubeParams: Array<string>,
sql: string,
};

export type InputMemberExpressionSqlFunction = {
type: 'SqlFunction'
} & InputSqlFunction;

export type InputMemberExpressionPatchMeasure = {
type: 'PatchMeasure',
sourceMeasure: string,
replaceAggregationType: string | null,
addFilters: Array<InputSqlFunction>,
};

export type InputMemberExpressionExpr = InputMemberExpressionSqlFunction | InputMemberExpressionPatchMeasure;

// This should be aligned with cubesql side
export type InputMemberExpression = {
cubeName: string,
alias: string,
expr: InputMemberExpressionExpr,
groupingSet: GroupingSet | null,
};

/**
Expand Down
45 changes: 17 additions & 28 deletions packages/cubejs-api-gateway/test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -771,6 +771,21 @@ describe('API Gateway', () => {
});

describe('sql api member expressions evaluations', () => {
const query = {
measures: [
// eslint-disable-next-line no-template-curly-in-string
'{"cubeName":"sales","alias":"sum_sales_line_i","expr":{"type":"SqlFunction","cubeParams":["sales"],"sql":"SUM(${sales.line_items_price})"},"groupingSet":null}'
],
dimensions: [
// eslint-disable-next-line no-template-curly-in-string
'{"cubeName":"sales","alias":"users_age","expr":{"type":"SqlFunction","cubeParams":["sales"],"sql":"${sales.users_age}"},"groupingSet":null}',
// eslint-disable-next-line no-template-curly-in-string
'{"cubeName":"sales","alias":"cast_sales_users","expr":{"type":"SqlFunction","cubeParams":["sales"],"sql":"CAST(${sales.users_first_name} AS TEXT)"},"groupingSet":null}'
],
segments: [],
order: []
};

test('throw error if expressions are not allowed', async () => {
const { apiGateway } = await createApiGateway();
const request: QueryRequest = {
Expand All @@ -779,20 +794,7 @@ describe('API Gateway', () => {
const errorMessage = message as { error: string };
expect(errorMessage.error).toEqual('Error: Expressions are not allowed in this context');
},
query: {
measures: [
// eslint-disable-next-line no-template-curly-in-string
'{"cube_name":"sales","alias":"sum_sales_line_i","cube_params":["sales"],"expr":"SUM(${sales.line_items_price})","grouping_set":null}'
],
dimensions: [
// eslint-disable-next-line no-template-curly-in-string
'{"cube_name":"sales","alias":"users_age","cube_params":["sales"],"expr":"${sales.users_age}","grouping_set":null}',
// eslint-disable-next-line no-template-curly-in-string
'{"cube_name":"sales","alias":"cast_sales_users","cube_params":["sales"],"expr":"CAST(${sales.users_first_name} AS TEXT)","grouping_set":null}'
],
segments: [],
order: []
},
query,
expressionParams: [],
exportAnnotatedSql: true,
memberExpressions: false,
Expand Down Expand Up @@ -820,20 +822,7 @@ describe('API Gateway', () => {
res(message) {
expect(message.hasOwnProperty('sql')).toBe(true);
},
query: {
measures: [
// eslint-disable-next-line no-template-curly-in-string
'{"cube_name":"sales","alias":"sum_sales_line_i","cube_params":["sales"],"expr":"SUM(${sales.line_items_price})","grouping_set":null}'
],
dimensions: [
// eslint-disable-next-line no-template-curly-in-string
'{"cube_name":"sales","alias":"users_age","cube_params":["sales"],"expr":"${sales.users_age}","grouping_set":null}',
// eslint-disable-next-line no-template-curly-in-string
'{"cube_name":"sales","alias":"cast_sales_users","cube_params":["sales"],"expr":"CAST(${sales.users_first_name} AS TEXT)","grouping_set":null}'
],
segments: [],
order: []
},
query,
expressionParams: [],
exportAnnotatedSql: true,
memberExpressions: true,
Expand Down
Loading
Loading