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

clintropolis 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 7f02bce9602 perf: optimize some multi-value string operators to allow 
planning to specialized ListFilteredVirtualColumn without extractionFn (#20469)
7f02bce9602 is described below

commit 7f02bce9602e2ccb75dd260f196721476776bb6a
Author: Clint Wylie <[email protected]>
AuthorDate: Wed Oct 21 04:37:18 2026 -0700

    perf: optimize some multi-value string operators to allow planning to 
specialized ListFilteredVirtualColumn without extractionFn (#20469)
---
 .../druid/benchmark/query/SqlBaseBenchmark.java    |  10 +-
 .../query/SqlMultiValueStringFilterBenchmark.java  | 236 ++++++++++++
 .../query/dimension/ListFilteredDimensionSpec.java |  26 +-
 .../segment/virtual/ListFilteredVirtualColumn.java |  40 ++-
 .../virtual/PrefixFilteredVirtualColumn.java       |  10 +-
 .../virtual/RegexFilteredVirtualColumn.java        |  10 +-
 ...FilteredDimensionSpecDimensionSelectorTest.java |  59 +++
 .../ListFilteredVirtualColumnSelectorTest.java     | 109 ++++++
 .../virtual/ListFilteredVirtualColumnTest.java     |  23 ++
 .../virtual/PrefixFilteredVirtualColumnTest.java   |  22 ++
 .../virtual/RegexFilteredVirtualColumnTest.java    |  22 ++
 .../sql/calcite/expression/DruidExpression.java    |  19 +
 .../MultiValueStringOperatorConversions.java       | 224 ++++++++----
 .../calcite/CalciteMultiValueStringQueryTest.java  | 396 ++++++++++++++++++++-
 14 files changed, 1100 insertions(+), 106 deletions(-)

diff --git 
a/benchmarks/src/test/java/org/apache/druid/benchmark/query/SqlBaseBenchmark.java
 
b/benchmarks/src/test/java/org/apache/druid/benchmark/query/SqlBaseBenchmark.java
index bdd196618ac..3c0ed62c817 100644
--- 
a/benchmarks/src/test/java/org/apache/druid/benchmark/query/SqlBaseBenchmark.java
+++ 
b/benchmarks/src/test/java/org/apache/druid/benchmark/query/SqlBaseBenchmark.java
@@ -237,6 +237,14 @@ public class SqlBaseBenchmark
     return context;
   }
 
+  /**
+   * Additional lookups, by name, to make available to queries via LOOKUP
+   */
+  protected Map<String, LookupExtractor> getLookups()
+  {
+    return Collections.emptyMap();
+  }
+
   protected IndexSpec getIndexSpec()
   {
     return IndexSpec.builder()
@@ -331,7 +339,7 @@ public class SqlBaseBenchmark
     final Pair<PlannerFactory, SqlEngine> sqlSystem = createSqlSystem(
         segments,
         realtimeSegments,
-        Collections.emptyMap(),
+        getLookups(),
         storageType,
         closer
     );
diff --git 
a/benchmarks/src/test/java/org/apache/druid/benchmark/query/SqlMultiValueStringFilterBenchmark.java
 
b/benchmarks/src/test/java/org/apache/druid/benchmark/query/SqlMultiValueStringFilterBenchmark.java
new file mode 100644
index 00000000000..fba88fb2ddf
--- /dev/null
+++ 
b/benchmarks/src/test/java/org/apache/druid/benchmark/query/SqlMultiValueStringFilterBenchmark.java
@@ -0,0 +1,236 @@
+/*
+ * 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.druid.benchmark.query;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Maps;
+import org.apache.druid.java.util.common.Pair;
+import org.apache.druid.java.util.common.StringUtils;
+import org.apache.druid.query.QueryContexts;
+import org.apache.druid.query.lookup.ImmutableLookupMap;
+import org.apache.druid.query.lookup.LookupExtractor;
+import org.apache.druid.sql.calcite.planner.PlannerConfig;
+import org.apache.druid.sql.calcite.planner.PlannerContext;
+import org.openjdk.jmh.annotations.Fork;
+import org.openjdk.jmh.annotations.Measurement;
+import org.openjdk.jmh.annotations.Param;
+import org.openjdk.jmh.annotations.Scope;
+import org.openjdk.jmh.annotations.State;
+import org.openjdk.jmh.annotations.Warmup;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Benchmark for the multi-value string filtering functions MV_FILTER_ONLY, 
MV_FILTER_NONE, MV_FILTER_REGEX, and
+ * MV_FILTER_PREFIX applied to expressions (LOOKUP, SUBSTRING, REGEXP_EXTRACT) 
of multi-value string columns, comparing
+ * the plain expression plan with the specialized filtering virtual column 
plan (with an expression virtual column
+ * delegate), and with the extractionFn plan used when {@link 
PlannerContext#CTX_SQL_USE_EXTRACTION_FNS} is set.
+ */
+@State(Scope.Benchmark)
+@Fork(value = 1)
+@Warmup(iterations = 3)
+@Measurement(iterations = 5)
+public class SqlMultiValueStringFilterBenchmark extends SqlBaseQueryBenchmark
+{
+  private static final String ENUM_LOOKUP = "enum-lookup";
+  private static final String NUMBER_LOOKUP = "number-lookup";
+  private static final int NUMBER_LOOKUP_KEYS = 1_000_000;
+  private static final int NUMBER_LOOKUP_VALUES = 1_000;
+
+  private static final List<Pair<String, String>> QUERIES = ImmutableList.of(
+      // 0: low cardinality multi-value string column (5 values)
+      Pair.of(
+          SqlBenchmarkDatasets.BASIC,
+          "SELECT MV_FILTER_ONLY(LOOKUP(dimMultivalEnumerated, 'enum-lookup'), 
ARRAY['greeting']), COUNT(*) "
+          + "FROM basic GROUP BY 1"
+      ),
+      // 1:
+      Pair.of(
+          SqlBenchmarkDatasets.BASIC,
+          "SELECT MV_FILTER_NONE(LOOKUP(dimMultivalEnumerated, 'enum-lookup'), 
ARRAY['greeting']), COUNT(*) "
+          + "FROM basic GROUP BY 1"
+      ),
+      // 2:
+      Pair.of(
+          SqlBenchmarkDatasets.BASIC,
+          "SELECT MV_FILTER_REGEX(LOOKUP(dimMultivalEnumerated, 
'enum-lookup'), '^g.*'), COUNT(*) "
+          + "FROM basic GROUP BY 1"
+      ),
+      // 3:
+      Pair.of(
+          SqlBenchmarkDatasets.BASIC,
+          "SELECT MV_FILTER_PREFIX(LOOKUP(dimMultivalEnumerated, 
'enum-lookup'), 'g'), COUNT(*) "
+          + "FROM basic GROUP BY 1"
+      ),
+      // 4:
+      Pair.of(
+          SqlBenchmarkDatasets.BASIC,
+          "SELECT COUNT(*) FROM basic "
+          + "WHERE MV_FILTER_ONLY(LOOKUP(dimMultivalEnumerated, 
'enum-lookup'), ARRAY['greeting']) = 'greeting'"
+      ),
+      // 5: high cardinality multi-value string columns (1M values)
+      Pair.of(
+          SqlBenchmarkDatasets.GROUPER,
+          "SELECT MV_FILTER_ONLY(LOOKUP(\"multi-string-Uniform-1_000_000\", 
'number-lookup'), ARRAY['bucket-1', 'bucket-2']), COUNT(*) "
+          + "FROM grouper GROUP BY 1"
+      ),
+      // 6:
+      Pair.of(
+          SqlBenchmarkDatasets.GROUPER,
+          "SELECT MV_FILTER_ONLY(LOOKUP(\"multi-string-ZipF-1_000_000\", 
'number-lookup'), ARRAY['bucket-1', 'bucket-2']), COUNT(*) "
+          + "FROM grouper GROUP BY 1"
+      ),
+      // 7:
+      Pair.of(
+          SqlBenchmarkDatasets.GROUPER,
+          "SELECT MV_FILTER_PREFIX(LOOKUP(\"multi-string-Uniform-1_000_000\", 
'number-lookup'), 'bucket-1'), COUNT(*) "
+          + "FROM grouper GROUP BY 1"
+      ),
+      // 8:
+      Pair.of(
+          SqlBenchmarkDatasets.GROUPER,
+          "SELECT COUNT(*) FROM grouper "
+          + "WHERE MV_FILTER_ONLY(LOOKUP(\"multi-string-Uniform-1_000_000\", 
'number-lookup'), ARRAY['bucket-1']) = 'bucket-1'"
+      ),
+      // 9: other functions which plan to an extractionFn when 
sqlUseExtractionFns is set
+      Pair.of(
+          SqlBenchmarkDatasets.BASIC,
+          "SELECT MV_FILTER_ONLY(SUBSTRING(dimMultivalEnumerated, 1, 1), 
ARRAY['B']), COUNT(*) "
+          + "FROM basic GROUP BY 1"
+      ),
+      // 10:
+      Pair.of(
+          SqlBenchmarkDatasets.BASIC,
+          "SELECT MV_FILTER_ONLY(REGEXP_EXTRACT(dimMultivalEnumerated, 
'^[A-Z][a-z]'), ARRAY['Ba']), COUNT(*) "
+          + "FROM basic GROUP BY 1"
+      ),
+      // 11:
+      Pair.of(
+          SqlBenchmarkDatasets.GROUPER,
+          "SELECT MV_FILTER_ONLY(SUBSTRING(\"multi-string-Uniform-1_000_000\", 
1, 2), ARRAY['12', '34']), COUNT(*) "
+          + "FROM grouper GROUP BY 1"
+      ),
+      // 12:
+      Pair.of(
+          SqlBenchmarkDatasets.GROUPER,
+          "SELECT 
MV_FILTER_ONLY(REGEXP_EXTRACT(\"multi-string-Uniform-1_000_000\", '[0-9]{2}$'), 
ARRAY['12', '34']), COUNT(*) "
+          + "FROM grouper GROUP BY 1"
+      )
+  );
+
+  @Param({
+      "0",
+      "1",
+      "2",
+      "3",
+      "4",
+      "5",
+      "6",
+      "7",
+      "8",
+      "9",
+      "10",
+      "11",
+      "12"
+  })
+  private String query;
+
+  /**
+   * expression: plain expression virtual columns, forced with {@link 
PlannerConfig#CTX_KEY_FORCE_EXPRESSION_VIRTUAL_COLUMNS}
+   * specialized: default planning, specialized filtering virtual columns with 
an expression virtual column delegate
+   * extractionFn: specialized filtering virtual columns with an extractionFn 
delegate dimension spec
+   */
+  @Param({
+      "expression",
+      "specialized",
+      "extractionFn"
+  })
+  private String plan;
+
+  @Override
+  public String getQuery()
+  {
+    return QUERIES.get(Integer.parseInt(query)).rhs;
+  }
+
+  @Override
+  public List<String> getDatasources()
+  {
+    return Collections.singletonList(QUERIES.get(Integer.parseInt(query)).lhs);
+  }
+
+  @Override
+  protected Map<String, Object> getContext()
+  {
+    final ImmutableMap.Builder<String, Object> builder =
+        ImmutableMap.<String, Object>builder().putAll(super.getContext());
+    switch (plan) {
+      case "expression":
+        builder.put(PlannerConfig.CTX_KEY_FORCE_EXPRESSION_VIRTUAL_COLUMNS, 
true);
+        break;
+      case "extractionFn":
+        builder.put(PlannerContext.CTX_SQL_USE_EXTRACTION_FNS, true);
+        break;
+      default:
+        break;
+    }
+    return builder.build();
+  }
+
+  @Override
+  protected Map<String, LookupExtractor> getLookups()
+  {
+    // many-to-one lookups, so filtered values include duplicates
+    final Map<String, String> enumLookup = ImmutableMap.of(
+        "Hello", "greeting",
+        "World", "greeting",
+        "Foo", "placeholder",
+        "Bar", "placeholder",
+        "Baz", "placeholder"
+    );
+    final Map<String, String> numberLookup = 
Maps.newHashMapWithExpectedSize(NUMBER_LOOKUP_KEYS + 1);
+    for (int i = 0; i <= NUMBER_LOOKUP_KEYS; i++) {
+      numberLookup.put(String.valueOf(i), "bucket-" + (i % 
NUMBER_LOOKUP_VALUES));
+    }
+    return ImmutableMap.of(
+        ENUM_LOOKUP,
+        ImmutableLookupMap.fromMap(enumLookup).asLookupExtractor(false, () -> 
StringUtils.toUtf8(ENUM_LOOKUP)),
+        NUMBER_LOOKUP,
+        ImmutableLookupMap.fromMap(numberLookup).asLookupExtractor(false, () 
-> StringUtils.toUtf8(NUMBER_LOOKUP))
+    );
+  }
+
+  @Override
+  protected void checkIncompatibleParameters()
+  {
+    // none of the plans can be vectorized
+    if (QueryContexts.Vectorize.FORCE.equals(vectorizeContext)) {
+      System.exit(0);
+    }
+    // auto schema ingests multi-value strings as arrays, but this benchmark 
is about multi-value strings
+    if ("auto".equals(schemaType)) {
+      System.exit(0);
+    }
+    super.checkIncompatibleParameters();
+  }
+}
diff --git 
a/processing/src/main/java/org/apache/druid/query/dimension/ListFilteredDimensionSpec.java
 
b/processing/src/main/java/org/apache/druid/query/dimension/ListFilteredDimensionSpec.java
index fdf73c17ab6..06f09f2e2e7 100644
--- 
a/processing/src/main/java/org/apache/druid/query/dimension/ListFilteredDimensionSpec.java
+++ 
b/processing/src/main/java/org/apache/druid/query/dimension/ListFilteredDimensionSpec.java
@@ -76,9 +76,9 @@ public class ListFilteredDimensionSpec extends 
BaseFilteredDimensionSpec
     }
 
     if (isWhitelist) {
-      return filterAllowList(values, selector, delegate.getExtractionFn() != 
null);
+      return filterAllowList(values, selector);
     } else {
-      return filterDenyList(values, selector, delegate.getExtractionFn() != 
null);
+      return filterDenyList(values, selector);
     }
   }
 
@@ -89,19 +89,21 @@ public class ListFilteredDimensionSpec extends 
BaseFilteredDimensionSpec
       IndexedGetter<String> fn
   )
   {
-    final IdMapping.Builder builder = 
IdMapping.Builder.ofCardinality(values.size());
     if (idLookup != null) {
+      final IdMapping.Builder builder = 
IdMapping.Builder.ofCardinality(values.size());
       for (String value : values) {
         int i = idLookup.lookupId(value);
         if (i >= 0) {
           builder.addMapping(i);
         }
       }
-    } else {
-      for (int i = 0; i < cardinality; i++) {
-        if (values.contains(fn.get(i))) {
-          builder.addMapping(i);
-        }
+      return builder.build();
+    }
+
+    final IdMapping.Builder builder = IdMapping.Builder.ofUnknownCardinality();
+    for (int i = 0; i < cardinality; i++) {
+      if (values.contains(fn.get(i))) {
+        builder.addForwardMapping(i);
       }
     }
     return builder.build();
@@ -122,9 +124,9 @@ public class ListFilteredDimensionSpec extends 
BaseFilteredDimensionSpec
     return builder.build();
   }
 
-  public static DimensionSelector filterAllowList(Set<String> values, 
DimensionSelector selector, boolean forcePredicateFilter)
+  public static DimensionSelector filterAllowList(Set<String> values, 
DimensionSelector selector)
   {
-    if (forcePredicateFilter || selector.getValueCardinality() < 0 || 
!selector.nameLookupPossibleInAdvance()) {
+    if (selector.getValueCardinality() < 0 || 
!selector.nameLookupPossibleInAdvance()) {
       return new PredicateFilteredDimensionSelector(selector, 
Predicates.in(values));
     }
     final IdMapping idMapping = buildAllowListIdMapping(
@@ -136,9 +138,9 @@ public class ListFilteredDimensionSpec extends 
BaseFilteredDimensionSpec
     return new ForwardingFilteredDimensionSelector(selector, idMapping);
   }
 
-  public static DimensionSelector filterDenyList(Set<String> values, 
DimensionSelector selector, boolean forcePredicateFilter)
+  public static DimensionSelector filterDenyList(Set<String> values, 
DimensionSelector selector)
   {
-    if (forcePredicateFilter || selector.getValueCardinality() < 0 || 
!selector.nameLookupPossibleInAdvance()) {
+    if (selector.getValueCardinality() < 0 || 
!selector.nameLookupPossibleInAdvance()) {
       return new PredicateFilteredDimensionSelector(
           selector,
           input -> !values.contains(input)
diff --git 
a/processing/src/main/java/org/apache/druid/segment/virtual/ListFilteredVirtualColumn.java
 
b/processing/src/main/java/org/apache/druid/segment/virtual/ListFilteredVirtualColumn.java
index 41b58b04a39..b24c1b7c837 100644
--- 
a/processing/src/main/java/org/apache/druid/segment/virtual/ListFilteredVirtualColumn.java
+++ 
b/processing/src/main/java/org/apache/druid/segment/virtual/ListFilteredVirtualColumn.java
@@ -142,17 +142,9 @@ public class ListFilteredVirtualColumn implements 
VirtualColumn
   )
   {
     if (allowList) {
-      return ListFilteredDimensionSpec.filterAllowList(
-          values,
-          factory.makeDimensionSelector(delegate),
-          delegate.getExtractionFn() != null
-      );
+      return ListFilteredDimensionSpec.filterAllowList(values, 
factory.makeDimensionSelector(delegate));
     } else {
-      return ListFilteredDimensionSpec.filterDenyList(
-          values,
-          factory.makeDimensionSelector(delegate),
-          delegate.getExtractionFn() != null
-      );
+      return ListFilteredDimensionSpec.filterDenyList(values, 
factory.makeDimensionSelector(delegate));
     }
   }
 
@@ -170,14 +162,20 @@ public class ListFilteredVirtualColumn implements 
VirtualColumn
   {
     return new ColumnCapabilitiesImpl().setType(delegate.getOutputType())
                                        .setDictionaryEncoded(true)
-                                       .setHasBitmapIndexes(true);
+                                       .setHasBitmapIndexes(true)
+                                       .setHasMultipleValues(true);
   }
 
   @Nullable
   @Override
   public ColumnCapabilities capabilities(ColumnInspector inspector, String 
columnName)
   {
-    return inspector.getColumnCapabilities(delegate.getDimension());
+    final ColumnCapabilities delegateCapabilities = 
inspector.getColumnCapabilities(delegate.getDimension());
+    if (delegateCapabilities == null) {
+      return null;
+    }
+    // filtering can leave a row with no values, so even if the delegate is 
single-valued this column is not
+    return 
ColumnCapabilitiesImpl.copyOf(delegateCapabilities).setHasMultipleValues(true).setHasNulls(true);
   }
 
   @Override
@@ -301,17 +299,29 @@ public class ListFilteredVirtualColumn implements 
VirtualColumn
       IdMapping idMapping
   )
   {
-    final int start = delegateIndex.getValue(idMapping.getReverseId(0)) == 
null ? 1 : 0;
     return getBitmapsInRange(
         delegateIndex,
         idMapping,
         DruidObjectPredicate.alwaysTrue(),
-        start,
+        getFirstNonNullIndex(delegateIndex, idMapping),
         idMapping.getValueCardinality(),
         false
     );
   }
 
+  /**
+   * Returns the first filtered dictionary index which is not null. Values are 
sorted with null first, so this is 1 if
+   * the filtered dictionary starts with null, otherwise 0, including when the 
filtered dictionary is empty because
+   * none of the values matched.
+   */
+  private static int getFirstNonNullIndex(DictionaryEncodedStringValueIndex 
delegateIndex, IdMapping idMapping)
+  {
+    if (idMapping.getValueCardinality() == 0) {
+      return 0;
+    }
+    return delegateIndex.getValue(idMapping.getReverseId(0)) == null ? 1 : 0;
+  }
+
   private static Iterable<ImmutableBitmap> getBitmapsInRange(
       DictionaryEncodedStringValueIndex delegate,
       IdMapping idMapping,
@@ -662,7 +672,7 @@ public class ListFilteredVirtualColumn implements 
VirtualColumn
     )
     {
       int startIndex, endIndex;
-      final int firstValue = delegate.getValue(idMapping.getReverseId(0)) == 
null ? 1 : 0;
+      final int firstValue = getFirstNonNullIndex(delegate, idMapping);
       if (startValue == null) {
         startIndex = firstValue;
       } else {
diff --git 
a/processing/src/main/java/org/apache/druid/segment/virtual/PrefixFilteredVirtualColumn.java
 
b/processing/src/main/java/org/apache/druid/segment/virtual/PrefixFilteredVirtualColumn.java
index 5b201edc528..49a656eb469 100644
--- 
a/processing/src/main/java/org/apache/druid/segment/virtual/PrefixFilteredVirtualColumn.java
+++ 
b/processing/src/main/java/org/apache/druid/segment/virtual/PrefixFilteredVirtualColumn.java
@@ -116,14 +116,20 @@ public class PrefixFilteredVirtualColumn implements 
VirtualColumn
     return new ColumnCapabilitiesImpl()
         .setType(delegate.getOutputType())
         .setDictionaryEncoded(true)
-        .setHasBitmapIndexes(true);
+        .setHasBitmapIndexes(true)
+        .setHasMultipleValues(true);
   }
 
   @Nullable
   @Override
   public ColumnCapabilities capabilities(ColumnInspector inspector, String 
columnName)
   {
-    return inspector.getColumnCapabilities(delegate.getDimension());
+    final ColumnCapabilities delegateCapabilities = 
inspector.getColumnCapabilities(delegate.getDimension());
+    if (delegateCapabilities == null) {
+      return null;
+    }
+    // filtering can leave a row with no values, so even if the delegate is 
single-valued this column is not
+    return 
ColumnCapabilitiesImpl.copyOf(delegateCapabilities).setHasMultipleValues(true).setHasNulls(true);
   }
 
   @Override
diff --git 
a/processing/src/main/java/org/apache/druid/segment/virtual/RegexFilteredVirtualColumn.java
 
b/processing/src/main/java/org/apache/druid/segment/virtual/RegexFilteredVirtualColumn.java
index e4c6a8f7327..dcc68c5ae2d 100644
--- 
a/processing/src/main/java/org/apache/druid/segment/virtual/RegexFilteredVirtualColumn.java
+++ 
b/processing/src/main/java/org/apache/druid/segment/virtual/RegexFilteredVirtualColumn.java
@@ -116,14 +116,20 @@ public class RegexFilteredVirtualColumn implements 
VirtualColumn
     return new ColumnCapabilitiesImpl()
         .setType(delegate.getOutputType())
         .setDictionaryEncoded(true)
-        .setHasBitmapIndexes(true);
+        .setHasBitmapIndexes(true)
+        .setHasMultipleValues(true);
   }
 
   @Nullable
   @Override
   public ColumnCapabilities capabilities(ColumnInspector inspector, String 
columnName)
   {
-    return inspector.getColumnCapabilities(delegate.getDimension());
+    final ColumnCapabilities delegateCapabilities = 
inspector.getColumnCapabilities(delegate.getDimension());
+    if (delegateCapabilities == null) {
+      return null;
+    }
+    // filtering can leave a row with no values, so even if the delegate is 
single-valued this column is not
+    return 
ColumnCapabilitiesImpl.copyOf(delegateCapabilities).setHasMultipleValues(true).setHasNulls(true);
   }
 
   @Override
diff --git 
a/processing/src/test/java/org/apache/druid/query/dimension/ListFilteredDimensionSpecDimensionSelectorTest.java
 
b/processing/src/test/java/org/apache/druid/query/dimension/ListFilteredDimensionSpecDimensionSelectorTest.java
index 907dae70b48..37753cf43c4 100644
--- 
a/processing/src/test/java/org/apache/druid/query/dimension/ListFilteredDimensionSpecDimensionSelectorTest.java
+++ 
b/processing/src/test/java/org/apache/druid/query/dimension/ListFilteredDimensionSpecDimensionSelectorTest.java
@@ -27,8 +27,11 @@ import it.unimi.dsi.fastutil.objects.Object2IntMap;
 import it.unimi.dsi.fastutil.objects.Object2IntOpenHashMap;
 import org.apache.commons.lang3.mutable.MutableInt;
 import org.apache.druid.java.util.common.NonnullPair;
+import org.apache.druid.math.expr.ExprMacroTable;
+import org.apache.druid.math.expr.Parser;
 import org.apache.druid.segment.DimensionSelector;
 import org.apache.druid.segment.data.IndexedInts;
+import 
org.apache.druid.segment.virtual.SingleStringInputDeferredEvaluationExpressionDimensionSelector;
 import org.apache.druid.testing.InitializedNullHandlingTest;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.Test;
@@ -109,6 +112,62 @@ public class 
ListFilteredDimensionSpecDimensionSelectorTest extends InitializedN
     testDenyList(false, true, false, PredicateFilteredDimensionSelector.class);
   }
 
+  @Test
+  public void testAllowListWithDuplicateDictionaryValues()
+  {
+    RowSupplier rowSupplier = new RowSupplier();
+    DimensionSelector selector = ListFilteredDimensionSpec.filterAllowList(
+        ImmutableSet.of("val2"),
+        makeDuplicateValueSelector(rowSupplier)
+    );
+    Assertions.assertSame(ForwardingFilteredDimensionSelector.class, 
selector.getClass());
+    Assertions.assertNull(selector.idLookup());
+    Assertions.assertEquals(3, selector.getValueCardinality());
+
+    rowSupplier.set(data.get(0));
+    Assertions.assertEquals(0, selector.getRow().size());
+
+    rowSupplier.set(data.get(1));
+    Assertions.assertEquals(ImmutableList.of("val2", "val2", "val2"), 
selector.getObject());
+    Assertions.assertTrue(selector.makeValueMatcher("val2").matches(false));
+
+    rowSupplier.set(data.get(2));
+    Assertions.assertEquals(0, selector.getRow().size());
+    Assertions.assertFalse(selector.makeValueMatcher("val3").matches(false));
+  }
+
+  @Test
+  public void testDenyListWithDuplicateDictionaryValues()
+  {
+    RowSupplier rowSupplier = new RowSupplier();
+    DimensionSelector selector = ListFilteredDimensionSpec.filterDenyList(
+        ImmutableSet.of("val2"),
+        makeDuplicateValueSelector(rowSupplier)
+    );
+    Assertions.assertSame(ForwardingFilteredDimensionSelector.class, 
selector.getClass());
+    Assertions.assertNull(selector.idLookup());
+    Assertions.assertEquals(3, selector.getValueCardinality());
+
+    rowSupplier.set(data.get(0));
+    Assertions.assertEquals(ImmutableList.of("val1", "val1"), 
selector.getObject());
+
+    rowSupplier.set(data.get(1));
+    Assertions.assertEquals(0, selector.getRow().size());
+    Assertions.assertFalse(selector.makeValueMatcher("val2").matches(false));
+
+    rowSupplier.set(data.get(2));
+    Assertions.assertEquals("val3", selector.getObject());
+  }
+
+  private DimensionSelector makeDuplicateValueSelector(RowSupplier rowSupplier)
+  {
+    NonnullPair<Object2IntMap<String>, Int2ObjectMap<String>> dictionaries = 
createDictionaries(data);
+    return new SingleStringInputDeferredEvaluationExpressionDimensionSelector(
+        new StringDimensionSelectorForTest(rowSupplier, dictionaries.lhs, 
dictionaries.rhs, false, true, true),
+        Parser.parse("substring(\"foo\", 0, 4)", ExprMacroTable.nil())
+    );
+  }
+
   private void testAllowList(
       boolean unknownCardinality,
       boolean validIdLookup,
diff --git 
a/processing/src/test/java/org/apache/druid/segment/virtual/ListFilteredVirtualColumnSelectorTest.java
 
b/processing/src/test/java/org/apache/druid/segment/virtual/ListFilteredVirtualColumnSelectorTest.java
index a9d2ca1f76f..bb9de3ab19a 100644
--- 
a/processing/src/test/java/org/apache/druid/segment/virtual/ListFilteredVirtualColumnSelectorTest.java
+++ 
b/processing/src/test/java/org/apache/druid/segment/virtual/ListFilteredVirtualColumnSelectorTest.java
@@ -27,13 +27,19 @@ import org.apache.druid.collections.bitmap.ImmutableBitmap;
 import org.apache.druid.data.input.MapBasedRow;
 import org.apache.druid.java.util.common.io.Closer;
 import org.apache.druid.query.dimension.DefaultDimensionSpec;
+import org.apache.druid.query.expression.TestExprMacroTable;
 import org.apache.druid.query.filter.ColumnIndexSelector;
 import org.apache.druid.segment.ColumnCache;
 import org.apache.druid.segment.ColumnValueSelector;
+import org.apache.druid.segment.Cursor;
+import org.apache.druid.segment.CursorBuildSpec;
+import org.apache.druid.segment.CursorHolder;
 import org.apache.druid.segment.DimensionSelector;
 import org.apache.druid.segment.QueryableIndex;
+import org.apache.druid.segment.QueryableIndexCursorFactory;
 import org.apache.druid.segment.RowAdapters;
 import org.apache.druid.segment.RowBasedColumnSelectorFactory;
+import org.apache.druid.segment.TestIndex;
 import org.apache.druid.segment.VirtualColumns;
 import org.apache.druid.segment.column.BaseColumnHolder;
 import org.apache.druid.segment.column.ColumnCapabilities;
@@ -43,14 +49,20 @@ import org.apache.druid.segment.column.ColumnIndexSupplier;
 import org.apache.druid.segment.column.ColumnType;
 import org.apache.druid.segment.column.RowSignature;
 import org.apache.druid.segment.column.ValueType;
+import org.apache.druid.segment.data.IndexedInts;
 import org.apache.druid.segment.filter.SelectorFilter;
 import 
org.apache.druid.segment.index.semantic.DictionaryEncodedStringValueIndex;
+import org.apache.druid.segment.index.semantic.DruidPredicateIndexes;
+import org.apache.druid.segment.index.semantic.NullValueIndex;
+import org.apache.druid.segment.index.semantic.StringValueSetIndexes;
 import org.apache.druid.testing.InitializedNullHandlingTest;
 import org.easymock.EasyMock;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.Test;
 
 import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
 
 public class ListFilteredVirtualColumnSelectorTest extends 
InitializedNullHandlingTest
 {
@@ -284,6 +296,103 @@ public class ListFilteredVirtualColumnSelectorTest 
extends InitializedNullHandli
     }
   }
 
+  @Test
+  public void testListFilteredVirtualColumnExpressionDelegate() throws 
IOException
+  {
+    // expression maps many dictionary values to the same value, so the 
delegate selector has duplicate names
+    final ExpressionVirtualColumn expressionVirtualColumn = new 
ExpressionVirtualColumn(
+        "expr",
+        "if(placementish == 'preferred', 'p', 'x')",
+        ColumnType.STRING,
+        TestExprMacroTable.INSTANCE
+    );
+    final ListFilteredVirtualColumn allowVirtualColumn = new 
ListFilteredVirtualColumn(
+        ALLOW_VIRTUAL_NAME,
+        DefaultDimensionSpec.of("expr"),
+        ImmutableSet.of("x"),
+        true
+    );
+    final ListFilteredVirtualColumn denyVirtualColumn = new 
ListFilteredVirtualColumn(
+        DENY_VIRTUAL_NAME,
+        DefaultDimensionSpec.of("expr"),
+        ImmutableSet.of("x"),
+        false
+    );
+    final VirtualColumns virtualColumns = VirtualColumns.create(
+        expressionVirtualColumn,
+        allowVirtualColumn,
+        denyVirtualColumn
+    );
+    final QueryableIndex index = TestIndex.getMMappedTestIndex();
+
+    try (final Closer closer = Closer.create()) {
+      final CursorHolder cursorHolder = closer.register(
+          new QueryableIndexCursorFactory(index).makeCursorHolder(
+              
CursorBuildSpec.builder().setVirtualColumns(virtualColumns).build()
+          )
+      );
+      final Cursor cursor = cursorHolder.asCursor();
+      final DimensionSelector baseSelector =
+          
cursor.getColumnSelectorFactory().makeDimensionSelector(DefaultDimensionSpec.of("placementish"));
+      final DimensionSelector allowSelector =
+          
cursor.getColumnSelectorFactory().makeDimensionSelector(DefaultDimensionSpec.of(ALLOW_VIRTUAL_NAME));
+      final DimensionSelector denySelector =
+          
cursor.getColumnSelectorFactory().makeDimensionSelector(DefaultDimensionSpec.of(DENY_VIRTUAL_NAME));
+
+      // delegate is dictionary backed, so the filtered selector can remap 
dictionary ids instead of evaluating the
+      // expression for every row
+      Assertions.assertEquals(
+          
"org.apache.druid.query.dimension.ForwardingFilteredDimensionSelector",
+          allowSelector.getClass().getName()
+      );
+      Assertions.assertEquals(
+          
"org.apache.druid.query.dimension.ForwardingFilteredDimensionSelector",
+          denySelector.getClass().getName()
+      );
+      Assertions.assertNull(allowSelector.idLookup());
+
+      int rows = 0;
+      while (!cursor.isDone()) {
+        final List<String> expectedAllow = new ArrayList<>();
+        final List<String> expectedDeny = new ArrayList<>();
+        final IndexedInts baseRow = baseSelector.getRow();
+        for (int i = 0; i < baseRow.size(); i++) {
+          if ("preferred".equals(baseSelector.lookupName(baseRow.get(i)))) {
+            expectedDeny.add("p");
+          } else {
+            expectedAllow.add("x");
+          }
+        }
+        Assertions.assertEquals(expectedAllow, lookupRow(allowSelector));
+        Assertions.assertEquals(expectedDeny, lookupRow(denySelector));
+        Assertions.assertEquals(!expectedAllow.isEmpty(), 
allowSelector.makeValueMatcher("x").matches(false));
+        Assertions.assertEquals(!expectedDeny.isEmpty(), 
denySelector.makeValueMatcher("p").matches(false));
+        rows++;
+        cursor.advance();
+      }
+      Assertions.assertEquals(index.getNumRows(), rows);
+
+      // the expression virtual column does not provide a dictionary index, so 
there are no list filtered indexes
+      final ColumnIndexSelector indexSelector = new ColumnCache(index, 
virtualColumns, closer);
+      final ColumnIndexSupplier indexSupplier = 
indexSelector.getIndexSupplier(ALLOW_VIRTUAL_NAME);
+      Assertions.assertNotNull(indexSupplier);
+      
Assertions.assertNull(indexSupplier.as(DictionaryEncodedStringValueIndex.class));
+      Assertions.assertNull(indexSupplier.as(StringValueSetIndexes.class));
+      Assertions.assertNull(indexSupplier.as(DruidPredicateIndexes.class));
+      Assertions.assertNull(indexSupplier.as(NullValueIndex.class));
+    }
+  }
+
+  private static List<String> lookupRow(DimensionSelector selector)
+  {
+    final IndexedInts row = selector.getRow();
+    final List<String> values = new ArrayList<>(row.size());
+    for (int i = 0; i < row.size(); i++) {
+      values.add(selector.lookupName(row.get(i)));
+    }
+    return values;
+  }
+
   private void assertCapabilities(VirtualizedColumnSelectorFactory 
selectorFactory, String columnName)
   {
     ColumnCapabilities capabilities = 
selectorFactory.getColumnCapabilities(columnName);
diff --git 
a/processing/src/test/java/org/apache/druid/segment/virtual/ListFilteredVirtualColumnTest.java
 
b/processing/src/test/java/org/apache/druid/segment/virtual/ListFilteredVirtualColumnTest.java
index c50c175394f..c5312243f3f 100644
--- 
a/processing/src/test/java/org/apache/druid/segment/virtual/ListFilteredVirtualColumnTest.java
+++ 
b/processing/src/test/java/org/apache/druid/segment/virtual/ListFilteredVirtualColumnTest.java
@@ -24,7 +24,10 @@ import com.fasterxml.jackson.databind.ObjectMapper;
 import com.google.common.collect.ImmutableSet;
 import nl.jqno.equalsverifier.EqualsVerifier;
 import org.apache.druid.query.dimension.DefaultDimensionSpec;
+import org.apache.druid.segment.ColumnInspector;
 import org.apache.druid.segment.TestHelper;
+import org.apache.druid.segment.column.ColumnCapabilities;
+import org.apache.druid.segment.column.ColumnCapabilitiesImpl;
 import org.apache.druid.segment.column.ColumnType;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.Test;
@@ -61,6 +64,26 @@ public class ListFilteredVirtualColumnTest
     Assertions.assertArrayEquals(virtualColumn.getCacheKey(), 
roundTrip.getCacheKey());
   }
 
+  @Test
+  public void testCapabilitiesSingleValuedDelegate()
+  {
+    // filtering can leave rows with no values, so the virtual column is 
multi-valued even if the delegate is not
+    ListFilteredVirtualColumn virtualColumn = new ListFilteredVirtualColumn(
+        "hello",
+        new DefaultDimensionSpec("column", "column", ColumnType.STRING),
+        ImmutableSet.of("foo", "bar"),
+        true
+    );
+    final ColumnInspector inspector =
+        column -> "column".equals(column) ? 
ColumnCapabilitiesImpl.createSimpleSingleValueStringColumnCapabilities() : null;
+    final ColumnCapabilities capabilities = 
virtualColumn.capabilities(inspector, "hello");
+    Assertions.assertEquals(ColumnType.STRING, capabilities.toColumnType());
+    Assertions.assertTrue(capabilities.hasMultipleValues().isTrue());
+    Assertions.assertTrue(capabilities.hasNulls().isTrue());
+    
Assertions.assertTrue(virtualColumn.capabilities("hello").hasMultipleValues().isTrue());
+    Assertions.assertNull(virtualColumn.capabilities(column -> null, "hello"));
+  }
+
   @Test
   public void testEqualsAndHashcode()
   {
diff --git 
a/processing/src/test/java/org/apache/druid/segment/virtual/PrefixFilteredVirtualColumnTest.java
 
b/processing/src/test/java/org/apache/druid/segment/virtual/PrefixFilteredVirtualColumnTest.java
index 881035eccac..78b92b58097 100644
--- 
a/processing/src/test/java/org/apache/druid/segment/virtual/PrefixFilteredVirtualColumnTest.java
+++ 
b/processing/src/test/java/org/apache/druid/segment/virtual/PrefixFilteredVirtualColumnTest.java
@@ -23,7 +23,10 @@ import com.fasterxml.jackson.core.JsonProcessingException;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import nl.jqno.equalsverifier.EqualsVerifier;
 import org.apache.druid.query.dimension.DefaultDimensionSpec;
+import org.apache.druid.segment.ColumnInspector;
 import org.apache.druid.segment.TestHelper;
+import org.apache.druid.segment.column.ColumnCapabilities;
+import org.apache.druid.segment.column.ColumnCapabilitiesImpl;
 import org.apache.druid.segment.column.ColumnType;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.Test;
@@ -45,6 +48,25 @@ public class PrefixFilteredVirtualColumnTest
     Assertions.assertArrayEquals(virtualColumn.getCacheKey(), 
roundTrip.getCacheKey());
   }
 
+  @Test
+  public void testCapabilitiesSingleValuedDelegate()
+  {
+    // filtering can leave rows with no values, so the virtual column is 
multi-valued even if the delegate is not
+    PrefixFilteredVirtualColumn virtualColumn = new 
PrefixFilteredVirtualColumn(
+        "hello",
+        new DefaultDimensionSpec("column", "column", ColumnType.STRING),
+        "a"
+    );
+    final ColumnInspector inspector =
+        column -> "column".equals(column) ? 
ColumnCapabilitiesImpl.createSimpleSingleValueStringColumnCapabilities() : null;
+    final ColumnCapabilities capabilities = 
virtualColumn.capabilities(inspector, "hello");
+    Assertions.assertEquals(ColumnType.STRING, capabilities.toColumnType());
+    Assertions.assertTrue(capabilities.hasMultipleValues().isTrue());
+    Assertions.assertTrue(capabilities.hasNulls().isTrue());
+    
Assertions.assertTrue(virtualColumn.capabilities("hello").hasMultipleValues().isTrue());
+    Assertions.assertNull(virtualColumn.capabilities(column -> null, "hello"));
+  }
+
   @Test
   public void testEqualsAndHashcode()
   {
diff --git 
a/processing/src/test/java/org/apache/druid/segment/virtual/RegexFilteredVirtualColumnTest.java
 
b/processing/src/test/java/org/apache/druid/segment/virtual/RegexFilteredVirtualColumnTest.java
index ded4b583584..364e94ebfc6 100644
--- 
a/processing/src/test/java/org/apache/druid/segment/virtual/RegexFilteredVirtualColumnTest.java
+++ 
b/processing/src/test/java/org/apache/druid/segment/virtual/RegexFilteredVirtualColumnTest.java
@@ -23,7 +23,10 @@ import com.fasterxml.jackson.core.JsonProcessingException;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import nl.jqno.equalsverifier.EqualsVerifier;
 import org.apache.druid.query.dimension.DefaultDimensionSpec;
+import org.apache.druid.segment.ColumnInspector;
 import org.apache.druid.segment.TestHelper;
+import org.apache.druid.segment.column.ColumnCapabilities;
+import org.apache.druid.segment.column.ColumnCapabilitiesImpl;
 import org.apache.druid.segment.column.ColumnType;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.Test;
@@ -45,6 +48,25 @@ public class RegexFilteredVirtualColumnTest
     Assertions.assertArrayEquals(virtualColumn.getCacheKey(), 
roundTrip.getCacheKey());
   }
 
+  @Test
+  public void testCapabilitiesSingleValuedDelegate()
+  {
+    // filtering can leave rows with no values, so the virtual column is 
multi-valued even if the delegate is not
+    RegexFilteredVirtualColumn virtualColumn = new RegexFilteredVirtualColumn(
+        "hello",
+        new DefaultDimensionSpec("column", "column", ColumnType.STRING),
+        "^.*abc$"
+    );
+    final ColumnInspector inspector =
+        column -> "column".equals(column) ? 
ColumnCapabilitiesImpl.createSimpleSingleValueStringColumnCapabilities() : null;
+    final ColumnCapabilities capabilities = 
virtualColumn.capabilities(inspector, "hello");
+    Assertions.assertEquals(ColumnType.STRING, capabilities.toColumnType());
+    Assertions.assertTrue(capabilities.hasMultipleValues().isTrue());
+    Assertions.assertTrue(capabilities.hasNulls().isTrue());
+    
Assertions.assertTrue(virtualColumn.capabilities("hello").hasMultipleValues().isTrue());
+    Assertions.assertNull(virtualColumn.capabilities(column -> null, "hello"));
+  }
+
   @Test
   public void testEqualsAndHashcode()
   {
diff --git 
a/sql/src/main/java/org/apache/druid/sql/calcite/expression/DruidExpression.java
 
b/sql/src/main/java/org/apache/druid/sql/calcite/expression/DruidExpression.java
index e5fa89ecd4c..6c2f2e526a3 100644
--- 
a/sql/src/main/java/org/apache/druid/sql/calcite/expression/DruidExpression.java
+++ 
b/sql/src/main/java/org/apache/druid/sql/calcite/expression/DruidExpression.java
@@ -505,6 +505,25 @@ public class DruidExpression
     );
   }
 
+  /**
+   * Returns a copy of this expression as {@link NodeType#SPECIALIZED} backed 
by a plain
+   * {@link ExpressionVirtualColumn}. When used as an argument of another 
{@link NodeType#SPECIALIZED} expression, the
+   * {@link org.apache.druid.sql.calcite.rel.VirtualColumnRegistry} will lift 
this expression into its own virtual
+   * column and replace the argument with a direct column reference to it, 
which allows the parent virtual column to
+   * reference it.
+   */
+  public DruidExpression asSpecialized()
+  {
+    return new DruidExpression(
+        NodeType.SPECIALIZED,
+        druidType,
+        simpleExtraction,
+        expressionGenerator,
+        arguments,
+        DEFAULT_VIRTUAL_COLUMN_BUILDER
+    );
+  }
+
   /**
    * Visit all sub {@link DruidExpression} (the {@link #arguments} of this 
expression), allowing the
    * {@link DruidExpressionShuttle} to potentially rewrite these arguments 
with new {@link DruidExpression}, finally
diff --git 
a/sql/src/main/java/org/apache/druid/sql/calcite/expression/builtin/MultiValueStringOperatorConversions.java
 
b/sql/src/main/java/org/apache/druid/sql/calcite/expression/builtin/MultiValueStringOperatorConversions.java
index 5b6b23f8224..4277903a13b 100644
--- 
a/sql/src/main/java/org/apache/druid/sql/calcite/expression/builtin/MultiValueStringOperatorConversions.java
+++ 
b/sql/src/main/java/org/apache/druid/sql/calcite/expression/builtin/MultiValueStringOperatorConversions.java
@@ -19,6 +19,7 @@
 
 package org.apache.druid.sql.calcite.expression.builtin;
 
+import com.google.common.collect.Iterables;
 import com.google.common.collect.Sets;
 import org.apache.calcite.rex.RexCall;
 import org.apache.calcite.rex.RexLiteral;
@@ -33,8 +34,11 @@ import org.apache.calcite.sql.type.SqlTypeName;
 import org.apache.druid.math.expr.Evals;
 import org.apache.druid.math.expr.Expr;
 import org.apache.druid.math.expr.InputBindings;
+import org.apache.druid.query.dimension.DimensionSpec;
+import org.apache.druid.segment.VirtualColumn;
 import org.apache.druid.segment.column.ColumnType;
 import org.apache.druid.segment.column.RowSignature;
+import org.apache.druid.segment.virtual.ExpressionVirtualColumn;
 import org.apache.druid.segment.virtual.ListFilteredVirtualColumn;
 import org.apache.druid.segment.virtual.PrefixFilteredVirtualColumn;
 import org.apache.druid.segment.virtual.RegexFilteredVirtualColumn;
@@ -48,6 +52,7 @@ import org.apache.druid.sql.calcite.planner.Calcites;
 import org.apache.druid.sql.calcite.planner.PlannerContext;
 
 import javax.annotation.Nullable;
+import java.util.ArrayList;
 import java.util.HashSet;
 import java.util.List;
 
@@ -375,48 +380,152 @@ public class MultiValueStringOperatorConversions
       };
 
       Expr expr = 
plannerContext.parseExpression(druidExpressions.get(1).getExpression());
-      if (druidExpressions.get(0).isSimpleExtraction() && expr.isLiteral()) {
+      if (expr.isLiteral()) {
         Object[] lit = expr.eval(InputBindings.nilBindings()).asArray();
         if (lit == null || lit.length == 0) {
-          return null;
+          return druidExpressions.get(0).isSimpleExtraction()
+                 ? null
+                 : DruidExpression.ofExpression(ColumnType.STRING, builder, 
druidExpressions);
         }
         HashSet<String> literals = Sets.newHashSetWithExpectedSize(lit.length);
         for (Object o : lit) {
           literals.add(Evals.asString(o));
         }
 
-        DruidExpression druidExpression = DruidExpression.ofVirtualColumn(
-            Calcites.getColumnTypeForRelDataType(rexNode.getType()),
-            builder,
+        final DruidExpression druidExpression = 
toFilteredVirtualColumnExpression(
+            plannerContext,
+            rowSignature,
+            rexNode,
             druidExpressions,
-            (name, outputType, parser, self) -> new ListFilteredVirtualColumn(
-                name,
-                self.getArguments().get(0)
-                    .getSimpleExtraction()
-                    
.toDimensionSpec(druidExpressions.get(0).getDirectColumn(), outputType),
-                literals,
-                isAllowList()
-            )
+            builder,
+            (name, delegate) -> new ListFilteredVirtualColumn(name, delegate, 
literals, isAllowList())
         );
-
-        // if the join expression VC registry is present, it means that this 
expression is part of a join condition
-        // and since that's the case, create virtual column here itself for 
optimized usage in join matching
-        if (plannerContext.getJoinExpressionVirtualColumnRegistry() != null) {
-          String virtualColumnName = 
plannerContext.getJoinExpressionVirtualColumnRegistry()
-                                                   
.getOrCreateVirtualColumnForExpression(
-                                                       druidExpression,
-                                                       ColumnType.STRING
-                                                   );
-          return DruidExpression.ofColumn(ColumnType.STRING, 
virtualColumnName);
+        if (druidExpression != null) {
+          return druidExpression;
         }
-
-        return druidExpression;
       }
 
       return DruidExpression.ofExpression(ColumnType.STRING, builder, 
druidExpressions);
     }
   }
 
+  /**
+   * Creates a {@link VirtualColumn} for a multi-value string filtering 
function given the {@link DimensionSpec} of the
+   * multi-value string to filter
+   */
+  @FunctionalInterface
+  private interface FilteredVirtualColumnCreator
+  {
+    VirtualColumn create(String name, DimensionSpec delegate);
+  }
+
+  /**
+   * Builds a {@link DruidExpression.NodeType#SPECIALIZED} expression for a 
multi-value string filtering function whose
+   * first argument is the multi-value string to filter, if the first argument 
can be used as the delegate
+   * {@link DimensionSpec} of a specialized filtering virtual column. Returns 
null if it cannot, in which case callers
+   * should fall back to a plain expression.
+   *
+   * @see #toFilterDelegate(PlannerContext, RowSignature, DruidExpression)
+   */
+  @Nullable
+  private static DruidExpression toFilteredVirtualColumnExpression(
+      PlannerContext plannerContext,
+      RowSignature rowSignature,
+      RexNode rexNode,
+      List<DruidExpression> druidExpressions,
+      DruidExpression.ExpressionGenerator builder,
+      FilteredVirtualColumnCreator virtualColumnCreator
+  )
+  {
+    final DruidExpression delegate = toFilterDelegate(plannerContext, 
rowSignature, druidExpressions.get(0));
+    if (delegate == null) {
+      return null;
+    }
+    final List<DruidExpression> arguments = new ArrayList<>(druidExpressions);
+    arguments.set(0, delegate);
+
+    final DruidExpression druidExpression = DruidExpression.ofVirtualColumn(
+        Calcites.getColumnTypeForRelDataType(rexNode.getType()),
+        builder,
+        arguments,
+        (name, outputType, parser, self) -> {
+          final DruidExpression arg = self.getArguments().get(0);
+          if (!arg.isSimpleExtraction()) {
+            // the delegate expression was not lifted into its own virtual 
column by specialization, so we cannot
+            // reference it by name, fall back to a plain expression virtual 
column
+            return self.toExpressionVirtualColumn(name, outputType, parser);
+          }
+          return virtualColumnCreator.create(
+              name,
+              arg.getSimpleExtraction().toDimensionSpec(arg.getDirectColumn(), 
outputType)
+          );
+        }
+    );
+
+    // if the join expression VC registry is present, it means that this 
expression is part of a join condition
+    // and since that's the case, create virtual column here itself for 
optimized usage in join matching
+    if (plannerContext.getJoinExpressionVirtualColumnRegistry() != null) {
+      String virtualColumnName = 
plannerContext.getJoinExpressionVirtualColumnRegistry()
+                                               
.getOrCreateVirtualColumnForExpression(
+                                                   druidExpression,
+                                                   ColumnType.STRING
+                                               );
+      return DruidExpression.ofColumn(ColumnType.STRING, virtualColumnName);
+    }
+
+    return druidExpression;
+  }
+
+  /**
+   * Returns a {@link DruidExpression} suitable to use as the delegate {@link 
DimensionSpec} of a specialized multi-value
+   * string filtering virtual column, or null if there is no such expression.
+   *
+   * Simple extractions are used directly. Expressions of a single {@link 
ColumnType#STRING} input are marked
+   * {@link DruidExpression#asSpecialized()} so that they are lifted into 
their own {@link ExpressionVirtualColumn}
+   * which the filtering virtual column can use as its delegate. When the 
input column is dictionary encoded, the
+   * expression virtual column produces a dimension selector backed by the 
input column dictionary which defers
+   * expression evaluation until values are looked up, allowing the filtering 
virtual column to evaluate the expression
+   * once per dictionary value instead of for every row.
+   */
+  @Nullable
+  private static DruidExpression toFilterDelegate(
+      PlannerContext plannerContext,
+      RowSignature rowSignature,
+      DruidExpression arg
+  )
+  {
+    if (arg.isSimpleExtraction()) {
+      return arg;
+    }
+    // virtual columns created for join conditions are not always carried into 
the native query when joins are nested,
+    // so leave expressions in join conditions inline
+    if (plannerContext.getJoinExpressionVirtualColumnRegistry() != null) {
+      return null;
+    }
+    if (arg.getType() == DruidExpression.NodeType.SPECIALIZED || 
!ColumnType.STRING.equals(arg.getDruidType())) {
+      return null;
+    }
+    final Expr expr = plannerContext.parseExpression(arg.getExpression());
+    final Expr.BindingAnalysis bindingAnalysis = expr.analyzeInputs();
+    if (bindingAnalysis.getRequiredBindings().size() != 1
+        || bindingAnalysis.hasInputArrays()
+        || bindingAnalysis.isOutputArray()) {
+      return null;
+    }
+    final String inputColumn = 
Iterables.getOnlyElement(bindingAnalysis.getRequiredBindings());
+    if 
(!rowSignature.getColumnType(inputColumn).map(ColumnType.STRING::equals).orElse(false))
 {
+      return null;
+    }
+    // a plain expression evaluates an empty multi-value row as if it were 
null, while the dictionary backed selector of
+    // an expression virtual column evaluates the expression once for each 
value in a row, so an empty row produces no
+    // values at all. These are only equivalent if the expression produces 
null for a null input, so don't lift
+    // expressions like NVL or COALESCE which replace null values
+    if (expr.eval(InputBindings.nilBindings()).value() != null) {
+      return null;
+    }
+    return arg.asSpecialized();
+  }
+
   public static class RegexFilter implements SqlOperatorConversion
   {
     private static final SqlFunction SQL_FUNCTION = OperatorConversions
@@ -465,31 +574,21 @@ public class MultiValueStringOperatorConversions
       }
       final DruidExpression.ExpressionGenerator builder = (args) ->
           "filter((x) -> regexp_like(x, " + 
DruidExpression.stringLiteral(pattern) + "), " + args.get(0).getExpression() + 
")";
+      // RegexFilteredVirtualColumn requires the entire value to match the 
pattern, while regexp_like matches if the
+      // pattern is found anywhere in the value, so only use the virtual 
column for simple extractions, which were
+      // already planned this way, and leave other expressions as plain 
expressions to not change their results
       if (druidExpressions.get(0).isSimpleExtraction()) {
-        DruidExpression druidExpression = DruidExpression.ofVirtualColumn(
-            Calcites.getColumnTypeForRelDataType(rexNode.getType()),
-            builder,
+        final DruidExpression druidExpression = 
toFilteredVirtualColumnExpression(
+            plannerContext,
+            rowSignature,
+            rexNode,
             druidExpressions,
-            (name, outputType, parser, self) -> new RegexFilteredVirtualColumn(
-                name,
-                self.getArguments().get(0)
-                    .getSimpleExtraction()
-                    
.toDimensionSpec(druidExpressions.get(0).getDirectColumn(), outputType),
-                pattern
-            )
+            builder,
+            (name, delegate) -> new RegexFilteredVirtualColumn(name, delegate, 
pattern)
         );
-
-        // If in a join context, create the VC immediately
-        if (plannerContext.getJoinExpressionVirtualColumnRegistry() != null) {
-          String virtualColumnName = 
plannerContext.getJoinExpressionVirtualColumnRegistry()
-                                                   
.getOrCreateVirtualColumnForExpression(
-                                                       druidExpression,
-                                                       ColumnType.STRING
-                                                   );
-          return DruidExpression.ofColumn(ColumnType.STRING, 
virtualColumnName);
+        if (druidExpression != null) {
+          return druidExpression;
         }
-
-        return druidExpression;
       }
 
       return DruidExpression.ofExpression(ColumnType.STRING, builder, 
druidExpressions);
@@ -550,30 +649,15 @@ public class MultiValueStringOperatorConversions
       final DruidExpression.ExpressionGenerator builder = (args) ->
           "filter((x) -> substring(x, 0, " + prefix.length() + ") == " + 
DruidExpression.stringLiteral(prefix) + ", " + args.get(0).getExpression() + 
")";
 
-      if (druidExpressions.get(0).isSimpleExtraction()) {
-        DruidExpression druidExpression = DruidExpression.ofVirtualColumn(
-            Calcites.getColumnTypeForRelDataType(rexNode.getType()),
-            builder,
-            druidExpressions,
-            (name, outputType, parser, self) -> new 
PrefixFilteredVirtualColumn(
-                name,
-                self.getArguments().get(0)
-                    .getSimpleExtraction()
-                    
.toDimensionSpec(druidExpressions.get(0).getDirectColumn(), outputType),
-                prefix
-            )
-        );
-
-        // If in a join context, create the VC immediately
-        if (plannerContext.getJoinExpressionVirtualColumnRegistry() != null) {
-          String virtualColumnName = 
plannerContext.getJoinExpressionVirtualColumnRegistry()
-                                                   
.getOrCreateVirtualColumnForExpression(
-                                                       druidExpression,
-                                                       ColumnType.STRING
-                                                   );
-          return DruidExpression.ofColumn(ColumnType.STRING, 
virtualColumnName);
-        }
-
+      final DruidExpression druidExpression = 
toFilteredVirtualColumnExpression(
+          plannerContext,
+          rowSignature,
+          rexNode,
+          druidExpressions,
+          builder,
+          (name, delegate) -> new PrefixFilteredVirtualColumn(name, delegate, 
prefix)
+      );
+      if (druidExpression != null) {
         return druidExpression;
       }
 
diff --git 
a/sql/src/test/java/org/apache/druid/sql/calcite/CalciteMultiValueStringQueryTest.java
 
b/sql/src/test/java/org/apache/druid/sql/calcite/CalciteMultiValueStringQueryTest.java
index 9c10b3e284d..e877e0fa46c 100644
--- 
a/sql/src/test/java/org/apache/druid/sql/calcite/CalciteMultiValueStringQueryTest.java
+++ 
b/sql/src/test/java/org/apache/druid/sql/calcite/CalciteMultiValueStringQueryTest.java
@@ -43,6 +43,7 @@ import 
org.apache.druid.query.groupby.orderby.OrderByColumnSpec;
 import org.apache.druid.query.lookup.RegisteredLookupExtractionFn;
 import org.apache.druid.query.ordering.StringComparators;
 import org.apache.druid.query.scan.ScanQuery;
+import org.apache.druid.segment.VirtualColumns;
 import org.apache.druid.segment.column.ColumnType;
 import org.apache.druid.segment.virtual.ExpressionVirtualColumn;
 import org.apache.druid.segment.virtual.ListFilteredVirtualColumn;
@@ -2114,11 +2115,13 @@ public class CalciteMultiValueStringQueryTest extends 
BaseCalciteQueryTest
                         .setInterval(querySegmentSpec(Filtration.eternity()))
                         .setGranularity(Granularities.ALL)
                         .setVirtualColumns(
-                            expressionVirtualColumn(
+                            new ListFilteredVirtualColumn(
                                 "v0",
-                                "filter((x) -> array_contains(array(null), x), 
lookup(\"dim3\",'lookyloo'))",
-                                ColumnType.STRING
-                            )
+                                DefaultDimensionSpec.of("v1"),
+                                Collections.singleton(null),
+                                true
+                            ),
+                            expressionVirtualColumn("v1", 
"lookup(\"dim3\",'lookyloo')", ColumnType.STRING)
                         )
                         .setDimensions(dimensions(new 
DefaultDimensionSpec("v0", "d0", ColumnType.STRING)))
                         .setAggregatorSpecs(aggregators(new 
CountAggregatorFactory("a0")))
@@ -2172,6 +2175,340 @@ public class CalciteMultiValueStringQueryTest extends 
BaseCalciteQueryTest
         .run();
   }
 
+  @Test
+  public void testMultiValuedFilterOnlyLookup()
+  {
+    testGroupByFilteredMultiValueExpression(
+        "SELECT MV_FILTER_ONLY(LOOKUP(dim3, 'lookyloo'), ARRAY['xa']), 
COUNT(*) FROM druid.foo GROUP BY 1",
+        VirtualColumns.create(
+            new ListFilteredVirtualColumn("v0", DefaultDimensionSpec.of("v1"), 
ImmutableSet.of("xa"), true),
+            expressionVirtualColumn("v1", "lookup(\"dim3\",'lookyloo')", 
ColumnType.STRING)
+        ),
+        ImmutableList.of(
+            new Object[]{null, 5L},
+            new Object[]{"xa", 1L}
+        )
+    );
+  }
+
+  @Test
+  public void testMultiValuedFilterNoneLookup()
+  {
+    testGroupByFilteredMultiValueExpression(
+        "SELECT MV_FILTER_NONE(LOOKUP(dim3, 'lookyloo'), ARRAY['xa']), 
COUNT(*) FROM druid.foo GROUP BY 1",
+        VirtualColumns.create(
+            new ListFilteredVirtualColumn("v0", DefaultDimensionSpec.of("v1"), 
ImmutableSet.of("xa"), false),
+            expressionVirtualColumn("v1", "lookup(\"dim3\",'lookyloo')", 
ColumnType.STRING)
+        ),
+        ImmutableList.of(
+            new Object[]{null, 7L}
+        )
+    );
+  }
+
+  @Test
+  public void testMultiValuedFilterRegexLookup()
+  {
+    // RegexFilteredVirtualColumn requires a full match while the expression 
finds the pattern anywhere, so expressions
+    // are not specialized
+    testGroupByFilteredMultiValueExpression(
+        "SELECT MV_FILTER_REGEX(LOOKUP(dim3, 'lookyloo'), '^x.*'), COUNT(*) 
FROM druid.foo GROUP BY 1",
+        VirtualColumns.create(
+            expressionVirtualColumn(
+                "v0",
+                "filter((x) -> regexp_like(x, '^x.*'), 
lookup(\"dim3\",'lookyloo'))",
+                ColumnType.STRING
+            )
+        ),
+        ImmutableList.of(
+            new Object[]{null, 5L},
+            new Object[]{"xa", 1L}
+        )
+    );
+  }
+
+  @Test
+  public void testMultiValuedFilterPrefixLookup()
+  {
+    testGroupByFilteredMultiValueExpression(
+        "SELECT MV_FILTER_PREFIX(LOOKUP(dim3, 'lookyloo'), 'x'), COUNT(*) FROM 
druid.foo GROUP BY 1",
+        VirtualColumns.create(
+            new PrefixFilteredVirtualColumn("v0", 
DefaultDimensionSpec.of("v1"), "x"),
+            expressionVirtualColumn("v1", "lookup(\"dim3\",'lookyloo')", 
ColumnType.STRING)
+        ),
+        ImmutableList.of(
+            new Object[]{null, 5L},
+            new Object[]{"xa", 1L}
+        )
+    );
+  }
+
+  @Test
+  public void testMultiValuedFilterOnlySingleInputExpression()
+  {
+    testGroupByFilteredMultiValueExpression(
+        "SELECT MV_FILTER_ONLY(UPPER(dim3), ARRAY['B']), COUNT(*) FROM 
druid.foo GROUP BY 1",
+        VirtualColumns.create(
+            new ListFilteredVirtualColumn("v0", DefaultDimensionSpec.of("v1"), 
ImmutableSet.of("B"), true),
+            expressionVirtualColumn("v1", "upper(\"dim3\")", ColumnType.STRING)
+        ),
+        ImmutableList.of(
+            new Object[]{null, 4L},
+            new Object[]{"B", 2L}
+        )
+    );
+  }
+
+  @Test
+  public void testMultiValuedFilterOnlySubstring()
+  {
+    testGroupByFilteredMultiValueExpression(
+        "SELECT MV_FILTER_ONLY(SUBSTRING(dim3, 1, 1), ARRAY['b']), COUNT(*) 
FROM druid.foo GROUP BY 1",
+        VirtualColumns.create(
+            new ListFilteredVirtualColumn("v0", DefaultDimensionSpec.of("v1"), 
ImmutableSet.of("b"), true),
+            expressionVirtualColumn("v1", "substring(\"dim3\", 0, 1)", 
ColumnType.STRING)
+        ),
+        ImmutableList.of(
+            new Object[]{null, 4L},
+            new Object[]{"b", 2L}
+        )
+    );
+  }
+
+  @Test
+  public void testMultiValuedFilterOnlyRegexpExtract()
+  {
+    testGroupByFilteredMultiValueExpression(
+        "SELECT MV_FILTER_ONLY(REGEXP_EXTRACT(dim3, '^[a-b]'), ARRAY['b']), 
COUNT(*) FROM druid.foo GROUP BY 1",
+        VirtualColumns.create(
+            new ListFilteredVirtualColumn("v0", DefaultDimensionSpec.of("v1"), 
ImmutableSet.of("b"), true),
+            expressionVirtualColumn("v1", "regexp_extract(\"dim3\",'^[a-b]')", 
ColumnType.STRING)
+        ),
+        ImmutableList.of(
+            new Object[]{null, 4L},
+            new Object[]{"b", 2L}
+        )
+    );
+  }
+
+  @Test
+  public void testMultiValuedFilterOnlyNoMatchesIsNullFilter()
+  {
+    // none of the allowed values exist, so the list filtered index has an 
empty dictionary
+    testFilteredMultiValueResults(
+        "SELECT COUNT(*) FROM druid.foo WHERE MV_FILTER_ONLY(dim3, 
ARRAY['zzz']) IS NULL",
+        ImmutableList.of(new Object[]{6L})
+    );
+  }
+
+  @Test
+  public void testMultiValuedFilterOnlyNoMatchesRangeFilter()
+  {
+    testFilteredMultiValueResults(
+        "SELECT COUNT(*) FROM druid.foo WHERE MV_FILTER_ONLY(dim3, 
ARRAY['zzz']) >= 'a'",
+        ImmutableList.of(new Object[]{0L})
+    );
+  }
+
+  @Test
+  public void testMultiValuedFilterOnlyExpressionOnJoinColumnFilter()
+  {
+    testFilteredMultiValueResults(
+        "SELECT COUNT(*) FROM druid.foo f INNER JOIN lookup.lookyloo l ON 
f.dim2 = l.k "
+        + "WHERE MV_FILTER_ONLY(UPPER(l.v), ARRAY['XA']) = 'XA'",
+        ImmutableList.of(new Object[]{2L})
+    );
+  }
+
+  @Test
+  public void testMultiValuedFilterOnlyExpressionInNestedJoinCondition()
+  {
+    testVectorizableFilteredMultiValueResults(
+        "SELECT f.dim1, l.v, l2.v FROM druid.foo f "
+        + "INNER JOIN lookup.lookyloo l ON MV_FILTER_ONLY(LOWER(f.dim3), 
ARRAY['a']) = l.k "
+        + "INNER JOIN lookup.lookyloo l2 ON f.dim2 = l2.k",
+        ImmutableList.of(new Object[]{"", "xa", "xa"})
+    );
+  }
+
+  @Test
+  public void testMultiValuedFilterOnlyExpressionOnJoinColumnInJoinCondition()
+  {
+    testVectorizableFilteredMultiValueResults(
+        "SELECT f.dim1, l.v, l2.v FROM druid.foo f "
+        + "INNER JOIN lookup.lookyloo l ON f.dim2 = l.k "
+        + "INNER JOIN lookup.lookyloo l2 ON MV_FILTER_ONLY(SUBSTRING(l.v, 2), 
ARRAY['a']) = l2.k",
+        ImmutableList.of(
+            new Object[]{"", "xa", "xa"},
+            new Object[]{"1", "xa", "xa"}
+        )
+    );
+  }
+
+  @Test
+  public void testMultiValuedFilterRegexExpressionNotSpecialized()
+  {
+    // expressions are not specialized for MV_FILTER_REGEX, so the pattern is 
still found anywhere in the value and
+    // 'abc' matches
+    testFilteredMultiValueResults(
+        "SELECT MV_FILTER_REGEX(LOWER(dim2), '^a'), COUNT(*) FROM druid.foo 
GROUP BY 1",
+        ImmutableList.of(
+            new Object[]{null, 3L},
+            new Object[]{"a", 2L},
+            new Object[]{"abc", 1L}
+        )
+    );
+  }
+
+  @Test
+  public void testMultiValuedFilterOnlyNvlNotSpecialized()
+  {
+    // NVL replaces nulls, so an empty row is not equivalent to a null row and 
the expression is not specialized
+    cannotVectorize();
+    testBuilder()
+        .sql("SELECT MV_FILTER_ONLY(NVL(dim3, 'x'), ARRAY['x']), COUNT(*) FROM 
druid.foo GROUP BY 1")
+        .expectedQuery(
+            GroupByQuery.builder()
+                        .setDataSource(CalciteTests.DATASOURCE1)
+                        .setInterval(querySegmentSpec(Filtration.eternity()))
+                        .setGranularity(Granularities.ALL)
+                        .setVirtualColumns(
+                            expressionVirtualColumn(
+                                "v0",
+                                "filter((x) -> array_contains(array('x'), x), 
nvl(\"dim3\",'x'))",
+                                ColumnType.STRING
+                            )
+                        )
+                        .setDimensions(dimensions(new 
DefaultDimensionSpec("v0", "d0", ColumnType.STRING)))
+                        .setAggregatorSpecs(aggregators(new 
CountAggregatorFactory("a0")))
+                        .setContext(QUERY_CONTEXT_DEFAULT)
+                        .build()
+        )
+        .expectedResults(
+            ImmutableList.of(
+                new Object[]{null, 4L},
+                new Object[]{"x", 2L}
+            )
+        )
+        .run();
+    testFilteredMultiValueResults(
+        "SELECT MV_FILTER_ONLY(NVL(dim3, 'x'), ARRAY['x', 'a']), COUNT(*) FROM 
(SELECT dim3 FROM druid.foo LIMIT 10) GROUP BY 1",
+        ImmutableList.of(
+            new Object[]{null, 3L},
+            new Object[]{"a", 1L},
+            new Object[]{"x", 2L}
+        )
+    );
+  }
+
+  @Test
+  public void testMultiValuedFilterOnlySpecializedInsideExpression()
+  {
+    // COALESCE applies to each value of the filtered multi-value string, the 
same as when filtering a column directly,
+    // so rows where everything was filtered are null instead of 'none'
+    testFilteredMultiValueResults(
+        "SELECT COALESCE(MV_FILTER_ONLY(UPPER(dim3), ARRAY['B']), 'none'), 
COUNT(*) FROM druid.foo GROUP BY 1",
+        ImmutableList.of(
+            new Object[]{null, 4L},
+            new Object[]{"B", 2L}
+        )
+    );
+    testFilteredMultiValueResults(
+        "SELECT COALESCE(MV_FILTER_ONLY(dim3, ARRAY['b']), 'none'), COUNT(*) 
FROM druid.foo GROUP BY 1",
+        ImmutableList.of(
+            new Object[]{null, 4L},
+            new Object[]{"b", 2L}
+        )
+    );
+  }
+
+  @Test
+  public void testMultiValuedFilterNoneLookupTopN()
+  {
+    // topN does not count empty multi-value rows, the same as when filtering 
a column directly
+    testVectorizableFilteredMultiValueResults(
+        "SELECT MV_FILTER_NONE(LOOKUP(dim3, 'lookyloo'), ARRAY['xa']), 
COUNT(*) FROM druid.foo "
+        + "GROUP BY 1 ORDER BY 2 DESC LIMIT 2",
+        ImmutableList.of(new Object[]{null, 6L})
+    );
+  }
+
+  @Test
+  public void testMultiValuedFilterExpressionOnSingleValuedColumnFilter()
+  {
+    // filtering a single valued column can leave rows with no values, which 
must still be treated as null even
+    // though the expression delegate has no indexes to answer the filter
+    testFilteredMultiValueResults(
+        "SELECT COUNT(*) FROM druid.foo WHERE MV_FILTER_ONLY(UPPER(dim1), 
ARRAY['ABC']) IS NULL",
+        ImmutableList.of(new Object[]{5L})
+    );
+    testFilteredMultiValueResults(
+        "SELECT COUNT(*) FROM druid.foo WHERE MV_FILTER_ONLY(UPPER(dim1), 
ARRAY['ABC']) = 'ABC'",
+        ImmutableList.of(new Object[]{1L})
+    );
+    testFilteredMultiValueResults(
+        "SELECT COUNT(*) FROM druid.foo WHERE MV_FILTER_PREFIX(UPPER(dim1), 
'AB') IS NULL",
+        ImmutableList.of(new Object[]{5L})
+    );
+  }
+
+  @Test
+  public void testMultiValuedFilterOnlyMultiInputExpression()
+  {
+    // expressions with more than one input cannot use a dictionary backed 
delegate, so plan to a plain expression
+    testGroupByFilteredMultiValueExpression(
+        "SELECT MV_FILTER_ONLY(CONCAT(dim3, dim2), ARRAY['aa']), COUNT(*) FROM 
druid.foo GROUP BY 1",
+        VirtualColumns.create(
+            expressionVirtualColumn(
+                "v0",
+                "filter((x) -> array_contains(array('aa'), x), 
concat(\"dim3\",\"dim2\"))",
+                ColumnType.STRING
+            )
+        ),
+        ImmutableList.of(
+            new Object[]{null, 5L},
+            new Object[]{"aa", 1L}
+        )
+    );
+  }
+
+  @Test
+  public void testMultiValuedFilterOnlyLookupFilter()
+  {
+    cannotVectorize();
+    final String sql = "SELECT COUNT(*) FROM druid.foo WHERE 
MV_FILTER_ONLY(LOOKUP(dim3, 'lookyloo'), ARRAY['xa']) = 'xa'";
+    final List<Object[]> expectedResults = ImmutableList.of(new Object[]{1L});
+    testBuilder()
+        .sql(sql)
+        .expectedQuery(
+            Druids.newTimeseriesQueryBuilder()
+                  .dataSource(CalciteTests.DATASOURCE1)
+                  .intervals(querySegmentSpec(Filtration.eternity()))
+                  .granularity(Granularities.ALL)
+                  .virtualColumns(
+                      new ListFilteredVirtualColumn("v0", 
DefaultDimensionSpec.of("v1"), ImmutableSet.of("xa"), true),
+                      expressionVirtualColumn("v1", 
"lookup(\"dim3\",'lookyloo')", ColumnType.STRING)
+                  )
+                  .filters(equality("v0", "xa", ColumnType.STRING))
+                  .aggregators(aggregators(new CountAggregatorFactory("a0")))
+                  .context(QUERY_CONTEXT_DEFAULT)
+                  .build()
+        )
+        .expectedResults(expectedResults)
+        .run();
+
+    testBuilder()
+        .sql(sql)
+        .queryContext(
+            QueryContexts.override(
+                QUERY_CONTEXT_DEFAULT,
+                
QueryContext.ofMap(QueryContextParameters.SQL_USE_EXTRACTION_FNS, true)
+            )
+        )
+        .expectedResults(expectedResults)
+        .run();
+  }
+
   @Test
   public void testMvContainsFilterWithExtractionFn()
   {
@@ -2632,4 +2969,55 @@ public class CalciteMultiValueStringQueryTest extends 
BaseCalciteQueryTest
         )
         .run();
   }
+
+  /**
+   * Tests a query grouping on a multi-value string filtering function, 
verifying the native query using the default
+   * query context, and that results match those of the same query with
+   * {@link QueryContextParameters#SQL_USE_EXTRACTION_FNS} enabled.
+   */
+  private void testGroupByFilteredMultiValueExpression(
+      String sql,
+      VirtualColumns expectedVirtualColumns,
+      List<Object[]> expectedResults
+  )
+  {
+    cannotVectorize();
+    testBuilder()
+        .sql(sql)
+        .expectedQuery(
+            GroupByQuery.builder()
+                        .setDataSource(CalciteTests.DATASOURCE1)
+                        .setInterval(querySegmentSpec(Filtration.eternity()))
+                        .setGranularity(Granularities.ALL)
+                        .setVirtualColumns(expectedVirtualColumns)
+                        .setDimensions(dimensions(new 
DefaultDimensionSpec("v0", "d0", ColumnType.STRING)))
+                        .setAggregatorSpecs(aggregators(new 
CountAggregatorFactory("a0")))
+                        .setContext(QUERY_CONTEXT_DEFAULT)
+                        .build()
+        )
+        .expectedResults(expectedResults)
+        .run();
+
+    testBuilder()
+        .sql(sql)
+        .queryContext(
+            QueryContexts.override(
+                QUERY_CONTEXT_DEFAULT,
+                
QueryContext.ofMap(QueryContextParameters.SQL_USE_EXTRACTION_FNS, true)
+            )
+        )
+        .expectedResults(expectedResults)
+        .run();
+  }
+
+  private void testFilteredMultiValueResults(String sql, List<Object[]> 
expectedResults)
+  {
+    cannotVectorize();
+    testBuilder().sql(sql).expectedResults(expectedResults).run();
+  }
+
+  private void testVectorizableFilteredMultiValueResults(String sql, 
List<Object[]> expectedResults)
+  {
+    testBuilder().sql(sql).expectedResults(expectedResults).run();
+  }
 }


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

Reply via email to