Skip to content

Commit

Permalink
Add migration to expressions into gl-style-migrate
Browse files Browse the repository at this point in the history
  • Loading branch information
Anand Thakker authored and jfirebaugh committed Aug 23, 2018
1 parent 8a9395e commit 466162f
Show file tree
Hide file tree
Showing 62 changed files with 2,378 additions and 358 deletions.
206 changes: 206 additions & 0 deletions src/style-spec/feature_filter/convert.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
// @flow

import { isExpressionFilter } from './index';

import type { FilterSpecification } from '../types';

type ExpectedTypes = {[string]: 'string' | 'number' | 'boolean'};

/**
* Convert the given legacy filter to (the JSON representation of) an
* equivalent expression
* @private
*/
export default function convertFilter(filter: FilterSpecification): mixed {
return _convertFilter(filter, {});
}

/*
* Convert the given filter to an expression, storing the expected types for
* any feature properties referenced in expectedTypes.
*
* These expected types are needed in order to construct preflight type checks
* needed for handling 'any' filters. A preflight type check is necessary in
* order to mimic legacy filters' semantics around expected type mismatches.
* For example, consider the legacy filter:
*
* ["any", ["all", [">", "y", 0], [">", "y", 0]], [">", "x", 0]]
*
* Naively, we might convert this to the expression:
*
* ["any", ["all", [">", ["get", "y"], 0], [">", ["get", "z"], 0]], [">", ["get", "x"], 0]]
*
* But if we tried to evaluate this against, say `{x: 1, y: null, z: 0}`, the
* [">", ["get", "y"], 0] would cause an evaluation error, leading to the
* entire filter returning false. Legacy filter semantics, though, ask for
* [">", "y", 0] to simply return `false` when `y` is of the wrong type,
* allowing the subsequent terms of the outer "any" expression to be evaluated
* (resulting, in this case, in a `true` value, because x > 0).
*
* We account for this by inserting a preflight type-checking expression before
* each "any" term, allowing us to avoid evaluating the actual converted filter
* if any type mismatches would cause it to produce an evalaution error:
*
* ["any",
* ["case",
* ["all", ["==", ["typeof", ["get", "y"]], "number"], ["==", ["typeof", ["get", "z"], "number]],
* ["all", [">", ["get", "y"], 0], [">", ["get", "z"], 0]],
* false
* ],
* ["case",
* ["==", ["typeof", ["get", "x"], "number"]],
* [">", ["get", "x"], 0],
* false
* ]
* ]
*
* An alternative, possibly more direct approach would be to use type checks
* in the conversion of each comparison operator, so that the converted version
* of each individual ==, >=, etc. would mimic the legacy filter semantics. The
* downside of this approach is that it can lead to many more type checks than
* would otherwise be necessary: outside the context of an "any" expression,
* bailing out due to a runtime type error (expression semantics) and returning
* false (legacy filter semantics) are equivalent: they cause the filter to
* produce a `false` result.
*/
function _convertFilter(filter: FilterSpecification, expectedTypes: ExpectedTypes): mixed {
if (isExpressionFilter(filter)) { return filter; }

if (!filter) return true;
const op = filter[0];
if (filter.length <= 1) return (op !== 'any');

let converted;

if (
op === '==' ||
op === '!=' ||
op === '<' ||
op === '>' ||
op === '<=' ||
op === '>='
) {
const [, property, value] = (filter: any);
converted = convertComparisonOp(property, value, op, expectedTypes);
} else if (op === 'any') {
const children = (filter: any).slice(1).map(f => {
const types = {};
const child = _convertFilter(f, types);
const typechecks = runtimeTypeChecks(types);
return typechecks === true ? child : ['case', typechecks, child, false];
});
return ['any'].concat(children);
} else if (op === 'all') {
const children = (filter: any).slice(1).map(f => _convertFilter(f, expectedTypes));
return ['all'].concat(children);
} else if (op === 'none') {
return ['!', _convertFilter(['any'].concat(filter.slice(1)), {})];
} else if (op === 'in') {
converted = convertInOp((filter[1]: any), filter.slice(2));
} else if (op === '!in') {
converted = convertInOp((filter[1]: any), filter.slice(2), true);
} else if (op === 'has') {
converted = convertHasOp((filter[1]: any));
} else if (op === '!has') {
converted = ['!', convertHasOp((filter[1]: any))];
} else {
converted = true;
}

return converted;
}

// Given a set of feature properties and an expected type for each one,
// construct an boolean expression that tests whether each property has the
// right type.
// E.g.: for {name: 'string', population: 'number'}, return
// [ 'all',
// ['==', ['typeof', ['get', 'name'], 'string']],
// ['==', ['typeof', ['get', 'population'], 'number]]
// ]
function runtimeTypeChecks(expectedTypes: ExpectedTypes) {
const conditions = [];
for (const property in expectedTypes) {
const get = property === '$id' ? ['id'] : ['get', property];
conditions.push(['==', ['typeof', get], expectedTypes[property]]);
}
if (conditions.length === 0) return true;
if (conditions.length === 1) return conditions[0];
return ['all'].concat(conditions);
}

function convertComparisonOp(property: string, value: any, op: string, expectedTypes: ?ExpectedTypes) {
let get;
if (property === '$type') {
return [op, ['geometry-type'], value];
} else if (property === '$id') {
get = ['id'];
} else {
get = ['get', property];
}

if (expectedTypes && value !== null) {
const type = ((typeof value): any);
expectedTypes[property] = type;
}

if (op === '==' && property !== '$id' && value === null) {
return [
'all',
['has', property], // missing property != null for legacy filters
['==', get, null]
];
} else if (op === '!=' && property !== '$id' && value === null) {
return [
'any',
['!', ['has', property]], // missing property != null for legacy filters
['!=', get, null]
];
}

return [op, get, value];
}

function convertInOp(property: string, values: Array<any>, negate = false) {
if (values.length === 0) return negate;

let get;
if (property === '$type') {
get = ['geometry-type'];
} else if (property === '$id') {
get = ['id'];
} else {
get = ['get', property];
}

// Determine if the list of values to be searched is homogenously typed.
// If so (and if the type is string or number), then we can use a
// [match, input, [...values], true, false] construction rather than a
// bunch of `==` tests.
let uniformTypes = true;
const type = typeof values[0];
for (const value of values) {
if (typeof value !== type) {
uniformTypes = false;
break;
}
}

if (uniformTypes && (type === 'string' || type === 'number')) {
return ['match', get, values, !negate, negate];
}

return [ negate ? 'all' : 'any' ].concat(
values.map(v => [negate ? '!=' : '==', get, v])
);
}

function convertHasOp(property: string) {
if (property === '$type') {
return true;
} else if (property === '$id') {
return ['!=', ['id'], null];
} else {
return ['has', property];
}
}
6 changes: 5 additions & 1 deletion src/style-spec/feature_filter/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ export default createFilter;
export { isExpressionFilter };

function isExpressionFilter(filter: any) {
if (filter === true || filter === false) {
return true;
}

if (!Array.isArray(filter) || filter.length === 0) {
return false;
}
Expand Down Expand Up @@ -65,7 +69,7 @@ const filterSpec = {
* @returns {Function} filter-evaluating function
*/
function createFilter(filter: any): FeatureFilter {
if (!filter) {
if (filter === null || filter === undefined) {
return () => true;
}

Expand Down
Loading

0 comments on commit 466162f

Please sign in to comment.