This is an automated email from the ASF dual-hosted git repository.

snuyanzin 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 c0e893976e8 FLINK-40233][table] Correctly share the parsed JSON across 
`JSON_VALUE`/`JSON_QUERY` calls when the owning call is skipped because of 
`NULL` or not taken branch
c0e893976e8 is described below

commit c0e893976e8a2f10b28d778ca5d053ffea8254c3
Author: Sergey Nuyanzin <[email protected]>
AuthorDate: Sat Aug 1 00:42:16 2026 +0200

    FLINK-40233][table] Correctly share the parsed JSON across 
`JSON_VALUE`/`JSON_QUERY` calls when the owning call is skipped because of 
`NULL` or not taken branch
---
 .../planner/codegen/calls/JsonParseReuse.scala     |  83 ++++++++
 .../planner/codegen/calls/JsonQueryCallGen.scala   |  44 ++--
 .../planner/codegen/calls/JsonValueCallGen.scala   |  43 ++--
 .../table/planner/codegen/JsonParseReuseTest.java  | 229 +++++++++++++++++++--
 4 files changed, 334 insertions(+), 65 deletions(-)

diff --git 
a/flink-table/flink-table-planner/src/main/scala/org/apache/flink/table/planner/codegen/calls/JsonParseReuse.scala
 
b/flink-table/flink-table-planner/src/main/scala/org/apache/flink/table/planner/codegen/calls/JsonParseReuse.scala
new file mode 100644
index 00000000000..7a340dee049
--- /dev/null
+++ 
b/flink-table/flink-table-planner/src/main/scala/org/apache/flink/table/planner/codegen/calls/JsonParseReuse.scala
@@ -0,0 +1,83 @@
+/*
+ * 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.calls
+
+import org.apache.flink.table.planner.codegen.{CodeGeneratorContext, 
CodeGenUtils, GeneratedExpression}
+import org.apache.flink.table.planner.codegen.CodeGenUtils.qualifyMethod
+import org.apache.flink.table.runtime.functions.SqlJsonUtils
+
+/**
+ * Shares the parsed JSON input between the JSON function calls of a single 
generated expression,
+ * see [[JsonValueCallGen]] and [[JsonQueryCallGen]].
+ *
+ * The input is parsed at most once per record, by whichever call runs first, 
and the result is held
+ * in a member variable that the following calls on the same input read 
instead of parsing again.
+ */
+object JsonParseReuse {
+
+  /**
+   * Returns the expression holding the parsed input. The caller generates its 
own call with the
+   * original operands and reads the parsed input from the returned expression.
+   *
+   * The parse is lazy: the result term is a call to a member method that 
parses on its first
+   * invocation for the record. `generateCallWithStmtIfArgsNotNull` places a 
call under a guard that
+   * requires *all* of its arguments to be non-null, so no single call can be 
made the owner of the
+   * parse - in
+   * {{{
+   * SELECT JSON_VALUE(v, CAST(NULL AS STRING)), JSON_QUERY(v, '$.a')
+   * }}}
+   * the NULL path makes the first call short-circuit while the second one 
still needs the parse.
+   * Conversely, when no call runs, no parse happens at all.
+   */
+  def parseSharedInput(
+      ctx: CodeGeneratorContext,
+      operands: Seq[GeneratedExpression]): GeneratedExpression = {
+    val input = operands.head
+    val inputTerm = s"${input.resultTerm}.toString()"
+
+    ctx.getReusableInputUnboxingExprs(inputTerm, Int.MinValue) match {
+      case Some(expr) => expr
+      case None =>
+        val varName = CodeGenUtils.newName(ctx, "jsonParsed")
+        val lastInputName = CodeGenUtils.newName(ctx, "jsonParsedInput")
+        val methodName = CodeGenUtils.newName(ctx, "parseJson")
+        val typeName = classOf[SqlJsonUtils.JsonValueContext].getName
+        val inputType = CodeGenUtils.BINARY_STRING
+        ctx.addReusableMember(s"$typeName $varName;")
+        ctx.addReusableMember(s"$inputType $lastInputName;")
+
+        // keyed on the immutable input so it re-parses on change; no 
per-record reset needed
+        ctx.addReusableMember(
+          s"""
+             |private $typeName $methodName($inputType in) {
+             |  if (in != $lastInputName) {
+             |    $lastInputName = in;
+             |    $varName = 
${qualifyMethod(BuiltInMethods.JSON_PARSE)}(in.toString());
+             |  }
+             |  return $varName;
+             |}
+             |""".stripMargin)
+
+        val parsed =
+          GeneratedExpression(s"$methodName(${input.resultTerm})", "false", 
"", null)
+        ctx.addReusableInputUnboxingExprs(inputTerm, Int.MinValue, parsed)
+
+        parsed
+    }
+  }
+}
diff --git 
a/flink-table/flink-table-planner/src/main/scala/org/apache/flink/table/planner/codegen/calls/JsonQueryCallGen.scala
 
b/flink-table/flink-table-planner/src/main/scala/org/apache/flink/table/planner/codegen/calls/JsonQueryCallGen.scala
index 2b386ac1fb7..5eb269ff4f8 100644
--- 
a/flink-table/flink-table-planner/src/main/scala/org/apache/flink/table/planner/codegen/calls/JsonQueryCallGen.scala
+++ 
b/flink-table/flink-table-planner/src/main/scala/org/apache/flink/table/planner/codegen/calls/JsonQueryCallGen.scala
@@ -21,7 +21,6 @@ import org.apache.flink.table.api.{JsonQueryOnEmptyOrError, 
JsonQueryWrapper, Js
 import org.apache.flink.table.planner.codegen.{CodeGeneratorContext, 
CodeGenException, CodeGenUtils, GeneratedExpression}
 import org.apache.flink.table.planner.codegen.CodeGenUtils.{qualifyEnum, 
qualifyMethod, BINARY_STRING, GENERIC_ARRAY}
 import 
org.apache.flink.table.planner.codegen.GenerateUtils.generateCallWithStmtIfArgsNotNull
-import org.apache.flink.table.runtime.functions.SqlJsonUtils
 import 
org.apache.flink.table.runtime.functions.SqlJsonUtils.JsonQueryReturnType
 import org.apache.flink.table.types.logical.{ArrayType, LogicalType, 
LogicalTypeRoot}
 
@@ -41,14 +40,22 @@ import org.apache.calcite.sql.SqlJsonEmptyOrError
  * }}}
  * generates code similar to:
  * {{{
- * // member variable (declared once)
+ * // members (declared once)
  * SqlJsonUtils.JsonValueContext jsonParsed$0;
+ * BinaryStringData jsonParsedInput$1;
  *
- * // in processElement (parse emitted only by the first function)
- * jsonParsed$0 = SqlJsonUtils.jsonParse(field$0.toString());
- * Object rawResult$1 = SqlJsonUtils.jsonValue(jsonParsed$0, "$.type", ...);
- * // second call reuses jsonParsed$123 without re-parsing
- * Object rawResult$2 = SqlJsonUtils.jsonQuery(jsonParsed$0, "$.address", ...);
+ * // parses once per input value and reuses the result for the same input
+ * private SqlJsonUtils.JsonValueContext parseJson$2(BinaryStringData in) {
+ *   if (in != jsonParsedInput$1) {
+ *     jsonParsedInput$1 = in;
+ *     jsonParsed$0 = SqlJsonUtils.jsonParse(in.toString());
+ *   }
+ *   return jsonParsed$0;
+ * }
+ *
+ * // whichever call runs first parses, the other ones reuse the result
+ * Object rawResult$3 = SqlJsonUtils.jsonValue(parseJson$2(field$0), "$.type", 
...);
+ * Object rawResult$4 = SqlJsonUtils.jsonQuery(parseJson$2(field$0), 
"$.address", ...);
  * }}}
  */
 class JsonQueryCallGen extends CallGenerator {
@@ -57,6 +64,8 @@ class JsonQueryCallGen extends CallGenerator {
       operands: Seq[GeneratedExpression],
       returnType: LogicalType): GeneratedExpression = {
 
+    val parsed = JsonParseReuse.parseSharedInput(ctx, operands)
+
     generateCallWithStmtIfArgsNotNull(ctx, returnType, operands, 
resultNullable = true) {
       argTerms =>
         {
@@ -68,26 +77,8 @@ class JsonQueryCallGen extends CallGenerator {
           } else {
             JsonQueryReturnType.STRING
           }
-          val inputTerm = s"${argTerms.head}.toString()"
-
-          val (varName, parseCode) =
-            ctx.getReusableInputUnboxingExprs(inputTerm, Int.MinValue) match {
-              case Some(expr) => (expr.resultTerm, "")
-              case None =>
-                val newVarName = CodeGenUtils.newName(ctx, "jsonParsed")
-                val typeName = classOf[SqlJsonUtils.JsonValueContext].getName
-                ctx.addReusableMember(s"$typeName $newVarName;")
-                ctx.addReusableInputUnboxingExprs(
-                  inputTerm,
-                  Int.MinValue,
-                  GeneratedExpression(newVarName, "false", "", null))
-                val assign =
-                  s"$newVarName = 
${qualifyMethod(BuiltInMethods.JSON_PARSE)}($inputTerm);"
-                (newVarName, assign)
-            }
-
           val terms = Seq(
-            varName,
+            parsed.resultTerm,
             s"${argTerms(1)}.toString()",
             qualifyEnum(jsonQueryReturnType),
             qualifyEnum(wrapperBehavior),
@@ -97,7 +88,6 @@ class JsonQueryCallGen extends CallGenerator {
 
           val rawResultTerm = CodeGenUtils.newName(ctx, "rawResult")
           val call = s"""
-                        |$parseCode
                         |Object $rawResultTerm =
                         |    
${qualifyMethod(BuiltInMethods.JSON_QUERY_PARSED)}(${terms
                          .mkString(", ")});
diff --git 
a/flink-table/flink-table-planner/src/main/scala/org/apache/flink/table/planner/codegen/calls/JsonValueCallGen.scala
 
b/flink-table/flink-table-planner/src/main/scala/org/apache/flink/table/planner/codegen/calls/JsonValueCallGen.scala
index f28b60e85e1..321a6d072c3 100644
--- 
a/flink-table/flink-table-planner/src/main/scala/org/apache/flink/table/planner/codegen/calls/JsonValueCallGen.scala
+++ 
b/flink-table/flink-table-planner/src/main/scala/org/apache/flink/table/planner/codegen/calls/JsonValueCallGen.scala
@@ -21,7 +21,6 @@ import org.apache.flink.table.api.JsonValueOnEmptyOrError
 import org.apache.flink.table.planner.codegen.{CodeGeneratorContext, 
CodeGenException, CodeGenUtils, GeneratedExpression}
 import org.apache.flink.table.planner.codegen.CodeGenUtils.{qualifyEnum, 
qualifyMethod, BINARY_STRING}
 import 
org.apache.flink.table.planner.codegen.GenerateUtils.generateCallWithStmtIfArgsNotNull
-import org.apache.flink.table.runtime.functions.SqlJsonUtils
 import org.apache.flink.table.types.logical.{LogicalType, LogicalTypeRoot}
 
 import org.apache.calcite.sql.SqlJsonEmptyOrError
@@ -41,14 +40,22 @@ import org.apache.calcite.sql.SqlJsonEmptyOrError
  * }}}
  * generates code similar to:
  * {{{
- * // member variable (declared once)
+ * // members (declared once)
  * SqlJsonUtils.JsonValueContext jsonParsed$0;
+ * BinaryStringData jsonParsedInput$1;
  *
- * // in processElement (parse emitted only by the first function)
- * jsonParsed$0 = SqlJsonUtils.jsonParse(field$0.toString());
- * Object rawResult$1 = SqlJsonUtils.jsonValue(jsonParsed$0, "$.type", ...);
- * // second call reuses jsonParsed$0 without re-parsing
- * Object rawResult$2 = SqlJsonUtils.jsonValue(jsonParsed$0, "$.age", ...);
+ * // parses once per input value and reuses the result for the same input
+ * private SqlJsonUtils.JsonValueContext parseJson$2(BinaryStringData in) {
+ *   if (in != jsonParsedInput$1) {
+ *     jsonParsedInput$1 = in;
+ *     jsonParsed$0 = SqlJsonUtils.jsonParse(in.toString());
+ *   }
+ *   return jsonParsed$0;
+ * }
+ *
+ * // whichever call runs first parses, the other ones reuse the result
+ * Object rawResult$3 = SqlJsonUtils.jsonValue(parseJson$2(field$0), "$.type", 
...);
+ * Object rawResult$4 = SqlJsonUtils.jsonValue(parseJson$2(field$0), "$.age", 
...);
  * }}}
  */
 class JsonValueCallGen extends CallGenerator {
@@ -57,31 +64,16 @@ class JsonValueCallGen extends CallGenerator {
       operands: Seq[GeneratedExpression],
       returnType: LogicalType): GeneratedExpression = {
 
+    val parsed = JsonParseReuse.parseSharedInput(ctx, operands)
+
     generateCallWithStmtIfArgsNotNull(ctx, returnType, operands, 
resultNullable = true) {
       argTerms =>
         {
           val emptyBehavior = getBehavior(operands, SqlJsonEmptyOrError.EMPTY)
           val errorBehavior = getBehavior(operands, SqlJsonEmptyOrError.ERROR)
-          val inputTerm = s"${argTerms.head}.toString()"
-
-          val (varName, parseCode) =
-            ctx.getReusableInputUnboxingExprs(inputTerm, Int.MinValue) match {
-              case Some(expr) => (expr.resultTerm, "")
-              case None =>
-                val newVarName = CodeGenUtils.newName(ctx, "jsonParsed")
-                val typeName = classOf[SqlJsonUtils.JsonValueContext].getName
-                ctx.addReusableMember(s"$typeName $newVarName;")
-                ctx.addReusableInputUnboxingExprs(
-                  inputTerm,
-                  Int.MinValue,
-                  GeneratedExpression(newVarName, "false", "", null))
-                val assign =
-                  s"$newVarName = 
${qualifyMethod(BuiltInMethods.JSON_PARSE)}($inputTerm);"
-                (newVarName, assign)
-            }
 
           val terms = Seq(
-            varName,
+            parsed.resultTerm,
             s"${argTerms(1)}.toString()",
             qualifyEnum(emptyBehavior._1),
             emptyBehavior._2,
@@ -91,7 +83,6 @@ class JsonValueCallGen extends CallGenerator {
 
           val rawResultTerm = CodeGenUtils.newName(ctx, "rawResult")
           val call = s"""
-                        |$parseCode
                         |Object $rawResultTerm =
                         |    
${qualifyMethod(BuiltInMethods.JSON_VALUE)}(${terms
                          .mkString(", ")});
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 a1591841b5b..61acdd86eea 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
@@ -23,19 +23,28 @@ import 
org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
 import org.apache.flink.streaming.api.transformations.OneInputTransformation;
 import org.apache.flink.table.api.EnvironmentSettings;
 import org.apache.flink.table.api.Table;
+import org.apache.flink.table.api.TableEnvironment;
 import org.apache.flink.table.api.TableResult;
 import org.apache.flink.table.api.bridge.java.StreamTableEnvironment;
+import org.apache.flink.table.api.config.ExecutionConfigOptions;
+import org.apache.flink.table.api.config.OptimizerConfigOptions;
+import org.apache.flink.table.api.config.TableConfigOptions;
+import org.apache.flink.table.codesplit.JavaCodeSplitter;
 import org.apache.flink.table.planner.codegen.calls.BuiltInMethods;
+import org.apache.flink.table.planner.factories.TestValuesTableFactory;
 import org.apache.flink.table.runtime.operators.CodeGenOperatorFactory;
 import org.apache.flink.types.Row;
 
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 
+import java.time.Instant;
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.List;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
+import java.util.stream.Collectors;
 
 import static org.assertj.core.api.Assertions.assertThat;
 
@@ -80,10 +89,10 @@ class JsonParseReuseTest {
         return count;
     }
 
-    private String extractGeneratedCode(final String sql) {
+    private List<String> generatedClassCodes(final String sql) {
         final Table table = tEnv.sqlQuery(sql);
         final Transformation<?> root = 
tEnv.toChangelogStream(table).getTransformation();
-        final StringBuilder allCode = new StringBuilder();
+        final List<String> codes = new ArrayList<>();
         for (final Transformation<?> t : root.getTransitivePredecessors()) {
             if (t instanceof OneInputTransformation
                     && ((OneInputTransformation<?, ?>) t).getOperatorFactory()
@@ -91,10 +100,14 @@ class JsonParseReuseTest {
                 final CodeGenOperatorFactory<?> factory =
                         (CodeGenOperatorFactory<?>)
                                 ((OneInputTransformation<?, ?>) 
t).getOperatorFactory();
-                allCode.append(factory.getGeneratedClass().getCode());
+                codes.add(factory.getGeneratedClass().getCode());
             }
         }
-        return allCode.toString();
+        return codes;
+    }
+
+    private String extractGeneratedCode(final String sql) {
+        return String.join("", generatedClassCodes(sql));
     }
 
     @Test
@@ -103,9 +116,10 @@ class JsonParseReuseTest {
                 "SELECT JSON_VALUE(json_data, '$.type'), JSON_VALUE(json_data, 
'$.age') FROM json_src";
         final List<Row> rows = collect(sql);
         assertThat(rows).containsExactlyInAnyOrder(Row.of("account", "42"), 
Row.of("admin", "30"));
-        assertThat(countJsonParse(extractGeneratedCode(sql)))
+        final String code = extractGeneratedCode(sql);
+        assertThat(countJsonParse(code))
                 .as("Two JSON_VALUE calls on the same input should parse once")
-                .isEqualTo(1);
+                .isOne();
     }
 
     @Test
@@ -118,9 +132,10 @@ class JsonParseReuseTest {
                 .containsExactlyInAnyOrder(
                         Row.of("{\"city\":\"Munich\"}", 
"[[\"user\",\"viewer\"]]"),
                         Row.of("{\"city\":\"Berlin\"}", "[[\"admin\"]]"));
-        assertThat(countJsonParse(extractGeneratedCode(sql)))
+        final String code = extractGeneratedCode(sql);
+        assertThat(countJsonParse(code))
                 .as("Two JSON_QUERY calls on the same input should parse once")
-                .isEqualTo(1);
+                .isOne();
     }
 
     @Test
@@ -133,9 +148,10 @@ class JsonParseReuseTest {
                 .containsExactlyInAnyOrder(
                         Row.of("account", "{\"city\":\"Munich\"}"),
                         Row.of("admin", "{\"city\":\"Berlin\"}"));
-        assertThat(countJsonParse(extractGeneratedCode(sql)))
+        final String code = extractGeneratedCode(sql);
+        assertThat(countJsonParse(code))
                 .as("JSON_VALUE + JSON_QUERY on the same input should parse 
once")
-                .isEqualTo(1);
+                .isOne();
     }
 
     @Test
@@ -149,9 +165,110 @@ class JsonParseReuseTest {
                 .containsExactlyInAnyOrder(
                         Row.of("account", "42", "{\"city\":\"Munich\"}"),
                         Row.of("admin", "30", "{\"city\":\"Berlin\"}"));
-        assertThat(countJsonParse(extractGeneratedCode(sql)))
+        final String code = extractGeneratedCode(sql);
+        assertThat(countJsonParse(code))
                 .as("Three JSON function calls on the same input should parse 
once")
-                .isEqualTo(1);
+                .isOne();
+    }
+
+    @Test
+    void testReuseSurvivesCodeSplitting() {
+        // an aggressive split must still route the input into each parseJson 
call site
+        tEnv.getConfig().set(TableConfigOptions.MAX_LENGTH_GENERATED_CODE, 1);
+        final String sql =
+                "SELECT JSON_VALUE(json_data, '$.type'), "
+                        + "JSON_VALUE(json_data, '$.age'), "
+                        + "JSON_QUERY(json_data, '$.address'), "
+                        + "JSON_QUERY(json_data, '$.roles' WITH WRAPPER) FROM 
json_src";
+        // GeneratedClass compiles splitCode, so correct results already prove 
reuse survives it
+        final List<Row> rows = collect(sql);
+        assertThat(rows)
+                .containsExactlyInAnyOrder(
+                        Row.of("account", "42", "{\"city\":\"Munich\"}", 
"[[\"user\",\"viewer\"]]"),
+                        Row.of("admin", "30", "{\"city\":\"Berlin\"}", 
"[[\"admin\"]]"));
+
+        // assert on the split code, not getCode() (unsplit), since only 
splitCode is compiled
+        final int maxLength = 
tEnv.getConfig().get(TableConfigOptions.MAX_LENGTH_GENERATED_CODE);
+        final int maxMembers = 
tEnv.getConfig().get(TableConfigOptions.MAX_MEMBERS_GENERATED_CODE);
+        final List<String> splitCodes =
+                generatedClassCodes(sql).stream()
+                        .map(code -> JavaCodeSplitter.split(code, maxLength, 
maxMembers))
+                        .collect(Collectors.toList());
+        assertThat(generatedClassCodes(sql))
+                .as("the aggressive limit must actually split some generated 
class")
+                .anySatisfy(
+                        code ->
+                                assertThat(JavaCodeSplitter.split(code, 
maxLength, maxMembers))
+                                        .isNotEqualTo(code));
+        
assertThat(splitCodes.stream().mapToInt(JsonParseReuseTest::countJsonParse).sum())
+                .as("Even in the split code the input is parsed once")
+                .isOne();
+    }
+
+    @Test
+    void testComputedColumnInputSharesParse() {
+        // calls on the same computed input (TRIM) must still share a parse
+        final String sql =
+                "SELECT JSON_VALUE(TRIM(json_data), '$.type'), "
+                        + "JSON_QUERY(TRIM(json_data), '$.address') FROM 
json_src";
+        final List<Row> rows = collect(sql);
+        assertThat(rows)
+                .containsExactlyInAnyOrder(
+                        Row.of("account", "{\"city\":\"Munich\"}"),
+                        Row.of("admin", "{\"city\":\"Berlin\"}"));
+        final String code = extractGeneratedCode(sql);
+        assertThat(countJsonParse(code))
+                .as("Calls on the same computed input should parse once")
+                .isOne();
+    }
+
+    @Test
+    void testFirstCallWithNullArgumentStillParses() {
+        // first call's args are null so its result is NULL, but the parse 
must still happen
+        final String sql =
+                "SELECT JSON_VALUE(json_data, CAST(NULL AS STRING)), "
+                        + "JSON_QUERY(json_data, '$.address') FROM json_src";
+        final List<Row> rows = collect(sql);
+        assertThat(rows)
+                .containsExactlyInAnyOrder(
+                        Row.of(null, "{\"city\":\"Munich\"}"),
+                        Row.of(null, "{\"city\":\"Berlin\"}"));
+        final String code = extractGeneratedCode(sql);
+        assertThat(countJsonParse(code))
+                .as("JSON_VALUE + JSON_QUERY on the same input should parse 
once")
+                .isOne();
+    }
+
+    @Test
+    void testFirstCallInsideNotTakenBranchStillParses() {
+        // The first JSON call owns the parse but sits in a CASE branch that 
is never taken.
+        final String sql =
+                "SELECT CASE WHEN json_data IS NULL "
+                        + "THEN JSON_VALUE(json_data, '$.type') ELSE 
'fallback' END, "
+                        + "JSON_QUERY(json_data, '$.address') FROM json_src";
+        final List<Row> rows = collect(sql);
+        assertThat(rows)
+                .containsExactlyInAnyOrder(
+                        Row.of("fallback", "{\"city\":\"Munich\"}"),
+                        Row.of("fallback", "{\"city\":\"Berlin\"}"));
+    }
+
+    @Test
+    void testCallInsideSurvivingBranchSharesWithCallOutside() {
+        // branch guarded by an unrelated column survives the optimizer, yet 
both rows share a parse
+        final String sql =
+                "SELECT CASE WHEN CHARACTER_LENGTH(other_json) > 3 "
+                        + "THEN JSON_VALUE(json_data, '$.type') ELSE 'fb' END, 
"
+                        + "JSON_QUERY(json_data, '$.address') FROM json_src";
+        final List<Row> rows = collect(sql);
+        assertThat(rows)
+                .containsExactlyInAnyOrder(
+                        Row.of("fb", "{\"city\":\"Munich\"}"),
+                        Row.of("admin", "{\"city\":\"Berlin\"}"));
+        final String code = extractGeneratedCode(sql);
+        assertThat(countJsonParse(code))
+                .as("Calls on the same input share one parse even across a 
CASE boundary")
+                .isOne();
     }
 
     @Test
@@ -165,4 +282,92 @@ class JsonParseReuseTest {
                 .as("JSON_VALUE calls on different inputs should parse 
separately")
                 .isEqualTo(2);
     }
+
+    @Test
+    void testReuseInOverWindowQueryIsResetPerRow() {
+        // JSON scalars run in a Calc ahead of the OverAggregate; each row 
must get its own parse
+        final TableEnvironment bEnv = 
TableEnvironment.create(EnvironmentSettings.inBatchMode());
+        bEnv.createTemporaryView(
+                "over_src",
+                bEnv.fromValues(Row.of(1, JSON_ROW1), Row.of(2, 
JSON_ROW2)).as("id", "j"));
+        final String sql =
+                "SELECT id, "
+                        + "MAX(JSON_VALUE(j, '$.type')) OVER (ORDER BY id ROWS 
BETWEEN CURRENT ROW AND CURRENT ROW), "
+                        + "MAX(JSON_QUERY(j, '$.address')) OVER (ORDER BY id 
ROWS BETWEEN CURRENT ROW AND CURRENT ROW) "
+                        + "FROM over_src";
+        final List<Row> rows = new ArrayList<>();
+        bEnv.executeSql(sql).collect().forEachRemaining(rows::add);
+        assertThat(rows)
+                .containsExactlyInAnyOrder(
+                        Row.of(1, "account", "{\"city\":\"Munich\"}"),
+                        Row.of(2, "admin", "{\"city\":\"Berlin\"}"));
+    }
+
+    @Test
+    void testFilterAndProjectionShareParse() {
+        // JSON_VALUE in WHERE and JSON_QUERY in SELECT on the same input 
share one parse
+        final String sql =
+                "SELECT JSON_QUERY(json_data, '$.address') FROM json_src "
+                        + "WHERE JSON_VALUE(json_data, '$.type') = 'admin'";
+        final List<Row> rows = collect(sql);
+        assertThat(rows).containsExactly(Row.of("{\"city\":\"Berlin\"}"));
+        final String code = extractGeneratedCode(sql);
+        assertThat(countJsonParse(code))
+                .as("Filter and projection on the same input should parse 
once")
+                .isOne();
+    }
+
+    @Test
+    void testReuseIsResetPerRowInMatchRecognize() {
+        // A matches the first three rows; the per-row parse must give SUM 
1+2+3, not 1+1+1
+        final List<Row> data =
+                Arrays.asList(
+                        Row.of(1000, "{\"n\":1}", Instant.ofEpochMilli(1000L)),
+                        Row.of(2000, "{\"n\":2}", Instant.ofEpochMilli(2000L)),
+                        Row.of(3000, "{\"n\":3}", Instant.ofEpochMilli(3000L)),
+                        Row.of(9000, "{\"n\":9}", 
Instant.ofEpochMilli(9000L)));
+        final String dataId = TestValuesTableFactory.registerData(data);
+        tEnv.executeSql(
+                "CREATE TABLE events ("
+                        + "  f0 INT,"
+                        + "  f1 STRING,"
+                        + "  ts TIMESTAMP_LTZ(3),"
+                        + "  WATERMARK FOR ts AS ts"
+                        + ") WITH ("
+                        + "  'connector' = 'values',"
+                        + "  'data-id' = '"
+                        + dataId
+                        + "',"
+                        + "  'bounded' = 'true')");
+        final String sql =
+                "SELECT total FROM events MATCH_RECOGNIZE ("
+                        + " ORDER BY ts"
+                        + " MEASURES SUM(CAST(JSON_VALUE(A.f1, '$.n') AS INT)) 
AS total"
+                        + " AFTER MATCH SKIP PAST LAST ROW"
+                        + " PATTERN (A+ B)"
+                        + " DEFINE A AS A.f0 < 9000, B AS B.f0 >= 9000)";
+        final List<Row> rows = collect(sql);
+        assertThat(rows).containsExactly(Row.of(6));
+    }
+
+    @Test
+    void testReuseIsResetPerRowInBatchFusion() {
+        // a projection Calc is only fused on top of a HashJoin, so force one 
(disable the rest)
+        final TableEnvironment bEnv = 
TableEnvironment.create(EnvironmentSettings.inBatchMode());
+        bEnv.getConfig()
+                
.set(ExecutionConfigOptions.TABLE_EXEC_OPERATOR_FUSION_CODEGEN_ENABLED, true)
+                .set(
+                        ExecutionConfigOptions.TABLE_EXEC_DISABLED_OPERATORS,
+                        "NestedLoopJoin,SortMergeJoin")
+                
.set(OptimizerConfigOptions.TABLE_OPTIMIZER_BROADCAST_JOIN_THRESHOLD, -1L);
+        bEnv.createTemporaryView(
+                "src", bEnv.fromValues(Row.of(1, JSON_ROW1), Row.of(2, 
JSON_ROW2)).as("id", "j"));
+        bEnv.createTemporaryView("dim", bEnv.fromValues(Row.of(1), 
Row.of(2)).as("id"));
+        final String sql =
+                "SELECT JSON_VALUE(src.j, '$.type'), JSON_VALUE(src.j, 
'$.age') "
+                        + "FROM src JOIN dim ON src.id = dim.id";
+        final List<Row> rows = new ArrayList<>();
+        bEnv.executeSql(sql).collect().forEachRemaining(rows::add);
+        assertThat(rows).containsExactlyInAnyOrder(Row.of("account", "42"), 
Row.of("admin", "30"));
+    }
 }

Reply via email to