This is an automated email from the ASF dual-hosted git repository.
gustavodemorais pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/flink.git
The following commit(s) were added to refs/heads/master by this push:
new dc256f305a4 [FLINK-40268][table] Add new JSON_TYPE built-in function
dc256f305a4 is described below
commit dc256f305a444910ee87db648ee4e27207f9bef6
Author: Vasudev Kelappassery <[email protected]>
AuthorDate: Fri Aug 14 13:58:44 2026 +0100
[FLINK-40268][table] Add new JSON_TYPE built-in function
This closes #28850.
---
docs/data/sql_functions.yml | 44 ++++++
docs/data/sql_functions_zh.yml | 44 ++++++
.../docs/reference/pyflink.table/expressions.rst | 1 +
flink-python/pyflink/table/expression.py | 37 +++++
.../flink/table/api/internal/BaseExpressions.java | 48 +++++++
.../functions/BuiltInFunctionDefinitions.java | 20 +++
.../strategies/JsonPathInputTypeStrategy.java | 84 ++++++++++++
.../strategies/SpecificInputTypeStrategies.java | 5 +
.../table/planner/codegen/JsonCodeGenUtils.java | 119 ++++++++++++++++
.../table/planner/codegen/ExprCodeGenerator.scala | 3 +
.../planner/codegen/calls/BuiltInMethods.scala | 11 ++
.../table/planner/codegen/JsonParseReuseTest.java | 49 +++++++
.../planner/functions/JsonFunctionsITCase.java | 150 +++++++++++++++++++++
.../table/runtime/functions/SqlJsonUtils.java | 80 +++++++++++
14 files changed, 695 insertions(+)
diff --git a/docs/data/sql_functions.yml b/docs/data/sql_functions.yml
index 75d85543051..19a50dad2f0 100644
--- a/docs/data/sql_functions.yml
+++ b/docs/data/sql_functions.yml
@@ -1267,6 +1267,50 @@ json:
-- JSON_EXISTS separates an absent path from a present one
SELECT JSON_EXISTS(json_doc, '$.items[*]'), JSON_LENGTH(json_doc,
'$.items[*]');
+ - sql: JSON_TYPE(jsonValue[, path])
+ table: jsonType(jsonValue[, path])
+ description: |
+ Returns a string value indicating the type of jsonValue, optionally at
the given JSON
+ `path`. Returns SQL `NULL` if jsonValue is SQL `NULL`, is not valid
JSON, or the path
+ does not resolve to exactly one value.
+
+ The possible results are `object`, `array`, `string`, `number`,
`boolean` and `null` for
+ the JSON null literal.
+
+ Every number is reported as `number`,whatever its magnitude or precision.
+ Anything quoted is a `string`, including dates and date-times.
+
+ ```sql
+ -- object
+ SELECT JSON_TYPE('{"a": true}')
+
+ -- array
+ SELECT JSON_TYPE('[1, 2]')
+
+ -- null
+ SELECT JSON_TYPE('null')
+
+ -- boolean
+ SELECT JSON_TYPE('true')
+
+ -- string
+ SELECT JSON_TYPE('"Hello, World!"')
+
+ -- string
+ SELECT JSON_TYPE('"2015-01-01"')
+
+ -- number
+ SELECT JSON_TYPE('66')
+
+ -- number
+ SELECT JSON_TYPE('11.1')
+
+ -- NULL, since 68s is not valid JSON
+ SELECT JSON_TYPE('68s')
+
+ -- array, read at the given path
+ SELECT JSON_TYPE('{"a": [1, 2]}', '$.a')
+ ```
variant:
- sql: PARSE_JSON(json_string[, allow_duplicate_keys])
diff --git a/docs/data/sql_functions_zh.yml b/docs/data/sql_functions_zh.yml
index 5b332fca088..613c561d838 100644
--- a/docs/data/sql_functions_zh.yml
+++ b/docs/data/sql_functions_zh.yml
@@ -1353,6 +1353,50 @@ json:
-- JSON_EXISTS separates an absent path from a present one
SELECT JSON_EXISTS(json_doc, '$.items[*]'), JSON_LENGTH(json_doc,
'$.items[*]');
+ - sql: JSON_TYPE(jsonValue[, path])
+ table: jsonType(jsonValue[, path])
+ description: |
+ Returns a string value indicating the type of jsonValue, optionally at
the given JSON
+ `path`. Returns SQL `NULL` if jsonValue is SQL `NULL`, is not valid
JSON, or the path
+ does not resolve to exactly one value.
+
+ The possible results are `object`, `array`, `string`, `number`,
`boolean` and `null` for
+ the JSON null literal.
+
+ Every number is reported as `number`,whatever its magnitude or precision.
+ Anything quoted is a `string`, including dates and date-times.
+
+ ```sql
+ -- object
+ SELECT JSON_TYPE('{"a": true}')
+
+ -- array
+ SELECT JSON_TYPE('[1, 2]')
+
+ -- null
+ SELECT JSON_TYPE('null')
+
+ -- boolean
+ SELECT JSON_TYPE('true')
+
+ -- string
+ SELECT JSON_TYPE('"Hello, World!"')
+
+ -- string
+ SELECT JSON_TYPE('"2015-01-01"')
+
+ -- number
+ SELECT JSON_TYPE('66')
+
+ -- number
+ SELECT JSON_TYPE('11.1')
+
+ -- NULL, since 68s is not valid JSON
+ SELECT JSON_TYPE('68s')
+
+ -- array, read at the given path
+ SELECT JSON_TYPE('{"a": [1, 2]}', '$.a')
+ ```
variant:
- sql: PARSE_JSON(json_string[, allow_duplicate_keys])
diff --git a/flink-python/docs/reference/pyflink.table/expressions.rst
b/flink-python/docs/reference/pyflink.table/expressions.rst
index 17a475516b9..10614e996b1 100644
--- a/flink-python/docs/reference/pyflink.table/expressions.rst
+++ b/flink-python/docs/reference/pyflink.table/expressions.rst
@@ -327,6 +327,7 @@ JSON functions
Expression.json_quote
Expression.json_unquote
Expression.json_length
+ Expression.json_type
value modification functions
----------------------------
diff --git a/flink-python/pyflink/table/expression.py
b/flink-python/pyflink/table/expression.py
index d4a9186882f..33fd03122f8 100644
--- a/flink-python/pyflink/table/expression.py
+++ b/flink-python/pyflink/table/expression.py
@@ -2324,6 +2324,43 @@ class Expression(Generic[T]):
else:
return _binary_op("jsonLength")(self, path)
+ def json_type(self, path=None) -> 'Expression':
+ """
+ Returns a string value indicating the type of the input.
+
+ Potential outputs are as following
+
+ * `object`
+ * `array`
+ * `string`
+ * `number`
+ * `boolean`
+ * `null`, for the JSON null literal
+
+ Every number is reported as `number`, whatever its magnitude or
precision.
+
+ Returns None if the input is None or is not valid JSON.
+
+ If `path` is given, the type is read at that location instead of the
root. A path that
+ does not resolve to exactly one value returns None.
+
+ Examples:
+ ::
+
+ >>> lit('{"a": true}').json_type() # 'object'
+ >>> lit('[1, 2]').json_type() # 'array'
+ >>> lit('null').json_type() # 'null'
+ >>> lit('"Hello, World!"').json_type() # 'string'
+ >>> lit('"2015-01-01"').json_type() # 'string'
+ >>> lit('66').json_type() # 'number'
+ >>> lit('11.1').json_type() # 'number'
+ >>> lit('68s').json_type() # None, not valid JSON
+ >>> lit('{"a": [1, 2]}').json_type('$.a') # 'array'
+ """
+ if path is None:
+ return _unary_op("jsonType")(self)
+ else:
+ return _binary_op("jsonType")(self, path)
# ---------------------------- value modification functions
-----------------------------
def object_update(self, *kv) -> "Expression":
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 53c05ff3c29..1dfc2687cbf 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
@@ -143,6 +143,7 @@ import static
org.apache.flink.table.functions.BuiltInFunctionDefinitions.JSON_E
import static
org.apache.flink.table.functions.BuiltInFunctionDefinitions.JSON_LENGTH;
import static
org.apache.flink.table.functions.BuiltInFunctionDefinitions.JSON_QUERY;
import static
org.apache.flink.table.functions.BuiltInFunctionDefinitions.JSON_QUOTE;
+import static
org.apache.flink.table.functions.BuiltInFunctionDefinitions.JSON_TYPE;
import static
org.apache.flink.table.functions.BuiltInFunctionDefinitions.JSON_UNQUOTE;
import static
org.apache.flink.table.functions.BuiltInFunctionDefinitions.JSON_VALUE;
import static
org.apache.flink.table.functions.BuiltInFunctionDefinitions.LAST_VALUE;
@@ -2507,6 +2508,53 @@ public abstract class BaseExpressions<InType, OutType> {
return jsonQuery(path, DataTypes.STRING(), wrappingBehavior, onEmpty,
onError);
}
+ /**
+ * Returns a string value indicating the type of the input.
+ *
+ * <p>Potential outputs are as following
+ *
+ * <ul>
+ * <li>object
+ * <li>array
+ * <li>string
+ * <li>number
+ * <li>boolean
+ * <li>null, for the JSON null literal
+ * </ul>
+ *
+ * <p>Every number is reported as number, whatever its magnitude or
precision.
+ *
+ * <p>Returns SQL NULL if the input is NULL or is not valid JSON.
+ *
+ * <p>Examples:
+ *
+ * <pre>{@code
+ * lit("{\"a\": true}").jsonType() // "object"
+ * lit("[1, 2]").jsonType() // "array"
+ * lit("null").jsonType() // "null"
+ * lit("\"Hello, World!\"").jsonType() // "string"
+ * lit("\"2015-01-01\"").jsonType() // "string"
+ * lit("66").jsonType() // "number"
+ * lit("11.1").jsonType() // "number"
+ * lit("68s").jsonType() // SQL NULL, not valid JSON
+ * }</pre>
+ */
+ public OutType jsonType() {
+ return toApiSpecificExpression(unresolvedCall(JSON_TYPE, toExpr()));
+ }
+
+ /**
+ * Like {@link #jsonType()}, but reads the type at {@code path} instead of
the root. A path that
+ * does not resolve to exactly one value returns SQL NULL.
+ *
+ * <pre>{@code
+ * lit("{\"a\": [1, 2]}").jsonType("$.a") // "array"
+ * }</pre>
+ */
+ public OutType jsonType(String path) {
+ return toApiSpecificExpression(unresolvedCall(JSON_TYPE, toExpr(),
valueLiteral(path)));
+ }
+
/**
* Extracts JSON values from a JSON string.
*
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 707c6ea5bfe..85edd2ead05 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
@@ -117,6 +117,7 @@ import static
org.apache.flink.table.types.inference.strategies.SpecificInputTyp
import static
org.apache.flink.table.types.inference.strategies.SpecificInputTypeStrategies.TWO_FULLY_COMPARABLE;
import static
org.apache.flink.table.types.inference.strategies.SpecificInputTypeStrategies.percentage;
import static
org.apache.flink.table.types.inference.strategies.SpecificInputTypeStrategies.percentageArray;
+import static
org.apache.flink.table.types.inference.strategies.SpecificInputTypeStrategies.plainJsonPath;
import static
org.apache.flink.table.types.inference.strategies.SpecificTypeStrategies.ARRAY_APPEND_PREPEND;
import static
org.apache.flink.table.types.inference.strategies.SpecificTypeStrategies.FROM_CHANGELOG_OUTPUT_TYPE_STRATEGY;
import static
org.apache.flink.table.types.inference.strategies.SpecificTypeStrategies.LATERAL_SNAPSHOT_OUTPUT_TYPE_STRATEGY;
@@ -3102,6 +3103,25 @@ public final class BuiltInFunctionDefinitions {
.runtimeProvided()
.build();
+ public static final BuiltInFunctionDefinition JSON_TYPE =
+ BuiltInFunctionDefinition.newBuilder()
+ .name("JSON_TYPE")
+ .kind(SCALAR)
+ .inputTypeStrategy(
+ plainJsonPath(
+ or(
+
sequence(logical(LogicalTypeFamily.CHARACTER_STRING)),
+ sequence(
+
logical(LogicalTypeFamily.CHARACTER_STRING),
+ and(
+ logical(
+
LogicalTypeFamily
+
.CHARACTER_STRING),
+ LITERAL)))))
+ .outputTypeStrategy(explicit(DataTypes.STRING()))
+ .runtimeProvided()
+ .build();
+
//
--------------------------------------------------------------------------------------------
// Variant functions
//
--------------------------------------------------------------------------------------------
diff --git
a/flink-table/flink-table-common/src/main/java/org/apache/flink/table/types/inference/strategies/JsonPathInputTypeStrategy.java
b/flink-table/flink-table-common/src/main/java/org/apache/flink/table/types/inference/strategies/JsonPathInputTypeStrategy.java
new file mode 100644
index 00000000000..5a7ef1f0a6c
--- /dev/null
+++
b/flink-table/flink-table-common/src/main/java/org/apache/flink/table/types/inference/strategies/JsonPathInputTypeStrategy.java
@@ -0,0 +1,84 @@
+/*
+ * 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.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 java.util.List;
+import java.util.Optional;
+import java.util.regex.Pattern;
+
+/**
+ * Strategy for a JSON function whose optional second argument is a path
literal. Rejects the {@code
+ * lax}/{@code strict} path mode prefix at planning time and delegates
everything else to {@code
+ * signatures}.
+ */
+@Internal
+public final class JsonPathInputTypeStrategy implements InputTypeStrategy {
+
+ private static final int ARG_PATH = 1;
+
+ private static final Pattern PATH_MODE_PREFIX =
+ Pattern.compile("\\s*(strict|lax)\\s+\\$.*",
Pattern.CASE_INSENSITIVE | Pattern.DOTALL);
+
+ private final InputTypeStrategy signatures;
+
+ JsonPathInputTypeStrategy(final InputTypeStrategy signatures) {
+ this.signatures = signatures;
+ }
+
+ @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() ||
callContext.getArgumentDataTypes().size() <= ARG_PATH) {
+ return inferredDataTypes;
+ }
+
+ final Optional<String> path = callContext.getArgumentValue(ARG_PATH,
String.class);
+ if (path.isPresent() &&
PATH_MODE_PREFIX.matcher(path.get()).matches()) {
+ return callContext.fail(
+ throwOnFailure,
+ "%s does not support the 'lax'/'strict' path mode prefix
(got: '%s'). "
+ + "Use a plain path such as '$.a.b'. To check path
existence or handle "
+ + "invalid input, use JSON_EXISTS or IS JSON.",
+ callContext.getName(),
+ path.get());
+ }
+
+ 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/SpecificInputTypeStrategies.java
b/flink-table/flink-table-common/src/main/java/org/apache/flink/table/types/inference/strategies/SpecificInputTypeStrategies.java
index d551ca89a55..9aa75e02463 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
@@ -96,6 +96,11 @@ public final class SpecificInputTypeStrategies {
public static final ArgumentTypeStrategy
JSON_QUERY_ON_EMPTY_ERROR_BEHAVIOUR =
new JsonQueryOnErrorEmptyArgumentTypeStrategy();
+ /** See {@link JsonPathInputTypeStrategy}. */
+ public static InputTypeStrategy plainJsonPath(final InputTypeStrategy
signatures) {
+ return new JsonPathInputTypeStrategy(signatures);
+ }
+
/** Argument type derived from the array element type. */
public static final ArgumentTypeStrategy ARRAY_ELEMENT_ARG =
new ArrayElementArgumentTypeStrategy();
diff --git
a/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/codegen/JsonCodeGenUtils.java
b/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/codegen/JsonCodeGenUtils.java
new file mode 100644
index 00000000000..0b466bded2c
--- /dev/null
+++
b/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/codegen/JsonCodeGenUtils.java
@@ -0,0 +1,119 @@
+/*
+ * 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.codegen;
+
+import org.apache.flink.table.planner.codegen.calls.BuiltInMethods;
+import org.apache.flink.table.runtime.functions.SqlJsonUtils;
+import org.apache.flink.table.types.logical.LogicalType;
+
+import scala.Option;
+import scala.Tuple2;
+import scala.collection.Seq;
+
+/** Utilities for the code generation of JSON functions. */
+public final class JsonCodeGenUtils {
+
+ private JsonCodeGenUtils() {}
+
+ /**
+ * Generates {@code JSON_TYPE(jsonValue)} or {@code JSON_TYPE(jsonValue,
path)}.
+ *
+ * <p>The parsed context is shared with the other JSON functions over the
same input, so the
+ * input is parsed only once per record.
+ */
+ public static GeneratedExpression generateJsonType(
+ CodeGeneratorContext ctx, LogicalType returnType,
Seq<GeneratedExpression> operands) {
+ boolean hasPath = operands.length() == 2;
+ return GenerateUtils.generateCallWithStmtIfArgsNotNull(
+ ctx,
+ returnType,
+ operands,
+ true,
+ false,
+ argTerms -> {
+ ParsedJson parsed = getOrCreateParsedJson(ctx,
argTerms.head() + ".toString()");
+ String call;
+ if (hasPath) {
+ String pathSpec =
operands.apply(1).literalValue().get().toString();
+ boolean definite =
SqlJsonUtils.isPathDefinite(pathSpec);
+ call =
+
CodeGenUtils.qualifyMethod(BuiltInMethods.JSON_TYPE_PATH())
+ + "("
+ + parsed.varName
+ + ", "
+ + argTerms.apply(1)
+ + ".toString(), "
+ + definite
+ + ")";
+ } else {
+ call =
+
CodeGenUtils.qualifyMethod(BuiltInMethods.JSON_TYPE())
+ + "("
+ + parsed.varName
+ + ")";
+ }
+ String resultExpr = CodeGenUtils.BINARY_STRING() +
".fromString(" + call + ")";
+ return new Tuple2<>(parsed.parseCode, resultExpr);
+ });
+ }
+
+ /**
+ * Emits code that parses the given JSON {@code inputTerm} into a reusable
{@link
+ * SqlJsonUtils.JsonValueContext} member variable.
+ *
+ * @return the parsed-context variable name and the parse statement, empty
if the same input was
+ * already parsed
+ */
+ private static ParsedJson getOrCreateParsedJson(CodeGeneratorContext ctx,
String inputTerm) {
+ Option<GeneratedExpression> existing =
+ ctx.getReusableInputUnboxingExprs(inputTerm,
Integer.MIN_VALUE);
+ if (existing.isDefined()) {
+ return new ParsedJson(existing.get().resultTerm(), "");
+ }
+
+ String varName = CodeGenUtils.newName(ctx, "jsonParsed");
+ String typeName = SqlJsonUtils.JsonValueContext.class.getName();
+ ctx.addReusableMember(typeName + " " + varName + ";");
+
+ ctx.addReusableInputUnboxingExprs(
+ inputTerm,
+ Integer.MIN_VALUE,
+ new GeneratedExpression(varName, "false", "", null,
Option.empty()));
+
+ String parseCode =
+ varName
+ + " = "
+ +
CodeGenUtils.qualifyMethod(BuiltInMethods.JSON_PARSE())
+ + "("
+ + inputTerm
+ + ");";
+ return new ParsedJson(varName, parseCode);
+ }
+
+ /** Holds the outcome of {@link #getOrCreateParsedJson}. */
+ private static final class ParsedJson {
+ private final String varName;
+ private final String parseCode;
+
+ ParsedJson(String varName, String parseCode) {
+ this.varName = varName;
+ this.parseCode = parseCode;
+ }
+ }
+}
diff --git
a/flink-table/flink-table-planner/src/main/scala/org/apache/flink/table/planner/codegen/ExprCodeGenerator.scala
b/flink-table/flink-table-planner/src/main/scala/org/apache/flink/table/planner/codegen/ExprCodeGenerator.scala
index 024229f2563..7e5779ea8c6 100644
---
a/flink-table/flink-table-planner/src/main/scala/org/apache/flink/table/planner/codegen/ExprCodeGenerator.scala
+++
b/flink-table/flink-table-planner/src/main/scala/org/apache/flink/table/planner/codegen/ExprCodeGenerator.scala
@@ -939,6 +939,9 @@ class ExprCodeGenerator(
case BuiltInFunctionDefinitions.JSON_LENGTH =>
new JsonLengthCallGen().generate(ctx, operands, resultType)
+ case BuiltInFunctionDefinitions.JSON_TYPE =>
+ JsonCodeGenUtils.generateJsonType(ctx, resultType, operands)
+
case BuiltInFunctionDefinitions.INTERNAL_HASHCODE =>
new HashCodeCallGen().generate(ctx, operands, resultType)
diff --git
a/flink-table/flink-table-planner/src/main/scala/org/apache/flink/table/planner/codegen/calls/BuiltInMethods.scala
b/flink-table/flink-table-planner/src/main/scala/org/apache/flink/table/planner/codegen/calls/BuiltInMethods.scala
index 6d0d695b39a..99ea6eb483f 100644
---
a/flink-table/flink-table-planner/src/main/scala/org/apache/flink/table/planner/codegen/calls/BuiltInMethods.scala
+++
b/flink-table/flink-table-planner/src/main/scala/org/apache/flink/table/planner/codegen/calls/BuiltInMethods.scala
@@ -513,6 +513,17 @@ object BuiltInMethods {
val JSON_PARSE =
Types.lookupMethod(classOf[SqlJsonUtils], "jsonParse", classOf[String])
+ val JSON_TYPE =
+ Types.lookupMethod(classOf[SqlJsonUtils], "jsonType",
classOf[SqlJsonUtils.JsonValueContext])
+
+ val JSON_TYPE_PATH = Types.lookupMethod(
+ classOf[SqlJsonUtils],
+ "jsonType",
+ classOf[SqlJsonUtils.JsonValueContext],
+ classOf[String],
+ classOf[Boolean]
+ )
+
val JSON_QUERY_PARSED = Types.lookupMethod(
classOf[SqlJsonUtils],
"jsonQueryParsed",
diff --git
a/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/codegen/JsonParseReuseTest.java
b/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/codegen/JsonParseReuseTest.java
index 9d646ade3aa..abceea8e83c 100644
---
a/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/codegen/JsonParseReuseTest.java
+++
b/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/codegen/JsonParseReuseTest.java
@@ -271,6 +271,55 @@ class JsonParseReuseTest {
.isOne();
}
+ @Test
+ void testSingleJsonTypeCall() {
+ final String sql = "SELECT JSON_TYPE(json_data) FROM json_src";
+ final List<Row> rows = collect(sql);
+ assertThat(rows).containsExactlyInAnyOrder(Row.of("object"),
Row.of("object"));
+ assertThat(countJsonParse(extractGeneratedCode(sql)))
+ .as("A single JSON_TYPE call should parse once")
+ .isOne();
+ }
+
+ @Test
+ void testTwoJsonTypeCalls() {
+ final String sql = "SELECT JSON_TYPE(json_data), JSON_TYPE(json_data)
FROM json_src";
+ final List<Row> rows = collect(sql);
+ assertThat(rows)
+ .containsExactlyInAnyOrder(Row.of("object", "object"),
Row.of("object", "object"));
+ assertThat(countJsonParse(extractGeneratedCode(sql)))
+ .as("Identical JSON_TYPE calls are one expression, so they
parse once")
+ .isOne();
+ }
+
+ @Test
+ void testJsonTypeAndJsonValueMixed() {
+ final String sql =
+ "SELECT JSON_VALUE(json_data, '$.type'), JSON_TYPE(json_data)
FROM json_src";
+ final List<Row> rows = collect(sql);
+ assertThat(rows)
+ .containsExactlyInAnyOrder(Row.of("account", "object"),
Row.of("admin", "object"));
+ assertThat(countJsonParse(extractGeneratedCode(sql)))
+ .as("JSON_VALUE + JSON_TYPE on the same input should parse
once")
+ .isOne();
+ }
+
+ @Test
+ void testJsonTypeWithJsonValueAndJsonQuery() {
+ final String sql =
+ "SELECT JSON_VALUE(json_data, '$.type'), "
+ + "JSON_QUERY(json_data, '$.address'), "
+ + "JSON_TYPE(json_data) FROM json_src";
+ final List<Row> rows = collect(sql);
+ assertThat(rows)
+ .containsExactlyInAnyOrder(
+ Row.of("account", "{\"city\":\"Munich\"}", "object"),
+ Row.of("admin", "{\"city\":\"Berlin\"}", "object"));
+ assertThat(countJsonParse(extractGeneratedCode(sql)))
+ .as("JSON_VALUE + JSON_QUERY + JSON_TYPE on the same input
should parse once")
+ .isOne();
+ }
+
@Test
void testDifferentJsonInputs() {
final String sql =
diff --git
a/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/functions/JsonFunctionsITCase.java
b/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/functions/JsonFunctionsITCase.java
index 9f3b1e377c3..65ffef63b2d 100644
---
a/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/functions/JsonFunctionsITCase.java
+++
b/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/functions/JsonFunctionsITCase.java
@@ -94,6 +94,7 @@ class JsonFunctionsITCase extends BuiltInFunctionTestBase {
testCases.addAll(jsonObjectSpec());
testCases.addAll(jsonSpec());
testCases.addAll(jsonArraySpec());
+ testCases.addAll(jsonTypeSpec());
testCases.addAll(jsonQuoteSpec());
testCases.addAll(jsonUnquoteSpecWithValidInput());
testCases.addAll(jsonUnquoteSpecWithInvalidInput());
@@ -1423,6 +1424,155 @@ class JsonFunctionsITCase extends
BuiltInFunctionTestBase {
STRING().notNull()));
}
+ private static List<TestSetSpec> jsonTypeSpec() {
+ return List.of(
+ // One flag per JSON type.
+ TestSetSpec.forFunction(BuiltInFunctionDefinitions.JSON_TYPE)
+ .onFieldsWithData(
+ "{\"a\": true}", "[1, 2]", "true", "\"Hello,
World!\"", "66")
+ .andDataTypes(STRING(), STRING(), STRING(), STRING(),
STRING())
+ .testResult(
+ $("f0").jsonType(), "JSON_TYPE(f0)", "object",
STRING().nullable())
+ .testResult(
+ $("f1").jsonType(), "JSON_TYPE(f1)", "array",
STRING().nullable())
+ .testResult(
+ $("f2").jsonType(), "JSON_TYPE(f2)",
"boolean", STRING().nullable())
+ .testResult(
+ $("f3").jsonType(), "JSON_TYPE(f3)", "string",
STRING().nullable())
+ .testResult(
+ $("f4").jsonType(), "JSON_TYPE(f4)", "number",
STRING().nullable()),
+
+ // The flag follows the JSON grammar alone: a number has no
width, and a quoted
+ // value is a string whatever it spells.
+ TestSetSpec.forFunction(BuiltInFunctionDefinitions.JSON_TYPE)
+ .onFieldsWithData(
+ "11.1", "99999999999999999999",
"\"2015-01-01\"", "\"66\"")
+ .andDataTypes(STRING(), STRING(), STRING(), STRING())
+ .testResult(
+ $("f0").jsonType(), "JSON_TYPE(f0)", "number",
STRING().nullable())
+ .testResult(
+ $("f1").jsonType(), "JSON_TYPE(f1)", "number",
STRING().nullable())
+ .testResult(
+ $("f2").jsonType(), "JSON_TYPE(f2)", "string",
STRING().nullable())
+ .testResult(
+ $("f3").jsonType(), "JSON_TYPE(f3)", "string",
STRING().nullable()),
+
+ // A SQL NULL input and invalid JSON both yield SQL NULL; the
JSON null literal
+ // returns the string 'null'.
+ TestSetSpec.forFunction(BuiltInFunctionDefinitions.JSON_TYPE)
+ .onFieldsWithData("68s", "null")
+ .andDataTypes(STRING(), STRING())
+ .testResult(
+ nullOf(STRING()).jsonType(),
+ "JSON_TYPE(CAST(NULL AS STRING))",
+ null,
+ STRING().nullable())
+ .testResult($("f0").jsonType(), "JSON_TYPE(f0)", null,
STRING().nullable())
+ .testResult(
+ $("f1").jsonType(), "JSON_TYPE(f1)", "null",
STRING().nullable())
+ .testResult(
+ $("f1").jsonType("$"),
+ "JSON_TYPE(f1, '$')",
+ "null",
+ STRING().nullable())
+ .testResult(
+ $("f1").jsonType("$.a"),
+ "JSON_TYPE(f1, '$.a')",
+ null,
+ STRING().nullable()),
+
+ // A path reads the type at that location instead of the root,
and yields NULL
+ // unless it resolves to exactly one value. A wildcard path is
indefinite: it reads
+ // back as a list, so it has a type only for a single match.
+ TestSetSpec.forFunction(BuiltInFunctionDefinitions.JSON_TYPE)
+ .onFieldsWithData("{\"a\": [1, 2]}", "{\"a\": [1]}")
+ .andDataTypes(STRING(), STRING())
+ .testResult(
+ $("f0").jsonType("$.a"),
+ "JSON_TYPE(f0, '$.a')",
+ "array",
+ STRING().nullable())
+ .testResult(
+ $("f0").jsonType("$.a[0]"),
+ "JSON_TYPE(f0, '$.a[0]')",
+ "number",
+ STRING().nullable())
+ .testResult(
+ $("f0").jsonType("$.b"),
+ "JSON_TYPE(f0, '$.b')",
+ null,
+ STRING().nullable())
+ .testResult(
+ $("f0").jsonType("$.["),
+ "JSON_TYPE(f0, '$.[')",
+ null,
+ STRING().nullable())
+ .testResult(
+ $("f0").jsonType(""),
+ "JSON_TYPE(f0, '')",
+ null,
+ STRING().nullable())
+ .testResult(
+ $("f0").jsonType("$.a[*]"),
+ "JSON_TYPE(f0, '$.a[*]')",
+ null,
+ STRING().nullable())
+ .testResult(
+ $("f1").jsonType("$.a[*]"),
+ "JSON_TYPE(f1, '$.a[*]')",
+ "number",
+ STRING().nullable()),
+
+ // The 'lax'/'strict' path mode prefix is rejected at planning
time, but a field of
+ // that name is addressed like any other.
+ TestSetSpec.forFunction(BuiltInFunctionDefinitions.JSON_TYPE)
+ .onFieldsWithData(
+ "{\"a\": 1}", "{\"lax\": {\"strict\": 2},
\"strict value\": 1}")
+ .andDataTypes(STRING(), STRING())
+ .testSqlValidationError(
+ "JSON_TYPE(f0, 'lax $.a')",
+ "JSON_TYPE does not support the 'lax'/'strict'
path mode prefix "
+ + "(got: 'lax $.a'). Use a plain path
such as '$.a.b'. To "
+ + "check path existence or handle
invalid input, use "
+ + "JSON_EXISTS or IS JSON.")
+ .testTableApiValidationError(
+ $("f0").jsonType("strict $.a"),
+ "JSON_TYPE does not support the 'lax'/'strict'
path mode prefix "
+ + "(got: 'strict $.a'). Use a plain
path such as '$.a.b'. "
+ + "To check path existence or handle
invalid input, use "
+ + "JSON_EXISTS or IS JSON.")
+ .testResult(
+ $("f1").jsonType("lax"),
+ "JSON_TYPE(f1, 'lax')",
+ "object",
+ STRING().nullable())
+ .testResult(
+ $("f1").jsonType("$[\"strict value\"]"),
+ "JSON_TYPE(f1, '$[\"strict value\"]')",
+ "number",
+ STRING().nullable()),
+
+ // Only CHARACTER_STRING casts implicitly to VARCHAR, so a
non-string is rejected
+ // rather than coerced. A path argument must be a literal.
+ TestSetSpec.forFunction(BuiltInFunctionDefinitions.JSON_TYPE)
+ .onFieldsWithData(1, "{}")
+ .andDataTypes(INT(), STRING())
+ .testTableApiValidationError(
+ $("f0").jsonType(),
+ "Invalid input arguments. Expected signatures
are:\n"
+ + "JSON_TYPE(<CHARACTER_STRING>)")
+ .testSqlValidationError(
+ "JSON_TYPE(f0)",
+ "Invalid input arguments. Expected signatures
are:\n"
+ + "JSON_TYPE(<CHARACTER_STRING>)")
+ .testSqlValidationError("JSON_TYPE(f1, f1)", "Invalid
input arguments.")
+ .testSqlValidationError(
+ "JSON_TYPE()",
+ "No match found for function signature
JSON_TYPE().\n"
+ + "Supported signatures are:\n"
+ + "JSON_TYPE(<CHARACTER_STRING>)"));
+ }
+
private static List<TestSetSpec> jsonQuoteSpec() {
return List.of(
diff --git
a/flink-table/flink-table-runtime/src/main/java/org/apache/flink/table/runtime/functions/SqlJsonUtils.java
b/flink-table/flink-table-runtime/src/main/java/org/apache/flink/table/runtime/functions/SqlJsonUtils.java
index 6b77cc4fa22..e921cca0324 100644
---
a/flink-table/flink-table-runtime/src/main/java/org/apache/flink/table/runtime/functions/SqlJsonUtils.java
+++
b/flink-table/flink-table-runtime/src/main/java/org/apache/flink/table/runtime/functions/SqlJsonUtils.java
@@ -97,6 +97,12 @@ public class SqlJsonUtils {
private static final String JSON_VALUE_FUNCTION_NAME = "JSON_VALUE";
private static final String JSON_EXISTS_FUNCTION_NAME = "JSON_EXISTS";
+ private static final Configuration JSON_PATH_TYPE_CONFIG =
+ Configuration.builder()
+ .jsonProvider(JSON_PATH_JSON_PROVIDER)
+ .mappingProvider(JSON_PATH_MAPPING_PROVIDER)
+ .build();
+
private SqlJsonUtils() {}
static {
@@ -526,6 +532,80 @@ public class SqlJsonUtils {
return JSON_PATH_JSON_PROVIDER.parse(input);
}
+ /**
+ * Returns the JSON type flag for the parsed value: {@code object}, {@code
array}, {@code
+ * string}, {@code number}, {@code boolean}, or {@code null} for the JSON
null literal. Returns
+ * SQL {@code NULL} for invalid JSON.
+ */
+ public static String jsonType(final JsonValueContext parsedInput) {
+ // Unparsed, or shared with a call that never assigned it: report NULL
either way.
+ if (parsedInput == null || parsedInput.hasException()) {
+ return null;
+ }
+ return getJsonType(parsedInput.obj);
+ }
+
+ private static String getJsonType(final Object val) {
+ if (val instanceof Number) {
+ return "number";
+ } else if (val instanceof String) {
+ return "string";
+ } else if (val instanceof Boolean) {
+ return "boolean";
+ } else if (val instanceof Map) {
+ return "object";
+ } else if (val instanceof Collection) {
+ return "array";
+ } else if (val == null) {
+ return "null";
+ }
+ return null;
+ }
+
+ /**
+ * Returns the JSON type flag at {@code path}, or {@code null} if the path
doesn't resolve to
+ * exactly one value. {@code definite} is computed at plan time by {@link
#isPathDefinite}.
+ */
+ public static String jsonType(
+ final JsonValueContext parsedInput, final String path, final
boolean definite) {
+ if (parsedInput == null || parsedInput.hasException() ||
path.isEmpty()) {
+ return null;
+ }
+
+ if (parsedInput.obj == null) {
+ return "$".equals(path) ? "null" : null;
+ }
+
+ final Object value;
+ try {
+ // PathNotFoundException extends InvalidPathException (covers both
exceptions)
+ value = JsonPath.parse(parsedInput.obj,
JSON_PATH_TYPE_CONFIG).read(path);
+ } catch (InvalidPathException e) {
+ return null;
+ }
+
+ if (!definite) {
+ // Indefinite paths (e.g. wildcards) read back as a list; only one
match has one type.
+ final List<?> matched = (List<?>) value;
+ return matched.size() == 1 ? getJsonType(matched.get(0)) : null;
+ }
+ return getJsonType(value);
+ }
+
+ /** Returns whether {@code pathSpec} is a definite JSON path. */
+ public static boolean isPathDefinite(final String pathSpec) {
+ // JsonPath.compile() rejects an empty path with an
IllegalArgumentException rather than
+ // the InvalidPathException caught below.
+ if (pathSpec.isEmpty()) {
+ return false;
+ }
+ try {
+ return JsonPath.isPathDefinite(pathSpec);
+ } catch (InvalidPathException e) {
+ return false;
+ }
+ }
+
/**
* Parses a JSON string into a reusable context object. The result can be
passed to {@link
* #jsonValue} or {@link #jsonQueryParsed} to avoid re-parsing the same
JSON string multiple