split up Expr.java (#10333)

diff --git a/core/src/main/java/org/apache/druid/math/expr/BinaryLogicalOperatorExpr.java b/core/src/main/java/org/apache/druid/math/expr/BinaryLogicalOperatorExpr.java
new file mode 100644
index 0000000..dad35f3
--- /dev/null
+++ b/core/src/main/java/org/apache/druid/math/expr/BinaryLogicalOperatorExpr.java
@@ -0,0 +1,266 @@
+/*
+ * 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.math.expr;
+
+import org.apache.druid.java.util.common.guava.Comparators;
+
+import javax.annotation.Nullable;
+import java.util.Objects;
+
+// logical operators live here
+
+class BinLtExpr extends BinaryEvalOpExprBase
+{
+  BinLtExpr(String op, Expr left, Expr right)
+  {
+    super(op, left, right);
+  }
+
+  @Override
+  protected BinaryOpExprBase copy(Expr left, Expr right)
+  {
+    return new BinLtExpr(op, left, right);
+  }
+
+  @Override
+  protected ExprEval evalString(@Nullable String left, @Nullable String right)
+  {
+    return ExprEval.of(Comparators.<String>naturalNullsFirst().compare(left, right) < 0, ExprType.LONG);
+  }
+
+  @Override
+  protected final long evalLong(long left, long right)
+  {
+    return Evals.asLong(left < right);
+  }
+
+  @Override
+  protected final double evalDouble(double left, double right)
+  {
+    // Use Double.compare for more consistent NaN handling.
+    return Evals.asDouble(Double.compare(left, right) < 0);
+  }
+}
+
+class BinLeqExpr extends BinaryEvalOpExprBase
+{
+  BinLeqExpr(String op, Expr left, Expr right)
+  {
+    super(op, left, right);
+  }
+
+  @Override
+  protected BinaryOpExprBase copy(Expr left, Expr right)
+  {
+    return new BinLeqExpr(op, left, right);
+  }
+
+  @Override
+  protected ExprEval evalString(@Nullable String left, @Nullable String right)
+  {
+    return ExprEval.of(Comparators.<String>naturalNullsFirst().compare(left, right) <= 0, ExprType.LONG);
+  }
+
+  @Override
+  protected final long evalLong(long left, long right)
+  {
+    return Evals.asLong(left <= right);
+  }
+
+  @Override
+  protected final double evalDouble(double left, double right)
+  {
+    // Use Double.compare for more consistent NaN handling.
+    return Evals.asDouble(Double.compare(left, right) <= 0);
+  }
+}
+
+class BinGtExpr extends BinaryEvalOpExprBase
+{
+  BinGtExpr(String op, Expr left, Expr right)
+  {
+    super(op, left, right);
+  }
+
+  @Override
+  protected BinaryOpExprBase copy(Expr left, Expr right)
+  {
+    return new BinGtExpr(op, left, right);
+  }
+
+  @Override
+  protected ExprEval evalString(@Nullable String left, @Nullable String right)
+  {
+    return ExprEval.of(Comparators.<String>naturalNullsFirst().compare(left, right) > 0, ExprType.LONG);
+  }
+
+  @Override
+  protected final long evalLong(long left, long right)
+  {
+    return Evals.asLong(left > right);
+  }
+
+  @Override
+  protected final double evalDouble(double left, double right)
+  {
+    // Use Double.compare for more consistent NaN handling.
+    return Evals.asDouble(Double.compare(left, right) > 0);
+  }
+}
+
+class BinGeqExpr extends BinaryEvalOpExprBase
+{
+  BinGeqExpr(String op, Expr left, Expr right)
+  {
+    super(op, left, right);
+  }
+
+  @Override
+  protected BinaryOpExprBase copy(Expr left, Expr right)
+  {
+    return new BinGeqExpr(op, left, right);
+  }
+
+  @Override
+  protected ExprEval evalString(@Nullable String left, @Nullable String right)
+  {
+    return ExprEval.of(Comparators.<String>naturalNullsFirst().compare(left, right) >= 0, ExprType.LONG);
+  }
+
+  @Override
+  protected final long evalLong(long left, long right)
+  {
+    return Evals.asLong(left >= right);
+  }
+
+  @Override
+  protected final double evalDouble(double left, double right)
+  {
+    // Use Double.compare for more consistent NaN handling.
+    return Evals.asDouble(Double.compare(left, right) >= 0);
+  }
+}
+
+class BinEqExpr extends BinaryEvalOpExprBase
+{
+  BinEqExpr(String op, Expr left, Expr right)
+  {
+    super(op, left, right);
+  }
+
+  @Override
+  protected BinaryOpExprBase copy(Expr left, Expr right)
+  {
+    return new BinEqExpr(op, left, right);
+  }
+
+  @Override
+  protected ExprEval evalString(@Nullable String left, @Nullable String right)
+  {
+    return ExprEval.of(Objects.equals(left, right), ExprType.LONG);
+  }
+
+  @Override
+  protected final long evalLong(long left, long right)
+  {
+    return Evals.asLong(left == right);
+  }
+
+  @Override
+  protected final double evalDouble(double left, double right)
+  {
+    return Evals.asDouble(left == right);
+  }
+}
+
+class BinNeqExpr extends BinaryEvalOpExprBase
+{
+  BinNeqExpr(String op, Expr left, Expr right)
+  {
+    super(op, left, right);
+  }
+
+  @Override
+  protected BinaryOpExprBase copy(Expr left, Expr right)
+  {
+    return new BinNeqExpr(op, left, right);
+  }
+
+  @Override
+  protected ExprEval evalString(@Nullable String left, @Nullable String right)
+  {
+    return ExprEval.of(!Objects.equals(left, right), ExprType.LONG);
+  }
+
+  @Override
+  protected final long evalLong(long left, long right)
+  {
+    return Evals.asLong(left != right);
+  }
+
+  @Override
+  protected final double evalDouble(double left, double right)
+  {
+    return Evals.asDouble(left != right);
+  }
+}
+
+class BinAndExpr extends BinaryOpExprBase
+{
+  BinAndExpr(String op, Expr left, Expr right)
+  {
+    super(op, left, right);
+  }
+
+  @Override
+  protected BinaryOpExprBase copy(Expr left, Expr right)
+  {
+    return new BinAndExpr(op, left, right);
+  }
+
+  @Override
+  public ExprEval eval(ObjectBinding bindings)
+  {
+    ExprEval leftVal = left.eval(bindings);
+    return leftVal.asBoolean() ? right.eval(bindings) : leftVal;
+  }
+}
+
+class BinOrExpr extends BinaryOpExprBase
+{
+  BinOrExpr(String op, Expr left, Expr right)
+  {
+    super(op, left, right);
+  }
+
+  @Override
+  protected BinaryOpExprBase copy(Expr left, Expr right)
+  {
+    return new BinOrExpr(op, left, right);
+  }
+
+  @Override
+  public ExprEval eval(ObjectBinding bindings)
+  {
+    ExprEval leftVal = left.eval(bindings);
+    return leftVal.asBoolean() ? leftVal : right.eval(bindings);
+  }
+
+}
diff --git a/core/src/main/java/org/apache/druid/math/expr/BinaryMathOperatorExpr.java b/core/src/main/java/org/apache/druid/math/expr/BinaryMathOperatorExpr.java
new file mode 100644
index 0000000..21fadd4
--- /dev/null
+++ b/core/src/main/java/org/apache/druid/math/expr/BinaryMathOperatorExpr.java
@@ -0,0 +1,191 @@
+/*
+ * 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.math.expr;
+
+import com.google.common.math.LongMath;
+import com.google.common.primitives.Ints;
+import org.apache.druid.common.config.NullHandling;
+
+import javax.annotation.Nullable;
+
+// math operators live here
+
+class BinPlusExpr extends BinaryEvalOpExprBase
+{
+  BinPlusExpr(String op, Expr left, Expr right)
+  {
+    super(op, left, right);
+  }
+
+  @Override
+  protected BinaryOpExprBase copy(Expr left, Expr right)
+  {
+    return new BinPlusExpr(op, left, right);
+  }
+
+  @Override
+  protected ExprEval evalString(@Nullable String left, @Nullable String right)
+  {
+    return ExprEval.of(NullHandling.nullToEmptyIfNeeded(left)
+                       + NullHandling.nullToEmptyIfNeeded(right));
+  }
+
+  @Override
+  protected final long evalLong(long left, long right)
+  {
+    return left + right;
+  }
+
+  @Override
+  protected final double evalDouble(double left, double right)
+  {
+    return left + right;
+  }
+}
+
+class BinMinusExpr extends BinaryEvalOpExprBase
+{
+  BinMinusExpr(String op, Expr left, Expr right)
+  {
+    super(op, left, right);
+  }
+
+  @Override
+  protected BinaryOpExprBase copy(Expr left, Expr right)
+  {
+    return new BinMinusExpr(op, left, right);
+  }
+
+  @Override
+  protected final long evalLong(long left, long right)
+  {
+    return left - right;
+  }
+
+  @Override
+  protected final double evalDouble(double left, double right)
+  {
+    return left - right;
+  }
+}
+
+class BinMulExpr extends BinaryEvalOpExprBase
+{
+  BinMulExpr(String op, Expr left, Expr right)
+  {
+    super(op, left, right);
+  }
+
+  @Override
+  protected BinaryOpExprBase copy(Expr left, Expr right)
+  {
+    return new BinMulExpr(op, left, right);
+  }
+
+  @Override
+  protected final long evalLong(long left, long right)
+  {
+    return left * right;
+  }
+
+  @Override
+  protected final double evalDouble(double left, double right)
+  {
+    return left * right;
+  }
+}
+
+class BinDivExpr extends BinaryEvalOpExprBase
+{
+  BinDivExpr(String op, Expr left, Expr right)
+  {
+    super(op, left, right);
+  }
+
+  @Override
+  protected BinaryOpExprBase copy(Expr left, Expr right)
+  {
+    return new BinDivExpr(op, left, right);
+  }
+
+  @Override
+  protected final long evalLong(long left, long right)
+  {
+    return left / right;
+  }
+
+  @Override
+  protected final double evalDouble(double left, double right)
+  {
+    return left / right;
+  }
+}
+
+class BinPowExpr extends BinaryEvalOpExprBase
+{
+  BinPowExpr(String op, Expr left, Expr right)
+  {
+    super(op, left, right);
+  }
+
+  @Override
+  protected BinaryOpExprBase copy(Expr left, Expr right)
+  {
+    return new BinPowExpr(op, left, right);
+  }
+
+  @Override
+  protected final long evalLong(long left, long right)
+  {
+    return LongMath.pow(left, Ints.checkedCast(right));
+  }
+
+  @Override
+  protected final double evalDouble(double left, double right)
+  {
+    return Math.pow(left, right);
+  }
+}
+
+class BinModuloExpr extends BinaryEvalOpExprBase
+{
+  BinModuloExpr(String op, Expr left, Expr right)
+  {
+    super(op, left, right);
+  }
+
+  @Override
+  protected BinaryOpExprBase copy(Expr left, Expr right)
+  {
+    return new BinModuloExpr(op, left, right);
+  }
+
+  @Override
+  protected final long evalLong(long left, long right)
+  {
+    return left % right;
+  }
+
+  @Override
+  protected final double evalDouble(double left, double right)
+  {
+    return left % right;
+  }
+}
diff --git a/core/src/main/java/org/apache/druid/math/expr/BinaryOperatorExpr.java b/core/src/main/java/org/apache/druid/math/expr/BinaryOperatorExpr.java
new file mode 100644
index 0000000..9c39058
--- /dev/null
+++ b/core/src/main/java/org/apache/druid/math/expr/BinaryOperatorExpr.java
@@ -0,0 +1,158 @@
+/*
+ * 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.math.expr;
+
+import com.google.common.collect.ImmutableSet;
+import org.apache.druid.common.config.NullHandling;
+import org.apache.druid.java.util.common.StringUtils;
+
+import javax.annotation.Nullable;
+import java.util.Objects;
+
+/**
+ * Base type for all binary operators, this {@link Expr} has two children {@link Expr} for the left and right side
+ * operands.
+ *
+ * Note: all concrete subclass of this should have constructor with the form of <init>(String, Expr, Expr)
+ * if it's not possible, just be sure Evals.binaryOp() can handle that
+ */
+abstract class BinaryOpExprBase implements Expr
+{
+  protected final String op;
+  protected final Expr left;
+  protected final Expr right;
+
+  BinaryOpExprBase(String op, Expr left, Expr right)
+  {
+    this.op = op;
+    this.left = left;
+    this.right = right;
+  }
+
+  @Override
+  public void visit(Visitor visitor)
+  {
+    left.visit(visitor);
+    right.visit(visitor);
+    visitor.visit(this);
+  }
+
+  @Override
+  public Expr visit(Shuttle shuttle)
+  {
+    Expr newLeft = left.visit(shuttle);
+    Expr newRight = right.visit(shuttle);
+    //noinspection ObjectEquality (checking for object equality here is intentional)
+    if (left != newLeft || right != newRight) {
+      return shuttle.visit(copy(newLeft, newRight));
+    }
+    return shuttle.visit(this);
+  }
+
+  @Override
+  public String toString()
+  {
+    return StringUtils.format("(%s %s %s)", op, left, right);
+  }
+
+  @Override
+  public String stringify()
+  {
+    return StringUtils.format("(%s %s %s)", left.stringify(), op, right.stringify());
+  }
+
+  protected abstract BinaryOpExprBase copy(Expr left, Expr right);
+
+  @Override
+  public BindingDetails analyzeInputs()
+  {
+    // currently all binary operators operate on scalar inputs
+    return left.analyzeInputs().with(right).withScalarArguments(ImmutableSet.of(left, right));
+  }
+
+  @Override
+  public boolean equals(Object o)
+  {
+    if (this == o) {
+      return true;
+    }
+    if (o == null || getClass() != o.getClass()) {
+      return false;
+    }
+    BinaryOpExprBase that = (BinaryOpExprBase) o;
+    return Objects.equals(op, that.op) &&
+           Objects.equals(left, that.left) &&
+           Objects.equals(right, that.right);
+  }
+
+  @Override
+  public int hashCode()
+  {
+    return Objects.hash(op, left, right);
+  }
+}
+
+/**
+ * Base class for numerical binary operators, with additional methods defined to evaluate primitive values directly
+ * instead of wrapped with {@link ExprEval}
+ */
+abstract class BinaryEvalOpExprBase extends BinaryOpExprBase
+{
+  BinaryEvalOpExprBase(String op, Expr left, Expr right)
+  {
+    super(op, left, right);
+  }
+
+  @Override
+  public ExprEval eval(ObjectBinding bindings)
+  {
+    ExprEval leftVal = left.eval(bindings);
+    ExprEval rightVal = right.eval(bindings);
+
+    // Result of any Binary expressions is null if any of the argument is null.
+    // e.g "select null * 2 as c;" or "select null + 1 as c;" will return null as per Standard SQL spec.
+    if (NullHandling.sqlCompatible() && (leftVal.value() == null || rightVal.value() == null)) {
+      return ExprEval.of(null);
+    }
+
+    if (leftVal.type() == ExprType.STRING && rightVal.type() == ExprType.STRING) {
+      return evalString(leftVal.asString(), rightVal.asString());
+    } else if (leftVal.type() == ExprType.LONG && rightVal.type() == ExprType.LONG) {
+      if (NullHandling.sqlCompatible() && (leftVal.isNumericNull() || rightVal.isNumericNull())) {
+        return ExprEval.of(null);
+      }
+      return ExprEval.of(evalLong(leftVal.asLong(), rightVal.asLong()));
+    } else {
+      if (NullHandling.sqlCompatible() && (leftVal.isNumericNull() || rightVal.isNumericNull())) {
+        return ExprEval.of(null);
+      }
+      return ExprEval.of(evalDouble(leftVal.asDouble(), rightVal.asDouble()));
+    }
+  }
+
+  protected ExprEval evalString(@Nullable String left, @Nullable String right)
+  {
+    throw new IllegalArgumentException("unsupported type " + ExprType.STRING);
+  }
+
+  protected abstract long evalLong(long left, long right);
+
+  protected abstract double evalDouble(double left, double right);
+}
diff --git a/core/src/main/java/org/apache/druid/math/expr/ConstantExpr.java b/core/src/main/java/org/apache/druid/math/expr/ConstantExpr.java
new file mode 100644
index 0000000..4f6099c
--- /dev/null
+++ b/core/src/main/java/org/apache/druid/math/expr/ConstantExpr.java
@@ -0,0 +1,457 @@
+/*
+ * 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.math.expr;
+
+import com.google.common.base.Preconditions;
+import org.apache.commons.lang.StringEscapeUtils;
+import org.apache.druid.common.config.NullHandling;
+import org.apache.druid.java.util.common.StringUtils;
+
+import javax.annotation.Nullable;
+import java.util.Arrays;
+import java.util.Objects;
+
+/**
+ * Base type for all constant expressions. {@link ConstantExpr} allow for direct value extraction without evaluating
+ * {@link Expr.ObjectBinding}. {@link ConstantExpr} are terminal nodes of an expression tree, and have no children
+ * {@link Expr}.
+ */
+abstract class ConstantExpr implements Expr
+{
+  @Override
+  public boolean isLiteral()
+  {
+    return true;
+  }
+
+  @Override
+  public void visit(Visitor visitor)
+  {
+    visitor.visit(this);
+  }
+
+  @Override
+  public Expr visit(Shuttle shuttle)
+  {
+    return shuttle.visit(this);
+  }
+
+  @Override
+  public BindingDetails analyzeInputs()
+  {
+    return new BindingDetails();
+  }
+
+  @Override
+  public String stringify()
+  {
+    return toString();
+  }
+}
+
+/**
+ * Base class for typed 'null' value constants (or default value, depending on {@link NullHandling#sqlCompatible})
+ */
+abstract class NullNumericConstantExpr extends ConstantExpr
+{
+  @Override
+  public Object getLiteralValue()
+  {
+    return null;
+  }
+
+  @Override
+  public String toString()
+  {
+    return NULL_LITERAL;
+  }
+}
+
+class LongExpr extends ConstantExpr
+{
+  private final Long value;
+
+  LongExpr(Long value)
+  {
+    this.value = Preconditions.checkNotNull(value, "value");
+  }
+
+  @Override
+  public Object getLiteralValue()
+  {
+    return value;
+  }
+
+  @Override
+  public String toString()
+  {
+    return String.valueOf(value);
+  }
+
+  @Override
+  public ExprEval eval(ObjectBinding bindings)
+  {
+    return ExprEval.ofLong(value);
+  }
+
+  @Override
+  public boolean equals(Object o)
+  {
+    if (this == o) {
+      return true;
+    }
+    if (o == null || getClass() != o.getClass()) {
+      return false;
+    }
+    LongExpr longExpr = (LongExpr) o;
+    return Objects.equals(value, longExpr.value);
+  }
+
+  @Override
+  public int hashCode()
+  {
+    return Objects.hash(value);
+  }
+}
+
+class NullLongExpr extends NullNumericConstantExpr
+{
+  @Override
+  public ExprEval eval(ObjectBinding bindings)
+  {
+    return ExprEval.ofLong(null);
+  }
+
+  @Override
+  public final int hashCode()
+  {
+    return NullLongExpr.class.hashCode();
+  }
+
+  @Override
+  public final boolean equals(Object obj)
+  {
+    return obj instanceof NullLongExpr;
+  }
+}
+
+class LongArrayExpr extends ConstantExpr
+{
+  private final Long[] value;
+
+  LongArrayExpr(Long[] value)
+  {
+    this.value = Preconditions.checkNotNull(value, "value");
+  }
+
+  @Override
+  public Object getLiteralValue()
+  {
+    return value;
+  }
+
+  @Override
+  public String toString()
+  {
+    return Arrays.toString(value);
+  }
+
+  @Override
+  public ExprEval eval(ObjectBinding bindings)
+  {
+    return ExprEval.ofLongArray(value);
+  }
+
+  @Override
+  public String stringify()
+  {
+    if (value.length == 0) {
+      return "<LONG>[]";
+    }
+    return StringUtils.format("<LONG>%s", toString());
+  }
+
+  @Override
+  public boolean equals(Object o)
+  {
+    if (this == o) {
+      return true;
+    }
+    if (o == null || getClass() != o.getClass()) {
+      return false;
+    }
+    LongArrayExpr that = (LongArrayExpr) o;
+    return Arrays.equals(value, that.value);
+  }
+
+  @Override
+  public int hashCode()
+  {
+    return Arrays.hashCode(value);
+  }
+}
+
+class StringExpr extends ConstantExpr
+{
+  @Nullable
+  private final String value;
+
+  StringExpr(@Nullable String value)
+  {
+    this.value = NullHandling.emptyToNullIfNeeded(value);
+  }
+
+  @Nullable
+  @Override
+  public Object getLiteralValue()
+  {
+    return value;
+  }
+
+  @Override
+  public String toString()
+  {
+    return value;
+  }
+
+  @Override
+  public ExprEval eval(ObjectBinding bindings)
+  {
+    return ExprEval.of(value);
+  }
+
+  @Override
+  public String stringify()
+  {
+    // escape as javascript string since string literals are wrapped in single quotes
+    return value == null ? NULL_LITERAL : StringUtils.format("'%s'", StringEscapeUtils.escapeJavaScript(value));
+  }
+
+  @Override
+  public boolean equals(Object o)
+  {
+    if (this == o) {
+      return true;
+    }
+    if (o == null || getClass() != o.getClass()) {
+      return false;
+    }
+    StringExpr that = (StringExpr) o;
+    return Objects.equals(value, that.value);
+  }
+
+  @Override
+  public int hashCode()
+  {
+    return Objects.hash(value);
+  }
+}
+
+class StringArrayExpr extends ConstantExpr
+{
+  private final String[] value;
+
+  StringArrayExpr(String[] value)
+  {
+    this.value = Preconditions.checkNotNull(value, "value");
+  }
+
+  @Override
+  public Object getLiteralValue()
+  {
+    return value;
+  }
+
+  @Override
+  public String toString()
+  {
+    return Arrays.toString(value);
+  }
+
+  @Override
+  public ExprEval eval(ObjectBinding bindings)
+  {
+    return ExprEval.ofStringArray(value);
+  }
+
+  @Override
+  public String stringify()
+  {
+    if (value.length == 0) {
+      return "<STRING>[]";
+    }
+
+    return StringUtils.format(
+        "<STRING>[%s]",
+        ARG_JOINER.join(
+            Arrays.stream(value)
+                  .map(s -> s == null
+                            ? NULL_LITERAL
+                            // escape as javascript string since string literals are wrapped in single quotes
+                            : StringUtils.format("'%s'", StringEscapeUtils.escapeJavaScript(s))
+                  )
+                  .iterator()
+        )
+    );
+  }
+
+  @Override
+  public boolean equals(Object o)
+  {
+    if (this == o) {
+      return true;
+    }
+    if (o == null || getClass() != o.getClass()) {
+      return false;
+    }
+    StringArrayExpr that = (StringArrayExpr) o;
+    return Arrays.equals(value, that.value);
+  }
+
+  @Override
+  public int hashCode()
+  {
+    return Arrays.hashCode(value);
+  }
+}
+
+class DoubleExpr extends ConstantExpr
+{
+  private final Double value;
+
+  DoubleExpr(Double value)
+  {
+    this.value = Preconditions.checkNotNull(value, "value");
+  }
+
+  @Override
+  public Object getLiteralValue()
+  {
+    return value;
+  }
+
+  @Override
+  public String toString()
+  {
+    return String.valueOf(value);
+  }
+
+  @Override
+  public ExprEval eval(ObjectBinding bindings)
+  {
+    return ExprEval.ofDouble(value);
+  }
+
+  @Override
+  public boolean equals(Object o)
+  {
+    if (this == o) {
+      return true;
+    }
+    if (o == null || getClass() != o.getClass()) {
+      return false;
+    }
+    DoubleExpr that = (DoubleExpr) o;
+    return Objects.equals(value, that.value);
+  }
+
+  @Override
+  public int hashCode()
+  {
+    return Objects.hash(value);
+  }
+}
+
+class NullDoubleExpr extends NullNumericConstantExpr
+{
+  @Override
+  public ExprEval eval(ObjectBinding bindings)
+  {
+    return ExprEval.ofDouble(null);
+  }
+
+  @Override
+  public final int hashCode()
+  {
+    return NullDoubleExpr.class.hashCode();
+  }
+
+  @Override
+  public final boolean equals(Object obj)
+  {
+    return obj instanceof NullDoubleExpr;
+  }
+}
+
+class DoubleArrayExpr extends ConstantExpr
+{
+  private final Double[] value;
+
+  DoubleArrayExpr(Double[] value)
+  {
+    this.value = Preconditions.checkNotNull(value, "value");
+  }
+
+  @Override
+  public Object getLiteralValue()
+  {
+    return value;
+  }
+
+  @Override
+  public String toString()
+  {
+    return Arrays.toString(value);
+  }
+
+  @Override
+  public ExprEval eval(ObjectBinding bindings)
+  {
+    return ExprEval.ofDoubleArray(value);
+  }
+
+  @Override
+  public String stringify()
+  {
+    if (value.length == 0) {
+      return "<DOUBLE>[]";
+    }
+    return StringUtils.format("<DOUBLE>%s", toString());
+  }
+
+  @Override
+  public boolean equals(Object o)
+  {
+    if (this == o) {
+      return true;
+    }
+    if (o == null || getClass() != o.getClass()) {
+      return false;
+    }
+    DoubleArrayExpr that = (DoubleArrayExpr) o;
+    return Arrays.equals(value, that.value);
+  }
+
+  @Override
+  public int hashCode()
+  {
+    return Arrays.hashCode(value);
+  }
+}
diff --git a/core/src/main/java/org/apache/druid/math/expr/Expr.java b/core/src/main/java/org/apache/druid/math/expr/Expr.java
index 1cd7d62..e0a1525 100644
--- a/core/src/main/java/org/apache/druid/math/expr/Expr.java
+++ b/core/src/main/java/org/apache/druid/math/expr/Expr.java
@@ -20,28 +20,16 @@
 package org.apache.druid.math.expr;
 
 import com.google.common.base.Joiner;
-import com.google.common.base.Preconditions;
-import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Sets;
-import com.google.common.math.LongMath;
-import com.google.common.primitives.Ints;
-import org.apache.commons.lang.StringEscapeUtils;
 import org.apache.druid.annotations.SubclassesMustOverrideEqualsAndHashCode;
-import org.apache.druid.common.config.NullHandling;
-import org.apache.druid.java.util.common.IAE;
 import org.apache.druid.java.util.common.ISE;
-import org.apache.druid.java.util.common.StringUtils;
-import org.apache.druid.java.util.common.guava.Comparators;
 
 import javax.annotation.Nullable;
 import java.util.ArrayList;
-import java.util.Arrays;
 import java.util.HashSet;
 import java.util.List;
-import java.util.Objects;
 import java.util.Set;
-import java.util.stream.Collectors;
 
 /**
  * Base interface of Druid expression language abstract syntax tree nodes. All {@link Expr} implementations are
@@ -444,1533 +432,3 @@
     }
   }
 }
-
-/**
- * Base type for all constant expressions. {@link ConstantExpr} allow for direct value extraction without evaluating
- * {@link Expr.ObjectBinding}. {@link ConstantExpr} are terminal nodes of an expression tree, and have no children
- * {@link Expr}.
- */
-abstract class ConstantExpr implements Expr
-{
-  @Override
-  public boolean isLiteral()
-  {
-    return true;
-  }
-
-  @Override
-  public void visit(Visitor visitor)
-  {
-    visitor.visit(this);
-  }
-
-  @Override
-  public Expr visit(Shuttle shuttle)
-  {
-    return shuttle.visit(this);
-  }
-
-  @Override
-  public BindingDetails analyzeInputs()
-  {
-    return new BindingDetails();
-  }
-
-  @Override
-  public String stringify()
-  {
-    return toString();
-  }
-}
-
-abstract class NullNumericConstantExpr extends ConstantExpr
-{
-  @Override
-  public Object getLiteralValue()
-  {
-    return null;
-  }
-
-  @Override
-  public String toString()
-  {
-    return NULL_LITERAL;
-  }
-}
-
-class LongExpr extends ConstantExpr
-{
-  private final Long value;
-
-  LongExpr(Long value)
-  {
-    this.value = Preconditions.checkNotNull(value, "value");
-  }
-
-  @Override
-  public Object getLiteralValue()
-  {
-    return value;
-  }
-
-  @Override
-  public String toString()
-  {
-    return String.valueOf(value);
-  }
-
-  @Override
-  public ExprEval eval(ObjectBinding bindings)
-  {
-    return ExprEval.ofLong(value);
-  }
-
-  @Override
-  public boolean equals(Object o)
-  {
-    if (this == o) {
-      return true;
-    }
-    if (o == null || getClass() != o.getClass()) {
-      return false;
-    }
-    LongExpr longExpr = (LongExpr) o;
-    return Objects.equals(value, longExpr.value);
-  }
-
-  @Override
-  public int hashCode()
-  {
-    return Objects.hash(value);
-  }
-}
-
-class NullLongExpr extends NullNumericConstantExpr
-{
-  @Override
-  public ExprEval eval(ObjectBinding bindings)
-  {
-    return ExprEval.ofLong(null);
-  }
-
-  @Override
-  public final int hashCode()
-  {
-    return NullLongExpr.class.hashCode();
-  }
-
-  @Override
-  public final boolean equals(Object obj)
-  {
-    return obj instanceof NullLongExpr;
-  }
-}
-
-
-class LongArrayExpr extends ConstantExpr
-{
-  private final Long[] value;
-
-  LongArrayExpr(Long[] value)
-  {
-    this.value = Preconditions.checkNotNull(value, "value");
-  }
-
-  @Override
-  public Object getLiteralValue()
-  {
-    return value;
-  }
-
-  @Override
-  public String toString()
-  {
-    return Arrays.toString(value);
-  }
-
-  @Override
-  public ExprEval eval(ObjectBinding bindings)
-  {
-    return ExprEval.ofLongArray(value);
-  }
-
-  @Override
-  public String stringify()
-  {
-    if (value.length == 0) {
-      return "<LONG>[]";
-    }
-    return StringUtils.format("<LONG>%s", toString());
-  }
-
-  @Override
-  public boolean equals(Object o)
-  {
-    if (this == o) {
-      return true;
-    }
-    if (o == null || getClass() != o.getClass()) {
-      return false;
-    }
-    LongArrayExpr that = (LongArrayExpr) o;
-    return Arrays.equals(value, that.value);
-  }
-
-  @Override
-  public int hashCode()
-  {
-    return Arrays.hashCode(value);
-  }
-}
-
-class StringExpr extends ConstantExpr
-{
-  @Nullable
-  private final String value;
-
-  StringExpr(@Nullable String value)
-  {
-    this.value = NullHandling.emptyToNullIfNeeded(value);
-  }
-
-  @Nullable
-  @Override
-  public Object getLiteralValue()
-  {
-    return value;
-  }
-
-  @Override
-  public String toString()
-  {
-    return value;
-  }
-
-  @Override
-  public ExprEval eval(ObjectBinding bindings)
-  {
-    return ExprEval.of(value);
-  }
-
-  @Override
-  public String stringify()
-  {
-    // escape as javascript string since string literals are wrapped in single quotes
-    return value == null ? NULL_LITERAL : StringUtils.format("'%s'", StringEscapeUtils.escapeJavaScript(value));
-  }
-
-  @Override
-  public boolean equals(Object o)
-  {
-    if (this == o) {
-      return true;
-    }
-    if (o == null || getClass() != o.getClass()) {
-      return false;
-    }
-    StringExpr that = (StringExpr) o;
-    return Objects.equals(value, that.value);
-  }
-
-  @Override
-  public int hashCode()
-  {
-    return Objects.hash(value);
-  }
-}
-
-class StringArrayExpr extends ConstantExpr
-{
-  private final String[] value;
-
-  StringArrayExpr(String[] value)
-  {
-    this.value = Preconditions.checkNotNull(value, "value");
-  }
-
-  @Override
-  public Object getLiteralValue()
-  {
-    return value;
-  }
-
-  @Override
-  public String toString()
-  {
-    return Arrays.toString(value);
-  }
-
-  @Override
-  public ExprEval eval(ObjectBinding bindings)
-  {
-    return ExprEval.ofStringArray(value);
-  }
-
-  @Override
-  public String stringify()
-  {
-    if (value.length == 0) {
-      return "<STRING>[]";
-    }
-
-    return StringUtils.format(
-        "<STRING>[%s]",
-        ARG_JOINER.join(
-            Arrays.stream(value)
-                  .map(s -> s == null
-                            ? NULL_LITERAL
-                            // escape as javascript string since string literals are wrapped in single quotes
-                            : StringUtils.format("'%s'", StringEscapeUtils.escapeJavaScript(s))
-                  )
-                  .iterator()
-        )
-    );
-  }
-
-  @Override
-  public boolean equals(Object o)
-  {
-    if (this == o) {
-      return true;
-    }
-    if (o == null || getClass() != o.getClass()) {
-      return false;
-    }
-    StringArrayExpr that = (StringArrayExpr) o;
-    return Arrays.equals(value, that.value);
-  }
-
-  @Override
-  public int hashCode()
-  {
-    return Arrays.hashCode(value);
-  }
-}
-
-class DoubleExpr extends ConstantExpr
-{
-  private final Double value;
-
-  DoubleExpr(Double value)
-  {
-    this.value = Preconditions.checkNotNull(value, "value");
-  }
-
-  @Override
-  public Object getLiteralValue()
-  {
-    return value;
-  }
-
-  @Override
-  public String toString()
-  {
-    return String.valueOf(value);
-  }
-
-  @Override
-  public ExprEval eval(ObjectBinding bindings)
-  {
-    return ExprEval.ofDouble(value);
-  }
-
-  @Override
-  public boolean equals(Object o)
-  {
-    if (this == o) {
-      return true;
-    }
-    if (o == null || getClass() != o.getClass()) {
-      return false;
-    }
-    DoubleExpr that = (DoubleExpr) o;
-    return Objects.equals(value, that.value);
-  }
-
-  @Override
-  public int hashCode()
-  {
-    return Objects.hash(value);
-  }
-}
-
-class NullDoubleExpr extends NullNumericConstantExpr
-{
-  @Override
-  public ExprEval eval(ObjectBinding bindings)
-  {
-    return ExprEval.ofDouble(null);
-  }
-
-  @Override
-  public final int hashCode()
-  {
-    return NullDoubleExpr.class.hashCode();
-  }
-
-  @Override
-  public final boolean equals(Object obj)
-  {
-    return obj instanceof NullDoubleExpr;
-  }
-}
-
-class DoubleArrayExpr extends ConstantExpr
-{
-  private final Double[] value;
-
-  DoubleArrayExpr(Double[] value)
-  {
-    this.value = Preconditions.checkNotNull(value, "value");
-  }
-
-  @Override
-  public Object getLiteralValue()
-  {
-    return value;
-  }
-
-  @Override
-  public String toString()
-  {
-    return Arrays.toString(value);
-  }
-
-  @Override
-  public ExprEval eval(ObjectBinding bindings)
-  {
-    return ExprEval.ofDoubleArray(value);
-  }
-
-  @Override
-  public String stringify()
-  {
-    if (value.length == 0) {
-      return "<DOUBLE>[]";
-    }
-    return StringUtils.format("<DOUBLE>%s", toString());
-  }
-
-  @Override
-  public boolean equals(Object o)
-  {
-    if (this == o) {
-      return true;
-    }
-    if (o == null || getClass() != o.getClass()) {
-      return false;
-    }
-    DoubleArrayExpr that = (DoubleArrayExpr) o;
-    return Arrays.equals(value, that.value);
-  }
-
-  @Override
-  public int hashCode()
-  {
-    return Arrays.hashCode(value);
-  }
-}
-
-/**
- * This {@link Expr} node is used to represent a variable in the expression language. At evaluation time, the string
- * identifier will be used to retrieve the runtime value for the variable from {@link Expr.ObjectBinding}.
- * {@link IdentifierExpr} are terminal nodes of an expression tree, and have no children {@link Expr}.
- */
-class IdentifierExpr implements Expr
-{
-  private final String identifier;
-  private final String binding;
-
-  /**
-   * Construct a identifier expression for a {@link LambdaExpr}, where the {@link #identifier} is equal to
-   * {@link #binding}
-   */
-  IdentifierExpr(String value)
-  {
-    this.identifier = value;
-    this.binding = value;
-  }
-
-  /**
-   * Construct a normal identifier expression, where {@link #binding} is the key to fetch the backing value from
-   * {@link Expr.ObjectBinding} and the {@link #identifier} is a unique string that identifies this usage of the
-   * binding.
-   */
-  IdentifierExpr(String identifier, String binding)
-  {
-    this.identifier = identifier;
-    this.binding = binding;
-  }
-
-  @Override
-  public String toString()
-  {
-    return binding;
-  }
-
-  /**
-   * Unique identifier for the binding
-   */
-  @Nullable
-  public String getIdentifier()
-  {
-    return identifier;
-  }
-
-  /**
-   * Value binding, key to retrieve value from {@link Expr.ObjectBinding#get(String)}
-   */
-  @Nullable
-  public String getBinding()
-  {
-    return binding;
-  }
-
-  @Nullable
-  @Override
-  public String getIdentifierIfIdentifier()
-  {
-    return identifier;
-  }
-
-  @Nullable
-  @Override
-  public String getBindingIfIdentifier()
-  {
-    return binding;
-  }
-
-  @Nullable
-  @Override
-  public IdentifierExpr getIdentifierExprIfIdentifierExpr()
-  {
-    return this;
-  }
-
-  @Override
-  public BindingDetails analyzeInputs()
-  {
-    return new BindingDetails(this);
-  }
-
-  @Override
-  public ExprEval eval(ObjectBinding bindings)
-  {
-    return ExprEval.bestEffortOf(bindings.get(binding));
-  }
-
-  @Override
-  public String stringify()
-  {
-    // escape as java strings since identifiers are wrapped in double quotes
-    return StringUtils.format("\"%s\"", StringEscapeUtils.escapeJava(binding));
-  }
-
-  @Override
-  public void visit(Visitor visitor)
-  {
-    visitor.visit(this);
-  }
-
-  @Override
-  public Expr visit(Shuttle shuttle)
-  {
-    return shuttle.visit(this);
-  }
-
-  @Override
-  public boolean equals(Object o)
-  {
-    if (this == o) {
-      return true;
-    }
-    if (o == null || getClass() != o.getClass()) {
-      return false;
-    }
-    IdentifierExpr that = (IdentifierExpr) o;
-    return Objects.equals(identifier, that.identifier);
-  }
-
-  @Override
-  public int hashCode()
-  {
-    return Objects.hash(identifier);
-  }
-}
-
-class LambdaExpr implements Expr
-{
-  private final ImmutableList<IdentifierExpr> args;
-  private final Expr expr;
-
-  LambdaExpr(List<IdentifierExpr> args, Expr expr)
-  {
-    this.args = ImmutableList.copyOf(args);
-    this.expr = expr;
-  }
-
-  @Override
-  public String toString()
-  {
-    return StringUtils.format("(%s -> %s)", args, expr);
-  }
-
-  int identifierCount()
-  {
-    return args.size();
-  }
-
-  @Nullable
-  public String getIdentifier()
-  {
-    Preconditions.checkState(args.size() < 2, "LambdaExpr has multiple arguments");
-    if (args.size() == 1) {
-      return args.get(0).toString();
-    }
-    return null;
-  }
-
-  public List<String> getIdentifiers()
-  {
-    return args.stream().map(IdentifierExpr::toString).collect(Collectors.toList());
-  }
-
-  ImmutableList<IdentifierExpr> getIdentifierExprs()
-  {
-    return args;
-  }
-
-  public Expr getExpr()
-  {
-    return expr;
-  }
-
-  @Override
-  public ExprEval eval(ObjectBinding bindings)
-  {
-    return expr.eval(bindings);
-  }
-
-  @Override
-  public String stringify()
-  {
-    return StringUtils.format("(%s) -> %s", ARG_JOINER.join(getIdentifiers()), expr.stringify());
-  }
-
-  @Override
-  public void visit(Visitor visitor)
-  {
-    expr.visit(visitor);
-    visitor.visit(this);
-  }
-
-  @Override
-  public Expr visit(Shuttle shuttle)
-  {
-    List<IdentifierExpr> newArgs =
-        args.stream().map(arg -> (IdentifierExpr) shuttle.visit(arg)).collect(Collectors.toList());
-    Expr newBody = expr.visit(shuttle);
-    return shuttle.visit(new LambdaExpr(newArgs, newBody));
-  }
-
-  @Override
-  public BindingDetails analyzeInputs()
-  {
-    final Set<String> lambdaArgs = args.stream().map(IdentifierExpr::toString).collect(Collectors.toSet());
-    BindingDetails bodyDetails = expr.analyzeInputs();
-    return bodyDetails.removeLambdaArguments(lambdaArgs);
-  }
-
-  @Override
-  public boolean equals(Object o)
-  {
-    if (this == o) {
-      return true;
-    }
-    if (o == null || getClass() != o.getClass()) {
-      return false;
-    }
-    LambdaExpr that = (LambdaExpr) o;
-    return Objects.equals(args, that.args) &&
-           Objects.equals(expr, that.expr);
-  }
-
-  @Override
-  public int hashCode()
-  {
-    return Objects.hash(args, expr);
-  }
-}
-
-/**
- * {@link Expr} node for a {@link Function} call. {@link FunctionExpr} has children {@link Expr} in the form of the
- * list of arguments that are passed to the {@link Function} along with the {@link Expr.ObjectBinding} when it is
- * evaluated.
- */
-class FunctionExpr implements Expr
-{
-  final Function function;
-  final ImmutableList<Expr> args;
-  private final String name;
-
-  FunctionExpr(Function function, String name, List<Expr> args)
-  {
-    this.function = function;
-    this.name = name;
-    this.args = ImmutableList.copyOf(args);
-    function.validateArguments(args);
-  }
-
-  @Override
-  public String toString()
-  {
-    return StringUtils.format("(%s %s)", name, args);
-  }
-
-  @Override
-  public ExprEval eval(ObjectBinding bindings)
-  {
-    return function.apply(args, bindings);
-  }
-
-  @Override
-  public String stringify()
-  {
-    return StringUtils.format("%s(%s)", name, ARG_JOINER.join(args.stream().map(Expr::stringify).iterator()));
-  }
-
-  @Override
-  public void visit(Visitor visitor)
-  {
-    for (Expr child : args) {
-      child.visit(visitor);
-    }
-    visitor.visit(this);
-  }
-
-  @Override
-  public Expr visit(Shuttle shuttle)
-  {
-    List<Expr> newArgs = args.stream().map(shuttle::visit).collect(Collectors.toList());
-    return shuttle.visit(new FunctionExpr(function, name, newArgs));
-  }
-
-  @Override
-  public BindingDetails analyzeInputs()
-  {
-    BindingDetails accumulator = new BindingDetails();
-
-    for (Expr arg : args) {
-      accumulator = accumulator.with(arg);
-    }
-    return accumulator.withScalarArguments(function.getScalarInputs(args))
-                      .withArrayArguments(function.getArrayInputs(args))
-                      .withArrayInputs(function.hasArrayInputs())
-                      .withArrayOutput(function.hasArrayOutput());
-  }
-
-  @Override
-  public boolean equals(Object o)
-  {
-    if (this == o) {
-      return true;
-    }
-    if (o == null || getClass() != o.getClass()) {
-      return false;
-    }
-    FunctionExpr that = (FunctionExpr) o;
-    return args.equals(that.args) &&
-           name.equals(that.name);
-  }
-
-  @Override
-  public int hashCode()
-  {
-    return Objects.hash(args, name);
-  }
-}
-
-/**
- * This {@link Expr} node is representative of an {@link ApplyFunction}, and has children in the form of a
- * {@link LambdaExpr} and the list of {@link Expr} arguments that are combined with {@link Expr.ObjectBinding} to
- * evaluate the {@link LambdaExpr}.
- */
-class ApplyFunctionExpr implements Expr
-{
-  final ApplyFunction function;
-  final String name;
-  final LambdaExpr lambdaExpr;
-  final ImmutableList<Expr> argsExpr;
-  final BindingDetails bindingDetails;
-  final BindingDetails lambdaBindingDetails;
-  final ImmutableList<BindingDetails> argsBindingDetails;
-
-  ApplyFunctionExpr(ApplyFunction function, String name, LambdaExpr expr, List<Expr> args)
-  {
-    this.function = function;
-    this.name = name;
-    this.argsExpr = ImmutableList.copyOf(args);
-    this.lambdaExpr = expr;
-
-    function.validateArguments(expr, args);
-
-    // apply function expressions are examined during expression selector creation, so precompute and cache the
-    // binding details of children
-    ImmutableList.Builder<BindingDetails> argBindingDetailsBuilder = ImmutableList.builder();
-    BindingDetails accumulator = new BindingDetails();
-    for (Expr arg : argsExpr) {
-      BindingDetails argDetails = arg.analyzeInputs();
-      argBindingDetailsBuilder.add(argDetails);
-      accumulator = accumulator.with(argDetails);
-    }
-
-    lambdaBindingDetails = lambdaExpr.analyzeInputs();
-
-    bindingDetails = accumulator.with(lambdaBindingDetails)
-                                .withArrayArguments(function.getArrayInputs(argsExpr))
-                                .withArrayInputs(true)
-                                .withArrayOutput(function.hasArrayOutput(lambdaExpr));
-    argsBindingDetails = argBindingDetailsBuilder.build();
-  }
-
-  @Override
-  public String toString()
-  {
-    return StringUtils.format("(%s %s, %s)", name, lambdaExpr, argsExpr);
-  }
-
-  @Override
-  public ExprEval eval(ObjectBinding bindings)
-  {
-    return function.apply(lambdaExpr, argsExpr, bindings);
-  }
-
-  @Override
-  public String stringify()
-  {
-    return StringUtils.format(
-        "%s(%s, %s)",
-        name,
-        lambdaExpr.stringify(),
-        ARG_JOINER.join(argsExpr.stream().map(Expr::stringify).iterator())
-    );
-  }
-
-  @Override
-  public void visit(Visitor visitor)
-  {
-    lambdaExpr.visit(visitor);
-    for (Expr arg : argsExpr) {
-      arg.visit(visitor);
-    }
-    visitor.visit(this);
-  }
-
-  @Override
-  public Expr visit(Shuttle shuttle)
-  {
-    LambdaExpr newLambda = (LambdaExpr) lambdaExpr.visit(shuttle);
-    List<Expr> newArgs = argsExpr.stream().map(shuttle::visit).collect(Collectors.toList());
-    return shuttle.visit(new ApplyFunctionExpr(function, name, newLambda, newArgs));
-  }
-
-  @Override
-  public BindingDetails analyzeInputs()
-  {
-    return bindingDetails;
-  }
-
-  @Override
-  public boolean equals(Object o)
-  {
-    if (this == o) {
-      return true;
-    }
-    if (o == null || getClass() != o.getClass()) {
-      return false;
-    }
-    ApplyFunctionExpr that = (ApplyFunctionExpr) o;
-    return name.equals(that.name) &&
-           lambdaExpr.equals(that.lambdaExpr) &&
-           argsExpr.equals(that.argsExpr);
-  }
-
-  @Override
-  public int hashCode()
-  {
-    return Objects.hash(name, lambdaExpr, argsExpr);
-  }
-}
-
-/**
- * Base type for all single argument operators, with a single {@link Expr} child for the operand.
- */
-abstract class UnaryExpr implements Expr
-{
-  final Expr expr;
-
-  UnaryExpr(Expr expr)
-  {
-    this.expr = expr;
-  }
-
-  abstract UnaryExpr copy(Expr expr);
-
-  @Override
-  public void visit(Visitor visitor)
-  {
-    expr.visit(visitor);
-    visitor.visit(this);
-  }
-
-  @Override
-  public Expr visit(Shuttle shuttle)
-  {
-    Expr newExpr = expr.visit(shuttle);
-    //noinspection ObjectEquality (checking for object equality here is intentional)
-    if (newExpr != expr) {
-      return shuttle.visit(copy(newExpr));
-    }
-    return shuttle.visit(this);
-  }
-
-  @Override
-  public BindingDetails analyzeInputs()
-  {
-    // currently all unary operators only operate on scalar inputs
-    return expr.analyzeInputs().withScalarArguments(ImmutableSet.of(expr));
-  }
-
-  @Override
-  public boolean equals(Object o)
-  {
-    if (this == o) {
-      return true;
-    }
-    if (o == null || getClass() != o.getClass()) {
-      return false;
-    }
-    UnaryExpr unaryExpr = (UnaryExpr) o;
-    return Objects.equals(expr, unaryExpr.expr);
-  }
-
-  @Override
-  public int hashCode()
-  {
-    return Objects.hash(expr);
-  }
-}
-
-class UnaryMinusExpr extends UnaryExpr
-{
-  UnaryMinusExpr(Expr expr)
-  {
-    super(expr);
-  }
-
-  @Override
-  UnaryExpr copy(Expr expr)
-  {
-    return new UnaryMinusExpr(expr);
-  }
-
-  @Override
-  public ExprEval eval(ObjectBinding bindings)
-  {
-    ExprEval ret = expr.eval(bindings);
-    if (NullHandling.sqlCompatible() && (ret.value() == null)) {
-      return ExprEval.of(null);
-    }
-    if (ret.type() == ExprType.LONG) {
-      return ExprEval.of(-ret.asLong());
-    }
-    if (ret.type() == ExprType.DOUBLE) {
-      return ExprEval.of(-ret.asDouble());
-    }
-    throw new IAE("unsupported type " + ret.type());
-  }
-
-  @Override
-  public String stringify()
-  {
-    return StringUtils.format("-%s", expr.stringify());
-  }
-
-  @Override
-  public String toString()
-  {
-    return StringUtils.format("-%s", expr);
-  }
-}
-
-class UnaryNotExpr extends UnaryExpr
-{
-  UnaryNotExpr(Expr expr)
-  {
-    super(expr);
-  }
-
-  @Override
-  UnaryExpr copy(Expr expr)
-  {
-    return new UnaryNotExpr(expr);
-  }
-
-  @Override
-  public ExprEval eval(ObjectBinding bindings)
-  {
-    ExprEval ret = expr.eval(bindings);
-    if (NullHandling.sqlCompatible() && (ret.value() == null)) {
-      return ExprEval.of(null);
-    }
-    // conforming to other boolean-returning binary operators
-    ExprType retType = ret.type() == ExprType.DOUBLE ? ExprType.DOUBLE : ExprType.LONG;
-    return ExprEval.of(!ret.asBoolean(), retType);
-  }
-
-  @Override
-  public String stringify()
-  {
-    return StringUtils.format("!%s", expr.stringify());
-  }
-
-  @Override
-  public String toString()
-  {
-    return StringUtils.format("!%s", expr);
-  }
-}
-
-/**
- * Base type for all binary operators, this {@link Expr} has two children {@link Expr} for the left and right side
- * operands.
- *
- * Note: all concrete subclass of this should have constructor with the form of <init>(String, Expr, Expr)
- * if it's not possible, just be sure Evals.binaryOp() can handle that
- */
-abstract class BinaryOpExprBase implements Expr
-{
-  protected final String op;
-  protected final Expr left;
-  protected final Expr right;
-
-  BinaryOpExprBase(String op, Expr left, Expr right)
-  {
-    this.op = op;
-    this.left = left;
-    this.right = right;
-  }
-
-  @Override
-  public void visit(Visitor visitor)
-  {
-    left.visit(visitor);
-    right.visit(visitor);
-    visitor.visit(this);
-  }
-
-  @Override
-  public Expr visit(Shuttle shuttle)
-  {
-    Expr newLeft = left.visit(shuttle);
-    Expr newRight = right.visit(shuttle);
-    //noinspection ObjectEquality (checking for object equality here is intentional)
-    if (left != newLeft || right != newRight) {
-      return shuttle.visit(copy(newLeft, newRight));
-    }
-    return shuttle.visit(this);
-  }
-
-  @Override
-  public String toString()
-  {
-    return StringUtils.format("(%s %s %s)", op, left, right);
-  }
-
-  @Override
-  public String stringify()
-  {
-    return StringUtils.format("(%s %s %s)", left.stringify(), op, right.stringify());
-  }
-
-  protected abstract BinaryOpExprBase copy(Expr left, Expr right);
-
-  @Override
-  public BindingDetails analyzeInputs()
-  {
-    // currently all binary operators operate on scalar inputs
-    return left.analyzeInputs().with(right).withScalarArguments(ImmutableSet.of(left, right));
-  }
-
-  @Override
-  public boolean equals(Object o)
-  {
-    if (this == o) {
-      return true;
-    }
-    if (o == null || getClass() != o.getClass()) {
-      return false;
-    }
-    BinaryOpExprBase that = (BinaryOpExprBase) o;
-    return Objects.equals(op, that.op) &&
-           Objects.equals(left, that.left) &&
-           Objects.equals(right, that.right);
-  }
-
-  @Override
-  public int hashCode()
-  {
-    return Objects.hash(op, left, right);
-  }
-}
-
-/**
- * Base class for numerical binary operators, with additional methods defined to evaluate primitive values directly
- * instead of wrapped with {@link ExprEval}
- */
-abstract class BinaryEvalOpExprBase extends BinaryOpExprBase
-{
-  BinaryEvalOpExprBase(String op, Expr left, Expr right)
-  {
-    super(op, left, right);
-  }
-
-  @Override
-  public ExprEval eval(ObjectBinding bindings)
-  {
-    ExprEval leftVal = left.eval(bindings);
-    ExprEval rightVal = right.eval(bindings);
-
-    // Result of any Binary expressions is null if any of the argument is null.
-    // e.g "select null * 2 as c;" or "select null + 1 as c;" will return null as per Standard SQL spec.
-    if (NullHandling.sqlCompatible() && (leftVal.value() == null || rightVal.value() == null)) {
-      return ExprEval.of(null);
-    }
-
-    if (leftVal.type() == ExprType.STRING && rightVal.type() == ExprType.STRING) {
-      return evalString(leftVal.asString(), rightVal.asString());
-    } else if (leftVal.type() == ExprType.LONG && rightVal.type() == ExprType.LONG) {
-      if (NullHandling.sqlCompatible() && (leftVal.isNumericNull() || rightVal.isNumericNull())) {
-        return ExprEval.of(null);
-      }
-      return ExprEval.of(evalLong(leftVal.asLong(), rightVal.asLong()));
-    } else {
-      if (NullHandling.sqlCompatible() && (leftVal.isNumericNull() || rightVal.isNumericNull())) {
-        return ExprEval.of(null);
-      }
-      return ExprEval.of(evalDouble(leftVal.asDouble(), rightVal.asDouble()));
-    }
-  }
-
-  protected ExprEval evalString(@Nullable String left, @Nullable String right)
-  {
-    throw new IllegalArgumentException("unsupported type " + ExprType.STRING);
-  }
-
-  protected abstract long evalLong(long left, long right);
-
-  protected abstract double evalDouble(double left, double right);
-}
-
-class BinMinusExpr extends BinaryEvalOpExprBase
-{
-  BinMinusExpr(String op, Expr left, Expr right)
-  {
-    super(op, left, right);
-  }
-
-  @Override
-  protected BinaryOpExprBase copy(Expr left, Expr right)
-  {
-    return new BinMinusExpr(op, left, right);
-  }
-
-  @Override
-  protected final long evalLong(long left, long right)
-  {
-    return left - right;
-  }
-
-  @Override
-  protected final double evalDouble(double left, double right)
-  {
-    return left - right;
-  }
-}
-
-class BinPowExpr extends BinaryEvalOpExprBase
-{
-  BinPowExpr(String op, Expr left, Expr right)
-  {
-    super(op, left, right);
-  }
-
-  @Override
-  protected BinaryOpExprBase copy(Expr left, Expr right)
-  {
-    return new BinPowExpr(op, left, right);
-  }
-
-  @Override
-  protected final long evalLong(long left, long right)
-  {
-    return LongMath.pow(left, Ints.checkedCast(right));
-  }
-
-  @Override
-  protected final double evalDouble(double left, double right)
-  {
-    return Math.pow(left, right);
-  }
-}
-
-class BinMulExpr extends BinaryEvalOpExprBase
-{
-  BinMulExpr(String op, Expr left, Expr right)
-  {
-    super(op, left, right);
-  }
-
-  @Override
-  protected BinaryOpExprBase copy(Expr left, Expr right)
-  {
-    return new BinMulExpr(op, left, right);
-  }
-
-  @Override
-  protected final long evalLong(long left, long right)
-  {
-    return left * right;
-  }
-
-  @Override
-  protected final double evalDouble(double left, double right)
-  {
-    return left * right;
-  }
-}
-
-class BinDivExpr extends BinaryEvalOpExprBase
-{
-  BinDivExpr(String op, Expr left, Expr right)
-  {
-    super(op, left, right);
-  }
-
-  @Override
-  protected BinaryOpExprBase copy(Expr left, Expr right)
-  {
-    return new BinDivExpr(op, left, right);
-  }
-
-  @Override
-  protected final long evalLong(long left, long right)
-  {
-    return left / right;
-  }
-
-  @Override
-  protected final double evalDouble(double left, double right)
-  {
-    return left / right;
-  }
-}
-
-class BinModuloExpr extends BinaryEvalOpExprBase
-{
-  BinModuloExpr(String op, Expr left, Expr right)
-  {
-    super(op, left, right);
-  }
-
-  @Override
-  protected BinaryOpExprBase copy(Expr left, Expr right)
-  {
-    return new BinModuloExpr(op, left, right);
-  }
-
-  @Override
-  protected final long evalLong(long left, long right)
-  {
-    return left % right;
-  }
-
-  @Override
-  protected final double evalDouble(double left, double right)
-  {
-    return left % right;
-  }
-}
-
-class BinPlusExpr extends BinaryEvalOpExprBase
-{
-  BinPlusExpr(String op, Expr left, Expr right)
-  {
-    super(op, left, right);
-  }
-
-  @Override
-  protected BinaryOpExprBase copy(Expr left, Expr right)
-  {
-    return new BinPlusExpr(op, left, right);
-  }
-
-  @Override
-  protected ExprEval evalString(@Nullable String left, @Nullable String right)
-  {
-    return ExprEval.of(NullHandling.nullToEmptyIfNeeded(left)
-                       + NullHandling.nullToEmptyIfNeeded(right));
-  }
-
-  @Override
-  protected final long evalLong(long left, long right)
-  {
-    return left + right;
-  }
-
-  @Override
-  protected final double evalDouble(double left, double right)
-  {
-    return left + right;
-  }
-}
-
-class BinLtExpr extends BinaryEvalOpExprBase
-{
-  BinLtExpr(String op, Expr left, Expr right)
-  {
-    super(op, left, right);
-  }
-
-  @Override
-  protected BinaryOpExprBase copy(Expr left, Expr right)
-  {
-    return new BinLtExpr(op, left, right);
-  }
-
-  @Override
-  protected ExprEval evalString(@Nullable String left, @Nullable String right)
-  {
-    return ExprEval.of(Comparators.<String>naturalNullsFirst().compare(left, right) < 0, ExprType.LONG);
-  }
-
-  @Override
-  protected final long evalLong(long left, long right)
-  {
-    return Evals.asLong(left < right);
-  }
-
-  @Override
-  protected final double evalDouble(double left, double right)
-  {
-    // Use Double.compare for more consistent NaN handling.
-    return Evals.asDouble(Double.compare(left, right) < 0);
-  }
-}
-
-class BinLeqExpr extends BinaryEvalOpExprBase
-{
-  BinLeqExpr(String op, Expr left, Expr right)
-  {
-    super(op, left, right);
-  }
-
-  @Override
-  protected BinaryOpExprBase copy(Expr left, Expr right)
-  {
-    return new BinLeqExpr(op, left, right);
-  }
-
-  @Override
-  protected ExprEval evalString(@Nullable String left, @Nullable String right)
-  {
-    return ExprEval.of(Comparators.<String>naturalNullsFirst().compare(left, right) <= 0, ExprType.LONG);
-  }
-
-  @Override
-  protected final long evalLong(long left, long right)
-  {
-    return Evals.asLong(left <= right);
-  }
-
-  @Override
-  protected final double evalDouble(double left, double right)
-  {
-    // Use Double.compare for more consistent NaN handling.
-    return Evals.asDouble(Double.compare(left, right) <= 0);
-  }
-}
-
-class BinGtExpr extends BinaryEvalOpExprBase
-{
-  BinGtExpr(String op, Expr left, Expr right)
-  {
-    super(op, left, right);
-  }
-
-  @Override
-  protected BinaryOpExprBase copy(Expr left, Expr right)
-  {
-    return new BinGtExpr(op, left, right);
-  }
-
-  @Override
-  protected ExprEval evalString(@Nullable String left, @Nullable String right)
-  {
-    return ExprEval.of(Comparators.<String>naturalNullsFirst().compare(left, right) > 0, ExprType.LONG);
-  }
-
-  @Override
-  protected final long evalLong(long left, long right)
-  {
-    return Evals.asLong(left > right);
-  }
-
-  @Override
-  protected final double evalDouble(double left, double right)
-  {
-    // Use Double.compare for more consistent NaN handling.
-    return Evals.asDouble(Double.compare(left, right) > 0);
-  }
-}
-
-class BinGeqExpr extends BinaryEvalOpExprBase
-{
-  BinGeqExpr(String op, Expr left, Expr right)
-  {
-    super(op, left, right);
-  }
-
-  @Override
-  protected BinaryOpExprBase copy(Expr left, Expr right)
-  {
-    return new BinGeqExpr(op, left, right);
-  }
-
-  @Override
-  protected ExprEval evalString(@Nullable String left, @Nullable String right)
-  {
-    return ExprEval.of(Comparators.<String>naturalNullsFirst().compare(left, right) >= 0, ExprType.LONG);
-  }
-
-  @Override
-  protected final long evalLong(long left, long right)
-  {
-    return Evals.asLong(left >= right);
-  }
-
-  @Override
-  protected final double evalDouble(double left, double right)
-  {
-    // Use Double.compare for more consistent NaN handling.
-    return Evals.asDouble(Double.compare(left, right) >= 0);
-  }
-}
-
-class BinEqExpr extends BinaryEvalOpExprBase
-{
-  BinEqExpr(String op, Expr left, Expr right)
-  {
-    super(op, left, right);
-  }
-
-  @Override
-  protected BinaryOpExprBase copy(Expr left, Expr right)
-  {
-    return new BinEqExpr(op, left, right);
-  }
-
-  @Override
-  protected ExprEval evalString(@Nullable String left, @Nullable String right)
-  {
-    return ExprEval.of(Objects.equals(left, right), ExprType.LONG);
-  }
-
-  @Override
-  protected final long evalLong(long left, long right)
-  {
-    return Evals.asLong(left == right);
-  }
-
-  @Override
-  protected final double evalDouble(double left, double right)
-  {
-    return Evals.asDouble(left == right);
-  }
-}
-
-class BinNeqExpr extends BinaryEvalOpExprBase
-{
-  BinNeqExpr(String op, Expr left, Expr right)
-  {
-    super(op, left, right);
-  }
-
-  @Override
-  protected BinaryOpExprBase copy(Expr left, Expr right)
-  {
-    return new BinNeqExpr(op, left, right);
-  }
-
-  @Override
-  protected ExprEval evalString(@Nullable String left, @Nullable String right)
-  {
-    return ExprEval.of(!Objects.equals(left, right), ExprType.LONG);
-  }
-
-  @Override
-  protected final long evalLong(long left, long right)
-  {
-    return Evals.asLong(left != right);
-  }
-
-  @Override
-  protected final double evalDouble(double left, double right)
-  {
-    return Evals.asDouble(left != right);
-  }
-}
-
-class BinAndExpr extends BinaryOpExprBase
-{
-  BinAndExpr(String op, Expr left, Expr right)
-  {
-    super(op, left, right);
-  }
-
-  @Override
-  protected BinaryOpExprBase copy(Expr left, Expr right)
-  {
-    return new BinAndExpr(op, left, right);
-  }
-
-  @Override
-  public ExprEval eval(ObjectBinding bindings)
-  {
-    ExprEval leftVal = left.eval(bindings);
-    return leftVal.asBoolean() ? right.eval(bindings) : leftVal;
-  }
-}
-
-class BinOrExpr extends BinaryOpExprBase
-{
-  BinOrExpr(String op, Expr left, Expr right)
-  {
-    super(op, left, right);
-  }
-
-  @Override
-  protected BinaryOpExprBase copy(Expr left, Expr right)
-  {
-    return new BinOrExpr(op, left, right);
-  }
-
-  @Override
-  public ExprEval eval(ObjectBinding bindings)
-  {
-    ExprEval leftVal = left.eval(bindings);
-    return leftVal.asBoolean() ? leftVal : right.eval(bindings);
-  }
-
-}
-
diff --git a/core/src/main/java/org/apache/druid/math/expr/FunctionalExpr.java b/core/src/main/java/org/apache/druid/math/expr/FunctionalExpr.java
new file mode 100644
index 0000000..2b3474a
--- /dev/null
+++ b/core/src/main/java/org/apache/druid/math/expr/FunctionalExpr.java
@@ -0,0 +1,334 @@
+/*
+ * 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.math.expr;
+
+import com.google.common.base.Preconditions;
+import com.google.common.collect.ImmutableList;
+import org.apache.druid.java.util.common.StringUtils;
+
+import javax.annotation.Nullable;
+import java.util.List;
+import java.util.Objects;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+class LambdaExpr implements Expr
+{
+  private final ImmutableList<IdentifierExpr> args;
+  private final Expr expr;
+
+  LambdaExpr(List<IdentifierExpr> args, Expr expr)
+  {
+    this.args = ImmutableList.copyOf(args);
+    this.expr = expr;
+  }
+
+  @Override
+  public String toString()
+  {
+    return StringUtils.format("(%s -> %s)", args, expr);
+  }
+
+  int identifierCount()
+  {
+    return args.size();
+  }
+
+  @Nullable
+  public String getIdentifier()
+  {
+    Preconditions.checkState(args.size() < 2, "LambdaExpr has multiple arguments");
+    if (args.size() == 1) {
+      return args.get(0).toString();
+    }
+    return null;
+  }
+
+  public List<String> getIdentifiers()
+  {
+    return args.stream().map(IdentifierExpr::toString).collect(Collectors.toList());
+  }
+
+  ImmutableList<IdentifierExpr> getIdentifierExprs()
+  {
+    return args;
+  }
+
+  public Expr getExpr()
+  {
+    return expr;
+  }
+
+  @Override
+  public ExprEval eval(ObjectBinding bindings)
+  {
+    return expr.eval(bindings);
+  }
+
+  @Override
+  public String stringify()
+  {
+    return StringUtils.format("(%s) -> %s", ARG_JOINER.join(getIdentifiers()), expr.stringify());
+  }
+
+  @Override
+  public void visit(Visitor visitor)
+  {
+    expr.visit(visitor);
+    visitor.visit(this);
+  }
+
+  @Override
+  public Expr visit(Shuttle shuttle)
+  {
+    List<IdentifierExpr> newArgs =
+        args.stream().map(arg -> (IdentifierExpr) shuttle.visit(arg)).collect(Collectors.toList());
+    Expr newBody = expr.visit(shuttle);
+    return shuttle.visit(new LambdaExpr(newArgs, newBody));
+  }
+
+  @Override
+  public BindingDetails analyzeInputs()
+  {
+    final Set<String> lambdaArgs = args.stream().map(IdentifierExpr::toString).collect(Collectors.toSet());
+    BindingDetails bodyDetails = expr.analyzeInputs();
+    return bodyDetails.removeLambdaArguments(lambdaArgs);
+  }
+
+  @Override
+  public boolean equals(Object o)
+  {
+    if (this == o) {
+      return true;
+    }
+    if (o == null || getClass() != o.getClass()) {
+      return false;
+    }
+    LambdaExpr that = (LambdaExpr) o;
+    return Objects.equals(args, that.args) &&
+           Objects.equals(expr, that.expr);
+  }
+
+  @Override
+  public int hashCode()
+  {
+    return Objects.hash(args, expr);
+  }
+}
+
+/**
+ * {@link Expr} node for a {@link Function} call. {@link FunctionExpr} has children {@link Expr} in the form of the
+ * list of arguments that are passed to the {@link Function} along with the {@link Expr.ObjectBinding} when it is
+ * evaluated.
+ */
+class FunctionExpr implements Expr
+{
+  final Function function;
+  final ImmutableList<Expr> args;
+  private final String name;
+
+  FunctionExpr(Function function, String name, List<Expr> args)
+  {
+    this.function = function;
+    this.name = name;
+    this.args = ImmutableList.copyOf(args);
+    function.validateArguments(args);
+  }
+
+  @Override
+  public String toString()
+  {
+    return StringUtils.format("(%s %s)", name, args);
+  }
+
+  @Override
+  public ExprEval eval(ObjectBinding bindings)
+  {
+    return function.apply(args, bindings);
+  }
+
+  @Override
+  public String stringify()
+  {
+    return StringUtils.format("%s(%s)", name, ARG_JOINER.join(args.stream().map(Expr::stringify).iterator()));
+  }
+
+  @Override
+  public void visit(Visitor visitor)
+  {
+    for (Expr child : args) {
+      child.visit(visitor);
+    }
+    visitor.visit(this);
+  }
+
+  @Override
+  public Expr visit(Shuttle shuttle)
+  {
+    List<Expr> newArgs = args.stream().map(shuttle::visit).collect(Collectors.toList());
+    return shuttle.visit(new FunctionExpr(function, name, newArgs));
+  }
+
+  @Override
+  public BindingDetails analyzeInputs()
+  {
+    BindingDetails accumulator = new BindingDetails();
+
+    for (Expr arg : args) {
+      accumulator = accumulator.with(arg);
+    }
+    return accumulator.withScalarArguments(function.getScalarInputs(args))
+                      .withArrayArguments(function.getArrayInputs(args))
+                      .withArrayInputs(function.hasArrayInputs())
+                      .withArrayOutput(function.hasArrayOutput());
+  }
+
+  @Override
+  public boolean equals(Object o)
+  {
+    if (this == o) {
+      return true;
+    }
+    if (o == null || getClass() != o.getClass()) {
+      return false;
+    }
+    FunctionExpr that = (FunctionExpr) o;
+    return args.equals(that.args) &&
+           name.equals(that.name);
+  }
+
+  @Override
+  public int hashCode()
+  {
+    return Objects.hash(args, name);
+  }
+}
+
+/**
+ * This {@link Expr} node is representative of an {@link ApplyFunction}, and has children in the form of a
+ * {@link LambdaExpr} and the list of {@link Expr} arguments that are combined with {@link Expr.ObjectBinding} to
+ * evaluate the {@link LambdaExpr}.
+ */
+class ApplyFunctionExpr implements Expr
+{
+  final ApplyFunction function;
+  final String name;
+  final LambdaExpr lambdaExpr;
+  final ImmutableList<Expr> argsExpr;
+  final BindingDetails bindingDetails;
+  final BindingDetails lambdaBindingDetails;
+  final ImmutableList<BindingDetails> argsBindingDetails;
+
+  ApplyFunctionExpr(ApplyFunction function, String name, LambdaExpr expr, List<Expr> args)
+  {
+    this.function = function;
+    this.name = name;
+    this.argsExpr = ImmutableList.copyOf(args);
+    this.lambdaExpr = expr;
+
+    function.validateArguments(expr, args);
+
+    // apply function expressions are examined during expression selector creation, so precompute and cache the
+    // binding details of children
+    ImmutableList.Builder<BindingDetails> argBindingDetailsBuilder = ImmutableList.builder();
+    BindingDetails accumulator = new BindingDetails();
+    for (Expr arg : argsExpr) {
+      BindingDetails argDetails = arg.analyzeInputs();
+      argBindingDetailsBuilder.add(argDetails);
+      accumulator = accumulator.with(argDetails);
+    }
+
+    lambdaBindingDetails = lambdaExpr.analyzeInputs();
+
+    bindingDetails = accumulator.with(lambdaBindingDetails)
+                                .withArrayArguments(function.getArrayInputs(argsExpr))
+                                .withArrayInputs(true)
+                                .withArrayOutput(function.hasArrayOutput(lambdaExpr));
+    argsBindingDetails = argBindingDetailsBuilder.build();
+  }
+
+  @Override
+  public String toString()
+  {
+    return StringUtils.format("(%s %s, %s)", name, lambdaExpr, argsExpr);
+  }
+
+  @Override
+  public ExprEval eval(ObjectBinding bindings)
+  {
+    return function.apply(lambdaExpr, argsExpr, bindings);
+  }
+
+  @Override
+  public String stringify()
+  {
+    return StringUtils.format(
+        "%s(%s, %s)",
+        name,
+        lambdaExpr.stringify(),
+        ARG_JOINER.join(argsExpr.stream().map(Expr::stringify).iterator())
+    );
+  }
+
+  @Override
+  public void visit(Visitor visitor)
+  {
+    lambdaExpr.visit(visitor);
+    for (Expr arg : argsExpr) {
+      arg.visit(visitor);
+    }
+    visitor.visit(this);
+  }
+
+  @Override
+  public Expr visit(Shuttle shuttle)
+  {
+    LambdaExpr newLambda = (LambdaExpr) lambdaExpr.visit(shuttle);
+    List<Expr> newArgs = argsExpr.stream().map(shuttle::visit).collect(Collectors.toList());
+    return shuttle.visit(new ApplyFunctionExpr(function, name, newLambda, newArgs));
+  }
+
+  @Override
+  public BindingDetails analyzeInputs()
+  {
+    return bindingDetails;
+  }
+
+  @Override
+  public boolean equals(Object o)
+  {
+    if (this == o) {
+      return true;
+    }
+    if (o == null || getClass() != o.getClass()) {
+      return false;
+    }
+    ApplyFunctionExpr that = (ApplyFunctionExpr) o;
+    return name.equals(that.name) &&
+           lambdaExpr.equals(that.lambdaExpr) &&
+           argsExpr.equals(that.argsExpr);
+  }
+
+  @Override
+  public int hashCode()
+  {
+    return Objects.hash(name, lambdaExpr, argsExpr);
+  }
+}
diff --git a/core/src/main/java/org/apache/druid/math/expr/IdentifierExpr.java b/core/src/main/java/org/apache/druid/math/expr/IdentifierExpr.java
new file mode 100644
index 0000000..d23657a
--- /dev/null
+++ b/core/src/main/java/org/apache/druid/math/expr/IdentifierExpr.java
@@ -0,0 +1,153 @@
+/*
+ * 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.math.expr;
+
+import org.apache.commons.lang.StringEscapeUtils;
+import org.apache.druid.java.util.common.StringUtils;
+
+import javax.annotation.Nullable;
+import java.util.Objects;
+
+/**
+ * This {@link Expr} node is used to represent a variable in the expression language. At evaluation time, the string
+ * identifier will be used to retrieve the runtime value for the variable from {@link Expr.ObjectBinding}.
+ * {@link IdentifierExpr} are terminal nodes of an expression tree, and have no children {@link Expr}.
+ */
+class IdentifierExpr implements Expr
+{
+  private final String identifier;
+  private final String binding;
+
+  /**
+   * Construct a identifier expression for a {@link LambdaExpr}, where the {@link #identifier} is equal to
+   * {@link #binding}
+   */
+  IdentifierExpr(String value)
+  {
+    this.identifier = value;
+    this.binding = value;
+  }
+
+  /**
+   * Construct a normal identifier expression, where {@link #binding} is the key to fetch the backing value from
+   * {@link Expr.ObjectBinding} and the {@link #identifier} is a unique string that identifies this usage of the
+   * binding.
+   */
+  IdentifierExpr(String identifier, String binding)
+  {
+    this.identifier = identifier;
+    this.binding = binding;
+  }
+
+  @Override
+  public String toString()
+  {
+    return binding;
+  }
+
+  /**
+   * Unique identifier for the binding
+   */
+  @Nullable
+  public String getIdentifier()
+  {
+    return identifier;
+  }
+
+  /**
+   * Value binding, key to retrieve value from {@link Expr.ObjectBinding#get(String)}
+   */
+  @Nullable
+  public String getBinding()
+  {
+    return binding;
+  }
+
+  @Nullable
+  @Override
+  public String getIdentifierIfIdentifier()
+  {
+    return identifier;
+  }
+
+  @Nullable
+  @Override
+  public String getBindingIfIdentifier()
+  {
+    return binding;
+  }
+
+  @Nullable
+  @Override
+  public IdentifierExpr getIdentifierExprIfIdentifierExpr()
+  {
+    return this;
+  }
+
+  @Override
+  public BindingDetails analyzeInputs()
+  {
+    return new BindingDetails(this);
+  }
+
+  @Override
+  public ExprEval eval(ObjectBinding bindings)
+  {
+    return ExprEval.bestEffortOf(bindings.get(binding));
+  }
+
+  @Override
+  public String stringify()
+  {
+    // escape as java strings since identifiers are wrapped in double quotes
+    return StringUtils.format("\"%s\"", StringEscapeUtils.escapeJava(binding));
+  }
+
+  @Override
+  public void visit(Visitor visitor)
+  {
+    visitor.visit(this);
+  }
+
+  @Override
+  public Expr visit(Shuttle shuttle)
+  {
+    return shuttle.visit(this);
+  }
+
+  @Override
+  public boolean equals(Object o)
+  {
+    if (this == o) {
+      return true;
+    }
+    if (o == null || getClass() != o.getClass()) {
+      return false;
+    }
+    IdentifierExpr that = (IdentifierExpr) o;
+    return Objects.equals(identifier, that.identifier);
+  }
+
+  @Override
+  public int hashCode()
+  {
+    return Objects.hash(identifier);
+  }
+}
diff --git a/core/src/main/java/org/apache/druid/math/expr/UnaryOperatorExpr.java b/core/src/main/java/org/apache/druid/math/expr/UnaryOperatorExpr.java
new file mode 100644
index 0000000..5a41e90
--- /dev/null
+++ b/core/src/main/java/org/apache/druid/math/expr/UnaryOperatorExpr.java
@@ -0,0 +1,166 @@
+/*
+ * 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.math.expr;
+
+import com.google.common.collect.ImmutableSet;
+import org.apache.druid.common.config.NullHandling;
+import org.apache.druid.java.util.common.IAE;
+import org.apache.druid.java.util.common.StringUtils;
+
+import java.util.Objects;
+
+/**
+ * Base type for all single argument operators, with a single {@link Expr} child for the operand.
+ */
+abstract class UnaryExpr implements Expr
+{
+  final Expr expr;
+
+  UnaryExpr(Expr expr)
+  {
+    this.expr = expr;
+  }
+
+  abstract UnaryExpr copy(Expr expr);
+
+  @Override
+  public void visit(Visitor visitor)
+  {
+    expr.visit(visitor);
+    visitor.visit(this);
+  }
+
+  @Override
+  public Expr visit(Shuttle shuttle)
+  {
+    Expr newExpr = expr.visit(shuttle);
+    //noinspection ObjectEquality (checking for object equality here is intentional)
+    if (newExpr != expr) {
+      return shuttle.visit(copy(newExpr));
+    }
+    return shuttle.visit(this);
+  }
+
+  @Override
+  public BindingDetails analyzeInputs()
+  {
+    // currently all unary operators only operate on scalar inputs
+    return expr.analyzeInputs().withScalarArguments(ImmutableSet.of(expr));
+  }
+
+  @Override
+  public boolean equals(Object o)
+  {
+    if (this == o) {
+      return true;
+    }
+    if (o == null || getClass() != o.getClass()) {
+      return false;
+    }
+    UnaryExpr unaryExpr = (UnaryExpr) o;
+    return Objects.equals(expr, unaryExpr.expr);
+  }
+
+  @Override
+  public int hashCode()
+  {
+    return Objects.hash(expr);
+  }
+}
+
+class UnaryMinusExpr extends UnaryExpr
+{
+  UnaryMinusExpr(Expr expr)
+  {
+    super(expr);
+  }
+
+  @Override
+  UnaryExpr copy(Expr expr)
+  {
+    return new UnaryMinusExpr(expr);
+  }
+
+  @Override
+  public ExprEval eval(ObjectBinding bindings)
+  {
+    ExprEval ret = expr.eval(bindings);
+    if (NullHandling.sqlCompatible() && (ret.value() == null)) {
+      return ExprEval.of(null);
+    }
+    if (ret.type() == ExprType.LONG) {
+      return ExprEval.of(-ret.asLong());
+    }
+    if (ret.type() == ExprType.DOUBLE) {
+      return ExprEval.of(-ret.asDouble());
+    }
+    throw new IAE("unsupported type " + ret.type());
+  }
+
+  @Override
+  public String stringify()
+  {
+    return StringUtils.format("-%s", expr.stringify());
+  }
+
+  @Override
+  public String toString()
+  {
+    return StringUtils.format("-%s", expr);
+  }
+}
+
+class UnaryNotExpr extends UnaryExpr
+{
+  UnaryNotExpr(Expr expr)
+  {
+    super(expr);
+  }
+
+  @Override
+  UnaryExpr copy(Expr expr)
+  {
+    return new UnaryNotExpr(expr);
+  }
+
+  @Override
+  public ExprEval eval(ObjectBinding bindings)
+  {
+    ExprEval ret = expr.eval(bindings);
+    if (NullHandling.sqlCompatible() && (ret.value() == null)) {
+      return ExprEval.of(null);
+    }
+    // conforming to other boolean-returning binary operators
+    ExprType retType = ret.type() == ExprType.DOUBLE ? ExprType.DOUBLE : ExprType.LONG;
+    return ExprEval.of(!ret.asBoolean(), retType);
+  }
+
+  @Override
+  public String stringify()
+  {
+    return StringUtils.format("!%s", expr.stringify());
+  }
+
+  @Override
+  public String toString()
+  {
+    return StringUtils.format("!%s", expr);
+  }
+}