You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@calcite.apache.org by jh...@apache.org on 2020/09/04 19:40:46 UTC

[calcite] 01/03: [CALCITE-4173] Add internal SEARCH operator and Sarg literal that represents a set of values or ranges

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

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

commit 850f0f4a04fc2399b8fd1c1fed532cd8e1e39514
Author: Julian Hyde <jh...@apache.org>
AuthorDate: Thu Aug 13 00:59:15 2020 -0700

    [CALCITE-4173] Add internal SEARCH operator and Sarg literal that represents a set of values or ranges
    
    Obsolete use of IN in RexCall; in Druid, replace some uses
    with DRUID_IN operator.
    
    Create Sarg instances during RexSimplify of AND, OR. Also
    during simplify, strengthen Sarg.containsNull from true to
    false if predicates prove that NULL values are impossible.
    
    In JDBC adapter we handle SEARCH natively, but in Geode,
    MongoDB and Spark adapters, expand SEARCH before translating
    to target query language. Later, it may be better to handle
    SEARCH explicitly. For instance, it will be easier to recognize
    expressions that can be translated to Geode's 'IN SET'
    construct.
    
    Close apache/calcite#2124
---
 .../adapter/enumerable/RexToLixTranslator.java     |   5 +
 .../org/apache/calcite/jdbc/CalciteMetaImpl.java   |   4 +
 .../apache/calcite/plan/SubstitutionVisitor.java   |   4 +
 .../calcite/rel/rel2sql/RelToSqlConverter.java     |  10 +-
 .../apache/calcite/rel/rel2sql/SqlImplementor.java | 201 +++++++++-
 .../calcite/rel/rules/SubQueryRemoveRule.java      |   6 +-
 .../java/org/apache/calcite/rex/RexBuilder.java    | 151 +++++++-
 .../main/java/org/apache/calcite/rex/RexCall.java  |   1 +
 .../java/org/apache/calcite/rex/RexLiteral.java    | 302 ++++++++-------
 .../java/org/apache/calcite/rex/RexSimplify.java   | 313 +++++++++++++---
 .../main/java/org/apache/calcite/rex/RexUtil.java  | 135 +++++++
 .../main/java/org/apache/calcite/sql/SqlKind.java  |  13 +
 .../main/java/org/apache/calcite/sql/SqlNode.java  |  17 +
 .../java/org/apache/calcite/sql/SqlNodeList.java   |   2 +
 .../apache/calcite/sql/fun/SqlBetweenOperator.java |   7 +-
 .../org/apache/calcite/sql/fun/SqlInOperator.java  |   3 +-
 .../calcite/sql/fun/SqlInternalOperators.java      |  59 +++
 .../apache/calcite/sql/fun/SqlSearchOperator.java  |  33 ++
 .../calcite/sql/fun/SqlStdOperatorTable.java       |   5 +
 .../org/apache/calcite/sql/type/SqlTypeName.java   |   5 +-
 .../java/org/apache/calcite/tools/RelBuilder.java  |  21 ++
 .../java/org/apache/calcite/util/RangeSets.java    | 386 ++++++++++++++++++++
 .../main/java/org/apache/calcite/util/Sarg.java    | 152 ++++++++
 .../main/java/org/apache/calcite/util/Util.java    | 109 +++++-
 .../calcite/jdbc/CalciteRemoteDriverTest.java      |   5 +-
 .../calcite/rel/rel2sql/RelToSqlConverterTest.java |  21 +-
 .../calcite/rel/rules/DateRangeRulesTest.java      |  10 +-
 .../apache/calcite/rex/RexProgramBuilderBase.java  |   7 +-
 .../org/apache/calcite/rex/RexProgramTest.java     | 109 ++++--
 .../org/apache/calcite/rex/RexProgramTestBase.java |  59 ++-
 .../org/apache/calcite/test/JdbcAdapterTest.java   |   9 +-
 .../java/org/apache/calcite/test/Matchers.java     |  13 +
 .../test/MaterializedViewRelOptRulesTest.java      |  46 ++-
 .../org/apache/calcite/test/RelBuilderTest.java    |  75 +++-
 .../org/apache/calcite/test/RelMetadataTest.java   |   2 +-
 .../java/org/apache/calcite/util/RangeSetTest.java | 405 +++++++++++++++++++++
 .../java/org/apache/calcite/util/UtilTest.java     |  80 ++--
 .../org/apache/calcite/test/RelOptRulesTest.xml    |  30 +-
 .../apache/calcite/test/SqlToRelConverterTest.xml  |  16 +-
 core/src/test/resources/sql/sort.iq                |   2 +-
 core/src/test/resources/sql/sub-query.iq           |   8 +-
 .../calcite/adapter/druid/DruidDateTimeUtils.java  |  37 +-
 .../calcite/adapter/druid/DruidJsonFilter.java     |  10 +-
 .../adapter/druid/DruidQueryFilterTest.java        |  11 +-
 .../calcite/adapter/geode/rel/GeodeFilter.java     |   9 +-
 .../calcite/adapter/geode/rel/GeodeRules.java      |   3 +-
 .../adapter/geode/rel/GeodeBookstoreTest.java      |   2 +-
 .../calcite/adapter/geode/rel/GeodeZipsTest.java   |   4 +-
 .../calcite/adapter/mongodb/MongoFilter.java       |  14 +-
 .../apache/calcite/adapter/mongodb/MongoRel.java   |   7 +-
 .../mongodb/MongoToEnumerableConverter.java        |   3 +-
 .../org/apache/calcite/test/SparkAdapterTest.java  |   6 +-
 52 files changed, 2520 insertions(+), 427 deletions(-)

diff --git a/core/src/main/java/org/apache/calcite/adapter/enumerable/RexToLixTranslator.java b/core/src/main/java/org/apache/calcite/adapter/enumerable/RexToLixTranslator.java
index 82d1858..d7ecad3 100644
--- a/core/src/main/java/org/apache/calcite/adapter/enumerable/RexToLixTranslator.java
+++ b/core/src/main/java/org/apache/calcite/adapter/enumerable/RexToLixTranslator.java
@@ -46,6 +46,7 @@ import org.apache.calcite.rex.RexProgram;
 import org.apache.calcite.rex.RexRangeRef;
 import org.apache.calcite.rex.RexSubQuery;
 import org.apache.calcite.rex.RexTableInputRef;
+import org.apache.calcite.rex.RexUtil;
 import org.apache.calcite.rex.RexVisitor;
 import org.apache.calcite.runtime.GeoFunctions;
 import org.apache.calcite.runtime.Geometries;
@@ -78,6 +79,7 @@ import static org.apache.calcite.sql.fun.SqlStdOperatorTable.CASE;
 import static org.apache.calcite.sql.fun.SqlStdOperatorTable.CHARACTER_LENGTH;
 import static org.apache.calcite.sql.fun.SqlStdOperatorTable.CHAR_LENGTH;
 import static org.apache.calcite.sql.fun.SqlStdOperatorTable.PREV;
+import static org.apache.calcite.sql.fun.SqlStdOperatorTable.SEARCH;
 import static org.apache.calcite.sql.fun.SqlStdOperatorTable.SUBSTRING;
 import static org.apache.calcite.sql.fun.SqlStdOperatorTable.UPPER;
 
@@ -1062,6 +1064,9 @@ public class RexToLixTranslator implements RexVisitor<RexToLixTranslator.Result>
     if (operator == CASE) {
       return implementCaseWhen(call);
     }
+    if (operator == SEARCH) {
+      return RexUtil.expandSearch(builder, program, call).accept(this);
+    }
     final RexImpTable.RexCallImplementor implementor =
         RexImpTable.INSTANCE.get(operator);
     if (implementor == null) {
diff --git a/core/src/main/java/org/apache/calcite/jdbc/CalciteMetaImpl.java b/core/src/main/java/org/apache/calcite/jdbc/CalciteMetaImpl.java
index e7bca05..ccbb2e9 100644
--- a/core/src/main/java/org/apache/calcite/jdbc/CalciteMetaImpl.java
+++ b/core/src/main/java/org/apache/calcite/jdbc/CalciteMetaImpl.java
@@ -413,6 +413,10 @@ public class CalciteMetaImpl extends MetaImpl {
     final CalciteConnectionImpl conn = (CalciteConnectionImpl) connection;
     final RelDataTypeSystem typeSystem = conn.typeFactory.getTypeSystem();
     for (SqlTypeName sqlTypeName : SqlTypeName.values()) {
+      if (sqlTypeName.isSpecial()) {
+        // Skip internal types (NULL, ANY, SYMBOL, SARG).
+        continue;
+      }
       allTypeList.add(
           new MetaTypeInfo(sqlTypeName.getName(),
               sqlTypeName.getJdbcOrdinal(),
diff --git a/core/src/main/java/org/apache/calcite/plan/SubstitutionVisitor.java b/core/src/main/java/org/apache/calcite/plan/SubstitutionVisitor.java
index 0b087ab..bcf7349 100644
--- a/core/src/main/java/org/apache/calcite/plan/SubstitutionVisitor.java
+++ b/core/src/main/java/org/apache/calcite/plan/SubstitutionVisitor.java
@@ -356,6 +356,10 @@ public class SubstitutionVisitor {
       }
       return RexUtil.invert(rexBuilder, call);
     }
+    case SEARCH: {
+      final RexNode e = RexUtil.expandSearch(rexBuilder, null, condition);
+      return canonizeNode(rexBuilder, e);
+    }
     case PLUS:
     case TIMES: {
       RexCall call = (RexCall) condition;
diff --git a/core/src/main/java/org/apache/calcite/rel/rel2sql/RelToSqlConverter.java b/core/src/main/java/org/apache/calcite/rel/rel2sql/RelToSqlConverter.java
index b625dd0..1c61f8f 100644
--- a/core/src/main/java/org/apache/calcite/rel/rel2sql/RelToSqlConverter.java
+++ b/core/src/main/java/org/apache/calcite/rel/rel2sql/RelToSqlConverter.java
@@ -95,6 +95,7 @@ import com.google.common.collect.Ordering;
 import java.util.ArrayDeque;
 import java.util.ArrayList;
 import java.util.Collection;
+import java.util.Collections;
 import java.util.Deque;
 import java.util.LinkedHashSet;
 import java.util.List;
@@ -104,7 +105,6 @@ import java.util.Objects;
 import java.util.Set;
 import java.util.SortedSet;
 import java.util.stream.Collectors;
-import java.util.stream.IntStream;
 
 /**
  * Utility to convert relational expressions to SQL abstract syntax tree.
@@ -640,10 +640,10 @@ public class RelToSqlConverter extends SqlImplementor
         // In case of empty values, we need to build:
         // select * from VALUES(NULL, NULL ...) as T (C1, C2 ...)
         // where 1=0.
-        List<SqlNode> nulls = IntStream.range(0, fieldNames.size())
-            .mapToObj(i ->
-                SqlLiteral.createNull(POS)).collect(Collectors.toList());
-        selects.add(ANONYMOUS_ROW.createCall(new SqlNodeList(nulls, POS)));
+        selects.add(
+            ANONYMOUS_ROW.createCall(POS,
+                Collections.nCopies(fieldNames.size(),
+                    SqlLiteral.createNull(POS))));
       } else {
         for (List<RexLiteral> tuple : e.getTuples()) {
           selects.add(ANONYMOUS_ROW.createCall(exprList(context, tuple)));
diff --git a/core/src/main/java/org/apache/calcite/rel/rel2sql/SqlImplementor.java b/core/src/main/java/org/apache/calcite/rel/rel2sql/SqlImplementor.java
index a172112..f0756a8 100644
--- a/core/src/main/java/org/apache/calcite/rel/rel2sql/SqlImplementor.java
+++ b/core/src/main/java/org/apache/calcite/rel/rel2sql/SqlImplementor.java
@@ -30,6 +30,8 @@ import org.apache.calcite.rel.core.Window;
 import org.apache.calcite.rel.type.RelDataType;
 import org.apache.calcite.rel.type.RelDataTypeFactory;
 import org.apache.calcite.rel.type.RelDataTypeField;
+import org.apache.calcite.rel.type.RelDataTypeSystemImpl;
+import org.apache.calcite.rex.RexBuilder;
 import org.apache.calcite.rex.RexCall;
 import org.apache.calcite.rex.RexCorrelVariable;
 import org.apache.calcite.rex.RexDynamicParam;
@@ -70,11 +72,14 @@ import org.apache.calcite.sql.fun.SqlCountAggFunction;
 import org.apache.calcite.sql.fun.SqlStdOperatorTable;
 import org.apache.calcite.sql.fun.SqlSumEmptyIsZeroAggFunction;
 import org.apache.calcite.sql.parser.SqlParserPos;
+import org.apache.calcite.sql.type.SqlTypeFactoryImpl;
 import org.apache.calcite.sql.type.SqlTypeFamily;
 import org.apache.calcite.sql.type.SqlTypeName;
 import org.apache.calcite.sql.validate.SqlValidatorUtil;
 import org.apache.calcite.util.DateString;
 import org.apache.calcite.util.Pair;
+import org.apache.calcite.util.RangeSets;
+import org.apache.calcite.util.Sarg;
 import org.apache.calcite.util.TimeString;
 import org.apache.calcite.util.TimestampString;
 import org.apache.calcite.util.Util;
@@ -84,6 +89,7 @@ import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
+import com.google.common.collect.Range;
 
 import java.math.BigDecimal;
 import java.util.AbstractList;
@@ -102,6 +108,7 @@ import java.util.Locale;
 import java.util.Map;
 import java.util.Objects;
 import java.util.Set;
+import java.util.function.Function;
 import java.util.function.IntFunction;
 import javax.annotation.Nonnull;
 import javax.annotation.Nullable;
@@ -650,6 +657,7 @@ public abstract class SqlImplementor {
     public SqlNode toSql(RexProgram program, RexNode rex) {
       final RexSubQuery subQuery;
       final SqlNode sqlSubQuery;
+      final RexLiteral literal;
       switch (rex.getKind()) {
       case LOCAL_REF:
         final int index = ((RexLocalRef) rex).getIndex();
@@ -702,7 +710,7 @@ public abstract class SqlImplementor {
         }
 
       case LITERAL:
-        return SqlImplementor.toSql((RexLiteral) rex);
+        return SqlImplementor.toSql(program, (RexLiteral) rex);
 
       case CASE:
         final RexCall caseCall = (RexCall) rex;
@@ -737,25 +745,24 @@ public abstract class SqlImplementor {
         return new SqlDynamicParam(caseParam.getIndex(), POS);
 
       case IN:
-        if (rex instanceof RexSubQuery) {
-          subQuery = (RexSubQuery) rex;
-          sqlSubQuery =
-              implementor().visitRoot(subQuery.rel).asQueryOrValues();
-          final List<RexNode> operands = subQuery.operands;
-          SqlNode op0;
-          if (operands.size() == 1) {
-            op0 = toSql(program, operands.get(0));
-          } else {
-            final List<SqlNode> cols = toSql(program, operands);
-            op0 = new SqlNodeList(cols, POS);
-          }
-          return subQuery.getOperator().createCall(POS, op0, sqlSubQuery);
+        subQuery = (RexSubQuery) rex;
+        sqlSubQuery = implementor().visitRoot(subQuery.rel).asQueryOrValues();
+        final List<RexNode> operands = subQuery.operands;
+        SqlNode op0;
+        if (operands.size() == 1) {
+          op0 = toSql(program, operands.get(0));
         } else {
-          final RexCall call = (RexCall) rex;
-          final List<SqlNode> cols = toSql(program, call.operands);
-          return call.getOperator().createCall(POS, cols.get(0),
-              new SqlNodeList(cols.subList(1, cols.size()), POS));
+          final List<SqlNode> cols = toSql(program, operands);
+          op0 = new SqlNodeList(cols, POS);
         }
+        return subQuery.getOperator().createCall(POS, op0, sqlSubQuery);
+
+      case SEARCH:
+        final RexCall search = (RexCall) rex;
+        literal = (RexLiteral) search.operands.get(1);
+        final Sarg sarg = literal.getValueAs(Sarg.class);
+        //noinspection unchecked
+        return toSql(program, search.operands.get(0), literal.getType(), sarg);
 
       case EXISTS:
       case SCALAR_QUERY:
@@ -769,6 +776,8 @@ public abstract class SqlImplementor {
         final SqlNode node = toSql(program, operand);
         switch (operand.getKind()) {
         case IN:
+          assert operand instanceof RexSubQuery
+              : "scalar IN is no longer allowed in RexCall: " + rex;
           return SqlStdOperatorTable.NOT_IN
               .createCall(POS, ((SqlCall) node).getOperandList());
         case LIKE:
@@ -826,6 +835,51 @@ public abstract class SqlImplementor {
       }
     }
 
+    /** Converts a Sarg to SQL, generating "operand IN (c1, c2, ...)" if the
+     * ranges are all points. */
+    @SuppressWarnings("UnstableApiUsage")
+    private <C extends Comparable<C>> SqlNode toSql(RexProgram program,
+        RexNode operand, RelDataType type, Sarg<C> sarg) {
+      final List<SqlNode> orList = new ArrayList<>();
+      final RexBuilder rexBuilder =
+          new RexBuilder(
+              new SqlTypeFactoryImpl(RelDataTypeSystemImpl.DEFAULT));
+      final SqlNode operandSql = toSql(program, operand);
+      if (sarg.containsNull) {
+        orList.add(SqlStdOperatorTable.IS_NULL.createCall(POS, operandSql));
+      }
+      if (sarg.isPoints()) {
+        final SqlNodeList list = sarg.rangeSet.asRanges().stream()
+            .map(range ->
+                toSql(program,
+                    rexBuilder.makeLiteral(range.lowerEndpoint(), type, true,
+                        true)))
+            .collect(SqlNode.toList());
+        switch (list.size()) {
+        case 1:
+          orList.add(
+              SqlStdOperatorTable.EQUALS.createCall(POS, operandSql,
+                  list.get(0)));
+          break;
+        default:
+          orList.add(SqlStdOperatorTable.IN.createCall(POS, operandSql, list));
+        }
+      } else {
+        final RangeSets.Consumer<C> consumer =
+            new RangeToSql<>(operandSql, orList, v ->
+                toSql(program, rexBuilder.makeLiteral(v, type, false)));
+        RangeSets.forEach(sarg.rangeSet, consumer);
+      }
+      switch (orList.size()) {
+      case 0:
+        return SqlLiteral.createBoolean(false, POS);
+      case 1:
+        return orList.get(0);
+      default:
+        return SqlStdOperatorTable.OR.createCall(POS, orList);
+      }
+    }
+
     /** Converts an expression from {@link RexWindowBound} to {@link SqlNode}
      * format.
      *
@@ -1158,13 +1212,120 @@ public abstract class SqlImplementor {
     public SqlImplementor implementor() {
       throw new UnsupportedOperationException();
     }
+
+    /** Converts a {@link Range} to a SQL expression.
+     *
+     * @param <C> Value type */
+    private static class RangeToSql<C extends Comparable<C>>
+        implements RangeSets.Consumer<C> {
+      private final List<SqlNode> list;
+      private final Function<C, SqlNode> literalFactory;
+      private final SqlNode arg;
+
+      RangeToSql(SqlNode arg, List<SqlNode> list,
+          Function<C, SqlNode> literalFactory) {
+        this.arg = arg;
+        this.list = list;
+        this.literalFactory = literalFactory;
+      }
+
+      private void addAnd(SqlNode... nodes) {
+        list.add(SqlStdOperatorTable.AND.createCall(POS, nodes));
+      }
+
+      private SqlNode op(SqlOperator op, C value) {
+        return op.createCall(POS, arg, literalFactory.apply(value));
+      }
+
+      @Override public void all() {
+        list.add(SqlLiteral.createBoolean(true, POS));
+      }
+
+      @Override public void atLeast(C lower) {
+        list.add(op(SqlStdOperatorTable.GREATER_THAN_OR_EQUAL, lower));
+      }
+
+      @Override public void atMost(C upper) {
+        list.add(op(SqlStdOperatorTable.LESS_THAN_OR_EQUAL, upper));
+      }
+
+      @Override public void greaterThan(C lower) {
+        list.add(op(SqlStdOperatorTable.GREATER_THAN, lower));
+      }
+
+      @Override public void lessThan(C upper) {
+        list.add(op(SqlStdOperatorTable.LESS_THAN, upper));
+      }
+
+      @Override public void singleton(C value) {
+        list.add(op(SqlStdOperatorTable.EQUALS, value));
+      }
+
+      @Override public void closed(C lower, C upper) {
+        addAnd(op(SqlStdOperatorTable.GREATER_THAN_OR_EQUAL, lower),
+            op(SqlStdOperatorTable.LESS_THAN_OR_EQUAL, upper));
+      }
+
+      @Override public void closedOpen(C lower, C upper) {
+        addAnd(op(SqlStdOperatorTable.GREATER_THAN_OR_EQUAL, lower),
+            op(SqlStdOperatorTable.LESS_THAN, upper));
+      }
+
+      @Override public void openClosed(C lower, C upper) {
+        addAnd(op(SqlStdOperatorTable.GREATER_THAN, lower),
+            op(SqlStdOperatorTable.LESS_THAN_OR_EQUAL, upper));
+      }
+
+      @Override public void open(C lower, C upper) {
+        addAnd(op(SqlStdOperatorTable.GREATER_THAN, lower),
+            op(SqlStdOperatorTable.LESS_THAN, upper));
+      }
+    }
+  }
+
+  /** Converts a {@link RexLiteral} in the context of a {@link RexProgram}
+   * to a {@link SqlNode}. */
+  public static SqlNode toSql(RexProgram program, RexLiteral literal) {
+    switch (literal.getTypeName()) {
+    case SYMBOL:
+      final Enum symbol = (Enum) literal.getValue();
+      return SqlLiteral.createSymbol(symbol, POS);
+
+    case ROW:
+      //noinspection unchecked
+      final List<RexLiteral> list = literal.getValueAs(List.class);
+      return SqlStdOperatorTable.ROW.createCall(POS,
+          list.stream().map(e -> toSql(program, e))
+              .collect(Util.toImmutableList()));
+
+    case SARG:
+      final Sarg arg = literal.getValueAs(Sarg.class);
+      throw new AssertionError("sargs [" + arg
+          + "] should be handled as part of predicates, not as literals");
+
+    default:
+      return toSql(literal);
+    }
   }
 
   /** Converts a {@link RexLiteral} to a {@link SqlLiteral}. */
-  public static SqlLiteral toSql(RexLiteral literal) {
-    if (literal.getTypeName() == SqlTypeName.SYMBOL) {
+  public static SqlNode toSql(RexLiteral literal) {
+    switch (literal.getTypeName()) {
+    case SYMBOL:
       final Enum symbol = (Enum) literal.getValue();
       return SqlLiteral.createSymbol(symbol, POS);
+
+    case ROW:
+      //noinspection unchecked
+      final List<RexLiteral> list = literal.getValueAs(List.class);
+      return SqlStdOperatorTable.ROW.createCall(POS,
+          list.stream().map(e -> toSql(e))
+              .collect(Util.toImmutableList()));
+
+    case SARG:
+      final Sarg arg = literal.getValueAs(Sarg.class);
+      throw new AssertionError("sargs [" + arg
+          + "] should be handled as part of predicates, not as literals");
     }
     switch (literal.getTypeName().getFamily()) {
     case CHARACTER:
diff --git a/core/src/main/java/org/apache/calcite/rel/rules/SubQueryRemoveRule.java b/core/src/main/java/org/apache/calcite/rel/rules/SubQueryRemoveRule.java
index 4a1fdc0..28d1bb2 100644
--- a/core/src/main/java/org/apache/calcite/rel/rules/SubQueryRemoveRule.java
+++ b/core/src/main/java/org/apache/calcite/rel/rules/SubQueryRemoveRule.java
@@ -428,15 +428,15 @@ public class SubQueryRemoveRule
         builder.distinct();
         break;
       default:
-        List<RexNode> isNullOpperands = fields.stream()
+        List<RexNode> isNullOperands = fields.stream()
             .map(builder::isNull)
             .collect(Collectors.toList());
         // uses keyIsNulls conditions in the filter to avoid empty results
-        isNullOpperands.addAll(keyIsNulls);
+        isNullOperands.addAll(keyIsNulls);
         builder.filter(
             builder.or(
                 builder.and(conditions),
-                builder.or(isNullOpperands)));
+                builder.or(isNullOperands)));
         RexNode project = builder.and(
             fields.stream()
                 .map(builder::isNotNull)
diff --git a/core/src/main/java/org/apache/calcite/rex/RexBuilder.java b/core/src/main/java/org/apache/calcite/rex/RexBuilder.java
index 676cf49..7479ad2 100644
--- a/core/src/main/java/org/apache/calcite/rex/RexBuilder.java
+++ b/core/src/main/java/org/apache/calcite/rex/RexBuilder.java
@@ -46,13 +46,16 @@ import org.apache.calcite.sql.type.SqlTypeUtil;
 import org.apache.calcite.util.DateString;
 import org.apache.calcite.util.NlsString;
 import org.apache.calcite.util.Pair;
+import org.apache.calcite.util.Sarg;
 import org.apache.calcite.util.TimeString;
 import org.apache.calcite.util.TimestampString;
 import org.apache.calcite.util.Util;
 
 import com.google.common.base.Preconditions;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableRangeSet;
 import com.google.common.collect.Lists;
+import com.google.common.collect.Range;
 
 import java.math.BigDecimal;
 import java.math.MathContext;
@@ -959,8 +962,8 @@ public class RexBuilder {
       o = ((TimestampString) o).round(p);
       break;
     }
-    if (type.getSqlTypeName() == SqlTypeName.DECIMAL && !SqlTypeUtil
-        .isValidDecimalValue((BigDecimal) o, type)) {
+    if (typeName == SqlTypeName.DECIMAL
+        && !SqlTypeUtil.isValidDecimalValue((BigDecimal) o, type)) {
       throw new IllegalArgumentException(
           "Cannot convert " + o + " to " + type  + " due to overflow");
     }
@@ -1056,6 +1059,13 @@ public class RexBuilder {
   }
 
   /**
+   * Creates a search argument literal.
+   */
+  public RexLiteral makeSearchArgumentLiteral(Sarg s, RelDataType type) {
+    return makeLiteral(Objects.requireNonNull(s), type, SqlTypeName.SARG);
+  }
+
+  /**
    * Creates a character string literal.
    */
   public RexLiteral makeLiteral(String s) {
@@ -1289,6 +1299,108 @@ public class RexBuilder {
     return makeNullLiteral(typeFactory.createSqlType(typeName));
   }
 
+  /** Creates a {@link RexNode} representation a SQL "arg IN (point, ...)"
+   * expression.
+   *
+   * <p>If all of the expressions are literals, creates a call {@link Sarg}
+   * literal, "SEARCH(arg, SARG([point0..point0], [point1..point1], ...)";
+   * otherwise creates a disjunction, "arg = point0 OR arg = point1 OR ...". */
+  public RexNode makeIn(RexNode arg, List<? extends RexNode> ranges) {
+    if (areAssignable(arg, ranges)) {
+      final Sarg sarg = toSarg(Comparable.class, ranges, false);
+      if (sarg != null) {
+        final RexNode range0 = ranges.get(0);
+        return makeCall(SqlStdOperatorTable.SEARCH,
+            arg,
+            makeSearchArgumentLiteral(sarg, range0.getType()));
+      }
+    }
+    return RexUtil.composeDisjunction(this, ranges.stream()
+        .map(r -> makeCall(SqlStdOperatorTable.EQUALS, arg, r))
+        .collect(Util.toImmutableList()));
+  }
+
+  /** Returns whether and argument and bounds are have types that are
+   * sufficiently compatible to be converted to a {@link Sarg}. */
+  private boolean areAssignable(RexNode arg, List<? extends RexNode> bounds) {
+    for (RexNode bound : bounds) {
+      if (!SqlTypeUtil.inSameFamily(arg.getType(), bound.getType())
+          && !(arg.getType().isStruct() && bound.getType().isStruct())) {
+        return false;
+      }
+    }
+    return true;
+  }
+
+  /** Creates a {@link RexNode} representation a SQL
+   * "arg BETWEEN lower AND upper" expression.
+   *
+   * <p>If the expressions are all literals of compatible type, creates a call
+   * to {@link Sarg} literal, {@code SEARCH(arg, SARG([lower..upper])};
+   * otherwise creates a disjunction, {@code arg >= lower AND arg <= upper}. */
+  public RexNode makeBetween(RexNode arg, RexNode lower, RexNode upper) {
+    final Comparable lowerValue = toComparable(Comparable.class, lower);
+    final Comparable upperValue = toComparable(Comparable.class, upper);
+    if (lowerValue != null
+        && upperValue != null
+        && areAssignable(arg, Arrays.asList(lower, upper))) {
+      final Sarg sarg =
+          Sarg.of(false,
+              ImmutableRangeSet.<Comparable>of(
+                  Range.closed(lowerValue, upperValue)));
+      return makeCall(SqlStdOperatorTable.SEARCH, arg,
+          makeSearchArgumentLiteral(sarg, lower.getType()));
+    }
+    return makeCall(SqlStdOperatorTable.AND,
+        makeCall(SqlStdOperatorTable.GREATER_THAN_OR_EQUAL, arg, lower),
+        makeCall(SqlStdOperatorTable.LESS_THAN_OR_EQUAL, arg, upper));
+  }
+
+  /** Converts a list of expressions to a search argument, or returns null if
+   * not possible. */
+  private static <C extends Comparable<C>> Sarg<C> toSarg(Class<C> clazz,
+      List<? extends RexNode> ranges, boolean containsNull) {
+    if (ranges.isEmpty()) {
+      // Cannot convert an empty list to a Sarg (by this interface, at least)
+      // because we use the type of the first element.
+      return null;
+    }
+    final ImmutableRangeSet.Builder<C> b = ImmutableRangeSet.builder();
+    for (RexNode range : ranges) {
+      final C value = toComparable(clazz, range);
+      if (value == null) {
+        return null;
+      }
+      b.add(Range.singleton(value));
+    }
+    return Sarg.of(containsNull, b.build());
+  }
+
+  private static <C extends Comparable<C>> C toComparable(Class<C> clazz,
+      RexNode point) {
+    switch (point.getKind()) {
+    case LITERAL:
+      final RexLiteral literal = (RexLiteral) point;
+      return literal.getValueAs(clazz);
+
+    case ROW:
+      final RexCall call = (RexCall) point;
+      final ImmutableList.Builder<Comparable> b = ImmutableList.builder();
+      for (RexNode operand : call.operands) {
+        //noinspection unchecked
+        final Comparable value = toComparable(Comparable.class, operand);
+        if (value == null) {
+          return null; // not a constant value
+        }
+        b.add(value);
+      }
+      return clazz.cast(FlatLists.ofComparable(b.build()));
+
+    default:
+      return null; // not a constant value
+    }
+  }
+
   /**
    * Creates a copy of an expression, which may have been created using a
    * different RexBuilder and/or {@link RelDataTypeFactory}, using this
@@ -1357,17 +1469,43 @@ public class RexBuilder {
   }
 
   /**
+   * Creates a literal of a given type, padding values of constant-width
+   * types to match their type.
+   *
+   * @param value     Value
+   * @param type      Type
+   * @param allowCast Whether to allow a cast. If false, value is always a
+   *                  {@link RexLiteral} but may not be the exact type
+   * @return Simple literal, or cast simple literal
+   */
+  public RexNode makeLiteral(Object value, RelDataType type,
+      boolean allowCast) {
+    return makeLiteral(value, type, allowCast, false);
+  }
+
+  /**
    * Creates a literal of a given type. The value is assumed to be
    * compatible with the type.
    *
+   * <p>The {@code trim} parameter controls whether to trim values of
+   * constant-width types such as {@code CHAR}. Consider a call to
+   * {@code makeLiteral("foo ", CHAR(5)}, and note that the value is too short
+   * for its type. If {@code trim} is true, the value is converted to "foo"
+   * and the type to {@code CHAR(3)}; if {@code trim} is false, the value is
+   * right-padded with spaces to {@code "foo  "}, to match the type
+   * {@code CHAR(5)}.
+   *
    * @param value     Value
    * @param type      Type
    * @param allowCast Whether to allow a cast. If false, value is always a
    *                  {@link RexLiteral} but may not be the exact type
+   * @param trim      Whether to trim values and type to the shortest equivalent
+   *                  value; for example whether to convert CHAR(4) 'foo '
+   *                  to CHAR(3) 'foo'
    * @return Simple literal, or cast simple literal
    */
   public RexNode makeLiteral(Object value, RelDataType type,
-      boolean allowCast) {
+      boolean allowCast, boolean trim) {
     if (value == null) {
       return makeCast(type, constantNull);
     }
@@ -1383,7 +1521,12 @@ public class RexBuilder {
     final SqlTypeName sqlTypeName = type.getSqlTypeName();
     switch (sqlTypeName) {
     case CHAR:
-      return makeCharLiteral(padRight((NlsString) value, type.getPrecision()));
+      final NlsString nlsString = (NlsString) value;
+      if (trim) {
+        return makeCharLiteral(nlsString.rtrim());
+      } else {
+        return makeCharLiteral(padRight(nlsString, type.getPrecision()));
+      }
     case VARCHAR:
       literal = makeCharLiteral((NlsString) value);
       if (allowCast) {
diff --git a/core/src/main/java/org/apache/calcite/rex/RexCall.java b/core/src/main/java/org/apache/calcite/rex/RexCall.java
index 107d6e2..8d21072 100644
--- a/core/src/main/java/org/apache/calcite/rex/RexCall.java
+++ b/core/src/main/java/org/apache/calcite/rex/RexCall.java
@@ -80,6 +80,7 @@ public class RexCall extends RexNode {
     this.nodeCount = RexUtil.nodeCount(1, this.operands);
     assert op.getKind() != null : op;
     assert op.validRexOperands(operands.size(), Litmus.THROW) : this;
+    assert op.kind != SqlKind.IN || this instanceof RexSubQuery;
   }
 
   //~ Methods ----------------------------------------------------------------
diff --git a/core/src/main/java/org/apache/calcite/rex/RexLiteral.java b/core/src/main/java/org/apache/calcite/rex/RexLiteral.java
index 70d23a2..a4dd9c3 100644
--- a/core/src/main/java/org/apache/calcite/rex/RexLiteral.java
+++ b/core/src/main/java/org/apache/calcite/rex/RexLiteral.java
@@ -20,8 +20,11 @@ import org.apache.calcite.avatica.util.ByteString;
 import org.apache.calcite.avatica.util.DateTimeUtils;
 import org.apache.calcite.avatica.util.TimeUnit;
 import org.apache.calcite.config.CalciteSystemProperty;
+import org.apache.calcite.linq4j.function.Functions;
 import org.apache.calcite.rel.RelNode;
 import org.apache.calcite.rel.type.RelDataType;
+import org.apache.calcite.rel.type.RelDataTypeField;
+import org.apache.calcite.runtime.FlatLists;
 import org.apache.calcite.runtime.GeoFunctions;
 import org.apache.calcite.runtime.Geometries;
 import org.apache.calcite.sql.SqlCollation;
@@ -35,6 +38,7 @@ import org.apache.calcite.util.ConversionUtil;
 import org.apache.calcite.util.DateString;
 import org.apache.calcite.util.Litmus;
 import org.apache.calcite.util.NlsString;
+import org.apache.calcite.util.Sarg;
 import org.apache.calcite.util.TimeString;
 import org.apache.calcite.util.TimestampString;
 import org.apache.calcite.util.Util;
@@ -42,13 +46,11 @@ import org.apache.calcite.util.Util;
 import com.google.common.base.Preconditions;
 import com.google.common.collect.ImmutableList;
 
-import java.io.IOException;
 import java.io.PrintWriter;
 import java.math.BigDecimal;
 import java.nio.ByteBuffer;
 import java.nio.charset.Charset;
 import java.text.SimpleDateFormat;
-import java.util.AbstractList;
 import java.util.Calendar;
 import java.util.List;
 import java.util.Locale;
@@ -357,6 +359,8 @@ public class RexLiteral extends RexNode {
       return (value instanceof NlsString)
           && (((NlsString) value).getCharset() != null)
           && (((NlsString) value).getCollation() != null);
+    case SARG:
+      return value instanceof Sarg;
     case SYMBOL:
       return value instanceof Enum;
     case ROW:
@@ -373,21 +377,39 @@ public class RexLiteral extends RexNode {
     }
   }
 
+  /** Returns the strict literal type for a given type. */
+  public static SqlTypeName strictTypeName(RelDataType type) {
+    final SqlTypeName typeName = type.getSqlTypeName();
+    switch (typeName) {
+    case INTEGER:
+    case TINYINT:
+    case SMALLINT:
+      return SqlTypeName.DECIMAL;
+    case VARBINARY:
+      return SqlTypeName.BINARY;
+    case VARCHAR:
+      return SqlTypeName.CHAR;
+    default:
+      return typeName;
+    }
+  }
+
   private static String toJavaString(
       Comparable value,
       SqlTypeName typeName, RelDataType type,
       RexDigestIncludeType includeType) {
     assert includeType != RexDigestIncludeType.OPTIONAL
         : "toJavaString must not be called with includeType=OPTIONAL";
-    String fullTypeString = type.getFullTypeString();
     if (value == null) {
-      return includeType == RexDigestIncludeType.NO_TYPE ? "null" : "null:" + fullTypeString;
+      return includeType == RexDigestIncludeType.NO_TYPE ? "null"
+          : "null:" + type.getFullTypeString();
     }
     StringBuilder sb = new StringBuilder();
-    appendAsJava(value, sb, typeName, false, includeType);
+    appendAsJava(value, sb, typeName, type, false, includeType);
 
     if (includeType != RexDigestIncludeType.NO_TYPE) {
       sb.append(':');
+      final String fullTypeString = type.getFullTypeString();
       if (!fullTypeString.endsWith("NOT NULL")) {
         sb.append(fullTypeString);
       } else {
@@ -551,7 +573,9 @@ public class RexLiteral extends RexNode {
    * Prints the value this literal as a Java string constant.
    */
   public void printAsJava(PrintWriter pw) {
-    appendAsJava(value, pw, typeName, true, RexDigestIncludeType.NO_TYPE);
+    Util.asStringBuilder(pw, sb ->
+        appendAsJava(value, sb, typeName, type, true,
+            RexDigestIncludeType.NO_TYPE));
   }
 
   /**
@@ -568,133 +592,149 @@ public class RexLiteral extends RexNode {
    * <li>1234ABCD</li>
    * </ul>
    *
-   * <p>The destination where the value is appended must not incur I/O operations. This method is
-   * not meant to be used for writing the values to permanent storage.</p>
-   *
-   * @param value a value to be appended to the provided destination as a Java string
-   * @param destination a destination where to append the specified value
-   * @param typeName a type name to be used for the transformation of the value to a Java string
-   * @param includeType an indicator whether to include the data type in the Java representation
-   * @throws IllegalStateException if the appending to the destination <code>Appendable</code> fails
-   *         due to I/O
+   * @param value    Value to be appended to the provided destination as a Java string
+   * @param sb       Destination to which to append the specified value
+   * @param typeName Type name to be used for the transformation of the value to a Java string
+   * @param type Type to be used for the transformation of the value to a Java string
+   * @param includeType Whether to include the data type in the Java representation
    */
-  private static void appendAsJava(
-      Comparable value,
-      Appendable destination,
-      SqlTypeName typeName,
-      boolean java, RexDigestIncludeType includeType) {
-    try {
-      switch (typeName) {
-      case CHAR:
-        NlsString nlsString = (NlsString) value;
-        if (java) {
-          Util.printJavaString(
-              destination,
-              nlsString.getValue(),
-              true);
-        } else {
-          boolean includeCharset =
-              (nlsString.getCharsetName() != null)
-                  && !nlsString.getCharsetName().equals(
-                  CalciteSystemProperty.DEFAULT_CHARSET.value());
-          destination.append(nlsString.asSql(includeCharset, false));
-        }
-        break;
-      case BOOLEAN:
-        assert value instanceof Boolean;
-        destination.append(value.toString());
-        break;
-      case DECIMAL:
-        assert value instanceof BigDecimal;
-        destination.append(value.toString());
-        break;
-      case DOUBLE:
-        assert value instanceof BigDecimal;
-        destination.append(Util.toScientificNotation((BigDecimal) value));
-        break;
-      case BIGINT:
-        assert value instanceof BigDecimal;
-        long narrowLong = ((BigDecimal) value).longValue();
-        destination.append(String.valueOf(narrowLong));
-        destination.append('L');
-        break;
-      case BINARY:
-        assert value instanceof ByteString;
-        destination.append("X'");
-        destination.append(((ByteString) value).toString(16));
-        destination.append("'");
-        break;
-      case NULL:
-        assert value == null;
-        destination.append("null");
-        break;
-      case SYMBOL:
-        assert value instanceof Enum;
-        destination.append("FLAG(");
-        destination.append(value.toString());
-        destination.append(")");
-        break;
-      case DATE:
-        assert value instanceof DateString;
-        destination.append(value.toString());
-        break;
-      case TIME:
-        assert value instanceof TimeString;
-        destination.append(value.toString());
-        break;
-      case TIME_WITH_LOCAL_TIME_ZONE:
-        assert value instanceof TimeString;
-        destination.append(value.toString());
-        break;
-      case TIMESTAMP:
-        assert value instanceof TimestampString;
-        destination.append(value.toString());
-        break;
-      case TIMESTAMP_WITH_LOCAL_TIME_ZONE:
-        assert value instanceof TimestampString;
-        destination.append(value.toString());
-        break;
-      case INTERVAL_YEAR:
-      case INTERVAL_YEAR_MONTH:
-      case INTERVAL_MONTH:
-      case INTERVAL_DAY:
-      case INTERVAL_DAY_HOUR:
-      case INTERVAL_DAY_MINUTE:
-      case INTERVAL_DAY_SECOND:
-      case INTERVAL_HOUR:
-      case INTERVAL_HOUR_MINUTE:
-      case INTERVAL_HOUR_SECOND:
-      case INTERVAL_MINUTE:
-      case INTERVAL_MINUTE_SECOND:
-      case INTERVAL_SECOND:
-        assert value instanceof BigDecimal;
-        destination.append(value.toString());
-        break;
-      case MULTISET:
-      case ROW:
-        @SuppressWarnings("unchecked")
-        final List<RexLiteral> list = (List) value;
-        destination.append(
-            (new AbstractList<String>() {
-              public String get(int index) {
-                return list.get(index).computeDigest(includeType);
-              }
-
-              public int size() {
-                return list.size();
-              }
-            }).toString());
-        break;
-      case GEOMETRY:
-        final String wkt = GeoFunctions.ST_AsWKT((Geometries.Geom) value);
-        destination.append(wkt);
-        break;
-      default:
-        assert valueMatchesType(value, typeName, true);
-        throw Util.needToImplement(typeName);
+  private static void appendAsJava(Comparable value, StringBuilder sb,
+      SqlTypeName typeName, RelDataType type, boolean java,
+      RexDigestIncludeType includeType) {
+    switch (typeName) {
+    case CHAR:
+      NlsString nlsString = (NlsString) value;
+      if (java) {
+        Util.printJavaString(
+            sb,
+            nlsString.getValue(),
+            true);
+      } else {
+        boolean includeCharset =
+            (nlsString.getCharsetName() != null)
+                && !nlsString.getCharsetName().equals(
+                CalciteSystemProperty.DEFAULT_CHARSET.value());
+        sb.append(nlsString.asSql(includeCharset, false));
       }
-    } catch (IOException e) {
-      throw new IllegalStateException("The destination Appendable should not incur I/O.", e);
+      break;
+    case BOOLEAN:
+      assert value instanceof Boolean;
+      sb.append(value.toString());
+      break;
+    case DECIMAL:
+      assert value instanceof BigDecimal;
+      sb.append(value.toString());
+      break;
+    case DOUBLE:
+      assert value instanceof BigDecimal;
+      sb.append(Util.toScientificNotation((BigDecimal) value));
+      break;
+    case BIGINT:
+      assert value instanceof BigDecimal;
+      long narrowLong = ((BigDecimal) value).longValue();
+      sb.append(String.valueOf(narrowLong));
+      sb.append('L');
+      break;
+    case BINARY:
+      assert value instanceof ByteString;
+      sb.append("X'");
+      sb.append(((ByteString) value).toString(16));
+      sb.append("'");
+      break;
+    case NULL:
+      assert value == null;
+      sb.append("null");
+      break;
+    case SARG:
+      assert value instanceof Sarg;
+      //noinspection unchecked,rawtypes
+      Util.asStringBuilder(sb, sb2 ->
+          printSarg(sb2, (Sarg) value, type));
+      break;
+    case SYMBOL:
+      assert value instanceof Enum;
+      sb.append("FLAG(");
+      sb.append(value.toString());
+      sb.append(")");
+      break;
+    case DATE:
+      assert value instanceof DateString;
+      sb.append(value.toString());
+      break;
+    case TIME:
+    case TIME_WITH_LOCAL_TIME_ZONE:
+      assert value instanceof TimeString;
+      sb.append(value.toString());
+      break;
+    case TIMESTAMP:
+    case TIMESTAMP_WITH_LOCAL_TIME_ZONE:
+      assert value instanceof TimestampString;
+      sb.append(value.toString());
+      break;
+    case INTERVAL_YEAR:
+    case INTERVAL_YEAR_MONTH:
+    case INTERVAL_MONTH:
+    case INTERVAL_DAY:
+    case INTERVAL_DAY_HOUR:
+    case INTERVAL_DAY_MINUTE:
+    case INTERVAL_DAY_SECOND:
+    case INTERVAL_HOUR:
+    case INTERVAL_HOUR_MINUTE:
+    case INTERVAL_HOUR_SECOND:
+    case INTERVAL_MINUTE:
+    case INTERVAL_MINUTE_SECOND:
+    case INTERVAL_SECOND:
+      assert value instanceof BigDecimal;
+      sb.append(value.toString());
+      break;
+    case MULTISET:
+    case ROW:
+      final List<RexLiteral> list = (List) value;
+      Util.asStringBuilder(sb, sb2 ->
+          Util.printList(sb, list.size(), (sb3, i) ->
+              sb3.append(list.get(i).computeDigest(includeType))));
+      break;
+    case GEOMETRY:
+      final String wkt = GeoFunctions.ST_AsWKT((Geometries.Geom) value);
+      sb.append(wkt);
+      break;
+    default:
+      assert valueMatchesType(value, typeName, true);
+      throw Util.needToImplement(typeName);
+    }
+  }
+
+  private static <C extends Comparable<C>> void printSarg(StringBuilder sb,
+      Sarg<C> sarg, RelDataType type) {
+    sarg.printTo(sb, (sb2, value) ->
+        sb2.append(toLiteral(type, value)));
+  }
+
+  /** Converts a value to a temporary literal, for the purposes of generating a
+   * digest. Literals of type ROW and MULTISET require that their components are
+   * also literals. */
+  private static RexLiteral toLiteral(RelDataType type, Comparable<?> value) {
+    final SqlTypeName typeName = strictTypeName(type);
+    switch (typeName) {
+    case ROW:
+      final List<Comparable<?>> fieldValues = (List) value;
+      final List<RelDataTypeField> fields = type.getFieldList();
+      final List<RexLiteral> fieldLiterals =
+          FlatLists.of(
+              Functions.generate(fieldValues.size(), i ->
+                  toLiteral(fields.get(i).getType(), fieldValues.get(i))));
+      return new RexLiteral((Comparable) fieldLiterals, type, typeName);
+
+    case MULTISET:
+      final List<Comparable<?>> elementValues = (List) value;
+      final List<RexLiteral> elementLiterals =
+          FlatLists.of(
+              Functions.generate(elementValues.size(), i ->
+                  toLiteral(type.getComponentType(), elementValues.get(i))));
+      return new RexLiteral((Comparable) elementLiterals, type, typeName);
+
+    default:
+      return new RexLiteral(value, type, typeName);
     }
   }
 
diff --git a/core/src/main/java/org/apache/calcite/rex/RexSimplify.java b/core/src/main/java/org/apache/calcite/rex/RexSimplify.java
index b48fd7e..464825e 100644
--- a/core/src/main/java/org/apache/calcite/rex/RexSimplify.java
+++ b/core/src/main/java/org/apache/calcite/rex/RexSimplify.java
@@ -33,6 +33,7 @@ import org.apache.calcite.sql.type.SqlTypeUtil;
 import org.apache.calcite.util.Bug;
 import org.apache.calcite.util.Pair;
 import org.apache.calcite.util.RangeSets;
+import org.apache.calcite.util.Sarg;
 import org.apache.calcite.util.Util;
 
 import com.google.common.collect.ArrayListMultimap;
@@ -58,6 +59,7 @@ import java.util.List;
 import java.util.Map;
 import java.util.Objects;
 import java.util.Set;
+import javax.annotation.Nonnull;
 
 import static org.apache.calcite.rex.RexUnknownAs.FALSE;
 import static org.apache.calcite.rex.RexUnknownAs.TRUE;
@@ -296,6 +298,8 @@ public class RexSimplify {
     case LESS_THAN_OR_EQUAL:
     case NOT_EQUALS:
       return simplifyComparison((RexCall) e, unknownAs);
+    case SEARCH:
+      return simplifySearch((RexCall) e, unknownAs);
     case LIKE:
       return simplifyLike((RexCall) e);
     case MINUS_PREFIX:
@@ -653,15 +657,19 @@ public class RexSimplify {
     return simplify(call.getOperands().get(0), unknownAs);
   }
 
-  private RexNode simplifyIs(RexCall call, RexUnknownAs unknownAs) {
+  private @Nonnull RexNode simplifyIs(RexCall call, RexUnknownAs unknownAs) {
     final SqlKind kind = call.getKind();
     final RexNode a = call.getOperands().get(0);
+    final RexNode simplified = simplifyIs1(kind, a, unknownAs);
+    return simplified == null ? call : simplified;
+  }
 
+  private RexNode simplifyIs1(SqlKind kind, RexNode a, RexUnknownAs unknownAs) {
     // UnknownAs.FALSE corresponds to x IS TRUE evaluation
     // UnknownAs.TRUE to x IS NOT FALSE
     // Note that both UnknownAs.TRUE and UnknownAs.FALSE only changes the meaning of Unknown
     // (1) if we are already in UnknownAs.FALSE mode; x IS TRUE can be simplified to x
-    // (2) similarily in UnknownAs.TRUE mode; x IS NOT FALSE can be simplified to x
+    // (2) similarly in UnknownAs.TRUE mode; x IS NOT FALSE can be simplified to x
     // (3) x IS FALSE could be rewritten to (NOT x) IS TRUE and from there the 1. rule applies
     // (4) x IS NOT TRUE can be rewritten to (NOT x) IS NOT FALSE and from there the 2. rule applies
     if (kind == SqlKind.IS_TRUE && unknownAs == RexUnknownAs.FALSE) {
@@ -681,11 +689,7 @@ public class RexSimplify {
       return pred;
     }
 
-    final RexNode simplified = simplifyIs2(kind, a, unknownAs);
-    if (simplified != null) {
-      return simplified;
-    }
-    return call;
+    return simplifyIs2(kind, a, unknownAs);
   }
 
   private RexNode simplifyIsPredicate(SqlKind kind, RexNode a) {
@@ -1118,7 +1122,7 @@ public class RexSimplify {
       safeOps.add(SqlKind.IS_DISTINCT_FROM);
       safeOps.add(SqlKind.IS_NOT_DISTINCT_FROM);
       safeOps.add(SqlKind.IN);
-      safeOps.add(SqlKind.NOT_IN);
+      safeOps.add(SqlKind.SEARCH);
       safeOps.add(SqlKind.OR);
       safeOps.add(SqlKind.AND);
       safeOps.add(SqlKind.NOT);
@@ -1294,6 +1298,14 @@ public class RexSimplify {
     final List<RexNode> terms = new ArrayList<>();
     final List<RexNode> notTerms = new ArrayList<>();
 
+    final SargCollector sargCollector = new SargCollector(rexBuilder, true);
+    operands.forEach(t -> sargCollector.accept(t, terms));
+    if (sargCollector.map.values().stream().anyMatch(b -> b.complexity() > 1)) {
+      operands.clear();
+      terms.forEach(t -> operands.add(sargCollector.fix(rexBuilder, t)));
+    }
+    terms.clear();
+
     for (RexNode o : operands) {
       RelOptUtil.decomposeConjunction(o, terms, notTerms);
     }
@@ -1702,51 +1714,6 @@ public class RexSimplify {
     return result;
   }
 
-  /** Subtracts a range from a range set. */
-  public static <C extends Comparable<C>> RangeSet<C> minus(RangeSet<C> rangeSet,
-      Range<C> range) {
-    final TreeRangeSet<C> mutableRangeSet = TreeRangeSet.create(rangeSet);
-    mutableRangeSet.remove(range);
-    return mutableRangeSet.equals(rangeSet) ? rangeSet
-        : ImmutableRangeSet.copyOf(mutableRangeSet);
-  }
-
-  // TODO
-  public static <C extends Comparable<C>> RangeSet<C> minus0(RangeSet<C> rangeSet,
-      Range<C> range) {
-    ImmutableRangeSet<C> x = ImmutableRangeSet.of(range).complement();
-    final ImmutableRangeSet.Builder<C> builder = ImmutableRangeSet.<C>builder();
-    for (Range<C> r : x.asRanges()) {
-      builder.addAll(rangeSet.subRangeSet(r));
-    }
-    return builder.build();
-  }
-
-  /** Returns the intersection of two range sets.
-   *
-   * <p>For example:
-   * <ul>
-   *   <li>empty intersect empty &rarr; empty
-   *   <li>{1, [3, 5], (9, &inf;)} intersect {1, 3, 5, 7, 9, 11}} &rarr;
-   *   {1, 3, 5, 11}
-   *   <li>all intersect {[3, 5)} &rarr; {[3, 5)}
-   * </ul>
-   *
-   * @param s0 First range set
-   * @param s1 Second range set
-   * @param <C> Value type
-   * @return Intersection of range sets
-   */
-  // TODO
-  private static <C extends Comparable<C>> RangeSet<C> intersect(RangeSet<C> s0,
-      RangeSet<C> s1) {
-    RangeSet<C> s = s0;
-    for (Range<C> range : s1.asRanges()) {
-      s = s.subRangeSet(range);
-    }
-    return s;
-  }
-
   /** Simplifies OR(x, x) into x, and similar.
    * The simplified expression returns UNKNOWN values as is (not as FALSE). */
   @Deprecated // to be removed before 2.0
@@ -1787,6 +1754,14 @@ public class RexSimplify {
   /** Simplifies a list of terms and combines them into an OR.
    * Modifies the list in place. */
   private RexNode simplifyOrs(List<RexNode> terms, RexUnknownAs unknownAs) {
+    final SargCollector sargCollector = new SargCollector(rexBuilder, false);
+    final List<RexNode> newTerms = new ArrayList<>();
+    terms.forEach(t -> sargCollector.accept(t, newTerms));
+    if (sargCollector.map.values().stream().anyMatch(b -> b.complexity() > 1)) {
+      terms.clear();
+      newTerms.forEach(t -> terms.add(sargCollector.fix(rexBuilder, t)));
+    }
+
     // CALCITE-3198 Auxiliary map to simplify cases like:
     //   X <> A OR X <> B => X IS NOT NULL or NULL
     // The map key will be the 'X'; and the value the first call 'X<>A' that is found,
@@ -1855,7 +1830,6 @@ public class RexSimplify {
         }
         break;
       }
-      terms.set(i, term);
     }
     return RexUtil.composeDisjunction(rexBuilder, terms);
   }
@@ -1918,6 +1892,27 @@ public class RexSimplify {
     }
   }
 
+  private RexNode simplifySearch(RexCall call, RexUnknownAs unknownAs) {
+    assert call.getKind() == SqlKind.SEARCH;
+    final RexNode a = call.getOperands().get(0);
+    if (call.getOperands().get(1) instanceof RexLiteral) {
+      RexLiteral literal = (RexLiteral) call.getOperands().get(1);
+      final Sarg sarg = literal.getValueAs(Sarg.class);
+      if (sarg.containsNull) {
+        final RexNode simplified = simplifyIs1(SqlKind.IS_NULL, a, unknownAs);
+        if (simplified != null
+            && simplified.isAlwaysFalse()) {
+          final Sarg sarg2 = Sarg.of(false, sarg.rangeSet);
+          final RexLiteral literal2 =
+              rexBuilder.makeLiteral(sarg2, literal.getType(),
+                  literal.getTypeName());
+          return call.clone(call.type, ImmutableList.of(a, literal2));
+        }
+      }
+    }
+    return call;
+  }
+
   private RexNode simplifyCast(RexCall e) {
     RexNode operand = e.getOperands().get(0);
     operand = simplify(operand, UNKNOWN);
@@ -2515,4 +2510,214 @@ public class RexSimplify {
     return true;
   }
 
+  /** Gathers expressions that can be converted into
+   * {@link Sarg search arguments}. */
+  static class SargCollector {
+    final Map<RexNode, RexSargBuilder> map = new HashMap<>();
+    private final RexBuilder rexBuilder;
+    private final boolean negate;
+
+    SargCollector(RexBuilder rexBuilder, boolean negate) {
+      this.rexBuilder = rexBuilder;
+      this.negate = negate;
+    }
+
+    private void accept(RexNode term, List<RexNode> newTerms) {
+      if (!accept_(term, newTerms)) {
+        newTerms.add(term);
+      }
+    }
+
+    private boolean accept_(RexNode e, List<RexNode> newTerms) {
+      switch (e.getKind()) {
+      case LESS_THAN:
+      case LESS_THAN_OR_EQUAL:
+      case GREATER_THAN:
+      case GREATER_THAN_OR_EQUAL:
+      case EQUALS:
+      case NOT_EQUALS:
+      case SEARCH:
+        return accept2(((RexCall) e).operands.get(0),
+            ((RexCall) e).operands.get(1), e.getKind(), newTerms);
+      case IS_NULL:
+        if (negate) {
+          return false;
+        }
+        return accept1(((RexCall) e).operands.get(0), e.getKind(),
+            rexBuilder.makeNullLiteral(e.getType()), newTerms);
+      default:
+        return false;
+      }
+    }
+
+    private boolean accept2(RexNode left, RexNode right, SqlKind kind,
+        List<RexNode> newTerms) {
+      switch (left.getKind()) {
+      case INPUT_REF:
+      case FIELD_ACCESS:
+        switch (right.getKind()) {
+        case LITERAL:
+          return accept1(left, kind, (RexLiteral) right, newTerms);
+        }
+        return false;
+      case LITERAL:
+        switch (right.getKind()) {
+        case INPUT_REF:
+        case FIELD_ACCESS:
+          return accept1(right, kind.reverse(), (RexLiteral) left, newTerms);
+        }
+        return false;
+      }
+      return false;
+    }
+
+    private static <E> E addFluent(List<? super E> list, E e) {
+      list.add(e);
+      return e;
+    }
+
+    // always returns true
+    private boolean accept1(RexNode e, SqlKind kind,
+        @Nonnull RexLiteral literal, List<RexNode> newTerms) {
+      final RexSargBuilder b =
+          map.computeIfAbsent(e, e2 ->
+              addFluent(newTerms, new RexSargBuilder(e2, rexBuilder, negate)));
+      if (negate) {
+        kind = kind.negateNullSafe();
+      }
+      final Comparable value = literal.getValueAs(Comparable.class);
+      switch (kind) {
+      case LESS_THAN:
+        b.addRange(Range.lessThan(value), literal.getType());
+        return true;
+      case LESS_THAN_OR_EQUAL:
+        b.addRange(Range.atMost(value), literal.getType());
+        return true;
+      case GREATER_THAN:
+        b.addRange(Range.greaterThan(value), literal.getType());
+        return true;
+      case GREATER_THAN_OR_EQUAL:
+        b.addRange(Range.atLeast(value), literal.getType());
+        return true;
+      case EQUALS:
+        b.addRange(Range.singleton(value), literal.getType());
+        return true;
+      case NOT_EQUALS:
+        b.addRange(Range.lessThan(value), literal.getType());
+        b.addRange(Range.greaterThan(value), literal.getType());
+        return true;
+      case SEARCH:
+        final Sarg sarg = literal.getValueAs(Sarg.class);
+        b.addSarg(sarg, negate, literal.getType());
+        return true;
+      case IS_NULL:
+        if (negate) {
+          throw new AssertionError();
+        }
+        b.containsNull = true;
+        return true;
+      default:
+        throw new AssertionError("unexpected " + kind);
+      }
+    }
+
+    /** If a term is a call to {@code SEARCH} on a {@link RexSargBuilder},
+     * converts it to a {@code SEARCH} on a {@link Sarg}. */
+    RexNode fix(RexBuilder rexBuilder, RexNode term) {
+      if (term instanceof RexSargBuilder) {
+        RexSargBuilder sargBuilder = (RexSargBuilder) term;
+        return rexBuilder.makeCall(SqlStdOperatorTable.SEARCH, sargBuilder.ref,
+            rexBuilder.makeSearchArgumentLiteral(sargBuilder.build(negate),
+                term.getType()));
+      }
+      return term;
+    }
+  }
+
+  /** Equivalent to a {@link RexLiteral} whose value is a {@link Sarg},
+   * but mutable, so that the Sarg can be expanded as {@link SargCollector}
+   * traverses a list of OR or AND terms.
+   *
+   * <p>The {@link SargCollector#fix} method converts it to an immutable
+   * literal. */
+  static class RexSargBuilder extends RexNode {
+    final RexNode ref;
+    private final RexBuilder rexBuilder;
+    private List<RelDataType> types = new ArrayList<>();
+    final RangeSet<Comparable> rangeSet = TreeRangeSet.create();
+    boolean containsNull;
+
+    RexSargBuilder(RexNode ref, RexBuilder rexBuilder, boolean negate) {
+      this.ref = Objects.requireNonNull(ref);
+      this.rexBuilder = Objects.requireNonNull(rexBuilder);
+      this.containsNull = false;
+    }
+
+    @Override public String toString() {
+      return "SEARCH(" + ref + ", " + rangeSet
+          + (containsNull ? " + null)" : ")");
+    }
+
+    /** Returns a rough estimate of whether it is worth converting to a Sarg.
+     *
+     * <p>Examples:
+     * <ul>
+     *   <li>{@code x = 1}, {@code x <> 1}, {@code x > 1} have complexity 1
+     *   <li>{@code x > 1 or x is null} has complexity 2
+     *   <li>{@code x in (2, 4, 6) or x > 20} has complexity 4
+     * </ul>
+     */
+    int complexity() {
+      int complexity = 0;
+      if (rangeSet.asRanges().size() == 2
+          && rangeSet.complement().asRanges().size() == 1
+          && RangeSets.isPoint(
+              Iterables.getOnlyElement(rangeSet.complement().asRanges()))) {
+        complexity++;
+      } else {
+        complexity += rangeSet.asRanges().size();
+      }
+      if (containsNull) {
+        ++complexity;
+      }
+      return complexity;
+    }
+
+    <C extends Comparable<C>> Sarg<C> build(boolean negate) {
+      final RangeSet rangeSet =
+          negate ? this.rangeSet.complement() : this.rangeSet;
+      return Sarg.of(containsNull, rangeSet);
+    }
+
+    @Override public RelDataType getType() {
+      return rexBuilder.typeFactory.leastRestrictive(Util.distinctList(types));
+    }
+
+    @Override public <R> R accept(RexVisitor<R> visitor) {
+      throw new UnsupportedOperationException();
+    }
+
+    @Override public <R, P> R accept(RexBiVisitor<R, P> visitor, P arg) {
+      throw new UnsupportedOperationException();
+    }
+
+    @Override public boolean equals(Object obj) {
+      throw new UnsupportedOperationException();
+    }
+
+    @Override public int hashCode() {
+      throw new UnsupportedOperationException();
+    }
+
+    void addRange(Range<Comparable> range, RelDataType type) {
+      types.add(type);
+      rangeSet.add(range);
+    }
+
+    void addSarg(Sarg sarg, boolean negate, RelDataType type) {
+      types.add(type);
+      rangeSet.addAll(negate ? sarg.rangeSet.complement() : sarg.rangeSet);
+      containsNull |= sarg.containsNull;
+    }
+  }
 }
diff --git a/core/src/main/java/org/apache/calcite/rex/RexUtil.java b/core/src/main/java/org/apache/calcite/rex/RexUtil.java
index ec42695..f111279 100644
--- a/core/src/main/java/org/apache/calcite/rex/RexUtil.java
+++ b/core/src/main/java/org/apache/calcite/rex/RexUtil.java
@@ -42,6 +42,8 @@ import org.apache.calcite.sql.validate.SqlValidatorUtil;
 import org.apache.calcite.util.ControlFlowException;
 import org.apache.calcite.util.Litmus;
 import org.apache.calcite.util.Pair;
+import org.apache.calcite.util.RangeSets;
+import org.apache.calcite.util.Sarg;
 import org.apache.calcite.util.Util;
 import org.apache.calcite.util.mapping.Mappings;
 
@@ -49,6 +51,7 @@ import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
+import com.google.common.collect.Range;
 
 import org.apiguardian.api.API;
 
@@ -64,6 +67,8 @@ import java.util.Objects;
 import java.util.Set;
 import java.util.function.Predicate;
 import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
 
 /**
  * Utility methods concerning row-expressions.
@@ -555,6 +560,65 @@ public class RexUtil {
     };
   }
 
+  public static RexNode expandSearch(RexBuilder rexBuilder,
+      @Nullable RexProgram program, RexNode node) {
+    final RexShuttle shuttle = new RexShuttle() {
+      @Override public RexNode visitCall(RexCall call) {
+        switch (call.getKind()) {
+        case SEARCH:
+          return visitSearch(rexBuilder, program, call);
+        default:
+          return super.visitCall(call);
+        }
+      }
+    };
+    return node.accept(shuttle);
+  }
+
+  private static <C extends Comparable<C>> RexNode
+      visitSearch(RexBuilder rexBuilder,
+          @Nullable RexProgram program, RexCall call) {
+    final RexNode ref = call.operands.get(0);
+    final RexLiteral literal =
+        (RexLiteral) deref(program, call.operands.get(1));
+    @SuppressWarnings("unchecked")
+    final Sarg<C> sarg = literal.getValueAs(Sarg.class);
+    final List<RexNode> orList = new ArrayList<>();
+
+    if (sarg.containsNull) {
+      orList.add(rexBuilder.makeCall(SqlStdOperatorTable.IS_NULL, ref));
+    }
+    if (sarg.isPoints()) {
+      // Generate 'ref = value1 OR ... OR ref = valueN'
+      sarg.rangeSet.asRanges().forEach(range ->
+          orList.add(
+              rexBuilder.makeCall(SqlStdOperatorTable.EQUALS, ref,
+                  rexBuilder.makeLiteral(range.lowerEndpoint(),
+                      literal.getType(), true, true))));
+    } else if (sarg.isComplementedPoints()) {
+      // Generate 'ref <> value1 AND ... AND ref <> valueN'
+      final List<RexNode> list = sarg.rangeSet.complement().asRanges().stream()
+          .map(range ->
+              rexBuilder.makeCall(SqlStdOperatorTable.NOT_EQUALS, ref,
+                  rexBuilder.makeLiteral(range.lowerEndpoint(),
+                      literal.getType(), true, true)))
+          .collect(Util.toImmutableList());
+      orList.add(composeConjunction(rexBuilder, list));
+    } else {
+      final RangeSets.Consumer<C> consumer =
+          new RangeToRex<>(ref, orList, rexBuilder, literal.getType());
+      RangeSets.forEach(sarg.rangeSet, consumer);
+    }
+    return composeDisjunction(rexBuilder, orList);
+  }
+
+  private static RexNode deref(RexProgram program, RexNode node) {
+    while (node instanceof RexLocalRef) {
+      node = program.getExprList().get(((RexLocalRef) node).index);
+    }
+    return node;
+  }
+
   /**
    * Walks over an expression and determines whether it is constant.
    */
@@ -2833,4 +2897,75 @@ public class RexUtil {
     }
   }
 
+  /** Converts a {@link Range} to a {@link RexNode} expression.
+   *
+   * @param <C> Value type */
+  private static class RangeToRex<C extends Comparable<C>>
+      implements RangeSets.Consumer<C> {
+    private final List<RexNode> list;
+    private final RexBuilder rexBuilder;
+    private final RelDataType type;
+    private final RexNode ref;
+
+    RangeToRex(RexNode ref, List<RexNode> list, RexBuilder rexBuilder,
+        RelDataType type) {
+      this.ref = Objects.requireNonNull(ref);
+      this.list = Objects.requireNonNull(list);
+      this.rexBuilder = Objects.requireNonNull(rexBuilder);
+      this.type = Objects.requireNonNull(type);
+    }
+
+    private void addAnd(RexNode... nodes) {
+      list.add(rexBuilder.makeCall(SqlStdOperatorTable.AND, nodes));
+    }
+
+    private RexNode op(SqlOperator op, C value) {
+      return rexBuilder.makeCall(op, ref,
+          rexBuilder.makeLiteral(value, type, true, true));
+    }
+
+    @Override public void all() {
+      list.add(rexBuilder.makeLiteral(true));
+    }
+
+    @Override public void atLeast(C lower) {
+      list.add(op(SqlStdOperatorTable.GREATER_THAN_OR_EQUAL, lower));
+    }
+
+    @Override public void atMost(C upper) {
+      list.add(op(SqlStdOperatorTable.LESS_THAN_OR_EQUAL, upper));
+    }
+
+    @Override public void greaterThan(C lower) {
+      list.add(op(SqlStdOperatorTable.GREATER_THAN, lower));
+    }
+
+    @Override public void lessThan(C upper) {
+      list.add(op(SqlStdOperatorTable.LESS_THAN, upper));
+    }
+
+    @Override public void singleton(C value) {
+      list.add(op(SqlStdOperatorTable.EQUALS, value));
+    }
+
+    @Override public void closed(C lower, C upper) {
+      addAnd(op(SqlStdOperatorTable.GREATER_THAN_OR_EQUAL, lower),
+          op(SqlStdOperatorTable.LESS_THAN_OR_EQUAL, upper));
+    }
+
+    @Override public void closedOpen(C lower, C upper) {
+      addAnd(op(SqlStdOperatorTable.GREATER_THAN_OR_EQUAL, lower),
+          op(SqlStdOperatorTable.LESS_THAN, upper));
+    }
+
+    @Override public void openClosed(C lower, C upper) {
+      addAnd(op(SqlStdOperatorTable.GREATER_THAN, lower),
+          op(SqlStdOperatorTable.LESS_THAN_OR_EQUAL, upper));
+    }
+
+    @Override public void open(C lower, C upper) {
+      addAnd(op(SqlStdOperatorTable.GREATER_THAN, lower),
+          op(SqlStdOperatorTable.LESS_THAN, upper));
+    }
+  }
 }
diff --git a/core/src/main/java/org/apache/calcite/sql/SqlKind.java b/core/src/main/java/org/apache/calcite/sql/SqlKind.java
index d14477c..5df60d7 100644
--- a/core/src/main/java/org/apache/calcite/sql/SqlKind.java
+++ b/core/src/main/java/org/apache/calcite/sql/SqlKind.java
@@ -274,6 +274,12 @@ public enum SqlKind {
    */
   NOT_IN("NOT IN"),
 
+  /** Variant of {@code IN} for the Druid adapter. */
+  DRUID_IN,
+
+  /** Variant of {@code NOT_IN} for the Druid adapter. */
+  DRUID_NOT_IN,
+
   /** Less-than operator, "&lt;". */
   LESS_THAN("<"),
 
@@ -301,6 +307,10 @@ public enum SqlKind {
   /** {@code IS NOT DISTINCT FROM} operator. */
   IS_NOT_DISTINCT_FROM,
 
+  /** {@code SEARCH} operator. (Analogous to scalar {@code IN}, used only in
+   * RexNode, not SqlNode.) */
+  SEARCH,
+
   /** Logical "OR" operator. */
   OR,
 
@@ -349,6 +359,9 @@ public enum SqlKind {
   /** {@code BETWEEN} operator. */
   BETWEEN,
 
+  /** Variant of {@code BETWEEN} for the Druid adapter. */
+  DRUID_BETWEEN,
+
   /** {@code CASE} expression. */
   CASE,
 
diff --git a/core/src/main/java/org/apache/calcite/sql/SqlNode.java b/core/src/main/java/org/apache/calcite/sql/SqlNode.java
index 82bad71..3b2bc0e 100644
--- a/core/src/main/java/org/apache/calcite/sql/SqlNode.java
+++ b/core/src/main/java/org/apache/calcite/sql/SqlNode.java
@@ -28,11 +28,13 @@ import org.apache.calcite.sql.validate.SqlValidatorScope;
 import org.apache.calcite.util.Litmus;
 import org.apache.calcite.util.Util;
 
+import java.util.ArrayList;
 import java.util.Collection;
 import java.util.List;
 import java.util.Objects;
 import java.util.Set;
 import java.util.function.UnaryOperator;
+import java.util.stream.Collector;
 import javax.annotation.Nonnull;
 
 /**
@@ -350,4 +352,19 @@ public abstract class SqlNode implements Cloneable {
     }
     return litmus.succeed();
   }
+
+  /**
+   * Returns a {@code Collector} that accumulates the input elements into a
+   * {@link SqlNodeList}.
+   *
+   * @param <T> Type of the input elements
+   *
+   * @return a {@code Collector} that collects all the input elements into a
+   * {@link SqlNodeList}, in encounter order
+   */
+  public static <T extends SqlNode> Collector<T, ArrayList<T>, SqlNodeList>
+      toList() {
+    return Collector.of(ArrayList::new, ArrayList::add, Util::combine,
+        list -> new SqlNodeList(list, SqlParserPos.ZERO));
+  }
 }
diff --git a/core/src/main/java/org/apache/calcite/sql/SqlNodeList.java b/core/src/main/java/org/apache/calcite/sql/SqlNodeList.java
index f773b61..937945d 100644
--- a/core/src/main/java/org/apache/calcite/sql/SqlNodeList.java
+++ b/core/src/main/java/org/apache/calcite/sql/SqlNodeList.java
@@ -32,6 +32,8 @@ import java.util.List;
 /**
  * A <code>SqlNodeList</code> is a list of {@link SqlNode}s. It is also a
  * {@link SqlNode}, so may appear in a parse tree.
+ *
+ * @see SqlNode#toList()
  */
 public class SqlNodeList extends SqlNode implements Iterable<SqlNode> {
   //~ Static fields/initializers ---------------------------------------------
diff --git a/core/src/main/java/org/apache/calcite/sql/fun/SqlBetweenOperator.java b/core/src/main/java/org/apache/calcite/sql/fun/SqlBetweenOperator.java
index 859bcf6..2da2a54 100644
--- a/core/src/main/java/org/apache/calcite/sql/fun/SqlBetweenOperator.java
+++ b/core/src/main/java/org/apache/calcite/sql/fun/SqlBetweenOperator.java
@@ -34,6 +34,7 @@ import org.apache.calcite.sql.type.InferTypes;
 import org.apache.calcite.sql.type.ReturnTypes;
 import org.apache.calcite.sql.type.SqlOperandTypeChecker;
 import org.apache.calcite.sql.util.SqlBasicVisitor;
+import org.apache.calcite.util.Litmus;
 import org.apache.calcite.util.Util;
 
 import static org.apache.calcite.util.Static.RESOURCE;
@@ -46,7 +47,7 @@ import static org.apache.calcite.util.Static.RESOURCE;
  * <blockquote><code>X [NOT] BETWEEN [ASYMMETRIC | SYMMETRIC] Y AND
  * Z</code></blockquote>
  *
- * <p>If the asymmetric/symmeteric keywords are left out ASYMMETRIC is default.
+ * <p>If the asymmetric/symmetric keywords are left out ASYMMETRIC is default.
  *
  * <p>This operator is always expanded (into something like <code>Y &lt;= X AND
  * X &lt;= Z</code>) before being converted into Rex nodes.
@@ -110,6 +111,10 @@ public class SqlBetweenOperator extends SqlInfixOperator {
 
   //~ Methods ----------------------------------------------------------------
 
+  @Override public boolean validRexOperands(int count, Litmus litmus) {
+    return litmus.fail("not a rex operator");
+  }
+
   public boolean isNegated() {
     return negated;
   }
diff --git a/core/src/main/java/org/apache/calcite/sql/fun/SqlInOperator.java b/core/src/main/java/org/apache/calcite/sql/fun/SqlInOperator.java
index 0c894f6..3851a14 100644
--- a/core/src/main/java/org/apache/calcite/sql/fun/SqlInOperator.java
+++ b/core/src/main/java/org/apache/calcite/sql/fun/SqlInOperator.java
@@ -60,7 +60,8 @@ public class SqlInOperator extends SqlBinaryOperator {
    */
   SqlInOperator(SqlKind kind) {
     this(kind.sql, kind);
-    assert kind == SqlKind.IN || kind == SqlKind.NOT_IN;
+    assert kind == SqlKind.IN || kind == SqlKind.NOT_IN
+        || kind == SqlKind.DRUID_IN || kind == SqlKind.DRUID_NOT_IN;
   }
 
   protected SqlInOperator(String name, SqlKind kind) {
diff --git a/core/src/main/java/org/apache/calcite/sql/fun/SqlInternalOperators.java b/core/src/main/java/org/apache/calcite/sql/fun/SqlInternalOperators.java
new file mode 100644
index 0000000..2347838
--- /dev/null
+++ b/core/src/main/java/org/apache/calcite/sql/fun/SqlInternalOperators.java
@@ -0,0 +1,59 @@
+/*
+ * 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.calcite.sql.fun;
+
+import org.apache.calcite.rex.RexCall;
+import org.apache.calcite.sql.SqlKind;
+import org.apache.calcite.sql.SqlOperatorTable;
+import org.apache.calcite.util.Litmus;
+
+/**
+ * Contains internal operators.
+ *
+ * <p>These operators are always created directly, not by looking up a function
+ * or operator by name or syntax, and therefore this class does not implement
+ * interface {@link SqlOperatorTable}.
+ */
+public abstract class SqlInternalOperators {
+  private SqlInternalOperators() {
+  }
+
+  /** An IN operator for Druid.
+   *
+   * <p>Unlike the regular
+   * {@link SqlStdOperatorTable#IN} operator it may
+   * be used in {@link RexCall}. It does not require that
+   * its operands have consistent types. */
+  public static final SqlInOperator DRUID_IN =
+      new SqlInOperator(SqlKind.DRUID_IN);
+
+  /** A NOT IN operator for Druid, analogous to {@link #DRUID_IN}. */
+  public static final SqlInOperator DRUID_NOT_IN =
+      new SqlInOperator(SqlKind.DRUID_NOT_IN);
+
+  /** A BETWEEN operator for Druid, analogous to {@link #DRUID_IN}. */
+  public static final SqlBetweenOperator DRUID_BETWEEN =
+      new SqlBetweenOperator(SqlBetweenOperator.Flag.SYMMETRIC, false) {
+        @Override public SqlKind getKind() {
+          return SqlKind.DRUID_BETWEEN;
+        }
+
+        @Override public boolean validRexOperands(int count, Litmus litmus) {
+          return litmus.succeed();
+        }
+      };
+}
diff --git a/core/src/main/java/org/apache/calcite/sql/fun/SqlSearchOperator.java b/core/src/main/java/org/apache/calcite/sql/fun/SqlSearchOperator.java
new file mode 100644
index 0000000..5aa9f5f
--- /dev/null
+++ b/core/src/main/java/org/apache/calcite/sql/fun/SqlSearchOperator.java
@@ -0,0 +1,33 @@
+/*
+ * 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.calcite.sql.fun;
+
+import org.apache.calcite.sql.SqlInternalOperator;
+import org.apache.calcite.sql.SqlKind;
+import org.apache.calcite.sql.type.InferTypes;
+import org.apache.calcite.sql.type.OperandTypes;
+import org.apache.calcite.sql.type.ReturnTypes;
+
+/** Operator that tests whether its left operand is included in the range of
+ * values covered by search arguments. */
+class SqlSearchOperator extends SqlInternalOperator {
+  SqlSearchOperator() {
+    super("SEARCH", SqlKind.SEARCH, 30, true, ReturnTypes.BOOLEAN_NULLABLE,
+        InferTypes.FIRST_KNOWN,
+        OperandTypes.COMPARABLE_UNORDERED_COMPARABLE_UNORDERED);
+  }
+}
diff --git a/core/src/main/java/org/apache/calcite/sql/fun/SqlStdOperatorTable.java b/core/src/main/java/org/apache/calcite/sql/fun/SqlStdOperatorTable.java
index 77cff50..c178607 100644
--- a/core/src/main/java/org/apache/calcite/sql/fun/SqlStdOperatorTable.java
+++ b/core/src/main/java/org/apache/calcite/sql/fun/SqlStdOperatorTable.java
@@ -402,6 +402,11 @@ public class SqlStdOperatorTable extends ReflectiveSqlOperatorTable {
   public static final SqlBinaryOperator NOT_IN =
       new SqlInOperator(SqlKind.NOT_IN);
 
+  /** Operator that tests whether its left operand is included in the range of
+   * values covered by search arguments. */
+  public static final SqlInternalOperator SEARCH =
+      new SqlSearchOperator();
+
   /**
    * The <code>&lt; SOME</code> operator (synonymous with
    * <code>&lt; ANY</code>).
diff --git a/core/src/main/java/org/apache/calcite/sql/type/SqlTypeName.java b/core/src/main/java/org/apache/calcite/sql/type/SqlTypeName.java
index f1df362..e3c29d3 100644
--- a/core/src/main/java/org/apache/calcite/sql/type/SqlTypeName.java
+++ b/core/src/main/java/org/apache/calcite/sql/type/SqlTypeName.java
@@ -120,7 +120,10 @@ public enum SqlTypeName {
       SqlTypeFamily.COLUMN_LIST),
   DYNAMIC_STAR(PrecScale.NO_NO | PrecScale.YES_NO | PrecScale.YES_YES, true,
       Types.JAVA_OBJECT, SqlTypeFamily.ANY),
-  GEOMETRY(PrecScale.NO_NO, true, ExtraSqlTypes.GEOMETRY, SqlTypeFamily.GEO);
+  /** Spatial type. Though not standard, it is common to several DBs, so we
+   * do not flag it 'special' (internal). */
+  GEOMETRY(PrecScale.NO_NO, false, ExtraSqlTypes.GEOMETRY, SqlTypeFamily.GEO),
+  SARG(PrecScale.NO_NO, true, Types.OTHER, SqlTypeFamily.ANY);
 
   public static final int MAX_DATETIME_PRECISION = 3;
 
diff --git a/core/src/main/java/org/apache/calcite/tools/RelBuilder.java b/core/src/main/java/org/apache/calcite/tools/RelBuilder.java
index 2cb4079..6ea73e3 100644
--- a/core/src/main/java/org/apache/calcite/tools/RelBuilder.java
+++ b/core/src/main/java/org/apache/calcite/tools/RelBuilder.java
@@ -582,6 +582,12 @@ public class RelBuilder {
 
   /** Creates a call to a scalar operator. */
   private @Nonnull RexCall call(SqlOperator operator, List<RexNode> operandList) {
+    switch (operator.getKind()) {
+    case BETWEEN:
+      assert operandList.size() == 3;
+      return (RexCall) between(operandList.get(0), operandList.get(1),
+          operandList.get(2));
+    }
     final RexBuilder builder = cluster.getRexBuilder();
     final RelDataType type = builder.deriveReturnType(operator, operandList);
     return (RexCall) builder.makeCall(type, operator, operandList);
@@ -593,6 +599,16 @@ public class RelBuilder {
     return call(operator, ImmutableList.copyOf(operands));
   }
 
+  /** Creates an IN. */
+  public RexNode in(RexNode arg, RexNode... ranges) {
+    return in(arg, ImmutableList.copyOf(ranges));
+  }
+
+  /** Creates an IN. */
+  public RexNode in(RexNode arg, Iterable<? extends RexNode> ranges) {
+    return getRexBuilder().makeIn(arg, ImmutableList.copyOf(ranges));
+  }
+
   /** Creates an AND. */
   public RexNode and(RexNode... operands) {
     return and(ImmutableList.copyOf(operands));
@@ -632,6 +648,11 @@ public class RelBuilder {
     return call(SqlStdOperatorTable.NOT_EQUALS, operand0, operand1);
   }
 
+  /** Creates a {@code BETWEEN}. */
+  public RexNode between(RexNode arg, RexNode lower, RexNode upper) {
+    return getRexBuilder().makeBetween(arg, lower, upper);
+  }
+
   /** Creates a IS NULL. */
   public RexNode isNull(RexNode operand) {
     return call(SqlStdOperatorTable.IS_NULL, operand);
diff --git a/core/src/main/java/org/apache/calcite/util/RangeSets.java b/core/src/main/java/org/apache/calcite/util/RangeSets.java
index 13de53a..c98a11e 100644
--- a/core/src/main/java/org/apache/calcite/util/RangeSets.java
+++ b/core/src/main/java/org/apache/calcite/util/RangeSets.java
@@ -16,16 +16,22 @@
  */
 package org.apache.calcite.util;
 
+import com.google.common.collect.BoundType;
 import com.google.common.collect.ImmutableRangeSet;
 import com.google.common.collect.Range;
 import com.google.common.collect.RangeSet;
 import com.google.common.collect.TreeRangeSet;
 
+import java.util.Iterator;
+import java.util.function.BiConsumer;
+import java.util.function.Function;
+
 /** Utilities for Guava {@link com.google.common.collect.RangeSet}. */
 @SuppressWarnings({"UnstableApiUsage"})
 public class RangeSets {
   private RangeSets() {}
 
+  @SuppressWarnings("rawtypes")
   private static final ImmutableRangeSet ALL =
       ImmutableRangeSet.of().complement();
 
@@ -43,4 +49,384 @@ public class RangeSets {
   public static <C extends Comparable<C>> RangeSet<C> rangeSetAll() {
     return (RangeSet) ALL;
   }
+
+  /** Compares two range sets. */
+  public static <C extends Comparable<C>> int compare(RangeSet<C> s0,
+      RangeSet<C> s1) {
+    final Iterator<Range<C>> i0 = s0.asRanges().iterator();
+    final Iterator<Range<C>> i1 = s1.asRanges().iterator();
+    for (;;) {
+      final boolean h0 = i0.hasNext();
+      final boolean h1 = i1.hasNext();
+      if (!h0 || !h1) {
+        return Boolean.compare(h0, h1);
+      }
+      final Range<C> r0 = i0.next();
+      final Range<C> r1 = i1.next();
+      int c = compare(r0, r1);
+      if (c != 0) {
+        return c;
+      }
+    }
+  }
+
+  /** Compares two ranges. */
+  public static <C extends Comparable<C>> int compare(Range<C> r0,
+      Range<C> r1) {
+    int c = Boolean.compare(r0.hasLowerBound(), r1.hasLowerBound());
+    if (c != 0) {
+      return c;
+    }
+    if (r0.hasLowerBound()) {
+      c = r0.lowerEndpoint().compareTo(r1.lowerEndpoint());
+      if (c != 0) {
+        return c;
+      }
+      c = r0.lowerBoundType().compareTo(r1.lowerBoundType());
+      if (c != 0) {
+        return c;
+      }
+    }
+    c = Boolean.compare(r0.hasUpperBound(), r1.hasUpperBound());
+    if (c != 0) {
+      return -c;
+    }
+    if (r0.hasUpperBound()) {
+      c = r0.upperEndpoint().compareTo(r1.upperEndpoint());
+      if (c != 0) {
+        return c;
+      }
+      c = r0.upperBoundType().compareTo(r1.upperBoundType());
+      if (c != 0) {
+        return c;
+      }
+    }
+    return 0;
+  }
+
+  /** Computes a hash code for a range set.
+   *
+   * <p>This method does not compute the same result as
+   * {@link RangeSet#hashCode}. That is a poor hash code because it is based
+   * upon {@link java.util.Set#hashCode}).
+   *
+   * <p>The algorithm is based on {@link java.util.List#hashCode()},
+   * which is well-defined because {@link RangeSet#asRanges()} is sorted. */
+  public static <C extends Comparable<C>> int hashCode(RangeSet<C> rangeSet) {
+    int h = 1;
+    for (Range<C> r : rangeSet.asRanges()) {
+      h = 31 * h + r.hashCode();
+    }
+    return h;
+  }
+
+  /** Returns whether a range is a point. */
+  public static <C extends Comparable<C>> boolean isPoint(Range<C> range) {
+    return range.hasLowerBound()
+        && range.hasUpperBound()
+        && range.lowerEndpoint().equals(range.upperEndpoint())
+        && !range.isEmpty();
+  }
+
+  /** Returns the number of ranges in a range set that are points.
+   *
+   * <p>If every range in a range set is a point then it can be converted to a
+   * SQL IN list. */
+  public static <C extends Comparable<C>> int countPoints(RangeSet<C> rangeSet) {
+    int n = 0;
+    for (Range<C> range : rangeSet.asRanges()) {
+      if (isPoint(range)) {
+        ++n;
+      }
+    }
+    return n;
+  }
+
+  /** Calls the appropriate handler method for each range in a range set,
+   * creating a new range set from the results. */
+  public static <C extends Comparable<C>, C2 extends Comparable<C2>>
+      RangeSet<C2> map(RangeSet<C> rangeSet, Handler<C, Range<C2>> handler) {
+    final ImmutableRangeSet.Builder<C2> builder = ImmutableRangeSet.builder();
+    rangeSet.asRanges().forEach(range -> builder.add(map(range, handler)));
+    return builder.build();
+  }
+
+  /** Calls the appropriate handler method for the type of range. */
+  public static <C extends Comparable<C>, R> R map(Range<C> range,
+      Handler<C, R> handler) {
+    if (range.hasLowerBound() && range.hasUpperBound()) {
+      final C lower = range.lowerEndpoint();
+      final C upper = range.upperEndpoint();
+      if (range.lowerBoundType() == BoundType.OPEN) {
+        if (range.upperBoundType() == BoundType.OPEN) {
+          return handler.open(lower, upper);
+        } else {
+          return handler.openClosed(lower, upper);
+        }
+      } else {
+        if (range.upperBoundType() == BoundType.OPEN) {
+          return handler.closedOpen(lower, upper);
+        } else {
+          if (lower.equals(upper)) {
+            return handler.singleton(lower);
+          } else {
+            return handler.closed(lower, upper);
+          }
+        }
+      }
+    } else if (range.hasLowerBound()) {
+      final C lower = range.lowerEndpoint();
+      if (range.lowerBoundType() == BoundType.OPEN) {
+        return handler.greaterThan(lower);
+      } else {
+        return handler.atLeast(lower);
+      }
+    } else if (range.hasUpperBound()) {
+      final C upper = range.upperEndpoint();
+      if (range.upperBoundType() == BoundType.OPEN) {
+        return handler.lessThan(upper);
+      } else {
+        return handler.atMost(upper);
+      }
+    } else {
+      return handler.all();
+    }
+  }
+
+  /** Copies a range set. */
+  public static <C extends Comparable<C>, C2 extends Comparable<C2>>
+      RangeSet<C2> copy(RangeSet<C> rangeSet, Function<C, C2> map) {
+    return map(rangeSet, new CopyingHandler<C, C2>() {
+      @Override C2 convert(C c) {
+        return map.apply(c);
+      }
+    });
+  }
+
+  /** Copies a range. */
+  public static <C extends Comparable<C>, C2 extends Comparable<C2>>
+      Range<C2> copy(Range<C> range, Function<C, C2> map) {
+    return map(range, new CopyingHandler<C, C2>() {
+      @Override C2 convert(C c) {
+        return map.apply(c);
+      }
+    });
+  }
+
+  public static <C extends Comparable<C>> void forEach(RangeSet<C> rangeSet,
+      Consumer<C> consumer) {
+    rangeSet.asRanges().forEach(range -> forEach(range, consumer));
+  }
+
+  public static <C extends Comparable<C>> void forEach(Range<C> range,
+      Consumer<C> consumer) {
+    if (range.hasLowerBound() && range.hasUpperBound()) {
+      final C lower = range.lowerEndpoint();
+      final C upper = range.upperEndpoint();
+      if (range.lowerBoundType() == BoundType.OPEN) {
+        if (range.upperBoundType() == BoundType.OPEN) {
+          consumer.open(lower, upper);
+        } else {
+          consumer.openClosed(lower, upper);
+        }
+      } else {
+        if (range.upperBoundType() == BoundType.OPEN) {
+          consumer.closedOpen(lower, upper);
+        } else {
+          if (lower.equals(upper)) {
+            consumer.singleton(lower);
+          } else {
+            consumer.closed(lower, upper);
+          }
+        }
+      }
+    } else if (range.hasLowerBound()) {
+      final C lower = range.lowerEndpoint();
+      if (range.lowerBoundType() == BoundType.OPEN) {
+        consumer.greaterThan(lower);
+      } else {
+        consumer.atLeast(lower);
+      }
+    } else if (range.hasUpperBound()) {
+      final C upper = range.upperEndpoint();
+      if (range.upperBoundType() == BoundType.OPEN) {
+        consumer.lessThan(upper);
+      } else {
+        consumer.atMost(upper);
+      }
+    } else {
+      consumer.all();
+    }
+  }
+
+  /** Creates a consumer that prints values to a {@link StringBuilder}. */
+  public static <C extends Comparable<C>> Consumer<C> printer(StringBuilder sb,
+      BiConsumer<StringBuilder, C> valuePrinter) {
+    return new Printer<>(sb, valuePrinter);
+  }
+
+  /** Deconstructor for {@link Range} values.
+   *
+   * @param <C> Value type
+   * @param <R> Return type
+   *
+   * @see Consumer */
+  public interface Handler<C extends Comparable<C>, R> {
+    R all();
+    R atLeast(C lower);
+    R atMost(C upper);
+    R greaterThan(C lower);
+    R lessThan(C upper);
+    R singleton(C value);
+    R closed(C lower, C upper);
+    R closedOpen(C lower, C upper);
+    R openClosed(C lower, C upper);
+    R open(C lower, C upper);
+  }
+
+  /** Consumer of {@link Range} values.
+   *
+   * @param <C> Value type
+   *
+   * @see Handler */
+  public interface Consumer<C extends Comparable<C>> {
+    void all();
+    void atLeast(C lower);
+    void atMost(C upper);
+    void greaterThan(C lower);
+    void lessThan(C upper);
+    void singleton(C value);
+    void closed(C lower, C upper);
+    void closedOpen(C lower, C upper);
+    void openClosed(C lower, C upper);
+    void open(C lower, C upper);
+  }
+
+  /** Handler that converts a Range into another Range of the same type,
+   * applying a mapping function to the range's bound(s).
+   *
+   * @param <C> Value type
+   * @param <C2> Output value type */
+  private abstract static
+      class CopyingHandler<C extends Comparable<C>, C2 extends Comparable<C2>>
+      implements RangeSets.Handler<C, Range<C2>> {
+    abstract C2 convert(C c);
+
+    @Override public Range<C2> all() {
+      return Range.all();
+    }
+
+    @Override public Range<C2> atLeast(C lower) {
+      return Range.atLeast(convert(lower));
+    }
+
+    @Override public Range<C2> atMost(C upper) {
+      return Range.atMost(convert(upper));
+    }
+
+    @Override public Range<C2> greaterThan(C lower) {
+      return Range.greaterThan(convert(lower));
+    }
+
+    @Override public Range<C2> lessThan(C upper) {
+      return Range.lessThan(convert(upper));
+    }
+
+    @Override public Range<C2> singleton(C value) {
+      return Range.singleton(convert(value));
+    }
+
+    @Override public Range<C2> closed(C lower, C upper) {
+      return Range.closed(convert(lower), convert(upper));
+    }
+
+    @Override public Range<C2> closedOpen(C lower, C upper) {
+      return Range.closedOpen(convert(lower), convert(upper));
+    }
+
+    @Override public Range<C2> openClosed(C lower, C upper) {
+      return Range.openClosed(convert(lower), convert(upper));
+    }
+
+    @Override public Range<C2> open(C lower, C upper) {
+      return Range.open(convert(lower), convert(upper));
+    }
+  }
+
+  /** Converts any type of range to a string, using a given value printer.
+   *
+   * @param <C> Value type */
+  static class Printer<C extends Comparable<C>> implements Consumer<C> {
+    private final StringBuilder sb;
+    private final BiConsumer<StringBuilder, C> valuePrinter;
+
+    Printer(StringBuilder sb, BiConsumer<StringBuilder, C> valuePrinter) {
+      this.sb = sb;
+      this.valuePrinter = valuePrinter;
+    }
+
+    @Override public void all() {
+      sb.append("(-\u221e\u2025+\u221e)");
+    }
+
+    @Override public void atLeast(C lower) {
+      sb.append('[');
+      valuePrinter.accept(sb, lower);
+      sb.append("\u2025+\u221e)");
+    }
+
+    @Override public void atMost(C upper) {
+      sb.append("(-\u221e\u2025");
+      valuePrinter.accept(sb, upper);
+      sb.append("]");
+    }
+
+    @Override public void greaterThan(C lower) {
+      sb.append('(');
+      valuePrinter.accept(sb, lower);
+      sb.append("\u2025+\u221e)");
+    }
+
+    @Override public void lessThan(C upper) {
+      sb.append("(-\u221e\u2025");
+      valuePrinter.accept(sb, upper);
+      sb.append(")");
+    }
+
+    @Override public void singleton(C value) {
+      valuePrinter.accept(sb, value);
+    }
+
+    @Override public void closed(C lower, C upper) {
+      sb.append('[');
+      valuePrinter.accept(sb, lower);
+      sb.append('\u2025');
+      valuePrinter.accept(sb, upper);
+      sb.append(']');
+    }
+
+    @Override public void closedOpen(C lower, C upper) {
+      sb.append('[');
+      valuePrinter.accept(sb, lower);
+      sb.append('\u2025');
+      valuePrinter.accept(sb, upper);
+      sb.append(')');
+    }
+
+    @Override public void openClosed(C lower, C upper) {
+      sb.append('(');
+      valuePrinter.accept(sb, lower);
+      sb.append('\u2025');
+      valuePrinter.accept(sb, upper);
+      sb.append(']');
+    }
+
+    @Override public void open(C lower, C upper) {
+      sb.append('(');
+      valuePrinter.accept(sb, lower);
+      sb.append('\u2025');
+      valuePrinter.accept(sb, upper);
+      sb.append(')');
+    }
+  }
 }
diff --git a/core/src/main/java/org/apache/calcite/util/Sarg.java b/core/src/main/java/org/apache/calcite/util/Sarg.java
new file mode 100644
index 0000000..2d460c5
--- /dev/null
+++ b/core/src/main/java/org/apache/calcite/util/Sarg.java
@@ -0,0 +1,152 @@
+/*
+ * 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.calcite.util;
+
+import org.apache.calcite.linq4j.Ord;
+import org.apache.calcite.sql.fun.SqlStdOperatorTable;
+
+import com.google.common.collect.ImmutableRangeSet;
+import com.google.common.collect.Range;
+import com.google.common.collect.RangeSet;
+
+import java.util.Objects;
+import java.util.function.BiConsumer;
+
+/** Set of values (or ranges) that are the target of a search.
+ *
+ * <p>The name is derived from <b>S</b>earch <b>arg</b>ument, an ancient
+ * concept in database implementation; see Access Path Selection in a Relational
+ * Database Management System &mdash; Selinger et al. 1979 or the
+ * "<a href="https://blog.acolyer.org/2016/01/04/access-path-selection/">morning
+ * paper summary</a>.
+ *
+ * <p>In RexNode, a Sarg only occur as the right-hand operand in a call to
+ * {@link SqlStdOperatorTable#SEARCH}, wrapped in a
+ * {@link org.apache.calcite.rex.RexLiteral}. Lifecycle methods:
+ *
+ * <ul>
+ * <li>{@link org.apache.calcite.rex.RexUtil#expandSearch} removes
+ *     calls to SEARCH and the included Sarg, converting them to comparisons;
+ * <li>{@link org.apache.calcite.rex.RexSimplify} converts complex comparisons
+ *     on the same argument into SEARCH calls with an included Sarg;
+ * <li>Various {@link org.apache.calcite.tools.RelBuilder} methods,
+ *     including {@link org.apache.calcite.tools.RelBuilder#in}
+ *     and {@link org.apache.calcite.tools.RelBuilder#between}
+ *     call {@link org.apache.calcite.rex.RexBuilder}
+ *     methods {@link org.apache.calcite.rex.RexBuilder#makeIn}
+ *     and {@link org.apache.calcite.rex.RexBuilder#makeBetween}
+ *     that create Sarg instances directly;
+ * <li>{@link org.apache.calcite.rel.rel2sql.SqlImplementor} converts
+ *     {@link org.apache.calcite.rex.RexCall}s
+ *     to SEARCH into {@link org.apache.calcite.sql.SqlNode} AST expressions
+ *     such as comparisons, {@code BETWEEN} and {@code IN}.
+ * </ul>
+ *
+ * @param <C> Value type
+ *
+ * @see SqlStdOperatorTable#SEARCH
+ */
+public class Sarg<C extends Comparable<C>> implements Comparable<Sarg<C>> {
+  public final RangeSet<C> rangeSet;
+  public final boolean containsNull;
+  public final int pointCount;
+
+  private Sarg(ImmutableRangeSet<C> rangeSet, boolean containsNull) {
+    this.rangeSet = Objects.requireNonNull(rangeSet);
+    this.containsNull = containsNull;
+    this.pointCount = RangeSets.countPoints(rangeSet);
+  }
+
+  /** Creates a search argument. */
+  public static <C extends Comparable<C>> Sarg<C> of(boolean containsNull,
+      RangeSet<C> rangeSet) {
+    return new Sarg<>(ImmutableRangeSet.copyOf(rangeSet), containsNull);
+  }
+
+  /**
+   * {@inheritDoc}
+   *
+   * <p>Produces a similar result to {@link RangeSet}, but adds ", null"
+   * if nulls are matched, and simplifies point ranges. For example,
+   * the Sarg that allows the range set
+   *
+   * <blockquote>{@code [[7&#x2025;7], [9&#x2025;9],
+   * (10&#x2025;+&infin;)]}</blockquote>
+   *
+   * and also null is printed as
+   *
+   * <blockquote>{@code Sarg[7, 9, (10&#x2025;+&infin;), null]}</blockquote>
+   */
+  @Override public String toString() {
+    final StringBuilder sb = new StringBuilder();
+    printTo(sb, StringBuilder::append);
+    return sb.toString();
+  }
+
+  /** Prints this Sarg to a StringBuilder, using the given printer to deal
+   * with each embedded value. */
+  public StringBuilder printTo(StringBuilder sb,
+      BiConsumer<StringBuilder, C> valuePrinter) {
+    sb.append("Sarg[");
+    final RangeSets.Consumer<C> printer = RangeSets.printer(sb, valuePrinter);
+    Ord.forEach(rangeSet.asRanges(), (r, i) -> {
+      if (i > 0) {
+        sb.append(", ");
+      }
+      RangeSets.forEach(r, printer);
+    });
+    if (containsNull) {
+      sb.append(", null");
+    }
+    return sb.append("]");
+  }
+
+  @Override public int compareTo(Sarg<C> o) {
+    return RangeSets.compare(rangeSet, o.rangeSet);
+  }
+
+  @Override public int hashCode() {
+    return RangeSets.hashCode(rangeSet) * 31 + (containsNull ? 2 : 3);
+  }
+
+  @Override public boolean equals(Object o) {
+    return o == this
+        || o instanceof Sarg
+        && rangeSet.equals(((Sarg) o).rangeSet)
+        && containsNull == ((Sarg) o).containsNull;
+  }
+
+  /** Returns whether this Sarg is a collection of 1 or more points (and perhaps
+   * an {@code IS NULL} if {@link #containsNull}).
+   *
+   * <p>Such sargs could be translated as {@code ref = value}
+   * or {@code ref IN (value1, ...)}. */
+  public boolean isPoints() {
+    return pointCount == rangeSet.asRanges().size();
+  }
+
+  /** Returns whether this Sarg, when negated, is a collection of 1 or more
+   * points (and perhaps an {@code IS NULL} if {@link #containsNull}).
+   *
+   * <p>Such sargs could be translated as {@code ref <> value}
+   * or {@code ref NOT IN (value1, ...)}. */
+  public boolean isComplementedPoints() {
+    return rangeSet.span().encloses(Range.all())
+        && rangeSet.complement().asRanges().stream()
+            .allMatch(RangeSets::isPoint);
+  }
+}
diff --git a/core/src/main/java/org/apache/calcite/util/Util.java b/core/src/main/java/org/apache/calcite/util/Util.java
index 7fbcb64..e60a18d 100644
--- a/core/src/main/java/org/apache/calcite/util/Util.java
+++ b/core/src/main/java/org/apache/calcite/util/Util.java
@@ -95,7 +95,9 @@ import java.util.RandomAccess;
 import java.util.Set;
 import java.util.StringTokenizer;
 import java.util.TimeZone;
+import java.util.function.Consumer;
 import java.util.function.Function;
+import java.util.function.ObjIntConsumer;
 import java.util.function.Predicate;
 import java.util.function.UnaryOperator;
 import java.util.jar.JarFile;
@@ -108,6 +110,9 @@ import javax.annotation.Nonnull;
  * Miscellaneous utility functions.
  */
 public class Util {
+
+  private static final int QUICK_DISTINCT = 15;
+
   private Util() {}
 
   //~ Static fields/initializers ---------------------------------------------
@@ -801,6 +806,51 @@ public class Util {
     }
   }
 
+  /** Prints a collection of elements to a StringBuilder, in the same format as
+   * {@link AbstractCollection#toString()}. */
+  public static <E> StringBuilder printIterable(StringBuilder sb,
+      Iterable<E> iterable) {
+    final Iterator<E> it = iterable.iterator();
+    if (!it.hasNext()) {
+      return sb.append("[]");
+    }
+    sb.append('[');
+    for (;;) {
+      final E e = it.next();
+      sb.append(e);
+      if (!it.hasNext()) {
+        return sb.append(']');
+      }
+      sb.append(", ");
+    }
+  }
+
+  /** Prints a set of elements to a StringBuilder, in the same format same as
+   * {@link AbstractCollection#toString()}.
+   *
+   * <p>The 'set' is represented by the number of elements and an action to
+   * perform for each element.
+   *
+   * <p>This method can be a very efficient way to convert a structure to a
+   * string, because the components can write directly to the StringBuilder
+   * rather than constructing intermediate strings.
+   *
+   * @see org.apache.calcite.linq4j.function.Functions#generate */
+  public static <E> StringBuilder printList(StringBuilder sb, int elementCount,
+      ObjIntConsumer<StringBuilder> consumer) {
+    if (elementCount == 0) {
+      return sb.append("[]");
+    }
+    sb.append('[');
+    for (int i = 0;;) {
+      consumer.accept(sb, i);
+      if (++i == elementCount) {
+        return sb.append(']');
+      }
+      sb.append(", ");
+    }
+  }
+
   /**
    * Returns the {@link Charset} object representing the value of
    * {@link CalciteSystemProperty#DEFAULT_CHARSET}.
@@ -2033,7 +2083,7 @@ public class Util {
       // Lists of size 0 and 1 are always distinct.
       return -1;
     }
-    if (size < 15) {
+    if (size < QUICK_DISTINCT) {
       // For smaller lists, avoid the overhead of creating a set. Threshold
       // determined empirically using UtilTest.testIsDistinctBenchmark.
       for (int i = 1; i < size; i++) {
@@ -2064,12 +2114,30 @@ public class Util {
    *
    * <p>If the list is already unique it is returned unchanged. */
   public static <E> List<E> distinctList(List<E> list) {
-    if (isDistinct(list)) {
+    // If the list is small, check for duplicates using pairwise comparison.
+    if (list.size() < QUICK_DISTINCT && isDistinct(list)) {
       return list;
     }
+    // Lists that have all the same element are common. Avoiding creating a set.
+    if (allSameElement(list)) {
+      return ImmutableList.of(list.get(0));
+    }
     return ImmutableList.copyOf(new LinkedHashSet<>(list));
   }
 
+  /** Returns whether all of the elements of a list are equal.
+   * The list is assumed to be non-empty. */
+  private static <E> boolean allSameElement(List<E> list) {
+    final Iterator<E> iterator = list.iterator();
+    final E first = iterator.next();
+    while (iterator.hasNext()) {
+      if (!first.equals(iterator.next())) {
+        return false;
+      }
+    }
+    return true;
+  }
+
   /** Converts an iterable into a list with unique elements.
    *
    * <p>The order is preserved; the second and subsequent occurrences are
@@ -2365,6 +2433,23 @@ public class Util {
     return reader(new FileInputStream(file));
   }
 
+  /** Given an {@link Appendable}, performs an action that requires a
+   * {@link StringBuilder}. Casts the Appendable if possible. */
+  public static void asStringBuilder(Appendable appendable,
+      Consumer<StringBuilder> consumer) {
+    if (appendable instanceof StringBuilder) {
+      consumer.accept((StringBuilder) appendable);
+    } else {
+      try {
+        final StringBuilder sb = new StringBuilder();
+        consumer.accept(sb);
+        appendable.append(sb);
+      } catch (IOException e) {
+        throw new RuntimeException(e);
+      }
+    }
+  }
+
   /** Creates a {@link Calendar} in the UTC time zone and root locale.
    * Does not use the time zone or locale. */
   public static Calendar calendar() {
@@ -2397,14 +2482,24 @@ public class Util {
    */
   public static <T> Collector<T, ImmutableList.Builder<T>, ImmutableList<T>>
       toImmutableList() {
-    return Collector.of(ImmutableList::builder, ImmutableList.Builder::add,
-        (t, u) -> {
-          t.addAll(u.build());
-          return t;
-        },
+    return Collector.of(ImmutableList::builder, ImmutableList.Builder::add, Util::combine,
         ImmutableList.Builder::build);
   }
 
+  /** Combines a second immutable list builder into a first. */
+  public static <E> ImmutableList.Builder<E> combine(
+      ImmutableList.Builder<E> b0, ImmutableList.Builder<E> b1) {
+    b0.addAll(b1.build());
+    return b0;
+  }
+
+  /** Combines a second array list into a first. */
+  public static <E> ArrayList<E> combine(ArrayList<E> list0,
+      ArrayList<E> list1) {
+    list0.addAll(list1);
+    return list0;
+  }
+
   /** Returns an operator that applies {@code op1} and then {@code op2}.
    *
    * <p>As {@link Function#andThen(Function)} but for {@link UnaryOperator}. */
diff --git a/core/src/test/java/org/apache/calcite/jdbc/CalciteRemoteDriverTest.java b/core/src/test/java/org/apache/calcite/jdbc/CalciteRemoteDriverTest.java
index 6bba338..a618b56 100644
--- a/core/src/test/java/org/apache/calcite/jdbc/CalciteRemoteDriverTest.java
+++ b/core/src/test/java/org/apache/calcite/jdbc/CalciteRemoteDriverTest.java
@@ -268,10 +268,11 @@ class CalciteRemoteDriverTest {
         .returns(CalciteAssert.checkResultContains("COLUMN_NAME=EMPNO"));
   }
 
-  @Test void testRemoteTypeInfo() throws Exception {
+  @Test void testRemoteTypeInfo() {
+    // TypeInfo does not include internal types (NULL, SYMBOL, ANY, etc.)
     CalciteAssert.hr().with(REMOTE_CONNECTION_FACTORY)
         .metaData(GET_TYPEINFO)
-        .returns(CalciteAssert.checkResultCount(is(45)));
+        .returns(CalciteAssert.checkResultCount(is(41)));
   }
 
   @Test void testRemoteTableTypes() throws Exception {
diff --git a/core/src/test/java/org/apache/calcite/rel/rel2sql/RelToSqlConverterTest.java b/core/src/test/java/org/apache/calcite/rel/rel2sql/RelToSqlConverterTest.java
index 8d07aee..71df0de 100644
--- a/core/src/test/java/org/apache/calcite/rel/rel2sql/RelToSqlConverterTest.java
+++ b/core/src/test/java/org/apache/calcite/rel/rel2sql/RelToSqlConverterTest.java
@@ -818,8 +818,7 @@ class RelToSqlConverterTest {
         .build();
     final String expected = "SELECT *\n"
         + "FROM \"scott\".\"EMP\"\n"
-        + "WHERE (\"EMPNO\" = 0 OR \"EMPNO\" = 1 OR (\"EMPNO\" = 2 OR \"EMPNO\" = 3))"
-        + " AND (\"DEPTNO\" = 5 OR (\"DEPTNO\" = 6 OR \"DEPTNO\" = 7))";
+        + "WHERE \"EMPNO\" IN (0, 1, 2, 3) AND \"DEPTNO\" IN (5, 6, 7)";
     relFn(relFn).ok(expected);
   }
 
@@ -1447,22 +1446,18 @@ class RelToSqlConverterTest {
   @Test void testUnparseIn1() {
     final Function<RelBuilder, RelNode> relFn = b ->
         b.scan("EMP")
-            .filter(
-                b.call(SqlStdOperatorTable.IN, b.field("DEPTNO"),
-                    b.literal(21)))
+            .filter(b.in(b.field("DEPTNO"), b.literal(21)))
             .build();
     final String expectedSql = "SELECT *\n"
         + "FROM \"scott\".\"EMP\"\n"
-        + "WHERE \"DEPTNO\" IN (21)";
+        + "WHERE \"DEPTNO\" = 21";
     relFn(relFn).ok(expectedSql);
   }
 
   @Test void testUnparseIn2() {
     final Function<RelBuilder, RelNode> relFn = b -> b
         .scan("EMP")
-        .filter(
-            b.call(SqlStdOperatorTable.IN, b.field("DEPTNO"),
-                b.literal(20), b.literal(21)))
+        .filter(b.in(b.field("DEPTNO"), b.literal(20), b.literal(21)))
         .build();
     final String expectedSql = "SELECT *\n"
         + "FROM \"scott\".\"EMP\"\n"
@@ -1474,7 +1469,7 @@ class RelToSqlConverterTest {
     final Function<RelBuilder, RelNode> relFn = b ->
         b.scan("EMP")
             .filter(
-                b.call(SqlStdOperatorTable.IN,
+                b.in(
                     b.call(SqlStdOperatorTable.ROW,
                         b.field("DEPTNO"), b.field("JOB")),
                     b.call(SqlStdOperatorTable.ROW, b.literal(1),
@@ -1482,7 +1477,7 @@ class RelToSqlConverterTest {
             .build();
     final String expectedSql = "SELECT *\n"
         + "FROM \"scott\".\"EMP\"\n"
-        + "WHERE ROW(\"DEPTNO\", \"JOB\") IN (ROW(1, 'PRESIDENT'))";
+        + "WHERE ROW(\"DEPTNO\", \"JOB\") = ROW(1, 'PRESIDENT')";
     relFn(relFn).ok(expectedSql);
   }
 
@@ -1490,11 +1485,11 @@ class RelToSqlConverterTest {
     final Function<RelBuilder, RelNode> relFn = b ->
         b.scan("EMP")
             .filter(
-                b.call(SqlStdOperatorTable.IN,
+                b.in(
                     b.call(SqlStdOperatorTable.ROW,
                         b.field("DEPTNO"), b.field("JOB")),
                     b.call(SqlStdOperatorTable.ROW, b.literal(1),
-                b.literal("PRESIDENT")),
+                        b.literal("PRESIDENT")),
                     b.call(SqlStdOperatorTable.ROW, b.literal(2),
                         b.literal("PRESIDENT"))))
             .build();
diff --git a/core/src/test/java/org/apache/calcite/rel/rules/DateRangeRulesTest.java b/core/src/test/java/org/apache/calcite/rel/rules/DateRangeRulesTest.java
index bf4ca05..47d38cd 100644
--- a/core/src/test/java/org/apache/calcite/rel/rules/DateRangeRulesTest.java
+++ b/core/src/test/java/org/apache/calcite/rel/rules/DateRangeRulesTest.java
@@ -88,8 +88,7 @@ class DateRangeRulesTest {
         "UTC",
         is("AND(AND(>=($8, 2014-01-01), <($8, 2015-01-01)),"
             + " AND(>=($8, 2014-06-01), <($8, 2014-07-01)))"),
-        is("AND(>=($8, 2014-01-01), <($8, 2015-01-01),"
-            + " >=($8, 2014-06-01), <($8, 2014-07-01))"));
+        is("SEARCH($8, Sarg[[2014-06-01\u20252014-07-01)])"));
   }
 
   /** Test case for
@@ -103,11 +102,8 @@ class DateRangeRulesTest {
         + "AND(>=($8, 2000-02-01), <($8, 2000-03-01)), "
         + "AND(>=($8, 2000-03-01), <($8, 2000-04-01)), "
         + "AND(>=($8, 2000-05-01), <($8, 2000-06-01))))";
-    final String s2 = "AND(>=($8, 2000-01-01), <($8, 2001-01-01),"
-        + " OR("
-        + "AND(>=($8, 2000-02-01), <($8, 2000-03-01)), "
-        + "AND(>=($8, 2000-03-01), <($8, 2000-04-01)), "
-        + "AND(>=($8, 2000-05-01), <($8, 2000-06-01))))";
+    final String s2 = "SEARCH($8, Sarg[[2000-02-01\u20252000-04-01),"
+        + " [2000-05-01\u20252000-06-01)])";
     final RexNode e =
         f.and(f.eq(f.exYearD, f.literal(2000)),
             f.or(f.eq(f.exMonthD, f.literal(2)),
diff --git a/core/src/test/java/org/apache/calcite/rex/RexProgramBuilderBase.java b/core/src/test/java/org/apache/calcite/rex/RexProgramBuilderBase.java
index 2d6fdc5..6035544 100644
--- a/core/src/test/java/org/apache/calcite/rex/RexProgramBuilderBase.java
+++ b/core/src/test/java/org/apache/calcite/rex/RexProgramBuilderBase.java
@@ -351,14 +351,15 @@ public abstract class RexProgramBuilderBase {
   }
 
   /**
-   * Generates {@code x IN (y, z)} expression when called as {@code in(x, y, z)}.
+   * Generates {@code x IN (y, z)} expression when called as
+   * {@code in(x, y, z)}.
+   *
    * @param node left side of the IN expression
    * @param nodes nodes in the right side of IN expression
    * @return IN expression
    */
   protected RexNode in(RexNode node, RexNode... nodes) {
-    return rexBuilder.makeCall(SqlStdOperatorTable.IN,
-        ImmutableList.<RexNode>builder().add(node).add(nodes).build());
+    return rexBuilder.makeIn(node, ImmutableList.copyOf(nodes));
   }
 
   // Types
diff --git a/core/src/test/java/org/apache/calcite/rex/RexProgramTest.java b/core/src/test/java/org/apache/calcite/rex/RexProgramTest.java
index 92b6851..1b836f2 100644
--- a/core/src/test/java/org/apache/calcite/rex/RexProgramTest.java
+++ b/core/src/test/java/org/apache/calcite/rex/RexProgramTest.java
@@ -1128,11 +1128,11 @@ class RexProgramTest extends RexProgramTestBase {
         RelOptPredicateList.EMPTY,
         "<(5, ?0.a)");
 
-    // condition "1 < a && a < 5" is unchanged
+    // condition "1 < a && a < 5" is converted to a Sarg
     checkSimplifyFilter(
         and(lt(literal(1), aRef), lt(aRef, literal(5))),
         RelOptPredicateList.EMPTY,
-        "AND(<(1, ?0.a), <(?0.a, 5))");
+        "SEARCH(?0.a, Sarg[(1\u20255)])");
 
     // condition "1 > a && 5 > x" yields "1 > a"
     checkSimplifyFilter(
@@ -1151,7 +1151,7 @@ class RexProgramTest extends RexProgramTestBase {
     checkSimplifyFilter(
         and(gt(aRef, literal(1)), lt(aRef, literal(10)), lt(aRef, literal(5))),
         RelOptPredicateList.EMPTY,
-        "AND(>(?0.a, 1), <(?0.a, 5))");
+        "SEARCH(?0.a, Sarg[(1\u20255)])");
 
     // condition "a > 1 && a < 10 && a < 5"
     // with pre-condition "a > 5"
@@ -1179,7 +1179,7 @@ class RexProgramTest extends RexProgramTestBase {
         and(gt(aRef, literal(1)), lt(aRef, literal(10)), lt(aRef, literal(5))),
         RelOptPredicateList.of(rexBuilder,
             ImmutableList.of(lt(bRef, literal(10)), ge(aRef, literal(1)))),
-        "AND(>(?0.a, 1), <(?0.a, 5))");
+        "SEARCH(?0.a, Sarg[(1\u20255)])");
 
     // condition "a > 1"
     // with pre-condition "b < 10 && a > 5"
@@ -1360,28 +1360,29 @@ class RexProgramTest extends RexProgramTestBase {
             .add(Range.singleton(3))
             .add(Range.greaterThan(5))
             .build();
-    assertThat(setComplex.toString(), is("[[0‥2], [3‥3], (5‥+∞)]"));
+    assertThat(setComplex.toString(),
+        is("[[0\u20252], [3\u20253], (5\u2025+\u221e)]"));
 
     assertThat(RangeSets.minus(setAll, Range.singleton(1)).toString(),
-        is("[(-∞‥1), (1‥+∞)]"));
+        is("[(-\u221e\u20251), (1\u2025+\u221e)]"));
     assertThat(RangeSets.minus(setNone, Range.singleton(1)), is(setNone));
     assertThat(RangeSets.minus(setGt2, Range.singleton(1)), is(setGt2));
     assertThat(RangeSets.minus(setGt1, Range.singleton(1)), is(setGt1));
     assertThat(RangeSets.minus(setGe1, Range.singleton(1)), is(setGt1));
     assertThat(RangeSets.minus(setGt0, Range.singleton(1)).toString(),
-        is("[(0‥1), (1‥+∞)]"));
+        is("[(0\u20251), (1\u2025+\u221e)]"));
     assertThat(RangeSets.minus(setComplex, Range.singleton(1)).toString(),
-        is("[[0‥1), (1‥2], [3‥3], (5‥+∞)]"));
+        is("[[0\u20251), (1\u20252], [3\u20253], (5\u2025+\u221e)]"));
     assertThat(RangeSets.minus(setComplex, Range.singleton(2)).toString(),
-        is("[[0‥2), [3‥3], (5‥+∞)]"));
+        is("[[0\u20252), [3\u20253], (5\u2025+\u221e)]"));
     assertThat(RangeSets.minus(setComplex, Range.singleton(3)).toString(),
-        is("[[0‥2], (5‥+∞)]"));
+        is("[[0\u20252], (5\u2025+\u221e)]"));
     assertThat(RangeSets.minus(setComplex, Range.open(2, 3)).toString(),
-        is("[[0‥2], [3‥3], (5‥+∞)]"));
+        is("[[0\u20252], [3\u20253], (5\u2025+\u221e)]"));
     assertThat(RangeSets.minus(setComplex, Range.closed(2, 3)).toString(),
-        is("[[0‥2), (5‥+∞)]"));
+        is("[[0\u20252), (5\u2025+\u221e)]"));
     assertThat(RangeSets.minus(setComplex, Range.closed(2, 7)).toString(),
-        is("[[0‥2), (7‥+∞)]"));
+        is("[[0\u20252), (7\u2025+\u221e)]"));
   }
 
   @Test void testSimplifyOrTerms() {
@@ -1572,7 +1573,12 @@ class RexProgramTest extends RexProgramTestBase {
             ge(aRef, literal(15))),
         ne(aRef, literal(6)),
         ne(aRef, literal(12)));
-    checkSimplifyUnchanged(expr);
+    final String simplified =
+        "SEARCH($0, Sarg[(0\u20256), (6\u202510], [15\u2025+\u221e)])";
+    final String expanded = "OR(AND(>($0, 0), <($0, 6)), AND(>($0, 6),"
+        + " <=($0, 10)), >=($0, 15))";
+    checkSimplify(expr, simplified)
+        .expandedSearch(expanded);
   }
 
   @Test void testSimplifyRange2() {
@@ -1580,7 +1586,8 @@ class RexProgramTest extends RexProgramTestBase {
     // a is null or a >= 15
     RexNode expr = or(isNull(aRef),
         ge(aRef, literal(15)));
-    checkSimplifyUnchanged(expr);
+    checkSimplify(expr, "SEARCH($0, Sarg[[15\u2025+\u221e), null])")
+        .expandedSearch("OR(IS NULL($0), >=($0, 15))");
   }
 
   /** Unit test for
@@ -1597,11 +1604,40 @@ class RexProgramTest extends RexProgramTestBase {
             lt(aRef, literal(12))),
         ge(aRef, literal(15)));
     // [CALCITE-4190] causes "or a >= 15" to disappear from the simplified form.
-    final String expected = "OR(IS NULL($0),"
-        + " AND(<(0, $0), <=($0, 10)),"
-        + " AND(<(8, $0), <($0, 12)),"
-        + " >=($0, 15))";
-    checkSimplify(expr, expected);
+    final String simplified =
+        "SEARCH($0, Sarg[(0\u202512), [15\u2025+\u221e), null])";
+    final String expanded =
+        "OR(IS NULL($0), AND(>($0, 0), <($0, 12)), >=($0, 15))";
+    checkSimplify(expr, simplified)
+        .expandedSearch(expanded);
+  }
+
+  @Test void testSimplifyRange4() {
+    final RexNode aRef = input(tInt(true), 0);
+    // not (a = 3 or a = 5)
+    RexNode expr = not(
+        or(eq(aRef, literal(3)),
+            eq(aRef, literal(5))));
+    final String expected =
+        "SEARCH($0, Sarg[(-\u221e\u20253), (3\u20255), (5\u2025+\u221e)])";
+    final String expanded = "AND(<>($0, 3), <>($0, 5))";
+    checkSimplify(expr, expected)
+        .expandedSearch(expanded);
+  }
+
+  @Test void testSimplifyRange5() {
+    final RexNode aRef = input(tInt(true), 0);
+    // not (a = 3 or a = 5) or a is null
+    RexNode expr = or(
+        not(
+            or(eq(aRef, literal(3)),
+                eq(aRef, literal(5)))),
+        isNull(aRef));
+    final String simplified =
+        "SEARCH($0, Sarg[(-\u221e\u20253), (3\u20255), (5\u2025+\u221e), null])";
+    final String expanded = "OR(IS NULL($0), AND(<>($0, 3), <>($0, 5)))";
+    checkSimplify(expr, simplified)
+        .expandedSearch(expanded);
   }
 
   @Test void testSimplifyItemRangeTerms() {
@@ -1611,10 +1647,11 @@ class RexProgramTest extends RexProgramTestBase {
     // (a=1 or a=2 or (arr[1]>4 and arr[1]<3 and a=3)) => a=1 or a=2
     checkSimplifyFilter(
         or(
-          eq(vInt(), literal(1)),
-          eq(vInt(), literal(2)),
-          and(gt(item, literal(4)), lt(item, literal(3)), eq(vInt(), literal(3)))),
-        "OR(=(?0.int0, 1), =(?0.int0, 2))");
+            eq(vInt(), literal(1)),
+            eq(vInt(), literal(2)),
+            and(gt(item, literal(4)), lt(item, literal(3)),
+                eq(vInt(), literal(3)))),
+        "SEARCH(?0.int0, Sarg[1, 2])");
     simplify = simplify.withParanoid(true);
   }
 
@@ -1793,7 +1830,7 @@ class RexProgramTest extends RexProgramTestBase {
         "=(?0.notNullInt0, 3)");
   }
 
-  @Test void testSimplifyCaseAndNotSimplicationIsInAction() {
+  @Test void testSimplifyCaseAndNotSimplificationIsInAction() {
     RexNode caseNode = case_(
         eq(vIntNotNull(), literal(0)), falseLiteral,
         eq(vIntNotNull(), literal(1)), trueLiteral,
@@ -2253,7 +2290,8 @@ class RexProgramTest extends RexProgramTestBase {
 
   /** Unit test for
    * <a href="https://issues.apache.org/jira/browse/CALCITE-2421">[CALCITE-2421]
-   * to-be-filled </a>. */
+   * RexSimplify#simplifyAnds foregoes some simplifications if unknownAsFalse
+   * set to true</a>. */
   @Test void testSelfComparisons() {
     checkSimplify3(and(eq(vInt(), vInt()), eq(vInt(1), vInt(1))),
         "AND(OR(null, IS NOT NULL(?0.int0)), OR(null, IS NOT NULL(?0.int1)))",
@@ -2567,6 +2605,15 @@ class RexProgramTest extends RexProgramTestBase {
         "true");
   }
 
+  @Test void testSimplifyOrIsNull() {
+    // x = 10 OR x IS NULL
+    checkSimplify(or(eq(vInt(0), literal(10)), isNull(vInt(0))),
+        "SEARCH(?0.int0, Sarg[10, null])");
+    // 10 = x OR x IS NULL
+    checkSimplify(or(eq(literal(10), vInt(0)), isNull(vInt(0))),
+        "SEARCH(?0.int0, Sarg[10, null])");
+  }
+
   @Test void testSimplifyOrNot() {
     // "x > 1 OR NOT (y > 2)" -> "x > 1 OR y <= 2"
     checkSimplify(or(gt(vInt(1), literal(1)), not(gt(vInt(2), literal(2)))),
@@ -2706,7 +2753,15 @@ class RexProgramTest extends RexProgramTestBase {
    * Computing digest of IN expressions leads to Exceptions</a>. */
   @Test void testInDigest() {
     RexNode e = in(vInt(), literal(1), literal(2));
-    assertThat(e.toString(), is("IN(?0.int0, 1, 2)"));
+    assertThat(e.toString(), is("SEARCH(?0.int0, Sarg[1, 2])"));
+  }
+
+  /** Tests that {@link #in} does not generate SEARCH if any of the arguments
+   * are not literals. */
+  @Test void testInDigest2() {
+    RexNode e = in(vInt(0), literal(1), plus(literal(2), vInt(1)));
+    assertThat(e.toString(),
+        is("OR(=(?0.int0, 1), =(?0.int0, +(2, ?0.int1)))"));
   }
 
   /** Unit test for
diff --git a/core/src/test/java/org/apache/calcite/rex/RexProgramTestBase.java b/core/src/test/java/org/apache/calcite/rex/RexProgramTestBase.java
index 0680b45..8babfac 100644
--- a/core/src/test/java/org/apache/calcite/rex/RexProgramTestBase.java
+++ b/core/src/test/java/org/apache/calcite/rex/RexProgramTestBase.java
@@ -19,9 +19,14 @@ package org.apache.calcite.rex;
 import org.apache.calcite.plan.RelOptPredicateList;
 import org.apache.calcite.sql.SqlKind;
 import org.apache.calcite.sql.type.SqlTypeName;
+import org.apache.calcite.test.Matchers;
 
 import com.google.common.collect.ImmutableMap;
 
+import org.hamcrest.Matcher;
+
+import java.util.Objects;
+
 import static org.hamcrest.CoreMatchers.equalTo;
 import static org.hamcrest.CoreMatchers.is;
 import static org.hamcrest.MatcherAssert.assertThat;
@@ -30,6 +35,10 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
 /** Base class for tests of {@link RexProgram}. */
 class RexProgramTestBase extends RexProgramBuilderBase {
 
+  protected Node node(RexNode node) {
+    return new Node(rexBuilder, node);
+  }
+
   protected void checkDigest(RexNode node, String expected) {
     assertEquals(expected, node.toString(), () -> "Digest of " + node.toString());
   }
@@ -75,13 +84,13 @@ class RexProgramTestBase extends RexProgramBuilderBase {
   }
 
   /** Simplifies an expression and checks that the result is as expected. */
-  protected void checkSimplify(RexNode node, String expected) {
+  protected SimplifiedNode checkSimplify(RexNode node, String expected) {
     final String nodeString = node.toString();
-    checkSimplify3_(node, expected, expected, expected);
     if (expected.equals(nodeString)) {
       throw new AssertionError("expected == node.toString(); "
           + "use checkSimplifyUnchanged");
     }
+    return checkSimplify3_(node, expected, expected, expected);
   }
 
   /** Simplifies an expression and checks that the result is unchanged. */
@@ -119,8 +128,8 @@ class RexProgramTestBase extends RexProgramBuilderBase {
     }
   }
 
-  protected void checkSimplify3_(RexNode node, String expected,
-                               String expectedFalse, String expectedTrue) {
+  protected SimplifiedNode checkSimplify3_(RexNode node, String expected,
+      String expectedFalse, String expectedTrue) {
     final RexNode simplified =
         simplify.simplifyUnknownAs(node, RexUnknownAs.UNKNOWN);
     assertThat("simplify(unknown as unknown): " + node,
@@ -140,12 +149,14 @@ class RexProgramTestBase extends RexProgramBuilderBase {
       assertThat("node type is not BOOLEAN, so <<expectedTrue>> should match <<expected>>",
           expectedTrue, is(expected));
     }
+    return new SimplifiedNode(rexBuilder, node, simplified);
   }
 
-  protected void checkSimplifyFilter(RexNode node, String expected) {
+  protected Node checkSimplifyFilter(RexNode node, String expected) {
     final RexNode simplified =
         this.simplify.simplifyUnknownAs(node, RexUnknownAs.FALSE);
     assertThat(simplified.toString(), equalTo(expected));
+    return node(node);
   }
 
   protected void checkSimplifyFilter(RexNode node, RelOptPredicateList predicates,
@@ -165,7 +176,7 @@ class RexProgramTestBase extends RexProgramBuilderBase {
     assertThat(
         "isAlwaysFalse() of expression: " + e.toString(), e.isAlwaysFalse(), is(!expected));
     assertThat(
-        "Simplification is not using isAlwaysX informations", simplify(e).toString(),
+        "Simplification is not using isAlwaysX information", simplify(e).toString(),
         is(expected ? "true" : "false"));
   }
 
@@ -179,4 +190,40 @@ class RexProgramTestBase extends RexProgramBuilderBase {
             .withParanoid(true);
     return simplify.simplifyUnknownAs(e, RexUnknownAs.UNKNOWN);
   }
+
+  /** Fluent test. */
+  static class Node {
+    final RexBuilder rexBuilder;
+    final RexNode node;
+
+    Node(RexBuilder rexBuilder, RexNode node) {
+      this.rexBuilder = Objects.requireNonNull(rexBuilder);
+      this.node = Objects.requireNonNull(node);
+    }
+  }
+
+  /** Fluent test that includes original and simplified expression. */
+  static class SimplifiedNode extends Node {
+    private final RexNode simplified;
+
+    SimplifiedNode(RexBuilder rexBuilder, RexNode node, RexNode simplified) {
+      super(rexBuilder, node);
+      this.simplified = simplified;
+    }
+
+    /** Asserts that the result of expanding calls to {@code SEARCH} operator
+     * in the simplified expression yields an expected {@link RexNode}. */
+    public Node expandedSearch(Matcher<RexNode> matcher) {
+      final RexNode node2 = RexUtil.expandSearch(rexBuilder, null, simplified);
+      assertThat(node2, matcher);
+      return this;
+    }
+
+    /** Asserts that the result of expanding calls to {@code SEARCH} operator
+     * in the simplified expression yields a {@link RexNode}
+     * with a given string representation. */
+    public Node expandedSearch(String expected) {
+      return expandedSearch(Matchers.hasRex(expected));
+    }
+  }
 }
diff --git a/core/src/test/java/org/apache/calcite/test/JdbcAdapterTest.java b/core/src/test/java/org/apache/calcite/test/JdbcAdapterTest.java
index 8502c33..ef0b27e 100644
--- a/core/src/test/java/org/apache/calcite/test/JdbcAdapterTest.java
+++ b/core/src/test/java/org/apache/calcite/test/JdbcAdapterTest.java
@@ -137,13 +137,10 @@ class JdbcAdapterTest {
             + "where \"store_name\" in ('Store 1', 'Store 10', 'Store 11', 'Store 15', 'Store 16', 'Store 24', 'Store 3', 'Store 7')")
         .runs()
         .enable(CalciteAssert.DB == CalciteAssert.DatabaseInstance.HSQLDB)
-        .planHasSql(
-            "SELECT \"store_id\", \"store_name\"\n"
+        .planHasSql("SELECT \"store_id\", \"store_name\"\n"
             + "FROM \"foodmart\".\"store\"\n"
-            + "WHERE \"store_name\" = 'Store 1' OR \"store_name\" = 'Store 10'"
-                + " OR (\"store_name\" = 'Store 11' OR \"store_name\" = 'Store 15')"
-                + " OR (\"store_name\" = 'Store 16' OR \"store_name\" = 'Store 24'"
-                + " OR (\"store_name\" = 'Store 3' OR \"store_name\" = 'Store 7'))")
+            + "WHERE \"store_name\" IN ('Store 1', 'Store 10', 'Store 11',"
+            + " 'Store 15', 'Store 16', 'Store 24', 'Store 3', 'Store 7')")
         .returns("store_id=1; store_name=Store 1\n"
             + "store_id=3; store_name=Store 3\n"
             + "store_id=7; store_name=Store 7\n"
diff --git a/core/src/test/java/org/apache/calcite/test/Matchers.java b/core/src/test/java/org/apache/calcite/test/Matchers.java
index 5566cf4..f0b1212 100644
--- a/core/src/test/java/org/apache/calcite/test/Matchers.java
+++ b/core/src/test/java/org/apache/calcite/test/Matchers.java
@@ -19,6 +19,7 @@ package org.apache.calcite.test;
 import org.apache.calcite.plan.RelOptUtil;
 import org.apache.calcite.rel.RelNode;
 import org.apache.calcite.rel.hint.Hintable;
+import org.apache.calcite.rex.RexNode;
 import org.apache.calcite.util.TestUtil;
 import org.apache.calcite.util.Util;
 
@@ -210,6 +211,18 @@ public class Matchers {
   }
 
   /**
+   * Creates a Matcher that matches a {@link RexNode} if its string
+   * representation, after converting Windows-style line endings ("\r\n")
+   * to Unix-style line endings ("\n"), is equal to the given {@code value}.
+   */
+  public static Matcher<RexNode> hasRex(final String value) {
+    return compose(Is.is(value), input -> {
+      // Convert RexNode to a string with Linux line-endings
+      return Util.toLinux(input.toString());
+    });
+  }
+
+  /**
    * Creates a Matcher that matches a {@link RelNode} if its hints string
    * representation is equal to the given {@code value}.
    */
diff --git a/core/src/test/java/org/apache/calcite/test/MaterializedViewRelOptRulesTest.java b/core/src/test/java/org/apache/calcite/test/MaterializedViewRelOptRulesTest.java
index 8e45d23..b100b39 100644
--- a/core/src/test/java/org/apache/calcite/test/MaterializedViewRelOptRulesTest.java
+++ b/core/src/test/java/org/apache/calcite/test/MaterializedViewRelOptRulesTest.java
@@ -448,12 +448,11 @@ public class MaterializedViewRelOptRulesTest extends AbstractMaterializedViewTes
             + "where \"depts\".\"deptno\" > 10\n"
             + "group by \"dependents\".\"empid\"")
         .withChecker(
-            resultContains(
-            "EnumerableAggregate(group=[{0}])",
-            "EnumerableUnion(all=[true])",
-            "EnumerableAggregate(group=[{2}])",
-            "EnumerableTableScan(table=[[hr, MV0]])",
-            "expr#5=[10], expr#6=[>($t0, $t5)], expr#7=[11], expr#8=[>=($t7, $t0)]"))
+            resultContains("EnumerableAggregate(group=[{0}])",
+                "EnumerableUnion(all=[true])",
+                "EnumerableAggregate(group=[{2}])",
+                "EnumerableTableScan(table=[[hr, MV0]])",
+                "expr#5=[Sarg[(10\u202511]]], expr#6=[SEARCH($t0, $t5)]"))
         .ok();
   }
 
@@ -491,12 +490,11 @@ public class MaterializedViewRelOptRulesTest extends AbstractMaterializedViewTes
             + "where \"depts\".\"deptno\" > 10 and \"depts\".\"deptno\" < 20\n"
             + "group by \"dependents\".\"empid\"")
         .withChecker(
-            resultContains(
-            "EnumerableAggregate(group=[{0}])",
-            "EnumerableUnion(all=[true])",
-            "EnumerableAggregate(group=[{2}])",
-            "EnumerableTableScan(table=[[hr, MV0]])",
-            "expr#13=[OR($t10, $t12)], expr#14=[AND($t6, $t8, $t13)]"))
+            resultContains("EnumerableAggregate(group=[{0}])",
+                "EnumerableUnion(all=[true])",
+                "EnumerableAggregate(group=[{2}])",
+                "EnumerableTableScan(table=[[hr, MV0]])",
+                "expr#5=[Sarg[(10\u202511], [19\u202520)]], expr#6=[SEARCH($t0, $t5)]"))
         .ok();
   }
 
@@ -705,15 +703,14 @@ public class MaterializedViewRelOptRulesTest extends AbstractMaterializedViewTes
             + "where \"depts\".\"deptno\" > 10 and \"depts\".\"deptno\" < 20\n"
             + "group by \"dependents\".\"empid\"")
         .withChecker(
-            resultContains(
-            "EnumerableCalc(expr#0..1=[{inputs}], expr#2=[1], expr#3=[+($t1, $t2)], "
-                + "empid=[$t0], EXPR$1=[$t3])\n"
-                + "  EnumerableAggregate(group=[{0}], agg#0=[$SUM0($1)])",
-            "EnumerableUnion(all=[true])",
-            "EnumerableAggregate(group=[{2}], agg#0=[COUNT()])",
-            "EnumerableAggregate(group=[{1}], agg#0=[$SUM0($2)])",
-            "EnumerableTableScan(table=[[hr, MV0]])",
-            "expr#13=[OR($t10, $t12)], expr#14=[AND($t6, $t8, $t13)]"))
+            resultContains("EnumerableCalc(expr#0..1=[{inputs}], expr#2=[1], "
+                    + "expr#3=[+($t1, $t2)], empid=[$t0], EXPR$1=[$t3])\n"
+                    + "  EnumerableAggregate(group=[{0}], agg#0=[$SUM0($1)])",
+                "EnumerableUnion(all=[true])",
+                "EnumerableAggregate(group=[{2}], agg#0=[COUNT()])",
+                "EnumerableAggregate(group=[{1}], agg#0=[$SUM0($2)])",
+                "EnumerableTableScan(table=[[hr, MV0]])",
+                "expr#5=[Sarg[(10\u202511], [19\u202520)]], expr#6=[SEARCH($t0, $t5)]"))
         .ok();
   }
 
@@ -889,10 +886,9 @@ public class MaterializedViewRelOptRulesTest extends AbstractMaterializedViewTes
             + "join \"emps\" on (\"emps\".\"deptno\" = \"depts\".\"deptno\")\n"
             + "where \"depts\".\"deptno\" > 10")
         .withChecker(
-            resultContains(
-            "EnumerableUnion(all=[true])",
-            "EnumerableTableScan(table=[[hr, MV0]])",
-            "expr#5=[10], expr#6=[>($t0, $t5)], expr#7=[30], expr#8=[>=($t7, $t0)]"))
+            resultContains("EnumerableUnion(all=[true])",
+                "EnumerableTableScan(table=[[hr, MV0]])",
+                "expr#5=[Sarg[(10\u202530]]], expr#6=[SEARCH($t0, $t5)]"))
         .ok();
   }
 
diff --git a/core/src/test/java/org/apache/calcite/test/RelBuilderTest.java b/core/src/test/java/org/apache/calcite/test/RelBuilderTest.java
index fa80fd6..610905e 100644
--- a/core/src/test/java/org/apache/calcite/test/RelBuilderTest.java
+++ b/core/src/test/java/org/apache/calcite/test/RelBuilderTest.java
@@ -545,7 +545,7 @@ public class RelBuilderTest {
         .filter(condition, condition2, condition, condition)
         .build();
     final String expected2 = ""
-        + "LogicalFilter(condition=[AND(>($7, 20), <($7, 30))])\n"
+        + "LogicalFilter(condition=[SEARCH($7, Sarg[(20\u202530)])])\n"
         + "  LogicalTableScan(table=[[scott, EMP]])\n";
     assertThat(root2, hasTree(expected2));
   }
@@ -637,10 +637,10 @@ public class RelBuilderTest {
                 builder.alias(builder.field(6), "C"))
             .build();
     final String expected = ""
-        + "LogicalProject(DEPTNO=[$7], COMM=[CAST($6):SMALLINT NOT NULL],"
-        + " $f2=[OR(=($7, 20), AND(null:NULL, =($7, 10), IS NULL($6),"
-        + " IS NULL($7)), =($7, 30))], n2=[IS NULL($2)],"
-        + " nn2=[IS NOT NULL($3)], $f5=[20], COMM0=[$6], C=[$6])\n"
+        + "LogicalProject(DEPTNO=[$7], COMM=[CAST($6):SMALLINT NOT NULL], "
+        + "$f2=[OR(SEARCH($7, Sarg[20, 30]), AND(null:NULL, =($7, 10), "
+        + "IS NULL($6), IS NULL($7)))], n2=[IS NULL($2)], "
+        + "nn2=[IS NOT NULL($3)], $f5=[20], COMM0=[$6], C=[$6])\n"
         + "  LogicalTableScan(table=[[scott, EMP]])\n";
     assertThat(root, hasTree(expected));
   }
@@ -3201,10 +3201,12 @@ public class RelBuilderTest {
         .build();
 
     final String expected = ""
-        + "LogicalCorrelate(correlation=[$cor0], joinType=[left], requiredColumns=[{7}])\n"
+        + "LogicalCorrelate(correlation=[$cor0], joinType=[left], "
+        + "requiredColumns=[{7}])\n"
         + "  LogicalTableScan(table=[[scott, EMP]])\n"
         + "  LogicalFilter(condition=[=($cor0.SAL, 1000)])\n"
-        + "    LogicalFilter(condition=[OR(AND(<($cor0.DEPTNO, 30), >($cor0.DEPTNO, 20)), "
+        + "    LogicalFilter(condition=[OR("
+        + "SEARCH($cor0.DEPTNO, Sarg[(20\u202530)]), "
         + "IS NULL($2))], variablesSet=[[$cor0]])\n"
         + "      LogicalTableScan(table=[[scott, DEPT]])\n";
 
@@ -3539,16 +3541,63 @@ public class RelBuilderTest {
 
   /** Test case for
    * <a href="https://issues.apache.org/jira/browse/CALCITE-3747">[CALCITE-3747]
-   * Constructing BETWEEN with RelBuilder throws class cast exception</a>. */
+   * Constructing BETWEEN with RelBuilder throws class cast exception</a>.
+   *
+   * <p>BETWEEN is no longer allowed in RexCall. 'a BETWEEN b AND c' is expanded
+   * 'a >= b AND a <= c', whether created via
+   * {@link RelBuilder#call(SqlOperator, RexNode...)} or
+   * {@link RelBuilder#between(RexNode, RexNode, RexNode)}.*/
   @Test void testCallBetweenOperator() {
-    final RelBuilder builder = RelBuilder.create(config().build());
-    final RexNode call = builder.scan("EMP")
-        .call(
-            SqlStdOperatorTable.BETWEEN,
+    final RelBuilder builder = RelBuilder.create(config().build()).scan("EMP");
+
+    final String expected = "SEARCH($0, Sarg[[1\u20255]])";
+    final RexNode call =
+        builder.call(SqlStdOperatorTable.BETWEEN,
             builder.field("EMPNO"),
             builder.literal(1),
             builder.literal(5));
-    assertThat(call.toString(), is("BETWEEN ASYMMETRIC($0, 1, 5)"));
+    assertThat(call.toString(), is(expected));
+
+    final RexNode call2 =
+        builder.between(builder.field("EMPNO"),
+            builder.literal(1),
+            builder.literal(5));
+    assertThat(call2.toString(), is(expected));
+
+    final RelNode root = builder.filter(call2).build();
+    final String expectedRel = ""
+        + "LogicalFilter(condition=[SEARCH($0, Sarg[[1\u20255]])])\n"
+        + "  LogicalTableScan(table=[[scott, EMP]])\n";
+    assertThat(root, hasTree(expectedRel));
+
+    // Consecutive filters are not merged. (For now, anyway.)
+    builder.push(root)
+        .filter(
+            builder.not(
+                builder.equals(builder.field("EMPNO"), builder.literal(3))),
+            builder.equals(builder.field("DEPTNO"), builder.literal(10)));
+    final RelNode root2 = builder.build();
+    final String expectedRel2 = ""
+        + "LogicalFilter(condition=[AND(<>($0, 3), =($7, 10))])\n"
+        + "  LogicalFilter(condition=[SEARCH($0, Sarg[[1\u20255]])])\n"
+        + "    LogicalTableScan(table=[[scott, EMP]])\n";
+    assertThat(root2, hasTree(expectedRel2));
+
+    // The conditions in one filter are simplified.
+    builder.scan("EMP")
+        .filter(
+            builder.between(builder.field("EMPNO"),
+                builder.literal(1),
+                builder.literal(5)),
+            builder.not(
+                builder.equals(builder.field("EMPNO"), builder.literal(3))),
+            builder.equals(builder.field("DEPTNO"), builder.literal(10)));
+    final RelNode root3 = builder.build();
+    final String expectedRel3 = ""
+        + "LogicalFilter(condition=[AND(SEARCH($0, Sarg[[1\u20253), (3\u20255]]), "
+        + "SEARCH($7, Sarg[10]))])\n"
+        + "  LogicalTableScan(table=[[scott, EMP]])\n";
+    assertThat(root3, hasTree(expectedRel3));
   }
 
   /** Test case for
diff --git a/core/src/test/java/org/apache/calcite/test/RelMetadataTest.java b/core/src/test/java/org/apache/calcite/test/RelMetadataTest.java
index ee653b3..eba2feb 100644
--- a/core/src/test/java/org/apache/calcite/test/RelMetadataTest.java
+++ b/core/src/test/java/org/apache/calcite/test/RelMetadataTest.java
@@ -2048,7 +2048,7 @@ public class RelMetadataTest extends SqlToRelTestBase {
         + "select empno, deptno from emp where empno=3 or deptno=4");
     final RelMetadataQuery mq = rel.getCluster().getMetadataQuery();
     assertThat(mq.getPulledUpPredicates(rel).pulledUpPredicates,
-        sortsAs("[OR(=($0, 1), =($1, 2), =($0, 3), =($1, 4))]"));
+        sortsAs("[OR(SEARCH($0, Sarg[1, 3]), SEARCH($1, Sarg[2, 4]))]"));
   }
 
   @Test void testPullUpPredicatesFromUnion2() {
diff --git a/core/src/test/java/org/apache/calcite/util/RangeSetTest.java b/core/src/test/java/org/apache/calcite/util/RangeSetTest.java
new file mode 100644
index 0000000..2d74877
--- /dev/null
+++ b/core/src/test/java/org/apache/calcite/util/RangeSetTest.java
@@ -0,0 +1,405 @@
+/*
+ * 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.calcite.util;
+
+import org.apache.calcite.linq4j.Ord;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableRangeSet;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.Range;
+import com.google.common.collect.RangeSet;
+import com.google.common.collect.TreeRangeSet;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.function.BiConsumer;
+
+import static org.hamcrest.CoreMatchers.is;
+import static org.hamcrest.MatcherAssert.assertThat;
+
+/**
+ * Unit test for {@link RangeSets} and other utilities relating to Guava
+ * {@link Range} and {@link RangeSet}.
+ */
+@SuppressWarnings("UnstableApiUsage")
+class RangeSetTest {
+  /** Tests {@link RangeSets#minus(RangeSet, Range)}. */
+  @SuppressWarnings("UnstableApiUsage")
+  @Test void testRangeSetMinus() {
+    final RangeSet<Integer> setNone = ImmutableRangeSet.of();
+    final RangeSet<Integer> setAll = setNone.complement();
+    final RangeSet<Integer> setGt2 = ImmutableRangeSet.of(Range.greaterThan(2));
+    final RangeSet<Integer> setGt1 = ImmutableRangeSet.of(Range.greaterThan(1));
+    final RangeSet<Integer> setGe1 = ImmutableRangeSet.of(Range.atLeast(1));
+    final RangeSet<Integer> setGt0 = ImmutableRangeSet.of(Range.greaterThan(0));
+    final RangeSet<Integer> setComplex =
+        ImmutableRangeSet.<Integer>builder()
+            .add(Range.closed(0, 2))
+            .add(Range.singleton(3))
+            .add(Range.greaterThan(5))
+            .build();
+    assertThat(setComplex.toString(), is("[[0\u20252], [3\u20253], (5\u2025+\u221e)]"));
+
+    assertThat(RangeSets.minus(setAll, Range.singleton(1)).toString(),
+        is("[(-\u221e\u20251), (1\u2025+\u221e)]"));
+    assertThat(RangeSets.minus(setNone, Range.singleton(1)), is(setNone));
+    assertThat(RangeSets.minus(setGt2, Range.singleton(1)), is(setGt2));
+    assertThat(RangeSets.minus(setGt1, Range.singleton(1)), is(setGt1));
+    assertThat(RangeSets.minus(setGe1, Range.singleton(1)), is(setGt1));
+    assertThat(RangeSets.minus(setGt0, Range.singleton(1)).toString(),
+        is("[(0\u20251), (1\u2025+\u221e)]"));
+    assertThat(RangeSets.minus(setComplex, Range.singleton(1)).toString(),
+        is("[[0\u20251), (1\u20252], [3\u20253], (5\u2025+\u221e)]"));
+    assertThat(RangeSets.minus(setComplex, Range.singleton(2)).toString(),
+        is("[[0\u20252), [3\u20253], (5\u2025+\u221e)]"));
+    assertThat(RangeSets.minus(setComplex, Range.singleton(3)).toString(),
+        is("[[0\u20252], (5\u2025+\u221e)]"));
+    assertThat(RangeSets.minus(setComplex, Range.open(2, 3)).toString(),
+        is("[[0\u20252], [3\u20253], (5\u2025+\u221e)]"));
+    assertThat(RangeSets.minus(setComplex, Range.closed(2, 3)).toString(),
+        is("[[0\u20252), (5\u2025+\u221e)]"));
+    assertThat(RangeSets.minus(setComplex, Range.closed(2, 7)).toString(),
+        is("[[0\u20252), (7\u2025+\u221e)]"));
+  }
+
+  /** Tests {@link RangeSets#isPoint(Range)}. */
+  @Test void testRangeSetIsPoint() {
+    assertThat(RangeSets.isPoint(Range.singleton(0)), is(true));
+    assertThat(RangeSets.isPoint(Range.closed(0, 0)), is(true));
+    assertThat(RangeSets.isPoint(Range.closed(0, 1)), is(false));
+    assertThat(RangeSets.isPoint(Range.openClosed(0, 1)), is(false));
+
+    // The integer range '0 > x and x < 2' contains only one valid integer
+    // but it is not a point.
+    assertThat(RangeSets.isPoint(Range.open(0, 2)), is(false));
+
+    assertThat(RangeSets.isPoint(Range.lessThan(0)), is(false));
+    assertThat(RangeSets.isPoint(Range.atMost(0)), is(false));
+    assertThat(RangeSets.isPoint(Range.greaterThan(0)), is(false));
+    assertThat(RangeSets.isPoint(Range.atLeast(0)), is(false));
+  }
+
+  /** Tests {@link RangeSets#countPoints(RangeSet)}. */
+  @Test void testRangeCountPoints() {
+    final Fixture f = new Fixture();
+    assertThat(RangeSets.countPoints(f.empty), is(0));
+    assertThat(RangeSets.countPoints(f.zeroRangeSet), is(1));
+    assertThat(RangeSets.countPoints(f.rangeSet), is(1));
+    final ImmutableRangeSet<Integer> set =
+        ImmutableRangeSet.<Integer>builder()
+            .add(Range.singleton(0))
+            .add(Range.open(1, 2))
+            .add(Range.singleton(3))
+            .add(Range.atLeast(4)).build();
+    assertThat(RangeSets.countPoints(set), is(2));
+    final ImmutableRangeSet<Integer> set2 =
+        ImmutableRangeSet.<Integer>builder()
+            .add(Range.open(1, 2))
+            .add(Range.atLeast(4)).build();
+    assertThat(RangeSets.countPoints(set2), is(0));
+  }
+
+  /** Tests {@link RangeSets#map} and {@link RangeSets#forEach}. */
+  @Test void testRangeMap() {
+    final StringBuilder sb = new StringBuilder();
+    final RangeSets.Handler<Integer, StringBuilder> h =
+        new RangeSets.Handler<Integer, StringBuilder>() {
+          @Override public StringBuilder all() {
+            return sb.append("all()");
+          }
+
+          @Override public StringBuilder atLeast(Integer lower) {
+            return sb.append("atLeast(").append(lower).append(")");
+          }
+
+          @Override public StringBuilder atMost(Integer upper) {
+            return sb.append("atMost(").append(upper).append(")");
+          }
+
+          @Override public StringBuilder greaterThan(Integer lower) {
+            return sb.append("greaterThan(").append(lower).append(")");
+          }
+
+          @Override public StringBuilder lessThan(Integer upper) {
+            return sb.append("lessThan(").append(upper).append(")");
+          }
+
+          @Override public StringBuilder singleton(Integer value) {
+            return sb.append("singleton(").append(value).append(")");
+          }
+
+          @Override public StringBuilder closed(Integer lower, Integer upper) {
+            return sb.append("closed(").append(lower).append(", ")
+                .append(upper).append(")");
+          }
+
+          @Override public StringBuilder closedOpen(Integer lower, Integer upper) {
+            return sb.append("closedOpen(").append(lower).append(", ")
+                .append(upper).append(")");
+          }
+
+          @Override public StringBuilder openClosed(Integer lower, Integer upper) {
+            return sb.append("openClosed(").append(lower).append(", ")
+                .append(upper).append(")");
+          }
+
+          @Override public StringBuilder open(Integer lower, Integer upper) {
+            return sb.append("open(").append(lower).append(", ")
+                .append(upper).append(")");
+          }
+        };
+    final RangeSets.Consumer<Integer> c =
+        new RangeSets.Consumer<Integer>() {
+          @Override public void all() {
+            sb.append("all()");
+          }
+
+          @Override public void atLeast(Integer lower) {
+            sb.append("atLeast(").append(lower).append(")");
+          }
+
+          @Override public void atMost(Integer upper) {
+            sb.append("atMost(").append(upper).append(")");
+          }
+
+          @Override public void greaterThan(Integer lower) {
+            sb.append("greaterThan(").append(lower).append(")");
+          }
+
+          @Override public void lessThan(Integer upper) {
+            sb.append("lessThan(").append(upper).append(")");
+          }
+
+          @Override public void singleton(Integer value) {
+            sb.append("singleton(").append(value).append(")");
+          }
+
+          @Override public void closed(Integer lower, Integer upper) {
+            sb.append("closed(").append(lower).append(", ")
+                .append(upper).append(")");
+          }
+
+          @Override public void closedOpen(Integer lower, Integer upper) {
+            sb.append("closedOpen(").append(lower).append(", ")
+                .append(upper).append(")");
+          }
+
+          @Override public void openClosed(Integer lower, Integer upper) {
+            sb.append("openClosed(").append(lower).append(", ")
+                .append(upper).append(")");
+          }
+
+          @Override public void open(Integer lower, Integer upper) {
+            sb.append("open(").append(lower).append(", ")
+                .append(upper).append(")");
+          }
+        };
+    final Fixture f = new Fixture();
+    for (Range<Integer> range : f.ranges) {
+      RangeSets.map(range, h);
+    }
+    assertThat(sb.toString(), is(f.rangesString));
+
+    sb.setLength(0);
+    for (Range<Integer> range : f.ranges) {
+      RangeSets.forEach(range, c);
+    }
+    assertThat(sb.toString(), is(f.rangesString));
+
+    // Use a smaller set of ranges that does not overlap
+    sb.setLength(0);
+    for (Range<Integer> range : f.disjointRanges) {
+      RangeSets.forEach(range, c);
+    }
+    assertThat(sb.toString(), is(f.disjointRangesString));
+
+    // For a RangeSet consisting of disjointRanges the effect is the same,
+    // but the ranges are sorted.
+    sb.setLength(0);
+    RangeSets.forEach(f.rangeSet, c);
+    assertThat(sb.toString(), is(f.disjointRangesSortedString));
+  }
+
+  /** Tests that {@link RangeSets#hashCode(RangeSet)} returns the same result
+   * as the hashCode of a list of the same ranges. */
+  @Test void testRangeSetHashCode() {
+    final Fixture f = new Fixture();
+    final int h = new ArrayList<>(f.rangeSet.asRanges()).hashCode();
+    assertThat(RangeSets.hashCode(f.rangeSet), is(h));
+    assertThat(RangeSets.hashCode(f.treeRangeSet), is(h));
+
+    assertThat(RangeSets.hashCode(ImmutableRangeSet.<Integer>of()),
+        is(ImmutableList.of().hashCode()));
+  }
+
+  /** Tests {@link RangeSets#compare(Range, Range)}. */
+  @Test void testRangeCompare() {
+    final Fixture f = new Fixture();
+    Ord.forEach(f.sortedRanges, (r0, i) ->
+        Ord.forEach(f.sortedRanges, (r1, j) -> {
+          final String reason = "compare " + r0 + " to " + r1;
+          assertThat(reason, RangeSets.compare(r0, r1),
+              is(Integer.compare(i, j)));
+        }));
+  }
+
+  /** Tests {@link RangeSets#compare(RangeSet, RangeSet)}. */
+  @Test void testRangeSetCompare() {
+    final Fixture f = new Fixture();
+    assertThat(RangeSets.compare(f.rangeSet, f.treeRangeSet), is(0));
+    assertThat(RangeSets.compare(f.rangeSet, f.rangeSet), is(0));
+    assertThat(RangeSets.compare(f.treeRangeSet, f.rangeSet), is(0));
+
+    // empty range set collates before everything
+    assertThat(RangeSets.compare(f.empty, f.treeRangeSet), is(-1));
+    assertThat(RangeSets.compare(f.treeRangeSet, f.empty), is(1));
+    assertThat(RangeSets.compare(f.empty, f.zeroRangeSet), is(-1));
+    assertThat(RangeSets.compare(f.zeroRangeSet, f.empty), is(1));
+
+    // removing the first element (if it's not the only element)
+    // makes a range set collate later
+    final RangeSet<Integer> s2 = TreeRangeSet.create(f.treeRangeSet);
+    s2.asRanges().remove(Iterables.getFirst(s2.asRanges(), null));
+    assertThat(RangeSets.compare(s2, f.treeRangeSet), is(1));
+    assertThat(RangeSets.compare(f.treeRangeSet, s2), is(-1));
+    assertThat(RangeSets.compare(f.empty, s2), is(-1));
+    assertThat(RangeSets.compare(s2, f.empty), is(1));
+
+    // removing the last element
+    // makes a range set collate earlier
+    final RangeSet<Integer> s3 = TreeRangeSet.create(f.treeRangeSet);
+    s3.asRanges().remove(Iterables.getLast(s3.asRanges(), null));
+    assertThat(RangeSets.compare(s3, f.treeRangeSet), is(-1));
+    assertThat(RangeSets.compare(f.treeRangeSet, s3), is(1));
+  }
+
+  /** Tests {@link RangeSets#printer(StringBuilder, BiConsumer)}. */
+  @Test void testRangePrint() {
+    final Fixture f = new Fixture();
+
+    // RangeSet's native printing
+    final List<String> list = new ArrayList<>();
+    f.ranges.forEach(r -> list.add(r.toString()));
+    final String expected = "[(-\u221e\u2025+\u221e), (-\u221e\u20253], "
+        + "[4\u2025+\u221e), (-\u221e\u20255), (6\u2025+\u221e), [7\u20257], "
+        + "(8\u20259), (10\u202511], [12\u202513], [14\u202515)]";
+    assertThat(list.toString(), is(expected));
+    list.clear();
+
+    final StringBuilder sb = new StringBuilder();
+    f.ranges.forEach(r -> {
+      RangeSets.forEach(r, RangeSets.printer(sb, StringBuilder::append));
+      list.add(sb.toString());
+      sb.setLength(0);
+    });
+    // our format matches Guava's, except points ("7" vs "[7, 7]")
+    final String expected2 = "[(-\u221e\u2025+\u221e), (-\u221e\u20253], "
+        + "[4\u2025+\u221e), (-\u221e\u20255), (6\u2025+\u221e), 7, "
+        + "(8\u20259), (10\u202511], [12\u202513], [14\u202515)]";
+    assertThat(list.toString(), is(expected2));
+    list.clear();
+  }
+
+  /** Data sets used by various tests. */
+  static class Fixture {
+    final ImmutableRangeSet<Integer> empty = ImmutableRangeSet.of();
+
+    final List<Range<Integer>> ranges =
+        Arrays.asList(Range.all(),
+            Range.atMost(3),
+            Range.atLeast(4),
+            Range.lessThan(5),
+            Range.greaterThan(6),
+            Range.singleton(7),
+            Range.open(8, 9),
+            Range.openClosed(10, 11),
+            Range.closed(12, 13),
+            Range.closedOpen(14, 15));
+    final String rangesString = "all()"
+        + "atMost(3)"
+        + "atLeast(4)"
+        + "lessThan(5)"
+        + "greaterThan(6)"
+        + "singleton(7)"
+        + "open(8, 9)"
+        + "openClosed(10, 11)"
+        + "closed(12, 13)"
+        + "closedOpen(14, 15)";
+
+    final List<Range<Integer>> sortedRanges =
+        Arrays.asList(
+            Range.lessThan(3),
+            Range.atMost(3),
+            Range.lessThan(5),
+            Range.all(),
+            Range.greaterThan(4),
+            Range.atLeast(4),
+            Range.greaterThan(6),
+            Range.singleton(7),
+            Range.open(8, 9),
+            Range.openClosed(8, 9),
+            Range.closedOpen(8, 9),
+            Range.closed(8, 9),
+            Range.openClosed(10, 11),
+            Range.closed(12, 13),
+            Range.closedOpen(14, 15));
+
+    final List<Range<Integer>> disjointRanges =
+        Arrays.asList(Range.lessThan(5),
+            Range.greaterThan(16),
+            Range.singleton(7),
+            Range.open(8, 9),
+            Range.openClosed(10, 11),
+            Range.closed(12, 13),
+            Range.closedOpen(14, 15));
+
+    final String disjointRangesString = "lessThan(5)"
+        + "greaterThan(16)"
+        + "singleton(7)"
+        + "open(8, 9)"
+        + "openClosed(10, 11)"
+        + "closed(12, 13)"
+        + "closedOpen(14, 15)";
+
+    final String disjointRangesSortedString = "lessThan(5)"
+        + "singleton(7)"
+        + "open(8, 9)"
+        + "openClosed(10, 11)"
+        + "closed(12, 13)"
+        + "closedOpen(14, 15)"
+        + "greaterThan(16)";
+
+    final RangeSet<Integer> rangeSet;
+    final TreeRangeSet<Integer> treeRangeSet;
+
+    final RangeSet<Integer> zeroRangeSet =
+        ImmutableRangeSet.of(Range.singleton(0));
+
+    Fixture() {
+      final ImmutableRangeSet.Builder<Integer> builder =
+          ImmutableRangeSet.builder();
+      disjointRanges.forEach(builder::add);
+      rangeSet = builder.build();
+      treeRangeSet = TreeRangeSet.create();
+      treeRangeSet.addAll(rangeSet);
+    }
+  }
+}
diff --git a/core/src/test/java/org/apache/calcite/util/UtilTest.java b/core/src/test/java/org/apache/calcite/util/UtilTest.java
index c5aa7c0..e681446 100644
--- a/core/src/test/java/org/apache/calcite/util/UtilTest.java
+++ b/core/src/test/java/org/apache/calcite/util/UtilTest.java
@@ -40,12 +40,9 @@ import org.apache.calcite.testlib.annotations.LocaleEnUs;
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMultiset;
-import com.google.common.collect.ImmutableRangeSet;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
-import com.google.common.collect.Range;
-import com.google.common.collect.RangeSet;
 import com.google.common.primitives.Ints;
 
 import org.hamcrest.Description;
@@ -555,6 +552,40 @@ class UtilTest {
     }
   }
 
+  /** Tests {@link Util#printList(StringBuilder, int, ObjIntConsumer)}. */
+  @Test void testPrintList() {
+    final StringBuilder sb = new StringBuilder();
+    Util.printList(sb, 0, (sb2, i) -> sb2.append(i * 2 + 1));
+    assertThat(sb.toString(), is("[]"));
+    sb.setLength(0);
+
+    Util.printList(sb, 1, (sb2, i) -> sb2.append(i * 2 + 1));
+    assertThat(sb.toString(), is("[1]"));
+    sb.setLength(0);
+
+    Util.printList(sb, 3, (sb2, i) -> sb2.append(i * 2 + 1));
+    assertThat(sb.toString(), is("[1, 3, 5]"));
+    sb.setLength(0);
+  }
+
+  /** Tests {@link Util#printIterable(StringBuilder, Iterable)}. */
+  @Test void testPrintIterable() {
+    final StringBuilder sb = new StringBuilder();
+    final Set<String> beatles =
+        new LinkedHashSet<>(Arrays.asList("John", "Paul", "George", "Ringo"));
+    Util.printIterable(sb, beatles);
+    assertThat(sb.toString(), is("[John, Paul, George, Ringo]"));
+    sb.setLength(0);
+
+    Util.printIterable(sb, ImmutableSet.of("abc"));
+    assertThat(sb.toString(), is("[abc]"));
+    sb.setLength(0);
+
+    Util.printIterable(sb, ImmutableList.of());
+    assertThat(sb.toString(), is("[]"));
+    sb.setLength(0);
+  }
+
   /**
    * Tests the difference engine, {@link DiffTestCase#diff}.
    */
@@ -1679,6 +1710,10 @@ class UtilTest {
     final Collection<String> cbcaC = new LinkedHashSet<>(cbca);
     assertThat(Util.distinctList(cbcaC), not(sameInstance(cbca)));
     assertThat(Util.distinctList(cbcaC), is(Arrays.asList("c", "b", "a")));
+    final List<String> a2 = ImmutableList.of("a", "a");
+    assertThat(Util.distinctList(a2), is(a));
+    final List<String> a1m = Collections.nCopies(1_000_000, "a");
+    assertThat(Util.distinctList(a1m), is(a));
   }
 
   /** Unit test for {@link Utilities#hashCode(double)}. */
@@ -2408,45 +2443,6 @@ class UtilTest {
     assertThat(map.containsKey("zyMurgy", false), is(true));
   }
 
-  /** Tests {@link RangeSets#minus(RangeSet, Range)}. */
-  @SuppressWarnings("UnstableApiUsage")
-  @Test void testRangeSetMinus() {
-    final RangeSet<Integer> setNone = ImmutableRangeSet.of();
-    final RangeSet<Integer> setAll = setNone.complement();
-    final RangeSet<Integer> setGt2 = ImmutableRangeSet.of(Range.greaterThan(2));
-    final RangeSet<Integer> setGt1 = ImmutableRangeSet.of(Range.greaterThan(1));
-    final RangeSet<Integer> setGe1 = ImmutableRangeSet.of(Range.atLeast(1));
-    final RangeSet<Integer> setGt0 = ImmutableRangeSet.of(Range.greaterThan(0));
-    final RangeSet<Integer> setComplex =
-        ImmutableRangeSet.<Integer>builder()
-            .add(Range.closed(0, 2))
-            .add(Range.singleton(3))
-            .add(Range.greaterThan(5))
-            .build();
-    assertThat(setComplex.toString(), is("[[0‥2], [3‥3], (5‥+∞)]"));
-
-    assertThat(RangeSets.minus(setAll, Range.singleton(1)).toString(),
-        is("[(-∞‥1), (1‥+∞)]"));
-    assertThat(RangeSets.minus(setNone, Range.singleton(1)), is(setNone));
-    assertThat(RangeSets.minus(setGt2, Range.singleton(1)), is(setGt2));
-    assertThat(RangeSets.minus(setGt1, Range.singleton(1)), is(setGt1));
-    assertThat(RangeSets.minus(setGe1, Range.singleton(1)), is(setGt1));
-    assertThat(RangeSets.minus(setGt0, Range.singleton(1)).toString(),
-        is("[(0‥1), (1‥+∞)]"));
-    assertThat(RangeSets.minus(setComplex, Range.singleton(1)).toString(),
-        is("[[0‥1), (1‥2], [3‥3], (5‥+∞)]"));
-    assertThat(RangeSets.minus(setComplex, Range.singleton(2)).toString(),
-        is("[[0‥2), [3‥3], (5‥+∞)]"));
-    assertThat(RangeSets.minus(setComplex, Range.singleton(3)).toString(),
-        is("[[0‥2], (5‥+∞)]"));
-    assertThat(RangeSets.minus(setComplex, Range.open(2, 3)).toString(),
-        is("[[0‥2], [3‥3], (5‥+∞)]"));
-    assertThat(RangeSets.minus(setComplex, Range.closed(2, 3)).toString(),
-        is("[[0‥2), (5‥+∞)]"));
-    assertThat(RangeSets.minus(setComplex, Range.closed(2, 7)).toString(),
-        is("[[0‥2), (7‥+∞)]"));
-  }
-
   @Test void testNlsStringClone() {
     final NlsString s = new NlsString("foo", "LATIN1", SqlCollation.IMPLICIT);
     assertThat(s.toString(), is("_LATIN1'foo'"));
diff --git a/core/src/test/resources/org/apache/calcite/test/RelOptRulesTest.xml b/core/src/test/resources/org/apache/calcite/test/RelOptRulesTest.xml
index 7662bf8..f1de506 100644
--- a/core/src/test/resources/org/apache/calcite/test/RelOptRulesTest.xml
+++ b/core/src/test/resources/org/apache/calcite/test/RelOptRulesTest.xml
@@ -152,7 +152,7 @@ where case when (sal = 1000) then
         <Resource name="planAfter">
             <![CDATA[
 LogicalProject(SAL=[$5])
-  LogicalFilter(condition=[OR(=($5, 1000), =($5, 2000))])
+  LogicalFilter(condition=[SEARCH($5, Sarg[1000, 2000])])
     LogicalTableScan(table=[[CATALOG, SALES, EMP]])
 ]]>
         </Resource>
@@ -205,7 +205,7 @@ OR case when sal = 2000 then null else 1 end is null]]>
         <Resource name="planAfter">
             <![CDATA[
 LogicalProject(SAL=[$5])
-  LogicalFilter(condition=[OR(=($5, 1000), =($5, 2000))])
+  LogicalFilter(condition=[SEARCH($5, Sarg[1000, 2000])])
     LogicalTableScan(table=[[CATALOG, SALES, EMP]])
 ]]>
         </Resource>
@@ -3493,7 +3493,7 @@ LogicalProject(EMPNO=[$0], ENAME=[$1], JOB=[$2], MGR=[$3], HIREDATE=[$4], SAL=[$
         <Resource name="planAfter">
             <![CDATA[
 LogicalProject(EMPNO=[$0], ENAME=[$1], JOB=[$2], MGR=[$3], HIREDATE=[$4], SAL=[$5], COMM=[$6], DEPTNO=[$7], SLACKER=[$8], BIRTHDATE=[$9])
-  LogicalFilter(condition=[AND(>=($9, 2014-04-01), <($9, 2014-05-01))])
+  LogicalFilter(condition=[SEARCH($9, Sarg[[2014-04-01‥2014-05-01)])])
     LogicalTableScan(table=[[CATALOG, SALES, EMP_B]])
 ]]>
         </Resource>
@@ -3514,7 +3514,7 @@ LogicalProject(EMPNO=[$0], ENAME=[$1], JOB=[$2], MGR=[$3], HIREDATE=[$4], SAL=[$
         <Resource name="planAfter">
             <![CDATA[
 LogicalProject(EMPNO=[$0], ENAME=[$1], JOB=[$2], MGR=[$3], HIREDATE=[$4], SAL=[$5], COMM=[$6], DEPTNO=[$7], SLACKER=[$8], BIRTHDATE=[$9])
-  LogicalFilter(condition=[AND(>=($9, 2014-01-01), <($9, 2015-01-01))])
+  LogicalFilter(condition=[SEARCH($9, Sarg[[2014-01-01‥2015-01-01)])])
     LogicalTableScan(table=[[CATALOG, SALES, EMP_B]])
 ]]>
         </Resource>
@@ -3613,9 +3613,9 @@ LogicalProject(EMPNO=[$0], ENAME=[$1], JOB=[$2], MGR=[$3], HIREDATE=[$4], SAL=[$
             <![CDATA[
 LogicalProject(EMPNO=[$0], ENAME=[$1], JOB=[$2], MGR=[$3], HIREDATE=[$4], SAL=[$5], COMM=[$6], DEPTNO=[$7], SLACKER=[$8], EMPNO0=[$9], ENAME0=[$10], JOB0=[$11], MGR0=[$12], HIREDATE0=[$13], SAL0=[$14], COMM0=[$15], DEPTNO0=[$16], SLACKER0=[$17])
   LogicalJoin(condition=[=($16, $7)], joinType=[inner])
-    LogicalFilter(condition=[AND(<>($7, 4), <>($7, 6))])
+    LogicalFilter(condition=[SEARCH($7, Sarg[(-∞‥4), (4‥6), (6‥+∞)])])
       LogicalTableScan(table=[[CATALOG, SALES, EMP]])
-    LogicalFilter(condition=[AND(<>($7, 4), <>($7, 6))])
+    LogicalFilter(condition=[SEARCH($7, Sarg[(-∞‥4), (4‥6), (6‥+∞)])])
       LogicalTableScan(table=[[CATALOG, SALES, EMP]])
 ]]>
         </Resource>
@@ -6103,7 +6103,7 @@ where d.deptno in (7, 9) or d.deptno > 10]]>
             <![CDATA[
 LogicalProject(EXPR$0=[1])
   LogicalJoin(condition=[=($7, $16)], joinType=[inner])
-    LogicalFilter(condition=[OR(=($7, 7), =($7, 9), >($7, 10))])
+    LogicalFilter(condition=[SEARCH($7, Sarg[7, 9, (10‥+∞)])])
       LogicalTableScan(table=[[CATALOG, SALES, EMP]])
     LogicalTableScan(table=[[CATALOG, SALES, EMP]])
 ]]>
@@ -6112,9 +6112,9 @@ LogicalProject(EXPR$0=[1])
             <![CDATA[
 LogicalProject(EXPR$0=[1])
   LogicalJoin(condition=[=($7, $16)], joinType=[inner])
-    LogicalFilter(condition=[OR(=($7, 7), =($7, 9), >($7, 10))])
+    LogicalFilter(condition=[SEARCH($7, Sarg[7, 9, (10‥+∞)])])
       LogicalTableScan(table=[[CATALOG, SALES, EMP]])
-    LogicalFilter(condition=[OR(=($7, 7), =($7, 9), >($7, 10))])
+    LogicalFilter(condition=[SEARCH($7, Sarg[7, 9, (10‥+∞)])])
       LogicalTableScan(table=[[CATALOG, SALES, EMP]])
 ]]>
         </Resource>
@@ -6129,7 +6129,7 @@ where d.deptno in (7, 9) or d.comm > 10]]>
             <![CDATA[
 LogicalProject(EXPR$0=[1])
   LogicalJoin(condition=[=($7, $16)], joinType=[inner])
-    LogicalFilter(condition=[OR(=($7, 7), =($7, 9), >($6, 10))])
+    LogicalFilter(condition=[OR(SEARCH($7, Sarg[7, 9]), SEARCH($6, Sarg[(10‥+∞)]))])
       LogicalTableScan(table=[[CATALOG, SALES, EMP]])
     LogicalTableScan(table=[[CATALOG, SALES, EMP]])
 ]]>
@@ -8608,7 +8608,7 @@ LogicalProject(NAME=[$0], LATITUDE=[$1], LONGITUDE=[$2], CUISINE=[$3], HILBERT=[
         <Resource name="planAfter">
             <![CDATA[
 LogicalProject(NAME=[$0], LATITUDE=[$1], LONGITUDE=[$2], CUISINE=[$3], HILBERT=[$4])
-  LogicalFilter(condition=[AND(OR(AND(>=($4, 36556), <=($4, 36568)), AND(>=($4, 36447), <=($4, 36548)), AND(>=($4, 36442), <=($4, 36445)), AND(>=($4, 36425), <=($4, 36427)), AND(>=($4, 36421), <=($4, 36423)), AND(>=($4, 36403), <=($4, 36404)), AND(>=($4, 36392), <=($4, 36401)), AND(>=($4, 33267), <=($4, 33270)), AND(>=($4, 33231), <=($4, 33265)), AND(>=($4, 33226), <=($4, 33229)), AND(>=($4, 33209), <=($4, 33211)), AND(>=($4, 33205), <=($4, 33207)), AND(>=($4, 33187), <=($4, 33190)), AND [...]
+  LogicalFilter(condition=[AND(SEARCH($4, Sarg[[28988‥28989], [28991‥28994], [29006‥29009], [29011‥29014], [32426‥32429], [32431‥32434], [32446‥32449], [32451‥32454], [32506‥32509], [32511‥32514], [32526‥32527], [32929‥32930], [32942‥32949], [32951‥32953], [32955‥32970], [32973‥32975], [33009‥33011], [33014‥33185], [33187‥33190], [33205‥33207], [33209‥33211], [33226‥33229], [33231‥33265], [33267‥33270], [36392‥36401], [36403‥36404], [36421‥36423], [36425‥36427], [36442‥36445], [36447‥365 [...]
     LogicalTableScan(table=[[CATALOG, GEO, RESTAURANTS]])
 ]]>
         </Resource>
@@ -8935,7 +8935,7 @@ LogicalAggregate(group=[{}], EXPR$0=[COUNT()])
             <![CDATA[
 LogicalAggregate(group=[{}], EXPR$0=[COUNT()])
   LogicalProject($f0=[1])
-    LogicalFilter(condition=[OR(=($7, 30), =($7, 10))])
+    LogicalFilter(condition=[SEARCH($7, Sarg[10, 30])])
       LogicalTableScan(table=[[CATALOG, SALES, EMP]])
 ]]>
         </Resource>
@@ -9474,7 +9474,7 @@ LogicalProject(NAME=[$0], LATITUDE=[$1], LONGITUDE=[$2], CUISINE=[$3], HILBERT=[
         <Resource name="planAfter">
             <![CDATA[
 LogicalProject(NAME=[$0], LATITUDE=[$1], LONGITUDE=[$2], CUISINE=[$3], HILBERT=[$4])
-  LogicalFilter(condition=[AND(OR(AND(>=($4, 36496), <=($4, 36520)), AND(>=($4, 36456), <=($4, 36464)), AND(>=($4, 33252), <=($4, 33254)), AND(>=($4, 33236), <=($4, 33244)), AND(>=($4, 33164), <=($4, 33176)), AND(>=($4, 33112), <=($4, 33156)), AND(>=($4, 33092), <=($4, 33100)), AND(>=($4, 33055), <=($4, 33080)), AND(>=($4, 33050), <=($4, 33053)), AND(>=($4, 33033), <=($4, 33035))), ST_CONTAINS(MULTIPOLYGON (((16 20, 15.987153539431617 20.39241877538086, 15.948669168242859 20.783157153320 [...]
+  LogicalFilter(condition=[AND(SEARCH($4, Sarg[[33033‥33035], [33050‥33053], [33055‥33080], [33092‥33100], [33112‥33156], [33164‥33176], [33236‥33244], [33252‥33254], [36456‥36464], [36496‥36520]]), ST_CONTAINS(MULTIPOLYGON (((16 20, 15.987153539431617 20.39241877538086, 15.948669168242859 20.78315715332031, 15.884711682419379 21.17054193209677, 15.795554957734407 21.552914270615126, 15.68158077697063 21.92863679181897, 15.543277195067716 22.296100594190538, 15.381236449196127 22.6537321 [...]
     LogicalTableScan(table=[[CATALOG, GEO, RESTAURANTS]])
 ]]>
         </Resource>
@@ -9497,7 +9497,7 @@ LogicalProject(NAME=[$0], LATITUDE=[$1], LONGITUDE=[$2], CUISINE=[$3], HILBERT=[
         <Resource name="planAfter">
             <![CDATA[
 LogicalProject(NAME=[$0], LATITUDE=[$1], LONGITUDE=[$2], CUISINE=[$3], HILBERT=[$4])
-  LogicalFilter(condition=[AND(OR(AND(>=($4, 36517), <=($4, 36519)), AND(>=($4, 36505), <=($4, 36507)), AND(>=($4, 36501), <=($4, 36503)), AND(>=($4, 36457), <=($4, 36459)), AND(>=($4, 33236), <=($4, 33240)), AND(>=($4, 33164), <=($4, 33176)), AND(>=($4, 33112), <=($4, 33156)), AND(>=($4, 33092), <=($4, 33100)), AND(>=($4, 33064), <=($4, 33076))), ST_DWITHIN(MULTILINESTRING ((8 20, 12 20)):GEOMETRY, ST_POINT($2, $1), 4))])
+  LogicalFilter(condition=[AND(SEARCH($4, Sarg[[33064‥33076], [33092‥33100], [33112‥33156], [33164‥33176], [33236‥33240], [36457‥36459], [36501‥36503], [36505‥36507], [36517‥36519]]), ST_DWITHIN(MULTILINESTRING ((8 20, 12 20)):GEOMETRY, ST_POINT($2, $1), 4))])
     LogicalTableScan(table=[[CATALOG, GEO, RESTAURANTS]])
 ]]>
         </Resource>
@@ -9519,7 +9519,7 @@ LogicalProject(NAME=[$0], LATITUDE=[$1], LONGITUDE=[$2], CUISINE=[$3], HILBERT=[
         <Resource name="planAfter">
             <![CDATA[
 LogicalProject(NAME=[$0], LATITUDE=[$1], LONGITUDE=[$2], CUISINE=[$3], HILBERT=[$4])
-  LogicalFilter(condition=[AND(OR(AND(>=($4, 36496), <=($4, 36520)), AND(>=($4, 36456), <=($4, 36464)), AND(>=($4, 33252), <=($4, 33254)), AND(>=($4, 33236), <=($4, 33244)), AND(>=($4, 33164), <=($4, 33176)), AND(>=($4, 33112), <=($4, 33156)), AND(>=($4, 33092), <=($4, 33100)), AND(>=($4, 33055), <=($4, 33080)), AND(>=($4, 33050), <=($4, 33053)), AND(>=($4, 33033), <=($4, 33035))), ST_DWITHIN(ST_POINT($2, $1), POINT (10 20):GEOMETRY, 6))])
+  LogicalFilter(condition=[AND(SEARCH($4, Sarg[[33033‥33035], [33050‥33053], [33055‥33080], [33092‥33100], [33112‥33156], [33164‥33176], [33236‥33244], [33252‥33254], [36456‥36464], [36496‥36520]]), ST_DWITHIN(ST_POINT($2, $1), POINT (10 20):GEOMETRY, 6))])
     LogicalTableScan(table=[[CATALOG, GEO, RESTAURANTS]])
 ]]>
         </Resource>
diff --git a/core/src/test/resources/org/apache/calcite/test/SqlToRelConverterTest.xml b/core/src/test/resources/org/apache/calcite/test/SqlToRelConverterTest.xml
index b1eab2a..adb27d2 100644
--- a/core/src/test/resources/org/apache/calcite/test/SqlToRelConverterTest.xml
+++ b/core/src/test/resources/org/apache/calcite/test/SqlToRelConverterTest.xml
@@ -1811,7 +1811,7 @@ LogicalProject(EMPNO=[$0], ENAME=[$1], JOB=[$2], MGR=[$3], HIREDATE=[$4], SAL=[$
         <Resource name="plan">
             <![CDATA[
 LogicalProject(EMPNO=[$0], ENAME=[$1], JOB=[$2], MGR=[$3], HIREDATE=[$4], SAL=[$5], COMM=[$6], DEPTNO=[$7], SLACKER=[$8], DEPTNO0=[$9], NAME=[$10])
-  LogicalJoin(condition=[AND(=($7, $9), OR(=($0, 1), =($0, 3)))], joinType=[inner])
+  LogicalJoin(condition=[AND(=($7, $9), SEARCH($0, Sarg[1, 3]))], joinType=[inner])
     LogicalTableScan(table=[[CATALOG, SALES, EMP]])
     LogicalTableScan(table=[[CATALOG, SALES, DEPT]])
 ]]>
@@ -3681,7 +3681,7 @@ group by (CASE WHEN (deptno IN (10, 20)) THEN 0 ELSE deptno END)]]>
         <Resource name="plan">
             <![CDATA[
 LogicalAggregate(group=[{0}], EXPR$1=[MIN($1)])
-  LogicalProject(EXPR$0=[CASE(OR(=($7, 10), =($7, 20)), 0, $7)], EMPNO=[$0])
+  LogicalProject(EXPR$0=[CASE(SEARCH($7, Sarg[10, 20]), 0, $7)], EMPNO=[$0])
     LogicalTableScan(table=[[CATALOG, SALES, EMP]])
 ]]>
         </Resource>
@@ -3711,7 +3711,7 @@ group by deptno]]>
         <Resource name="plan">
             <![CDATA[
 LogicalAggregate(group=[{0}], EXPR$1=[SUM($1) FILTER $2], EXPR$2=[COUNT()])
-  LogicalProject(DEPTNO=[$7], $f1=[*($5, 2)], $f2=[AND(<>($0, 1), <>($0, 2))])
+  LogicalProject(DEPTNO=[$7], $f1=[*($5, 2)], $f2=[SEARCH($0, Sarg[(-∞‥1), (1‥2), (2‥+∞)])])
     LogicalTableScan(table=[[CATALOG, SALES, EMP]])
 ]]>
         </Resource>
@@ -3899,7 +3899,7 @@ sum(case when deptno in (3, 4) then 0 else 1 end) > 10]]>
 LogicalProject(DEPTNO=[$0])
   LogicalFilter(condition=[>(+($1, $2), 10)])
     LogicalAggregate(group=[{0}], agg#0=[SUM($1)], agg#1=[SUM($2)])
-      LogicalProject(DEPTNO=[$7], $f1=[CASE(OR(=($7, 1), =($7, 2)), 0, 1)], $f2=[CASE(OR(=($7, 3), =($7, 4)), 0, 1)])
+      LogicalProject(DEPTNO=[$7], $f1=[CASE(SEARCH($7, Sarg[1, 2]), 0, 1)], $f2=[CASE(SEARCH($7, Sarg[3, 4]), 0, 1)])
         LogicalTableScan(table=[[CATALOG, SALES, EMP]])
 ]]>
         </Resource>
@@ -5140,9 +5140,9 @@ LogicalProject(ORDERID=[$0], ROWTIME=[$1], window_start=[$2], window_end=[$3])
             <![CDATA[select *
 from table(
 hop(
+  DATA => table Shipments,
   TIMECOL => descriptor(rowtime),
   SLIDE => INTERVAL '1' MINUTE,
-  DATA => table Shipments,
   SIZE => INTERVAL '2' MINUTE))]]>
         </Resource>
         <Resource name="plan">
@@ -6775,7 +6775,7 @@ group by deptno]]>
         <Resource name="plan">
             <![CDATA[
 LogicalAggregate(group=[{0}], EXPR$1=[COLLECT($1) WITHIN GROUP ([2])], EXPR$2=[COUNT()])
-  LogicalProject(DEPTNO=[$7], EMPNO=[$0], $f2=[AND(<>($0, 1), <>($0, 2))])
+  LogicalProject(DEPTNO=[$7], EMPNO=[$0], $f2=[SEARCH($0, Sarg[(-∞‥1), (1‥2), (2‥+∞)])])
     LogicalTableScan(table=[[CATALOG, SALES, EMP]])
 ]]>
         </Resource>
@@ -7124,7 +7124,7 @@ GROUP by deptno, job]]>
         </Resource>
         <Resource name="plan">
             <![CDATA[
-LogicalProject(JOB_NAME=[CASE(OR(=($1, '810000'), =($1, '820000')), $1, 'error':VARCHAR(10))], EXPR$1=[$2])
+LogicalProject(JOB_NAME=[CASE(SEARCH($1, Sarg['810000', '820000']:CHAR(6)), $1, 'error':VARCHAR(10))], EXPR$1=[$2])
   LogicalAggregate(group=[{0, 1}], EXPR$1=[COUNT()])
     LogicalProject(DEPTNO=[$7], JOB=[$2], EMPNO=[$0])
       LogicalFilter(condition=[OR(<>($2, ''), =($2, '810000'), =($2, '820000'))])
@@ -7181,7 +7181,7 @@ group by case when coalesce(ename, 'a') in ('1', '2') then 'CKA' else 'QT' END]]
         <Resource name="plan">
             <![CDATA[
 LogicalAggregate(group=[{0}], EXPR$1=[COUNT(DISTINCT $1)])
-  LogicalProject(EXPR$0=[CASE(OR(=($1, '1'), =($1, '2')), 'CKA', 'QT ')], DEPTNO=[$7])
+  LogicalProject(EXPR$0=[CASE(SEARCH($1, Sarg['1', '2']:CHAR(1)), 'CKA', 'QT ')], DEPTNO=[$7])
     LogicalTableScan(table=[[CATALOG, SALES, EMP]])
 ]]>
         </Resource>
diff --git a/core/src/test/resources/sql/sort.iq b/core/src/test/resources/sql/sort.iq
index 33e2875..01f18b2 100644
--- a/core/src/test/resources/sql/sort.iq
+++ b/core/src/test/resources/sql/sort.iq
@@ -51,7 +51,7 @@ select * from "days" where "day" between 2 and 4 order by "day";
 (3 rows)
 
 !ok
-EnumerableCalc(expr#0..1=[{inputs}], expr#2=[2], expr#3=[>=($t0, $t2)], expr#4=[4], expr#5=[<=($t0, $t4)], expr#6=[AND($t3, $t5)], proj#0..1=[{exprs}], $condition=[$t6])
+EnumerableCalc(expr#0..1=[{inputs}], expr#2=[Sarg[[2‥4]]], expr#3=[SEARCH($t0, $t2)], proj#0..1=[{exprs}], $condition=[$t3])
   EnumerableTableScan(table=[[foodmart2, days]])
 !plan
 
diff --git a/core/src/test/resources/sql/sub-query.iq b/core/src/test/resources/sql/sub-query.iq
index 85cd94f..3d1c3a9 100644
--- a/core/src/test/resources/sql/sub-query.iq
+++ b/core/src/test/resources/sql/sub-query.iq
@@ -1009,7 +1009,7 @@ EnumerableCalc(expr#0..3=[{inputs}], expr#4=[NOT($t2)], expr#5=[IS TRUE($t4)], e
     EnumerableLimit(fetch=[1])
       EnumerableSort(sort0=[$0], dir0=[DESC])
         EnumerableAggregate(group=[{0}], c=[COUNT()])
-          EnumerableCalc(expr#0..2=[{inputs}], expr#3=[true], expr#4=[10], expr#5=[=($t4, $t0)], cs=[$t3], $condition=[$t5])
+          EnumerableCalc(expr#0..2=[{inputs}], expr#3=[true], expr#4=[CAST($t0):TINYINT], expr#5=[Sarg[10]], expr#6=[SEARCH($t4, $t5)], cs=[$t3], $condition=[$t6])
             EnumerableTableScan(table=[[scott, DEPT]])
 !plan
 
@@ -1259,7 +1259,7 @@ EnumerableCalc(expr#0..3=[{inputs}], expr#4=[IS NULL($t3)], expr#5=[NOT($t2)], e
     EnumerableLimit(fetch=[1])
       EnumerableSort(sort0=[$0], dir0=[DESC])
         EnumerableAggregate(group=[{0}], c=[COUNT()])
-          EnumerableCalc(expr#0..2=[{inputs}], expr#3=[true], expr#4=[10], expr#5=[=($t4, $t0)], cs=[$t3], $condition=[$t5])
+          EnumerableCalc(expr#0..2=[{inputs}], expr#3=[true], expr#4=[CAST($t0):TINYINT], expr#5=[Sarg[10]], expr#6=[SEARCH($t4, $t5)], cs=[$t3], $condition=[$t6])
             EnumerableTableScan(table=[[scott, DEPT]])
 !plan
 
@@ -1573,7 +1573,7 @@ EnumerableCalc(expr#0..3=[{inputs}], expr#4=[IS NULL($t3)], expr#5=[NOT($t2)], e
     EnumerableLimit(fetch=[1])
       EnumerableSort(sort0=[$0], dir0=[DESC])
         EnumerableAggregate(group=[{0}], c=[COUNT()])
-          EnumerableCalc(expr#0..2=[{inputs}], expr#3=[true], expr#4=[10], expr#5=[=($t4, $t0)], cs=[$t3], $condition=[$t5])
+          EnumerableCalc(expr#0..2=[{inputs}], expr#3=[true], expr#4=[CAST($t0):TINYINT], expr#5=[Sarg[10]], expr#6=[SEARCH($t4, $t5)], cs=[$t3], $condition=[$t6])
             EnumerableTableScan(table=[[scott, DEPT]])
 !plan
 
@@ -1864,7 +1864,7 @@ EnumerableCalc(expr#0..4=[{inputs}], expr#5=[NOT($t4)], expr#6=[IS NOT NULL($t4)
     EnumerableSort(sort0=[$2], dir0=[ASC])
       EnumerableCalc(expr#0..7=[{inputs}], EMPNO=[$t0], SAL=[$t5], DEPTNO=[$t7])
         EnumerableTableScan(table=[[scott, EMP]])
-    EnumerableCalc(expr#0..2=[{inputs}], expr#3=[true], expr#4=[10], expr#5=[=($t4, $t0)], DEPTNO=[$t0], $f1=[$t3], $condition=[$t5])
+    EnumerableCalc(expr#0..2=[{inputs}], expr#3=[true], expr#4=[CAST($t0):TINYINT], expr#5=[Sarg[10]], expr#6=[SEARCH($t4, $t5)], DEPTNO=[$t0], $f1=[$t3], $condition=[$t6])
       EnumerableTableScan(table=[[scott, DEPT]])
 !plan
 
diff --git a/druid/src/main/java/org/apache/calcite/adapter/druid/DruidDateTimeUtils.java b/druid/src/main/java/org/apache/calcite/adapter/druid/DruidDateTimeUtils.java
index db7997f..0e5a3bd 100644
--- a/druid/src/main/java/org/apache/calcite/adapter/druid/DruidDateTimeUtils.java
+++ b/druid/src/main/java/org/apache/calcite/adapter/druid/DruidDateTimeUtils.java
@@ -25,12 +25,15 @@ import org.apache.calcite.rex.RexNode;
 import org.apache.calcite.sql.SqlKind;
 import org.apache.calcite.sql.type.SqlTypeName;
 import org.apache.calcite.util.DateString;
+import org.apache.calcite.util.RangeSets;
+import org.apache.calcite.util.Sarg;
 import org.apache.calcite.util.TimestampString;
 import org.apache.calcite.util.Util;
 import org.apache.calcite.util.trace.CalciteTrace;
 
 import com.google.common.collect.BoundType;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableRangeSet;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Range;
 import com.google.common.collect.TreeRangeSet;
@@ -111,8 +114,8 @@ public class DruidDateTimeUtils {
     case LESS_THAN_OR_EQUAL:
     case GREATER_THAN:
     case GREATER_THAN_OR_EQUAL:
-    case BETWEEN:
-    case IN:
+    case DRUID_IN:
+    case SEARCH:
       return leafToRanges((RexCall) node, withNot);
 
     case NOT:
@@ -165,6 +168,7 @@ public class DruidDateTimeUtils {
 
   @Nullable
   protected static List<Range<Long>> leafToRanges(RexCall call, boolean withNot) {
+    final ImmutableList.Builder<Range<Long>> ranges;
     switch (call.getKind()) {
     case EQUALS:
     case LESS_THAN:
@@ -218,9 +222,8 @@ public class DruidDateTimeUtils {
       return ImmutableList.of(Range.lessThan(inverted ? value2 : value1),
           Range.greaterThan(inverted ? value1 : value2));
     }
-    case IN: {
-      ImmutableList.Builder<Range<Long>> ranges =
-          ImmutableList.builder();
+    case DRUID_IN:
+      ranges = ImmutableList.builder();
       for (RexNode operand : Util.skip(call.operands)) {
         final Long element = literalValue(operand);
         if (element == null) {
@@ -234,12 +237,34 @@ public class DruidDateTimeUtils {
         }
       }
       return ranges.build();
-    }
+
+    case SEARCH:
+      final RexLiteral right = (RexLiteral) call.operands.get(1);
+      final Sarg<?> sarg = right.getValueAs(Sarg.class);
+      ranges = ImmutableList.builder();
+      for (Range range : sarg.rangeSet.asRanges()) {
+        Range<Long> range2 = RangeSets.copy(range, DruidDateTimeUtils::toLong);
+        if (withNot) {
+          ranges.addAll(ImmutableRangeSet.of(range2).complement().asRanges());
+        } else {
+          ranges.add(range2);
+        }
+      }
+      return ranges.build();
+
     default:
       return null;
     }
   }
 
+  private static Long toLong(Comparable comparable) {
+    if (comparable instanceof TimestampString) {
+      TimestampString timestampString = (TimestampString) comparable;
+      return timestampString.getMillisSinceEpoch();
+    }
+    throw new AssertionError("unsupported type: " + comparable.getClass());
+  }
+
   /**
    * Returns the literal value for the given node, assuming it is a literal with
    * datetime type, or a cast that only alters nullability on top of a literal with
diff --git a/druid/src/main/java/org/apache/calcite/adapter/druid/DruidJsonFilter.java b/druid/src/main/java/org/apache/calcite/adapter/druid/DruidJsonFilter.java
index 669d745..bbde69c 100644
--- a/druid/src/main/java/org/apache/calcite/adapter/druid/DruidJsonFilter.java
+++ b/druid/src/main/java/org/apache/calcite/adapter/druid/DruidJsonFilter.java
@@ -267,7 +267,11 @@ abstract class DruidJsonFilter implements DruidJson {
   @Nullable
   private static DruidJsonFilter toInKindDruidFilter(RexNode e, RelDataType rowType,
       DruidQuery druidQuery) {
-    if (e.getKind() != SqlKind.IN && e.getKind() != SqlKind.NOT_IN) {
+    switch (e.getKind()) {
+    case DRUID_IN:
+    case DRUID_NOT_IN:
+      break;
+    default:
       throw new AssertionError(
           DruidQuery.format("Expecting IN or NOT IN but got [%s]", e.getKind()));
     }
@@ -355,8 +359,8 @@ abstract class DruidJsonFilter implements DruidJson {
       return toBoundDruidFilter(e, rowType, druidQuery);
     case BETWEEN:
       return toBetweenDruidFilter(e, rowType, druidQuery);
-    case IN:
-    case NOT_IN:
+    case DRUID_IN:
+    case DRUID_NOT_IN:
       return toInKindDruidFilter(e, rowType, druidQuery);
     case IS_NULL:
     case IS_NOT_NULL:
diff --git a/druid/src/test/java/org/apache/calcite/adapter/druid/DruidQueryFilterTest.java b/druid/src/test/java/org/apache/calcite/adapter/druid/DruidQueryFilterTest.java
index 7a50733..b108166 100644
--- a/druid/src/test/java/org/apache/calcite/adapter/druid/DruidQueryFilterTest.java
+++ b/druid/src/test/java/org/apache/calcite/adapter/druid/DruidQueryFilterTest.java
@@ -22,7 +22,7 @@ import org.apache.calcite.rel.type.RelDataType;
 import org.apache.calcite.rel.type.RelDataTypeSystem;
 import org.apache.calcite.rex.RexBuilder;
 import org.apache.calcite.rex.RexNode;
-import org.apache.calcite.sql.fun.SqlStdOperatorTable;
+import org.apache.calcite.sql.fun.SqlInternalOperators;
 import org.apache.calcite.sql.type.SqlTypeName;
 
 import com.fasterxml.jackson.core.JsonFactory;
@@ -49,8 +49,8 @@ import static org.hamcrest.core.Is.is;
 class DruidQueryFilterTest {
 
   private DruidQuery druidQuery;
-  @BeforeEach
-  public void testSetup() {
+
+  @BeforeEach void testSetup() {
     druidQuery = Mockito.mock(DruidQuery.class);
     final CalciteConnectionConfig connectionConfigMock = Mockito
         .mock(CalciteConnectionConfig.class);
@@ -62,6 +62,7 @@ class DruidQueryFilterTest {
                 ImmutableSet.of(), "timestamp", null, null,
                 null));
   }
+
   @Test void testInFilter() throws IOException {
     final Fixture f = new Fixture();
     final List<? extends RexNode> listRexNodes =
@@ -71,7 +72,7 @@ class DruidQueryFilterTest {
             f.rexBuilder.makeLiteral("value1"));
 
     RexNode inRexNode =
-        f.rexBuilder.makeCall(SqlStdOperatorTable.IN, listRexNodes);
+        f.rexBuilder.makeCall(SqlInternalOperators.DRUID_IN, listRexNodes);
     DruidJsonFilter returnValue = DruidJsonFilter
         .toDruidFilters(inRexNode, f.varcharRowType, druidQuery);
     assertThat("Filter is null", returnValue, notNullValue());
@@ -95,7 +96,7 @@ class DruidQueryFilterTest {
             f.rexBuilder.makeLiteral("upper-bound"));
     RelDataType relDataType = f.typeFactory.createSqlType(SqlTypeName.BOOLEAN);
     RexNode betweenRexNode = f.rexBuilder.makeCall(relDataType,
-        SqlStdOperatorTable.BETWEEN, listRexNodes);
+        SqlInternalOperators.DRUID_BETWEEN, listRexNodes);
 
     DruidJsonFilter returnValue = DruidJsonFilter
         .toDruidFilters(betweenRexNode, f.varcharRowType, druidQuery);
diff --git a/geode/src/main/java/org/apache/calcite/adapter/geode/rel/GeodeFilter.java b/geode/src/main/java/org/apache/calcite/adapter/geode/rel/GeodeFilter.java
index f9fe8a1..c2f2914 100644
--- a/geode/src/main/java/org/apache/calcite/adapter/geode/rel/GeodeFilter.java
+++ b/geode/src/main/java/org/apache/calcite/adapter/geode/rel/GeodeFilter.java
@@ -30,6 +30,7 @@ import org.apache.calcite.rex.RexCall;
 import org.apache.calcite.rex.RexInputRef;
 import org.apache.calcite.rex.RexLiteral;
 import org.apache.calcite.rex.RexNode;
+import org.apache.calcite.rex.RexUtil;
 import org.apache.calcite.sql.SqlKind;
 import org.apache.calcite.util.DateString;
 import org.apache.calcite.util.TimeString;
@@ -131,8 +132,14 @@ public class GeodeFilter extends Filter implements GeodeRel {
      * @return OQL predicate string
      */
     private String translateMatch(RexNode condition) {
+      // Remove SEARCH calls because current translation logic cannot handle it.
+      // However, it would efficient to handle SEARCH explicitly; a Geode
+      // 'IN SET' would always manifest as a SEARCH.
+      final RexNode condition2 =
+          RexUtil.expandSearch(rexBuilder, null, condition);
+
       // Returns condition decomposed by OR
-      List<RexNode> disjunctions = RelOptUtil.disjunctions(condition);
+      List<RexNode> disjunctions = RelOptUtil.disjunctions(condition2);
       if (disjunctions.size() == 1) {
         return translateAnd(disjunctions.get(0));
       } else {
diff --git a/geode/src/main/java/org/apache/calcite/adapter/geode/rel/GeodeRules.java b/geode/src/main/java/org/apache/calcite/adapter/geode/rel/GeodeRules.java
index cd970e6..dab2857 100644
--- a/geode/src/main/java/org/apache/calcite/adapter/geode/rel/GeodeRules.java
+++ b/geode/src/main/java/org/apache/calcite/adapter/geode/rel/GeodeRules.java
@@ -289,7 +289,8 @@ public class GeodeRules {
         return true;
       }
 
-      if (!SqlKind.COMPARISON.contains(node.getKind())) {
+      if (!SqlKind.COMPARISON.contains(node.getKind())
+          && node.getKind() != SqlKind.SEARCH) {
         return false;
       }
 
diff --git a/geode/src/test/java/org/apache/calcite/adapter/geode/rel/GeodeBookstoreTest.java b/geode/src/test/java/org/apache/calcite/adapter/geode/rel/GeodeBookstoreTest.java
index d9bd664..f2e1d8f 100644
--- a/geode/src/test/java/org/apache/calcite/adapter/geode/rel/GeodeBookstoreTest.java
+++ b/geode/src/test/java/org/apache/calcite/adapter/geode/rel/GeodeBookstoreTest.java
@@ -94,7 +94,7 @@ class GeodeBookstoreTest extends AbstractGeodeTest {
             + "retailCost=34.99; yearPublished=2011; author=Daisy Mae West; title=A Treatise of "
             + "Treatises\n")
         .explainContains("PLAN=GeodeToEnumerableConverter\n"
-            + "  GeodeFilter(condition=[AND(>($0, 122), <=($0, 123))])\n"
+            + "  GeodeFilter(condition=[SEARCH($0, Sarg[(122\u2025123]])])\n"
             + "    GeodeTableScan(table=[[geode, BookMaster]])")
         .queryContains(
             GeodeAssertions.query("SELECT * FROM /BookMaster "
diff --git a/geode/src/test/java/org/apache/calcite/adapter/geode/rel/GeodeZipsTest.java b/geode/src/test/java/org/apache/calcite/adapter/geode/rel/GeodeZipsTest.java
index 22e011b..6627e52 100644
--- a/geode/src/test/java/org/apache/calcite/adapter/geode/rel/GeodeZipsTest.java
+++ b/geode/src/test/java/org/apache/calcite/adapter/geode/rel/GeodeZipsTest.java
@@ -38,9 +38,9 @@ import java.sql.DriverManager;
 import java.sql.SQLException;
 import java.util.Arrays;
 import java.util.Collections;
-import java.util.LinkedHashSet;
 import java.util.Locale;
 import java.util.Set;
+import java.util.TreeSet;
 import java.util.stream.Collectors;
 
 /**
@@ -235,7 +235,7 @@ class GeodeZipsTest extends AbstractGeodeTest {
       StructImpl struct = (StructImpl) s;
       return struct.get("state");
     })
-        .collect(Collectors.toCollection(LinkedHashSet::new));
+        .collect(Collectors.toCollection(TreeSet::new));
 
     String stateListPredicate = stateList.stream()
         .map(s -> String.format(Locale.ROOT, "state = '%s'", s))
diff --git a/mongodb/src/main/java/org/apache/calcite/adapter/mongodb/MongoFilter.java b/mongodb/src/main/java/org/apache/calcite/adapter/mongodb/MongoFilter.java
index cbf4fa7..61a35c0 100644
--- a/mongodb/src/main/java/org/apache/calcite/adapter/mongodb/MongoFilter.java
+++ b/mongodb/src/main/java/org/apache/calcite/adapter/mongodb/MongoFilter.java
@@ -24,10 +24,12 @@ import org.apache.calcite.plan.RelTraitSet;
 import org.apache.calcite.rel.RelNode;
 import org.apache.calcite.rel.core.Filter;
 import org.apache.calcite.rel.metadata.RelMetadataQuery;
+import org.apache.calcite.rex.RexBuilder;
 import org.apache.calcite.rex.RexCall;
 import org.apache.calcite.rex.RexInputRef;
 import org.apache.calcite.rex.RexLiteral;
 import org.apache.calcite.rex.RexNode;
+import org.apache.calcite.rex.RexUtil;
 import org.apache.calcite.util.JsonBuilder;
 import org.apache.calcite.util.Pair;
 
@@ -68,7 +70,8 @@ public class MongoFilter extends Filter implements MongoRel {
   public void implement(Implementor implementor) {
     implementor.visitChild(0, getInput());
     Translator translator =
-        new Translator(MongoRules.mongoFieldNames(getRowType()));
+        new Translator(implementor.rexBuilder,
+            MongoRules.mongoFieldNames(getRowType()));
     String match = translator.translateMatch(condition);
     implementor.add(null, match);
   }
@@ -80,9 +83,11 @@ public class MongoFilter extends Filter implements MongoRel {
         HashMultimap.create();
     final Map<String, RexLiteral> eqMap =
         new LinkedHashMap<>();
+    private final RexBuilder rexBuilder;
     private final List<String> fieldNames;
 
-    Translator(List<String> fieldNames) {
+    Translator(RexBuilder rexBuilder, List<String> fieldNames) {
+      this.rexBuilder = rexBuilder;
       this.fieldNames = fieldNames;
     }
 
@@ -93,8 +98,11 @@ public class MongoFilter extends Filter implements MongoRel {
     }
 
     private Object translateOr(RexNode condition) {
+      final RexNode condition2 =
+          RexUtil.expandSearch(rexBuilder, null, condition);
+
       List<Object> list = new ArrayList<>();
-      for (RexNode node : RelOptUtil.disjunctions(condition)) {
+      for (RexNode node : RelOptUtil.disjunctions(condition2)) {
         list.add(translateAnd(node));
       }
       switch (list.size()) {
diff --git a/mongodb/src/main/java/org/apache/calcite/adapter/mongodb/MongoRel.java b/mongodb/src/main/java/org/apache/calcite/adapter/mongodb/MongoRel.java
index d47206c..2f40afa 100644
--- a/mongodb/src/main/java/org/apache/calcite/adapter/mongodb/MongoRel.java
+++ b/mongodb/src/main/java/org/apache/calcite/adapter/mongodb/MongoRel.java
@@ -19,6 +19,7 @@ package org.apache.calcite.adapter.mongodb;
 import org.apache.calcite.plan.Convention;
 import org.apache.calcite.plan.RelOptTable;
 import org.apache.calcite.rel.RelNode;
+import org.apache.calcite.rex.RexBuilder;
 import org.apache.calcite.util.Pair;
 
 import java.util.ArrayList;
@@ -37,10 +38,14 @@ public interface MongoRel extends RelNode {
    * {@link MongoRel} nodes into a MongoDB query. */
   class Implementor {
     final List<Pair<String, String>> list = new ArrayList<>();
-
+    final RexBuilder rexBuilder;
     RelOptTable table;
     MongoTable mongoTable;
 
+    public Implementor(RexBuilder rexBuilder) {
+      this.rexBuilder = rexBuilder;
+    }
+
     public void add(String findOp, String aggOp) {
       list.add(Pair.of(findOp, aggOp));
     }
diff --git a/mongodb/src/main/java/org/apache/calcite/adapter/mongodb/MongoToEnumerableConverter.java b/mongodb/src/main/java/org/apache/calcite/adapter/mongodb/MongoToEnumerableConverter.java
index 6297df0..6cb0c62 100644
--- a/mongodb/src/main/java/org/apache/calcite/adapter/mongodb/MongoToEnumerableConverter.java
+++ b/mongodb/src/main/java/org/apache/calcite/adapter/mongodb/MongoToEnumerableConverter.java
@@ -79,7 +79,8 @@ public class MongoToEnumerableConverter
     //     "{$filter: {state: 'CA'}}",
     //     "{$group: {_id: '$city', c: {$sum: 1}, p: {$sum: "$pop"}}")
     final BlockBuilder list = new BlockBuilder();
-    final MongoRel.Implementor mongoImplementor = new MongoRel.Implementor();
+    final MongoRel.Implementor mongoImplementor =
+        new MongoRel.Implementor(getCluster().getRexBuilder());
     mongoImplementor.visitChild(0, getInput());
     int aggCount = 0;
     int findCount = 0;
diff --git a/spark/src/test/java/org/apache/calcite/test/SparkAdapterTest.java b/spark/src/test/java/org/apache/calcite/test/SparkAdapterTest.java
index ad4ba2f..a642944 100644
--- a/spark/src/test/java/org/apache/calcite/test/SparkAdapterTest.java
+++ b/spark/src/test/java/org/apache/calcite/test/SparkAdapterTest.java
@@ -474,7 +474,7 @@ class SparkAdapterTest {
         + "where x between 3 and 4";
 
     final String plan = "PLAN="
-        + "EnumerableCalc(expr#0..1=[{inputs}], expr#2=[3], expr#3=[>=($t0, $t2)], expr#4=[4], expr#5=[<=($t0, $t4)], expr#6=[AND($t3, $t5)], proj#0..1=[{exprs}], $condition=[$t6])\n"
+        + "EnumerableCalc(expr#0..1=[{inputs}], expr#2=[Sarg[[3\u20254]]], expr#3=[SEARCH($t0, $t2)], proj#0..1=[{exprs}], $condition=[$t3])\n"
         + "  EnumerableValues(tuples=[[{ 1, 'a' }, { 2, 'b' }, { 3, 'b' }, { 4, 'c' }, { 2, 'c' }]])\n\n";
 
     final String expectedResult = "X=3; Y=b\n"
@@ -490,7 +490,7 @@ class SparkAdapterTest {
         + "where x in (3, 4)";
 
     final String plan = "PLAN="
-        + "EnumerableCalc(expr#0..1=[{inputs}], expr#2=[3], expr#3=[=($t0, $t2)], expr#4=[4], expr#5=[=($t0, $t4)], expr#6=[OR($t3, $t5)], proj#0..1=[{exprs}], $condition=[$t6])\n"
+        + "EnumerableCalc(expr#0..1=[{inputs}], expr#2=[Sarg[3, 4]], expr#3=[SEARCH($t0, $t2)], proj#0..1=[{exprs}], $condition=[$t3])\n"
         + "  EnumerableValues(tuples=[[{ 1, 'a' }, { 2, 'b' }, { 3, 'b' }, { 4, 'c' }, { 2, 'c' }]])\n\n";
 
     final String expectedResult = "X=3; Y=b\n"
@@ -538,7 +538,7 @@ class SparkAdapterTest {
         + "where x = 1 or x = 2";
 
     final String plan = "PLAN="
-        + "EnumerableCalc(expr#0..1=[{inputs}], expr#2=[1], expr#3=[=($t0, $t2)], expr#4=[2], expr#5=[=($t0, $t4)], expr#6=[OR($t3, $t5)], proj#0..1=[{exprs}], $condition=[$t6])\n"
+        + "EnumerableCalc(expr#0..1=[{inputs}], expr#2=[Sarg[1, 2]], expr#3=[SEARCH($t0, $t2)], proj#0..1=[{exprs}], $condition=[$t3])\n"
         + "  EnumerableValues(tuples=[[{ 1, 'a' }, { 2, 'b' }, { 1, 'b' }, { 2, 'c' }, { 2, 'c' }]])\n\n";
 
     final String expectedResult = "X=1; Y=a\n"