This is an automated email from the ASF dual-hosted git repository. raminqaf pushed a commit to branch master in repository https://gitbox.apache.org/repos/asf/flink.git
commit 469332bf2947919049b95982f89e78e35a471a8a Author: Ramin Gharib <[email protected]> AuthorDate: Wed Oct 28 17:08:30 2026 +0100 [FLINK-39049][table] Add VARIANT_GET and TRY_VARIANT_GET `VARIANT_GET(variant, path[, returnType])` returns the value of a Variant at a path, cast to `returnType`, or as a Variant without `returnType`. `TRY_VARIANT_GET` returns NULL instead of an error when the value cannot be cast or a path that is not a literal is malformed. Paths follow FLIP-618: `$`, `.name`, `['name']`, `["name"]` and the 0-based `[n]`, without wildcards, filters, slices, negative indices or path modes. The internal `SimplePath` in flink-table-common parses them, so a malformed literal path fails at planning. A missing field, an index past the end, or a step on the wrong kind of value gives NULL. `returnType` is a string literal, as in Spark, parsed with `LogicalTypeParser`. The cast reuses `FromVariantConverter`, so `VARIANT_GET(v, p, 'T')` casts like `CAST`, and a variant null gives NULL unless `returnType` is `VARIANT`. The result is always nullable. The Table API and PyFlink get `variantGet` / `variant_get` and `tryVariantGet` / `try_variant_get`. `getLocalTimeZone(ReadableConfig)` moves from the planner's `TableConfigUtils` to `TableConfigValidation` in flink-table-api-java, so the runtime function can read the session time zone. The planner's method delegates to it. The docs add the function entries and a "Variant paths" section. They also correct `variant[INT]` and `variant[STRING]`, which throw on the wrong kind of value instead of returning NULL. Generated-by: Claude Code 2.1.310 (Claude Opus 5.5) --- .../docs/sql/functions/built-in-functions.md | 30 ++ docs/content.zh/docs/sql/reference/data-types.md | 5 +- .../docs/sql/functions/built-in-functions.md | 30 ++ docs/content/docs/sql/reference/data-types.md | 5 +- docs/data/sql_functions.yml | 64 ++++- docs/data/sql_functions_zh.yml | 62 ++++- .../docs/reference/pyflink.table/expressions.rst | 11 + flink-python/pyflink/table/expression.py | 34 +++ .../pyflink/table/tests/test_expression.py | 8 + .../flink/table/api/internal/BaseExpressions.java | 66 +++++ .../table/api/internal/TableConfigValidation.java | 17 ++ .../functions/BuiltInFunctionDefinitions.java | 20 ++ .../strategies/SpecificInputTypeStrategies.java | 3 + .../strategies/SpecificTypeStrategies.java | 3 + .../strategies/VariantGetInputTypeStrategy.java | 109 ++++++++ .../strategies/VariantGetTypeStrategy.java | 89 ++++++ .../org/apache/flink/table/utils/SimplePath.java | 303 +++++++++++++++++++++ .../apache/flink/table/utils/SimplePathTest.java | 114 ++++++++ .../table/planner/utils/TableConfigUtils.java | 10 +- .../planner/functions/VariantFunctionsITCase.java | 167 ++++++++++++ .../nodes/exec/stream/VariantSemanticTest.java | 52 ++++ .../functions/scalar/TryVariantGetFunction.java | 36 +++ .../functions/scalar/VariantGetFunction.java | 182 +++++++++++++ 23 files changed, 1404 insertions(+), 16 deletions(-) diff --git a/docs/content.zh/docs/sql/functions/built-in-functions.md b/docs/content.zh/docs/sql/functions/built-in-functions.md index 79115623e0a..ad1e9f83a7b 100644 --- a/docs/content.zh/docs/sql/functions/built-in-functions.md +++ b/docs/content.zh/docs/sql/functions/built-in-functions.md @@ -233,6 +233,36 @@ A document fails to parse if it: In all of these cases, `TRY_PARSE_XML` returns `NULL`. +#### Variant paths + +`VARIANT_GET` and `TRY_VARIANT_GET` take a path that names one place in a Variant. A path starts +with `$`, the whole value, followed by steps: `.name`, `['name']` or `["name"]` for a field, and +`[n]` for an array element. For example, on +`PARSE_JSON('{"user": {"name": "ada"}, "items": [{"qty": 2}]}')`: + +| Path | Points at | +|---------------------|------------------------------------| +| `$` | the whole value | +| `$.user.name` | `"ada"` | +| `$['user']["name"]` | `"ada"` | +| `$.items[0].qty` | `2` | +| `$.items[1]` | nothing, the index is past the end | +| `$.user[0]` | nothing, `user` is not an array | + +- Array indices start at 0, as in `JSON_VALUE`, while `variant[INT]` starts at 1. So + `VARIANT_GET(v, '$.items[0]')` and `v['items'][1]` are the same element. +- Use brackets with single or double quotes for a name with whitespace or one of the characters + `. [ ] ( ) * ' " \`, for example `$['first name']` or `$["first name"]`. Inside the quotes, `\\`, + `\'` and `\"` stand for a backslash and a quote. +- Wildcards, filters, slices, negative indices and the `lax` and `strict` modes are not supported, + so a path always names one place. +- Trailing spaces are ignored. +- A malformed path literal fails at planning time. Any other malformed path fails when the function + runs. + +A path points at nothing if a field is missing, an index is past the end, or a step does not match +the kind of value, such as a field of an array. Each function describes what it does in that case. + ### 值构建函数 {{< sql_functions_zh "valueconstruction" >}} diff --git a/docs/content.zh/docs/sql/reference/data-types.md b/docs/content.zh/docs/sql/reference/data-types.md index 703185f11de..fdfc3a12cde 100644 --- a/docs/content.zh/docs/sql/reference/data-types.md +++ b/docs/content.zh/docs/sql/reference/data-types.md @@ -1551,7 +1551,10 @@ To store one of these values, cast the number to a `VARIANT` instead of parsing A `VARIANT` can be converted to a scalar type with `CAST` or `TRY_CAST`. A cast succeeds only when the target holds the stored value without reinterpreting it, so a value is never wrapped or rounded -to make it fit. Otherwise `CAST` fails and `TRY_CAST` returns `NULL`. +to make it fit. Otherwise `CAST` fails and `TRY_CAST` returns `NULL`. `VARIANT_GET` and +`TRY_VARIANT_GET` read the value at a +[path]({{< ref "docs/sql/functions/built-in-functions" >}}#variant-paths) such as `'$.items[0].qty'` +and cast it by the same rules, returning `NULL` when nothing is at the path. | Stored kind | Succeeds for | |-----------------|-----------------------------------------------------| diff --git a/docs/content/docs/sql/functions/built-in-functions.md b/docs/content/docs/sql/functions/built-in-functions.md index 01bf2070579..0d8ebd0abb4 100644 --- a/docs/content/docs/sql/functions/built-in-functions.md +++ b/docs/content/docs/sql/functions/built-in-functions.md @@ -236,6 +236,36 @@ A document fails to parse if it: In all of these cases, `TRY_PARSE_XML` returns `NULL`. +#### Variant paths + +`VARIANT_GET` and `TRY_VARIANT_GET` take a path that names one place in a Variant. A path starts +with `$`, the whole value, followed by steps: `.name`, `['name']` or `["name"]` for a field, and +`[n]` for an array element. For example, on +`PARSE_JSON('{"user": {"name": "ada"}, "items": [{"qty": 2}]}')`: + +| Path | Points at | +|---------------------|------------------------------------| +| `$` | the whole value | +| `$.user.name` | `"ada"` | +| `$['user']["name"]` | `"ada"` | +| `$.items[0].qty` | `2` | +| `$.items[1]` | nothing, the index is past the end | +| `$.user[0]` | nothing, `user` is not an array | + +- Array indices start at 0, as in `JSON_VALUE`, while `variant[INT]` starts at 1. So + `VARIANT_GET(v, '$.items[0]')` and `v['items'][1]` are the same element. +- Use brackets with single or double quotes for a name with whitespace or one of the characters + `. [ ] ( ) * ' " \`, for example `$['first name']` or `$["first name"]`. Inside the quotes, `\\`, + `\'` and `\"` stand for a backslash and a quote. +- Wildcards, filters, slices, negative indices and the `lax` and `strict` modes are not supported, + so a path always names one place. +- Trailing spaces are ignored. +- A malformed path literal fails at planning time. Any other malformed path fails when the function + runs. + +A path points at nothing if a field is missing, an index is past the end, or a step does not match +the kind of value, such as a field of an array. Each function describes what it does in that case. + ### Value Construction Functions {{< sql_functions "valueconstruction" >}} diff --git a/docs/content/docs/sql/reference/data-types.md b/docs/content/docs/sql/reference/data-types.md index 1ac289a0402..3cedce4ff8c 100644 --- a/docs/content/docs/sql/reference/data-types.md +++ b/docs/content/docs/sql/reference/data-types.md @@ -1559,7 +1559,10 @@ To store one of these values, cast the number to a `VARIANT` instead of parsing A `VARIANT` can be converted to a scalar type with `CAST` or `TRY_CAST`. A cast succeeds only when the target holds the stored value without reinterpreting it, so a value is never wrapped or rounded -to make it fit. Otherwise `CAST` fails and `TRY_CAST` returns `NULL`. +to make it fit. Otherwise `CAST` fails and `TRY_CAST` returns `NULL`. `VARIANT_GET` and +`TRY_VARIANT_GET` read the value at a +[path]({{< ref "docs/sql/functions/built-in-functions" >}}#variant-paths) such as `'$.items[0].qty'` +and cast it by the same rules, returning `NULL` when nothing is at the path. | Stored kind | Succeeds for | |-----------------|-----------------------------------------------------| diff --git a/docs/data/sql_functions.yml b/docs/data/sql_functions.yml index 22463dcdf00..fadf6f734b4 100644 --- a/docs/data/sql_functions.yml +++ b/docs/data/sql_functions.yml @@ -1451,13 +1451,69 @@ variant: - sql: variant '[' INT ']' table: VARIANT.at(INT) description: | - If the VARIANT is an ARRAY value, returns a VARIANT whose value is the element at - the specified index. The index starts from 1, If the index is out of range, it returns NULL. + If the VARIANT is an array, returns a VARIANT holding the element at the index. The index + starts at 1. If the index is out of range, returns NULL. If the VARIANT is not an array, an + error is thrown. To return NULL instead, use `VARIANT_GET`. - sql: variant '[' STRING ']' table: VARIANT.at(STRING) description: | - If the VARIANT is a MAP value that has an element with this key, a VARIANT holding - the associated value is returned. Otherwise, NULL is returned. + If the VARIANT is an object that has a field with this key, returns a VARIANT holding the + value of the field. If the object has no such field, returns NULL. If the VARIANT is not an + object, an error is thrown. To return NULL instead, use `VARIANT_GET`. + - sql: VARIANT_GET(variant, path[, returnType]) + table: VARIANT.variantGet(path[, returnType]) + description: | + Returns the value of a Variant at a path, cast to `returnType`. Without `returnType`, the + value is returned as a Variant. If the value cannot be cast to `returnType`, an error is + thrown. To return NULL instead of an error, use the `TRY_VARIANT_GET` function. See the + "Variant paths" section below the function table for the path syntax. + + Returns NULL if: + + * `variant` or `path` is NULL, + * nothing is at the path, or + * the value at the path is a variant null and `returnType` is not `'VARIANT'`. + + Without `returnType`, or with `'VARIANT'`, a variant null stays a variant null. It is not SQL + NULL, so `IS NULL` is false and `JSON_STRING` returns `'null'`. + + `returnType` is a string literal such as `'INT'` or `'ARRAY<ROW<sku STRING, qty INT>>'`, and + must not be NULL. The value is cast the way `CAST(v AS returnType)` casts a Variant. The + result is always nullable. + + ```sql + -- 17 of type INT + VARIANT_GET(PARSE_JSON('{"order_id": 17}'), '$.order_id', 'INT') + + -- 2 of type INT + VARIANT_GET(PARSE_JSON('{"items": [{"qty": 2}]}'), '$.items[0].qty', 'INT') + + -- NULL of type STRING, nothing is at the path + VARIANT_GET(PARSE_JSON('{"order_id": 17}'), '$.user.name', 'STRING') + + -- NULL of type INT, a variant null cast to INT + VARIANT_GET(PARSE_JSON('{"coupon": null}'), '$.coupon', 'INT') + + -- null of type VARIANT, a variant null and not SQL NULL + VARIANT_GET(PARSE_JSON('{"coupon": null}'), '$.coupon') + + -- {"name":"ada"} of type VARIANT + VARIANT_GET(PARSE_JSON('{"user": {"name": "ada"}}'), '$.user') + + -- fails, a Variant string does not cast to INT + VARIANT_GET(PARSE_JSON('{"price": "12.50"}'), '$.price', 'INT') + ``` + - sql: TRY_VARIANT_GET(variant, path[, returnType]) + table: VARIANT.tryVariantGet(path[, returnType]) + description: | + Returns the value of a Variant at a path like `VARIANT_GET`, but returns NULL instead of an + error if the value cannot be cast to `returnType`, or if a path that is not a literal is + malformed. A malformed path literal still fails at planning time. + + ```sql + -- NULL of type INT, a Variant string does not cast to INT + TRY_VARIANT_GET(PARSE_JSON('{"price": "12.50"}'), '$.price', 'INT') + ``` valueconstruction: - sql: | diff --git a/docs/data/sql_functions_zh.yml b/docs/data/sql_functions_zh.yml index d1dc90163fb..c9b379fcbe2 100644 --- a/docs/data/sql_functions_zh.yml +++ b/docs/data/sql_functions_zh.yml @@ -1549,12 +1549,70 @@ variant: - sql: variant '[' INT ']' table: VARIANT.at(INT) description: | - 如果这是一个 ARRAY 类型的 VARIANT,则返回一个 VARIANT,其值为指定索引处的元素。索引从 1 开始,如果索引超出范围,则返回 NULL。 + If the VARIANT is an array, returns a VARIANT holding the element at the index. The index + starts at 1. If the index is out of range, returns NULL. If the VARIANT is not an array, an + error is thrown. To return NULL instead, use `VARIANT_GET`. - sql: variant '[' STRING ']' table: VARIANT.at(STRING) description: | - 如果这是一个 MAP 类型的 VARIANT,则返回一个 VARIANT,其值为指定 key 对应的值。否则返回 NULL。 + If the VARIANT is an object that has a field with this key, returns a VARIANT holding the + value of the field. If the object has no such field, returns NULL. If the VARIANT is not an + object, an error is thrown. To return NULL instead, use `VARIANT_GET`. + - sql: VARIANT_GET(variant, path[, returnType]) + table: VARIANT.variantGet(path[, returnType]) + description: | + Returns the value of a Variant at a path, cast to `returnType`. Without `returnType`, the + value is returned as a Variant. If the value cannot be cast to `returnType`, an error is + thrown. To return NULL instead of an error, use the `TRY_VARIANT_GET` function. See the + "Variant paths" section below the function table for the path syntax. + + Returns NULL if: + + * `variant` or `path` is NULL, + * nothing is at the path, or + * the value at the path is a variant null and `returnType` is not `'VARIANT'`. + + Without `returnType`, or with `'VARIANT'`, a variant null stays a variant null. It is not SQL + NULL, so `IS NULL` is false and `JSON_STRING` returns `'null'`. + + `returnType` is a string literal such as `'INT'` or `'ARRAY<ROW<sku STRING, qty INT>>'`, and + must not be NULL. The value is cast the way `CAST(v AS returnType)` casts a Variant. The + result is always nullable. + + ```sql + -- 17 of type INT + VARIANT_GET(PARSE_JSON('{"order_id": 17}'), '$.order_id', 'INT') + + -- 2 of type INT + VARIANT_GET(PARSE_JSON('{"items": [{"qty": 2}]}'), '$.items[0].qty', 'INT') + + -- NULL of type STRING, nothing is at the path + VARIANT_GET(PARSE_JSON('{"order_id": 17}'), '$.user.name', 'STRING') + + -- NULL of type INT, a variant null cast to INT + VARIANT_GET(PARSE_JSON('{"coupon": null}'), '$.coupon', 'INT') + + -- null of type VARIANT, a variant null and not SQL NULL + VARIANT_GET(PARSE_JSON('{"coupon": null}'), '$.coupon') + + -- {"name":"ada"} of type VARIANT + VARIANT_GET(PARSE_JSON('{"user": {"name": "ada"}}'), '$.user') + + -- fails, a Variant string does not cast to INT + VARIANT_GET(PARSE_JSON('{"price": "12.50"}'), '$.price', 'INT') + ``` + - sql: TRY_VARIANT_GET(variant, path[, returnType]) + table: VARIANT.tryVariantGet(path[, returnType]) + description: | + Returns the value of a Variant at a path like `VARIANT_GET`, but returns NULL instead of an + error if the value cannot be cast to `returnType`, or if a path that is not a literal is + malformed. A malformed path literal still fails at planning time. + + ```sql + -- NULL of type INT, a Variant string does not cast to INT + TRY_VARIANT_GET(PARSE_JSON('{"price": "12.50"}'), '$.price', 'INT') + ``` valueconstruction: diff --git a/flink-python/docs/reference/pyflink.table/expressions.rst b/flink-python/docs/reference/pyflink.table/expressions.rst index e2ee9777c58..6c09c999e1c 100644 --- a/flink-python/docs/reference/pyflink.table/expressions.rst +++ b/flink-python/docs/reference/pyflink.table/expressions.rst @@ -335,6 +335,17 @@ JSON functions Expression.json_length Expression.json_type +variant functions +----------------- + +.. currentmodule:: pyflink.table.expression + +.. autosummary:: + :toctree: api/ + + Expression.variant_get + Expression.try_variant_get + value modification functions ---------------------------- diff --git a/flink-python/pyflink/table/expression.py b/flink-python/pyflink/table/expression.py index 426a3059bd7..e497ecb5590 100644 --- a/flink-python/pyflink/table/expression.py +++ b/flink-python/pyflink/table/expression.py @@ -2456,6 +2456,40 @@ class Expression(Generic[T]): else: return _binary_op("tryParseXml")(self, force_array) + def variant_get(self, path, return_type: DataType = None) -> 'Expression': + """ + Returns the value of a VARIANT at a path, cast to return_type. Without return_type, the + value is returned as a VARIANT. If the VARIANT or the path is NULL, or nothing is at the + path, NULL is returned. A variant null at the path gives NULL, unless return_type is + omitted or DataTypes.VARIANT(), which keeps it as a variant null. If the value cannot be + cast to return_type, an error is thrown. To return NULL instead, use + :func:`~Expression.try_variant_get`. + + Array indices in a path such as '$.items[0].qty' start at 0. See the SQL function + VARIANT_GET for the path syntax. + + Example: + :: + + >>> lit('{"a": {"b": 1}}').parse_json().variant_get('$.a.b', DataTypes.INT()) # 1 + >>> lit('{"a": 1}').parse_json().variant_get('$.b', DataTypes.INT()) # NULL + """ + if return_type is None: + return _binary_op("variantGet")(self, path) + else: + return _ternary_op("variantGet")(self, path, _to_java_data_type(return_type)) + + def try_variant_get(self, path, return_type: DataType = None) -> 'Expression': + """ + Returns the value of a VARIANT at a path, cast to return_type. Unlike + :func:`~Expression.variant_get`, NULL is returned instead of an error if the value cannot + be cast to return_type or a path that is not a literal is malformed. + """ + if return_type is None: + return _binary_op("tryVariantGet")(self, path) + else: + return _ternary_op("tryVariantGet")(self, path, _to_java_data_type(return_type)) + def json_length(self, path=None) -> 'Expression': """ Returns the number of elements in a JSON document, or the length of the value at the diff --git a/flink-python/pyflink/table/tests/test_expression.py b/flink-python/pyflink/table/tests/test_expression.py index 08c4f9b9d5f..fba77f6b89e 100644 --- a/flink-python/pyflink/table/tests/test_expression.py +++ b/flink-python/pyflink/table/tests/test_expression.py @@ -280,6 +280,14 @@ class PyFlinkBatchExpressionTests(PyFlinkTestCase): self.assertEqual("PARSE_XML(a, b)", str(expr1.parse_xml(expr2))) self.assertEqual("TRY_PARSE_XML(a, b)", str(expr1.try_parse_xml(expr2))) + # variant functions + self.assertEqual("VARIANT_GET(a, '$.x')", str(expr1.variant_get('$.x'))) + self.assertEqual("VARIANT_GET(a, '$.x', 'INT')", + str(expr1.variant_get('$.x', DataTypes.INT()))) + self.assertEqual("TRY_VARIANT_GET(a, b)", str(expr1.try_variant_get(expr2))) + self.assertEqual("TRY_VARIANT_GET(a, '$.x', 'ARRAY<INT>')", + str(expr1.try_variant_get('$.x', DataTypes.ARRAY(DataTypes.INT())))) + # bitmap functions self.assertEqual("BITMAP_AND(a, b)", str(expr1.bitmap_and(expr2))) self.assertEqual("BITMAP_ANDNOT(a, b)", str(expr1.bitmap_andnot(expr2))) diff --git a/flink-table/flink-table-api-java/src/main/java/org/apache/flink/table/api/internal/BaseExpressions.java b/flink-table/flink-table-api-java/src/main/java/org/apache/flink/table/api/internal/BaseExpressions.java index 29f12604fc8..a5d041aca8e 100644 --- a/flink-table/flink-table-api-java/src/main/java/org/apache/flink/table/api/internal/BaseExpressions.java +++ b/flink-table/flink-table-api-java/src/main/java/org/apache/flink/table/api/internal/BaseExpressions.java @@ -239,10 +239,12 @@ import static org.apache.flink.table.functions.BuiltInFunctionDefinitions.TRUNCA import static org.apache.flink.table.functions.BuiltInFunctionDefinitions.TRY_CAST; import static org.apache.flink.table.functions.BuiltInFunctionDefinitions.TRY_PARSE_JSON; import static org.apache.flink.table.functions.BuiltInFunctionDefinitions.TRY_PARSE_XML; +import static org.apache.flink.table.functions.BuiltInFunctionDefinitions.TRY_VARIANT_GET; import static org.apache.flink.table.functions.BuiltInFunctionDefinitions.UNHEX; import static org.apache.flink.table.functions.BuiltInFunctionDefinitions.UPPER; import static org.apache.flink.table.functions.BuiltInFunctionDefinitions.URL_DECODE; import static org.apache.flink.table.functions.BuiltInFunctionDefinitions.URL_ENCODE; +import static org.apache.flink.table.functions.BuiltInFunctionDefinitions.VARIANT_GET; import static org.apache.flink.table.functions.BuiltInFunctionDefinitions.VAR_POP; import static org.apache.flink.table.functions.BuiltInFunctionDefinitions.VAR_SAMP; import static org.apache.flink.table.functions.BuiltInFunctionDefinitions.WINDOW_END; @@ -1481,6 +1483,70 @@ public abstract class BaseExpressions<InType, OutType> { unresolvedCall(TRY_PARSE_XML, toExpr(), objectToExpression(forceArray))); } + /** + * Returns the value of a {@link DataTypes#VARIANT()} at a path as a {@link + * DataTypes#VARIANT()}. If the variant or the path is {@code NULL}, or nothing is at the path, + * {@code NULL} is returned. A variant null at the path stays a variant null, which is not + * {@code NULL}. + * + * <p>This is a shortcut for {@code variantGet(path, DataTypes.VARIANT())}. See {@link + * #variantGet(Object, DataType)}. + */ + public OutType variantGet(InType path) { + return toApiSpecificExpression( + unresolvedCall(VARIANT_GET, toExpr(), objectToExpression(path))); + } + + /** + * Returns the value of a {@link DataTypes#VARIANT()} at a path such as {@code $.items[0].qty}, + * cast to the given type. Array indices start at 0. If the variant or the path is {@code NULL}, + * or nothing is at the path, {@code NULL} is returned. If the value cannot be cast to the type, + * an error is thrown. To return {@code NULL} instead, use {@link #tryVariantGet(Object, + * DataType)}. + * + * <p>The value is cast like {@code CAST}, so a variant null gives {@code NULL} unless the type + * is {@link DataTypes#VARIANT()}. See the SQL function {@code VARIANT_GET} for the path syntax. + * + * @param returnType the type of the result, always nullable. + */ + public OutType variantGet(InType path, DataType returnType) { + return toApiSpecificExpression( + unresolvedCall( + VARIANT_GET, + toExpr(), + objectToExpression(path), + valueLiteral(returnType.getLogicalType().asSerializableString()))); + } + + /** + * Returns the value of a {@link DataTypes#VARIANT()} at a path as a {@link + * DataTypes#VARIANT()}. If nothing is at the path, or a path that is not a literal is + * malformed, {@code NULL} is returned. + * + * <p>This is a shortcut for {@code tryVariantGet(path, DataTypes.VARIANT())}. See {@link + * #tryVariantGet(Object, DataType)}. + */ + public OutType tryVariantGet(InType path) { + return toApiSpecificExpression( + unresolvedCall(TRY_VARIANT_GET, toExpr(), objectToExpression(path))); + } + + /** + * Returns the value of a {@link DataTypes#VARIANT()} at a path, cast to the given type. Unlike + * {@link #variantGet(Object, DataType)}, {@code NULL} is returned instead of an error if the + * value cannot be cast to the type or a path that is not a literal is malformed. + * + * @param returnType the type of the result, always nullable. + */ + public OutType tryVariantGet(InType path, DataType returnType) { + return toApiSpecificExpression( + unresolvedCall( + TRY_VARIANT_GET, + toExpr(), + objectToExpression(path), + valueLiteral(returnType.getLogicalType().asSerializableString()))); + } + /** Returns the base string decoded with base64. */ public OutType fromBase64() { return toApiSpecificExpression(unresolvedCall(FROM_BASE64, toExpr())); diff --git a/flink-table/flink-table-api-java/src/main/java/org/apache/flink/table/api/internal/TableConfigValidation.java b/flink-table/flink-table-api-java/src/main/java/org/apache/flink/table/api/internal/TableConfigValidation.java index b6d1eae5f02..777a8df9d58 100644 --- a/flink-table/flink-table-api-java/src/main/java/org/apache/flink/table/api/internal/TableConfigValidation.java +++ b/flink-table/flink-table-api-java/src/main/java/org/apache/flink/table/api/internal/TableConfigValidation.java @@ -19,8 +19,10 @@ package org.apache.flink.table.api.internal; import org.apache.flink.annotation.Internal; +import org.apache.flink.configuration.ReadableConfig; import org.apache.flink.table.api.TableConfig; import org.apache.flink.table.api.ValidationException; +import org.apache.flink.table.api.config.TableConfigOptions; import java.time.ZoneId; @@ -49,6 +51,21 @@ public class TableConfigValidation { } } + /** + * Similar to {@link TableConfig#getLocalTimeZone()} but extracting it from a generic {@link + * ReadableConfig}. + * + * @see TableConfig#getLocalTimeZone() + */ + public static ZoneId getLocalTimeZone(ReadableConfig config) { + final String zone = config.get(TableConfigOptions.LOCAL_TIME_ZONE); + if (TableConfigOptions.LOCAL_TIME_ZONE.defaultValue().equals(zone)) { + return ZoneId.systemDefault(); + } + validateTimeZone(zone); + return ZoneId.of(zone); + } + private TableConfigValidation() { // No instantiation } diff --git a/flink-table/flink-table-common/src/main/java/org/apache/flink/table/functions/BuiltInFunctionDefinitions.java b/flink-table/flink-table-common/src/main/java/org/apache/flink/table/functions/BuiltInFunctionDefinitions.java index 6c09b633d81..31ab26c0401 100644 --- a/flink-table/flink-table-common/src/main/java/org/apache/flink/table/functions/BuiltInFunctionDefinitions.java +++ b/flink-table/flink-table-common/src/main/java/org/apache/flink/table/functions/BuiltInFunctionDefinitions.java @@ -3289,6 +3289,26 @@ public final class BuiltInFunctionDefinitions { .runtimeProvided() .build(); + public static final BuiltInFunctionDefinition VARIANT_GET = + BuiltInFunctionDefinition.newBuilder() + .name("VARIANT_GET") + .kind(SCALAR) + .inputTypeStrategy(SpecificInputTypeStrategies.VARIANT_GET) + .outputTypeStrategy(SpecificTypeStrategies.VARIANT_GET) + .runtimeClass( + "org.apache.flink.table.runtime.functions.scalar.VariantGetFunction") + .build(); + + public static final BuiltInFunctionDefinition TRY_VARIANT_GET = + BuiltInFunctionDefinition.newBuilder() + .name("TRY_VARIANT_GET") + .kind(SCALAR) + .inputTypeStrategy(SpecificInputTypeStrategies.VARIANT_GET) + .outputTypeStrategy(SpecificTypeStrategies.VARIANT_GET) + .runtimeClass( + "org.apache.flink.table.runtime.functions.scalar.TryVariantGetFunction") + .build(); + // -------------------------------------------------------------------------------------------- // Bitmap functions // -------------------------------------------------------------------------------------------- diff --git a/flink-table/flink-table-common/src/main/java/org/apache/flink/table/types/inference/strategies/SpecificInputTypeStrategies.java b/flink-table/flink-table-common/src/main/java/org/apache/flink/table/types/inference/strategies/SpecificInputTypeStrategies.java index e012f310953..ea5f2433a12 100644 --- a/flink-table/flink-table-common/src/main/java/org/apache/flink/table/types/inference/strategies/SpecificInputTypeStrategies.java +++ b/flink-table/flink-table-common/src/main/java/org/apache/flink/table/types/inference/strategies/SpecificInputTypeStrategies.java @@ -66,6 +66,9 @@ public final class SpecificInputTypeStrategies { /** See {@link ObjectOfInputTypeStrategy}. */ public static final InputTypeStrategy OBJECT_OF = new ObjectOfInputTypeStrategy(); + /** See {@link VariantGetInputTypeStrategy}. */ + public static final InputTypeStrategy VARIANT_GET = new VariantGetInputTypeStrategy(); + /** See {@link ObjectUpdateInputTypeStrategy}. */ public static final InputTypeStrategy OBJECT_UPDATE = new ObjectUpdateInputTypeStrategy(); diff --git a/flink-table/flink-table-common/src/main/java/org/apache/flink/table/types/inference/strategies/SpecificTypeStrategies.java b/flink-table/flink-table-common/src/main/java/org/apache/flink/table/types/inference/strategies/SpecificTypeStrategies.java index 00a82abb95d..cf04745381b 100644 --- a/flink-table/flink-table-common/src/main/java/org/apache/flink/table/types/inference/strategies/SpecificTypeStrategies.java +++ b/flink-table/flink-table-common/src/main/java/org/apache/flink/table/types/inference/strategies/SpecificTypeStrategies.java @@ -231,6 +231,9 @@ public final class SpecificTypeStrategies { /** Type strategy specific for {@link BuiltInFunctionDefinitions#OBJECT_UPDATE}. */ public static final TypeStrategy OBJECT_UPDATE = new ObjectUpdateTypeStrategy(); + /** See {@link VariantGetTypeStrategy}. */ + public static final TypeStrategy VARIANT_GET = new VariantGetTypeStrategy(); + /** Type strategy specific for {@link BuiltInFunctionDefinitions#ML_PREDICT}. */ public static final TypeStrategy ML_PREDICT_OUTPUT_TYPE_STRATEGY = MLPredictTypeStrategy.ML_PREDICT_OUTPUT_TYPE_STRATEGY; diff --git a/flink-table/flink-table-common/src/main/java/org/apache/flink/table/types/inference/strategies/VariantGetInputTypeStrategy.java b/flink-table/flink-table-common/src/main/java/org/apache/flink/table/types/inference/strategies/VariantGetInputTypeStrategy.java new file mode 100644 index 00000000000..6ffd02c00d4 --- /dev/null +++ b/flink-table/flink-table-common/src/main/java/org/apache/flink/table/types/inference/strategies/VariantGetInputTypeStrategy.java @@ -0,0 +1,109 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.flink.table.types.inference.strategies; + +import org.apache.flink.annotation.Internal; +import org.apache.flink.table.api.ValidationException; +import org.apache.flink.table.functions.FunctionDefinition; +import org.apache.flink.table.types.DataType; +import org.apache.flink.table.types.inference.ArgumentCount; +import org.apache.flink.table.types.inference.CallContext; +import org.apache.flink.table.types.inference.InputTypeStrategy; +import org.apache.flink.table.types.inference.Signature; +import org.apache.flink.table.types.logical.LogicalTypeFamily; +import org.apache.flink.table.types.logical.LogicalTypeRoot; +import org.apache.flink.table.utils.SimplePath; + +import java.util.List; +import java.util.Optional; + +import static org.apache.flink.table.types.inference.InputTypeStrategies.LITERAL; +import static org.apache.flink.table.types.inference.InputTypeStrategies.and; +import static org.apache.flink.table.types.inference.InputTypeStrategies.logical; +import static org.apache.flink.table.types.inference.InputTypeStrategies.or; +import static org.apache.flink.table.types.inference.InputTypeStrategies.sequence; +import static org.apache.flink.table.types.inference.strategies.VariantGetTypeStrategy.ARG_RETURN_TYPE; + +/** + * Input strategy for {@code VARIANT_GET(variant, path [, returnType])} and {@code TRY_VARIANT_GET}. + * + * <p>A literal path must be a {@link SimplePath}, so a malformed one fails at planning time. A path + * that is not a literal is parsed when the function runs. The {@code returnType} must be a literal + * that names a type {@code VARIANT} can be cast to, see {@link VariantGetTypeStrategy}. + */ +@Internal +public final class VariantGetInputTypeStrategy implements InputTypeStrategy { + + private static final int ARG_PATH = 1; + + private static final InputTypeStrategy SIGNATURES = + or( + sequence( + List.of("variant", "path"), + List.of( + logical(LogicalTypeRoot.VARIANT), + logical(LogicalTypeFamily.CHARACTER_STRING))), + sequence( + List.of("variant", "path", "returnType"), + List.of( + logical(LogicalTypeRoot.VARIANT), + logical(LogicalTypeFamily.CHARACTER_STRING), + and(logical(LogicalTypeFamily.CHARACTER_STRING), LITERAL)))); + + @Override + public ArgumentCount getArgumentCount() { + return SIGNATURES.getArgumentCount(); + } + + @Override + public Optional<List<DataType>> inferInputTypes( + final CallContext callContext, final boolean throwOnFailure) { + final Optional<List<DataType>> inferredDataTypes = + SIGNATURES.inferInputTypes(callContext, throwOnFailure); + if (inferredDataTypes.isEmpty()) { + return inferredDataTypes; + } + + if (callContext.isArgumentLiteral(ARG_PATH)) { + final Optional<String> path = callContext.getArgumentValue(ARG_PATH, String.class); + if (path.isPresent()) { + try { + SimplePath.parse(path.get()); + } catch (ValidationException e) { + return callContext.fail(throwOnFailure, e.getMessage()); + } + } + } + + if (callContext.getArgumentDataTypes().size() > ARG_RETURN_TYPE) { + try { + VariantGetTypeStrategy.resolveTargetType(callContext); + } catch (ValidationException e) { + return callContext.fail(throwOnFailure, e.getMessage()); + } + } + + return inferredDataTypes; + } + + @Override + public List<Signature> getExpectedSignatures(final FunctionDefinition definition) { + return SIGNATURES.getExpectedSignatures(definition); + } +} diff --git a/flink-table/flink-table-common/src/main/java/org/apache/flink/table/types/inference/strategies/VariantGetTypeStrategy.java b/flink-table/flink-table-common/src/main/java/org/apache/flink/table/types/inference/strategies/VariantGetTypeStrategy.java new file mode 100644 index 00000000000..7ddf00a2594 --- /dev/null +++ b/flink-table/flink-table-common/src/main/java/org/apache/flink/table/types/inference/strategies/VariantGetTypeStrategy.java @@ -0,0 +1,89 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.flink.table.types.inference.strategies; + +import org.apache.flink.annotation.Internal; +import org.apache.flink.table.api.DataTypes; +import org.apache.flink.table.api.ValidationException; +import org.apache.flink.table.types.DataType; +import org.apache.flink.table.types.inference.CallContext; +import org.apache.flink.table.types.inference.TypeStrategy; +import org.apache.flink.table.types.logical.LogicalType; +import org.apache.flink.table.types.logical.VariantType; +import org.apache.flink.table.types.logical.utils.LogicalTypeCasts; +import org.apache.flink.table.types.logical.utils.LogicalTypeParser; + +import java.util.Optional; + +import static org.apache.flink.table.types.utils.TypeConversions.fromLogicalToDataType; + +/** + * Type strategy for {@code VARIANT_GET(variant, path [, returnType])} and {@code TRY_VARIANT_GET}. + * + * <p>The result is the type named by the {@code returnType} literal, or {@code VARIANT} without + * one. It is always nullable, because a path that points at nothing gives {@code NULL}. + */ +@Internal +public final class VariantGetTypeStrategy implements TypeStrategy { + + static final int ARG_RETURN_TYPE = 2; + + @Override + public Optional<DataType> inferType(final CallContext callContext) { + if (callContext.getArgumentDataTypes().size() <= ARG_RETURN_TYPE) { + return Optional.of(DataTypes.VARIANT().nullable()); + } + return Optional.of(resolveTargetType(callContext)); + } + + /** + * Resolves the {@code returnType} literal to a nullable type that {@code VARIANT} can be cast + * to. + * + * @throws ValidationException if the literal is not a type or names a type that {@code VARIANT} + * cannot be cast to + */ + static DataType resolveTargetType(final CallContext callContext) { + // The input strategy only accepts a non-null literal. + final String typeString = + callContext + .getArgumentValue(ARG_RETURN_TYPE, String.class) + .orElseThrow(IllegalStateException::new); + final LogicalType logicalType; + try { + logicalType = + LogicalTypeParser.parse( + typeString, callContext.getDataTypeFactory().getClassLoader()) + .copy(true); + } catch (RuntimeException e) { + throw new ValidationException( + String.format( + "Invalid type '%s' for %s: %s", + typeString, callContext.getName(), e.getMessage()), + e); + } + if (!LogicalTypeCasts.supportsExplicitCast(new VariantType(), logicalType)) { + throw new ValidationException( + String.format( + "%s cannot return type %s, because a VARIANT cannot be cast to it.", + callContext.getName(), logicalType.asSummaryString())); + } + return fromLogicalToDataType(logicalType); + } +} diff --git a/flink-table/flink-table-common/src/main/java/org/apache/flink/table/utils/SimplePath.java b/flink-table/flink-table-common/src/main/java/org/apache/flink/table/utils/SimplePath.java new file mode 100644 index 00000000000..5b6204cad5e --- /dev/null +++ b/flink-table/flink-table-common/src/main/java/org/apache/flink/table/utils/SimplePath.java @@ -0,0 +1,303 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.flink.table.utils; + +import org.apache.flink.annotation.Internal; +import org.apache.flink.table.api.ValidationException; +import org.apache.flink.util.Preconditions; + +import javax.annotation.Nullable; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.regex.Pattern; + +/** + * A path that points at exactly one place in a value, such as {@code $.user.email} or {@code + * $['items'][0]}. + * + * <p>The syntax is the part of the JSON functions' path syntax that names a single place: no + * wildcards, filters, slices, negative indices, functions or {@code lax} and {@code strict} modes. + * Every path that this class accepts means the same in the JSON functions. A path starts with + * {@code $}, the whole value, followed by any number of steps: + * + * <ul> + * <li>{@code .name} is a field. The name cannot contain whitespace or one of the characters + * {@code . [ ] ( ) * ' " \}. + * <li>{@code ['name']} and {@code ["name"]} are a field with any name. Inside the quotes, a + * backslash followed by a backslash or a quote stands for that character. + * <li>{@code [n]} is the array element at the 0-based index {@code n}. + * </ul> + * + * <p>Trailing spaces are ignored, as in the JSON functions, so a space-padded {@code CHAR(n)} path + * works. + */ +@Internal +public final class SimplePath implements Serializable { + + private static final long serialVersionUID = 1L; + + private static final String SYNTAX = + "A path is '$' followed by the steps .name, ['name'], [\"name\"] or [index], " + + "without wildcards, filters, slices, negative indices or functions. " + + "Use brackets for a name that has whitespace or one of the characters " + + ". [ ] ( ) * ' \" \\ in it."; + + private static final String NAME_STOP_CHARACTERS = ".[]()*'\"\\"; + + private static final Pattern PATH_MODE_PREFIX = + Pattern.compile("\\s*(strict|lax)\\s.*", Pattern.CASE_INSENSITIVE | Pattern.DOTALL); + + private final String path; + + private final List<Step> steps; + + private SimplePath(final String path, final List<Step> steps) { + this.path = path; + this.steps = steps; + } + + /** + * Parses a path. + * + * @throws ValidationException if the path is malformed or uses syntax that can point at more + * than one place + */ + public static SimplePath parse(final String path) { + Preconditions.checkNotNull(path, "Path must not be null."); + return new Parser(path).parse(); + } + + /** Returns the steps after {@code $}, so the root path has none. */ + public List<Step> getSteps() { + return steps; + } + + @Override + public String toString() { + return path; + } + + // -------------------------------------------------------------------------------------------- + + /** A step to a field of an object or to an element of an array. */ + @Internal + public static final class Step implements Serializable { + + private static final long serialVersionUID = 1L; + + private final @Nullable String fieldName; + + private final int index; + + private Step(final @Nullable String fieldName, final int index) { + this.fieldName = fieldName; + this.index = index; + } + + static Step field(final String fieldName) { + return new Step(Preconditions.checkNotNull(fieldName), -1); + } + + static Step index(final int index) { + Preconditions.checkArgument(index >= 0, "Index must not be negative."); + return new Step(null, index); + } + + public boolean isField() { + return fieldName != null; + } + + public String getFieldName() { + Preconditions.checkState(fieldName != null, "Step is not a field."); + return fieldName; + } + + public int getIndex() { + Preconditions.checkState(fieldName == null, "Step is not an index."); + return index; + } + + @Override + public boolean equals(final Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + final Step that = (Step) o; + return index == that.index && Objects.equals(fieldName, that.fieldName); + } + + @Override + public int hashCode() { + return Objects.hash(fieldName, index); + } + + @Override + public String toString() { + if (fieldName == null) { + return "[" + index + "]"; + } + return "['" + fieldName.replace("\\", "\\\\").replace("'", "\\'") + "']"; + } + } + + // -------------------------------------------------------------------------------------------- + + private static final class Parser { + + private final String path; + + // The end of the path without trailing spaces. + private final int end; + + private int pos; + + private Parser(final String path) { + this.path = path; + int end = path.length(); + while (end > 0 && path.charAt(end - 1) == ' ') { + end--; + } + this.end = end; + } + + private SimplePath parse() { + if (!path.startsWith("$")) { + if (PATH_MODE_PREFIX.matcher(path).matches()) { + throw error("the 'lax' and 'strict' modes are not supported"); + } + throw error("it must start with '$'"); + } + pos = 1; + final List<Step> steps = new ArrayList<>(); + while (pos < end) { + final char c = path.charAt(pos); + if (c == '.') { + pos++; + steps.add(Step.field(readName())); + } else if (c == '[') { + pos++; + steps.add(readBracketStep()); + } else { + throw error(String.format("unexpected '%s' after '%s'", c, consumed())); + } + } + return new SimplePath(path, List.copyOf(steps)); + } + + private String readName() { + final int start = pos; + while (pos < end && isNameCharacter(path.charAt(pos))) { + pos++; + } + if (pos == start) { + throw error(String.format("expected a field name after '%s'", consumed())); + } + return path.substring(start, pos); + } + + private Step readBracketStep() { + final String prefix = path.substring(0, pos - 1); + final char c = pos < end ? path.charAt(pos) : 0; + final Step step; + if (c == '\'' || c == '"') { + pos++; + step = Step.field(readQuotedName(c, prefix)); + } else if (isDigit(c)) { + step = Step.index(readIndex()); + } else { + throw error( + String.format("expected an index or a quoted name after '%s'", consumed())); + } + if (pos >= end || path.charAt(pos) != ']') { + throw error(String.format("expected ']' after '%s'", consumed())); + } + pos++; + return step; + } + + private String readQuotedName(final char quote, final String prefix) { + final StringBuilder name = new StringBuilder(); + while (pos < end) { + final char c = path.charAt(pos); + if (c == quote) { + pos++; + return name.toString(); + } + if (c == '\\') { + final char escaped = pos + 1 < end ? path.charAt(pos + 1) : 0; + if (escaped != '\\' && escaped != '\'' && escaped != '"') { + throw error( + String.format( + "unsupported escape sequence in the name after '%s'. " + + "Only a backslash or a quote can follow a " + + "backslash", + prefix)); + } + name.append(escaped); + pos += 2; + } else { + name.append(c); + pos++; + } + } + throw error(String.format("the name after '%s' has no closing quote", prefix)); + } + + private int readIndex() { + final int start = pos; + while (pos < end && isDigit(path.charAt(pos))) { + pos++; + } + try { + return Integer.parseInt(path.substring(start, pos)); + } catch (NumberFormatException e) { + throw error( + String.format( + "the index after '%s' is larger than %d", + path.substring(0, start), Integer.MAX_VALUE)); + } + } + + private String consumed() { + return path.substring(0, pos); + } + + private ValidationException error(final String reason) { + return new ValidationException( + String.format("Invalid path '%s': %s. %s", path, reason, SYNTAX)); + } + + private static boolean isDigit(final char c) { + return c >= '0' && c <= '9'; + } + + private static boolean isNameCharacter(final char c) { + return NAME_STOP_CHARACTERS.indexOf(c) < 0 + && !Character.isWhitespace(c) + && !Character.isSpaceChar(c) + && !Character.isISOControl(c); + } + } +} diff --git a/flink-table/flink-table-common/src/test/java/org/apache/flink/table/utils/SimplePathTest.java b/flink-table/flink-table-common/src/test/java/org/apache/flink/table/utils/SimplePathTest.java new file mode 100644 index 00000000000..5a4f7c0beda --- /dev/null +++ b/flink-table/flink-table-common/src/test/java/org/apache/flink/table/utils/SimplePathTest.java @@ -0,0 +1,114 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.flink.table.utils; + +import org.apache.flink.table.api.ValidationException; +import org.apache.flink.table.utils.SimplePath.Step; +import org.apache.flink.util.InstantiationUtil; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; + +import java.util.List; +import java.util.stream.Stream; + +import static org.apache.flink.table.utils.SimplePath.Step.field; +import static org.apache.flink.table.utils.SimplePath.Step.index; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.params.provider.Arguments.arguments; + +/** Tests for {@link SimplePath}. */ +class SimplePathTest { + + private static Stream<Arguments> validPaths() { + return Stream.of( + arguments("$", List.of()), + arguments("$.items[0].qty", List.of(field("items"), index(0), field("qty"))), + arguments("$.a.b", List.of(field("a"), field("b"))), + arguments("$[007]", List.of(index(7))), + arguments("$[2147483647]", List.of(index(Integer.MAX_VALUE))), + arguments("$[\"a.b\"]", List.of(field("a.b"))), + arguments("$['it\\'s']", List.of(field("it's"))), + // The padding of a CHAR(n) value. + arguments("$.a ", List.of(field("a")))); + } + + @ParameterizedTest(name = "[{index}] {0}") + @MethodSource("validPaths") + void testParse(final String path, final List<Step> steps) { + assertThat(SimplePath.parse(path).getSteps()).isEqualTo(steps); + } + + @ParameterizedTest(name = "[{index}] {0}") + @CsvSource( + delimiter = '|', + quoteCharacter = '`', + value = { + "` $.a` | it must start with '$'", + "lax $.a | the 'lax' and 'strict' modes are not supported", + "$.* | expected a field name after '$.'", + "$[-1] | expected an index or a quoted name after '$['", + "$[0,1] | expected ']' after '$[0'", + "$['a\\'] | the name after '$' has no closing quote", + "$.x['a\\nb'] | unsupported escape sequence in the name after '$.x'", + "$[2147483648] | the index after '$[' is larger than 2147483647" + }) + void testParseFails(final String path, final String reason) { + assertThatThrownBy(() -> SimplePath.parse(path)) + .isInstanceOf(ValidationException.class) + .hasMessageStartingWith(String.format("Invalid path '%s': %s.", path, reason)); + } + + @ParameterizedTest(name = "[{index}] U+{0}") + @ValueSource(chars = {']', '(', ')', '*', '\'', '"', '\\', ' ', '\t', '\u00A0', '\u0001'}) + void testFieldNameStopsAt(final char c) { + final String path = "$.a" + c + "b"; + assertThatThrownBy(() -> SimplePath.parse(path)) + .isInstanceOf(ValidationException.class) + .hasMessageStartingWith( + String.format("Invalid path '%s': unexpected '%s' after '$.a'.", path, c)); + } + + @Test + void testErrorMessageExplainsTheSyntax() { + assertThatThrownBy(() -> SimplePath.parse("$.a b")) + .hasMessage( + "Invalid path '$.a b': unexpected ' ' after '$.a'. A path is '$' followed " + + "by the steps .name, ['name'], [\"name\"] or [index], without " + + "wildcards, filters, slices, negative indices or functions. " + + "Use brackets for a name that has whitespace or one of the " + + "characters . [ ] ( ) * ' \" \\ in it."); + } + + @Test + void testSerializable() throws Exception { + final SimplePath path = SimplePath.parse("$.items[0]['sku']"); + + final SimplePath copy = + InstantiationUtil.clone(path, SimplePathTest.class.getClassLoader()); + + assertThat(copy.getSteps()).isEqualTo(path.getSteps()); + assertThat(copy).hasToString("$.items[0]['sku']"); + } +} diff --git a/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/utils/TableConfigUtils.java b/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/utils/TableConfigUtils.java index 5163074334e..ea1233c9b47 100644 --- a/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/utils/TableConfigUtils.java +++ b/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/utils/TableConfigUtils.java @@ -22,7 +22,7 @@ import org.apache.flink.configuration.ReadableConfig; import org.apache.flink.table.api.TableConfig; import org.apache.flink.table.api.config.AggregatePhaseStrategy; import org.apache.flink.table.api.config.ExecutionConfigOptions; -import org.apache.flink.table.api.config.TableConfigOptions; +import org.apache.flink.table.api.internal.TableConfigValidation; import org.apache.flink.table.planner.calcite.CalciteConfig; import org.apache.flink.table.planner.calcite.CalciteConfig$; import org.apache.flink.table.planner.plan.utils.OperatorType; @@ -33,7 +33,6 @@ import java.util.Set; import static org.apache.flink.table.api.config.ExecutionConfigOptions.TABLE_EXEC_DISABLED_OPERATORS; import static org.apache.flink.table.api.config.OptimizerConfigOptions.TABLE_OPTIMIZER_AGG_PHASE_STRATEGY; -import static org.apache.flink.table.api.internal.TableConfigValidation.validateTimeZone; /** Utility class for {@link TableConfig} related helper functions. */ public class TableConfigUtils { @@ -97,12 +96,7 @@ public class TableConfigUtils { * @see TableConfig#getLocalTimeZone() */ public static ZoneId getLocalTimeZone(ReadableConfig tableConfig) { - final String zone = tableConfig.get(TableConfigOptions.LOCAL_TIME_ZONE); - if (TableConfigOptions.LOCAL_TIME_ZONE.defaultValue().equals(zone)) { - return ZoneId.systemDefault(); - } - validateTimeZone(zone); - return ZoneId.of(zone); + return TableConfigValidation.getLocalTimeZone(tableConfig); } @Deprecated diff --git a/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/functions/VariantFunctionsITCase.java b/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/functions/VariantFunctionsITCase.java new file mode 100644 index 00000000000..6780b01b5cc --- /dev/null +++ b/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/functions/VariantFunctionsITCase.java @@ -0,0 +1,167 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.flink.table.planner.functions; + +import org.apache.flink.configuration.Configuration; +import org.apache.flink.table.api.TableRuntimeException; +import org.apache.flink.table.api.config.TableConfigOptions; +import org.apache.flink.table.functions.BuiltInFunctionDefinitions; + +import java.time.Instant; +import java.util.stream.Stream; + +import static org.apache.flink.table.api.DataTypes.DECIMAL; +import static org.apache.flink.table.api.DataTypes.INT; +import static org.apache.flink.table.api.DataTypes.STRING; +import static org.apache.flink.table.api.DataTypes.TIMESTAMP_LTZ; +import static org.apache.flink.table.api.DataTypes.VARIANT; +import static org.apache.flink.table.api.Expressions.$; +import static org.apache.flink.table.api.Expressions.jsonString; +import static org.apache.flink.table.api.Expressions.lit; +import static org.apache.flink.table.api.Expressions.nullOf; + +/** + * Tests for the VARIANT functions that are not covered by the JSON and XML function tests. + * + * <p>Path syntax is covered by {@code SimplePathTest}. A path that changes from row to row is + * covered by {@code VariantSemanticTest}. + */ +class VariantFunctionsITCase extends BuiltInFunctionTestBase { + + private static final String ORDER = + "{\"order_id\": 17, \"user\": {\"name\": \"ada\", \"email\": \"[email protected]\"}, " + + "\"items\": [{\"sku\": \"A1\", \"qty\": 2}], \"coupon\": null, " + + "\"price\": \"12.50\"}"; + + @Override + Configuration getConfiguration() { + return super.getConfiguration().set(TableConfigOptions.LOCAL_TIME_ZONE, "Asia/Shanghai"); + } + + @Override + Stream<TestSetSpec> getTestSetSpecs() { + return Stream.of(variantGet(), variantGetFailure(), tryVariantGet()); + } + + private static TestSetSpec variantGet() { + return TestSetSpec.forFunction(BuiltInFunctionDefinitions.VARIANT_GET) + .onFieldsWithData(ORDER, "$.user.name", null, Instant.EPOCH) + .andDataTypes(STRING().notNull(), STRING().notNull(), STRING(), TIMESTAMP_LTZ(3)) + .testResult( + $("f0").parseJson().variantGet("$.order_id", INT()), + "VARIANT_GET(PARSE_JSON(f0), '$.order_id', 'int')", + 17, + INT()) + .testResult( + jsonString($("f0").parseJson().variantGet("$.user.name", VARIANT())), + "JSON_STRING(VARIANT_GET(PARSE_JSON(f0), '$.user.name', 'VARIANT'))", + "\"ada\"", + STRING()) + // A path that points at nothing gives NULL, so even a NOT NULL type is nullable. + .testResult( + $("f0").parseJson().variantGet("$.missing", INT().notNull()), + "VARIANT_GET(PARSE_JSON(f0), '$.missing', 'INT NOT NULL')", + null, + INT()) + // A variant null stays a VARIANT, and casts to NULL. + .testResult( + jsonString($("f0").parseJson().variantGet("$.coupon")), + "JSON_STRING(VARIANT_GET(PARSE_JSON(f0), '$.coupon'))", + "null", + STRING()) + .testResult( + $("f0").parseJson().variantGet("$.coupon", STRING()), + "VARIANT_GET(PARSE_JSON(f0), '$.coupon', 'STRING')", + null, + STRING()) + .testResult( + $("f0").parseJson().variantGet($("f1"), STRING()), + "VARIANT_GET(PARSE_JSON(f0), f1, 'STRING')", + "ada", + STRING()) + .testResult( + $("f2").parseJson().variantGet("$.order_id", INT()), + "VARIANT_GET(PARSE_JSON(f2), '$.order_id', 'INT')", + null, + INT()) + .testResult( + $("f0").parseJson().variantGet(nullOf(STRING())), + "VARIANT_GET(PARSE_JSON(f0), NULL)", + null, + VARIANT()) + // A TIMESTAMP_LTZ renders in the session time zone, as in CAST. + .testResult( + $("f3").cast(VARIANT()).variantGet("$", STRING()), + "VARIANT_GET(CAST(f3 AS VARIANT), '$', 'STRING')", + "1970-01-01 08:00:00.000000", + STRING()); + } + + private static TestSetSpec variantGetFailure() { + return TestSetSpec.forFunction(BuiltInFunctionDefinitions.VARIANT_GET, "failures") + .onFieldsWithData(ORDER) + .andDataTypes(STRING().notNull()) + .testSqlRuntimeError( + "VARIANT_GET(PARSE_JSON(f0), '$.price', 'DECIMAL(4, 2)')", + TableRuntimeException.class, + "Failed to cast the VARIANT value at path '$.price' to DECIMAL(4, 2): " + + "Cannot cast a VARIANT STRING value to DECIMAL(4, 2).") + // A path that becomes a literal only during optimization fails at run time. + .testTableApiRuntimeError( + $("f0").parseJson().variantGet(lit("$.user[").cast(STRING())), + TableRuntimeException.class, + "Invalid path '$.user['") + .testSqlValidationError( + "VARIANT_GET(PARSE_JSON(f0), '$.items[-1]')", + "Invalid path '$.items[-1]': expected an index or a quoted name after " + + "'$.items['.") + .testSqlValidationError( + "VARIANT_GET(PARSE_JSON(f0), '$.a', 'MULTISET<INT>')", + "VARIANT_GET cannot return type MULTISET<INT>, because a VARIANT cannot " + + "be cast to it.") + .testSqlValidationError( + "VARIANT_GET(PARSE_JSON(f0), '$.a', 'NOT A TYPE')", + "Invalid type 'NOT A TYPE' for VARIANT_GET:") + .testSqlValidationError( + "VARIANT_GET(PARSE_JSON(f0), '$.a', f0)", + "Invalid input arguments. Expected signatures are:"); + } + + private static TestSetSpec tryVariantGet() { + return TestSetSpec.forFunction(BuiltInFunctionDefinitions.TRY_VARIANT_GET) + .onFieldsWithData(ORDER) + .andDataTypes(STRING().notNull()) + .testResult( + $("f0").parseJson().tryVariantGet("$.price", DECIMAL(4, 2)), + "TRY_VARIANT_GET(PARSE_JSON(f0), '$.price', 'DECIMAL(4, 2)')", + null, + DECIMAL(4, 2)) + // A path that becomes a literal only during optimization gives NULL. + .testResult( + $("f0").parseJson().tryVariantGet(lit("$.user[").cast(STRING()), STRING()), + "TRY_VARIANT_GET(PARSE_JSON(f0), CAST('$.user[' AS STRING), 'STRING')", + null, + STRING()) + // A malformed literal path still fails at planning time. + .testTableApiValidationError( + $("f0").parseJson().tryVariantGet("$.user["), + "Invalid path '$.user[': expected an index or a quoted name after " + + "'$.user['."); + } +} diff --git a/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/nodes/exec/stream/VariantSemanticTest.java b/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/nodes/exec/stream/VariantSemanticTest.java index 580201100f4..1fba4b01203 100644 --- a/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/nodes/exec/stream/VariantSemanticTest.java +++ b/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/nodes/exec/stream/VariantSemanticTest.java @@ -112,6 +112,56 @@ public class VariantSemanticTest extends SemanticTestBase { .runSql("INSERT INTO sink_t SELECT TRY_PARSE_JSON(v) FROM t") .build(); + static final String JSON_OBJECT_A_AND_B = "{\"a\": 1, \"b\": 2}"; + + static final TableTestProgram TRY_VARIANT_GET_PATH_PER_ROW = + TableTestProgram.of( + "try-variant-get-path-per-row", + "validates TRY_VARIANT_GET reads a path that changes from row to row") + .setupTableSource( + SourceTestStep.newBuilder("t") + .addSchema("v STRING", "p STRING") + .producedValues( + Row.of(JSON_OBJECT_A_AND_B, "$.a"), + Row.of(JSON_OBJECT_A_AND_B, "$.c"), + Row.of(JSON_OBJECT_A_AND_B, "$.b"), + Row.of(JSON_OBJECT_A_AND_B, "$.a["), + // The path before the malformed one again, which a + // cache that keeps its key on an error gets wrong. + Row.of(JSON_OBJECT_A_AND_B, "$.b")) + .build()) + .setupTableSink( + SinkTestStep.newBuilder("sink_t") + .addSchema("i INT") + .consumedValues( + Row.of(1), new Row(1), Row.of(2), new Row(1), Row.of(2)) + .build()) + .runSql( + "INSERT INTO sink_t SELECT TRY_VARIANT_GET(PARSE_JSON(v), p, 'INT') FROM t") + .build(); + + static final TableTestProgram VARIANT_GET_FAIL_MALFORMED_PATH = + TableTestProgram.of( + "variant-get-fail-malformed-path", + "validates VARIANT_GET throws on a malformed path that is not a literal") + .setupTableSource( + SourceTestStep.newBuilder("t") + .addSchema("v STRING", "p STRING") + .producedValues( + Row.of(JSON_OBJECT_A_AND_B, "$.a"), + Row.of(JSON_OBJECT_A_AND_B, "$.a[")) + .build()) + .setupTableSink( + SinkTestStep.newBuilder("sink_t") + .addSchema("i INT") + .consumedValues(Row.of(1)) + .build()) + .runFailingSql( + "INSERT INTO sink_t SELECT VARIANT_GET(PARSE_JSON(v), p, 'INT') FROM t", + TableRuntimeException.class, + "Invalid path '$.a[': expected an index or a quoted name after '$.a['.") + .build(); + static final TableTestProgram VARIANT_OMITTED_FROM_INSERT_COLUMN_LIST = TableTestProgram.of( "variant-omitted-from-insert-column-list", @@ -683,6 +733,8 @@ public class VariantSemanticTest extends SemanticTestBase { PARSE_JSON_OF_NULL, PARSE_JSON_FAIL_MALFORMED_JSON, TRY_PARSE_JSON_HANDLE_MALFORMED_JSON, + TRY_VARIANT_GET_PATH_PER_ROW, + VARIANT_GET_FAIL_MALFORMED_PATH, BUILTIN_AGG, BUILTIN_AGG_WITH_RETRACTION, VARIANT_AS_UDF_ARG, diff --git a/flink-table/flink-table-runtime/src/main/java/org/apache/flink/table/runtime/functions/scalar/TryVariantGetFunction.java b/flink-table/flink-table-runtime/src/main/java/org/apache/flink/table/runtime/functions/scalar/TryVariantGetFunction.java new file mode 100644 index 00000000000..b6f0d36a71e --- /dev/null +++ b/flink-table/flink-table-runtime/src/main/java/org/apache/flink/table/runtime/functions/scalar/TryVariantGetFunction.java @@ -0,0 +1,36 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.flink.table.runtime.functions.scalar; + +import org.apache.flink.annotation.Internal; +import org.apache.flink.table.functions.BuiltInFunctionDefinitions; +import org.apache.flink.table.functions.SpecializedFunction.SpecializedContext; + +/** + * Implementation of {@link BuiltInFunctionDefinitions#TRY_VARIANT_GET}, which returns {@code NULL} + * where {@link VariantGetFunction} fails: for a malformed path that is not a literal, or a value + * that cannot be cast to the result type. + */ +@Internal +public class TryVariantGetFunction extends VariantGetFunction { + + public TryVariantGetFunction(SpecializedContext context) { + super(BuiltInFunctionDefinitions.TRY_VARIANT_GET, context, true); + } +} diff --git a/flink-table/flink-table-runtime/src/main/java/org/apache/flink/table/runtime/functions/scalar/VariantGetFunction.java b/flink-table/flink-table-runtime/src/main/java/org/apache/flink/table/runtime/functions/scalar/VariantGetFunction.java new file mode 100644 index 00000000000..d6d1facbb45 --- /dev/null +++ b/flink-table/flink-table-runtime/src/main/java/org/apache/flink/table/runtime/functions/scalar/VariantGetFunction.java @@ -0,0 +1,182 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.flink.table.runtime.functions.scalar; + +import org.apache.flink.annotation.Internal; +import org.apache.flink.annotation.VisibleForTesting; +import org.apache.flink.table.api.TableRuntimeException; +import org.apache.flink.table.api.ValidationException; +import org.apache.flink.table.api.internal.TableConfigValidation; +import org.apache.flink.table.data.StringData; +import org.apache.flink.table.data.utils.FromVariantConverter; +import org.apache.flink.table.functions.BuiltInFunctionDefinition; +import org.apache.flink.table.functions.BuiltInFunctionDefinitions; +import org.apache.flink.table.functions.SpecializedFunction.SpecializedContext; +import org.apache.flink.table.types.inference.CallContext; +import org.apache.flink.table.types.logical.LogicalType; +import org.apache.flink.table.types.logical.LogicalTypeRoot; +import org.apache.flink.table.utils.SimplePath; +import org.apache.flink.types.variant.Variant; + +import javax.annotation.Nullable; + +import java.util.List; +import java.util.Optional; + +/** + * Implementation of {@link BuiltInFunctionDefinitions#VARIANT_GET}. + * + * <p>A path that points at nothing gives {@code NULL}: a missing field, an index past the end, a + * field of a non-object or an element of a non-array. The value found is cast to the result type + * the way {@code CAST} does, so a variant null becomes {@code NULL} unless the result is a {@code + * VARIANT}. + */ +@Internal +public class VariantGetFunction extends BuiltInScalarFunction { + + private static final int ARG_PATH = 1; + + private final boolean isTry; + + private final @Nullable SimplePath literalPath; + + // Null when the result is a VARIANT, which needs no cast. + private final @Nullable FromVariantConverter converter; + + private final String targetTypeName; + + private transient @Nullable String lastPathString; + + private transient @Nullable SimplePath lastPath; + + private transient @Nullable ValidationException lastPathError; + + public VariantGetFunction(SpecializedContext context) { + this(BuiltInFunctionDefinitions.VARIANT_GET, context, false); + } + + VariantGetFunction( + BuiltInFunctionDefinition definition, SpecializedContext context, boolean isTry) { + super(definition, context); + this.isTry = isTry; + this.literalPath = parseLiteralPath(context.getCallContext()); + final LogicalType outputType = getOutputDataType().getLogicalType(); + this.converter = + outputType.is(LogicalTypeRoot.VARIANT) + ? null + : FromVariantConverter.create( + outputType, + TableConfigValidation.getLocalTimeZone(context.getConfiguration())); + this.targetTypeName = outputType.asSummaryString(); + } + + public @Nullable Object eval(@Nullable Variant variant, @Nullable StringData path) { + return eval(variant, path, null); + } + + public @Nullable Object eval( + @Nullable Variant variant, @Nullable StringData path, @Nullable StringData type) { + // The type only matters for planning, where it defines the output type. + if (variant == null || path == null) { + return null; + } + final SimplePath simplePath = literalPath != null ? literalPath : parsePath(path); + if (simplePath == null) { + return null; + } + final Variant value = navigate(variant, simplePath); + if (value == null || converter == null) { + return value; + } + if (value.isNull()) { + return null; + } + try { + return converter.convert(value); + } catch (RuntimeException e) { + if (isTry) { + return null; + } + throw new TableRuntimeException( + String.format( + "Failed to cast the VARIANT value at path '%s' to %s: %s", + simplePath, targetTypeName, e.getMessage()), + e); + } + } + + /** + * Returns the literal path, or {@code null} to parse the path of each row. + * + * <p>The input strategy rejects a malformed literal path. A path that becomes a literal only + * during optimization, such as a constant column of a view, skips that check, so a malformed + * one is left to the rules for a path that is not a literal. + */ + private static @Nullable SimplePath parseLiteralPath(CallContext callContext) { + if (!callContext.isArgumentLiteral(ARG_PATH)) { + return null; + } + final Optional<String> path = callContext.getArgumentValue(ARG_PATH, String.class); + if (path.isEmpty()) { + return null; + } + try { + return SimplePath.parse(path.get()); + } catch (ValidationException e) { + return null; + } + } + + /** Returns the path, or {@code null} for a malformed path in {@code TRY_VARIANT_GET}. */ + private @Nullable SimplePath parsePath(StringData path) { + final String pathString = path.toString(); + if (!pathString.equals(lastPathString)) { + lastPathString = pathString; + try { + lastPath = SimplePath.parse(pathString); + lastPathError = null; + } catch (ValidationException e) { + lastPath = null; + lastPathError = e; + } + } + if (lastPathError != null && !isTry) { + throw new TableRuntimeException(lastPathError.getMessage(), lastPathError); + } + return lastPath; + } + + /** Returns the value at the path, or {@code null} if the path points at nothing. */ + @VisibleForTesting + static @Nullable Variant navigate(Variant variant, SimplePath path) { + final List<SimplePath.Step> steps = path.getSteps(); + Variant current = variant; + for (final SimplePath.Step step : steps) { + if (step.isField()) { + current = current.isObject() ? current.getField(step.getFieldName()) : null; + } else { + current = current.isArray() ? current.getElement(step.getIndex()) : null; + } + if (current == null) { + return null; + } + } + return current; + } +}
