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); }