Skip to content

Commit

Permalink
Add slice and index-of expressions (#9450)
Browse files Browse the repository at this point in the history
  • Loading branch information
lbutler authored Apr 2, 2020
1 parent 469b9ff commit 274d215
Show file tree
Hide file tree
Showing 21 changed files with 592 additions and 28 deletions.
32 changes: 6 additions & 26 deletions src/style-spec/expression/definitions/in.js
Original file line number Diff line number Diff line change
@@ -1,33 +1,13 @@
// @flow

import {ValueType, BooleanType, toString} from '../types';
import {BooleanType, StringType, ValueType, NullType, toString, NumberType, isValidType, isValidNativeType} from '../types';
import RuntimeError from '../runtime_error';
import {typeOf} from '../values';

import type {Expression} from '../expression';
import type ParsingContext from '../parsing_context';
import type EvaluationContext from '../evaluation_context';
import type {Type} from '../types';
import type {Value} from '../values';

function isComparableType(type: Type) {
return type.kind === 'boolean' ||
type.kind === 'string' ||
type.kind === 'number' ||
type.kind === 'null' ||
type.kind === 'value';
}

function isComparableRuntimeValue(needle: boolean | string | number | null) {
return typeof needle === 'boolean' ||
typeof needle === 'string' ||
typeof needle === 'number';
}

function isSearchableRuntimeValue(haystack: Array<Value> | string) {
return Array.isArray(haystack) ||
typeof haystack === 'string';
}

class In implements Expression {
type: Type;
Expand All @@ -51,7 +31,7 @@ class In implements Expression {

if (!needle || !haystack) return null;

if (!isComparableType(needle.type)) {
if (!isValidType(needle.type, [BooleanType, StringType, NumberType, NullType, ValueType])) {
return context.error(`Expected first argument to be of type boolean, string, number or null, but found ${toString(needle.type)} instead`);
}

Expand All @@ -62,13 +42,13 @@ class In implements Expression {
const needle = (this.needle.evaluate(ctx): any);
const haystack = (this.haystack.evaluate(ctx): any);

if (needle == null || !haystack) return false;
if (!haystack) return false;

if (!isComparableRuntimeValue(needle)) {
throw new RuntimeError(`Expected first argument to be of type boolean, string or number, but found ${toString(typeOf(needle))} instead.`);
if (!isValidNativeType(needle, ['boolean', 'string', 'number', 'null'])) {
throw new RuntimeError(`Expected first argument to be of type boolean, string, number or null, but found ${toString(typeOf(needle))} instead.`);
}

if (!isSearchableRuntimeValue(haystack)) {
if (!isValidNativeType(haystack, ['string', 'array'])) {
throw new RuntimeError(`Expected second argument to be of type array or string, but found ${toString(typeOf(haystack))} instead.`);
}

Expand Down
4 changes: 4 additions & 0 deletions src/style-spec/expression/definitions/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,10 @@ import Assertion from './assertion';
import Coercion from './coercion';
import At from './at';
import In from './in';
import IndexOf from './index_of';
import Match from './match';
import Case from './case';
import Slice from './slice';
import Step from './step';
import Interpolate from './interpolate';
import Coalesce from './coalesce';
Expand Down Expand Up @@ -64,6 +66,7 @@ const expressions: ExpressionRegistry = {
'format': FormatExpression,
'image': ImageExpression,
'in': In,
'index-of': IndexOf,
'interpolate': Interpolate,
'interpolate-hcl': Interpolate,
'interpolate-lab': Interpolate,
Expand All @@ -74,6 +77,7 @@ const expressions: ExpressionRegistry = {
'number': Assertion,
'number-format': NumberFormat,
'object': Assertion,
'slice': Slice,
'step': Step,
'string': Assertion,
'to-boolean': Coercion,
Expand Down
89 changes: 89 additions & 0 deletions src/style-spec/expression/definitions/index_of.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
// @flow

import {BooleanType, StringType, ValueType, NullType, toString, NumberType, isValidType, isValidNativeType} from '../types';
import RuntimeError from '../runtime_error';
import {typeOf} from '../values';

import type {Expression} from '../expression';
import type ParsingContext from '../parsing_context';
import type EvaluationContext from '../evaluation_context';
import type {Type} from '../types';

class IndexOf implements Expression {
type: Type;
needle: Expression;
haystack: Expression;
fromIndex: ?Expression;

constructor(needle: Expression, haystack: Expression, fromIndex?: Expression) {
this.type = NumberType;
this.needle = needle;
this.haystack = haystack;
this.fromIndex = fromIndex;
}

static parse(args: $ReadOnlyArray<mixed>, context: ParsingContext) {
if (args.length <= 2 || args.length >= 5) {
return context.error(`Expected 3 or 4 arguments, but found ${args.length - 1} instead.`);
}

const needle = context.parse(args[1], 1, ValueType);

const haystack = context.parse(args[2], 2, ValueType);

if (!needle || !haystack) return null;
if (!isValidType(needle.type, [BooleanType, StringType, NumberType, NullType, ValueType])) {
return context.error(`Expected first argument to be of type boolean, string, number or null, but found ${toString(needle.type)} instead`);
}

if (args.length === 4) {
const fromIndex = context.parse(args[3], 3, NumberType);
if (!fromIndex) return null;
return new IndexOf(needle, haystack, fromIndex);
} else {
return new IndexOf(needle, haystack);
}
}

evaluate(ctx: EvaluationContext) {
const needle = (this.needle.evaluate(ctx): any);
const haystack = (this.haystack.evaluate(ctx): any);

if (!isValidNativeType(needle, ['boolean', 'string', 'number', 'null'])) {
throw new RuntimeError(`Expected first argument to be of type boolean, string, number or null, but found ${toString(typeOf(needle))} instead.`);
}

if (!isValidNativeType(haystack, ['string', 'array'])) {
throw new RuntimeError(`Expected second argument to be of type array or string, but found ${toString(typeOf(haystack))} instead.`);
}

if (this.fromIndex) {
const fromIndex = (this.fromIndex.evaluate(ctx): number);
return haystack.indexOf(needle, fromIndex);
}

return haystack.indexOf(needle);
}

eachChild(fn: (_: Expression) => void) {
fn(this.needle);
fn(this.haystack);
if (this.fromIndex) {
fn(this.fromIndex);
}
}

outputDefined() {
return false;
}

serialize() {
if (this.fromIndex != null && this.fromIndex !== undefined) {
const fromIndex = this.fromIndex.serialize();
return ["index-of", this.needle.serialize(), this.haystack.serialize(), fromIndex];
}
return ["index-of", this.needle.serialize(), this.haystack.serialize()];
}
}

export default IndexOf;
86 changes: 86 additions & 0 deletions src/style-spec/expression/definitions/slice.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
// @flow

import {ValueType, NumberType, StringType, array, toString, isValidType, isValidNativeType} from '../types';
import RuntimeError from '../runtime_error';
import {typeOf} from '../values';

import type {Expression} from '../expression';
import type ParsingContext from '../parsing_context';
import type EvaluationContext from '../evaluation_context';
import type {Type} from '../types';

class Slice implements Expression {
type: Type;
input: Expression;
beginIndex: Expression;
endIndex: ?Expression;

constructor(type: Type, input: Expression, beginIndex: Expression, endIndex?: Expression) {
this.type = type;
this.input = input;
this.beginIndex = beginIndex;
this.endIndex = endIndex;

}

static parse(args: $ReadOnlyArray<mixed>, context: ParsingContext) {
if (args.length <= 2 || args.length >= 5) {
return context.error(`Expected 3 or 4 arguments, but found ${args.length - 1} instead.`);
}

const input = context.parse(args[1], 1, ValueType);
const beginIndex = context.parse(args[2], 2, NumberType);

if (!input || !beginIndex) return null;

if (!isValidType(input.type, [array(ValueType), StringType, ValueType])) {
return context.error(`Expected first argument to be of type array or string, but found ${toString(input.type)} instead`);
}

if (args.length === 4) {
const endIndex = context.parse(args[3], 3, NumberType);
if (!endIndex) return null;
return new Slice(input.type, input, beginIndex, endIndex);
} else {
return new Slice(input.type, input, beginIndex);
}
}

evaluate(ctx: EvaluationContext) {
const input = (this.input.evaluate(ctx): any);
const beginIndex = (this.beginIndex.evaluate(ctx): number);

if (!isValidNativeType(input, ['string', 'array'])) {
throw new RuntimeError(`Expected first argument to be of type array or string, but found ${toString(typeOf(input))} instead.`);
}

if (this.endIndex) {
const endIndex = (this.endIndex.evaluate(ctx): number);
return input.slice(beginIndex, endIndex);
}

return input.slice(beginIndex);
}

eachChild(fn: (_: Expression) => void) {
fn(this.input);
fn(this.beginIndex);
if (this.endIndex) {
fn(this.endIndex);
}
}

outputDefined() {
return false;
}

serialize() {
if (this.endIndex != null && this.endIndex !== undefined) {
const endIndex = this.endIndex.serialize();
return ["slice", this.input.serialize(), this.beginIndex.serialize(), endIndex];
}
return ["slice", this.input.serialize(), this.beginIndex.serialize()];
}
}

export default Slice;
20 changes: 20 additions & 0 deletions src/style-spec/expression/types.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ export type ArrayType = {
N: ?number
}

export type NativeType = 'number' | 'string' | 'boolean' | 'null' | 'array' | 'object'

export const NullType = {kind: 'null'};
export const NumberType = {kind: 'number'};
export const StringType = {kind: 'string'};
Expand Down Expand Up @@ -104,3 +106,21 @@ export function checkSubtype(expected: Type, t: Type): ?string {

return `Expected ${toString(expected)} but found ${toString(t)} instead.`;
}

export function isValidType(provided: Type, allowedTypes: Array<Type>): boolean {
return allowedTypes.some(t => t.kind === provided.kind);
}

export function isValidNativeType(provided: any, allowedTypes: Array<NativeType>): boolean {
return allowedTypes.some(t => {
if (t === 'null') {
return provided === null;
} else if (t === 'array') {
return Array.isArray(provided);
} else if (t === 'object') {
return provided && !Array.isArray(provided) && typeof provided === 'object';
} else {
return t === typeof provided;
}
});
}
18 changes: 18 additions & 0 deletions src/style-spec/reference/v8.json
Original file line number Diff line number Diff line change
Expand Up @@ -2597,6 +2597,15 @@
}
}
},
"index-of": {
"doc": "Returns the first index at which a given element can be found in an array, or for a string, the first occurrence of the specified value. If a second argument is provided, then the search is started from that position. Returns -1 if the value is not found.",
"group": "Lookup",
"sdk-support": {
"basic functionality": {
"js": "1.10.0"
}
}
},
"case": {
"doc": "Selects the first output whose corresponding test condition evaluates to true, or the fallback value otherwise.",
"group": "Decision",
Expand Down Expand Up @@ -3520,6 +3529,15 @@
"macos": "0.9.0"
}
}
},
"slice": {
"doc": "Returns a portion of a string or an array starting from the provided beginning index. If a second argument is provided, then the return portion will run to, but not include, the end index.",
"group": "String",
"sdk-support": {
"basic functionality": {
"js": "1.10.0"
}
}
}
}
},
Expand Down
2 changes: 2 additions & 0 deletions test/integration/expression-tests/in/assert-array/test.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
[{}, {"properties": {"i": null, "arr": [9, 8, 7]}}],
[{}, {"properties": {"i": 1, "arr": [9, 8, 7]}}],
[{}, {"properties": {"i": 9, "arr": [9, 8, 7]}}],
[{}, {"properties": {"i": null, "arr": [9, 8, 7, null]}}],
[{}, {"properties": {"i": 1, "arr": null}}]
],
"expected": {
Expand All @@ -20,6 +21,7 @@
false,
false,
true,
true,
{"error":"Expected value to be of type array, but found null instead."}
],
"serialized": [
Expand Down
2 changes: 2 additions & 0 deletions test/integration/expression-tests/in/basic-array/test.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
[{}, {"properties": {"i": 9, "arr": [9, 8, 7]}}],
[{}, {"properties": {"i": "foo", "arr": ["baz", "bar", "hello", "foo", "world"]}}],
[{}, {"properties": {"i": true, "arr": ["foo", 123, null, 456, false, {}, true]}}],
[{}, {"properties": {"i": null, "arr": ["foo", 123, null, 456, false, {}, true]}}],
[{}, {"properties": {"i": 1, "arr": null}}]
],
"expected": {
Expand All @@ -24,6 +25,7 @@
true,
true,
true,
true,
false
],
"serialized": [
Expand Down
4 changes: 2 additions & 2 deletions test/integration/expression-tests/in/invalid-needle/test.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@
"type": "boolean"
},
"outputs": [
{"error":"Expected first argument to be of type boolean, string or number, but found object instead."},
{"error":"Expected first argument to be of type boolean, string or number, but found object instead."}
{"error":"Expected first argument to be of type boolean, string, number or null, but found object instead."},
{"error":"Expected first argument to be of type boolean, string, number or null, but found object instead."}
],
"serialized": [
"boolean",
Expand Down
26 changes: 26 additions & 0 deletions test/integration/expression-tests/index-of/assert-array/test.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
{
"expression": ["index-of", ["get", "i"], ["array", ["get", "arr"]]],
"inputs": [
[{}, { "properties": { "i": null, "arr": [9, 8, 7] } }],
[{}, { "properties": { "i": null, "arr": [9, 8, 7, null] } }],
[{}, { "properties": { "i": 1, "arr": [9, 8, 7] } }],
[{}, { "properties": { "i": 9, "arr": [9, 8, 7, 9] } }],
[{}, { "properties": { "i": 1, "arr": null } }]
],
"expected": {
"compiled": {
"result": "success",
"isFeatureConstant": false,
"isZoomConstant": true,
"type": "number"
},
"outputs": [
-1,
3,
-1,
0,
{ "error": "Expected value to be of type array, but found null instead." }
],
"serialized": ["index-of", ["get", "i"], ["array", ["get", "arr"]]]
}
}
Loading

0 comments on commit 274d215

Please sign in to comment.