diff --git a/src/style-spec/feature_filter/index.js b/src/style-spec/feature_filter/index.js index 749fee45493..fc921858e50 100644 --- a/src/style-spec/feature_filter/index.js +++ b/src/style-spec/feature_filter/index.js @@ -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 @@ -12,21 +16,42 @@ 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): 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), '||')) : @@ -34,52 +59,50 @@ function compile(filter) { 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>, 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) { + 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) { + return ['!', filter]; } -// Comparison function to sort numbers and strings -function compare(a, b) { - return a < b ? -1 : a > b ? 1 : 0; -} diff --git a/src/style-spec/function/evaluation_context.js b/src/style-spec/function/evaluation_context.js index 4bfa43e8782..eb6613bbd1b 100644 --- a/src/style-spec/function/evaluation_context.js +++ b/src/style-spec/function/evaluation_context.js @@ -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) { diff --git a/src/style-spec/validate/validate_expression.js b/src/style-spec/validate/validate_expression.js index 9dafe7f79f0..410500e634e 100644 --- a/src/style-spec/validate/validate_expression.js +++ b/src/style-spec/validate/validate_expression.js @@ -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 []; } diff --git a/src/style-spec/validate/validate_filter.js b/src/style-spec/validate/validate_filter.js index b87ab3eb829..78c4a354cde 100644 --- a/src/style-spec/validate/validate_filter.js +++ b/src/style-spec/validate/validate_filter.js @@ -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; @@ -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) { diff --git a/src/style-spec/validate/validate_function.js b/src/style-spec/validate/validate_function.js index 8d8ea9bbdef..6e1f21c3dbd 100644 --- a/src/style-spec/validate/validate_function.js +++ b/src/style-spec/validate/validate_function.js @@ -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' })); } diff --git a/test/integration/expression-tests/coalesce/null/test.json b/test/integration/expression-tests/coalesce/null/test.json new file mode 100644 index 00000000000..0a8a7198115 --- /dev/null +++ b/test/integration/expression-tests/coalesce/null/test.json @@ -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] + } +} diff --git a/test/unit/style-spec/feature_filter.test.js b/test/unit/style-spec/feature_filter.test.js index 3077bf90c8f..b82d27311f8 100644 --- a/test/unit/style-spec/feature_filter.test.js +++ b/test/unit/style-spec/feature_filter.test.js @@ -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);