Support SearchQueryDimFilter in sql via new methods (#10350)

* Support SearchQueryDimFilter in sql via new methods

* Contains is a reserved word

* revert unnecessary change

* Fix toDruidExpression method

* rename methods

* java docs

* Add native functions

* revert change in dockerfile

* remove changes from dockerfile

* More tests

* travis fix

* Handle null values better
diff --git a/docs/misc/math-expr.md b/docs/misc/math-expr.md
index dc35647..3867fcb 100644
--- a/docs/misc/math-expr.md
+++ b/docs/misc/math-expr.md
@@ -78,6 +78,8 @@
 |parse_long|parse_long(string[, radix]) parses a string as a long with the given radix, or 10 (decimal) if a radix is not provided.|
 |regexp_extract|regexp_extract(expr, pattern[, index]) applies a regular expression pattern and extracts a capture group index, or null if there is no match. If index is unspecified or zero, returns the substring that matched the pattern. The pattern may match anywhere inside `expr`; if you want to match the entire string instead, use the `^` and `$` markers at the start and end of your pattern.|
 |regexp_like|regexp_like(expr, pattern) returns whether `expr` matches regular expression `pattern`. The pattern may match anywhere inside `expr`; if you want to match the entire string instead, use the `^` and `$` markers at the start and end of your pattern. |
+|contains_string|contains_string(expr, string) returns whether `expr` contains `string` as a substring. This method is case-sensitive.|
+|icontains_string|contains_string(expr, string) returns whether `expr` contains `string` as a substring. This method is case-insensitive.|
 |replace|replace(expr, pattern, replacement) replaces pattern with replacement|
 |substring|substring(expr, index, length) behaves like java.lang.String's substring|
 |right|right(expr, length) returns the rightmost length characters from a string|
diff --git a/docs/querying/sql.md b/docs/querying/sql.md
index 1595c27..94cfcca 100644
--- a/docs/querying/sql.md
+++ b/docs/querying/sql.md
@@ -397,6 +397,8 @@
 |`POSITION(needle IN haystack [FROM fromIndex])`|Returns the index of needle within haystack, with indexes starting from 1. The search will begin at fromIndex, or 1 if fromIndex is not specified. If the needle is not found, returns 0.|
 |`REGEXP_EXTRACT(expr, pattern, [index])`|Apply regular expression `pattern` to `expr` and extract a capture group, or `NULL` if there is no match. If index is unspecified or zero, returns the first substring that matched the pattern. The pattern may match anywhere inside `expr`; if you want to match the entire string instead, use the `^` and `$` markers at the start and end of your pattern. Note: when `druid.generic.useDefaultValueForNull = true`, it is not possible to differentiate an empty-string match from a non-match (both will return `NULL`).|
 |`REGEXP_LIKE(expr, pattern)`|Returns whether `expr` matches regular expression `pattern`. The pattern may match anywhere inside `expr`; if you want to match the entire string instead, use the `^` and `$` markers at the start and end of your pattern. Similar to [`LIKE`](#comparison-operators), but uses regexps instead of LIKE patterns. Especially useful in WHERE clauses.|
+|`CONTAINS_STRING(<expr>, str)`|Returns true if the `str` is a substring of `expr`.|
+|`ICONTAINS_STRING(<expr>, str)`|Returns true if the `str` is a substring of `expr`. The match is case-insensitive.|
 |`REPLACE(expr, pattern, replacement)`|Replaces pattern with replacement in expr, and returns the result.|
 |`STRPOS(haystack, needle)`|Returns the index of needle within haystack, with indexes starting from 1. If the needle is not found, returns 0.|
 |`SUBSTRING(expr, index, [length])`|Returns a substring of expr starting at index, with a max length, both measured in UTF-16 code units.|
diff --git a/processing/src/main/java/org/apache/druid/query/expression/CaseInsensitiveContainsExprMacro.java b/processing/src/main/java/org/apache/druid/query/expression/CaseInsensitiveContainsExprMacro.java
new file mode 100644
index 0000000..5f69c38
--- /dev/null
+++ b/processing/src/main/java/org/apache/druid/query/expression/CaseInsensitiveContainsExprMacro.java
@@ -0,0 +1,63 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.druid.query.expression;
+
+import org.apache.druid.java.util.common.IAE;
+import org.apache.druid.math.expr.Expr;
+import org.apache.druid.math.expr.ExprMacroTable;
+
+import java.util.List;
+
+/**
+ * This class implements a function that checks if one string contains another string. It is required that second
+ * string be a literal. This expression is case-insensitive.
+ * signature:
+ * long contains_string(string, string)
+ * <p>
+ * Examples:
+ * - {@code contains_string("foobar", "bar") - 1 }
+ * - {@code contains_string("foobar", "car") - 0 }
+ * - {@code contains_string("foobar", "Bar") - 1 }
+ * <p>
+ * See {@link ContainsExprMacro} for the case-sensitive version.
+ */
+
+public class CaseInsensitiveContainsExprMacro implements ExprMacroTable.ExprMacro
+{
+  public static final String FN_NAME = "icontains_string";
+
+  @Override
+  public String name()
+  {
+    return FN_NAME;
+  }
+
+  @Override
+  public Expr apply(final List<Expr> args)
+  {
+    if (args.size() != 2) {
+      throw new IAE("Function[%s] must have 2 arguments", name());
+    }
+
+    final Expr arg = args.get(0);
+    final Expr searchStr = args.get(1);
+    return new ContainsExpr(FN_NAME, arg, searchStr, false);
+  }
+}
diff --git a/processing/src/main/java/org/apache/druid/query/expression/ContainsExpr.java b/processing/src/main/java/org/apache/druid/query/expression/ContainsExpr.java
new file mode 100644
index 0000000..f9550f3
--- /dev/null
+++ b/processing/src/main/java/org/apache/druid/query/expression/ContainsExpr.java
@@ -0,0 +1,101 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.druid.query.expression;
+
+import org.apache.druid.common.config.NullHandling;
+import org.apache.druid.java.util.common.IAE;
+import org.apache.druid.java.util.common.StringUtils;
+import org.apache.druid.math.expr.Expr;
+import org.apache.druid.math.expr.ExprEval;
+import org.apache.druid.math.expr.ExprMacroTable;
+import org.apache.druid.math.expr.ExprType;
+
+import javax.annotation.Nonnull;
+import java.util.function.Function;
+
+/**
+ * {@link Expr} class returned by {@link ContainsExprMacro} and {@link CaseInsensitiveContainsExprMacro} for
+ * evaluating the expression.
+ */
+class ContainsExpr extends ExprMacroTable.BaseScalarUnivariateMacroFunctionExpr
+{
+  private final Function<String, Boolean> searchFunction;
+  private final Expr searchStrExpr;
+
+  ContainsExpr(String functioName, Expr arg, Expr searchStrExpr, boolean caseSensitive)
+  {
+    super(functioName, arg);
+    this.searchStrExpr = validateSearchExpr(searchStrExpr, functioName);
+    // Creates the function eagerly to avoid branching in eval.
+    this.searchFunction = createFunction(searchStrExpr, caseSensitive);
+  }
+
+  private ContainsExpr(String functioName, Expr arg, Expr searchStrExpr, Function<String, Boolean> searchFunction)
+  {
+    super(functioName, arg);
+    this.searchFunction = searchFunction;
+    this.searchStrExpr = validateSearchExpr(searchStrExpr, functioName);
+  }
+
+  @Nonnull
+  @Override
+  public ExprEval eval(final Expr.ObjectBinding bindings)
+  {
+    final String s = NullHandling.nullToEmptyIfNeeded(arg.eval(bindings).asString());
+
+    if (s == null) {
+      // same behavior as regexp_like.
+      return ExprEval.of(false, ExprType.LONG);
+    } else {
+      final boolean doesContain = searchFunction.apply(s);
+      return ExprEval.of(doesContain, ExprType.LONG);
+    }
+  }
+
+  @Override
+  public Expr visit(Expr.Shuttle shuttle)
+  {
+    Expr newArg = arg.visit(shuttle);
+    return shuttle.visit(new ContainsExpr(name, newArg, searchStrExpr, searchFunction));
+  }
+
+  @Override
+  public String stringify()
+  {
+    return StringUtils.format("%s(%s, %s)", name, arg.stringify(), searchStrExpr.stringify());
+  }
+
+  private Function<String, Boolean> createFunction(Expr searchStrExpr, boolean caseSensitive)
+  {
+    String searchStr = StringUtils.nullToEmptyNonDruidDataString((String) searchStrExpr.getLiteralValue());
+    if (caseSensitive) {
+      return s -> s.contains(searchStr);
+    }
+    return s -> org.apache.commons.lang.StringUtils.containsIgnoreCase(s, searchStr);
+  }
+
+  private Expr validateSearchExpr(Expr searchExpr, String functioName)
+  {
+    if (!ExprUtils.isStringLiteral(searchExpr)) {
+      throw new IAE("Function[%s] substring must be a string literal", functioName);
+    }
+    return searchExpr;
+  }
+}
diff --git a/processing/src/main/java/org/apache/druid/query/expression/ContainsExprMacro.java b/processing/src/main/java/org/apache/druid/query/expression/ContainsExprMacro.java
new file mode 100644
index 0000000..a1744f6
--- /dev/null
+++ b/processing/src/main/java/org/apache/druid/query/expression/ContainsExprMacro.java
@@ -0,0 +1,62 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.druid.query.expression;
+
+import org.apache.druid.java.util.common.IAE;
+import org.apache.druid.math.expr.Expr;
+import org.apache.druid.math.expr.ExprMacroTable;
+
+import java.util.List;
+
+/**
+ * This class implements a function that checks if one string contains another string. It is required that second
+ * string be a literal. This expression is case-sensitive.
+ * signature:
+ * long contains_string(string, string)
+ * <p>
+ * Examples:
+ * - {@code contains_string("foobar", "bar") - 1 }
+ * - {@code contains_string("foobar", "car") - 0 }
+ * - {@code contains_string("foobar", "Bar") - 0 }
+ * <p>
+ * See {@link CaseInsensitiveContainsExprMacro} for the case-insensitive version.
+ */
+public class ContainsExprMacro implements ExprMacroTable.ExprMacro
+{
+  public static final String FN_NAME = "contains_string";
+
+  @Override
+  public String name()
+  {
+    return FN_NAME;
+  }
+
+  @Override
+  public Expr apply(final List<Expr> args)
+  {
+    if (args.size() != 2) {
+      throw new IAE("Function[%s] must have 2 arguments", name());
+    }
+
+    final Expr arg = args.get(0);
+    final Expr searchStr = args.get(1);
+    return new ContainsExpr(FN_NAME, arg, searchStr, true);
+  }
+}
diff --git a/processing/src/test/java/org/apache/druid/query/expression/CaseInsensitiveExprMacroTest.java b/processing/src/test/java/org/apache/druid/query/expression/CaseInsensitiveExprMacroTest.java
new file mode 100644
index 0000000..1722ad3
--- /dev/null
+++ b/processing/src/test/java/org/apache/druid/query/expression/CaseInsensitiveExprMacroTest.java
@@ -0,0 +1,156 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.druid.query.expression;
+
+import com.google.common.collect.ImmutableMap;
+import org.apache.druid.common.config.NullHandling;
+import org.apache.druid.math.expr.ExprEval;
+import org.apache.druid.math.expr.ExprType;
+import org.apache.druid.math.expr.Parser;
+import org.junit.Assert;
+import org.junit.Test;
+
+public class CaseInsensitiveExprMacroTest extends MacroTestBase
+{
+  public CaseInsensitiveExprMacroTest()
+  {
+    super(new CaseInsensitiveContainsExprMacro());
+  }
+
+  @Test
+  public void testErrorZeroArguments()
+  {
+    expectException(IllegalArgumentException.class, "Function[icontains_string] must have 2 arguments");
+    eval("icontains_string()", Parser.withMap(ImmutableMap.of()));
+  }
+
+  @Test
+  public void testErrorThreeArguments()
+  {
+    expectException(IllegalArgumentException.class, "Function[icontains_string] must have 2 arguments");
+    eval("icontains_string('a', 'b', 'c')", Parser.withMap(ImmutableMap.of()));
+  }
+
+  @Test
+  public void testMatchSearchLowerCase()
+  {
+    final ExprEval<?> result = eval("icontains_string(a, 'OBA')", Parser.withMap(ImmutableMap.of("a", "foobar")));
+    Assert.assertEquals(
+        ExprEval.of(true, ExprType.LONG).value(),
+        result.value()
+    );
+  }
+
+  @Test
+  public void testMatchSearchUpperCase()
+  {
+    final ExprEval<?> result = eval("icontains_string(a, 'oba')", Parser.withMap(ImmutableMap.of("a", "FOOBAR")));
+    Assert.assertEquals(
+        ExprEval.of(true, ExprType.LONG).value(),
+        result.value()
+    );
+  }
+
+  @Test
+  public void testNoMatch()
+  {
+    final ExprEval<?> result = eval("icontains_string(a, 'bar')", Parser.withMap(ImmutableMap.of("a", "foo")));
+    Assert.assertEquals(
+        ExprEval.of(false, ExprType.LONG).value(),
+        result.value()
+    );
+  }
+
+  @Test
+  public void testNullSearch()
+  {
+    if (NullHandling.sqlCompatible()) {
+      expectException(IllegalArgumentException.class, "Function[icontains_string] substring must be a string literal");
+    }
+
+    final ExprEval<?> result = eval("icontains_string(a, null)", Parser.withMap(ImmutableMap.of("a", "foo")));
+    Assert.assertEquals(
+        ExprEval.of(true, ExprType.LONG).value(),
+        result.value()
+    );
+  }
+
+  @Test
+  public void testEmptyStringSearch()
+  {
+    final ExprEval<?> result = eval("icontains_string(a, '')", Parser.withMap(ImmutableMap.of("a", "foo")));
+    Assert.assertEquals(
+        ExprEval.of(true, ExprType.LONG).value(),
+        result.value()
+    );
+  }
+
+  @Test
+  public void testNullSearchOnEmptyString()
+  {
+    if (NullHandling.sqlCompatible()) {
+      expectException(IllegalArgumentException.class, "Function[icontains_string] substring must be a string literal");
+    }
+
+    final ExprEval<?> result = eval("icontains_string(a, null)", Parser.withMap(ImmutableMap.of("a", "")));
+    Assert.assertEquals(
+        ExprEval.of(true, ExprType.LONG).value(),
+        result.value()
+    );
+  }
+
+  @Test
+  public void testEmptyStringSearchOnEmptyString()
+  {
+    final ExprEval<?> result = eval("icontains_string(a, '')", Parser.withMap(ImmutableMap.of("a", "")));
+    Assert.assertEquals(
+        ExprEval.of(true, ExprType.LONG).value(),
+        result.value()
+    );
+  }
+
+  @Test
+  public void testNullSearchOnNull()
+  {
+    if (NullHandling.sqlCompatible()) {
+      expectException(IllegalArgumentException.class, "Function[icontains_string] substring must be a string literal");
+    }
+
+    final ExprEval<?> result = eval(
+        "icontains_string(a, null)",
+        Parser.withSuppliers(ImmutableMap.of("a", () -> null))
+    );
+    Assert.assertEquals(
+        ExprEval.of(true, ExprType.LONG).value(),
+        result.value()
+    );
+  }
+
+  @Test
+  public void testEmptyStringSearchOnNull()
+  {
+    final ExprEval<?> result = eval("icontains_string(a, '')", Parser.withSuppliers(ImmutableMap.of("a", () -> null)));
+    Assert.assertEquals(
+        ExprEval.of(!NullHandling.sqlCompatible(), ExprType.LONG).value(),
+        result.value()
+    );
+  }
+
+}
diff --git a/processing/src/test/java/org/apache/druid/query/expression/ContainsExprMacroTest.java b/processing/src/test/java/org/apache/druid/query/expression/ContainsExprMacroTest.java
new file mode 100644
index 0000000..bfbff7d
--- /dev/null
+++ b/processing/src/test/java/org/apache/druid/query/expression/ContainsExprMacroTest.java
@@ -0,0 +1,142 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.druid.query.expression;
+
+import com.google.common.collect.ImmutableMap;
+import org.apache.druid.common.config.NullHandling;
+import org.apache.druid.math.expr.ExprEval;
+import org.apache.druid.math.expr.ExprType;
+import org.apache.druid.math.expr.Parser;
+import org.junit.Assert;
+import org.junit.Test;
+
+public class ContainsExprMacroTest extends MacroTestBase
+{
+  public ContainsExprMacroTest()
+  {
+    super(new ContainsExprMacro());
+  }
+
+  @Test
+  public void testErrorZeroArguments()
+  {
+    expectException(IllegalArgumentException.class, "Function[contains_string] must have 2 arguments");
+    eval("contains_string()", Parser.withMap(ImmutableMap.of()));
+  }
+
+  @Test
+  public void testErrorThreeArguments()
+  {
+    expectException(IllegalArgumentException.class, "Function[contains_string] must have 2 arguments");
+    eval("contains_string('a', 'b', 'c')", Parser.withMap(ImmutableMap.of()));
+  }
+
+  @Test
+  public void testMatch()
+  {
+    final ExprEval<?> result = eval("contains_string(a, 'oba')", Parser.withMap(ImmutableMap.of("a", "foobar")));
+    Assert.assertEquals(
+        ExprEval.of(true, ExprType.LONG).value(),
+        result.value()
+    );
+  }
+
+  @Test
+  public void testNoMatch()
+  {
+    final ExprEval<?> result = eval("contains_string(a, 'bar')", Parser.withMap(ImmutableMap.of("a", "foo")));
+    Assert.assertEquals(
+        ExprEval.of(false, ExprType.LONG).value(),
+        result.value()
+    );
+  }
+
+  @Test
+  public void testNullSearch()
+  {
+    if (NullHandling.sqlCompatible()) {
+      expectException(IllegalArgumentException.class, "Function[contains_string] substring must be a string literal");
+    }
+
+    final ExprEval<?> result = eval("contains_string(a, null)", Parser.withMap(ImmutableMap.of("a", "foo")));
+    Assert.assertEquals(
+        ExprEval.of(true, ExprType.LONG).value(),
+        result.value()
+    );
+  }
+
+  @Test
+  public void testEmptyStringSearch()
+  {
+    final ExprEval<?> result = eval("contains_string(a, '')", Parser.withMap(ImmutableMap.of("a", "foo")));
+    Assert.assertEquals(
+        ExprEval.of(true, ExprType.LONG).value(),
+        result.value()
+    );
+  }
+
+  @Test
+  public void testNullSearchOnEmptyString()
+  {
+    if (NullHandling.sqlCompatible()) {
+      expectException(IllegalArgumentException.class, "Function[contains_string] substring must be a string literal");
+    }
+
+    final ExprEval<?> result = eval("contains_string(a, null)", Parser.withMap(ImmutableMap.of("a", "")));
+    Assert.assertEquals(
+        ExprEval.of(true, ExprType.LONG).value(),
+        result.value()
+    );
+  }
+
+  @Test
+  public void testEmptyStringSearchOnEmptyString()
+  {
+    final ExprEval<?> result = eval("contains_string(a, '')", Parser.withMap(ImmutableMap.of("a", "")));
+    Assert.assertEquals(
+        ExprEval.of(true, ExprType.LONG).value(),
+        result.value()
+    );
+  }
+
+  @Test
+  public void testNullSearchOnNull()
+  {
+    if (NullHandling.sqlCompatible()) {
+      expectException(IllegalArgumentException.class, "Function[contains_string] substring must be a string literal");
+    }
+
+    final ExprEval<?> result = eval("contains_string(a, null)", Parser.withSuppliers(ImmutableMap.of("a", () -> null)));
+    Assert.assertEquals(
+        ExprEval.of(true, ExprType.LONG).value(),
+        result.value()
+    );
+  }
+
+  @Test
+  public void testEmptyStringSearchOnNull()
+  {
+    final ExprEval<?> result = eval("contains_string(a, '')", Parser.withSuppliers(ImmutableMap.of("a", () -> null)));
+    Assert.assertEquals(
+        ExprEval.of(!NullHandling.sqlCompatible(), ExprType.LONG).value(),
+        result.value()
+    );
+  }
+}
diff --git a/server/src/main/java/org/apache/druid/guice/ExpressionModule.java b/server/src/main/java/org/apache/druid/guice/ExpressionModule.java
index 9e451e8..7a25f92 100644
--- a/server/src/main/java/org/apache/druid/guice/ExpressionModule.java
+++ b/server/src/main/java/org/apache/druid/guice/ExpressionModule.java
@@ -25,6 +25,8 @@
 import com.google.inject.multibindings.Multibinder;
 import org.apache.druid.initialization.DruidModule;
 import org.apache.druid.math.expr.ExprMacroTable;
+import org.apache.druid.query.expression.CaseInsensitiveContainsExprMacro;
+import org.apache.druid.query.expression.ContainsExprMacro;
 import org.apache.druid.query.expression.GuiceExprMacroTable;
 import org.apache.druid.query.expression.IPv4AddressMatchExprMacro;
 import org.apache.druid.query.expression.IPv4AddressParseExprMacro;
@@ -52,6 +54,8 @@
           .add(LikeExprMacro.class)
           .add(RegexpExtractExprMacro.class)
           .add(RegexpLikeExprMacro.class)
+          .add(ContainsExprMacro.class)
+          .add(CaseInsensitiveContainsExprMacro.class)
           .add(TimestampCeilExprMacro.class)
           .add(TimestampExtractExprMacro.class)
           .add(TimestampFloorExprMacro.class)
diff --git a/sql/src/main/java/org/apache/druid/sql/calcite/expression/builtin/ContainsOperatorConversion.java b/sql/src/main/java/org/apache/druid/sql/calcite/expression/builtin/ContainsOperatorConversion.java
new file mode 100644
index 0000000..aca2260
--- /dev/null
+++ b/sql/src/main/java/org/apache/druid/sql/calcite/expression/builtin/ContainsOperatorConversion.java
@@ -0,0 +1,161 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.druid.sql.calcite.expression.builtin;
+
+import org.apache.calcite.rex.RexCall;
+import org.apache.calcite.rex.RexLiteral;
+import org.apache.calcite.rex.RexNode;
+import org.apache.calcite.sql.SqlFunction;
+import org.apache.calcite.sql.SqlFunctionCategory;
+import org.apache.calcite.sql.SqlOperator;
+import org.apache.calcite.sql.type.SqlTypeFamily;
+import org.apache.calcite.sql.type.SqlTypeName;
+import org.apache.druid.java.util.common.StringUtils;
+import org.apache.druid.query.expression.CaseInsensitiveContainsExprMacro;
+import org.apache.druid.query.expression.ContainsExprMacro;
+import org.apache.druid.query.filter.DimFilter;
+import org.apache.druid.query.filter.SearchQueryDimFilter;
+import org.apache.druid.query.search.ContainsSearchQuerySpec;
+import org.apache.druid.query.search.SearchQuerySpec;
+import org.apache.druid.segment.VirtualColumn;
+import org.apache.druid.segment.column.RowSignature;
+import org.apache.druid.sql.calcite.expression.DruidExpression;
+import org.apache.druid.sql.calcite.expression.Expressions;
+import org.apache.druid.sql.calcite.expression.OperatorConversions;
+import org.apache.druid.sql.calcite.expression.SqlOperatorConversion;
+import org.apache.druid.sql.calcite.planner.PlannerContext;
+import org.apache.druid.sql.calcite.rel.VirtualColumnRegistry;
+
+import javax.annotation.Nullable;
+import java.util.List;
+
+/**
+ * Register {@code contains_string} and {@code icontains_string} functions with calcite that internally
+ * translate these functions into {@link SearchQueryDimFilter} with {@link ContainsSearchQuerySpec} as
+ * search query spec.
+ */
+public class ContainsOperatorConversion implements SqlOperatorConversion
+{
+  private final SqlOperator operator;
+  private final boolean caseSensitive;
+
+  private ContainsOperatorConversion(
+      final SqlFunction sqlFunction,
+      final boolean caseSensitive
+  )
+  {
+    this.operator = sqlFunction;
+    this.caseSensitive = caseSensitive;
+  }
+
+  public static SqlOperatorConversion caseSensitive()
+  {
+    final SqlFunction sqlFunction = createSqlFunction(ContainsExprMacro.FN_NAME);
+    return new ContainsOperatorConversion(sqlFunction, true);
+  }
+
+  public static SqlOperatorConversion caseInsensitive()
+  {
+    final SqlFunction sqlFunction = createSqlFunction(CaseInsensitiveContainsExprMacro.FN_NAME);
+    return new ContainsOperatorConversion(sqlFunction, false);
+  }
+
+  private static SqlFunction createSqlFunction(final String functionName)
+  {
+    return OperatorConversions
+        .operatorBuilder(StringUtils.toUpperCase(functionName))
+        .operandTypes(SqlTypeFamily.CHARACTER, SqlTypeFamily.CHARACTER)
+        .requiredOperands(2)
+        .literalOperands(1)
+        .returnTypeNonNull(SqlTypeName.BOOLEAN)
+        .functionCategory(SqlFunctionCategory.STRING)
+        .build();
+  }
+
+  @Override
+  public SqlOperator calciteOperator()
+  {
+    return operator;
+  }
+
+  @Nullable
+  @Override
+  public DruidExpression toDruidExpression(
+      PlannerContext plannerContext,
+      RowSignature rowSignature,
+      RexNode rexNode
+  )
+  {
+    return OperatorConversions.convertCall(
+        plannerContext,
+        rowSignature,
+        rexNode,
+        operands -> DruidExpression.fromExpression(DruidExpression.functionCall(
+            StringUtils.toLowerCase(operator.getName()),
+            operands
+        ))
+    );
+  }
+
+  @Nullable
+  @Override
+  public DimFilter toDruidFilter(
+      PlannerContext plannerContext,
+      RowSignature rowSignature,
+      @Nullable VirtualColumnRegistry virtualColumnRegistry,
+      RexNode rexNode
+  )
+  {
+    final List<RexNode> operands = ((RexCall) rexNode).getOperands();
+    final DruidExpression druidExpression = Expressions.toDruidExpression(
+        plannerContext,
+        rowSignature,
+        operands.get(0)
+    );
+
+    if (druidExpression == null) {
+      return null;
+    }
+
+    final String search = RexLiteral.stringValue(operands.get(1));
+    final SearchQuerySpec spec = new ContainsSearchQuerySpec(search, caseSensitive);
+
+    if (druidExpression.isSimpleExtraction()) {
+      return new SearchQueryDimFilter(
+          druidExpression.getSimpleExtraction().getColumn(),
+          spec,
+          druidExpression.getSimpleExtraction().getExtractionFn(),
+          null
+      );
+    } else if (virtualColumnRegistry != null) {
+      VirtualColumn v = virtualColumnRegistry.getOrCreateVirtualColumnForExpression(
+          plannerContext,
+          druidExpression,
+          operands.get(0).getType()
+      );
+
+      return new SearchQueryDimFilter(
+          v.getOutputName(), spec, null, null);
+    } else {
+      return null;
+    }
+  }
+
+}
diff --git a/sql/src/main/java/org/apache/druid/sql/calcite/planner/DruidOperatorTable.java b/sql/src/main/java/org/apache/druid/sql/calcite/planner/DruidOperatorTable.java
index 3f7699f..e1fb0b4 100644
--- a/sql/src/main/java/org/apache/druid/sql/calcite/planner/DruidOperatorTable.java
+++ b/sql/src/main/java/org/apache/druid/sql/calcite/planner/DruidOperatorTable.java
@@ -61,6 +61,7 @@
 import org.apache.druid.sql.calcite.expression.builtin.CastOperatorConversion;
 import org.apache.druid.sql.calcite.expression.builtin.CeilOperatorConversion;
 import org.apache.druid.sql.calcite.expression.builtin.ConcatOperatorConversion;
+import org.apache.druid.sql.calcite.expression.builtin.ContainsOperatorConversion;
 import org.apache.druid.sql.calcite.expression.builtin.DateTruncOperatorConversion;
 import org.apache.druid.sql.calcite.expression.builtin.ExtractOperatorConversion;
 import org.apache.druid.sql.calcite.expression.builtin.FloorOperatorConversion;
@@ -181,6 +182,8 @@
           .add(new AliasedOperatorConversion(new TruncateOperatorConversion(), "TRUNC"))
           .add(new LPadOperatorConversion())
           .add(new RPadOperatorConversion())
+          .add(ContainsOperatorConversion.caseSensitive())
+          .add(ContainsOperatorConversion.caseInsensitive())
           .build();
 
   private static final List<SqlOperatorConversion> VALUE_COERCION_OPERATOR_CONVERSIONS =
diff --git a/sql/src/test/java/org/apache/druid/sql/calcite/expression/ExpressionTestHelper.java b/sql/src/test/java/org/apache/druid/sql/calcite/expression/ExpressionTestHelper.java
index 1d84217..6e05ce8 100644
--- a/sql/src/test/java/org/apache/druid/sql/calcite/expression/ExpressionTestHelper.java
+++ b/sql/src/test/java/org/apache/druid/sql/calcite/expression/ExpressionTestHelper.java
@@ -38,7 +38,9 @@
 import org.apache.druid.segment.RowAdapters;
 import org.apache.druid.segment.RowBasedColumnSelectorFactory;
 import org.apache.druid.segment.VirtualColumn;
+import org.apache.druid.segment.VirtualColumns;
 import org.apache.druid.segment.column.RowSignature;
+import org.apache.druid.segment.virtual.VirtualizedColumnSelectorFactory;
 import org.apache.druid.sql.calcite.planner.Calcites;
 import org.apache.druid.sql.calcite.planner.PlannerConfig;
 import org.apache.druid.sql.calcite.planner.PlannerContext;
@@ -283,11 +285,14 @@
     );
 
     final ValueMatcher matcher = expectedFilter.toFilter().makeMatcher(
-        RowBasedColumnSelectorFactory.create(
-            RowAdapters.standardRow(),
-            () -> new MapBasedRow(0L, bindings),
-            rowSignature,
-            false
+        new VirtualizedColumnSelectorFactory(
+            RowBasedColumnSelectorFactory.create(
+                RowAdapters.standardRow(),
+                () -> new MapBasedRow(0L, bindings),
+                rowSignature,
+                false
+            ),
+            VirtualColumns.create(virtualColumns)
         )
     );
 
diff --git a/sql/src/test/java/org/apache/druid/sql/calcite/expression/ExpressionsTest.java b/sql/src/test/java/org/apache/druid/sql/calcite/expression/ExpressionsTest.java
index 9975cff..bf4bd4a 100644
--- a/sql/src/test/java/org/apache/druid/sql/calcite/expression/ExpressionsTest.java
+++ b/sql/src/test/java/org/apache/druid/sql/calcite/expression/ExpressionsTest.java
@@ -35,9 +35,12 @@
 import org.apache.druid.query.expression.TestExprMacroTable;
 import org.apache.druid.query.extraction.RegexDimExtractionFn;
 import org.apache.druid.query.filter.RegexDimFilter;
+import org.apache.druid.query.filter.SearchQueryDimFilter;
+import org.apache.druid.query.search.ContainsSearchQuerySpec;
 import org.apache.druid.segment.column.RowSignature;
 import org.apache.druid.segment.column.ValueType;
 import org.apache.druid.segment.virtual.ExpressionVirtualColumn;
+import org.apache.druid.sql.calcite.expression.builtin.ContainsOperatorConversion;
 import org.apache.druid.sql.calcite.expression.builtin.DateTruncOperatorConversion;
 import org.apache.druid.sql.calcite.expression.builtin.LPadOperatorConversion;
 import org.apache.druid.sql.calcite.expression.builtin.LeftOperatorConversion;
@@ -1072,6 +1075,231 @@
     );
   }
 
+  @Test
+  public void testContains()
+  {
+    testHelper.testExpression(
+        ContainsOperatorConversion.caseSensitive().calciteOperator(),
+        ImmutableList.of(
+            testHelper.makeInputRef("spacey"),
+            testHelper.makeLiteral("there")
+        ),
+        DruidExpression.fromExpression("contains_string(\"spacey\",'there')"),
+        1L
+    );
+
+    testHelper.testExpression(
+        ContainsOperatorConversion.caseSensitive().calciteOperator(),
+        ImmutableList.of(
+            testHelper.makeInputRef("spacey"),
+            testHelper.makeLiteral("There")
+        ),
+        DruidExpression.fromExpression("contains_string(\"spacey\",'There')"),
+        0L
+    );
+
+    testHelper.testExpression(
+        ContainsOperatorConversion.caseInsensitive().calciteOperator(),
+        ImmutableList.of(
+            testHelper.makeInputRef("spacey"),
+            testHelper.makeLiteral("There")
+        ),
+        DruidExpression.fromExpression("icontains_string(\"spacey\",'There')"),
+        1L
+    );
+
+    testHelper.testExpression(
+        ContainsOperatorConversion.caseSensitive().calciteOperator(),
+        ImmutableList.of(
+            testHelper.makeCall(
+                SqlStdOperatorTable.CONCAT,
+                testHelper.makeLiteral("what is"),
+                testHelper.makeInputRef("spacey")
+            ),
+            testHelper.makeLiteral("what")
+        ),
+        DruidExpression.fromExpression("contains_string(concat('what is',\"spacey\"),'what')"),
+        1L
+    );
+
+    testHelper.testExpression(
+        ContainsOperatorConversion.caseSensitive().calciteOperator(),
+        ImmutableList.of(
+            testHelper.makeCall(
+                SqlStdOperatorTable.CONCAT,
+                testHelper.makeLiteral("what is"),
+                testHelper.makeInputRef("spacey")
+            ),
+            testHelper.makeLiteral("there")
+        ),
+        DruidExpression.fromExpression("contains_string(concat('what is',\"spacey\"),'there')"),
+        1L
+    );
+
+    testHelper.testExpression(
+        ContainsOperatorConversion.caseInsensitive().calciteOperator(),
+        ImmutableList.of(
+            testHelper.makeCall(
+                SqlStdOperatorTable.CONCAT,
+                testHelper.makeLiteral("what is"),
+                testHelper.makeInputRef("spacey")
+            ),
+            testHelper.makeLiteral("There")
+        ),
+        DruidExpression.fromExpression("icontains_string(concat('what is',\"spacey\"),'There')"),
+        1L
+    );
+
+    testHelper.testExpression(
+        SqlStdOperatorTable.AND,
+        ImmutableList.of(
+            testHelper.makeCall(
+                ContainsOperatorConversion.caseSensitive().calciteOperator(),
+                testHelper.makeInputRef("spacey"),
+                testHelper.makeLiteral("there")
+            ),
+            testHelper.makeCall(
+                SqlStdOperatorTable.EQUALS,
+                testHelper.makeLiteral("yes"),
+                testHelper.makeLiteral("yes")
+            )
+        ),
+        DruidExpression.fromExpression("(contains_string(\"spacey\",'there') && ('yes' == 'yes'))"),
+        1L
+    );
+
+    testHelper.testExpression(
+        SqlStdOperatorTable.AND,
+        ImmutableList.of(
+            testHelper.makeCall(
+                ContainsOperatorConversion.caseInsensitive().calciteOperator(),
+                testHelper.makeInputRef("spacey"),
+                testHelper.makeLiteral("There")
+            ),
+            testHelper.makeCall(
+                SqlStdOperatorTable.EQUALS,
+                testHelper.makeLiteral("yes"),
+                testHelper.makeLiteral("yes")
+            )
+        ),
+        DruidExpression.fromExpression("(icontains_string(\"spacey\",'There') && ('yes' == 'yes'))"),
+        1L
+    );
+  }
+
+  @Test
+  public void testContainsAsFilter()
+  {
+    testHelper.testFilter(
+        ContainsOperatorConversion.caseSensitive().calciteOperator(),
+        ImmutableList.of(
+            testHelper.makeInputRef("spacey"),
+            testHelper.makeLiteral("there")
+        ),
+        Collections.emptyList(),
+        new SearchQueryDimFilter("spacey", new ContainsSearchQuerySpec("there", true), null),
+        true
+    );
+
+    testHelper.testFilter(
+        ContainsOperatorConversion.caseSensitive().calciteOperator(),
+        ImmutableList.of(
+            testHelper.makeInputRef("spacey"),
+            testHelper.makeLiteral("There")
+        ),
+        Collections.emptyList(),
+        new SearchQueryDimFilter("spacey", new ContainsSearchQuerySpec("There", true), null),
+        false
+    );
+
+    testHelper.testFilter(
+        ContainsOperatorConversion.caseInsensitive().calciteOperator(),
+        ImmutableList.of(
+            testHelper.makeInputRef("spacey"),
+            testHelper.makeLiteral("There")
+        ),
+        Collections.emptyList(),
+        new SearchQueryDimFilter("spacey", new ContainsSearchQuerySpec("There", false), null),
+        true
+    );
+
+    testHelper.testFilter(
+        ContainsOperatorConversion.caseSensitive().calciteOperator(),
+        ImmutableList.of(
+            testHelper.makeCall(
+                SqlStdOperatorTable.CONCAT,
+                testHelper.makeLiteral("what is"),
+                testHelper.makeInputRef("spacey")
+            ),
+            testHelper.makeLiteral("what")
+        ),
+        ImmutableList.of(
+            new ExpressionVirtualColumn(
+                "v0",
+                "concat('what is',\"spacey\")",
+                ValueType.STRING,
+                TestExprMacroTable.INSTANCE
+            )
+        ),
+        new SearchQueryDimFilter("v0", new ContainsSearchQuerySpec("what", true), null),
+        true
+    );
+
+    testHelper.testFilter(
+        ContainsOperatorConversion.caseSensitive().calciteOperator(),
+        ImmutableList.of(
+            testHelper.makeCall(
+                SqlStdOperatorTable.CONCAT,
+                testHelper.makeLiteral("what is"),
+                testHelper.makeInputRef("spacey")
+            ),
+            testHelper.makeLiteral("there")
+        ),
+        ImmutableList.of(
+            new ExpressionVirtualColumn(
+                "v0",
+                "concat('what is',\"spacey\")",
+                ValueType.STRING,
+                TestExprMacroTable.INSTANCE
+            )
+        ),
+        new SearchQueryDimFilter("v0", new ContainsSearchQuerySpec("there", true), null),
+        true
+    );
+
+    testHelper.testFilter(
+        ContainsOperatorConversion.caseInsensitive().calciteOperator(),
+        ImmutableList.of(
+            testHelper.makeCall(
+                SqlStdOperatorTable.CONCAT,
+                testHelper.makeLiteral("what is"),
+                testHelper.makeInputRef("spacey")
+            ),
+            testHelper.makeLiteral("What")
+        ),
+        ImmutableList.of(
+            new ExpressionVirtualColumn(
+                "v0",
+                "concat('what is',\"spacey\")",
+                ValueType.STRING,
+                TestExprMacroTable.INSTANCE
+            )
+        ),
+        new SearchQueryDimFilter("v0", new ContainsSearchQuerySpec("What", false), null),
+        true
+    );
+
+    testHelper.testFilter(
+        ContainsOperatorConversion.caseSensitive().calciteOperator(),
+        ImmutableList.of(
+            testHelper.makeInputRef("spacey"),
+            testHelper.makeLiteral("")
+        ),
+        Collections.emptyList(),
+        new SearchQueryDimFilter("spacey", new ContainsSearchQuerySpec("", true), null),
+        true
+    );
+  }
 
   @Test
   public void testTimeFloor()
diff --git a/website/.spelling b/website/.spelling
index 14fd910..d9698fb 100644
--- a/website/.spelling
+++ b/website/.spelling
@@ -1095,6 +1095,8 @@
 parse_long
 regexp_extract
 regexp_like
+contains_string
+icontains_string
 result1
 result2
 rint