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

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


The following commit(s) were added to refs/heads/master by this push:
     new 2274dc4bc3 PHOENIX-7585 New BSON Condition Function begins_with() 
(#2214)
2274dc4bc3 is described below

commit 2274dc4bc3e7b645102e489bfcd7d5baf346a250
Author: Viraj Jasani <vjas...@apache.org>
AuthorDate: Mon Jul 7 21:40:13 2025 -0700

    PHOENIX-7585 New BSON Condition Function begins_with() (#2214)
---
 .../src/main/antlr3/PhoenixBsonExpression.g        |  16 ++-
 .../BsonConditionInvalidArgumentException.java     |  33 +++++++
 .../util/bson/SQLComparisonExpressionUtils.java    |  59 +++++++++++
 .../parse/DocumentFieldBeginsWithParseNode.java    |  63 ++++++++++++
 .../org/apache/phoenix/parse/ParseNodeFactory.java |   5 +
 .../java/org/apache/phoenix/end2end/Bson1IT.java   |  22 +++++
 .../util/bson/ComparisonExpressionUtilsTest.java   | 108 ++++++++++++++++++++-
 7 files changed, 302 insertions(+), 4 deletions(-)

diff --git a/phoenix-core-client/src/main/antlr3/PhoenixBsonExpression.g 
b/phoenix-core-client/src/main/antlr3/PhoenixBsonExpression.g
index 537b4c4259..86313300ef 100644
--- a/phoenix-core-client/src/main/antlr3/PhoenixBsonExpression.g
+++ b/phoenix-core-client/src/main/antlr3/PhoenixBsonExpression.g
@@ -32,6 +32,7 @@ tokens
     ATTR_NOT = 'attribute_not_exists';
     FIELD = 'field_exists';
     FIELD_NOT = 'field_not_exists';
+    BEGINS_WITH = 'begins_with';
 }
 
 @parser::header {
@@ -211,8 +212,6 @@ and_expression returns [ParseNode ret]
 not_expression returns [ParseNode ret]
     :   (NOT? boolean_expression ) => n=NOT? e=boolean_expression { $ret = n 
== null ? e : factory.not(e); }
     |   n=NOT? LPAREN e=expression RPAREN { $ret = n == null ? e : 
factory.not(e); }
-    |   (ATTR | FIELD) ( LPAREN t=literal RPAREN {$ret = 
factory.documentFieldExists(t, true); } )
-    |   (ATTR_NOT | FIELD_NOT) ( LPAREN t=literal RPAREN {$ret = 
factory.documentFieldExists(t, false); } )
     ;
 
 comparison_op returns [CompareOperator ret]
@@ -232,6 +231,10 @@ boolean_expression returns [ParseNode ret]
                       |        (IN (LPAREN v=one_or_more_expressions RPAREN 
{List<ParseNode> il = new ArrayList<ParseNode>(v.size() + 1); il.add(l); 
il.addAll(v); $ret = factory.inList(il,n!=null);}))
                       ))
                   |  { $ret = l; } )
+        |   (ATTR | FIELD) ( LPAREN t=literal RPAREN {$ret = 
factory.documentFieldExists(t, true); } )
+        |   (ATTR_NOT | FIELD_NOT) ( LPAREN t=literal RPAREN {$ret = 
factory.documentFieldExists(t, false); } )
+        |   BEGINS_WITH ( LPAREN l=value_expression COMMA r=value_expression 
RPAREN
+                {$ret = factory.documentFieldBeginsWith(l, r); } )
     ;
 
 value_expression returns [ParseNode ret]
@@ -512,3 +515,12 @@ CHAR_ESC
         )
     |   '\'\''  { setText("\'"); }
     ;
+
+WS
+    :   ( ' ' | '\t' | '\u2002' ) { $channel=HIDDEN; }
+    ;
+
+EOL
+    :  ('\r' | '\n')
+    { skip(); }
+    ;
diff --git 
a/phoenix-core-client/src/main/java/org/apache/phoenix/expression/util/bson/BsonConditionInvalidArgumentException.java
 
b/phoenix-core-client/src/main/java/org/apache/phoenix/expression/util/bson/BsonConditionInvalidArgumentException.java
new file mode 100644
index 0000000000..3475d46416
--- /dev/null
+++ 
b/phoenix-core-client/src/main/java/org/apache/phoenix/expression/util/bson/BsonConditionInvalidArgumentException.java
@@ -0,0 +1,33 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.phoenix.expression.util.bson;
+
+/**
+ * Exception thrown when invalid arguments are provided to BSON condition 
expressions.
+ */
+public class BsonConditionInvalidArgumentException extends 
IllegalArgumentException {
+
+    public BsonConditionInvalidArgumentException(String message) {
+        super(message);
+    }
+
+    public BsonConditionInvalidArgumentException(String message, Throwable 
cause) {
+        super(message, cause);
+    }
+}
diff --git 
a/phoenix-core-client/src/main/java/org/apache/phoenix/expression/util/bson/SQLComparisonExpressionUtils.java
 
b/phoenix-core-client/src/main/java/org/apache/phoenix/expression/util/bson/SQLComparisonExpressionUtils.java
index c4faa88275..8a9ac9bd72 100644
--- 
a/phoenix-core-client/src/main/java/org/apache/phoenix/expression/util/bson/SQLComparisonExpressionUtils.java
+++ 
b/phoenix-core-client/src/main/java/org/apache/phoenix/expression/util/bson/SQLComparisonExpressionUtils.java
@@ -21,6 +21,7 @@ package org.apache.phoenix.expression.util.bson;
 import org.apache.phoenix.parse.AndParseNode;
 import org.apache.phoenix.parse.BetweenParseNode;
 import org.apache.phoenix.parse.DocumentFieldExistsParseNode;
+import org.apache.phoenix.parse.DocumentFieldBeginsWithParseNode;
 import org.apache.phoenix.parse.BsonExpressionParser;
 import org.apache.phoenix.parse.EqualParseNode;
 import org.apache.phoenix.parse.GreaterThanOrEqualParseNode;
@@ -44,6 +45,7 @@ import java.sql.SQLException;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
+import java.util.stream.IntStream;
 
 /**
  * SQL style condition expression evaluation support.
@@ -164,6 +166,17 @@ public final class SQLComparisonExpressionUtils {
       String fieldName = (String) fieldKey.getValue();
       fieldName = replaceExpressionFieldNames(fieldName, keyAliasDocument, 
sortedKeyNames);
       return documentFieldExistsParseNode.isExists() == exists(fieldName, 
rawBsonDocument);
+    } else if (parseNode instanceof DocumentFieldBeginsWithParseNode) {
+      final DocumentFieldBeginsWithParseNode documentFieldBeginsWithParseNode =
+              (DocumentFieldBeginsWithParseNode) parseNode;
+      final LiteralParseNode fieldKey =
+              (LiteralParseNode) 
documentFieldBeginsWithParseNode.getFieldKey();
+      final LiteralParseNode value =
+              (LiteralParseNode) documentFieldBeginsWithParseNode.getValue();
+      String fieldName = (String) fieldKey.getValue();
+      fieldName = replaceExpressionFieldNames(fieldName, keyAliasDocument, 
sortedKeyNames);
+      final String prefixValue = (String) value.getValue();
+      return beginsWith(fieldName, prefixValue, rawBsonDocument, 
comparisonValuesDocument);
     } else if (parseNode instanceof EqualParseNode) {
       final EqualParseNode equalParseNode = (EqualParseNode) parseNode;
       final LiteralParseNode lhs = (LiteralParseNode) equalParseNode.getLHS();
@@ -514,4 +527,50 @@ public final class SQLComparisonExpressionUtils {
             comparisonValuesDocument);
   }
 
+  /**
+   * Returns true if the value of the field begins with the prefix value 
represented by
+   * {@code prefixValue}. The comparison supports String and Binary data types 
only.
+   * For other data types, throws BsonConditionInvalidArgumentException.
+   *
+   * @param fieldKey The field key for which value is checked for prefix.
+   * @param prefixValue The prefix value to check against the field value.
+   * @param rawBsonDocument Bson Document representing the cell value on which 
the comparison is
+   * to be performed.
+   * @param comparisonValuesDocument Bson Document with values placeholder.
+   * @return True if the value of the field begins with prefixValue.
+   * @throws BsonConditionInvalidArgumentException if unsupported data types 
are used.
+   */
+  private static boolean beginsWith(final String fieldKey, final String 
prefixValue,
+                                    final RawBsonDocument rawBsonDocument,
+                                    final BsonDocument 
comparisonValuesDocument)
+          throws BsonConditionInvalidArgumentException {
+    BsonValue topLevelValue = rawBsonDocument.get(fieldKey);
+    BsonValue fieldValue = topLevelValue != null ?
+            topLevelValue :
+            CommonComparisonExpressionUtils.getFieldFromDocument(fieldKey, 
rawBsonDocument);
+    if (fieldValue == null) {
+      return false;
+    }
+    BsonValue prefixBsonValue = comparisonValuesDocument.get(prefixValue);
+    if (prefixBsonValue == null) {
+      return false;
+    }
+    if (fieldValue.isString() && prefixBsonValue.isString()) {
+      String fieldStr = ((BsonString) fieldValue).getValue();
+      String prefixStr = ((BsonString) prefixBsonValue).getValue();
+      return fieldStr.startsWith(prefixStr);
+    } else if (fieldValue.isBinary() && prefixBsonValue.isBinary()) {
+      byte[] fieldBytes = fieldValue.asBinary().getData();
+      byte[] prefixBytes = prefixBsonValue.asBinary().getData();
+      if (prefixBytes.length > fieldBytes.length) {
+        return false;
+      }
+      return IntStream.range(0, prefixBytes.length)
+              .noneMatch(i -> fieldBytes[i] != prefixBytes[i]);
+    } else {
+      throw new BsonConditionInvalidArgumentException(
+              "begins_with function only supports String and Binary data 
types.");
+    }
+  }
+
 }
diff --git 
a/phoenix-core-client/src/main/java/org/apache/phoenix/parse/DocumentFieldBeginsWithParseNode.java
 
b/phoenix-core-client/src/main/java/org/apache/phoenix/parse/DocumentFieldBeginsWithParseNode.java
new file mode 100644
index 0000000000..91cc703407
--- /dev/null
+++ 
b/phoenix-core-client/src/main/java/org/apache/phoenix/parse/DocumentFieldBeginsWithParseNode.java
@@ -0,0 +1,63 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.phoenix.parse;
+
+import org.apache.phoenix.compile.ColumnResolver;
+
+import java.sql.SQLException;
+import java.util.Arrays;
+import java.util.List;
+
+/**
+ * Parse Node to help determine whether the document field starts with a given 
value.
+ * The first operand is the field key and the second operand is the value to 
check.
+ */
+public class DocumentFieldBeginsWithParseNode extends CompoundParseNode {
+
+    DocumentFieldBeginsWithParseNode(ParseNode fieldKey, ParseNode value) {
+        super(Arrays.asList(fieldKey, value));
+    }
+
+    @Override
+    public <T> T accept(ParseNodeVisitor<T> visitor) throws SQLException {
+        List<T> l = java.util.Collections.emptyList();
+        if (visitor.visitEnter(this)) {
+            l = acceptChildren(visitor);
+        }
+        return visitor.visitLeave(this, l);
+    }
+
+    @Override
+    public void toSQL(ColumnResolver resolver, StringBuilder buf) {
+        List<ParseNode> children = getChildren();
+        buf.append("begins_with(");
+        children.get(0).toSQL(resolver, buf);
+        buf.append(", ");
+        children.get(1).toSQL(resolver, buf);
+        buf.append(")");
+    }
+
+    public ParseNode getFieldKey() {
+        return getChildren().get(0);
+    }
+
+    public ParseNode getValue() {
+        return getChildren().get(1);
+    }
+}
diff --git 
a/phoenix-core-client/src/main/java/org/apache/phoenix/parse/ParseNodeFactory.java
 
b/phoenix-core-client/src/main/java/org/apache/phoenix/parse/ParseNodeFactory.java
index 658f052c28..f70f4ad16f 100644
--- 
a/phoenix-core-client/src/main/java/org/apache/phoenix/parse/ParseNodeFactory.java
+++ 
b/phoenix-core-client/src/main/java/org/apache/phoenix/parse/ParseNodeFactory.java
@@ -298,6 +298,11 @@ public class ParseNodeFactory {
         return new DocumentFieldExistsParseNode(fieldName, exists);
     }
 
+    public DocumentFieldBeginsWithParseNode documentFieldBeginsWith(ParseNode 
fieldKey,
+                                                                    ParseNode 
value) {
+        return new DocumentFieldBeginsWithParseNode(fieldKey, value);
+    }
+
     public ColumnDef columnDef(ColumnName columnDefName, String sqlTypeName,
                                boolean isArray, Integer arrSize, Boolean 
isNull,
                                Integer maxLength, Integer scale, boolean isPK,
diff --git a/phoenix-core/src/it/java/org/apache/phoenix/end2end/Bson1IT.java 
b/phoenix-core/src/it/java/org/apache/phoenix/end2end/Bson1IT.java
index f49faad77f..5ed87f4837 100644
--- a/phoenix-core/src/it/java/org/apache/phoenix/end2end/Bson1IT.java
+++ b/phoenix-core/src/it/java/org/apache/phoenix/end2end/Bson1IT.java
@@ -186,6 +186,27 @@ public class Bson1IT extends ParallelStatsDisabledIT {
       assertEquals(bsonDocument2, document2);
 
       assertFalse(rs.next());
+
+      conditionExpression =
+              "NestedList1[0] <= :NestedList1_485 AND NestedList1[2][0] >= 
:NestedList1_xyz0123 "
+                      + "AND NestedList1[2][1].Id < :Id1 AND IdS < :Ids1 AND 
Id2 > :Id2 "
+                      + "AND begins_with(Title, :TitlePrefix)";
+
+      conditionDoc = new BsonDocument();
+      conditionDoc.put("$EXPR", new BsonString(conditionExpression));
+      conditionDoc.put("$VAL", compareValuesDocument);
+
+      query = "SELECT * FROM " + tableName + " WHERE 
BSON_CONDITION_EXPRESSION(COL, '"
+          + conditionDoc.toJson() + "')";
+      rs = conn.createStatement().executeQuery(query);
+
+      assertTrue(rs.next());
+      assertEquals("pk0002", rs.getString(1));
+      assertEquals(4596.354, rs.getDouble(2), 0.0);
+      document2 = (BsonDocument) rs.getObject(3);
+      assertEquals(bsonDocument2, document2);
+
+      assertFalse(rs.next());
     }
   }
 
@@ -194,6 +215,7 @@ public class Bson1IT extends ParallelStatsDisabledIT {
             "  \":NestedList1_485\" : -485.33,\n" +
             "  \":ISBN\" : \"111-1111111111\",\n" +
             "  \":Title\" : \"Book 101 Title\",\n" +
+            "  \":TitlePrefix\" : \"Book \",\n" +
             "  \":Id\" : 101.01,\n" +
             "  \":Id2\" : 12,\n" +
             "  \":Id1\" : 120,\n" +
diff --git 
a/phoenix-core/src/test/java/org/apache/phoenix/util/bson/ComparisonExpressionUtilsTest.java
 
b/phoenix-core/src/test/java/org/apache/phoenix/util/bson/ComparisonExpressionUtilsTest.java
index b06d815000..af68fcbf97 100644
--- 
a/phoenix-core/src/test/java/org/apache/phoenix/util/bson/ComparisonExpressionUtilsTest.java
+++ 
b/phoenix-core/src/test/java/org/apache/phoenix/util/bson/ComparisonExpressionUtilsTest.java
@@ -34,11 +34,13 @@ import org.bson.RawBsonDocument;
 import org.junit.Test;
 
 import org.apache.hadoop.hbase.util.Bytes;
+import 
org.apache.phoenix.expression.util.bson.BsonConditionInvalidArgumentException;
 import 
org.apache.phoenix.expression.util.bson.DocumentComparisonExpressionUtils;
 import org.apache.phoenix.expression.util.bson.SQLComparisonExpressionUtils;
 
 import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
 
 /**
  * Tests for BSON Condition Expression Utility.
@@ -1568,6 +1570,106 @@ public class ComparisonExpressionUtilsTest {
 
   }
 
+  @Test
+  public void testBeginsWithFunction() {
+    RawBsonDocument rawBsonDocument = getDocumentValue();
+    RawBsonDocument compareValues = getCompareValDocument();
+
+    assertTrue(SQLComparisonExpressionUtils.evaluateConditionExpression(
+            "begins_with(Title, #Title)", rawBsonDocument, compareValues));
+
+    assertTrue(SQLComparisonExpressionUtils.evaluateConditionExpression(
+            "begins_with(ISBN, :ISBN)", rawBsonDocument, compareValues));
+
+    assertTrue(SQLComparisonExpressionUtils.evaluateConditionExpression(
+            "begins_with(Title, :TitlePrefix)", rawBsonDocument, 
compareValues));
+
+    assertFalse(SQLComparisonExpressionUtils.evaluateConditionExpression(
+            "begins_with(Title, #NestedList1_xyz0123)", rawBsonDocument, 
compareValues));
+
+    assertFalse(SQLComparisonExpressionUtils.evaluateConditionExpression(
+            "begins_with(ISBN, #NestedList1_1)", rawBsonDocument, 
compareValues));
+
+    assertTrue(SQLComparisonExpressionUtils.evaluateConditionExpression(
+            "begins_with(NestedMap1.Title, #Title)", rawBsonDocument, 
compareValues));
+
+    assertFalse(SQLComparisonExpressionUtils.evaluateConditionExpression(
+            "begins_with(NestedMap1.Title, #NestedList1_xyz0123)", 
rawBsonDocument, compareValues));
+
+    assertTrue(SQLComparisonExpressionUtils.evaluateConditionExpression(
+            "begins_with(NestedMap1.NList1[2], #NestedMap1_NList1_3)", 
rawBsonDocument,
+            compareValues));
+
+    assertFalse(SQLComparisonExpressionUtils.evaluateConditionExpression(
+            "begins_with(NestedMap1.NList1[2], $NestedMap1_NList1_30)", 
rawBsonDocument,
+            compareValues));
+
+    assertFalse(SQLComparisonExpressionUtils.evaluateConditionExpression(
+            "begins_with(NonExistentField, #Title)", rawBsonDocument, 
compareValues));
+
+    try {
+      SQLComparisonExpressionUtils.evaluateConditionExpression(
+              "begins_with(Id, #Title)", rawBsonDocument, compareValues);
+      fail("Expected BsonConditionInvalidArgumentException");
+    } catch (BsonConditionInvalidArgumentException e) {
+      // expected
+    }
+
+    try {
+      SQLComparisonExpressionUtils.evaluateConditionExpression(
+              "begins_with(Title, #NestedMap1_NList1_3)", rawBsonDocument, 
compareValues);
+      fail("Expected BsonConditionInvalidArgumentException");
+    } catch (BsonConditionInvalidArgumentException e) {
+      // expected
+    }
+
+    assertTrue(SQLComparisonExpressionUtils.evaluateConditionExpression(
+            "begins_with(Title, #Title) AND field_exists(Id) = 
begins_with(Title, #Title)",
+            rawBsonDocument, compareValues));
+
+    assertFalse(SQLComparisonExpressionUtils.evaluateConditionExpression(
+            "begins_with(Title, #NestedList1_xyz0123) AND field_exists(Id)", 
rawBsonDocument,
+            compareValues));
+
+    assertTrue(SQLComparisonExpressionUtils.evaluateConditionExpression(
+            "begins_with(Title, #NestedList1_xyz0123) OR field_exists(Id)", 
rawBsonDocument,
+            compareValues));
+
+    assertTrue(SQLComparisonExpressionUtils.evaluateConditionExpression(
+            "begins_with(Title, #Title) OR field_not_exists(Id)", 
rawBsonDocument, compareValues));
+
+    assertFalse(SQLComparisonExpressionUtils.evaluateConditionExpression(
+            "NOT begins_with(Title, #Title)", rawBsonDocument, compareValues));
+
+    assertTrue(SQLComparisonExpressionUtils.evaluateConditionExpression(
+            "NOT begins_with(Title, #NestedList1_xyz0123)", rawBsonDocument, 
compareValues));
+
+    assertFalse(SQLComparisonExpressionUtils.evaluateConditionExpression(
+            "NOT begins_with(Title, #Title)", rawBsonDocument, compareValues));
+
+    assertTrue(SQLComparisonExpressionUtils.evaluateConditionExpression(
+            "NOT begins_with(Title, #NestedList1_xyz0123)", rawBsonDocument, 
compareValues));
+
+    assertFalse(SQLComparisonExpressionUtils.evaluateConditionExpression(
+            "NOT begins_with(Title, :TitlePrefix)", rawBsonDocument, 
compareValues));
+
+    assertFalse(SQLComparisonExpressionUtils.evaluateConditionExpression(
+            "NOT begins_with(NestedMap1.NList1[2], #NestedMap1_NList1_3)", 
rawBsonDocument,
+            compareValues));
+
+    assertTrue(SQLComparisonExpressionUtils.evaluateConditionExpression(
+            "NOT begins_with(NestedMap1.NList1[2], $NestedMap1_NList1_30)", 
rawBsonDocument,
+            compareValues));
+
+    assertTrue(SQLComparisonExpressionUtils.evaluateConditionExpression(
+            "NOT begins_with(Title, #NestedList1_xyz0123) AND 
field_exists(Id)", rawBsonDocument,
+            compareValues));
+
+    assertFalse(SQLComparisonExpressionUtils.evaluateConditionExpression(
+            "NOT begins_with(Title, #Title) OR NOT begins_with(Title, 
:TitlePrefix)",
+            rawBsonDocument, compareValues));
+  }
+
   private static RawBsonDocument getCompareValDocument() {
     String json = "{\n" +
             "  \"$Id20\" : 101.011,\n" +
@@ -1598,7 +1700,8 @@ public class ComparisonExpressionUtilsTest {
             "  \"#NMap1_NList1\" : \"NListVal01\",\n" +
             "  \"$NestedList1_4850\" : -485.35,\n" +
             "  \"$Id\" : 101.01,\n" +
-            "  \"#Title\" : \"Book 101 Title\"\n" +
+            "  \"#Title\" : \"Book 101 Title\",\n" +
+            "  \":TitlePrefix\" : \"Book\"\n" +
             "}";
     //{
     //  "$Id20" : 101.011,
@@ -1628,7 +1731,8 @@ public class ComparisonExpressionUtilsTest {
     //  "#NMap1_NList1" : "NListVal01",
     //  "$NestedList1_4850" : -485.35,
     //  "$Id" : 101.01,
-    //  "#Title" : "Book 101 Title"
+    //  "#Title" : "Book 101 Title",
+    //  ":TitlePrefix" : "Book"
     //}
     return RawBsonDocument.parse(json);
   }

Reply via email to