diff --git a/src/ol/expr/cpu.js b/src/ol/expr/cpu.js index d1656773191..9c0664d0d7e 100644 --- a/src/ol/expr/cpu.js +++ b/src/ol/expr/cpu.js @@ -26,13 +26,16 @@ import { * value. The evaluator function should do as little allocation and work as possible. */ +export const UNKNOWN_VALUE = null; + /** * @typedef {Object} EvaluationContext - * @property {Object} properties The values for properties used in 'get' expressions. - * @property {Object} variables The values for variables used in 'var' expressions. - * @property {number} resolution The map resolution. - * @property {string|number|null} featureId The feature id. - * @property {string} geometryType Geometry type of the current object. + * Each of these values can be set to null, which means that they are not known in the current context. + * @property {Object|UNKNOWN_VALUE} properties The values for properties used in 'get' expressions. + * @property {Object|UNKNOWN_VALUE} variables The values for variables used in 'var' expressions. + * @property {number|UNKNOWN_VALUE} resolution The map resolution. + * @property {string|number|UNKNOWN_VALUE} featureId The feature id. + * @property {string|UNKNOWN_VALUE} geometryType Geometry type of the current object. */ /** @@ -40,16 +43,20 @@ import { */ export function newEvaluationContext() { return { - variables: {}, - properties: {}, - resolution: NaN, - featureId: null, - geometryType: null, + variables: UNKNOWN_VALUE, + properties: UNKNOWN_VALUE, + resolution: UNKNOWN_VALUE, + featureId: UNKNOWN_VALUE, + geometryType: UNKNOWN_VALUE, }; } /** - * @typedef {function(EvaluationContext):import("./expression.js").LiteralValue} ExpressionEvaluator + * @typedef {import("./expression.js").LiteralValue} LiteralValue + */ + +/** + * @typedef {function(EvaluationContext):LiteralValue} ExpressionEvaluator */ /** @@ -138,8 +145,9 @@ export function compileExpression(expression, context) { } case Ops.Concat: { const args = expression.args.map((e) => compileExpression(e, context)); - return (context) => - ''.concat(...args.map((arg) => arg(context).toString())); + return checkForUnknown(args, (evaluatedArgs) => + ''.concat(...evaluatedArgs.map((arg) => arg.toString())) + ); } case Ops.Resolution: { return (context) => context.resolution; @@ -217,6 +225,9 @@ function compileAssertionExpression(expression, context) { return (context) => { for (let i = 0; i < length; ++i) { const value = args[i](context); + if (value === UNKNOWN_VALUE) { + return UNKNOWN_VALUE; + } if (typeof value === type) { return value; } @@ -240,11 +251,15 @@ function compileAccessorExpression(expression, context) { switch (expression.operator) { case Ops.Get: { return (context) => - context.properties[/** @type {string} */ (nameExpression(context))]; + context.properties === UNKNOWN_VALUE + ? UNKNOWN_VALUE + : context.properties[/** @type {string} */ (nameExpression(context))]; } case Ops.Var: { return (context) => - context.variables[/** @type {string} */ (nameExpression(context))]; + context.variables === UNKNOWN_VALUE + ? UNKNOWN_VALUE + : context.variables[/** @type {string} */ (nameExpression(context))]; } default: { throw new Error(`Unsupported accessor operator ${expression.operator}`); @@ -252,6 +267,26 @@ function compileAccessorExpression(expression, context) { } } +/** + * @param {Array} argEvaluators Argument evaluators + * @param {function(Array): ReturnType} evaluator Final evaluator taking in the evaluated args + * @return {function(EvaluationContext):ReturnType} the evaluator function; if any arg evaluated to UNKNOWN_VALUE, will return UNKNOWN_VALUE + * @template ReturnType + */ +function checkForUnknown(argEvaluators, evaluator) { + return (context) => { + const evaluatedArgs = new Array(argEvaluators.length); + for (let i = 0, ii = evaluatedArgs.length; i < ii; i++) { + const value = argEvaluators[i](context); + if (value === UNKNOWN_VALUE) { + return UNKNOWN_VALUE; + } + evaluatedArgs[i] = value; + } + return evaluator(evaluatedArgs); + }; +} + /** * @param {import('./expression.js').CallExpression} expression The call expression. * @param {import('./expression.js').ParsingContext} context The parsing context. @@ -263,22 +298,22 @@ function compileComparisonExpression(expression, context) { const right = compileExpression(expression.args[1], context); switch (op) { case Ops.Equal: { - return (context) => left(context) === right(context); + return checkForUnknown([left, right], ([left, right]) => left === right); } case Ops.NotEqual: { - return (context) => left(context) !== right(context); + return checkForUnknown([left, right], ([left, right]) => left !== right); } case Ops.LessThan: { - return (context) => left(context) < right(context); + return checkForUnknown([left, right], ([left, right]) => left < right); } case Ops.LessThanOrEqualTo: { - return (context) => left(context) <= right(context); + return checkForUnknown([left, right], ([left, right]) => left <= right); } case Ops.GreaterThan: { - return (context) => left(context) > right(context); + return checkForUnknown([left, right], ([left, right]) => left > right); } case Ops.GreaterThanOrEqualTo: { - return (context) => left(context) >= right(context); + return checkForUnknown([left, right], ([left, right]) => left >= right); } default: { throw new Error(`Unsupported comparison operator ${op}`); @@ -301,27 +336,27 @@ function compileLogicalExpression(expression, context) { } switch (op) { case Ops.Any: { - return (context) => { + return checkForUnknown(args, (evaluatedArgs) => { for (let i = 0; i < length; ++i) { - if (args[i](context)) { + if (evaluatedArgs[i]) { return true; } } return false; - }; + }); } case Ops.All: { - return (context) => { + return checkForUnknown(args, (evaluatedArgs) => { for (let i = 0; i < length; ++i) { - if (!args[i](context)) { + if (!evaluatedArgs[i]) { return false; } } return true; - }; + }); } case Ops.Not: { - return (context) => !args[0](context); + return checkForUnknown(args, ([arg]) => !arg); } default: { throw new Error(`Unsupported logical operator ${op}`); @@ -344,75 +379,76 @@ function compileNumericExpression(expression, context) { } switch (op) { case Ops.Multiply: { - return (context) => { + return checkForUnknown(args, (evaluatedArgs) => { let value = 1; for (let i = 0; i < length; ++i) { - value *= args[i](context); + value *= evaluatedArgs[i]; } return value; - }; + }); } case Ops.Divide: { - return (context) => args[0](context) / args[1](context); + return checkForUnknown(args, ([first, second]) => first / second); } case Ops.Add: { - return (context) => { + return checkForUnknown(args, (evaluatedArgs) => { let value = 0; for (let i = 0; i < length; ++i) { - value += args[i](context); + value += evaluatedArgs[i]; } return value; - }; + }); } case Ops.Subtract: { - return (context) => args[0](context) - args[1](context); + return checkForUnknown(args, ([first, second]) => first - second); } case Ops.Clamp: { - return (context) => { - const value = args[0](context); - const min = args[1](context); + return checkForUnknown(args, ([value, min, max]) => { if (value < min) { return min; } - const max = args[2](context); if (value > max) { return max; } return value; - }; + }); } case Ops.Mod: { - return (context) => args[0](context) % args[1](context); + return checkForUnknown(args, ([first, second]) => first % second); } case Ops.Pow: { - return (context) => Math.pow(args[0](context), args[1](context)); + return checkForUnknown(args, ([first, second]) => + Math.pow(first, second) + ); } case Ops.Abs: { - return (context) => Math.abs(args[0](context)); + return checkForUnknown(args, ([arg]) => Math.abs(arg)); } case Ops.Floor: { - return (context) => Math.floor(args[0](context)); + return checkForUnknown(args, ([arg]) => Math.floor(arg)); } case Ops.Ceil: { - return (context) => Math.ceil(args[0](context)); + return checkForUnknown(args, ([arg]) => Math.ceil(arg)); } case Ops.Round: { - return (context) => Math.round(args[0](context)); + return checkForUnknown(args, ([arg]) => Math.round(arg)); } case Ops.Sin: { - return (context) => Math.sin(args[0](context)); + return checkForUnknown(args, ([arg]) => Math.sin(arg)); } case Ops.Cos: { - return (context) => Math.cos(args[0](context)); + return checkForUnknown(args, ([arg]) => Math.cos(arg)); } case Ops.Atan: { if (length === 2) { - return (context) => Math.atan2(args[0](context), args[1](context)); + return checkForUnknown(args, ([first, second]) => + Math.atan2(first, second) + ); } - return (context) => Math.atan(args[0](context)); + return checkForUnknown(args, ([arg]) => Math.atan(arg)); } case Ops.Sqrt: { - return (context) => Math.sqrt(args[0](context)); + return checkForUnknown(args, ([arg]) => Math.sqrt(arg)); } default: { throw new Error(`Unsupported numeric operator ${op}`); @@ -434,6 +470,9 @@ function compileCaseExpression(expression, context) { return (context) => { for (let i = 0; i < length - 1; i += 2) { const condition = args[i](context); + if (condition === UNKNOWN_VALUE) { + return UNKNOWN_VALUE; + } if (condition) { return args[i + 1](context); } @@ -455,8 +494,15 @@ function compileMatchExpression(expression, context) { } return (context) => { const value = args[0](context); + if (value === UNKNOWN_VALUE) { + return UNKNOWN_VALUE; + } for (let i = 1; i < length; i += 2) { - if (value === args[i](context)) { + const matched = args[i](context); + if (matched === UNKNOWN_VALUE) { + return UNKNOWN_VALUE; + } + if (value === matched) { return args[i + 1](context); } } @@ -475,15 +521,15 @@ function compileInterpolateExpression(expression, context) { for (let i = 0; i < length; ++i) { args[i] = compileExpression(expression.args[i], context); } - return (context) => { - const base = args[0](context); - const value = args[1](context); + return checkForUnknown(args, (evaluatedArgs) => { + const base = evaluatedArgs[0]; + const value = evaluatedArgs[1]; let previousInput; let previousOutput; for (let i = 2; i < length; i += 2) { - const input = args[i](context); - let output = args[i + 1](context); + const input = evaluatedArgs[i]; + let output = evaluatedArgs[i + 1]; const isColor = Array.isArray(output); if (isColor) { output = withAlpha(output); @@ -515,7 +561,7 @@ function compileInterpolateExpression(expression, context) { previousOutput = output; } return previousOutput; - }; + }); } /** diff --git a/test/node/ol/expr/cpu.test.js b/test/node/ol/expr/cpu.test.js index a04378cd074..874b8226ff7 100644 --- a/test/node/ol/expr/cpu.test.js +++ b/test/node/ol/expr/cpu.test.js @@ -8,6 +8,7 @@ import { newParsingContext, } from '../../../../src/ol/expr/expression.js'; import { + UNKNOWN_VALUE, buildExpression, newEvaluationContext, } from '../../../../src/ol/expr/cpu.js'; @@ -593,6 +594,104 @@ describe('ol/expr/cpu.js', () => { expression: ['interpolate', ['linear'], 0.5, 0, 'red', 1, [0, 255, 0]], expected: [219, 170, 0, 1], }, + { + name: 'incomplete context (unknown properties, color)', + type: ColorType, + expression: ['*', ['get', 'color'], [255, 255, 255, 0.5]], + context: { + properties: UNKNOWN_VALUE, + }, + expected: UNKNOWN_VALUE, + }, + { + name: 'incomplete context (unknown properties, string)', + type: StringType, + expression: ['concat', ['get', 'type'], '-icon'], + context: { + properties: UNKNOWN_VALUE, + }, + expected: UNKNOWN_VALUE, + }, + { + name: 'incomplete context (unknown properties, boolean)', + type: BooleanType, + expression: ['all', ['get', 'enabled'], true], + context: { + properties: UNKNOWN_VALUE, + }, + expected: UNKNOWN_VALUE, + }, + { + name: 'incomplete context (unknown properties, assertion)', + type: StringType, + expression: ['string', ['get', 'type'], 'hello'], + context: { + properties: UNKNOWN_VALUE, + }, + expected: UNKNOWN_VALUE, + }, + { + name: 'incomplete context (unknown properties, comparison)', + type: BooleanType, + expression: ['==', ['get', 'enabled'], false], + context: { + properties: UNKNOWN_VALUE, + }, + expected: UNKNOWN_VALUE, + }, + { + name: 'incomplete context (unknown properties, case)', + type: NumberType, + expression: ['case', ['get', 'enabled'], 10, false, 20, 30], + context: { + properties: UNKNOWN_VALUE, + }, + expected: UNKNOWN_VALUE, + }, + { + name: 'incomplete context (unknown properties, match)', + type: NumberType, + expression: ['match', ['get', 'type'], 'abc', 10, 'def', 20, 30], + context: { + properties: UNKNOWN_VALUE, + }, + expected: UNKNOWN_VALUE, + }, + { + name: 'incomplete context (unknown properties, interpolate)', + type: NumberType, + expression: [ + 'interpolate', + ['linear'], + ['get', 'value'], + 0, + -50, + 10, + 50, + ], + context: { + properties: UNKNOWN_VALUE, + }, + expected: UNKNOWN_VALUE, + }, + { + name: 'incomplete context (unknown variables)', + type: ColorType, + expression: ['*', ['var', 'color'], [255, 255, 255, 0.5]], + context: { + variables: UNKNOWN_VALUE, + }, + expected: UNKNOWN_VALUE, + }, + { + name: 'incomplete context (unknown resolution)', + type: NumberType, + expression: ['-', ['resolution'], 100], + context: { + resolution: UNKNOWN_VALUE, + }, + expected: UNKNOWN_VALUE, + }, ]; for (const c of cases) { @@ -679,6 +778,7 @@ describe('ol/expr/cpu.js', () => { const parsingContext = newParsingContext(); const evaluator = buildExpression(expression, type, parsingContext); const evaluationContext = newEvaluationContext(); + evaluationContext.variables = {}; for (const [input, output] of t.cases) { it(`works for ${input}`, () => { evaluationContext.variables.input = input;