Skip to content

Commit a5b44d1

Browse files
feat: Complex boolean logic (#1038)
* Add Recursive schema (tree-like) validation for AND & OR conditions * query validation * code style * new query parser * operators OR and AND * add tests * complex boolean logic in filters * fix query format * code style * code style * add eslint * code style Fixes #259
1 parent 26b329e commit a5b44d1

File tree

10 files changed

+926
-83
lines changed

10 files changed

+926
-83
lines changed

packages/cubejs-api-gateway/index.js

Lines changed: 48 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -71,13 +71,11 @@ const prepareAnnotation = (metaConfig, query) => {
7171
};
7272
};
7373

74-
const getQueryGranularity = (queries) => {
75-
return R.pipe(
76-
R.map(({ timeDimensions }) => timeDimensions[0] && timeDimensions[0].granularity || null),
77-
R.filter(Boolean),
78-
R.uniq
79-
)(queries);
80-
};
74+
const getQueryGranularity = (queries) => R.pipe(
75+
R.map(({ timeDimensions }) => timeDimensions[0] && timeDimensions[0].granularity || null),
76+
R.filter(Boolean),
77+
R.uniq
78+
)(queries);
8179

8280
const getPivotQuery = (queryType, queries) => {
8381
let [pivotQuery] = queries;
@@ -188,15 +186,22 @@ const operators = [
188186
'measureFilter',
189187
];
190188

189+
const oneFilter = Joi.object().keys({
190+
dimension: id,
191+
member: id,
192+
operator: Joi.valid(operators).required(),
193+
values: Joi.array().items(Joi.string().allow('', null), Joi.lazy(() => oneFilter))
194+
}).xor('dimension', 'member');
195+
196+
const oneCondition = Joi.object().keys({
197+
or: Joi.array().items(oneFilter, Joi.lazy(() => oneCondition).description('oneCondition schema')),
198+
and: Joi.array().items(oneFilter, Joi.lazy(() => oneCondition).description('oneCondition schema')),
199+
}).xor('or', 'and');
200+
191201
const querySchema = Joi.object().keys({
192202
measures: Joi.array().items(id),
193203
dimensions: Joi.array().items(dimensionWithTime),
194-
filters: Joi.array().items(Joi.object().keys({
195-
dimension: id,
196-
member: id,
197-
operator: Joi.valid(operators).required(),
198-
values: Joi.array().items(Joi.string().allow('', null))
199-
}).xor('dimension', 'member')),
204+
filters: Joi.array().items(oneFilter, oneCondition),
200205
timeDimensions: Joi.array().items(Joi.object().keys({
201206
dimension: id.required(),
202207
granularity: Joi.valid('day', 'month', 'year', 'week', 'hour', 'minute', 'second', null),
@@ -234,6 +239,34 @@ const normalizeQueryOrder = order => {
234239

235240
const DateRegex = /^\d\d\d\d-\d\d-\d\d$/;
236241

242+
const checkQueryFilters = (filter) => {
243+
filter.find(f => {
244+
if (f.or) {
245+
checkQueryFilters(f.or);
246+
return false;
247+
}
248+
if (f.and) {
249+
checkQueryFilters(f.and);
250+
return false;
251+
}
252+
253+
if (!f.operator) {
254+
throw new UserError(`Operator required for filter: ${JSON.stringify(f)}`);
255+
}
256+
257+
if (operators.indexOf(f.operator) === -1) {
258+
throw new UserError(`Operator ${f.operator} not supported for filter: ${JSON.stringify(f)}`);
259+
}
260+
261+
if (!f.values && ['set', 'notSet', 'measureFilter'].indexOf(f.operator) === -1) {
262+
throw new UserError(`Values required for filter: ${JSON.stringify(f)}`);
263+
}
264+
return false;
265+
});
266+
267+
return true;
268+
};
269+
237270
const normalizeQuery = (query) => {
238271
const { error } = Joi.validate(query, querySchema);
239272
if (error) {
@@ -247,41 +280,9 @@ const normalizeQuery = (query) => {
247280
'Query should contain either measures, dimensions or timeDimensions with granularities in order to be valid'
248281
);
249282
}
250-
const filterWithoutOperator = (query.filters || []).find(f => !f.operator);
251-
if (filterWithoutOperator) {
252-
throw new UserError(`Operator required for filter: ${JSON.stringify(filterWithoutOperator)}`);
253-
}
254-
const filterWithIncorrectOperator = (query.filters || [])
255-
.find(f => [
256-
'equals',
257-
'notEquals',
258-
'contains',
259-
'notContains',
260-
'in',
261-
'notIn',
262-
'gt',
263-
'gte',
264-
'lt',
265-
'lte',
266-
'set',
267-
'notSet',
268-
'inDateRange',
269-
'notInDateRange',
270-
'onTheDate',
271-
'beforeDate',
272-
'afterDate',
273-
'measureFilter',
274-
].indexOf(f.operator) === -1);
275-
276-
if (filterWithIncorrectOperator) {
277-
throw new UserError(`Operator ${filterWithIncorrectOperator.operator} not supported for filter: ${JSON.stringify(filterWithIncorrectOperator)}`);
278-
}
283+
284+
checkQueryFilters(query.filters || []);
279285

280-
const filterWithoutValues = (query.filters || [])
281-
.find(f => !f.values && ['set', 'notSet', 'measureFilter'].indexOf(f.operator) === -1);
282-
if (filterWithoutValues) {
283-
throw new UserError(`Values required for filter: ${JSON.stringify(filterWithoutValues)}`);
284-
}
285286
const regularToTimeDimension = (query.dimensions || []).filter(d => d.split('.').length === 3).map(d => ({
286287
dimension: d.split('.').slice(0, 2).join('.'),
287288
granularity: d.split('.')[2]

packages/cubejs-api-gateway/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@
1212
"node": ">=8.11.1"
1313
},
1414
"scripts": {
15-
"test": "jest"
15+
"test": "jest",
16+
"lint": "eslint *.js"
1617
},
1718
"main": "index.js",
1819
"types": "index.d.ts",

packages/cubejs-schema-compiler/adapter/BaseDimension.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ class BaseDimension {
2323
return this.dimensionDefinition().sql;
2424
}
2525

26+
getMembers() {
27+
return [this];
28+
}
29+
2630
cube() {
2731
return this.query.cubeEvaluator.cubeFromPath(this.dimension);
2832
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
class BaseGroupFilter {
2+
constructor(query, filter) {
3+
this.values = filter.values;
4+
this.operator = filter.operator;
5+
this.measure = filter.measure;
6+
this.dimension = filter.dimension;
7+
}
8+
9+
filterToWhere() {
10+
return this.values.map(f => `(${f.filterToWhere()})`).join(` ${this.operator} `);
11+
}
12+
13+
getMembers() {
14+
return this.values.map(f => {
15+
if (f.getMembers) {
16+
return f.getMembers();
17+
}
18+
return f;
19+
});
20+
}
21+
}
22+
23+
module.exports = BaseGroupFilter;

packages/cubejs-schema-compiler/adapter/BaseMeasure.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ class BaseMeasure {
99
this.measure = measure;
1010
}
1111

12+
getMembers() {
13+
return [this];
14+
}
15+
1216
selectColumns() {
1317
return [`${this.measureSql()} ${this.aliasName()}`];
1418
}

0 commit comments

Comments
 (0)