diff --git a/CMakeLists.txt b/CMakeLists.txt index 630cd7ad6..0ecfa338a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -230,6 +230,7 @@ list(APPEND INCLUDE_FILES ${PROJECT_SOURCE_DIR}/include/mbgl/style/expression/image.hpp ${PROJECT_SOURCE_DIR}/include/mbgl/style/expression/image_expression.hpp ${PROJECT_SOURCE_DIR}/include/mbgl/style/expression/in.hpp + ${PROJECT_SOURCE_DIR}/include/mbgl/style/expression/index_of.hpp ${PROJECT_SOURCE_DIR}/include/mbgl/style/expression/interpolate.hpp ${PROJECT_SOURCE_DIR}/include/mbgl/style/expression/interpolator.hpp ${PROJECT_SOURCE_DIR}/include/mbgl/style/expression/is_constant.hpp @@ -630,6 +631,7 @@ list(APPEND SRC_FILES ${PROJECT_SOURCE_DIR}/src/mbgl/style/expression/image.cpp ${PROJECT_SOURCE_DIR}/src/mbgl/style/expression/image_expression.cpp ${PROJECT_SOURCE_DIR}/src/mbgl/style/expression/in.cpp + ${PROJECT_SOURCE_DIR}/src/mbgl/style/expression/index_of.cpp ${PROJECT_SOURCE_DIR}/src/mbgl/style/expression/interpolate.cpp ${PROJECT_SOURCE_DIR}/src/mbgl/style/expression/is_constant.cpp ${PROJECT_SOURCE_DIR}/src/mbgl/style/expression/is_expression.cpp diff --git a/include/mbgl/style/expression/expression.hpp b/include/mbgl/style/expression/expression.hpp index be677fdce..e907e6bed 100644 --- a/include/mbgl/style/expression/expression.hpp +++ b/include/mbgl/style/expression/expression.hpp @@ -178,7 +178,8 @@ enum class Kind : int32_t { ImageExpression, In, Within, - Distance + Distance, + IndexOf }; class Expression { diff --git a/include/mbgl/style/expression/index_of.hpp b/include/mbgl/style/expression/index_of.hpp new file mode 100644 index 000000000..6269a3908 --- /dev/null +++ b/include/mbgl/style/expression/index_of.hpp @@ -0,0 +1,41 @@ +#pragma once + +#include +#include +#include + +namespace mbgl { +namespace style { +namespace expression { + +class IndexOf : public Expression { +public: + IndexOf(std::unique_ptr keyword_, + std::unique_ptr input_, + std::unique_ptr fromIndex_); + + static ParseResult parse(const mbgl::style::conversion::Convertible& value, ParsingContext& ctx); + + EvaluationResult evaluate(const EvaluationContext& params) const override; + void eachChild(const std::function&) const override; + + bool operator==(const Expression& e) const override; + + std::vector> possibleOutputs() const override; + std::string getOperator() const override; + +private: + EvaluationResult evaluateForArrayInput(const std::vector& array, + const Value& keyword, + size_t fromIndex) const; + EvaluationResult evaluateForStringInput(const std::string& string, const Value& keyword, size_t fromIndex) const; + +private: + std::unique_ptr keyword; + std::unique_ptr input; + std::unique_ptr fromIndex; +}; + +} // namespace expression +} // namespace style +} // namespace mbgl diff --git a/metrics/integration/expression-tests/index-of/assert-array/test.json b/metrics/integration/expression-tests/index-of/assert-array/test.json new file mode 100644 index 000000000..b49240169 --- /dev/null +++ b/metrics/integration/expression-tests/index-of/assert-array/test.json @@ -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" + }, + "serialized": ["index-of", ["get", "i"], ["array", ["get", "arr"]]], + "outputs": [ + -1, + 3, + -1, + 0, + {"error": "Expected value to be of type array, but found null instead."} + ] + } + } \ No newline at end of file diff --git a/metrics/integration/expression-tests/index-of/assert-string/test.json b/metrics/integration/expression-tests/index-of/assert-string/test.json new file mode 100644 index 000000000..60d64417d --- /dev/null +++ b/metrics/integration/expression-tests/index-of/assert-string/test.json @@ -0,0 +1,24 @@ +{ + "expression": ["index-of", ["get", "substr"], ["string", ["get", "str"]]], + "inputs": [ + [{}, {"properties": {"substr": null, "str": "helloworld"}}], + [{}, {"properties": {"substr": "foo", "str": "helloworld"}}], + [{}, {"properties": {"substr": "low", "str": "helloworld"}}], + [{}, {"properties": {"substr": "low", "str": null}}] + ], + "expected": { + "compiled": { + "result": "success", + "isFeatureConstant": false, + "isZoomConstant": true, + "type": "number" + }, + "serialized": ["index-of", ["get", "substr"], ["string", ["get", "str"]]], + "outputs": [ + -1, + -1, + 3, + {"error": "Expected value to be of type string, but found null instead."} + ] + } + } \ No newline at end of file diff --git a/metrics/integration/expression-tests/index-of/basic-array/test.json b/metrics/integration/expression-tests/index-of/basic-array/test.json new file mode 100644 index 000000000..00fb9036c --- /dev/null +++ b/metrics/integration/expression-tests/index-of/basic-array/test.json @@ -0,0 +1,46 @@ +{ + "expression": ["index-of", ["get", "i"], ["get", "arr"]], + "inputs": [ + [{}, {"properties": {"i": null, "arr": [9, 8, 7]}}], + [{}, {"properties": {"i": 1, "arr": [9, 8, 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": 1, "arr": null}}] + ], + "expected": { + "compiled": { + "result": "success", + "isFeatureConstant": false, + "isZoomConstant": true, + "type": "number" + }, + "serialized": ["index-of", ["get", "i"], ["get", "arr"]], + "outputs": [ + -1, + -1, + 0, + 3, + 6, + { + "error": "Expected second argument to be of type array or string, but found null instead." + } + ] + } +} diff --git a/metrics/integration/expression-tests/index-of/basic-string/test.json b/metrics/integration/expression-tests/index-of/basic-string/test.json new file mode 100644 index 000000000..7cb787ea4 --- /dev/null +++ b/metrics/integration/expression-tests/index-of/basic-string/test.json @@ -0,0 +1,32 @@ +{ + "expression": ["index-of", ["get", "substr"], ["get", "str"]], + "inputs": [ + [{}, {"properties": {"substr": null, "str": "helloworld"}}], + [{}, {"properties": {"substr": "foo", "str": "helloworld"}}], + [{}, {"properties": {"substr": "low", "str": "helloworld"}}], + [{}, {"properties": {"substr": true, "str": "falsetrue"}}], + [{}, {"properties": {"substr": false, "str": "falsetrue"}}], + [{}, {"properties": {"substr": 123, "str": "hello123world"}}], + [{}, {"properties": {"substr": "low", "str": null}}] + ], + "expected": { + "compiled": { + "result": "success", + "isFeatureConstant": false, + "isZoomConstant": true, + "type": "number" + }, + "serialized": ["index-of", ["get", "substr"], ["get", "str"]], + "outputs": [ + -1, + -1, + 3, + 5, + 0, + 5, + { + "error": "Expected second argument to be of type array or string, but found null instead." + } + ] + } + } \ No newline at end of file diff --git a/metrics/integration/expression-tests/index-of/invalid-haystack/test.json b/metrics/integration/expression-tests/index-of/invalid-haystack/test.json new file mode 100644 index 000000000..5b8e56398 --- /dev/null +++ b/metrics/integration/expression-tests/index-of/invalid-haystack/test.json @@ -0,0 +1,28 @@ +{ + "expression": ["index-of", ["get", "needle"], ["get", "haystack"]], + "inputs": [ + [{}, {"properties": {"needle": 1, "haystack": 123}}], + [{}, {"properties": {"needle": "foo", "haystack": {}}}], + [{}, {"properties": {"needle": "foo", "haystack": null}}] + ], + "expected": { + "compiled": { + "result": "success", + "isFeatureConstant": false, + "isZoomConstant": true, + "type": "number" + }, + "serialized": ["index-of", ["get", "needle"], ["get", "haystack"]], + "outputs": [ + { + "error": "Expected second argument to be of type array or string, but found number instead." + }, + { + "error": "Expected second argument to be of type array or string, but found object instead." + }, + { + "error": "Expected second argument to be of type array or string, but found null instead." + } + ] + } + } \ No newline at end of file diff --git a/metrics/integration/expression-tests/index-of/invalid-needle/test.json b/metrics/integration/expression-tests/index-of/invalid-needle/test.json new file mode 100644 index 000000000..1385a7092 --- /dev/null +++ b/metrics/integration/expression-tests/index-of/invalid-needle/test.json @@ -0,0 +1,25 @@ +{ + "expression": ["index-of", ["get", "needle"], ["get", "haystack"]], + "inputs": [ + [{}, {"properties": {"needle": {}, "haystack": [9, 8, 7]}}], + [{}, {"properties": {"needle": {}, "haystack": "helloworld"}}] + ], + "expected": { + "compiled": { + "result": "success", + "isFeatureConstant": false, + "isZoomConstant": true, + "type": "number" + }, + "serialized": ["index-of", ["get", "needle"], ["get", "haystack"]], + "outputs": [ + { + "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." + } + ] + } + } + \ No newline at end of file diff --git a/metrics/integration/expression-tests/index-of/with-from-index/test.json b/metrics/integration/expression-tests/index-of/with-from-index/test.json new file mode 100644 index 000000000..52a029e7b --- /dev/null +++ b/metrics/integration/expression-tests/index-of/with-from-index/test.json @@ -0,0 +1,45 @@ +{ + "expression": ["index-of", ["get", "needle"], ["get", "hay"], ["get", "i"]], + "inputs": [ + [{}, {"properties": {"needle": null, "hay": "helloworld", "i": 0}}], + [{}, {"properties": {"needle": "foo", "hay": "helloworld", "i": 0}}], + [{}, {"properties": {"needle": "low", "hay": "helloworldlow", "i": 4}}], + [{}, {"properties": {"needle": true, "hay": "falsetruetrue", "i": 6}}], + [{}, {"properties": {"needle": false, "hay": "falsetrue", "i": 0}}], + [{}, {"properties": {"needle": 123, "hay": "hello123world", "i": 6}}], + [{}, {"properties": {"needle": "low", "hay": null, "i": 0}}], + [{}, {"properties": {"needle": 7, "hay": [9, 8, 7, 8, 7, 7], "i": 3}}], + [{}, {"properties": {"needle": 9, "hay": [9, 8, 7, 8, 7, 7], "i": 1}}], + [{}, {"properties": {"needle": 8, "hay": [9, 8, 7, 8, 7, 7], "i": 1}}], + [{}, {"properties": {"needle": 8, "hay": [9, 8, 7, 8, 7, 7], "i": 10000}}], + [{}, {"properties": {"needle": 8, "hay": [9, 8, 7, 8, 7, 7], "i": "wrong"}}] + ], + "expected": { + "compiled": { + "result": "success", + "isFeatureConstant": false, + "isZoomConstant": true, + "type": "number" + }, + "serialized": ["index-of", ["get", "needle"], ["get", "hay"], ["get", "i"]], + "outputs": [ + -1, + -1, + 10, + 9, + 0, + -1, + { + "error": "Expected second argument to be of type array or string, but found null instead." + }, + 4, + -1, + 1, + -1, + { + "error": "Expected third argument to be of type number, but found string instead." + } + ] + } + } + \ No newline at end of file diff --git a/platform/android/CHANGELOG.md b/platform/android/CHANGELOG.md index 27c35a182..80fc6cf91 100644 --- a/platform/android/CHANGELOG.md +++ b/platform/android/CHANGELOG.md @@ -7,6 +7,7 @@ MapLibre welcomes participation and contributions from everyone. Please read [`C ### ✨ Features and improvements * Change to a more natural fling animation and allow setting `flingThreshold` and `flingAnimationBaseTime` in `UiSettings` ([#963](https://github.com/maplibre/maplibre-native/pull/963)) +* Add support for [index-of expression](https://maplibre.org/maplibre-style-spec/expressions/#index-of) ([#1113](https://github.com/maplibre/maplibre-native/pull/1113)) ### 🐞 Bug fixes diff --git a/platform/android/MapboxGLAndroidSDK/src/main/java/com/mapbox/mapboxsdk/style/expressions/Expression.java b/platform/android/MapboxGLAndroidSDK/src/main/java/com/mapbox/mapboxsdk/style/expressions/Expression.java index 4218b1e67..4a815e4d2 100644 --- a/platform/android/MapboxGLAndroidSDK/src/main/java/com/mapbox/mapboxsdk/style/expressions/Expression.java +++ b/platform/android/MapboxGLAndroidSDK/src/main/java/com/mapbox/mapboxsdk/style/expressions/Expression.java @@ -1552,6 +1552,31 @@ public class Expression { return new Expression("in", needle, haystack); } + /** + * Returns the first position at which a `needle` can be found in a `haystack`. + * + * @param needle the item expression + * @param haystack the array or string expression + * @return position in the array or string or -1 if not found. + * @see Style specification + */ + public static Expression indexOf(@NonNull Expression keyword, @NonNull Expression input) { + return new Expression("index-of", keyword, input); + } + + /** + * Returns the first position at which a `needle` can be found in a `haystack`. + * + * @param needle the item expression + * @param haystack the array or string expression + * @param fromIndex the index to start searching from + * @return position in the array or string or -1 if not found. + * @see Style specification + */ + public static Expression indexOf(@NonNull Expression keyword, @NonNull Expression input, @NonNull Expression fromIndex) { + return new Expression("index-of", keyword, input, fromIndex); + } + /** * Retrieves whether an item exists in an array or a substring exists in a string. * diff --git a/platform/android/MapboxGLAndroidSDK/src/test/java/com/mapbox/mapboxsdk/style/expressions/ExpressionTest.kt b/platform/android/MapboxGLAndroidSDK/src/test/java/com/mapbox/mapboxsdk/style/expressions/ExpressionTest.kt index fed563d1d..d845a67e9 100644 --- a/platform/android/MapboxGLAndroidSDK/src/test/java/com/mapbox/mapboxsdk/style/expressions/ExpressionTest.kt +++ b/platform/android/MapboxGLAndroidSDK/src/test/java/com/mapbox/mapboxsdk/style/expressions/ExpressionTest.kt @@ -455,6 +455,27 @@ class ExpressionTest { Assert.assertTrue("expression should match", Arrays.deepEquals(expected, actual)) } + @Test + @Throws(Exception::class) + fun testIndexOf() { + val expected = arrayOf("index-of", 2f, arrayOf("literal", arrayOf(1f, 2f, 3f))) + val actual = + Expression.indexOf(Expression.literal(2f), Expression.literal(arrayOf(1f, 2f, 3f))) + .toArray() + Assert.assertTrue("expression should match", Arrays.deepEquals(expected, actual)) + } + + + @Test + @Throws(Exception::class) + fun testIndexOfWithFromIndex() { + val expected = arrayOf("index-of", 2f, arrayOf("literal", arrayOf(1f, 2f, 3f)), 1f) + val actual = + Expression.indexOf(Expression.literal(2f), Expression.literal(arrayOf(1f, 2f, 3f)), Expression.literal(1f)) + .toArray() + Assert.assertTrue("expression should match", Arrays.deepEquals(expected, actual)) + } + @Test @Throws(Exception::class) fun testInNumber() { diff --git a/src/mbgl/style/expression/index_of.cpp b/src/mbgl/style/expression/index_of.cpp new file mode 100644 index 000000000..dfff3fff9 --- /dev/null +++ b/src/mbgl/style/expression/index_of.cpp @@ -0,0 +1,138 @@ +#include "mbgl/style/expression/expression.hpp" +#include +#include +#include +#include +namespace mbgl { +namespace style { +namespace expression { + +IndexOf::IndexOf(std::unique_ptr keyword_, + std::unique_ptr input_, + std::unique_ptr fromIndex_) + : Expression(Kind::IndexOf, type::Number), + keyword(std::move(keyword_)), + input(std::move(input_)), + fromIndex(std::move(fromIndex_)) {} + +EvaluationResult IndexOf::evaluate(const EvaluationContext ¶ms) const { + const EvaluationResult evaluatedKeyword = keyword->evaluate(params); + const EvaluationResult evaluatedInput = input->evaluate(params); + if (!evaluatedKeyword) { + return evaluatedKeyword.error(); + } + if (!evaluatedInput) { + return evaluatedInput.error(); + } + + if (!(evaluatedKeyword->is() || evaluatedKeyword->is() || evaluatedKeyword->is() || + evaluatedKeyword->is())) { + return EvaluationError{"Expected first argument to be of type boolean, string, number or null, but found " + + toString(typeOf(*evaluatedKeyword)) + " instead."}; + } + + size_t fromIndexValue = 0; + if (fromIndex) { + const EvaluationResult evaluatedFromIndex = fromIndex->evaluate(params); + if (!evaluatedFromIndex) { + return evaluatedFromIndex.error(); + } + if (!evaluatedFromIndex->is()) { + return EvaluationError{"Expected third argument to be of type number, but found " + + toString(typeOf(*evaluatedFromIndex)) + " instead."}; + } + fromIndexValue = static_cast(evaluatedFromIndex->get()); + } + + return evaluatedInput->match( + [&](const std::string &s) { return evaluateForStringInput(s, *evaluatedKeyword, fromIndexValue); }, + [&](const std::vector &v) { return evaluateForArrayInput(v, *evaluatedKeyword, fromIndexValue); }, + [&](const auto &) -> EvaluationResult { + return EvaluationError{"Expected second argument to be of type array or string, but found " + + toString(typeOf(*evaluatedInput)) + " instead."}; + }); +} + +EvaluationResult IndexOf::evaluateForArrayInput(const std::vector &array, + const Value &keywordValue, + size_t fromIndexValue) const { + for (size_t index = fromIndexValue; index < array.size(); ++index) { + if (array[index] == keywordValue) { + return static_cast(index); + } + } + return static_cast(-1); +} + +EvaluationResult IndexOf::evaluateForStringInput(const std::string &string, + const Value &keywordValue, + size_t fromIndexValue) const { + std::string keywordString = keywordValue.match( + [](const std::string &s) { return s; }, + [](bool b) { return b ? std::string{"true"} : std::string{"false"}; }, + [](NullValue) { return std::string{"null"}; }, + [](double n) { return util::toString(n); }, + [](const auto &) -> std::string { + // it should be impossible to get here - we do validation in evaluate() + assert(false); + return ""; + }); + size_t index = string.find(keywordString, fromIndexValue); + if (index == std::string::npos) { + return static_cast(-1); + } + return static_cast(index); +} + +void IndexOf::eachChild(const std::function &visit) const { + visit(*keyword); + visit(*input); + if (fromIndex) { + visit(*fromIndex); + } +} + +using namespace mbgl::style::conversion; +ParseResult IndexOf::parse(const Convertible &value, ParsingContext &ctx) { + assert(isArray(value)); + + std::size_t length = arrayLength(value); + if (length != 3 && length != 4) { + ctx.error("Expected 2 or 3 arguments, but found " + util::toString(length - 1) + " instead."); + return ParseResult(); + } + + ParseResult keyword = ctx.parse(arrayMember(value, 1), 1); + ParseResult input = ctx.parse(arrayMember(value, 2), 2); + + ParseResult fromIndex = length == 4 ? ctx.parse(arrayMember(value, 3), 3) : ParseResult(); + + if (!keyword || !input) { + return ParseResult(); + } + + return ParseResult( + std::make_unique(std::move(*keyword), std::move(*input), fromIndex ? std::move(*fromIndex) : nullptr)); +} + +bool IndexOf::operator==(const Expression &e) const { + if (e.getKind() == Kind::IndexOf) { + auto rhs = static_cast(&e); + const auto fromIndexEqual = (fromIndex && rhs->fromIndex && *fromIndex == *(rhs->fromIndex)) || + (!fromIndex && !rhs->fromIndex); + return *keyword == *(rhs->keyword) && *input == *(rhs->input) && fromIndexEqual; + } + return false; +} + +std::vector> IndexOf::possibleOutputs() const { + return {std::nullopt}; +} + +std::string IndexOf::getOperator() const { + return "index-of"; +} + +} // namespace expression +} // namespace style +} // namespace mbgl diff --git a/src/mbgl/style/expression/parsing_context.cpp b/src/mbgl/style/expression/parsing_context.cpp index f91ed1105..1d6fcb251 100644 --- a/src/mbgl/style/expression/parsing_context.cpp +++ b/src/mbgl/style/expression/parsing_context.cpp @@ -25,6 +25,7 @@ #include #include #include +#include #include #include @@ -139,6 +140,7 @@ MAPBOX_ETERNAL_CONSTEXPR const auto expressionRegistry = {"to-string", Coercion::parse}, {"var", Var::parse}, {"within", Within::parse}, + {"index-of", IndexOf::parse}, }); bool isExpression(const std::string& name) {