diff --git a/document-store/src/integrationTest/java/org/hypertrace/core/documentstore/DocStoreQueryV1Test.java b/document-store/src/integrationTest/java/org/hypertrace/core/documentstore/DocStoreQueryV1Test.java index 3bcaa27b..92f4a561 100644 --- a/document-store/src/integrationTest/java/org/hypertrace/core/documentstore/DocStoreQueryV1Test.java +++ b/document-store/src/integrationTest/java/org/hypertrace/core/documentstore/DocStoreQueryV1Test.java @@ -4068,12 +4068,14 @@ void testNullHandling(String dataStoreName) throws IOException { } } + /** Tests the behavior of top level array columns. */ @Nested class FlatCollectionTopLevelArrayColumns { + /** Tests the LHS array is NOT EMPTY. */ @ParameterizedTest @ArgumentsSource(PostgresProvider.class) - void testNotEmpty(String dataStoreName) throws JsonProcessingException { + void testArrayIsNotEmpty(String dataStoreName) throws JsonProcessingException { Collection flatCollection = getFlatCollection(dataStoreName); Query query = @@ -4095,13 +4097,13 @@ void testNotEmpty(String dataStoreName) throws JsonProcessingException { JsonNode tags = json.get("tags"); assertTrue(tags.isArray() && !tags.isEmpty()); } - // (Ids 1 to 8 have non-empty tags) assertEquals(8, count); } + /** Tests the LHS array is EMPTY */ @ParameterizedTest @ArgumentsSource(PostgresProvider.class) - void testEmpty(String dataStoreName) { + void testArrayIsEmpty(String dataStoreName) { Collection flatCollection = getFlatCollection(dataStoreName); Query query = @@ -4123,7 +4125,6 @@ void testEmpty(String dataStoreName) { count++; } - // (Ids 9 and 10 have NULL or EMPTY arrays) assertEquals(2, count); } @@ -4160,56 +4161,6 @@ void testUnnest(String dataStoreName) { } } - @ParameterizedTest - @ArgumentsSource(PostgresProvider.class) - void testInStringArray(String dataStoreName) throws JsonProcessingException { - Collection flatCollection = getFlatCollection(dataStoreName); - - Query inQuery = - Query.builder() - .addSelection(IdentifierExpression.of("item")) - .addSelection(ArrayIdentifierExpression.of("tags")) - .setFilter( - RelationalExpression.of( - ArrayIdentifierExpression.of("tags", ArrayType.TEXT), - IN, - ConstantExpression.ofStrings(List.of("hygiene", "grooming")))) - .build(); - - Iterator results = flatCollection.find(inQuery); - - int count = 0; - Set items = new HashSet<>(); - while (results.hasNext()) { - Document doc = results.next(); - JsonNode json = new ObjectMapper().readTree(doc.toJson()); - count++; - - String item = json.get("item").asText(); - items.add(item); - - // Verify that returned arrays contain at least one of the IN values - JsonNode tags = json.get("tags"); - if (tags != null && tags.isArray()) { - boolean containsMatch = false; - for (JsonNode tag : tags) { - String tagValue = tag.asText(); - if ("hygiene".equals(tagValue) || "grooming".equals(tagValue)) { - containsMatch = true; - break; - } - } - assertTrue(containsMatch, "Array should contain at least one IN value for item: " + item); - } - } - - // Should return rows where tags array overlaps with ["hygiene", "grooming"] - // hygiene: IDs 1, 5, 8 (Soap), 6, 7 (Comb) - assertTrue(count >= 5, "Should return at least 5 items"); - assertTrue(items.contains("Soap")); - assertTrue(items.contains("Comb")); - } - @ParameterizedTest @ArgumentsSource(PostgresProvider.class) void testNotInStringArray(String dataStoreName) throws JsonProcessingException { @@ -4261,11 +4212,31 @@ void testNotInStringArray(String dataStoreName) throws JsonProcessingException { @ParameterizedTest @ArgumentsSource(PostgresProvider.class) - void testInIntArray(String dataStoreName) throws JsonProcessingException { + void testIn(String dataStoreName) { Collection flatCollection = getFlatCollection(dataStoreName); - // Test IN on integer array (numbers column) - Query inQuery = + // STRING array: tags IN ['hygiene', 'grooming'] + Query stringInQuery = + Query.builder() + .addSelection(IdentifierExpression.of("item")) + .addSelection(ArrayIdentifierExpression.of("tags")) + .setFilter( + RelationalExpression.of( + ArrayIdentifierExpression.of("tags", ArrayType.TEXT), + IN, + ConstantExpression.ofStrings(List.of("hygiene", "grooming")))) + .build(); + + Iterator stringResults = flatCollection.find(stringInQuery); + int stringCount = 0; + while (stringResults.hasNext()) { + stringResults.next(); + stringCount++; + } + assertTrue(stringCount >= 5, "Should return at least 5 items"); + + // INTEGER array: numbers IN [1, 10, 20] + Query intInQuery = Query.builder() .addSelection(IdentifierExpression.of("item")) .addSelection(ArrayIdentifierExpression.of("numbers")) @@ -4276,43 +4247,16 @@ void testInIntArray(String dataStoreName) throws JsonProcessingException { ConstantExpression.ofNumbers(List.of(1, 10, 20)))) .build(); - Iterator results = flatCollection.find(inQuery); - - int count = 0; - while (results.hasNext()) { - Document doc = results.next(); - JsonNode json = new ObjectMapper().readTree(doc.toJson()); - count++; - - // Verify that returned arrays contain at least one of the IN values - JsonNode numbers = json.get("numbers"); - if (numbers != null && numbers.isArray()) { - boolean containsMatch = false; - for (JsonNode num : numbers) { - int value = num.asInt(); - if (value == 1 || value == 10 || value == 20) { - containsMatch = true; - break; - } - } - assertTrue( - containsMatch, - "Array should contain at least one IN value for item: " + json.get("item").asText()); - } + Iterator intResults = flatCollection.find(intInQuery); + int intCount = 0; + while (intResults.hasNext()) { + intResults.next(); + intCount++; } + assertTrue(intCount >= 6, "Should return at least 6 items"); - // Should return rows where numbers array overlaps with [1, 10, 20] - // IDs: 1 {1,2,3}, 2 {10,20}, 3 {5,10,15}, 6 {20,30}, 7 {10}, 8 {1,10,20} - assertTrue(count >= 6, "Should return at least 6 items"); - } - - @ParameterizedTest - @ArgumentsSource(PostgresProvider.class) - void testInDoubleArray(String dataStoreName) throws JsonProcessingException { - Collection flatCollection = getFlatCollection(dataStoreName); - - // Test IN on double precision array (scores column) - Query inQuery = + // DOUBLE PRECISION array: scores IN [3.14, 5.0] + Query doubleInQuery = Query.builder() .addSelection(IdentifierExpression.of("item")) .addSelection(ArrayIdentifierExpression.of("scores")) @@ -4323,34 +4267,13 @@ void testInDoubleArray(String dataStoreName) throws JsonProcessingException { ConstantExpression.ofNumbers(List.of(3.14, 5.0)))) .build(); - Iterator results = flatCollection.find(inQuery); - - int count = 0; - while (results.hasNext()) { - Document doc = results.next(); - JsonNode json = new ObjectMapper().readTree(doc.toJson()); - count++; - - // Verify that returned arrays contain at least one of the IN values - JsonNode scores = json.get("scores"); - if (scores != null && scores.isArray()) { - boolean containsMatch = false; - for (JsonNode score : scores) { - double value = score.asDouble(); - if (Math.abs(value - 3.14) < 0.01 || Math.abs(value - 5.0) < 0.01) { - containsMatch = true; - break; - } - } - assertTrue( - containsMatch, - "Array should contain at least one IN value for item: " + json.get("item").asText()); - } + Iterator doubleResults = flatCollection.find(doubleInQuery); + int doubleCount = 0; + while (doubleResults.hasNext()) { + doubleResults.next(); + doubleCount++; } - - // Should return rows where scores array overlaps with [3.14, 5.0] - // IDs: 3 {3.14,2.71}, 4 {5.0,10.0}, 8 {2.5,5.0} - assertTrue(count >= 3, "Should return at least 3 items"); + assertTrue(doubleCount >= 3, "Should return at least 3 items"); } @ParameterizedTest @@ -4387,7 +4310,7 @@ void testInWithUnnest(String dataStoreName) { assertNotNull(doc); count++; } - assertEquals(5, count, "Should return at least one unnested tag matching the filter"); + assertEquals(5, count); } } @@ -4432,7 +4355,7 @@ void testNotInWithUnnest(String dataStoreName) { @ParameterizedTest @ArgumentsSource(PostgresProvider.class) - void testEmptyWithUnnest(String dataStoreName) { + void testArrayIsEmptyWithUnnest(String dataStoreName) { Collection flatCollection = getFlatCollection(dataStoreName); Query unnestQuery = @@ -4467,7 +4390,7 @@ void testEmptyWithUnnest(String dataStoreName) { @ParameterizedTest @ArgumentsSource(PostgresProvider.class) - void testNotEmptyWithUnnest(String dataStoreName) { + void testArrayIsNotEmptyWithUnnest(String dataStoreName) { Collection flatCollection = getFlatCollection(dataStoreName); Query unnestQuery = @@ -4524,55 +4447,6 @@ void testContainsStrArrayWithUnnest(String dataStoreName) { assertEquals(5, count); } - @ParameterizedTest - @ArgumentsSource(PostgresProvider.class) - void testContainsStrArray(String dataStoreName) throws JsonProcessingException { - Collection flatCollection = getFlatCollection(dataStoreName); - - Query query = - Query.builder() - .addSelection(IdentifierExpression.of("item")) - .addSelection(ArrayIdentifierExpression.of("tags", ArrayType.TEXT)) - .setFilter( - RelationalExpression.of( - ArrayIdentifierExpression.of("tags", ArrayType.TEXT), - CONTAINS, - ConstantExpression.ofStrings(List.of("hygiene", "personal-care")))) - .build(); - - Iterator results = flatCollection.find(query); - - int count = 0; - Set items = new HashSet<>(); - while (results.hasNext()) { - Document doc = results.next(); - JsonNode json = new ObjectMapper().readTree(doc.toJson()); - count++; - - String item = json.get("item").asText(); - items.add(item); - - // Verify that returned arrays contain both "hygiene" AND "personal-care" - JsonNode tags = json.get("tags"); - assertTrue(tags.isArray(), "tags should be an array"); - boolean containsHygiene = false; - boolean containsPersonalCare = false; - for (JsonNode tag : tags) { - if ("hygiene".equals(tag.asText())) { - containsHygiene = true; - } - if ("personal-care".equals(tag.asText())) { - containsPersonalCare = true; - } - } - assertTrue(containsHygiene); - assertTrue(containsPersonalCare); - } - - assertEquals(1, count); - assertTrue(items.contains("Soap")); - } - @ParameterizedTest @ArgumentsSource(PostgresProvider.class) void testNotContainsStrArray(String dataStoreName) throws JsonProcessingException { @@ -4620,53 +4494,6 @@ void testNotContainsStrArray(String dataStoreName) throws JsonProcessingExceptio assertNotEquals(2, items.stream().filter("Shampoo"::equals).count()); } - @ParameterizedTest - @ArgumentsSource(PostgresProvider.class) - void testContainsOnIntArray(String dataStoreName) throws JsonProcessingException { - Collection flatCollection = getFlatCollection(dataStoreName); - - Query query = - Query.builder() - .addSelection(IdentifierExpression.of("item")) - .addSelection(ArrayIdentifierExpression.of("numbers", ArrayType.INTEGER)) - .setFilter( - RelationalExpression.of( - ArrayIdentifierExpression.of("numbers", ArrayType.INTEGER), - CONTAINS, - ConstantExpression.ofNumbers(List.of(1, 2)))) - .build(); - - Iterator results = flatCollection.find(query); - - int count = 0; - while (results.hasNext()) { - Document doc = results.next(); - JsonNode json = new ObjectMapper().readTree(doc.toJson()); - count++; - - // Verify numbers field is a proper JSON array, not a PostgreSQL string like "{1,2,3}" - JsonNode numbers = json.get("numbers"); - assertNotNull(numbers); - assertTrue(numbers.isArray(), "numbers should be JSON array, got: " + numbers); - - // Verify array contains both 1 and 2 - boolean contains1 = false; - boolean contains2 = false; - for (JsonNode num : numbers) { - if (num.asInt() == 1) { - contains1 = true; - } - if (num.asInt() == 2) { - contains2 = true; - } - } - assertTrue(contains1); - assertTrue(contains2); - } - - assertEquals(2, count); - } - @ParameterizedTest @ArgumentsSource(PostgresProvider.class) void testNotContainsOnIntArray(String dataStoreName) throws JsonProcessingException { @@ -4718,10 +4545,31 @@ void testNotContainsOnIntArray(String dataStoreName) throws JsonProcessingExcept @ParameterizedTest @ArgumentsSource(PostgresProvider.class) - void testContainsOnDoubleArray(String dataStoreName) throws JsonProcessingException { + void testContains(String dataStoreName) { Collection flatCollection = getFlatCollection(dataStoreName); - Query query = + // INTEGER array: numbers CONTAINS [1, 2] + Query intQuery = + Query.builder() + .addSelection(IdentifierExpression.of("item")) + .addSelection(ArrayIdentifierExpression.of("numbers", ArrayType.INTEGER)) + .setFilter( + RelationalExpression.of( + ArrayIdentifierExpression.of("numbers", ArrayType.INTEGER), + CONTAINS, + ConstantExpression.ofNumbers(List.of(1, 2)))) + .build(); + + Iterator intResults = flatCollection.find(intQuery); + int intCount = 0; + while (intResults.hasNext()) { + intResults.next(); + intCount++; + } + assertEquals(2, intCount); + + // DOUBLE PRECISION array: scores CONTAINS [3.14, 2.71] + Query doubleQuery = Query.builder() .addSelection(IdentifierExpression.of("item")) .addSelection(ArrayIdentifierExpression.of("scores", ArrayType.DOUBLE_PRECISION)) @@ -4732,100 +4580,169 @@ void testContainsOnDoubleArray(String dataStoreName) throws JsonProcessingExcept ConstantExpression.ofNumbers(List.of(3.14, 2.71)))) .build(); - Iterator results = flatCollection.find(query); - - int count = 0; - while (results.hasNext()) { - Document doc = results.next(); - JsonNode json = new ObjectMapper().readTree(doc.toJson()); - count++; + Iterator doubleResults = flatCollection.find(doubleQuery); + int doubleCount = 0; + while (doubleResults.hasNext()) { + doubleResults.next(); + doubleCount++; + } + assertEquals(1, doubleCount); - JsonNode scores = json.get("scores"); - assertNotNull(scores); - assertTrue(scores.isArray(), "scores should be JSON array, got: " + scores); + // STRING array: tags CONTAINS ['hygiene'] + Query stringQuery = + Query.builder() + .addSelection(IdentifierExpression.of("item")) + .addSelection(ArrayIdentifierExpression.of("tags", ArrayType.TEXT)) + .setFilter( + RelationalExpression.of( + ArrayIdentifierExpression.of("tags", ArrayType.TEXT), + CONTAINS, + ConstantExpression.ofStrings(List.of("hygiene")))) + .build(); - boolean contains314 = false; - boolean contains271 = false; - for (JsonNode score : scores) { - double val = score.asDouble(); - if (val == 3.14) { - contains314 = true; - } - if (val == 2.71) { - contains271 = true; - } - } - assertTrue(contains314); - assertTrue(contains271); + Iterator stringResults = flatCollection.find(stringQuery); + int stringCount = 0; + while (stringResults.hasNext()) { + stringResults.next(); + stringCount++; } + assertEquals(3, stringCount); + } - assertEquals(1, count); + @ParameterizedTest + @ArgumentsSource(PostgresProvider.class) + void testAny(String dataStoreName) throws IOException { + // INTEGER array: numbers ANY = 10 + assertAnyOnArray( + dataStoreName, + "numbers", + ConstantExpression.of(10), + "query/flat_integer_array_filter_response.json", + 4); + + // DOUBLE PRECISION array: scores ANY = 3.14 + assertAnyOnArray( + dataStoreName, + "scores", + ConstantExpression.of(3.14), + "query/flat_double_array_filter_response.json", + 1); + + // BOOLEAN array: flags ANY = true + assertAnyOnArray( + dataStoreName, + "flags", + ConstantExpression.of(true), + "query/flat_boolean_array_filter_response.json", + 5); } + /** + * Tests the behavior of EQ/NEQ on array fields with scalar RHS. This should behave like + * CONTAINS/NOT_CONTAINS + */ @ParameterizedTest @ArgumentsSource(PostgresProvider.class) - void testAnyOnIntegerArray(String dataStoreName) throws IOException { + void testEqNotEqToScalar(String dataStoreName) { Collection flatCollection = getFlatCollection(dataStoreName); - Query integerArrayQuery = + // EQ/NOT on arrays should behave like CONTAINS/NOT_CONTAINS + Query eqQuery = Query.builder() .addSelection(IdentifierExpression.of("item")) + .addSelection(ArrayIdentifierExpression.of("tags", ArrayType.TEXT)) .setFilter( - ArrayRelationalFilterExpression.builder() - .operator(ArrayOperator.ANY) - .filter( - RelationalExpression.of( - IdentifierExpression.of("numbers"), EQ, ConstantExpression.of(10))) - .build()) + RelationalExpression.of( + ArrayIdentifierExpression.of("tags", ArrayType.TEXT), + EQ, + ConstantExpression.of("hygiene"))) .build(); - Iterator resultIterator = flatCollection.find(integerArrayQuery); - assertDocsAndSizeEqualWithoutOrder( - dataStoreName, resultIterator, "query/flat_integer_array_filter_response.json", 4); + Iterator results = flatCollection.find(eqQuery); + + int count = 0; + while (results.hasNext()) { + Document next = results.next(); + count++; + } + + assertEquals(3, count); + + Query neqQuery = + Query.builder() + .addSelection(IdentifierExpression.of("item")) + .addSelection(ArrayIdentifierExpression.of("tags", ArrayType.TEXT)) + .setFilter( + RelationalExpression.of( + ArrayIdentifierExpression.of("tags", ArrayType.TEXT), + NEQ, + ConstantExpression.of("hygiene"))) + .build(); + + results = flatCollection.find(neqQuery); + + count = 0; + while (results.hasNext()) { + Document next = results.next(); + count++; + } + + assertEquals(7, count); } + /** + * Tests the behavior of EQ/NEQ on array fields with array RHS. This should be an exact match + */ @ParameterizedTest @ArgumentsSource(PostgresProvider.class) - void testAnyOnDoubleArray(String dataStoreName) throws IOException { + void testEqAndNeqToArrays(String dataStoreName) { Collection flatCollection = getFlatCollection(dataStoreName); - Query doubleArrayQuery = + Query eqQuery = Query.builder() .addSelection(IdentifierExpression.of("item")) + .addSelection(ArrayIdentifierExpression.of("tags", ArrayType.TEXT)) .setFilter( - ArrayRelationalFilterExpression.builder() - .operator(ArrayOperator.ANY) - .filter( - RelationalExpression.of( - IdentifierExpression.of("scores"), EQ, ConstantExpression.of(3.14))) - .build()) + RelationalExpression.of( + ArrayIdentifierExpression.of("tags", ArrayType.TEXT), + EQ, + ConstantExpression.ofStrings(List.of("hygiene", "family-pack")))) .build(); - Iterator resultIterator = flatCollection.find(doubleArrayQuery); - assertDocsAndSizeEqualWithoutOrder( - dataStoreName, resultIterator, "query/flat_double_array_filter_response.json", 1); + Iterator results = flatCollection.find(eqQuery); + + int count = 0; + while (results.hasNext()) { + Document next = results.next(); + count++; + } + + assertEquals(0, count); } - @ParameterizedTest - @ArgumentsSource(PostgresProvider.class) - void testAnyOnBooleanArray(String dataStoreName) throws IOException { + private void assertAnyOnArray( + String dataStoreName, + String fieldName, + ConstantExpression rhs, + String expectedJsonPath, + int expectedCount) + throws IOException { + Collection flatCollection = getFlatCollection(dataStoreName); - Query booleanArrayQuery = + Query anyArrayQuery = Query.builder() .addSelection(IdentifierExpression.of("item")) .setFilter( ArrayRelationalFilterExpression.builder() .operator(ArrayOperator.ANY) - .filter( - RelationalExpression.of( - IdentifierExpression.of("flags"), EQ, ConstantExpression.of(true))) + .filter(RelationalExpression.of(IdentifierExpression.of(fieldName), EQ, rhs)) .build()) .build(); - Iterator resultIterator = flatCollection.find(booleanArrayQuery); + Iterator resultIterator = flatCollection.find(anyArrayQuery); assertDocsAndSizeEqualWithoutOrder( - dataStoreName, resultIterator, "query/flat_boolean_array_filter_response.json", 5); + dataStoreName, resultIterator, expectedJsonPath, expectedCount); } } @@ -5553,6 +5470,60 @@ void testContainsNotContainsArraysInArrays(String dataStoreName) { } assertEquals(9, count); } + + /** + * Given LHS is a nested array, operator is EQ/NEQ, and RHS is a scalar, it should behave as + * containment (LHS contains RHS) + */ + @ParameterizedTest + @ArgumentsSource(PostgresProvider.class) + void testEqNeqScalarsOnArrays(String dataStoreName) { + Collection flatCollection = getFlatCollection(dataStoreName); + + // Should be treated like CONTAINS + Query eqQuery = + Query.builder() + .addSelection(IdentifierExpression.of("item")) + .setFilter( + RelationalExpression.of( + JsonIdentifierExpression.of( + "props", JsonFieldType.STRING_ARRAY, "source-loc"), + EQ, + ConstantExpression.of("warehouse-A"))) + .build(); + + Iterator resultIterator = flatCollection.find(eqQuery); + + int count = 0; + while (resultIterator.hasNext()) { + Document doc = resultIterator.next(); + assertNotNull(doc); + count++; + } + assertEquals(1, count); + + // should be treated like NOT_CONTAINS + Query notEq = + Query.builder() + .addSelection(IdentifierExpression.of("item")) + .setFilter( + RelationalExpression.of( + JsonIdentifierExpression.of( + "props", JsonFieldType.STRING_ARRAY, "source-loc"), + NEQ, + ConstantExpression.of("warehouse-A"))) + .build(); + + resultIterator = flatCollection.find(notEq); + + count = 0; + while (resultIterator.hasNext()) { + Document doc = resultIterator.next(); + assertNotNull(doc); + count++; + } + assertEquals(9, count); + } } private static Collection getFlatCollection(String dataStoreName) { diff --git a/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/query/v1/parser/builder/PostgresSelectExpressionParserBuilderImpl.java b/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/query/v1/parser/builder/PostgresSelectExpressionParserBuilderImpl.java index def36325..b98a5dd2 100644 --- a/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/query/v1/parser/builder/PostgresSelectExpressionParserBuilderImpl.java +++ b/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/query/v1/parser/builder/PostgresSelectExpressionParserBuilderImpl.java @@ -1,9 +1,15 @@ package org.hypertrace.core.documentstore.postgres.query.v1.parser.builder; +import static org.hypertrace.core.documentstore.expression.operators.RelationalOperator.EQ; +import static org.hypertrace.core.documentstore.expression.operators.RelationalOperator.NEQ; import static org.hypertrace.core.documentstore.postgres.utils.PostgresUtils.getType; -import lombok.AllArgsConstructor; +import org.hypertrace.core.documentstore.expression.impl.ArrayIdentifierExpression; +import org.hypertrace.core.documentstore.expression.impl.ConstantExpression; +import org.hypertrace.core.documentstore.expression.impl.JsonFieldType; +import org.hypertrace.core.documentstore.expression.impl.JsonIdentifierExpression; import org.hypertrace.core.documentstore.expression.impl.RelationalExpression; +import org.hypertrace.core.documentstore.expression.type.SelectTypeExpression; import org.hypertrace.core.documentstore.postgres.query.v1.PostgresQueryParser; import org.hypertrace.core.documentstore.postgres.query.v1.vistors.PostgresConstantExpressionVisitor; import org.hypertrace.core.documentstore.postgres.query.v1.vistors.PostgresDataAccessorIdentifierExpressionVisitor; @@ -11,12 +17,15 @@ import org.hypertrace.core.documentstore.postgres.query.v1.vistors.PostgresFunctionExpressionVisitor; import org.hypertrace.core.documentstore.postgres.query.v1.vistors.PostgresSelectTypeExpressionVisitor; -@AllArgsConstructor public class PostgresSelectExpressionParserBuilderImpl implements PostgresSelectExpressionParserBuilder { private final PostgresQueryParser postgresQueryParser; + public PostgresSelectExpressionParserBuilderImpl(PostgresQueryParser postgresQueryParser) { + this.postgresQueryParser = postgresQueryParser; + } + @Override public PostgresSelectTypeExpressionVisitor build(final RelationalExpression expression) { switch (expression.getOperator()) { @@ -29,6 +38,15 @@ public PostgresSelectTypeExpressionVisitor build(final RelationalExpression expr return new PostgresFunctionExpressionVisitor( new PostgresFieldIdentifierExpressionVisitor(this.postgresQueryParser)); + case EQ: + case NEQ: + // For EQ/NEQ on array fields, treat like CONTAINS to use -> instead of ->> + if (shouldSwitchToContainsFlow(expression)) { + // Use field identifier (JSON accessor ->) for array fields + return new PostgresFunctionExpressionVisitor( + new PostgresFieldIdentifierExpressionVisitor(this.postgresQueryParser)); + } + // Fall through to default for non-array fields default: return new PostgresFunctionExpressionVisitor( new PostgresDataAccessorIdentifierExpressionVisitor( @@ -36,4 +54,50 @@ public PostgresSelectTypeExpressionVisitor build(final RelationalExpression expr getType(expression.getRhs().accept(new PostgresConstantExpressionVisitor())))); } } + + /** + * Checks if this is an EQ/NEQ operator on an array field. + * + *

Only converts to CONTAINS when RHS is a scalar value. If RHS is an array, we want exact + * equality match, not containment. + * + *

Handles both: + * + *

    + *
  • {@link JsonIdentifierExpression} with array field type (JSONB arrays) + *
  • {@link ArrayIdentifierExpression} with array type (top-level array columns) + *
+ */ + private boolean shouldSwitchToContainsFlow(final RelationalExpression expression) { + if (expression.getOperator() != EQ && expression.getOperator() != NEQ) { + return false; + } + + // Check if RHS is an array/iterable - if so, don't convert (since we want an exact match for + // such cases) + if (expression.getRhs() instanceof ConstantExpression) { + ConstantExpression constExpr = (ConstantExpression) expression.getRhs(); + if (constExpr.getValue() instanceof Iterable) { + return false; + } + } + + return isArrayField(expression.getLhs()); + } + + private boolean isArrayField(final SelectTypeExpression lhs) { + if (lhs instanceof JsonIdentifierExpression) { + JsonIdentifierExpression jsonExpr = (JsonIdentifierExpression) lhs; + return jsonExpr + .getFieldType() + .map( + fieldType -> + fieldType == JsonFieldType.BOOLEAN_ARRAY + || fieldType == JsonFieldType.STRING_ARRAY + || fieldType == JsonFieldType.NUMBER_ARRAY + || fieldType == JsonFieldType.OBJECT_ARRAY) + .orElse(false); + } + return lhs instanceof ArrayIdentifierExpression; + } } diff --git a/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/query/v1/parser/filter/PostgresArrayEqualityParserSelector.java b/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/query/v1/parser/filter/PostgresArrayEqualityParserSelector.java new file mode 100644 index 00000000..a3ac5b05 --- /dev/null +++ b/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/query/v1/parser/filter/PostgresArrayEqualityParserSelector.java @@ -0,0 +1,68 @@ +package org.hypertrace.core.documentstore.postgres.query.v1.parser.filter; + +import org.hypertrace.core.documentstore.expression.impl.AggregateExpression; +import org.hypertrace.core.documentstore.expression.impl.AliasedIdentifierExpression; +import org.hypertrace.core.documentstore.expression.impl.ArrayIdentifierExpression; +import org.hypertrace.core.documentstore.expression.impl.ConstantExpression; +import org.hypertrace.core.documentstore.expression.impl.ConstantExpression.DocumentConstantExpression; +import org.hypertrace.core.documentstore.expression.impl.FunctionExpression; +import org.hypertrace.core.documentstore.expression.impl.IdentifierExpression; +import org.hypertrace.core.documentstore.expression.impl.JsonIdentifierExpression; +import org.hypertrace.core.documentstore.parser.SelectTypeExpressionVisitor; + +/** + * Selects the appropriate array equality parser based on the LHS expression type. + * + *

For JsonIdentifierExpression: uses JSONB array equality parser + * + *

For ArrayIdentifierExpression: uses top-level array equality parser + */ +class PostgresArrayEqualityParserSelector implements SelectTypeExpressionVisitor { + + private static final PostgresRelationalFilterParser jsonArrayEqualityParser = + new PostgresJsonArrayEqualityFilterParser(); + private static final PostgresRelationalFilterParser topLevelArrayEqualityParser = + new PostgresTopLevelArrayEqualityFilterParser(); + private static final PostgresRelationalFilterParser standardParser = + new PostgresStandardRelationalFilterParser(); + + @Override + public PostgresRelationalFilterParser visit(JsonIdentifierExpression expression) { + return jsonArrayEqualityParser; + } + + @Override + public PostgresRelationalFilterParser visit(ArrayIdentifierExpression expression) { + return topLevelArrayEqualityParser; + } + + @Override + public T visit(IdentifierExpression expression) { + return (T) standardParser; + } + + @Override + public T visit(AggregateExpression expression) { + return (T) standardParser; + } + + @Override + public T visit(ConstantExpression expression) { + return (T) standardParser; + } + + @Override + public T visit(DocumentConstantExpression expression) { + return (T) standardParser; + } + + @Override + public T visit(FunctionExpression expression) { + return (T) standardParser; + } + + @Override + public T visit(AliasedIdentifierExpression expression) { + return (T) standardParser; + } +} diff --git a/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/query/v1/parser/filter/PostgresJsonArrayEqualityFilterParser.java b/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/query/v1/parser/filter/PostgresJsonArrayEqualityFilterParser.java new file mode 100644 index 00000000..fdc4ae7b --- /dev/null +++ b/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/query/v1/parser/filter/PostgresJsonArrayEqualityFilterParser.java @@ -0,0 +1,44 @@ +package org.hypertrace.core.documentstore.postgres.query.v1.parser.filter; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.hypertrace.core.documentstore.expression.impl.RelationalExpression; + +/** + * Handles EQ/NEQ operations on JSONB array fields when RHS is also an array, using exact equality + * (=) instead of containment (@>). + * + *

Generates: {@code props->'source-loc' = '["hygiene","family-pack"]'::jsonb} + */ +class PostgresJsonArrayEqualityFilterParser implements PostgresRelationalFilterParser { + + private static final PostgresStandardRelationalOperatorMapper mapper = + new PostgresStandardRelationalOperatorMapper(); + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + @Override + public String parse( + final RelationalExpression expression, final PostgresRelationalFilterContext context) { + final String parsedLhs = expression.getLhs().accept(context.lhsParser()); + final Object parsedRhs = expression.getRhs().accept(context.rhsParser()); + final String operator = mapper.getMapping(expression.getOperator(), parsedRhs); + + if (parsedRhs == null) { + return String.format("%s %s NULL", parsedLhs, operator); + } + + // Convert the array to a JSONB string representation + try { + String jsonbValue; + if (parsedRhs instanceof Iterable) { + jsonbValue = OBJECT_MAPPER.writeValueAsString(parsedRhs); + } else { + jsonbValue = String.valueOf(parsedRhs); + } + context.getParamsBuilder().addObjectParam(jsonbValue); + return String.format("%s %s ?::jsonb", parsedLhs, operator); + } catch (JsonProcessingException e) { + throw new RuntimeException("Failed to serialize RHS array to JSON", e); + } + } +} diff --git a/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/query/v1/parser/filter/PostgresNotContainsRelationalFilterParser.java b/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/query/v1/parser/filter/PostgresNotContainsRelationalFilterParser.java index ace7960f..f00c3354 100644 --- a/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/query/v1/parser/filter/PostgresNotContainsRelationalFilterParser.java +++ b/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/query/v1/parser/filter/PostgresNotContainsRelationalFilterParser.java @@ -35,8 +35,7 @@ private boolean shouldUseJsonParser( boolean isJsonField = expression.getLhs() instanceof JsonIdentifierExpression; boolean isFlatCollection = context.getPgColTransformer().getDocumentType() == DocumentType.FLAT; - boolean useJsonParser = !isFlatCollection || isJsonField; - return useJsonParser; + return !isFlatCollection || isJsonField; } } diff --git a/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/query/v1/parser/filter/PostgresRelationalFilterParserFactoryImpl.java b/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/query/v1/parser/filter/PostgresRelationalFilterParserFactoryImpl.java index 41a13b7c..bb9bced7 100644 --- a/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/query/v1/parser/filter/PostgresRelationalFilterParserFactoryImpl.java +++ b/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/query/v1/parser/filter/PostgresRelationalFilterParserFactoryImpl.java @@ -2,9 +2,11 @@ import static java.util.Map.entry; import static org.hypertrace.core.documentstore.expression.operators.RelationalOperator.CONTAINS; +import static org.hypertrace.core.documentstore.expression.operators.RelationalOperator.EQ; import static org.hypertrace.core.documentstore.expression.operators.RelationalOperator.EXISTS; import static org.hypertrace.core.documentstore.expression.operators.RelationalOperator.IN; import static org.hypertrace.core.documentstore.expression.operators.RelationalOperator.LIKE; +import static org.hypertrace.core.documentstore.expression.operators.RelationalOperator.NEQ; import static org.hypertrace.core.documentstore.expression.operators.RelationalOperator.NOT_CONTAINS; import static org.hypertrace.core.documentstore.expression.operators.RelationalOperator.NOT_EXISTS; import static org.hypertrace.core.documentstore.expression.operators.RelationalOperator.NOT_IN; @@ -13,12 +15,18 @@ import com.google.common.collect.Maps; import java.util.Map; import org.hypertrace.core.documentstore.DocumentType; +import org.hypertrace.core.documentstore.expression.impl.ArrayIdentifierExpression; +import org.hypertrace.core.documentstore.expression.impl.ConstantExpression; +import org.hypertrace.core.documentstore.expression.impl.JsonFieldType; +import org.hypertrace.core.documentstore.expression.impl.JsonIdentifierExpression; import org.hypertrace.core.documentstore.expression.impl.RelationalExpression; import org.hypertrace.core.documentstore.expression.operators.RelationalOperator; +import org.hypertrace.core.documentstore.expression.type.SelectTypeExpression; import org.hypertrace.core.documentstore.postgres.query.v1.PostgresQueryParser; public class PostgresRelationalFilterParserFactoryImpl implements PostgresRelationalFilterParserFactory { + private static final Map parserMap = Maps.immutableEnumMap( Map.ofEntries( @@ -41,12 +49,139 @@ public PostgresRelationalFilterParser parser( boolean isFlatCollection = postgresQueryParser.getPgColTransformer().getDocumentType() == DocumentType.FLAT; - if (expression.getOperator() == CONTAINS) { + RelationalOperator operator = expression.getOperator(); + // Transform EQ/NEQ to CONTAINS/NOT_CONTAINS for array fields with scalar RHS + // (but not for unnested fields, which are already scalar) + if (shouldConvertEqToContains(expression, postgresQueryParser)) { + operator = (expression.getOperator() == EQ) ? CONTAINS : NOT_CONTAINS; + } + + if (operator == CONTAINS) { return expression.getLhs().accept(new PostgresContainsParserSelector(isFlatCollection)); - } else if (expression.getOperator() == IN) { + } else if (operator == IN) { return expression.getLhs().accept(new PostgresInParserSelector(isFlatCollection)); + } else if (operator == NOT_CONTAINS) { + return parserMap.get(NOT_CONTAINS); + } + + // For EQ/NEQ on array fields with array RHS, use specialized array equality parser (exact match + // instead of containment) + if (shouldUseArrayEqualityParser(expression, postgresQueryParser)) { + return expression.getLhs().accept(new PostgresArrayEqualityParserSelector()); } return parserMap.getOrDefault(expression.getOperator(), postgresStandardRelationalFilterParser); } + + /** + * Determines if EQ/NEQ should be converted to CONTAINS/NOT_CONTAINS. + * + *

Conversion happens when: + * + *

    + *
  • Operator is EQ or NEQ + *
  • RHS is a SCALAR value (not an array/iterable) + *
  • LHS is a JsonIdentifierExpression with an array field type (STRING_ARRAY, NUMBER_ARRAY, + * etc.) OR + *
  • LHS is an ArrayIdentifierExpression with an array type (TEXT, BIGINT, etc.) + *
  • Field has NOT been unnested (unnested fields are scalar, not arrays) + *
+ * + *

If RHS is an array, we DO NOT convert - we want exact equality match (= operator), not + * containment (@> operator). + * + *

This provides semantic equivalence: checking if an array contains a scalar value is more + * intuitive than checking if the array equals the value. + */ + private boolean shouldConvertEqToContains( + final RelationalExpression expression, final PostgresQueryParser postgresQueryParser) { + if (expression.getOperator() != EQ && expression.getOperator() != NEQ) { + return false; + } + + // Check if RHS is an array/iterable - if so, don't convert (we want exact match) + if (isArrayRhs(expression.getRhs())) { + return false; + } + + // Check if LHS is an array field + if (!isArrayField(expression.getLhs())) { + return false; + } + + // Check if field has been unnested - unnested fields are scalar, not arrays + String fieldName = getFieldName(expression.getLhs()); + return fieldName == null + || !postgresQueryParser + .getPgColumnNames() + .containsKey(fieldName); // Field is unnested - treat as scalar + } + + /** + * Determines if we should use the specialized array equality parser. + * + *

Use this parser when: + * + *

    + *
  • Operator is EQ or NEQ + *
  • RHS is an array/iterable (for exact match). + *
  • LHS is either {@link JsonIdentifierExpression} with array type OR {@link + * ArrayIdentifierExpression} + *
  • Field has NOT been unnested (unnested fields are scalar, not arrays) + *
+ */ + private boolean shouldUseArrayEqualityParser( + final RelationalExpression expression, final PostgresQueryParser postgresQueryParser) { + if (expression.getOperator() != EQ && expression.getOperator() != NEQ) { + return false; + } + + // Check if RHS is an array/iterable AND LHS is an array field + if (!isArrayRhs(expression.getRhs()) || !isArrayField(expression.getLhs())) { + return false; + } + + // Check if field has been unnested - unnested fields are scalar, not arrays + String fieldName = getFieldName(expression.getLhs()); + return fieldName == null || !postgresQueryParser.getPgColumnNames().containsKey(fieldName); + } + + /** + * Checks if the RHS expression contains an array/iterable value. Currently, we don't have a very + * clean way to get the RHS data type. //todo: Implement a clean way to get the RHS data type + */ + private boolean isArrayRhs(final SelectTypeExpression rhs) { + if (rhs instanceof ConstantExpression) { + ConstantExpression constExpr = (ConstantExpression) rhs; + return constExpr.getValue() instanceof Iterable; + } + return false; + } + + /** Checks if the LHS expression is an array field. */ + private boolean isArrayField(final SelectTypeExpression lhs) { + if (lhs instanceof JsonIdentifierExpression) { + JsonIdentifierExpression jsonExpr = (JsonIdentifierExpression) lhs; + return jsonExpr + .getFieldType() + .map( + fieldType -> + fieldType == JsonFieldType.BOOLEAN_ARRAY + || fieldType == JsonFieldType.STRING_ARRAY + || fieldType == JsonFieldType.NUMBER_ARRAY + || fieldType == JsonFieldType.OBJECT_ARRAY) + .orElse(false); + } + return lhs instanceof ArrayIdentifierExpression; + } + + /** Extracts the field name from an identifier expression. */ + private String getFieldName(final SelectTypeExpression lhs) { + if (lhs instanceof JsonIdentifierExpression) { + return ((JsonIdentifierExpression) lhs).getName(); + } else if (lhs instanceof ArrayIdentifierExpression) { + return ((ArrayIdentifierExpression) lhs).getName(); + } + return null; + } } diff --git a/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/query/v1/parser/filter/PostgresTopLevelArrayEqualityFilterParser.java b/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/query/v1/parser/filter/PostgresTopLevelArrayEqualityFilterParser.java new file mode 100644 index 00000000..7fd7ead6 --- /dev/null +++ b/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/query/v1/parser/filter/PostgresTopLevelArrayEqualityFilterParser.java @@ -0,0 +1,66 @@ +package org.hypertrace.core.documentstore.postgres.query.v1.parser.filter; + +import java.util.Collections; +import java.util.stream.Collectors; +import java.util.stream.StreamSupport; +import org.hypertrace.core.documentstore.expression.impl.ArrayIdentifierExpression; +import org.hypertrace.core.documentstore.expression.impl.RelationalExpression; +import org.hypertrace.core.documentstore.postgres.query.v1.parser.filter.nonjson.field.PostgresArrayTypeExtractor; + +/** + * Handles EQ/NEQ operations on top-level array columns when RHS is also an array, using exact + * equality (=) instead of containment (@>). + * + *

Generates: {@code tags = ARRAY['hygiene','family-pack']::text[]} + */ +class PostgresTopLevelArrayEqualityFilterParser implements PostgresRelationalFilterParser { + + private static final PostgresStandardRelationalOperatorMapper mapper = + new PostgresStandardRelationalOperatorMapper(); + + @Override + public String parse( + final RelationalExpression expression, final PostgresRelationalFilterContext context) { + final String parsedLhs = expression.getLhs().accept(context.lhsParser()); + final Object parsedRhs = expression.getRhs().accept(context.rhsParser()); + final String operator = mapper.getMapping(expression.getOperator(), parsedRhs); + + if (parsedRhs == null) { + return String.format("%s %s NULL", parsedLhs, operator); + } + + // Normalize to an Iterable + Iterable values = normalizeToIterable(parsedRhs); + + // Add each value as an individual parameter + String placeholders = + StreamSupport.stream(values.spliterator(), false) + .map( + value -> { + context.getParamsBuilder().addObjectParam(value); + return "?"; + }) + .collect(Collectors.joining(", ")); + + ArrayIdentifierExpression arrayExpr = (ArrayIdentifierExpression) expression.getLhs(); + String arrayTypeCast = arrayExpr.accept(new PostgresArrayTypeExtractor()); + + // Generate: tags = ARRAY[?, ?]::text[] + if (arrayTypeCast != null) { + return String.format("%s %s ARRAY[%s]::%s", parsedLhs, operator, placeholders, arrayTypeCast); + } else { + // Fallback to text[] cast + return String.format("%s %s ARRAY[%s]::text[]", parsedLhs, operator, placeholders); + } + } + + private Iterable normalizeToIterable(final Object value) { + if (value == null) { + return Collections.emptyList(); + } else if (value instanceof Iterable) { + return (Iterable) value; + } else { + return Collections.singletonList(value); + } + } +} diff --git a/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/query/v1/parser/filter/nonjson/field/PostgresArrayTypeExtractor.java b/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/query/v1/parser/filter/nonjson/field/PostgresArrayTypeExtractor.java index 6d58153b..7c5070c6 100644 --- a/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/query/v1/parser/filter/nonjson/field/PostgresArrayTypeExtractor.java +++ b/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/query/v1/parser/filter/nonjson/field/PostgresArrayTypeExtractor.java @@ -26,7 +26,7 @@ *
  • {@code null} if {@link ArrayIdentifierExpression} is used without an explicit type * */ -class PostgresArrayTypeExtractor implements SelectTypeExpressionVisitor { +public class PostgresArrayTypeExtractor implements SelectTypeExpressionVisitor { public PostgresArrayTypeExtractor() {} diff --git a/document-store/src/test/java/org/hypertrace/core/documentstore/postgres/query/v1/parser/filter/PostgresArrayEqualityParserSelectorTest.java b/document-store/src/test/java/org/hypertrace/core/documentstore/postgres/query/v1/parser/filter/PostgresArrayEqualityParserSelectorTest.java new file mode 100644 index 00000000..aad19276 --- /dev/null +++ b/document-store/src/test/java/org/hypertrace/core/documentstore/postgres/query/v1/parser/filter/PostgresArrayEqualityParserSelectorTest.java @@ -0,0 +1,118 @@ +package org.hypertrace.core.documentstore.postgres.query.v1.parser.filter; + +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import org.hypertrace.core.documentstore.expression.impl.AggregateExpression; +import org.hypertrace.core.documentstore.expression.impl.AliasedIdentifierExpression; +import org.hypertrace.core.documentstore.expression.impl.ArrayIdentifierExpression; +import org.hypertrace.core.documentstore.expression.impl.ArrayType; +import org.hypertrace.core.documentstore.expression.impl.ConstantExpression; +import org.hypertrace.core.documentstore.expression.impl.ConstantExpression.DocumentConstantExpression; +import org.hypertrace.core.documentstore.expression.impl.FunctionExpression; +import org.hypertrace.core.documentstore.expression.impl.IdentifierExpression; +import org.hypertrace.core.documentstore.expression.impl.JsonFieldType; +import org.hypertrace.core.documentstore.expression.impl.JsonIdentifierExpression; +import org.hypertrace.core.documentstore.expression.operators.AggregationOperator; +import org.hypertrace.core.documentstore.expression.operators.FunctionOperator; +import org.junit.jupiter.api.Test; + +class PostgresArrayEqualityParserSelectorTest { + + @Test + void testVisitJsonIdentifierExpression() { + PostgresArrayEqualityParserSelector selector = new PostgresArrayEqualityParserSelector(); + JsonIdentifierExpression expr = + JsonIdentifierExpression.of("props", JsonFieldType.STRING_ARRAY, "colors"); + + Object result = selector.visit(expr); + + assertNotNull(result); + assertInstanceOf(PostgresJsonArrayEqualityFilterParser.class, result); + } + + @Test + void testVisitArrayIdentifierExpression() { + PostgresArrayEqualityParserSelector selector = new PostgresArrayEqualityParserSelector(); + ArrayIdentifierExpression expr = ArrayIdentifierExpression.of("tags", ArrayType.TEXT); + + Object result = selector.visit(expr); + + assertNotNull(result); + assertInstanceOf(PostgresTopLevelArrayEqualityFilterParser.class, result); + } + + @Test + void testVisitIdentifierExpressionFallsBackToStandardParser() { + PostgresArrayEqualityParserSelector selector = new PostgresArrayEqualityParserSelector(); + IdentifierExpression expr = IdentifierExpression.of("item"); + + Object result = selector.visit(expr); + + assertNotNull(result); + assertInstanceOf(PostgresStandardRelationalFilterParser.class, result); + } + + @Test + void testVisitAggregateExpressionFallsBackToStandardParser() { + PostgresArrayEqualityParserSelector selector = new PostgresArrayEqualityParserSelector(); + AggregateExpression expr = + AggregateExpression.of(AggregationOperator.COUNT, IdentifierExpression.of("item")); + + Object result = selector.visit(expr); + + assertNotNull(result); + assertInstanceOf(PostgresStandardRelationalFilterParser.class, result); + } + + @Test + void testVisitConstantExpressionFallsBackToStandardParser() { + PostgresArrayEqualityParserSelector selector = new PostgresArrayEqualityParserSelector(); + ConstantExpression expr = ConstantExpression.of("test"); + + Object result = selector.visit(expr); + + assertNotNull(result); + assertInstanceOf(PostgresStandardRelationalFilterParser.class, result); + } + + @Test + void testVisitDocumentConstantExpressionFallsBackToStandardParser() { + PostgresArrayEqualityParserSelector selector = new PostgresArrayEqualityParserSelector(); + DocumentConstantExpression expr = + (DocumentConstantExpression) + ConstantExpression.of((org.hypertrace.core.documentstore.Document) null); + + Object result = selector.visit(expr); + + assertNotNull(result); + assertInstanceOf(PostgresStandardRelationalFilterParser.class, result); + } + + @Test + void testVisitFunctionExpressionFallsBackToStandardParser() { + PostgresArrayEqualityParserSelector selector = new PostgresArrayEqualityParserSelector(); + FunctionExpression expr = + FunctionExpression.builder() + .operator(FunctionOperator.LENGTH) + .operand(IdentifierExpression.of("item")) + .build(); + + Object result = selector.visit(expr); + + assertNotNull(result); + assertInstanceOf(PostgresStandardRelationalFilterParser.class, result); + } + + @Test + void testVisitAliasedIdentifierExpressionFallsBackToStandardParser() { + PostgresArrayEqualityParserSelector selector = new PostgresArrayEqualityParserSelector(); + AliasedIdentifierExpression expr = + AliasedIdentifierExpression.builder().name("item").contextAlias("i").build(); + + Object result = selector.visit(expr); + + assertNotNull(result); + assertInstanceOf(PostgresStandardRelationalFilterParser.class, result); + } +} diff --git a/document-store/src/test/java/org/hypertrace/core/documentstore/postgres/query/v1/parser/filter/PostgresJsonArrayEqualityFilterParserTest.java b/document-store/src/test/java/org/hypertrace/core/documentstore/postgres/query/v1/parser/filter/PostgresJsonArrayEqualityFilterParserTest.java new file mode 100644 index 00000000..d2d5da5d --- /dev/null +++ b/document-store/src/test/java/org/hypertrace/core/documentstore/postgres/query/v1/parser/filter/PostgresJsonArrayEqualityFilterParserTest.java @@ -0,0 +1,103 @@ +package org.hypertrace.core.documentstore.postgres.query.v1.parser.filter; + +import static org.hypertrace.core.documentstore.expression.operators.RelationalOperator.EQ; +import static org.hypertrace.core.documentstore.expression.operators.RelationalOperator.NEQ; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.List; +import org.hypertrace.core.documentstore.expression.impl.ConstantExpression; +import org.hypertrace.core.documentstore.expression.impl.JsonFieldType; +import org.hypertrace.core.documentstore.expression.impl.JsonIdentifierExpression; +import org.hypertrace.core.documentstore.expression.impl.RelationalExpression; +import org.hypertrace.core.documentstore.postgres.Params; +import org.hypertrace.core.documentstore.postgres.query.v1.PostgresQueryParser; +import org.hypertrace.core.documentstore.postgres.query.v1.parser.filter.PostgresRelationalFilterParser.PostgresRelationalFilterContext; +import org.hypertrace.core.documentstore.postgres.query.v1.vistors.PostgresSelectTypeExpressionVisitor; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class PostgresJsonArrayEqualityFilterParserTest { + + private PostgresJsonArrayEqualityFilterParser parser; + private PostgresRelationalFilterContext context; + private PostgresSelectTypeExpressionVisitor lhsParser; + private PostgresQueryParser queryParser; + private Params.Builder paramsBuilder; + + @BeforeEach + void setUp() { + parser = new PostgresJsonArrayEqualityFilterParser(); + lhsParser = mock(PostgresSelectTypeExpressionVisitor.class); + queryParser = mock(PostgresQueryParser.class); + paramsBuilder = Params.newBuilder(); + + when(queryParser.getParamsBuilder()).thenReturn(paramsBuilder); + + context = + PostgresRelationalFilterContext.builder() + .lhsParser(lhsParser) + .postgresQueryParser(queryParser) + .build(); + } + + @Test + void testParseWithNullRHS() { + JsonIdentifierExpression lhs = + JsonIdentifierExpression.of("props", JsonFieldType.STRING_ARRAY, "colors"); + // RHS parsed value will be null from rhsParser + ConstantExpression rhs = ConstantExpression.of("ignored"); + RelationalExpression expression = RelationalExpression.of(lhs, EQ, rhs); + + when(lhsParser.visit(any(JsonIdentifierExpression.class))).thenReturn("props->'colors'"); + // Simulate rhsParser returning null + PostgresSelectTypeExpressionVisitor rhsParser = mock(PostgresSelectTypeExpressionVisitor.class); + when(rhsParser.visit(rhs)).thenReturn(null); + context = + PostgresRelationalFilterContext.builder() + .lhsParser(lhsParser) + .rhsParser(rhsParser) + .postgresQueryParser(queryParser) + .build(); + + String result = parser.parse(expression, context); + + assertEquals("props->'colors' IS NULL", result); + assertEquals(0, paramsBuilder.build().getObjectParams().size()); + } + + @Test + void testParseIterableRhsEq() { + JsonIdentifierExpression lhs = + JsonIdentifierExpression.of("props", JsonFieldType.STRING_ARRAY, "colors"); + ConstantExpression rhs = ConstantExpression.ofStrings(List.of("Blue", "Green")); + RelationalExpression expression = RelationalExpression.of(lhs, EQ, rhs); + + when(lhsParser.visit(any(JsonIdentifierExpression.class))).thenReturn("props->'colors'"); + + String result = parser.parse(expression, context); + + assertEquals("props->'colors' = ?::jsonb", result); + assertEquals(1, paramsBuilder.build().getObjectParams().size()); + assertEquals("[\"Blue\",\"Green\"]", paramsBuilder.build().getObjectParams().get(1)); + } + + /** Tests parsing when RHS is an iterable (e.g., list of strings) and the operator is NEQ */ + @Test + void testParseIterableRhsNeq() { + JsonIdentifierExpression lhs = + JsonIdentifierExpression.of("props", JsonFieldType.STRING_ARRAY, "colors"); + ConstantExpression rhs = ConstantExpression.ofStrings(List.of("Blue", "Red")); + RelationalExpression expression = RelationalExpression.of(lhs, NEQ, rhs); + + when(lhsParser.visit(any(JsonIdentifierExpression.class))).thenReturn("props->'colors'"); + + String result = parser.parse(expression, context); + + assertEquals("props->'colors' != ?::jsonb", result); + assertEquals(1, paramsBuilder.build().getObjectParams().size()); + assertEquals("[\"Blue\",\"Red\"]", paramsBuilder.build().getObjectParams().get(1)); + } +} diff --git a/document-store/src/test/java/org/hypertrace/core/documentstore/postgres/query/v1/parser/filter/PostgresTopLevelArrayEqualityFilterParserTest.java b/document-store/src/test/java/org/hypertrace/core/documentstore/postgres/query/v1/parser/filter/PostgresTopLevelArrayEqualityFilterParserTest.java new file mode 100644 index 00000000..bc2369f7 --- /dev/null +++ b/document-store/src/test/java/org/hypertrace/core/documentstore/postgres/query/v1/parser/filter/PostgresTopLevelArrayEqualityFilterParserTest.java @@ -0,0 +1,115 @@ +package org.hypertrace.core.documentstore.postgres.query.v1.parser.filter; + +import static org.hypertrace.core.documentstore.expression.operators.RelationalOperator.EQ; +import static org.hypertrace.core.documentstore.expression.operators.RelationalOperator.NEQ; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.List; +import org.hypertrace.core.documentstore.expression.impl.ArrayIdentifierExpression; +import org.hypertrace.core.documentstore.expression.impl.ArrayType; +import org.hypertrace.core.documentstore.expression.impl.ConstantExpression; +import org.hypertrace.core.documentstore.expression.impl.RelationalExpression; +import org.hypertrace.core.documentstore.postgres.Params; +import org.hypertrace.core.documentstore.postgres.query.v1.PostgresQueryParser; +import org.hypertrace.core.documentstore.postgres.query.v1.parser.filter.PostgresRelationalFilterParser.PostgresRelationalFilterContext; +import org.hypertrace.core.documentstore.postgres.query.v1.vistors.PostgresSelectTypeExpressionVisitor; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class PostgresTopLevelArrayEqualityFilterParserTest { + + private PostgresTopLevelArrayEqualityFilterParser parser; + private PostgresRelationalFilterContext context; + private PostgresSelectTypeExpressionVisitor lhsParser; + private PostgresQueryParser queryParser; + private Params.Builder paramsBuilder; + + @BeforeEach + void setUp() { + parser = new PostgresTopLevelArrayEqualityFilterParser(); + lhsParser = mock(PostgresSelectTypeExpressionVisitor.class); + queryParser = mock(PostgresQueryParser.class); + paramsBuilder = Params.newBuilder(); + + when(queryParser.getParamsBuilder()).thenReturn(paramsBuilder); + + context = + PostgresRelationalFilterContext.builder() + .lhsParser(lhsParser) + .postgresQueryParser(queryParser) + .build(); + } + + @Test + void testParseNullRhs() { + ArrayIdentifierExpression lhs = ArrayIdentifierExpression.of("tags", ArrayType.TEXT); + ConstantExpression rhs = ConstantExpression.of("ignored"); + RelationalExpression expression = RelationalExpression.of(lhs, EQ, rhs); + + when(lhsParser.visit(any(ArrayIdentifierExpression.class))).thenReturn("tags"); + // Simulate rhsParser returning null + PostgresSelectTypeExpressionVisitor rhsParser = mock(PostgresSelectTypeExpressionVisitor.class); + when(rhsParser.visit(rhs)).thenReturn(null); + context = + PostgresRelationalFilterContext.builder() + .lhsParser(lhsParser) + .rhsParser(rhsParser) + .postgresQueryParser(queryParser) + .build(); + + String result = parser.parse(expression, context); + + assertEquals("tags IS NULL", result); + assertEquals(0, paramsBuilder.build().getObjectParams().size()); + } + + @Test + void testParseScalarRhsEq() { + ArrayIdentifierExpression lhs = ArrayIdentifierExpression.of("tags", ArrayType.TEXT); + ConstantExpression rhs = ConstantExpression.of("hygiene"); + RelationalExpression expression = RelationalExpression.of(lhs, EQ, rhs); + + when(lhsParser.visit(any(ArrayIdentifierExpression.class))).thenReturn("tags"); + + String result = parser.parse(expression, context); + + assertEquals("tags = ARRAY[?]::text[]", result); + assertEquals(1, paramsBuilder.build().getObjectParams().size()); + assertEquals("hygiene", paramsBuilder.build().getObjectParams().get(1)); + } + + @Test + void testParseIterableRhsEq() { + ArrayIdentifierExpression lhs = ArrayIdentifierExpression.of("tags", ArrayType.TEXT); + ConstantExpression rhs = ConstantExpression.ofStrings(List.of("hygiene", "family-pack")); + RelationalExpression expression = RelationalExpression.of(lhs, EQ, rhs); + + when(lhsParser.visit(any(ArrayIdentifierExpression.class))).thenReturn("tags"); + + String result = parser.parse(expression, context); + + assertEquals("tags = ARRAY[?, ?]::text[]", result); + assertEquals(2, paramsBuilder.build().getObjectParams().size()); + assertEquals("hygiene", paramsBuilder.build().getObjectParams().get(1)); + assertEquals("family-pack", paramsBuilder.build().getObjectParams().get(2)); + } + + @Test + void testParseIterableRhsNeq() { + ArrayIdentifierExpression lhs = ArrayIdentifierExpression.of("tags", ArrayType.TEXT); + ConstantExpression rhs = ConstantExpression.ofStrings(List.of("a", "b")); + RelationalExpression expression = RelationalExpression.of(lhs, NEQ, rhs); + + when(lhsParser.visit(any(ArrayIdentifierExpression.class))).thenReturn("tags"); + + String result = parser.parse(expression, context); + + assertEquals("tags != ARRAY[?, ?]::text[]", result); + assertEquals(2, paramsBuilder.build().getObjectParams().size()); + assertEquals("a", paramsBuilder.build().getObjectParams().get(1)); + assertEquals("b", paramsBuilder.build().getObjectParams().get(2)); + } +}