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

gianm pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/druid.git


The following commit(s) were added to refs/heads/master by this push:
     new b6d638b067e fix: Properly compute virtual column chains post-join. 
(#20538)
b6d638b067e is described below

commit b6d638b067ed68fc624826e6f12aec08e89d8aa0
Author: Gian Merlino <[email protected]>
AuthorDate: Thu Oct 8 22:33:09 2026 -0700

    fix: Properly compute virtual column chains post-join. (#20538)
    
    Previously, when separating virtual columns into pre- and post-join
    sets, we would only look one layer deep. This patch changes the
    logic to look through multiple layers, so chains of virtual columns
    that depend on post-join inputs are properly moved post-join in
    their entirety.
---
 .../segment/join/HashJoinSegmentCursorFactory.java |  2 +-
 .../druid/segment/join/filter/JoinableClauses.java | 39 ++++++++-
 .../join/HashJoinSegmentCursorFactoryTest.java     | 96 ++++++++++++++++++++++
 .../druid/sql/calcite/CalciteJoinQueryTest.java    | 49 +++++++++++
 4 files changed, 183 insertions(+), 3 deletions(-)

diff --git 
a/processing/src/main/java/org/apache/druid/segment/join/HashJoinSegmentCursorFactory.java
 
b/processing/src/main/java/org/apache/druid/segment/join/HashJoinSegmentCursorFactory.java
index a7030fae546..e952520b608 100644
--- 
a/processing/src/main/java/org/apache/druid/segment/join/HashJoinSegmentCursorFactory.java
+++ 
b/processing/src/main/java/org/apache/druid/segment/join/HashJoinSegmentCursorFactory.java
@@ -231,7 +231,7 @@ public class HashJoinSegmentCursorFactory implements 
CursorFactory
         Iterables.concat(
             Sets.difference(
                 
ImmutableSet.copyOf(spec.getVirtualColumns().getVirtualColumns()),
-                joinFilterPreAnalysis.getPostJoinVirtualColumns()
+                actualPreAnalysis.getPostJoinVirtualColumns()
             ),
             joinFilterSplit.getPushDownVirtualColumns()
         )
diff --git 
a/processing/src/main/java/org/apache/druid/segment/join/filter/JoinableClauses.java
 
b/processing/src/main/java/org/apache/druid/segment/join/filter/JoinableClauses.java
index 5774bab5708..c8afbdeccad 100644
--- 
a/processing/src/main/java/org/apache/druid/segment/join/filter/JoinableClauses.java
+++ 
b/processing/src/main/java/org/apache/druid/segment/join/filter/JoinableClauses.java
@@ -32,8 +32,10 @@ import javax.annotation.Nonnull;
 import javax.annotation.Nullable;
 import java.util.ArrayList;
 import java.util.Collection;
+import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
+import java.util.Map;
 import java.util.Optional;
 import java.util.Set;
 import java.util.stream.Collectors;
@@ -91,15 +93,17 @@ public class JoinableClauses
   }
 
   /**
-   * Retrieve subset of virtual columns which require inputs which are only 
present on the join table
+   * Retrieve subset of virtual columns which require inputs which are only 
present on the join table, either
+   * directly or through other virtual columns.
    */
   public Set<VirtualColumn> getPostJoinVirtualColumns(
       final VirtualColumns virtualColumns
   )
   {
     final Set<VirtualColumn> postJoinVirtualColumns = new HashSet<>();
+    final Map<String, Boolean> postJoinByName = new HashMap<>();
     for (VirtualColumn virtualColumn : virtualColumns.getVirtualColumns()) {
-      if (areSomeColumnsFromJoin(virtualColumn.requiredColumns())) {
+      if (isPostJoinVirtualColumn(virtualColumn, virtualColumns, 
postJoinByName)) {
         postJoinVirtualColumns.add(virtualColumn);
       }
     }
@@ -132,6 +136,37 @@ public class JoinableClauses
     return null;
   }
 
+  /**
+   * Whether a virtual column requires a join table column, directly or 
through other virtual columns in
+   * {@code virtualColumns}. Memoizes results in {@code postJoinByName}. 
Recursion terminates because
+   * {@link VirtualColumns} rejects dependency cycles.
+   */
+  private boolean isPostJoinVirtualColumn(
+      final VirtualColumn virtualColumn,
+      final VirtualColumns virtualColumns,
+      final Map<String, Boolean> postJoinByName
+  )
+  {
+    final Boolean memoized = postJoinByName.get(virtualColumn.getOutputName());
+    if (memoized != null) {
+      return memoized;
+    }
+
+    boolean postJoin = areSomeColumnsFromJoin(virtualColumn.requiredColumns());
+    if (!postJoin) {
+      for (String column : virtualColumn.requiredColumns()) {
+        final VirtualColumn dependency = 
virtualColumns.getVirtualColumn(column);
+        if (dependency != null && isPostJoinVirtualColumn(dependency, 
virtualColumns, postJoinByName)) {
+          postJoin = true;
+          break;
+        }
+      }
+    }
+
+    postJoinByName.put(virtualColumn.getOutputName(), postJoin);
+    return postJoin;
+  }
+
   private static void checkPreJoinableClausesForDuplicatesAndShadowing(
       final List<PreJoinableClause> preJoinableClauses
   )
diff --git 
a/processing/src/test/java/org/apache/druid/segment/join/HashJoinSegmentCursorFactoryTest.java
 
b/processing/src/test/java/org/apache/druid/segment/join/HashJoinSegmentCursorFactoryTest.java
index aa9fb0eab3c..60e89bff6b0 100644
--- 
a/processing/src/test/java/org/apache/druid/segment/join/HashJoinSegmentCursorFactoryTest.java
+++ 
b/processing/src/test/java/org/apache/druid/segment/join/HashJoinSegmentCursorFactoryTest.java
@@ -39,6 +39,7 @@ import org.apache.druid.segment.CursorHolder;
 import org.apache.druid.segment.DeferredCursorFactory;
 import org.apache.druid.segment.ReferenceCountedSegmentProvider;
 import org.apache.druid.segment.TopNOptimizationInspector;
+import org.apache.druid.segment.VirtualColumn;
 import org.apache.druid.segment.VirtualColumns;
 import org.apache.druid.segment.column.ColumnCapabilities;
 import org.apache.druid.segment.column.ValueType;
@@ -1453,6 +1454,101 @@ public class HashJoinSegmentCursorFactoryTest extends 
BaseHashJoinSegmentCursorF
     );
   }
 
+  @Test
+  public void 
test_makeCursor_factToCountryInnerUsingChainedPostJoinVirtualColumns()
+  {
+    // "v1" refers to the join table only through "v0", so it must be computed 
post-join along with "v0".
+    List<JoinableClause> joinableClauses = 
ImmutableList.of(factToCountryOnIsoCode(JoinType.INNER));
+
+    VirtualColumns virtualColumns = VirtualColumns.create(
+        makeExpressionVirtualColumn(
+            StringUtils.format("upper(\"%scountryName\")", 
FACT_TO_COUNTRY_ON_ISO_CODE_PREFIX),
+            "v0"
+        ),
+        makeExpressionVirtualColumn("concat(\"v0\", '!')", "v1")
+    );
+
+    Filter filter = new SelectorDimFilter("v1", "CANADA!", null).toFilter();
+
+    JoinFilterPreAnalysis joinFilterPreAnalysis = makeDefaultConfigPreAnalysis(
+        filter,
+        joinableClauses,
+        virtualColumns
+    );
+    JoinTestHelper.verifyCursor(
+        new HashJoinSegmentCursorFactory(
+            factSegment.as(CursorFactory.class),
+            null,
+            joinableClauses,
+            joinFilterPreAnalysis
+        ).makeCursorHolder(
+            
CursorBuildSpec.builder().setFilter(filter).setVirtualColumns(virtualColumns).build()
+        ),
+        ImmutableList.of(
+            "page",
+            "countryIsoCode",
+            "v0",
+            "v1"
+        ),
+        ImmutableList.of(
+            new Object[]{"Didier Leclair", "CA", "CANADA", "CANADA!"},
+            new Object[]{"Les Argonautes", "CA", "CANADA", "CANADA!"},
+            new Object[]{"Sarah Michelle Gellar", "CA", "CANADA", "CANADA!"}
+        )
+    );
+  }
+
+  @Test
+  public void 
test_makeCursor_factToCountryInnerUsingVirtualColumnsThatDifferFromPreAnalysis()
+  {
+    // The pre-analysis sees "v1" as post-join, through "v0". The cursor build 
spec redefines "v0" on the base table,
+    // making "v1" pre-join, so the cursor must classify virtual columns using 
its own spec, not the pre-analysis.
+    List<JoinableClause> joinableClauses = 
ImmutableList.of(factToCountryOnIsoCode(JoinType.INNER));
+
+    Filter filter = new SelectorDimFilter("countryIsoCode", "CA", 
null).toFilter();
+
+    VirtualColumn v1 = makeExpressionVirtualColumn("concat(\"v0\", '!')", 
"v1");
+
+    JoinFilterPreAnalysis joinFilterPreAnalysis = makeDefaultConfigPreAnalysis(
+        filter,
+        joinableClauses,
+        VirtualColumns.create(
+            makeExpressionVirtualColumn(
+                StringUtils.format("upper(\"%scountryName\")", 
FACT_TO_COUNTRY_ON_ISO_CODE_PREFIX),
+                "v0"
+            ),
+            v1
+        )
+    );
+
+    VirtualColumns virtualColumns = VirtualColumns.create(
+        makeExpressionVirtualColumn("upper(\"countryIsoCode\")", "v0"),
+        v1
+    );
+
+    JoinTestHelper.verifyCursor(
+        new HashJoinSegmentCursorFactory(
+            factSegment.as(CursorFactory.class),
+            null,
+            joinableClauses,
+            joinFilterPreAnalysis
+        ).makeCursorHolder(
+            
CursorBuildSpec.builder().setFilter(filter).setVirtualColumns(virtualColumns).build()
+        ),
+        ImmutableList.of(
+            "page",
+            "countryIsoCode",
+            "v0",
+            "v1"
+        ),
+        ImmutableList.of(
+            new Object[]{"Didier Leclair", "CA", "CA", "CA!"},
+            new Object[]{"Les Argonautes", "CA", "CA", "CA!"},
+            new Object[]{"Sarah Michelle Gellar", "CA", "CA", "CA!"}
+        )
+    );
+  }
+
   @Test
   public void test_makeCursor_factToCountryUsingExpression()
   {
diff --git 
a/sql/src/test/java/org/apache/druid/sql/calcite/CalciteJoinQueryTest.java 
b/sql/src/test/java/org/apache/druid/sql/calcite/CalciteJoinQueryTest.java
index f7819cf9d81..8d9c8c579bc 100644
--- a/sql/src/test/java/org/apache/druid/sql/calcite/CalciteJoinQueryTest.java
+++ b/sql/src/test/java/org/apache/druid/sql/calcite/CalciteJoinQueryTest.java
@@ -81,6 +81,8 @@ import org.apache.druid.segment.column.ColumnType;
 import org.apache.druid.segment.column.RowSignature;
 import org.apache.druid.segment.join.JoinType;
 import org.apache.druid.segment.virtual.ListFilteredVirtualColumn;
+import org.apache.druid.segment.virtual.NestedFieldVirtualColumn;
+import org.apache.druid.segment.virtual.NestedObjectVirtualColumn;
 import org.apache.druid.server.QueryLifecycle;
 import org.apache.druid.server.security.AuthorizationResult;
 import org.apache.druid.sql.calcite.DecoupledTestConfig.IgnoreQueriesReason;
@@ -1628,6 +1630,53 @@ public class CalciteJoinQueryTest extends 
BaseCalciteQueryTest
     );
   }
 
+  @MethodSource("provideQueryContexts")
+  @ParameterizedTest(name = "{0}")
+  public void testInnerJoinLookupWithChainedPostJoinVirtualColumns(Map<String, 
Object> queryContext)
+  {
+    // JSON_VALUE of JSON_OBJECT plans into two virtual columns: "v1" reads 
the joined column "j0.v", and "v0" reads
+    // only "v1". Both must be computed after the join.
+    testQuery(
+        "SELECT dim1, dim2, JSON_VALUE(JSON_OBJECT(KEY 'x' VALUE l.v), 
'$.x')\n"
+        + "FROM foo\n"
+        + "INNER JOIN lookup.lookyloo l ON foo.dim2 = l.k",
+        queryContext,
+        ImmutableList.of(
+            newScanQueryBuilder()
+                .dataSource(
+                    join(
+                        new TableDataSource(CalciteTests.DATASOURCE1),
+                        new LookupDataSource("lookyloo"),
+                        "j0.",
+                        equalsCondition(makeColumnExpression("dim2"), 
makeColumnExpression("j0.k")),
+                        JoinType.INNER
+                    )
+                )
+                .intervals(querySegmentSpec(Filtration.eternity()))
+                .virtualColumns(
+                    new NestedFieldVirtualColumn("v1", "$.x", "v0", 
ColumnType.STRING),
+                    new NestedObjectVirtualColumn(
+                        "v1",
+                        ImmutableMap.of(
+                            "x",
+                            new 
NestedObjectVirtualColumn.TypedExpression("\"j0.v\"", ColumnType.STRING)
+                        ),
+                        queryFramework().macroTable()
+                    )
+                )
+                .columns("dim1", "dim2", "v0")
+                .columnTypes(ColumnType.STRING, ColumnType.STRING, 
ColumnType.STRING)
+                .context(queryContext)
+                .build()
+        ),
+        ImmutableList.of(
+            new Object[]{"", "a", "xa"},
+            new Object[]{"1", "a", "xa"},
+            new Object[]{"def", "abc", "xabc"}
+        )
+    );
+  }
+
   @MethodSource("provideQueryContexts")
   @ParameterizedTest(name = "{0}")
   public void testInnerJoinTwoLookupsToTableUsingNumericColumn(Map<String, 
Object> queryContext)


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to