Skip to content

Commit

Permalink
Support expressions in filters
Browse files Browse the repository at this point in the history
 - Converts classic filters to expressions under the hood
 - Also accepts `{expression: [...]}`, requiring that the given
 expression be of type Boolean
  • Loading branch information
Anand Thakker committed Aug 30, 2017
1 parent 6d49264 commit 9e6acf8
Show file tree
Hide file tree
Showing 7 changed files with 148 additions and 50 deletions.
113 changes: 68 additions & 45 deletions src/style-spec/feature_filter/index.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
// @flow

module.exports = createFilter;
const compileExpression = require('../function/compile');
const {BooleanType} = require('../function/types');

import type {Feature} from '../function';

const types = ['Unknown', 'Point', 'LineString', 'Polygon'];
module.exports = createFilter;

/**
* Given a filter expressed as nested arrays, return a new function
Expand All @@ -12,74 +16,93 @@ const types = ['Unknown', 'Point', 'LineString', 'Polygon'];
* @param {Array} filter mapbox gl filter
* @returns {Function} filter-evaluating function
*/
function createFilter(filter) {
return new Function('f', `var p = (f && f.properties || {}); return ${compile(filter)}`);
function createFilter(filter: any) {
if (!filter) {
return (_: VectorTileFeature) => true;
}

let expression = Array.isArray(filter) ? convertFilter(filter) : filter.expression;
if (Array.isArray(expression) && expression[0] !== 'coalesce') {
expression = ['coalesce', expression, false];
}
const compiled = compileExpression(expression, BooleanType);

if (compiled.result === 'success') {
return (feature: VectorTileFeature) => {
const expressionFeature: Feature = {
properties: feature.properties || {},
type: feature.type,
id: typeof feature.id !== 'undefined' ? feature.id : null
};
return compiled.function({}, expressionFeature);
};
} else {
throw new Error(compiled.errors.map(err => `${err.key}: ${err.message}`).join(', '));
}
}

function compile(filter) {
if (!filter) return 'true';
function convertFilter(filter: ?Array<any>): mixed {
if (!filter) return true;
const op = filter[0];
if (filter.length <= 1) return op === 'any' ? 'false' : 'true';
const str =
op === '==' ? compileComparisonOp(filter[1], filter[2], '===', false) :
op === '!=' ? compileComparisonOp(filter[1], filter[2], '!==', false) :
if (filter.length <= 1) return (op !== 'any');
const converted =
op === '==' ? compileComparisonOp(filter[1], filter[2], '==') :
op === '!=' ? compileComparisonOp(filter[1], filter[2], '!=') :
op === '<' ||
op === '>' ||
op === '<=' ||
op === '>=' ? compileComparisonOp(filter[1], filter[2], op, true) :
op === '>=' ? compileComparisonOp(filter[1], filter[2], op) :
op === 'any' ? compileLogicalOp(filter.slice(1), '||') :
op === 'all' ? compileLogicalOp(filter.slice(1), '&&') :
op === 'none' ? compileNegation(compileLogicalOp(filter.slice(1), '||')) :
op === 'in' ? compileInOp(filter[1], filter.slice(2)) :
op === '!in' ? compileNegation(compileInOp(filter[1], filter.slice(2))) :
op === 'has' ? compileHasOp(filter[1]) :
op === '!has' ? compileNegation(compileHasOp(filter[1])) :
'true';
return `(${str})`;
true;
return converted;
}

function compilePropertyReference(property) {
const ref =
property === '$type' ? 'f.type' :
property === '$id' ? 'f.id' : `p[${JSON.stringify(property)}]`;
return ref;
function compilePropertyReference(property: string, type?: ?string) {
if (property === '$type') return ['geometry-type'];
const ref = property === '$id' ? ['id'] : ['get', property];
return type ? [type, ref] : ref;
}

function compileComparisonOp(property, value, op, checkType) {
const left = compilePropertyReference(property);
const right = property === '$type' ? types.indexOf(value) : JSON.stringify(value);
return (checkType ? `typeof ${left}=== typeof ${right}&&` : '') + left + op + right;
function compileComparisonOp(property: string, value: any, op: string) {
const fallback = op === '!=';
if (value === null) {
return [
'coalesce',
[op, ['typeof', compilePropertyReference(property)], 'Null'],
fallback
];
}
const ref = compilePropertyReference(property, typeof value);
return ['coalesce', [op, ref, value], fallback];
}

function compileLogicalOp(expressions, op) {
return expressions.map(compile).join(op);
function compileLogicalOp(expressions: Array<Array<any>>, op: string) {
return [op].concat(expressions.map(convertFilter));
}

function compileInOp(property, values) {
if (property === '$type') values = values.map((value) => {
return types.indexOf(value);
});
const left = JSON.stringify(values.sort(compare));
const right = compilePropertyReference(property);

if (values.length <= 200) return `${left}.indexOf(${right}) !== -1`;
function compileInOp(property: string, values: Array<any>) {
if (values.length === 0) {
return false;
}

return `${'function(v, a, i, j) {' +
'while (i <= j) { var m = (i + j) >> 1;' +
' if (a[m] === v) return true; if (a[m] > v) j = m - 1; else i = m + 1;' +
'}' +
'return false; }('}${right}, ${left},0,${values.length - 1})`;
const input = compilePropertyReference(property);
return ["coalesce", ["contains", input, ["array", ["literal", values]]], false];
}

function compileHasOp(property) {
return property === '$id' ? '"id" in f' : `${JSON.stringify(property)} in p`;
function compileHasOp(property: string) {
const has = property === '$id' ?
['!=', ['typeof', ['id']], 'Null'] :
['has', property];
return has;
}

function compileNegation(expression) {
return `!(${expression})`;
function compileNegation(filter: boolean | Array<any>) {
return ['!', filter];
}

// Comparison function to sort numbers and strings
function compare(a, b) {
return a < b ? -1 : a > b ? 1 : 0;
}
3 changes: 2 additions & 1 deletion src/style-spec/function/evaluation_context.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,8 @@ module.exports = () => ({

has: function (obj: {[string]: Value}, key: string, name?: string) {
ensure(obj, `Cannot get property ${key} from null object${name ? ` ${name}` : ''}.`);
return this.as(obj, ObjectType, name).hasOwnProperty(key);
ensure(typeof obj === 'object', `Expected ${name || 'value'} to be of type Object, but found ${toString(typeOf(obj))} instead.`);
return obj.hasOwnProperty(key);
},

contains: function (value: Value, array: Array<Value>) {
Expand Down
10 changes: 9 additions & 1 deletion src/style-spec/validate/validate_expression.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,15 @@ module.exports = function validateExpression(options) {
const key = `${options.key}.expression`;

if (compiled.result === 'success') {
if (!options.disallowNestedZoom || compiled.isZoomConstant) {
if (compiled.isZoomConstant) {
return [];
}

if (options.allowZoom === 'never') {
return [new ValidationError(`${key}`, options.value, '"zoom" expressions not available.')];
}

if (options.allowZoom !== 'top-level-curve') {
return [];
}

Expand Down
14 changes: 12 additions & 2 deletions src/style-spec/validate/validate_filter.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@

const ValidationError = require('../error/validation_error');
const validateExpression = require('./validate_expression');
const validateEnum = require('./validate_enum');
const getType = require('../util/get_type');
const unbundle = require('../util/unbundle_jsonlint');
const extend = require('../util/extend');

module.exports = function validateFilter(options) {
const value = options.value;
Expand All @@ -12,8 +14,16 @@ module.exports = function validateFilter(options) {

let errors = [];

if (getType(value) !== 'array') {
return [new ValidationError(key, value, 'array expected, %s found', getType(value))];
type = getType(value);
if (type !== 'array' && type !== 'object') {
return [new ValidationError(key, value, 'array or object expected, %s found', getType(value))];
}

if (type === 'object') {
return validateExpression(extend({}, options, {
allowZoom: 'never',
valueSpec: { value: 'boolean' }
}));
}

if (value.length < 1) {
Expand Down
2 changes: 1 addition & 1 deletion src/style-spec/validate/validate_function.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ const extend = require('../util/extend');
module.exports = function validateFunction(options) {
if (options.value.expression) {
return validateExpression(extend({}, options, {
disallowNestedZoom: true
allowZoom: 'top-level-curve'
}));
}

Expand Down
21 changes: 21 additions & 0 deletions test/integration/expression-tests/coalesce/null/test.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"expectExpressionType": null,
"expression": [
"coalesce",
["get", "z"],
0
],
"inputs": [
[{}, {"properties": {"z": 1}}],
[{}, {"properties": {"z": null}}]
],
"expected": {
"compiled": {
"result": "success",
"isFeatureConstant": false,
"isZoomConstant": true,
"type": "Value"
},
"outputs": [1, 0]
}
}
35 changes: 35 additions & 0 deletions test/unit/style-spec/feature_filter.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,41 @@
const test = require('mapbox-gl-js-test').test;
const filter = require('../../../src/style-spec').featureFilter;

test('expression, compare two properties', (t) => {
const f = filter({
expression: ['==', ['string', ['get', 'x']], ['string', ['get', 'y']]]
});
t.equal(f({properties: {x: 1, y: 1}}), false);
t.equal(f({properties: {x: '1', y: '1'}}), true);
t.equal(f({properties: {x: 'same', y: 'same'}}), true);
t.equal(f({properties: {x: null}}), false);
t.equal(f({properties: {x: undefined}}), false);
t.end();
});

test('expression, type error', (t) => {
t.throws(() => {
filter({
expression: ['==', ['number', ['get', 'x']], ['string', ['get', 'y']]]
});
});

t.throws(() => {
filter({
expression: ['number', ['get', 'x']]
});
});

t.doesNotThrow(() => {
filter({
expression: ['boolean', ['get', 'x']]
});
});

t.end();
});


test('degenerate', (t) => {
t.equal(filter()(), true);
t.equal(filter(undefined)(), true);
Expand Down

0 comments on commit 9e6acf8

Please sign in to comment.