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

tkalkirill pushed a commit to branch ignite-29016
in repository https://gitbox.apache.org/repos/asf/ignite.git

commit 506527be4b594fa3c3db742e3d12eeceb2329fd5
Author: Kirill Tkalenko <[email protected]>
AuthorDate: Wed Aug 26 21:17:29 2026 +0300

    IGNITE-29016 Wip
---
 .../query/calcite/exec/LogicalRelImplementor.java  |   4 +-
 .../query/calcite/exec/TableFunctionScan.java      |  43 +++++-
 .../calcite/exec/exp/ExpressionFactoryImpl.java    |   9 +-
 .../query/calcite/exec/exp/IgniteRexBuilder.java   |  52 +++++++
 .../query/calcite/exec/exp/IgniteSqlFunctions.java |  45 ++++++
 .../query/calcite/exec/exp/RexExecutorImpl.java    |   2 +-
 .../query/calcite/exec/exp/RexImpTable.java        |  50 +++++-
 .../query/calcite/exec/exp/RexToLixTranslator.java |  67 +++++++-
 .../query/calcite/prepare/BaseQueryContext.java    |   8 +-
 .../query/calcite/prepare/IgniteSqlSemantics.java  |  38 +++++
 .../query/calcite/prepare/IgniteSqlValidator.java  |  20 ++-
 .../calcite/sql/fun/IgniteOwnSqlOperatorTable.java |  17 ++
 .../query/calcite/util/IgniteMethod.java           |  15 ++
 .../calcite/exec/exp/RexToLixTranslatorTest.java   |  89 +++++++++++
 .../EmptyStringIsNullIntegrationTest.java          | 171 +++++++++++++++++++++
 .../query/calcite/integration/FunctionsTest.java   |   7 +
 .../ignite/testsuites/IntegrationTestSuite.java    |   2 +
 17 files changed, 616 insertions(+), 23 deletions(-)

diff --git 
a/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/exec/LogicalRelImplementor.java
 
b/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/exec/LogicalRelImplementor.java
index ed04f327eb3..5e06556716a 100644
--- 
a/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/exec/LogicalRelImplementor.java
+++ 
b/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/exec/LogicalRelImplementor.java
@@ -768,7 +768,9 @@ public class LogicalRelImplementor<Row> implements 
IgniteRelVisitor<Node<Row>> {
 
         RowFactory<Row> rowFactory = 
ctx.rowHandler().factory(ctx.getTypeFactory(), rowType);
 
-        return new ScanNode<>(ctx, rowType, new TableFunctionScan<>(rowType, 
dataSupplier, rowFactory));
+        boolean emptyStrIsNull = 
IgniteSqlSemantics.emptyStringIsNull(ctx.unwrap(IgniteSqlSemantics.class));
+
+        return new ScanNode<>(ctx, rowType, new TableFunctionScan<>(rowType, 
dataSupplier, rowFactory, emptyStrIsNull));
     }
 
     /** {@inheritDoc} */
diff --git 
a/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/exec/TableFunctionScan.java
 
b/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/exec/TableFunctionScan.java
index b29f91d6a7f..ac0eddbcba1 100644
--- 
a/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/exec/TableFunctionScan.java
+++ 
b/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/exec/TableFunctionScan.java
@@ -17,13 +17,16 @@
 
 package org.apache.ignite.internal.processors.query.calcite.exec;
 
+import java.util.BitSet;
 import java.util.Collection;
 import java.util.Iterator;
 import java.util.function.Supplier;
 import org.apache.calcite.rel.type.RelDataType;
+import org.apache.calcite.sql.type.SqlTypeUtil;
 import org.apache.ignite.internal.processors.query.IgniteSQLException;
 import 
org.apache.ignite.internal.processors.query.calcite.exec.RowHandler.RowFactory;
 import org.apache.ignite.internal.util.typedef.F;
+import org.jetbrains.annotations.Nullable;
 
 /** */
 public class TableFunctionScan<Row> implements Iterable<Row> {
@@ -36,15 +39,30 @@ public class TableFunctionScan<Row> implements 
Iterable<Row> {
     /** */
     private final RowFactory<Row> rowFactory;
 
+    /** */
+    private final @Nullable BitSet strCols;
+
     /** */
     public TableFunctionScan(
         RelDataType rowType,
         Supplier<Iterable<?>> dataSupplier,
-        RowFactory<Row> rowFactory
+        RowFactory<Row> rowFactory,
+        boolean emptyStringIsNull
     ) {
         this.rowType = rowType;
         this.dataSupplier = dataSupplier;
         this.rowFactory = rowFactory;
+
+        if (emptyStringIsNull) {
+            strCols = new BitSet(rowType.getFieldCount());
+
+            for (int i = 0; i < rowType.getFieldCount(); i++) {
+                if 
(SqlTypeUtil.isCharacter(rowType.getFieldList().get(i).getType()))
+                    strCols.set(i);
+            }
+        }
+        else
+            strCols = null;
     }
 
     /** {@inheritDoc} */
@@ -66,6 +84,27 @@ public class TableFunctionScan<Row> implements Iterable<Row> 
{
                 + "] doesn't match defined columns number [" + 
rowType.getFieldCount() + "].");
         }
 
-        return rowFactory.create(rowArr);
+        return rowFactory.create(nullIfEmpty(rowArr));
+    }
+
+    /** Converts empty strings returned for string columns to {@code null}. */
+    private Object[] nullIfEmpty(Object[] row) {
+        if (strCols == null)
+            return row;
+
+        Object[] res = row;
+
+        for (int i = strCols.nextSetBit(0); i >= 0; i = strCols.nextSetBit(i + 
1)) {
+            Object val = row[i];
+
+            if (val instanceof String && ((String)val).isEmpty()) {
+                if (res == row)
+                    res = row.clone();
+
+                res[i] = null;
+            }
+        }
+
+        return res;
     }
 }
diff --git 
a/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/exec/exp/ExpressionFactoryImpl.java
 
b/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/exec/exp/ExpressionFactoryImpl.java
index 4af8a6bda03..55f45763f4b 100644
--- 
a/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/exec/exp/ExpressionFactoryImpl.java
+++ 
b/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/exec/exp/ExpressionFactoryImpl.java
@@ -72,6 +72,7 @@ import 
org.apache.ignite.internal.processors.query.calcite.exec.exp.RexToLixTran
 import 
org.apache.ignite.internal.processors.query.calcite.exec.exp.agg.AccumulatorWrapper;
 import 
org.apache.ignite.internal.processors.query.calcite.exec.exp.agg.AccumulatorsFactory;
 import 
org.apache.ignite.internal.processors.query.calcite.exec.exp.agg.AggregateType;
+import 
org.apache.ignite.internal.processors.query.calcite.prepare.IgniteSqlSemantics;
 import 
org.apache.ignite.internal.processors.query.calcite.prepare.bounds.ExactBounds;
 import 
org.apache.ignite.internal.processors.query.calcite.prepare.bounds.MultiBounds;
 import 
org.apache.ignite.internal.processors.query.calcite.prepare.bounds.RangeBounds;
@@ -107,6 +108,9 @@ public class ExpressionFactoryImpl<Row> implements 
ExpressionFactory<Row> {
     /** */
     private final RexBuilder rexBuilder;
 
+    /** */
+    private final boolean emptyStrIsNull;
+
     /** */
     private static final RelDataType EMPTY_TYPE = new 
RelDataTypeFactory.Builder(Commons.typeFactory()).build();
 
@@ -130,6 +134,8 @@ public class ExpressionFactoryImpl<Row> implements 
ExpressionFactory<Row> {
         this.typeFactory = typeFactory;
         this.conformance = conformance;
         this.rexBuilder = rexBuilder;
+
+        emptyStrIsNull = 
IgniteSqlSemantics.emptyStringIsNull(ctx.unwrap(IgniteSqlSemantics.class));
     }
 
     /** {@inheritDoc} */
@@ -549,7 +555,7 @@ public class ExpressionFactoryImpl<Row> implements 
ExpressionFactory<Row> {
         Function1<String, InputGetter> correlates = new 
CorrelatesBuilder(builder, ctx_, hnd_).build(nodes);
 
         List<Expression> projects = 
RexToLixTranslator.translateProjects(program, typeFactory, conformance,
-            builder, null, ctx_, inputGetter, correlates);
+            builder, null, ctx_, inputGetter, correlates, emptyStrIsNull);
 
         assert nodes.size() == projects.size();
 
@@ -618,6 +624,7 @@ public class ExpressionFactoryImpl<Row> implements 
ExpressionFactory<Row> {
         }
 
         b.append(", biParam=").append(biParam);
+        b.append(", emptyStrIsNull=").append(emptyStrIsNull);
 
         b.append(']');
 
diff --git 
a/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/exec/exp/IgniteRexBuilder.java
 
b/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/exec/exp/IgniteRexBuilder.java
index d4e2debbcad..13023cd5fc5 100644
--- 
a/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/exec/exp/IgniteRexBuilder.java
+++ 
b/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/exec/exp/IgniteRexBuilder.java
@@ -19,21 +19,40 @@ package 
org.apache.ignite.internal.processors.query.calcite.exec.exp;
 
 import java.math.BigDecimal;
 import java.math.RoundingMode;
+import java.util.List;
 import org.apache.calcite.rel.type.RelDataType;
 import org.apache.calcite.rel.type.RelDataTypeFactory;
 import org.apache.calcite.rex.RexBuilder;
 import org.apache.calcite.rex.RexLiteral;
+import org.apache.calcite.rex.RexNode;
+import org.apache.calcite.sql.SqlKind;
+import org.apache.calcite.sql.SqlOperator;
+import org.apache.calcite.sql.SqlUtil;
+import org.apache.calcite.sql.parser.SqlParserPos;
 import org.apache.calcite.sql.type.SqlTypeName;
 import org.apache.calcite.sql.type.SqlTypeUtil;
+import org.apache.calcite.util.NlsString;
 import org.apache.ignite.internal.processors.query.IgniteSQLException;
 import org.apache.ignite.internal.processors.query.calcite.util.TypeUtils;
 import org.jetbrains.annotations.Nullable;
 
+import static 
org.apache.ignite.internal.processors.query.calcite.sql.fun.IgniteOwnSqlOperatorTable.NULL_IF_EMPTY;
+
 /** */
 public class IgniteRexBuilder extends RexBuilder {
+    /** */
+    private final boolean emptyStrIsNull;
+
     /** */
     public IgniteRexBuilder(RelDataTypeFactory typeFactory) {
+        this(typeFactory, false);
+    }
+
+    /** */
+    public IgniteRexBuilder(RelDataTypeFactory typeFactory, boolean 
emptyStrIsNull) {
         super(typeFactory);
+
+        this.emptyStrIsNull = emptyStrIsNull;
     }
 
     /** {@inheritDoc} */
@@ -56,4 +75,37 @@ public class IgniteRexBuilder extends RexBuilder {
 
         return super.makeLiteral(o, type, typeName);
     }
+
+    /** {@inheritDoc} */
+    @Override public RexNode makeCall(SqlParserPos pos, RelDataType type, 
SqlOperator op, List<RexNode> exprs) {
+        return nullIfEmptyResult(pos, super.makeCall(pos, type, op, exprs), 
op);
+    }
+
+    /** {@inheritDoc} */
+    @Override public RexNode makeCall(SqlParserPos pos, SqlOperator op, List<? 
extends RexNode> exprs) {
+        return nullIfEmptyResult(pos, super.makeCall(pos, op, exprs), op);
+    }
+
+    /** {@inheritDoc} */
+    @Override public RexLiteral makeCharLiteral(NlsString str) {
+        // VALUES conversion can retain the original character literal after 
validation.
+        if (emptyStrIsNull && str.getValue().isEmpty())
+            return 
makeNullLiteral(SqlUtil.createNlsStringType(getTypeFactory(), str));
+
+        return super.makeCharLiteral(str);
+    }
+
+    /** Wraps a string expression so an empty result is represented as {@code 
null}. */
+    private RexNode nullIfEmptyResult(SqlParserPos pos, RexNode call, 
SqlOperator op) {
+        if (!emptyStrIsNull || op == NULL_IF_EMPTY || op.getKind() == 
SqlKind.AS || op.getKind() == SqlKind.CAST
+            || op.getKind() == SqlKind.DESCENDING || op.getKind() == 
SqlKind.NULLS_FIRST
+            || op.getKind() == SqlKind.NULLS_LAST
+            || !SqlTypeUtil.isCharacter(call.getType())) {
+            return call;
+        }
+
+        RelDataType type = 
getTypeFactory().createTypeWithNullability(call.getType(), true);
+
+        return super.makeCall(pos, type, NULL_IF_EMPTY, List.of(call));
+    }
 }
diff --git 
a/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/exec/exp/IgniteSqlFunctions.java
 
b/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/exec/exp/IgniteSqlFunctions.java
index be9dc99330d..9ea898f563a 100644
--- 
a/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/exec/exp/IgniteSqlFunctions.java
+++ 
b/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/exec/exp/IgniteSqlFunctions.java
@@ -51,6 +51,9 @@ public class IgniteSqlFunctions {
     /** */
     private static final int DFLT_NUM_PRECISION = 
IgniteTypeSystem.INSTANCE.getDefaultPrecision(SqlTypeName.DECIMAL);
 
+    /** */
+    private static final SqlFunctions.PosixRegexFunction POSIX_REGEX = new 
SqlFunctions.PosixRegexFunction();
+
     /**
      * Default constructor.
      */
@@ -342,4 +345,46 @@ public class IgniteSqlFunctions {
 
         return SqlFunctions.neAny(a, b);
     }
+
+    /** Converts an empty string value to {@code null}. */
+    public static @Nullable String nullIfEmpty(@Nullable String s) {
+        return s == null || s.isEmpty() ? null : s;
+    }
+
+    /** Case-sensitive POSIX regular expression match. */
+    public static @Nullable Boolean posixRegexCaseSensitive(@Nullable String 
s, @Nullable String regex) {
+        return posixRegex(s, regex, true, false);
+    }
+
+    /** Case-insensitive POSIX regular expression match. */
+    public static @Nullable Boolean posixRegexCaseInsensitive(@Nullable String 
s, @Nullable String regex) {
+        return posixRegex(s, regex, false, false);
+    }
+
+    /** Negated case-sensitive POSIX regular expression match. */
+    public static @Nullable Boolean negatedPosixRegexCaseSensitive(@Nullable 
String s, @Nullable String regex) {
+        return posixRegex(s, regex, true, true);
+    }
+
+    /** Negated case-insensitive POSIX regular expression match. */
+    public static @Nullable Boolean negatedPosixRegexCaseInsensitive(@Nullable 
String s, @Nullable String regex) {
+        return posixRegex(s, regex, false, true);
+    }
+
+    /**
+     * POSIX regular expression match.
+     *
+     * <p>The pattern is evaluated even when the source is {@code null}. This 
preserves an invalid-pattern error while
+     * the result of a valid match with a null operand remains {@code 
null}.</p>
+     */
+    private static @Nullable Boolean posixRegex(@Nullable String s, @Nullable 
String regex, boolean caseSensitive, boolean negate) {
+        if (regex == null)
+            return null;
+
+        boolean matches = caseSensitive
+            ? POSIX_REGEX.posixRegexSensitive(s == null ? "" : s, regex)
+            : POSIX_REGEX.posixRegexInsensitive(s == null ? "" : s, regex);
+
+        return s == null ? null : matches != negate;
+    }
 }
diff --git 
a/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/exec/exp/RexExecutorImpl.java
 
b/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/exec/exp/RexExecutorImpl.java
index eb1297bcc59..3cf8f81abda 100644
--- 
a/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/exec/exp/RexExecutorImpl.java
+++ 
b/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/exec/exp/RexExecutorImpl.java
@@ -103,7 +103,7 @@ public class RexExecutorImpl implements RexExecutor {
         final RexProgram program = programBuilder.getProgram();
         final List<Expression> expressions =
             RexToLixTranslator.translateProjects(program, javaTypeFactory,
-                conformance, blockBuilder, null, root_, getter, null);
+                conformance, blockBuilder, null, root_, getter, null, false);
         blockBuilder.add(
             Expressions.return_(null,
                 Expressions.newArrayInit(Object[].class, expressions)));
diff --git 
a/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/exec/exp/RexImpTable.java
 
b/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/exec/exp/RexImpTable.java
index e3e3ce5be27..b0aec4fa822 100644
--- 
a/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/exec/exp/RexImpTable.java
+++ 
b/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/exec/exp/RexImpTable.java
@@ -266,6 +266,7 @@ import static 
org.apache.ignite.internal.processors.query.calcite.sql.fun.Ignite
 import static 
org.apache.ignite.internal.processors.query.calcite.sql.fun.IgniteOwnSqlOperatorTable.GREATEST2;
 import static 
org.apache.ignite.internal.processors.query.calcite.sql.fun.IgniteOwnSqlOperatorTable.LEAST2;
 import static 
org.apache.ignite.internal.processors.query.calcite.sql.fun.IgniteOwnSqlOperatorTable.NULL_BOUND;
+import static 
org.apache.ignite.internal.processors.query.calcite.sql.fun.IgniteOwnSqlOperatorTable.NULL_IF_EMPTY;
 import static 
org.apache.ignite.internal.processors.query.calcite.sql.fun.IgniteOwnSqlOperatorTable.QUERY_ENGINE;
 import static 
org.apache.ignite.internal.processors.query.calcite.sql.fun.IgniteOwnSqlOperatorTable.SYSTEM_RANGE;
 import static 
org.apache.ignite.internal.processors.query.calcite.sql.fun.IgniteOwnSqlOperatorTable.TYPEOF;
@@ -324,6 +325,7 @@ public class RexImpTable {
         defineMethod(SOUNDEX, BuiltInMethod.SOUNDEX.method, NullPolicy.STRICT);
         defineMethod(DIFFERENCE, BuiltInMethod.DIFFERENCE.method, 
NullPolicy.STRICT);
         defineMethod(REVERSE, BuiltInMethod.REVERSE.method, NullPolicy.STRICT);
+        defineMethod(NULL_IF_EMPTY, IgniteMethod.NULL_IF_EMPTY.method(), 
NullPolicy.NONE);
 
         map.put(TRIM, new TrimImplementor());
 
@@ -455,16 +457,21 @@ public class RexImpTable {
             BuiltInMethod.SIMILAR_ESCAPE.method);
 
         // POSIX REGEX
-        ReflectiveImplementor insensitiveImplementor =
-            defineReflective(POSIX_REGEX_CASE_INSENSITIVE,
-                BuiltInMethod.POSIX_REGEX_INSENSITIVE.method);
-        ReflectiveImplementor sensitiveImplementor =
-            defineReflective(POSIX_REGEX_CASE_SENSITIVE,
-                BuiltInMethod.POSIX_REGEX_SENSITIVE.method);
+        AbstractRexCallImplementor insensitiveImplementor =
+            new 
ReflectiveImplementor(ImmutableList.of(BuiltInMethod.POSIX_REGEX_INSENSITIVE.method));
+        AbstractRexCallImplementor sensitiveImplementor =
+            new 
ReflectiveImplementor(ImmutableList.of(BuiltInMethod.POSIX_REGEX_SENSITIVE.method));
+
+        map.put(POSIX_REGEX_CASE_INSENSITIVE, new 
EmptyStringSemanticsImplementor(insensitiveImplementor,
+            new 
MethodImplementor(IgniteMethod.POSIX_REGEX_CASE_INSENSITIVE.method(), 
NullPolicy.NONE, false)));
+        map.put(POSIX_REGEX_CASE_SENSITIVE, new 
EmptyStringSemanticsImplementor(sensitiveImplementor,
+            new 
MethodImplementor(IgniteMethod.POSIX_REGEX_CASE_SENSITIVE.method(), 
NullPolicy.NONE, false)));
         map.put(NEGATED_POSIX_REGEX_CASE_INSENSITIVE,
-            NotImplementor.of(insensitiveImplementor));
+            new 
EmptyStringSemanticsImplementor(NotImplementor.of(insensitiveImplementor),
+                new 
MethodImplementor(IgniteMethod.NEGATED_POSIX_REGEX_CASE_INSENSITIVE.method(), 
NullPolicy.NONE, false)));
         map.put(NEGATED_POSIX_REGEX_CASE_SENSITIVE,
-            NotImplementor.of(sensitiveImplementor));
+            new 
EmptyStringSemanticsImplementor(NotImplementor.of(sensitiveImplementor),
+                new 
MethodImplementor(IgniteMethod.NEGATED_POSIX_REGEX_CASE_SENSITIVE.method(), 
NullPolicy.NONE, false)));
         defineReflective(REGEXP_REPLACE_3,
             BuiltInMethod.REGEXP_REPLACE3.method,
             BuiltInMethod.REGEXP_REPLACE4.method,
@@ -2588,4 +2595,31 @@ public class RexImpTable {
             }
         };
     }
+
+    /** Selects an expression implementation according to the empty string SQL 
semantics. */
+    private static class EmptyStringSemanticsImplementor implements 
RexCallImplementor {
+        /**  */
+        private final RexCallImplementor dfltImplementor;
+
+        /** */
+        private final RexCallImplementor emptyStrIsNullImplementor;
+
+        /** */
+        private EmptyStringSemanticsImplementor(RexCallImplementor 
dfltImplementor, RexCallImplementor emptyStrIsNullImplementor) {
+            this.dfltImplementor = dfltImplementor;
+            this.emptyStrIsNullImplementor = emptyStrIsNullImplementor;
+        }
+
+        /** {@inheritDoc} */
+        @Override public RexToLixTranslator.Result implement(
+            RexToLixTranslator translator,
+            RexCall call,
+            List<RexToLixTranslator.Result> arguments
+        ) {
+            RexCallImplementor implementor = translator.emptyStringIsNull() ? 
emptyStrIsNullImplementor : dfltImplementor;
+
+            return implementor.implement(translator, call, arguments);
+        }
+    }
+
 }
diff --git 
a/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/exec/exp/RexToLixTranslator.java
 
b/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/exec/exp/RexToLixTranslator.java
index f11b432d1f0..40336eaad54 100644
--- 
a/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/exec/exp/RexToLixTranslator.java
+++ 
b/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/exec/exp/RexToLixTranslator.java
@@ -76,6 +76,7 @@ import 
org.apache.ignite.internal.processors.query.calcite.util.RexUtils;
 
 import static org.apache.calcite.sql.fun.SqlStdOperatorTable.CASE;
 import static org.apache.calcite.sql.fun.SqlStdOperatorTable.SEARCH;
+import static 
org.apache.ignite.internal.processors.query.calcite.sql.fun.IgniteOwnSqlOperatorTable.NULL_IF_EMPTY;
 
 /**
  * Translates {@link RexNode REX expressions} to {@link Expression linq4j 
expressions}.
@@ -105,6 +106,9 @@ public class RexToLixTranslator implements 
RexVisitor<RexToLixTranslator.Result>
     /** */
     private final Function1<String, InputGetter> correlates;
 
+    /** */
+    private final boolean emptyStrIsNull;
+
     /**
      * Map from RexLiteral's variable name to its literal, which is often a 
({@link ConstantExpression})) It is used in
      * the some {@code RexCall}'s implementors, such as {@code 
ExtractImplementor}.
@@ -142,7 +146,9 @@ public class RexToLixTranslator implements 
RexVisitor<RexToLixTranslator.Result>
         BlockBuilder list,
         RexBuilder builder,
         SqlConformance conformance,
-        Function1<String, InputGetter> correlates) {
+        Function1<String, InputGetter> correlates,
+        boolean emptyStrIsNull
+    ) {
         this.program = program; // may be null
         this.typeFactory = Objects.requireNonNull(typeFactory);
         this.conformance = Objects.requireNonNull(conformance);
@@ -151,6 +157,7 @@ public class RexToLixTranslator implements 
RexVisitor<RexToLixTranslator.Result>
         this.list = Objects.requireNonNull(list);
         this.builder = Objects.requireNonNull(builder);
         this.correlates = correlates; // may be null
+        this.emptyStrIsNull = emptyStrIsNull;
     }
 
     /**
@@ -164,12 +171,14 @@ public class RexToLixTranslator implements 
RexVisitor<RexToLixTranslator.Result>
      * @param root Root expression
      * @param inputGetter Generates expressions for inputs
      * @param correlates Provider of references to the values of correlated 
variables
+     * @param emptyStringIsNull Whether empty string is represented as {@code 
null}
      * @return Sequence of expressions, optional condition
      */
     public static List<Expression> translateProjects(RexProgram program,
         JavaTypeFactory typeFactory, SqlConformance conformance,
         BlockBuilder list, PhysType outputPhysType, Expression root,
-        InputGetter inputGetter, Function1<String, InputGetter> correlates) {
+        InputGetter inputGetter, Function1<String, InputGetter> correlates,
+        boolean emptyStringIsNull) {
         List<Type> storageTypes = null;
         if (outputPhysType != null) {
             final RelDataType rowType = outputPhysType.getRowType();
@@ -178,7 +187,7 @@ public class RexToLixTranslator implements 
RexVisitor<RexToLixTranslator.Result>
                 storageTypes.add(outputPhysType.getJavaFieldType(i));
         }
         return new RexToLixTranslator(program, typeFactory, root, inputGetter,
-            list, new IgniteRexBuilder(typeFactory), conformance, null)
+            list, new IgniteRexBuilder(typeFactory, emptyStringIsNull), 
conformance, null, emptyStringIsNull)
             .setCorrelates(correlates)
             .translateList(program.getProjectList(), storageTypes);
     }
@@ -206,7 +215,7 @@ public class RexToLixTranslator implements 
RexVisitor<RexToLixTranslator.Result>
     Expression translate(RexNode expr, RexImpTable.NullAs nullAs,
         Type storageType) {
         currentStorageType = storageType;
-        final Result result = expr.accept(this);
+        final Result result = normalizeStringResult(expr, expr.accept(this));
         final Expression translated =
             ConverterUtils.toInternal(result.valueVariable, storageType);
         assert translated != null;
@@ -831,7 +840,39 @@ public class RexToLixTranslator implements 
RexVisitor<RexToLixTranslator.Result>
      * @return Whether expression is nullable
      */
     public boolean isNullable(RexNode e) {
-        return e.getType().isNullable();
+        return (emptyStrIsNull && SqlTypeUtil.isCharacter(e.getType())) || 
e.getType().isNullable();
+    }
+
+    /** Returns whether empty string is represented as {@code null}. */
+    boolean emptyStringIsNull() {
+        return emptyStrIsNull;
+    }
+
+    /** Converts an empty result of a string expression to {@code null}. */
+    private Result normalizeStringResult(RexNode node, Result result) {
+        if (!emptyStrIsNull || isNullIfEmpty(node) || 
!SqlTypeUtil.isCharacter(node.getType())
+            || result.valueVariable.getType() != String.class) {
+            return result;
+        }
+
+        ParameterExpression valVariable = Expressions.parameter(
+            String.class, list.newName(result.valueVariable.name + 
"_null_if_empty"));
+        list.add(Expressions.declare(Modifier.FINAL, valVariable,
+            Expressions.call(IgniteMethod.NULL_IF_EMPTY.method(), 
result.valueVariable)));
+
+        ParameterExpression isNullVariable = Expressions.parameter(
+            Boolean.TYPE, list.newName(result.isNullVariable.name + 
"_null_if_empty"));
+        list.add(Expressions.declare(Modifier.FINAL, isNullVariable, 
checkNull(valVariable)));
+
+        return new Result(isNullVariable, valVariable);
+    }
+
+    /** Returns whether the node explicitly converts an empty string to {@code 
null}. */
+    private boolean isNullIfEmpty(RexNode node) {
+        while (node instanceof RexLocalRef)
+            node = deref(node);
+
+        return node instanceof RexCall && ((RexCall)node).getOperator() == 
NULL_IF_EMPTY;
     }
 
     /** */
@@ -840,7 +881,7 @@ public class RexToLixTranslator implements 
RexVisitor<RexToLixTranslator.Result>
             return this;
 
         return new RexToLixTranslator(program, typeFactory, root, inputGetter,
-            block, builder, conformance, correlates);
+            block, builder, conformance, correlates, emptyStrIsNull);
     }
 
     /** */
@@ -850,7 +891,7 @@ public class RexToLixTranslator implements 
RexVisitor<RexToLixTranslator.Result>
             return this;
 
         return new RexToLixTranslator(program, typeFactory, root, inputGetter, 
list,
-            builder, conformance, correlates);
+            builder, conformance, correlates, emptyStrIsNull);
     }
 
     /** */
@@ -1050,7 +1091,7 @@ public class RexToLixTranslator implements 
RexVisitor<RexToLixTranslator.Result>
         final List<Result> operandResults = new ArrayList<>();
         for (int i = 0; i < operandList.size(); i++) {
             final Result operandResult =
-                implementCallOperand(operandList.get(i), storageTypes.get(i), 
this);
+                implementCallOperand(operandList.get(i), storageTypes.get(i), 
this, operator != NULL_IF_EMPTY);
             operandResults.add(operandResult);
         }
         callOperandResultMap.put(call, operandResults);
@@ -1062,9 +1103,19 @@ public class RexToLixTranslator implements 
RexVisitor<RexToLixTranslator.Result>
     /** */
     private static Result implementCallOperand(final RexNode operand,
         final Type storageType, final RexToLixTranslator translator) {
+        return implementCallOperand(operand, storageType, translator, true);
+    }
+
+    /** */
+    private static Result implementCallOperand(final RexNode operand, final 
Type storageType,
+        final RexToLixTranslator translator, boolean normalizeStringResult) {
         final Type originalStorageType = translator.currentStorageType;
         translator.currentStorageType = storageType;
         Result operandResult = operand.accept(translator);
+
+        if (normalizeStringResult)
+            operandResult = translator.normalizeStringResult(operand, 
operandResult);
+
         if (storageType != null)
             operandResult = translator.toInnerStorageType(operandResult, 
storageType);
         translator.currentStorageType = originalStorageType;
diff --git 
a/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/prepare/BaseQueryContext.java
 
b/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/prepare/BaseQueryContext.java
index 90cd1cd56f4..8520c6f82e1 100644
--- 
a/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/prepare/BaseQueryContext.java
+++ 
b/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/prepare/BaseQueryContext.java
@@ -79,6 +79,9 @@ public final class BaseQueryContext extends 
AbstractQueryContext {
     /** */
     private static final RexBuilder REX_BUILDER;
 
+    /** */
+    private static final RexBuilder EMPTY_STR_IS_NULL_REX_BUILDER;
+
     /** */
     public static final RelOptCluster CLUSTER;
 
@@ -117,6 +120,7 @@ public final class BaseQueryContext extends 
AbstractQueryContext {
         TYPE_FACTORY = new IgniteTypeFactory(typeSys);
 
         REX_BUILDER = new IgniteRexBuilder(TYPE_FACTORY);
+        EMPTY_STR_IS_NULL_REX_BUILDER = new IgniteRexBuilder(TYPE_FACTORY, 
true);
 
         CLUSTER = RelOptCluster.create(EMPTY_PLANNER, REX_BUILDER);
 
@@ -204,7 +208,9 @@ public final class BaseQueryContext extends 
AbstractQueryContext {
 
         typeFactory = TYPE_FACTORY;
 
-        rexBuilder = REX_BUILDER;
+        IgniteSqlSemantics sqlSem = unwrap(IgniteSqlSemantics.class);
+
+        rexBuilder = IgniteSqlSemantics.emptyStringIsNull(sqlSem) ? 
EMPTY_STR_IS_NULL_REX_BUILDER : REX_BUILDER;
     }
 
     /**
diff --git 
a/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/prepare/IgniteSqlSemantics.java
 
b/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/prepare/IgniteSqlSemantics.java
index 52db5be64fe..4a03dfc17f9 100644
--- 
a/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/prepare/IgniteSqlSemantics.java
+++ 
b/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/prepare/IgniteSqlSemantics.java
@@ -26,9 +26,13 @@ public final class IgniteSqlSemantics {
     /** */
     private final RoundingMode paginationRoundingMode;
 
+    /** */
+    private final boolean emptyStrIsNull;
+
     /** */
     private IgniteSqlSemantics(Builder builder) {
         paginationRoundingMode = builder.paginationRoundingMode;
+        emptyStrIsNull = builder.emptyStrIsNull;
     }
 
     /** Returns a new builder initialized with default settings. */
@@ -41,11 +45,26 @@ public final class IgniteSqlSemantics {
         return paginationRoundingMode;
     }
 
+    /**
+     * Returns whether empty string in literals, parameters, SQL writes, 
expression results, and UDF/UDTF
+     * inputs and outputs are treated as {@code null}.
+     *
+     * <p>The setting must be identical on all cluster nodes and should only 
be enabled on a new cluster. Existing
+     * empty strings and indexes built for them may otherwise produce 
inconsistent query results. The setting affects
+     * SQL only; values written through key-value APIs must be normalized by 
the user.
+     */
+    public boolean emptyStringIsNull() {
+        return emptyStrIsNull;
+    }
+
     /** */
     public static final class Builder {
         /** */
         private RoundingMode paginationRoundingMode = 
IgniteMath.NUMERIC_ROUNDING_MODE;
 
+        /** */
+        private boolean emptyStrIsNull;
+
         /** */
         private Builder() {
             // No-op.
@@ -58,6 +77,20 @@ public final class IgniteSqlSemantics {
             return this;
         }
 
+        /**
+         * Sets whether empty string in literals, parameters, SQL writes, 
expression results, and UDF/UDTF
+         * inputs and outputs should be treated as {@code null}.
+         *
+         * <p>The value must be identical on all cluster nodes and should only 
be enabled on a new cluster. Existing
+         * empty strings and indexes built for them may otherwise produce 
inconsistent query results. The setting
+         * affects SQL only; values written through key-value APIs must be 
normalized by the user.
+         */
+        public Builder emptyStringIsNull(boolean emptyStrIsNull) {
+            this.emptyStrIsNull = emptyStrIsNull;
+
+            return this;
+        }
+
         /** */
         public IgniteSqlSemantics build() {
             return new IgniteSqlSemantics(this);
@@ -68,4 +101,9 @@ public final class IgniteSqlSemantics {
     public static long convertPaginationValueToLong(Number value, @Nullable 
IgniteSqlSemantics sem) {
         return sem == null ? IgniteMath.convertToLongExact(value) : 
IgniteMath.convertToLongExact(value, sem.paginationRoundingMode());
     }
+
+    /** Returns whether empty string is treated as {@code null} by the 
specified SQL semantics. */
+    public static boolean emptyStringIsNull(@Nullable IgniteSqlSemantics sem) {
+        return sem != null && sem.emptyStrIsNull;
+    }
 }
diff --git 
a/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/prepare/IgniteSqlValidator.java
 
b/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/prepare/IgniteSqlValidator.java
index d7af46290f4..38123cfcd68 100644
--- 
a/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/prepare/IgniteSqlValidator.java
+++ 
b/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/prepare/IgniteSqlValidator.java
@@ -61,6 +61,7 @@ import org.apache.calcite.sql.type.SqlOperandTypeInference;
 import org.apache.calcite.sql.type.SqlTypeCoercionRule;
 import org.apache.calcite.sql.type.SqlTypeFamily;
 import org.apache.calcite.sql.type.SqlTypeName;
+import org.apache.calcite.sql.type.SqlTypeUtil;
 import org.apache.calcite.sql.validate.SelectScope;
 import org.apache.calcite.sql.validate.SqlQualified;
 import org.apache.calcite.sql.validate.SqlValidator;
@@ -722,7 +723,17 @@ public class IgniteSqlValidator extends SqlValidatorImpl {
                 return type;
         }
 
-        return super.deriveType(scope, expr);
+        RelDataType type = super.deriveType(scope, expr);
+
+        if (IgniteSqlSemantics.emptyStringIsNull(sqlSem)
+            && expr instanceof SqlCall && 
!((SqlCall)expr).getOperator().isAggregator()
+            && expr.getKind() != SqlKind.AS && expr.getKind() != SqlKind.CAST
+            && SqlTypeUtil.isCharacter(type) && !type.isNullable()) {
+            type = typeFactory.createTypeWithNullability(type, true);
+            setValidatedNodeType(expr, type);
+        }
+
+        return type;
     }
 
     /** */
@@ -814,6 +825,13 @@ public class IgniteSqlValidator extends SqlValidatorImpl {
 
     /** {@inheritDoc} */
     @Override public SqlLiteral resolveLiteral(SqlLiteral literal) {
+        // Replace it before type inference so an empty string literal has a 
nullable SQL type.
+        if (IgniteSqlSemantics.emptyStringIsNull(sqlSem)
+            && literal.getTypeName().getFamily() == SqlTypeFamily.CHARACTER
+            && literal.getValueAs(String.class).isEmpty()) {
+            return SqlLiteral.createNull(literal.getParserPosition());
+        }
+
         if (literal instanceof SqlNumericLiteral && 
literal.createSqlType(typeFactory).getSqlTypeName() == SqlTypeName.BIGINT) {
             BigDecimal bd = literal.getValueAs(BigDecimal.class);
 
diff --git 
a/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/sql/fun/IgniteOwnSqlOperatorTable.java
 
b/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/sql/fun/IgniteOwnSqlOperatorTable.java
index d5a7dd434e3..34893afd2a4 100644
--- 
a/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/sql/fun/IgniteOwnSqlOperatorTable.java
+++ 
b/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/sql/fun/IgniteOwnSqlOperatorTable.java
@@ -16,6 +16,8 @@
  */
 package org.apache.ignite.internal.processors.query.calcite.sql.fun;
 
+import java.util.function.Supplier;
+import org.apache.calcite.plan.Strong;
 import org.apache.calcite.sql.SqlAggFunction;
 import org.apache.calcite.sql.SqlFunction;
 import org.apache.calcite.sql.SqlFunctionCategory;
@@ -96,6 +98,21 @@ public class IgniteOwnSqlOperatorTable extends 
ReflectiveSqlOperatorTable {
             OperandTypes.NILADIC,
             SqlFunctionCategory.SYSTEM);
 
+    /** Converts an empty string expression result to {@code null}. */
+    public static final SqlFunction NULL_IF_EMPTY = new SqlFunction(
+        "$NULL_IF_EMPTY",
+        SqlKind.OTHER_FUNCTION,
+        ReturnTypes.ARG0_FORCE_NULLABLE,
+        null,
+        OperandTypes.CHARACTER,
+        SqlFunctionCategory.SYSTEM
+    ) {
+        /** {@inheritDoc} */
+        @Override public Supplier<Strong.Policy> getStrongPolicyInference() {
+            return () -> Strong.Policy.AS_IS;
+        }
+    };
+
     /**
      * Least of two arguments. Unlike LEAST, which is converted to CASE WHEN 
THEN END clause, this function
      * is natively implemented.
diff --git 
a/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/util/IgniteMethod.java
 
b/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/util/IgniteMethod.java
index f4773275fe6..5970ea93f76 100644
--- 
a/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/util/IgniteMethod.java
+++ 
b/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/util/IgniteMethod.java
@@ -89,6 +89,21 @@ public enum IgniteMethod {
     /** See {@link IgniteSqlFunctions#toByteString(String)} */
     STRING_TO_BYTESTRING(IgniteSqlFunctions.class, "toByteString", 
String.class),
 
+    /** See {@link IgniteSqlFunctions#nullIfEmpty(String)} */
+    NULL_IF_EMPTY(IgniteSqlFunctions.class, "nullIfEmpty", String.class),
+
+    /** See {@link IgniteSqlFunctions#posixRegexCaseSensitive(String, String)} 
*/
+    POSIX_REGEX_CASE_SENSITIVE(IgniteSqlFunctions.class, 
"posixRegexCaseSensitive", String.class, String.class),
+
+    /** See {@link IgniteSqlFunctions#posixRegexCaseInsensitive(String, 
String)} */
+    POSIX_REGEX_CASE_INSENSITIVE(IgniteSqlFunctions.class, 
"posixRegexCaseInsensitive", String.class, String.class),
+
+    /** See {@link IgniteSqlFunctions#negatedPosixRegexCaseSensitive(String, 
String)} */
+    NEGATED_POSIX_REGEX_CASE_SENSITIVE(IgniteSqlFunctions.class, 
"negatedPosixRegexCaseSensitive", String.class, String.class),
+
+    /** See {@link IgniteSqlFunctions#negatedPosixRegexCaseInsensitive(String, 
String)} */
+    NEGATED_POSIX_REGEX_CASE_INSENSITIVE(IgniteSqlFunctions.class, 
"negatedPosixRegexCaseInsensitive", String.class, String.class),
+
     /** See {@link IgniteSqlFunctions#least2(Object, Object)} */
     LEAST2(IgniteSqlFunctions.class, "least2", Object.class, Object.class),
 
diff --git 
a/modules/calcite/src/test/java/org/apache/ignite/internal/processors/query/calcite/exec/exp/RexToLixTranslatorTest.java
 
b/modules/calcite/src/test/java/org/apache/ignite/internal/processors/query/calcite/exec/exp/RexToLixTranslatorTest.java
new file mode 100644
index 00000000000..3ca2992654b
--- /dev/null
+++ 
b/modules/calcite/src/test/java/org/apache/ignite/internal/processors/query/calcite/exec/exp/RexToLixTranslatorTest.java
@@ -0,0 +1,89 @@
+/*
+ * 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.ignite.internal.processors.query.calcite.exec.exp;
+
+import java.util.List;
+import org.apache.calcite.DataContext;
+import org.apache.calcite.linq4j.tree.BlockBuilder;
+import org.apache.calcite.linq4j.tree.Expression;
+import org.apache.calcite.linq4j.tree.Expressions;
+import org.apache.calcite.linq4j.tree.ParameterExpression;
+import org.apache.calcite.rel.type.RelDataType;
+import org.apache.calcite.rex.RexNode;
+import org.apache.calcite.rex.RexProgram;
+import org.apache.calcite.rex.RexProgramBuilder;
+import org.apache.calcite.sql.fun.SqlStdOperatorTable;
+import org.apache.calcite.sql.type.SqlTypeName;
+import org.apache.calcite.sql.validate.SqlConformanceEnum;
+import 
org.apache.ignite.internal.processors.query.calcite.type.IgniteTypeFactory;
+import org.junit.Test;
+
+import static org.junit.Assert.assertEquals;
+
+/** Tests for {@link RexToLixTranslator}. */
+public class RexToLixTranslatorTest {
+    /** */
+    @Test
+    public void testEmptyStringResultIsNormalizedOnce() {
+        IgniteTypeFactory typeFactory = new IgniteTypeFactory();
+        IgniteRexBuilder rexBuilder = new IgniteRexBuilder(typeFactory, true);
+
+        RelDataType strType = 
typeFactory.createTypeWithNullability(typeFactory.createSqlType(SqlTypeName.VARCHAR),
 true);
+        RelDataType rowType = typeFactory.builder().add("VAL", 
strType).build();
+
+        RexNode input = rexBuilder.makeInputRef(strType, 0);
+        RexNode upper = rexBuilder.makeCall(SqlStdOperatorTable.UPPER, input);
+        RexNode lower = rexBuilder.makeCall(SqlStdOperatorTable.LOWER, upper);
+
+        RexProgramBuilder programBuilder = new RexProgramBuilder(rowType, 
rexBuilder);
+        programBuilder.addProject(lower, "RES");
+        RexProgram program = programBuilder.getProgram();
+
+        BlockBuilder block = new BlockBuilder();
+        ParameterExpression inputVal = Expressions.parameter(String.class, 
"input");
+
+        List<Expression> projects = RexToLixTranslator.translateProjects(
+            program,
+            typeFactory,
+            SqlConformanceEnum.DEFAULT,
+            block,
+            null,
+            DataContext.ROOT,
+            (builder, idx, storageType) -> inputVal,
+            null,
+            true
+        );
+
+        block.add(Expressions.return_(null, projects.get(0)));
+
+        String code = block.toBlock().toString();
+
+        // One normalization for the input and one for each string function 
result.
+        assertEquals(code, 3, occurrences(code, "nullIfEmpty("));
+    }
+
+    /** Counts non-overlapping occurrences of the specified substring. */
+    private static int occurrences(String str, String substr) {
+        int cnt = 0;
+
+        for (int pos = 0; (pos = str.indexOf(substr, pos)) >= 0; pos += 
substr.length())
+            cnt++;
+
+        return cnt;
+    }
+}
diff --git 
a/modules/calcite/src/test/java/org/apache/ignite/internal/processors/query/calcite/integration/EmptyStringIsNullIntegrationTest.java
 
b/modules/calcite/src/test/java/org/apache/ignite/internal/processors/query/calcite/integration/EmptyStringIsNullIntegrationTest.java
new file mode 100644
index 00000000000..f9df72a1b2c
--- /dev/null
+++ 
b/modules/calcite/src/test/java/org/apache/ignite/internal/processors/query/calcite/integration/EmptyStringIsNullIntegrationTest.java
@@ -0,0 +1,171 @@
+/*
+ * 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.ignite.internal.processors.query.calcite.integration;
+
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.List;
+import org.apache.calcite.plan.Contexts;
+import org.apache.calcite.tools.FrameworkConfig;
+import org.apache.calcite.tools.Frameworks;
+import org.apache.ignite.cache.query.annotations.QuerySqlFunction;
+import org.apache.ignite.cache.query.annotations.QuerySqlTableFunction;
+import org.apache.ignite.configuration.CacheConfiguration;
+import org.apache.ignite.configuration.IgniteConfiguration;
+import org.apache.ignite.internal.processors.query.IgniteSQLException;
+import 
org.apache.ignite.internal.processors.query.calcite.CalciteQueryProcessor;
+import 
org.apache.ignite.internal.processors.query.calcite.prepare.IgniteSqlSemantics;
+import org.apache.ignite.plugin.AbstractTestPluginProvider;
+import org.apache.ignite.plugin.PluginContext;
+import org.jetbrains.annotations.Nullable;
+import org.junit.Test;
+
+/** Tests SQL semantics that treats empty string as {@code null}. */
+public class EmptyStringIsNullIntegrationTest extends 
AbstractBasicIntegrationTest {
+    /** {@inheritDoc} */
+    @Override protected IgniteConfiguration getConfiguration(String 
igniteInstanceName) throws Exception {
+        return super.getConfiguration(igniteInstanceName)
+            .setPluginProviders(new AbstractTestPluginProvider() {
+                /** {@inheritDoc} */
+                @Override public String name() {
+                    return "Empty string is null semantics";
+                }
+
+                /** {@inheritDoc} */
+                @Override public <T> @Nullable T createComponent(PluginContext 
ctx, Class<T> cls) {
+                    if (FrameworkConfig.class.equals(cls)) {
+                        FrameworkConfig cfg = 
Frameworks.newConfigBuilder(CalciteQueryProcessor.FRAMEWORK_CONFIG)
+                            .context(Contexts.chain(
+                                
CalciteQueryProcessor.FRAMEWORK_CONFIG.getContext(),
+                                Contexts.of(IgniteSqlSemantics.builder()
+                                    .emptyStringIsNull(true)
+                                    .build())))
+                            .build();
+
+                        return (T)cfg;
+                    }
+
+                    return super.createComponent(ctx, cls);
+                }
+            });
+    }
+
+    /** */
+    @Test
+    public void testLiteralsAndComparisons() {
+        assertQuery("SELECT '', '' IS NULL, '' IS NOT NULL, COALESCE('', 
'fallback')")
+            .returns(null, true, false, "fallback")
+            .check();
+
+        assertQuery("SELECT '' = '', 'value' = '', 'value' <> ''")
+            .returns(null, null, null)
+            .check();
+
+        assertQuery("SELECT CAST(? AS VARCHAR) IS NULL")
+            .withParams("")
+            .returns(true)
+            .check();
+    }
+
+    /** */
+    @Test
+    public void testStorage() {
+        sql("CREATE TABLE empty_string_test(id INT PRIMARY KEY, val VARCHAR)");
+
+        sql("INSERT INTO empty_string_test VALUES (1, ''), (2, 'value'), (3, 
?)", "");
+
+        assertQuery("SELECT id, val, val IS NULL FROM empty_string_test ORDER 
BY id")
+            .returns(1, null, true)
+            .returns(2, "value", false)
+            .returns(3, null, true)
+            .check();
+
+        assertQuery("SELECT id FROM empty_string_test WHERE val = '' OR val <> 
''")
+            .resultSize(0)
+            .check();
+    }
+
+    /** */
+    @Test
+    public void testNotNullConstraint() {
+        sql("CREATE TABLE empty_string_not_null_test(id INT PRIMARY KEY, val 
VARCHAR NOT NULL)");
+
+        assertThrows("INSERT INTO empty_string_not_null_test VALUES (1, '')", 
IgniteSQLException.class,
+            "Null value is not allowed");
+        assertThrows("INSERT INTO empty_string_not_null_test VALUES (2, ?)", 
IgniteSQLException.class,
+            "Null value is not allowed", "");
+    }
+
+    /** */
+    @Test
+    public void testExpressionAndAggregateResults() {
+        assertQuery("SELECT LTRIM('     '), RTRIM('     '), TRIM('     '), 
REPEAT('value', -1)")
+            .returns(null, null, null, null)
+            .check();
+
+        assertQuery("SELECT REPLACE('11', '1', ''), STRING_AGG('', '')")
+            .returns(null, null)
+            .check();
+
+        assertQuery("SELECT '' ~ '.*', '' ~* '.*', '' !~ '.*', '' !~* '.*', 
'value' ~ ''")
+            .returns(null, null, null, null, null)
+            .check();
+
+        assertThrows("SELECT '' ~ '[a-z'", IgniteSQLException.class, null);
+    }
+
+    /** */
+    @Test
+    public void testUdfs() {
+        client.getOrCreateCache(new CacheConfiguration<Integer, 
Integer>(DEFAULT_CACHE_NAME)
+            .setSqlSchema("PUBLIC")
+            .setSqlFunctionClasses(Functions.class));
+
+        assertQuery("SELECT STRINGISNULL(''), STRINGISNULL(?), 
STRINGISNULL(CAST(? AS VARCHAR)), EMPTYSTRING()")
+            .withParams("", "")
+            .returns(true, true, true, null)
+            .check();
+
+        assertQuery("SELECT * FROM STRINGNULLS('')")
+            .returns(true, null)
+            .check();
+    }
+
+    /** */
+    public static class Functions {
+        /** */
+        @QuerySqlFunction
+        public static boolean stringIsNull(String val) {
+            return val == null;
+        }
+
+        /** */
+        @QuerySqlFunction
+        public static String emptyString() {
+            return "";
+        }
+
+        /** */
+        @QuerySqlTableFunction(
+            columnTypes = {boolean.class, String.class},
+            columnNames = {"INPUT_IS_NULL", "EMPTY_RESULT"}
+        )
+        public static Collection<List<?>> stringNulls(String val) {
+            return List.of(Arrays.asList(val == null, ""));
+        }
+    }
+}
diff --git 
a/modules/calcite/src/test/java/org/apache/ignite/internal/processors/query/calcite/integration/FunctionsTest.java
 
b/modules/calcite/src/test/java/org/apache/ignite/internal/processors/query/calcite/integration/FunctionsTest.java
index 2e52c5a3479..20c4906f9cd 100644
--- 
a/modules/calcite/src/test/java/org/apache/ignite/internal/processors/query/calcite/integration/FunctionsTest.java
+++ 
b/modules/calcite/src/test/java/org/apache/ignite/internal/processors/query/calcite/integration/FunctionsTest.java
@@ -263,6 +263,12 @@ public class FunctionsTest extends 
AbstractBasicIntegrationTest {
         assertQuery("SELECT REPLACE('aA', 'A', 'b')").returns("ab").check();
     }
 
+    /** */
+    @Test
+    public void testEmptyStringIsNotNullByDefault() {
+        assertQuery("SELECT '', '' IS NULL, LTRIM('     ')").returns("", 
false, "").check();
+    }
+
     /** */
     @Test
     public void testRange() {
@@ -429,6 +435,7 @@ public class FunctionsTest extends 
AbstractBasicIntegrationTest {
         assertQuery("SELECT 'abcd' !~* null").returns(NULL_RESULT).check();
         assertQuery("SELECT null !~* null").returns(NULL_RESULT).check();
         assertThrows("SELECT 'abcd' ~ '[a-z'", IgniteSQLException.class, null);
+        assertQuery("SELECT CAST(NULL AS VARCHAR) ~ 
'[a-z'").returns(NULL_RESULT).check();
     }
 
     /** */
diff --git 
a/modules/calcite/src/test/java/org/apache/ignite/testsuites/IntegrationTestSuite.java
 
b/modules/calcite/src/test/java/org/apache/ignite/testsuites/IntegrationTestSuite.java
index 2a2cb856731..21ec5915b30 100644
--- 
a/modules/calcite/src/test/java/org/apache/ignite/testsuites/IntegrationTestSuite.java
+++ 
b/modules/calcite/src/test/java/org/apache/ignite/testsuites/IntegrationTestSuite.java
@@ -39,6 +39,7 @@ import 
org.apache.ignite.internal.processors.query.calcite.integration.DataTypes
 import 
org.apache.ignite.internal.processors.query.calcite.integration.DateTimeTest;
 import 
org.apache.ignite.internal.processors.query.calcite.integration.DistributedJoinIntegrationTest;
 import 
org.apache.ignite.internal.processors.query.calcite.integration.DynamicParametersIntegrationTest;
+import 
org.apache.ignite.internal.processors.query.calcite.integration.EmptyStringIsNullIntegrationTest;
 import 
org.apache.ignite.internal.processors.query.calcite.integration.ExpiredEntriesIntegrationTest;
 import 
org.apache.ignite.internal.processors.query.calcite.integration.FunctionsTest;
 import 
org.apache.ignite.internal.processors.query.calcite.integration.HashSpoolIntegrationTest;
@@ -197,6 +198,7 @@ import org.junit.runners.Suite;
     SystemColumnsScanTest.class,
     BulkOperationDeadlockIntegrationTest.class,
     SelectForUpdateIntegrationTest.class,
+    EmptyStringIsNullIntegrationTest.class,
 })
 public class IntegrationTestSuite {
 }

Reply via email to