Skip to content

Commit 9e339f7

Browse files
committed
feat: Allow null filter values
Fixes #362
1 parent 9b99860 commit 9e339f7

File tree

4 files changed

+144
-21
lines changed

4 files changed

+144
-21
lines changed

packages/cubejs-api-gateway/index.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@ const querySchema = Joi.object().keys({
127127
dimension: id,
128128
member: id,
129129
operator: Joi.valid(operators).required(),
130-
values: Joi.array().items(Joi.string().allow(''))
130+
values: Joi.array().items(Joi.string().allow('', null))
131131
}).xor('dimension', 'member')),
132132
timeDimensions: Joi.array().items(Joi.object().keys({
133133
dimension: id.required(),

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

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,37 @@ const express = require('express');
55
const ApiGateway = require('./index');
66

77
const compilerApi = jest.fn().mockImplementation(() => ({
8-
getSql() {
9-
return 'SELECT * FROM test';
8+
async getSql() {
9+
return {
10+
sql: ['SELECT * FROM test', []],
11+
aliasNameToMember: {
12+
foo__bar: 'Foo.bar'
13+
}
14+
};
1015
},
1116

12-
metaConfig() {
13-
return [];
17+
async metaConfig() {
18+
return [{
19+
config: {
20+
name: 'Foo',
21+
measures: [{
22+
name: 'Foo.bar'
23+
}],
24+
dimensions: [{
25+
name: 'Foo.id'
26+
}]
27+
}
28+
}];
1429
}
1530
}));
16-
const adapterApi = jest.fn();
17-
const logger = jest.fn();
31+
const adapterApi = jest.fn().mockImplementation(() => ({
32+
async executeQuery() {
33+
return {
34+
data: [{ foo__bar: 42 }]
35+
};
36+
}
37+
}));
38+
const logger = (type, message) => console.log({ type, ...message });
1839

1940
describe(`API Gateway`, () => {
2041
process.env.NODE_ENV = 'production';
@@ -45,4 +66,13 @@ describe(`API Gateway`, () => {
4566
.expect(400);
4667
expect(res.body && res.body.error).toStrictEqual("Query should contain either measures, dimensions or timeDimensions with granularities in order to be valid");
4768
});
69+
70+
test(`null filter values`, async () => {
71+
const res = await request(app)
72+
.get('/cubejs-api/v1/load?query={"measures":["Foo.bar"],"filters":[{"dimension":"Foo.id","operator":"equals","values":[null]}]}')
73+
.set('Authorization', 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.e30.t-IDcSemACt8x4iTMCda8Yhe3iZaWbvV5XKSTbuAn0M')
74+
.expect(200);
75+
console.log(res.body);
76+
expect(res.body && res.body.data).toStrictEqual([{ 'Foo.bar': 42 }]);
77+
});
4878
});

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

Lines changed: 38 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
const inlection = require('inflection');
22
const momentRange = require('moment-range');
33
const moment = momentRange.extendMoment(require('moment-timezone'));
4-
const { repeat, join, map, contains } = require('ramda');
4+
const {
5+
repeat, join, map, contains
6+
} = require('ramda');
57

68
const BaseDimension = require('./BaseDimension');
79

@@ -40,8 +42,9 @@ class BaseFilter extends BaseDimension {
4042
}
4143

4244
conditionSql(columnSql) {
43-
const operatorMethod = `${inlection.camelize(this.operator).replace(/[A-Z]/, (c) =>
44-
(c != null ? c : '').toLowerCase()
45+
const operatorMethod = `${inlection.camelize(this.operator).replace(
46+
/[A-Z]/,
47+
(c) => (c != null ? c : '').toLowerCase()
4548
)}Where`;
4649
const sql = this[operatorMethod](columnSql);
4750
return this.query.paramAllocator.allocateParamsForQuestionString(sql, this.filterParams());
@@ -67,9 +70,7 @@ class BaseFilter extends BaseDimension {
6770

6871
// noinspection JSMethodCanBeStatic
6972
escapeWildcardChars(param) {
70-
return typeof param === 'string'
71-
? param.replace(/([_%])/gi, '\\$1')
72-
: param;
73+
return typeof param === 'string' ? param.replace(/([_%])/gi, '\\$1') : param;
7374
}
7475

7576
isWildcardOperator() {
@@ -83,7 +84,7 @@ class BaseFilter extends BaseDimension {
8384
if (this.operator === 'set' || this.operator === 'not_set' || this.operator === 'expressionEquals') {
8485
return [];
8586
}
86-
const params = Array.isArray(this.values) ? this.values : [this.values];
87+
const params = this.valuesArray().filter(v => v != null);
8788

8889
if (this.isWildcardOperator()) {
8990
return map(this.escapeWildcardChars, params);
@@ -92,6 +93,14 @@ class BaseFilter extends BaseDimension {
9293
return params;
9394
}
9495

96+
valuesArray() {
97+
return Array.isArray(this.values) ? this.values : [this.values];
98+
}
99+
100+
valuesContainNull() {
101+
return this.valuesArray().indexOf(null) !== -1;
102+
}
103+
95104
castParameter() {
96105
return '?';
97106
}
@@ -110,8 +119,15 @@ class BaseFilter extends BaseDimension {
110119

111120
likeOr(column, not) {
112121
const basePart = this.likeIgnoreCase(column, not);
113-
const nullCheck = `${not ? ` OR ${column} IS NULL` : ''}`;
114-
return `${join(not ? ' AND ' : ' OR ', repeat(basePart, this.values.length))}${nullCheck}`;
122+
return `${join(not ? ' AND ' : ' OR ', repeat(basePart, this.filterParams().length))}${this.orIsNullCheck(column, not)}`;
123+
}
124+
125+
orIsNullCheck(column, not) {
126+
return `${this.shouldAddOrIsNull(not) ? ` OR ${column} IS NULL` : ''}`;
127+
}
128+
129+
shouldAddOrIsNull(not) {
130+
return not ? !this.valuesContainNull() : this.valuesContainNull();
115131
}
116132

117133
likeIgnoreCase(column, not) {
@@ -123,27 +139,35 @@ class BaseFilter extends BaseDimension {
123139
return this.inWhere(column);
124140
}
125141

126-
return `${column} = ${this.castParameter()}`;
142+
if (this.valuesContainNull()) {
143+
return this.notSetWhere(column);
144+
}
145+
146+
return `${column} = ${this.castParameter()}${this.orIsNullCheck(column, false)}`;
127147
}
128148

129149
inPlaceholders() {
130-
return `(${join(', ', repeat(this.castParameter(), this.values.length || 1))})`;
150+
return `(${join(', ', repeat(this.castParameter(), this.filterParams().length || 1))})`;
131151
}
132152

133153
inWhere(column) {
134-
return `${column} IN ${this.inPlaceholders()}`;
154+
return `${column} IN ${this.inPlaceholders()}${this.orIsNullCheck(column, false)}`;
135155
}
136156

137157
notEqualsWhere(column) {
138158
if (this.isArrayValues()) {
139159
return this.notInWhere(column);
140160
}
141161

142-
return `${column} <> ${this.castParameter()}`;
162+
if (this.valuesContainNull()) {
163+
return this.setWhere(column);
164+
}
165+
166+
return `${column} <> ${this.castParameter()}${this.orIsNullCheck(column, true)}`;
143167
}
144168

145169
notInWhere(column) {
146-
return `${column} NOT IN ${this.inPlaceholders()}`;
170+
return `${column} NOT IN ${this.inPlaceholders()}${this.orIsNullCheck(column, true)}`;
147171
}
148172

149173
setWhere(column) {

packages/cubejs-schema-compiler/test/SQLGenerationTest.js

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1305,6 +1305,75 @@ describe('SQL Generation', function test() {
13051305
])
13061306
);
13071307

1308+
it(
1309+
'contains null filter',
1310+
() => runQueryTest({
1311+
measures: [],
1312+
dimensions: [
1313+
'visitors.source'
1314+
],
1315+
timeDimensions: [],
1316+
timezone: 'America/Los_Angeles',
1317+
filters: [{
1318+
dimension: 'visitors.source',
1319+
operator: 'contains',
1320+
values: ['goo', null]
1321+
}],
1322+
order: [{
1323+
id: 'visitors.source'
1324+
}]
1325+
}, [
1326+
{ "visitors__source": 'google' },
1327+
{ "visitors__source": null }
1328+
])
1329+
);
1330+
1331+
it(
1332+
'null filter',
1333+
() => runQueryTest({
1334+
measures: [],
1335+
dimensions: [
1336+
'visitors.source'
1337+
],
1338+
timeDimensions: [],
1339+
timezone: 'America/Los_Angeles',
1340+
filters: [{
1341+
dimension: 'visitors.source',
1342+
operator: 'equals',
1343+
values: ['google', null]
1344+
}],
1345+
order: [{
1346+
id: 'visitors.source'
1347+
}]
1348+
}, [
1349+
{ "visitors__source": 'google' },
1350+
{ "visitors__source": null },
1351+
])
1352+
);
1353+
1354+
it(
1355+
'not equals filter',
1356+
() => runQueryTest({
1357+
measures: [],
1358+
dimensions: [
1359+
'visitors.source'
1360+
],
1361+
timeDimensions: [],
1362+
timezone: 'America/Los_Angeles',
1363+
filters: [{
1364+
dimension: 'visitors.source',
1365+
operator: 'notEquals',
1366+
values: ['google']
1367+
}],
1368+
order: [{
1369+
id: 'visitors.source'
1370+
}]
1371+
}, [
1372+
{ "visitors__source": 'some' },
1373+
{ "visitors__source": null },
1374+
])
1375+
);
1376+
13081377
it('year granularity', () =>
13091378
runQueryTest({
13101379
measures: [

0 commit comments

Comments
 (0)