From fb9faf8cc7abccc55479cbcc7195b683a082789b Mon Sep 17 00:00:00 2001 From: Prashant Pandey Date: Fri, 5 Dec 2025 13:04:06 +0530 Subject: [PATCH 1/8] Added test cases for the following when LHS is a JSON nested array: 1. CONTAINS scalar. 2. NOT_CONTAINS scalar. 3. CONTAINS array. 4. NOT_CONTAINS array. --- .../documentstore/DocStoreQueryV1Test.java | 304 +++++++++--------- 1 file changed, 154 insertions(+), 150 deletions(-) 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 c4812528..deceadc4 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 @@ -3215,9 +3215,8 @@ class FlatPostgresCollectionGeneralQueries { @ParameterizedTest @ArgumentsSource(PostgresProvider.class) void testFindAll(String dataStoreName) throws IOException { - Datastore datastore = datastoreMap.get(dataStoreName); - Collection flatCollection = - datastore.getCollectionForType(FLAT_COLLECTION_NAME, DocumentType.FLAT); + + Collection flatCollection = getFlatCollection(dataStoreName); // Test basic query to retrieve all documents Query query = Query.builder().build(); @@ -3242,9 +3241,7 @@ void testFindAll(String dataStoreName) throws IOException { @ParameterizedTest @ArgumentsSource(PostgresProvider.class) void testUnnestPreserveEmptyArraysFalse(String dataStoreName) throws IOException { - Datastore datastore = datastoreMap.get(dataStoreName); - Collection flatCollection = - datastore.getCollectionForType(FLAT_COLLECTION_NAME, DocumentType.FLAT); + Collection flatCollection = getFlatCollection(dataStoreName); Query unnestQuery = Query.builder() @@ -3262,9 +3259,7 @@ void testUnnestPreserveEmptyArraysFalse(String dataStoreName) throws IOException @ParameterizedTest @ArgumentsSource(PostgresProvider.class) void testUnnestPreserveEmptyArraysTrue(String dataStoreName) throws IOException { - Datastore datastore = datastoreMap.get(dataStoreName); - Collection flatCollection = - datastore.getCollectionForType(FLAT_COLLECTION_NAME, DocumentType.FLAT); + Collection flatCollection = getFlatCollection(dataStoreName); // Include all documents in result irrespective of tags field (LEFT JOIN) // Counts rows after unnest: 25 (from 8 docs with tags) + 2 (from docs with NULL/empty) @@ -3426,9 +3421,7 @@ void testFlatVsNestedCollectionConsistency(String dataStoreName) throws IOExcept @ParameterizedTest @ArgumentsSource(PostgresProvider.class) void testFlatPostgresCollectionUnnestWithComplexQuery(String dataStoreName) throws IOException { - Datastore datastore = datastoreMap.get(dataStoreName); - Collection flatCollection = - datastore.getCollectionForType(FLAT_COLLECTION_NAME, DocumentType.FLAT); + Collection flatCollection = getFlatCollection(dataStoreName); // Tests UNNEST with WHERE filter (price >= 5), unnest filter (NOT LIKE 'home-%'), // GROUP BY tags, HAVING (count > 1), ORDER BY count DESC @@ -3471,9 +3464,7 @@ void testFlatPostgresCollectionUnnestWithComplexQuery(String dataStoreName) thro @ArgumentsSource(PostgresProvider.class) void testFlatPostgresCollectionUnnestWithOnlyUnnestFilter(String dataStoreName) throws IOException { - Datastore datastore = datastoreMap.get(dataStoreName); - Collection flatCollection = - datastore.getCollectionForType(FLAT_COLLECTION_NAME, DocumentType.FLAT); + Collection flatCollection = getFlatCollection(dataStoreName); // Query with unnest filter but NO main WHERE filter Query unnestFilterOnlyQuery = @@ -3501,9 +3492,7 @@ void testFlatPostgresCollectionUnnestWithOnlyUnnestFilter(String dataStoreName) @ArgumentsSource(PostgresProvider.class) void testFlatPostgresCollectionUnnestWithOnlyMainFilter(String dataStoreName) throws IOException { - Datastore datastore = datastoreMap.get(dataStoreName); - Collection flatCollection = - datastore.getCollectionForType(FLAT_COLLECTION_NAME, DocumentType.FLAT); + Collection flatCollection = getFlatCollection(dataStoreName); // Query with main WHERE filter but NO unnest filter Query mainFilterOnlyQuery = @@ -3528,9 +3517,7 @@ void testFlatPostgresCollectionUnnestWithOnlyMainFilter(String dataStoreName) @ParameterizedTest @ArgumentsSource(PostgresProvider.class) void testFlatPostgresCollectionArrayRelationalFilter(String dataStoreName) throws IOException { - Datastore datastore = datastoreMap.get(dataStoreName); - Collection flatCollection = - datastore.getCollectionForType(FLAT_COLLECTION_NAME, DocumentType.FLAT); + Collection flatCollection = getFlatCollection(dataStoreName); // Filter: ANY tag in tags equals "hygiene" AND _id <= 8 // Exclude docs 9-10 (NULL/empty arrays) to avoid ARRAY[] type error @@ -3564,9 +3551,7 @@ void testFlatPostgresCollectionArrayRelationalFilter(String dataStoreName) throw @ParameterizedTest @ArgumentsSource(PostgresProvider.class) void testFlatPostgresCollectionUnnestMixedCaseField(String dataStoreName) throws IOException { - Datastore datastore = datastoreMap.get(dataStoreName); - Collection flatCollection = - datastore.getCollectionForType(FLAT_COLLECTION_NAME, DocumentType.FLAT); + Collection flatCollection = getFlatCollection(dataStoreName); // Test UNNEST on field with mixed case: categoryTags // This will create alias "categoryTags_unnested" which must be quoted to preserve case @@ -3734,9 +3719,7 @@ class FlatCollectionScalarColumns { @ParameterizedTest @ArgumentsSource(PostgresProvider.class) void testGroupBy(String dataStoreName) throws IOException { - Datastore datastore = datastoreMap.get(dataStoreName); - Collection flatCollection = - datastore.getCollectionForType(FLAT_COLLECTION_NAME, DocumentType.FLAT); + Collection flatCollection = getFlatCollection(dataStoreName); // Test GROUP BY on scalar field (item) with COUNT aggregation Query groupByQuery = @@ -3769,9 +3752,7 @@ void testGroupBy(String dataStoreName) throws IOException { @ParameterizedTest @ArgumentsSource(PostgresProvider.class) void testAllRelationalOps(String dataStoreName) { - Datastore datastore = datastoreMap.get(dataStoreName); - Collection flatCollection = - datastore.getCollectionForType(FLAT_COLLECTION_NAME, DocumentType.FLAT); + Collection flatCollection = getFlatCollection(dataStoreName); // Test NEQ (Not Equal) on string field Query neqQuery = @@ -3881,9 +3862,7 @@ void testAllRelationalOps(String dataStoreName) { @ParameterizedTest @ArgumentsSource(PostgresProvider.class) void testSorting(String dataStoreName) throws IOException { - Datastore datastore = datastoreMap.get(dataStoreName); - Collection flatCollection = - datastore.getCollectionForType(FLAT_COLLECTION_NAME, DocumentType.FLAT); + Collection flatCollection = getFlatCollection(dataStoreName); // Test 1: Sort by string field ASC Query sortItemAscQuery = @@ -3968,9 +3947,7 @@ void testSorting(String dataStoreName) throws IOException { @ParameterizedTest @ArgumentsSource(PostgresProvider.class) void testNumericAggregations(String dataStoreName) throws IOException { - Datastore datastore = datastoreMap.get(dataStoreName); - Collection flatCollection = - datastore.getCollectionForType(FLAT_COLLECTION_NAME, DocumentType.FLAT); + Collection flatCollection = getFlatCollection(dataStoreName); // Test SUM, AVG, MIN, MAX, COUNT on integer fields Query aggQuery = @@ -4035,9 +4012,7 @@ void testNumericAggregations(String dataStoreName) throws IOException { @ParameterizedTest @ArgumentsSource(PostgresProvider.class) void testNullHandling(String dataStoreName) throws IOException { - Datastore datastore = datastoreMap.get(dataStoreName); - Collection flatCollection = - datastore.getCollectionForType(FLAT_COLLECTION_NAME, DocumentType.FLAT); + Collection flatCollection = getFlatCollection(dataStoreName); // Note: All scalar fields (item, price, quantity, in_stock) have non-NULL values // in existing data. This test validates correct handling when no NULLs are present. @@ -4099,9 +4074,7 @@ class FlatCollectionTopLevelArrayColumns { @ParameterizedTest @ArgumentsSource(PostgresProvider.class) void testNotEmpty(String dataStoreName) throws JsonProcessingException { - Datastore datastore = datastoreMap.get(dataStoreName); - Collection flatCollection = - datastore.getCollectionForType(FLAT_COLLECTION_NAME, DocumentType.FLAT); + Collection flatCollection = getFlatCollection(dataStoreName); Query query = Query.builder() @@ -4129,9 +4102,7 @@ void testNotEmpty(String dataStoreName) throws JsonProcessingException { @ParameterizedTest @ArgumentsSource(PostgresProvider.class) void testEmpty(String dataStoreName) { - Datastore datastore = datastoreMap.get(dataStoreName); - Collection flatCollection = - datastore.getCollectionForType(FLAT_COLLECTION_NAME, DocumentType.FLAT); + Collection flatCollection = getFlatCollection(dataStoreName); Query query = Query.builder() @@ -4160,9 +4131,7 @@ void testEmpty(String dataStoreName) { @ArgumentsSource(PostgresProvider.class) void testUnnest(String dataStoreName) { - Datastore datastore = datastoreMap.get(dataStoreName); - Collection flatCollection = - datastore.getCollectionForType(FLAT_COLLECTION_NAME, DocumentType.FLAT); + Collection flatCollection = getFlatCollection(dataStoreName); for (boolean preserveNullAndEmpty : List.of(true, false)) { Query unnestQuery = @@ -4194,9 +4163,7 @@ void testUnnest(String dataStoreName) { @ParameterizedTest @ArgumentsSource(PostgresProvider.class) void testInStringArray(String dataStoreName) throws JsonProcessingException { - Datastore datastore = datastoreMap.get(dataStoreName); - Collection flatCollection = - datastore.getCollectionForType(FLAT_COLLECTION_NAME, DocumentType.FLAT); + Collection flatCollection = getFlatCollection(dataStoreName); Query inQuery = Query.builder() @@ -4246,9 +4213,7 @@ void testInStringArray(String dataStoreName) throws JsonProcessingException { @ParameterizedTest @ArgumentsSource(PostgresProvider.class) void testNotInStringArray(String dataStoreName) throws JsonProcessingException { - Datastore datastore = datastoreMap.get(dataStoreName); - Collection flatCollection = - datastore.getCollectionForType(FLAT_COLLECTION_NAME, DocumentType.FLAT); + Collection flatCollection = getFlatCollection(dataStoreName); // Test NOT_IN on native array WITHOUT unnest // This should use NOT (array overlap) to check arrays don't contain any of the values @@ -4297,9 +4262,7 @@ void testNotInStringArray(String dataStoreName) throws JsonProcessingException { @ParameterizedTest @ArgumentsSource(PostgresProvider.class) void testInIntArray(String dataStoreName) throws JsonProcessingException { - Datastore datastore = datastoreMap.get(dataStoreName); - Collection flatCollection = - datastore.getCollectionForType(FLAT_COLLECTION_NAME, DocumentType.FLAT); + Collection flatCollection = getFlatCollection(dataStoreName); // Test IN on integer array (numbers column) Query inQuery = @@ -4346,9 +4309,7 @@ void testInIntArray(String dataStoreName) throws JsonProcessingException { @ParameterizedTest @ArgumentsSource(PostgresProvider.class) void testInDoubleArray(String dataStoreName) throws JsonProcessingException { - Datastore datastore = datastoreMap.get(dataStoreName); - Collection flatCollection = - datastore.getCollectionForType(FLAT_COLLECTION_NAME, DocumentType.FLAT); + Collection flatCollection = getFlatCollection(dataStoreName); // Test IN on double precision array (scores column) Query inQuery = @@ -4395,9 +4356,7 @@ void testInDoubleArray(String dataStoreName) throws JsonProcessingException { @ParameterizedTest @ArgumentsSource(PostgresProvider.class) void testInWithUnnest(String dataStoreName) { - Datastore datastore = datastoreMap.get(dataStoreName); - Collection flatCollection = - datastore.getCollectionForType(FLAT_COLLECTION_NAME, DocumentType.FLAT); + Collection flatCollection = getFlatCollection(dataStoreName); for (boolean preserveNullAndEmptyArrays : List.of(true, false)) { Query unnestQuery = @@ -4435,9 +4394,7 @@ void testInWithUnnest(String dataStoreName) { @ParameterizedTest @ArgumentsSource(PostgresProvider.class) void testNotInWithUnnest(String dataStoreName) { - Datastore datastore = datastoreMap.get(dataStoreName); - Collection flatCollection = - datastore.getCollectionForType(FLAT_COLLECTION_NAME, DocumentType.FLAT); + Collection flatCollection = getFlatCollection(dataStoreName); for (boolean preserveNullAndEmptyArrays : List.of(true, false)) { Query unnestQuery = @@ -4476,9 +4433,7 @@ void testNotInWithUnnest(String dataStoreName) { @ParameterizedTest @ArgumentsSource(PostgresProvider.class) void testEmptyWithUnnest(String dataStoreName) { - Datastore datastore = datastoreMap.get(dataStoreName); - Collection flatCollection = - datastore.getCollectionForType(FLAT_COLLECTION_NAME, DocumentType.FLAT); + Collection flatCollection = getFlatCollection(dataStoreName); Query unnestQuery = Query.builder() @@ -4513,9 +4468,7 @@ void testEmptyWithUnnest(String dataStoreName) { @ParameterizedTest @ArgumentsSource(PostgresProvider.class) void testNotEmptyWithUnnest(String dataStoreName) { - Datastore datastore = datastoreMap.get(dataStoreName); - Collection flatCollection = - datastore.getCollectionForType(FLAT_COLLECTION_NAME, DocumentType.FLAT); + Collection flatCollection = getFlatCollection(dataStoreName); Query unnestQuery = Query.builder() @@ -4547,9 +4500,7 @@ void testNotEmptyWithUnnest(String dataStoreName) { @ParameterizedTest @ArgumentsSource(PostgresProvider.class) void testContainsStrArrayWithUnnest(String dataStoreName) { - Datastore datastore = datastoreMap.get(dataStoreName); - Collection flatCollection = - datastore.getCollectionForType(FLAT_COLLECTION_NAME, DocumentType.FLAT); + Collection flatCollection = getFlatCollection(dataStoreName); Query query = Query.builder() @@ -4576,9 +4527,7 @@ void testContainsStrArrayWithUnnest(String dataStoreName) { @ParameterizedTest @ArgumentsSource(PostgresProvider.class) void testContainsStrArray(String dataStoreName) throws JsonProcessingException { - Datastore datastore = datastoreMap.get(dataStoreName); - Collection flatCollection = - datastore.getCollectionForType(FLAT_COLLECTION_NAME, DocumentType.FLAT); + Collection flatCollection = getFlatCollection(dataStoreName); Query query = Query.builder() @@ -4627,9 +4576,7 @@ void testContainsStrArray(String dataStoreName) throws JsonProcessingException { @ParameterizedTest @ArgumentsSource(PostgresProvider.class) void testNotContainsStrArray(String dataStoreName) throws JsonProcessingException { - Datastore datastore = datastoreMap.get(dataStoreName); - Collection flatCollection = - datastore.getCollectionForType(FLAT_COLLECTION_NAME, DocumentType.FLAT); + Collection flatCollection = getFlatCollection(dataStoreName); Query query = Query.builder() @@ -4676,9 +4623,7 @@ void testNotContainsStrArray(String dataStoreName) throws JsonProcessingExceptio @ParameterizedTest @ArgumentsSource(PostgresProvider.class) void testContainsOnIntArray(String dataStoreName) throws JsonProcessingException { - Datastore datastore = datastoreMap.get(dataStoreName); - Collection flatCollection = - datastore.getCollectionForType(FLAT_COLLECTION_NAME, DocumentType.FLAT); + Collection flatCollection = getFlatCollection(dataStoreName); Query query = Query.builder() @@ -4725,9 +4670,7 @@ void testContainsOnIntArray(String dataStoreName) throws JsonProcessingException @ParameterizedTest @ArgumentsSource(PostgresProvider.class) void testNotContainsOnIntArray(String dataStoreName) throws JsonProcessingException { - Datastore datastore = datastoreMap.get(dataStoreName); - Collection flatCollection = - datastore.getCollectionForType(FLAT_COLLECTION_NAME, DocumentType.FLAT); + Collection flatCollection = getFlatCollection(dataStoreName); Query query = Query.builder() @@ -4776,9 +4719,7 @@ void testNotContainsOnIntArray(String dataStoreName) throws JsonProcessingExcept @ParameterizedTest @ArgumentsSource(PostgresProvider.class) void testContainsOnDoubleArray(String dataStoreName) throws JsonProcessingException { - Datastore datastore = datastoreMap.get(dataStoreName); - Collection flatCollection = - datastore.getCollectionForType(FLAT_COLLECTION_NAME, DocumentType.FLAT); + Collection flatCollection = getFlatCollection(dataStoreName); Query query = Query.builder() @@ -4824,9 +4765,7 @@ void testContainsOnDoubleArray(String dataStoreName) throws JsonProcessingExcept @ParameterizedTest @ArgumentsSource(PostgresProvider.class) void testAnyOnIntegerArray(String dataStoreName) throws IOException { - Datastore datastore = datastoreMap.get(dataStoreName); - Collection flatCollection = - datastore.getCollectionForType(FLAT_COLLECTION_NAME, DocumentType.FLAT); + Collection flatCollection = getFlatCollection(dataStoreName); Query integerArrayQuery = Query.builder() @@ -4848,9 +4787,7 @@ void testAnyOnIntegerArray(String dataStoreName) throws IOException { @ParameterizedTest @ArgumentsSource(PostgresProvider.class) void testAnyOnDoubleArray(String dataStoreName) throws IOException { - Datastore datastore = datastoreMap.get(dataStoreName); - Collection flatCollection = - datastore.getCollectionForType(FLAT_COLLECTION_NAME, DocumentType.FLAT); + Collection flatCollection = getFlatCollection(dataStoreName); Query doubleArrayQuery = Query.builder() @@ -4872,9 +4809,7 @@ void testAnyOnDoubleArray(String dataStoreName) throws IOException { @ParameterizedTest @ArgumentsSource(PostgresProvider.class) void testAnyOnBooleanArray(String dataStoreName) throws IOException { - Datastore datastore = datastoreMap.get(dataStoreName); - Collection flatCollection = - datastore.getCollectionForType(FLAT_COLLECTION_NAME, DocumentType.FLAT); + Collection flatCollection = getFlatCollection(dataStoreName); Query booleanArrayQuery = Query.builder() @@ -4900,9 +4835,7 @@ class FlatCollectionJsonbColumns { @ParameterizedTest @ArgumentsSource(PostgresProvider.class) void testSelections(String dataStoreName) throws IOException { - Datastore datastore = datastoreMap.get(dataStoreName); - Collection flatCollection = - datastore.getCollectionForType(FLAT_COLLECTION_NAME, DocumentType.FLAT); + Collection flatCollection = getFlatCollection(dataStoreName); Query brandSelectionQuery = Query.builder() @@ -4940,9 +4873,7 @@ void testSelections(String dataStoreName) throws IOException { @ParameterizedTest @ArgumentsSource(PostgresProvider.class) void testRelOpArrayContains(String dataStoreName) { - Datastore datastore = datastoreMap.get(dataStoreName); - Collection flatCollection = - datastore.getCollectionForType(FLAT_COLLECTION_NAME, DocumentType.FLAT); + Collection flatCollection = getFlatCollection(dataStoreName); // Test 1: CONTAINS - props.colors CONTAINS "Green" // Expected: 1 document (id=1, Dettol Soap has ["Green", "White"]) @@ -4989,9 +4920,7 @@ void testRelOpArrayContains(String dataStoreName) { @ParameterizedTest @ArgumentsSource(PostgresProvider.class) void testRelOpArrayIN(String dataStoreName) { - Datastore datastore = datastoreMap.get(dataStoreName); - Collection flatCollection = - datastore.getCollectionForType(FLAT_COLLECTION_NAME, DocumentType.FLAT); + Collection flatCollection = getFlatCollection(dataStoreName); // Test 1: IN - props.brand IN ["Dettol", "Lifebuoy"] // Expected: 2 documents (id=1 Dettol, id=5 Lifebuoy) @@ -5032,9 +4961,7 @@ void testRelOpArrayIN(String dataStoreName) { @ParameterizedTest @ArgumentsSource(PostgresProvider.class) void testRelOpScalarEq(String dataStoreName) { - Datastore datastore = datastoreMap.get(dataStoreName); - Collection flatCollection = - datastore.getCollectionForType(FLAT_COLLECTION_NAME, DocumentType.FLAT); + Collection flatCollection = getFlatCollection(dataStoreName); Query eqQuery = Query.builder() @@ -5068,9 +4995,7 @@ void testRelOpScalarEq(String dataStoreName) { @ParameterizedTest @ArgumentsSource(PostgresProvider.class) void testRelOpScalarNumericComparison(String dataStoreName) { - Datastore datastore = datastoreMap.get(dataStoreName); - Collection flatCollection = - datastore.getCollectionForType(FLAT_COLLECTION_NAME, DocumentType.FLAT); + Collection flatCollection = getFlatCollection(dataStoreName); Query gtQuery = Query.builder() @@ -5140,9 +5065,7 @@ void testRelOpScalarNumericComparison(String dataStoreName) { @ParameterizedTest @ArgumentsSource(PostgresProvider.class) void testUnnest(String dataStoreName) { - Datastore datastore = datastoreMap.get(dataStoreName); - Collection flatCollection = - datastore.getCollectionForType(FLAT_COLLECTION_NAME, DocumentType.FLAT); + Collection flatCollection = getFlatCollection(dataStoreName); // Test UNNEST on JSONB array field: props.colors // Expected: Should unnest colors and count distinct items with colors @@ -5172,9 +5095,7 @@ void testUnnest(String dataStoreName) { @ParameterizedTest @ArgumentsSource(PostgresProvider.class) void testGroupByScalarField(String dataStoreName) throws IOException { - Datastore datastore = datastoreMap.get(dataStoreName); - Collection flatCollection = - datastore.getCollectionForType(FLAT_COLLECTION_NAME, DocumentType.FLAT); + Collection flatCollection = getFlatCollection(dataStoreName); // Test GROUP BY on JSONB scalar field: props.brand // This tests grouping by a nested string field in a JSONB column @@ -5196,9 +5117,7 @@ void testGroupByScalarField(String dataStoreName) throws IOException { @ParameterizedTest @ArgumentsSource(PostgresProvider.class) void testGroupByArray(String dataStoreName) throws IOException { - Datastore datastore = datastoreMap.get(dataStoreName); - Collection flatCollection = - datastore.getCollectionForType(FLAT_COLLECTION_NAME, DocumentType.FLAT); + Collection flatCollection = getFlatCollection(dataStoreName); // Test GROUP BY on JSONB array field: props.colors with UNNEST // This tests grouping by individual elements (after unnesting) in a JSONB array @@ -5223,9 +5142,7 @@ void testGroupByArray(String dataStoreName) throws IOException { @ParameterizedTest @ArgumentsSource(PostgresProvider.class) void testAnyOnArray(String dataStoreName) { - Datastore datastore = datastoreMap.get(dataStoreName); - Collection flatCollection = - datastore.getCollectionForType(FLAT_COLLECTION_NAME, DocumentType.FLAT); + Collection flatCollection = getFlatCollection(dataStoreName); // Test ArrayRelationalFilterExpression.ANY on JSONB array (props.colors) // This uses jsonb_array_elements() internally @@ -5251,9 +5168,7 @@ void testAnyOnArray(String dataStoreName) { @ParameterizedTest @ArgumentsSource(PostgresProvider.class) void testInOnUnnestedArray(String dataStoreName) throws Exception { - Datastore datastore = datastoreMap.get(dataStoreName); - Collection flatCollection = - datastore.getCollectionForType(FLAT_COLLECTION_NAME, DocumentType.FLAT); + Collection flatCollection = getFlatCollection(dataStoreName); Query unnestQuery = Query.builder() @@ -5293,9 +5208,7 @@ void testInOnUnnestedArray(String dataStoreName) throws Exception { @ParameterizedTest @ArgumentsSource(PostgresProvider.class) void testNotInOnUnnestedArray(String dataStoreName) throws Exception { - Datastore datastore = datastoreMap.get(dataStoreName); - Collection flatCollection = - datastore.getCollectionForType(FLAT_COLLECTION_NAME, DocumentType.FLAT); + Collection flatCollection = getFlatCollection(dataStoreName); Query unnestQuery = Query.builder() @@ -5334,9 +5247,7 @@ void testNotInOnUnnestedArray(String dataStoreName) throws Exception { @ParameterizedTest @ArgumentsSource(PostgresProvider.class) void testExistsOnArrays(String dataStoreName) throws JsonProcessingException { - Datastore datastore = datastoreMap.get(dataStoreName); - Collection flatCollection = - datastore.getCollectionForType(FLAT_COLLECTION_NAME, DocumentType.FLAT); + Collection flatCollection = getFlatCollection(dataStoreName); // Query using EXISTS on JSONB array field // props.colors has: non-empty (rows 1, 3, 5), empty (row 7), NULL (rest) @@ -5376,9 +5287,7 @@ void testExistsOnArrays(String dataStoreName) throws JsonProcessingException { @ParameterizedTest @ArgumentsSource(PostgresProvider.class) void testNotExistsOnArrays(String dataStoreName) throws JsonProcessingException { - Datastore datastore = datastoreMap.get(dataStoreName); - Collection flatCollection = - datastore.getCollectionForType(FLAT_COLLECTION_NAME, DocumentType.FLAT); + Collection flatCollection = getFlatCollection(dataStoreName); // Query using NOT_EXISTS on JSONB array field // Test with props.colors field @@ -5425,9 +5334,7 @@ void testNotExistsOnArrays(String dataStoreName) throws JsonProcessingException @ParameterizedTest @ArgumentsSource(PostgresProvider.class) void testExistsOnScalars(String dataStoreName) { - Datastore datastore = datastoreMap.get(dataStoreName); - Collection flatCollection = - datastore.getCollectionForType(FLAT_COLLECTION_NAME, DocumentType.FLAT); + Collection flatCollection = getFlatCollection(dataStoreName); Query query = Query.builder() @@ -5455,9 +5362,7 @@ void testExistsOnScalars(String dataStoreName) { @ParameterizedTest @ArgumentsSource(PostgresProvider.class) void testNotExistsOnScalars(String dataStoreName) { - Datastore datastore = datastoreMap.get(dataStoreName); - Collection flatCollection = - datastore.getCollectionForType(FLAT_COLLECTION_NAME, DocumentType.FLAT); + Collection flatCollection = getFlatCollection(dataStoreName); Query query = Query.builder() @@ -5485,9 +5390,7 @@ void testNotExistsOnScalars(String dataStoreName) { @ParameterizedTest @ArgumentsSource(PostgresProvider.class) void testExistsOnUnnestedArray(String dataStoreName) { - Datastore datastore = datastoreMap.get(dataStoreName); - Collection flatCollection = - datastore.getCollectionForType(FLAT_COLLECTION_NAME, DocumentType.FLAT); + Collection flatCollection = getFlatCollection(dataStoreName); Query unnestQuery = Query.builder() @@ -5522,9 +5425,7 @@ void testExistsOnUnnestedArray(String dataStoreName) { @ParameterizedTest @ArgumentsSource(PostgresProvider.class) void testNotExistsOnUnnestedArray(String dataStoreName) { - Datastore datastore = datastoreMap.get(dataStoreName); - Collection flatCollection = - datastore.getCollectionForType(FLAT_COLLECTION_NAME, DocumentType.FLAT); + Collection flatCollection = getFlatCollection(dataStoreName); Query unnestQuery = Query.builder() @@ -5553,6 +5454,109 @@ void testNotExistsOnUnnestedArray(String dataStoreName) { } assertEquals(7, count); } + + @ParameterizedTest + @ArgumentsSource(PostgresProvider.class) + void testContainsNotContainsScalarsInArrays(String dataStoreName) { + Collection flatCollection = getFlatCollection(dataStoreName); + + Query containsQuery = + Query.builder() + .addSelection(IdentifierExpression.of("item")) + .setFilter( + RelationalExpression.of( + JsonIdentifierExpression.of( + "props", JsonFieldType.STRING_ARRAY, "source-loc"), + CONTAINS, + ConstantExpression.of("warehouse-A"))) + .build(); + + Iterator resultIterator = flatCollection.find(containsQuery); + + int count = 0; + while (resultIterator.hasNext()) { + Document doc = resultIterator.next(); + assertNotNull(doc); + count++; + } + assertEquals(1, count); + + Query notContainsQuery = + Query.builder() + .addSelection(IdentifierExpression.of("item")) + .setFilter( + RelationalExpression.of( + JsonIdentifierExpression.of( + "props", JsonFieldType.STRING_ARRAY, "source-loc"), + NOT_CONTAINS, + ConstantExpression.of("warehouse-A"))) + .build(); + + resultIterator = flatCollection.find(notContainsQuery); + + count = 0; + while (resultIterator.hasNext()) { + Document doc = resultIterator.next(); + assertNotNull(doc); + count++; + } + assertEquals(9, count); + } + + @ParameterizedTest + @ArgumentsSource(PostgresProvider.class) + void testContainsNotContainsArraysInArrays(String dataStoreName) { + Collection flatCollection = getFlatCollection(dataStoreName); + + // This looks for an exact match for an array: ["warehouse-B", "store-2", "online"] in the + // props->source-loc array. The corresponding Mongo's pipeline is: [{"$match": {"props.colors": {"$elemMatch": {"$eq": ["warehouse-B", "store-2", "online"]}}}}] + Query containsQuery = + Query.builder() + .addSelection(IdentifierExpression.of("item")) + .setFilter( + RelationalExpression.of( + JsonIdentifierExpression.of( + "props", JsonFieldType.STRING_ARRAY, "source-loc"), + CONTAINS, + ConstantExpression.ofStrings(List.of("warehouse-B", "store-2", "online")))) + .build(); + + Iterator resultIterator = flatCollection.find(containsQuery); + + int count = 0; + while (resultIterator.hasNext()) { + Document doc = resultIterator.next(); + assertNotNull(doc); + count++; + } + assertEquals(1, count); + + Query notContainsQuery = + Query.builder() + .addSelection(IdentifierExpression.of("item")) + .setFilter( + RelationalExpression.of( + JsonIdentifierExpression.of( + "props", JsonFieldType.STRING_ARRAY, "source-loc"), + NOT_CONTAINS, + ConstantExpression.ofStrings(List.of("warehouse-B", "store-2", "online")))) + .build(); + + resultIterator = flatCollection.find(notContainsQuery); + + count = 0; + while (resultIterator.hasNext()) { + Document doc = resultIterator.next(); + assertNotNull(doc); + count++; + } + assertEquals(9, count); + } + } + + private static Collection getFlatCollection(String dataStoreName) { + Datastore datastore = datastoreMap.get(dataStoreName); + return datastore.getCollectionForType(FLAT_COLLECTION_NAME, DocumentType.FLAT); } @Nested From a43fb87d677c6368a9a1e1d85097595efe3a138b Mon Sep 17 00:00:00 2001 From: Prashant Pandey Date: Fri, 5 Dec 2025 13:07:46 +0530 Subject: [PATCH 2/8] Spotless --- .../org/hypertrace/core/documentstore/DocStoreQueryV1Test.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 deceadc4..3bcaa27b 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 @@ -5509,7 +5509,8 @@ void testContainsNotContainsArraysInArrays(String dataStoreName) { Collection flatCollection = getFlatCollection(dataStoreName); // This looks for an exact match for an array: ["warehouse-B", "store-2", "online"] in the - // props->source-loc array. The corresponding Mongo's pipeline is: [{"$match": {"props.colors": {"$elemMatch": {"$eq": ["warehouse-B", "store-2", "online"]}}}}] + // props->source-loc array. The corresponding Mongo's pipeline is: [{"$match": + // {"props.colors": {"$elemMatch": {"$eq": ["warehouse-B", "store-2", "online"]}}}}] Query containsQuery = Query.builder() .addSelection(IdentifierExpression.of("item")) From b660f0a3f531ecff917fe41aeb3c954e7e7437bb Mon Sep 17 00:00:00 2001 From: Prashant Pandey Date: Fri, 5 Dec 2025 15:21:36 +0530 Subject: [PATCH 3/8] Add support for equalitys on top-level and nested arrays --- .../documentstore/DocStoreQueryV1Test.java | 175 +++++++++++++----- ...gresSelectExpressionParserBuilderImpl.java | 72 ++++++- .../PostgresArrayEqualityParserSelector.java | 68 +++++++ ...PostgresJsonArrayEqualityFilterParser.java | 44 +++++ ...gresNotContainsRelationalFilterParser.java | 3 +- ...gresRelationalFilterParserFactoryImpl.java | 103 ++++++++++- ...gresTopLevelArrayEqualityFilterParser.java | 66 +++++++ .../field/PostgresArrayTypeExtractor.java | 2 +- 8 files changed, 477 insertions(+), 56 deletions(-) create mode 100644 document-store/src/main/java/org/hypertrace/core/documentstore/postgres/query/v1/parser/filter/PostgresArrayEqualityParserSelector.java create mode 100644 document-store/src/main/java/org/hypertrace/core/documentstore/postgres/query/v1/parser/filter/PostgresJsonArrayEqualityFilterParser.java create mode 100644 document-store/src/main/java/org/hypertrace/core/documentstore/postgres/query/v1/parser/filter/PostgresTopLevelArrayEqualityFilterParser.java 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..7f195772 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 @@ -4524,55 +4524,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 { @@ -4827,6 +4778,82 @@ void testAnyOnBooleanArray(String dataStoreName) throws IOException { assertDocsAndSizeEqualWithoutOrder( dataStoreName, resultIterator, "query/flat_boolean_array_filter_response.json", 5); } + + @ParameterizedTest + @ArgumentsSource(PostgresProvider.class) + void testEqNotEqScalar(String dataStoreName) { + Collection flatCollection = getFlatCollection(dataStoreName); + + // 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( + RelationalExpression.of( + ArrayIdentifierExpression.of("tags", ArrayType.TEXT), + EQ, + ConstantExpression.of("hygiene"))) + .build(); + + 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); + } + + @ParameterizedTest + @ArgumentsSource(PostgresProvider.class) + void testEqNotEqArrays(String dataStoreName) { + Collection flatCollection = getFlatCollection(dataStoreName); + + Query eqQuery = + Query.builder() + .addSelection(IdentifierExpression.of("item")) + .addSelection(ArrayIdentifierExpression.of("tags", ArrayType.TEXT)) + .setFilter( + RelationalExpression.of( + ArrayIdentifierExpression.of("tags", ArrayType.TEXT), + EQ, + ConstantExpression.ofStrings(List.of("hygiene", "family-pack")))) + .build(); + + Iterator results = flatCollection.find(eqQuery); + + int count = 0; + while (results.hasNext()) { + Document next = results.next(); + count++; + } + + assertEquals(0, count); + } } @Nested @@ -5553,6 +5580,56 @@ void testContainsNotContainsArraysInArrays(String dataStoreName) { } assertEquals(9, count); } + + @ParameterizedTest + @ArgumentsSource(PostgresProvider.class) + void testEqNotEqScalarsInArrays(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..000f21b0 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,8 +1,13 @@ 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.postgres.query.v1.PostgresQueryParser; import org.hypertrace.core.documentstore.postgres.query.v1.vistors.PostgresConstantExpressionVisitor; @@ -11,14 +16,20 @@ 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) { + // For EQ/NEQ on array fields, treat like CONTAINS to use -> instead of ->> + boolean isEqOrNeqOnArrayField = isEqOrNeqOnArrayField(expression); + switch (expression.getOperator()) { case CONTAINS: case NOT_CONTAINS: @@ -29,6 +40,15 @@ public PostgresSelectTypeExpressionVisitor build(final RelationalExpression expr return new PostgresFunctionExpressionVisitor( new PostgresFieldIdentifierExpressionVisitor(this.postgresQueryParser)); + case EQ: + case NEQ: + if (isEqOrNeqOnArrayField) { + // 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 +56,52 @@ 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: + * + *

    + *
  • JsonIdentifierExpression with array field type (JSONB arrays) + *
  • ArrayIdentifierExpression with array type (top-level array columns) + *
+ */ + private boolean isEqOrNeqOnArrayField(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()); + } + + /** Checks if the expression is an array field. */ + private boolean isArrayField( + final org.hypertrace.core.documentstore.expression.type.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..ebf156c7 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,103 @@ 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 + if (shouldConvertEqToContains(expression)) { + 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 + if (shouldUseArrayEqualityParser(expression)) { + 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.) + *
+ * + *

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) { + 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; + } + + // RHS is scalar - check if LHS is an array field + return isArrayField(expression.getLhs()); + } + + /** + * 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 JsonIdentifierExpression with array type OR ArrayIdentifierExpression + *
+ */ + private boolean shouldUseArrayEqualityParser(final RelationalExpression expression) { + if (expression.getOperator() != EQ && expression.getOperator() != NEQ) { + return false; + } + // Check if RHS is an array/iterable AND LHS is an array field + return isArrayRhs(expression.getRhs()) && isArrayField(expression.getLhs()); + } + + /** Checks if the RHS expression contains an array/iterable value. */ + 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; + } } 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() {} From 2c70154d4d666d46895ee8a1143d86082c9d66ec Mon Sep 17 00:00:00 2001 From: Prashant Pandey Date: Fri, 5 Dec 2025 16:00:30 +0530 Subject: [PATCH 4/8] WIP --- ...gresRelationalFilterParserFactoryImpl.java | 50 ++++++++++++++++--- 1 file changed, 43 insertions(+), 7 deletions(-) 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 ebf156c7..166665a7 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 @@ -51,7 +51,8 @@ public PostgresRelationalFilterParser parser( RelationalOperator operator = expression.getOperator(); // Transform EQ/NEQ to CONTAINS/NOT_CONTAINS for array fields with scalar RHS - if (shouldConvertEqToContains(expression)) { + // (but not for unnested fields, which are already scalar) + if (shouldConvertEqToContains(expression, postgresQueryParser)) { operator = (expression.getOperator() == EQ) ? CONTAINS : NOT_CONTAINS; } @@ -64,7 +65,7 @@ public PostgresRelationalFilterParser parser( } // For EQ/NEQ on array fields with array RHS, use specialized array equality parser - if (shouldUseArrayEqualityParser(expression)) { + if (shouldUseArrayEqualityParser(expression, postgresQueryParser)) { return expression.getLhs().accept(new PostgresArrayEqualityParserSelector()); } @@ -82,6 +83,7 @@ public PostgresRelationalFilterParser parser( *
  • 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 @@ -90,7 +92,8 @@ public PostgresRelationalFilterParser parser( *

    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) { + private boolean shouldConvertEqToContains( + final RelationalExpression expression, final PostgresQueryParser postgresQueryParser) { if (expression.getOperator() != EQ && expression.getOperator() != NEQ) { return false; } @@ -100,8 +103,18 @@ private boolean shouldConvertEqToContains(final RelationalExpression expression) return false; } - // RHS is scalar - check if LHS is an array field - return isArrayField(expression.getLhs()); + // 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()); + if (fieldName != null && postgresQueryParser.getPgColumnNames().containsKey(fieldName)) { + return false; // Field is unnested - treat as scalar + } + + return true; } /** @@ -113,14 +126,27 @@ private boolean shouldConvertEqToContains(final RelationalExpression expression) *

  • Operator is EQ or NEQ *
  • RHS is an array/iterable (for exact match) *
  • LHS is either JsonIdentifierExpression with array type OR ArrayIdentifierExpression + *
  • Field has NOT been unnested (unnested fields are scalar, not arrays) * */ - private boolean shouldUseArrayEqualityParser(final RelationalExpression expression) { + 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 - return isArrayRhs(expression.getRhs()) && isArrayField(expression.getLhs()); + 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()); + if (fieldName != null && postgresQueryParser.getPgColumnNames().containsKey(fieldName)) { + return false; + } + + return true; } /** Checks if the RHS expression contains an array/iterable value. */ @@ -148,4 +174,14 @@ private boolean isArrayField(final SelectTypeExpression lhs) { } 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; + } } From 079326d23b3b74454ba07e5c8c8fe1d4f9633521 Mon Sep 17 00:00:00 2001 From: Prashant Pandey Date: Mon, 8 Dec 2025 12:38:26 +0530 Subject: [PATCH 5/8] Add more UTs --- .../documentstore/DocStoreQueryV1Test.java | 9 +- ...gresSelectExpressionParserBuilderImpl.java | 18 ++- ...gresRelationalFilterParserFactoryImpl.java | 30 ++--- ...gresJsonArrayEqualityFilterParserTest.java | 119 ++++++++++++++++++ ...TopLevelArrayEqualityFilterParserTest.java | 115 +++++++++++++++++ 5 files changed, 263 insertions(+), 28 deletions(-) create mode 100644 document-store/src/test/java/org/hypertrace/core/documentstore/postgres/query/v1/parser/filter/PostgresJsonArrayEqualityFilterParserTest.java create mode 100644 document-store/src/test/java/org/hypertrace/core/documentstore/postgres/query/v1/parser/filter/PostgresTopLevelArrayEqualityFilterParserTest.java 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 7f195772..976a97cb 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 @@ -4828,9 +4828,10 @@ void testEqNotEqScalar(String dataStoreName) { assertEquals(7, count); } + /** Tests the behavior of EQ/NEQ on array fields with array RHS. */ @ParameterizedTest @ArgumentsSource(PostgresProvider.class) - void testEqNotEqArrays(String dataStoreName) { + void testEqAndNeqOnArrays(String dataStoreName) { Collection flatCollection = getFlatCollection(dataStoreName); Query eqQuery = @@ -5581,9 +5582,13 @@ 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 testEqNotEqScalarsInArrays(String dataStoreName) { + void testEqNeqScalarsOnArrays(String dataStoreName) { Collection flatCollection = getFlatCollection(dataStoreName); // Should be treated like CONTAINS 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 000f21b0..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 @@ -9,6 +9,7 @@ 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; @@ -27,9 +28,6 @@ public PostgresSelectExpressionParserBuilderImpl(PostgresQueryParser postgresQue @Override public PostgresSelectTypeExpressionVisitor build(final RelationalExpression expression) { - // For EQ/NEQ on array fields, treat like CONTAINS to use -> instead of ->> - boolean isEqOrNeqOnArrayField = isEqOrNeqOnArrayField(expression); - switch (expression.getOperator()) { case CONTAINS: case NOT_CONTAINS: @@ -42,13 +40,13 @@ public PostgresSelectTypeExpressionVisitor build(final RelationalExpression expr case EQ: case NEQ: - if (isEqOrNeqOnArrayField) { + // 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( @@ -66,11 +64,11 @@ public PostgresSelectTypeExpressionVisitor build(final RelationalExpression expr *

    Handles both: * *

      - *
    • JsonIdentifierExpression with array field type (JSONB arrays) - *
    • ArrayIdentifierExpression with array type (top-level array columns) + *
    • {@link JsonIdentifierExpression} with array field type (JSONB arrays) + *
    • {@link ArrayIdentifierExpression} with array type (top-level array columns) *
    */ - private boolean isEqOrNeqOnArrayField(final RelationalExpression expression) { + private boolean shouldSwitchToContainsFlow(final RelationalExpression expression) { if (expression.getOperator() != EQ && expression.getOperator() != NEQ) { return false; } @@ -87,9 +85,7 @@ private boolean isEqOrNeqOnArrayField(final RelationalExpression expression) { return isArrayField(expression.getLhs()); } - /** Checks if the expression is an array field. */ - private boolean isArrayField( - final org.hypertrace.core.documentstore.expression.type.SelectTypeExpression lhs) { + private boolean isArrayField(final SelectTypeExpression lhs) { if (lhs instanceof JsonIdentifierExpression) { JsonIdentifierExpression jsonExpr = (JsonIdentifierExpression) lhs; return jsonExpr 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 166665a7..53c0382b 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 @@ -64,7 +64,8 @@ public PostgresRelationalFilterParser parser( return parserMap.get(NOT_CONTAINS); } - // For EQ/NEQ on array fields with array RHS, use specialized array equality parser + // 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()); } @@ -110,11 +111,10 @@ private boolean shouldConvertEqToContains( // Check if field has been unnested - unnested fields are scalar, not arrays String fieldName = getFieldName(expression.getLhs()); - if (fieldName != null && postgresQueryParser.getPgColumnNames().containsKey(fieldName)) { - return false; // Field is unnested - treat as scalar - } - - return true; + return fieldName == null + || !postgresQueryParser + .getPgColumnNames() + .containsKey(fieldName); // Field is unnested - treat as scalar } /** @@ -124,8 +124,9 @@ private boolean shouldConvertEqToContains( * *
      *
    • Operator is EQ or NEQ - *
    • RHS is an array/iterable (for exact match) - *
    • LHS is either JsonIdentifierExpression with array type OR ArrayIdentifierExpression + *
    • 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) *
    */ @@ -142,14 +143,13 @@ private boolean shouldUseArrayEqualityParser( // Check if field has been unnested - unnested fields are scalar, not arrays String fieldName = getFieldName(expression.getLhs()); - if (fieldName != null && postgresQueryParser.getPgColumnNames().containsKey(fieldName)) { - return false; - } - - return true; + return fieldName == null || !postgresQueryParser.getPgColumnNames().containsKey(fieldName); } - /** Checks if the RHS expression contains an array/iterable value. */ + /** + * 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; @@ -172,7 +172,7 @@ private boolean isArrayField(final SelectTypeExpression lhs) { || fieldType == JsonFieldType.OBJECT_ARRAY) .orElse(false); } - return lhs instanceof ArrayIdentifierExpression; + return !(lhs instanceof ArrayIdentifierExpression); } /** Extracts the field name from an identifier expression. */ 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..7defa9a7 --- /dev/null +++ b/document-store/src/test/java/org/hypertrace/core/documentstore/postgres/query/v1/parser/filter/PostgresJsonArrayEqualityFilterParserTest.java @@ -0,0 +1,119 @@ +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 testParse_nullRhs() { + 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' = NULL", result); + assertEquals(0, paramsBuilder.build().getObjectParams().size()); + } + + @Test + void testParseScalarRhsEq() { + JsonIdentifierExpression lhs = + JsonIdentifierExpression.of("props", JsonFieldType.STRING_ARRAY, "colors"); + ConstantExpression rhs = ConstantExpression.of("Blue"); + 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", paramsBuilder.build().getObjectParams().get(1)); + } + + @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)); + } +} From 8f1cbb9a85dac7234827c1c35b02db6839b92a2e Mon Sep 17 00:00:00 2001 From: Prashant Pandey Date: Mon, 8 Dec 2025 13:07:18 +0530 Subject: [PATCH 6/8] Fix test cases --- .../documentstore/DocStoreQueryV1Test.java | 393 +++++++----------- ...gresRelationalFilterParserFactoryImpl.java | 2 +- 2 files changed, 142 insertions(+), 253 deletions(-) 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 976a97cb..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 = @@ -4571,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 { @@ -4669,119 +4545,105 @@ 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("scores", ArrayType.DOUBLE_PRECISION)) + .addSelection(ArrayIdentifierExpression.of("numbers", ArrayType.INTEGER)) .setFilter( RelationalExpression.of( - ArrayIdentifierExpression.of("scores", ArrayType.DOUBLE_PRECISION), + ArrayIdentifierExpression.of("numbers", ArrayType.INTEGER), CONTAINS, - ConstantExpression.ofNumbers(List.of(3.14, 2.71)))) + 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++; - - JsonNode scores = json.get("scores"); - assertNotNull(scores); - assertTrue(scores.isArray(), "scores should be JSON array, got: " + scores); - - 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 intResults = flatCollection.find(intQuery); + int intCount = 0; + while (intResults.hasNext()) { + intResults.next(); + intCount++; } + assertEquals(2, intCount); - assertEquals(1, count); - } - - @ParameterizedTest - @ArgumentsSource(PostgresProvider.class) - void testAnyOnIntegerArray(String dataStoreName) throws IOException { - Collection flatCollection = getFlatCollection(dataStoreName); - - Query integerArrayQuery = + // DOUBLE PRECISION array: scores CONTAINS [3.14, 2.71] + Query doubleQuery = Query.builder() .addSelection(IdentifierExpression.of("item")) + .addSelection(ArrayIdentifierExpression.of("scores", ArrayType.DOUBLE_PRECISION)) .setFilter( - ArrayRelationalFilterExpression.builder() - .operator(ArrayOperator.ANY) - .filter( - RelationalExpression.of( - IdentifierExpression.of("numbers"), EQ, ConstantExpression.of(10))) - .build()) + RelationalExpression.of( + ArrayIdentifierExpression.of("scores", ArrayType.DOUBLE_PRECISION), + CONTAINS, + ConstantExpression.ofNumbers(List.of(3.14, 2.71)))) .build(); - Iterator resultIterator = flatCollection.find(integerArrayQuery); - assertDocsAndSizeEqualWithoutOrder( - dataStoreName, resultIterator, "query/flat_integer_array_filter_response.json", 4); - } - - @ParameterizedTest - @ArgumentsSource(PostgresProvider.class) - void testAnyOnDoubleArray(String dataStoreName) throws IOException { - Collection flatCollection = getFlatCollection(dataStoreName); + Iterator doubleResults = flatCollection.find(doubleQuery); + int doubleCount = 0; + while (doubleResults.hasNext()) { + doubleResults.next(); + doubleCount++; + } + assertEquals(1, doubleCount); - Query doubleArrayQuery = + // STRING array: tags CONTAINS ['hygiene'] + Query stringQuery = 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), + CONTAINS, + ConstantExpression.ofStrings(List.of("hygiene")))) .build(); - Iterator resultIterator = flatCollection.find(doubleArrayQuery); - assertDocsAndSizeEqualWithoutOrder( - dataStoreName, resultIterator, "query/flat_double_array_filter_response.json", 1); + Iterator stringResults = flatCollection.find(stringQuery); + int stringCount = 0; + while (stringResults.hasNext()) { + stringResults.next(); + stringCount++; + } + assertEquals(3, stringCount); } @ParameterizedTest @ArgumentsSource(PostgresProvider.class) - void testAnyOnBooleanArray(String dataStoreName) throws IOException { - Collection flatCollection = getFlatCollection(dataStoreName); + 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); - Query booleanArrayQuery = - Query.builder() - .addSelection(IdentifierExpression.of("item")) - .setFilter( - ArrayRelationalFilterExpression.builder() - .operator(ArrayOperator.ANY) - .filter( - RelationalExpression.of( - IdentifierExpression.of("flags"), EQ, ConstantExpression.of(true))) - .build()) - .build(); + // DOUBLE PRECISION array: scores ANY = 3.14 + assertAnyOnArray( + dataStoreName, + "scores", + ConstantExpression.of(3.14), + "query/flat_double_array_filter_response.json", + 1); - Iterator resultIterator = flatCollection.find(booleanArrayQuery); - assertDocsAndSizeEqualWithoutOrder( - dataStoreName, resultIterator, "query/flat_boolean_array_filter_response.json", 5); + // 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 testEqNotEqScalar(String dataStoreName) { + void testEqNotEqToScalar(String dataStoreName) { Collection flatCollection = getFlatCollection(dataStoreName); // EQ/NOT on arrays should behave like CONTAINS/NOT_CONTAINS @@ -4828,10 +4690,12 @@ void testEqNotEqScalar(String dataStoreName) { assertEquals(7, count); } - /** Tests the behavior of EQ/NEQ on array fields with array RHS. */ + /** + * Tests the behavior of EQ/NEQ on array fields with array RHS. This should be an exact match + */ @ParameterizedTest @ArgumentsSource(PostgresProvider.class) - void testEqAndNeqOnArrays(String dataStoreName) { + void testEqAndNeqToArrays(String dataStoreName) { Collection flatCollection = getFlatCollection(dataStoreName); Query eqQuery = @@ -4855,6 +4719,31 @@ void testEqAndNeqOnArrays(String dataStoreName) { assertEquals(0, count); } + + private void assertAnyOnArray( + String dataStoreName, + String fieldName, + ConstantExpression rhs, + String expectedJsonPath, + int expectedCount) + throws IOException { + + Collection flatCollection = getFlatCollection(dataStoreName); + + Query anyArrayQuery = + Query.builder() + .addSelection(IdentifierExpression.of("item")) + .setFilter( + ArrayRelationalFilterExpression.builder() + .operator(ArrayOperator.ANY) + .filter(RelationalExpression.of(IdentifierExpression.of(fieldName), EQ, rhs)) + .build()) + .build(); + + Iterator resultIterator = flatCollection.find(anyArrayQuery); + assertDocsAndSizeEqualWithoutOrder( + dataStoreName, resultIterator, expectedJsonPath, expectedCount); + } } @Nested 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 53c0382b..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 @@ -172,7 +172,7 @@ private boolean isArrayField(final SelectTypeExpression lhs) { || fieldType == JsonFieldType.OBJECT_ARRAY) .orElse(false); } - return !(lhs instanceof ArrayIdentifierExpression); + return lhs instanceof ArrayIdentifierExpression; } /** Extracts the field name from an identifier expression. */ From a67edb56506c802d259704fa863725b9e15cc9a9 Mon Sep 17 00:00:00 2001 From: Prashant Pandey Date: Mon, 8 Dec 2025 13:12:10 +0530 Subject: [PATCH 7/8] Fix test cases --- ...gresJsonArrayEqualityFilterParserTest.java | 20 ++----------------- 1 file changed, 2 insertions(+), 18 deletions(-) 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 index 7defa9a7..d2d5da5d 100644 --- 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 @@ -44,7 +44,7 @@ void setUp() { } @Test - void testParse_nullRhs() { + void testParseWithNullRHS() { JsonIdentifierExpression lhs = JsonIdentifierExpression.of("props", JsonFieldType.STRING_ARRAY, "colors"); // RHS parsed value will be null from rhsParser @@ -64,26 +64,10 @@ void testParse_nullRhs() { String result = parser.parse(expression, context); - assertEquals("props->'colors' = NULL", result); + assertEquals("props->'colors' IS NULL", result); assertEquals(0, paramsBuilder.build().getObjectParams().size()); } - @Test - void testParseScalarRhsEq() { - JsonIdentifierExpression lhs = - JsonIdentifierExpression.of("props", JsonFieldType.STRING_ARRAY, "colors"); - ConstantExpression rhs = ConstantExpression.of("Blue"); - 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", paramsBuilder.build().getObjectParams().get(1)); - } - @Test void testParseIterableRhsEq() { JsonIdentifierExpression lhs = From 5a32cec32350f876e43c9e4ff3b942e64b3b84fe Mon Sep 17 00:00:00 2001 From: Prashant Pandey Date: Mon, 8 Dec 2025 13:22:08 +0530 Subject: [PATCH 8/8] Added PostgresArrayEqualityParserSelectorTest --- ...stgresArrayEqualityParserSelectorTest.java | 118 ++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 document-store/src/test/java/org/hypertrace/core/documentstore/postgres/query/v1/parser/filter/PostgresArrayEqualityParserSelectorTest.java 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); + } +}