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 979fd446..0ff2b650 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 @@ -85,12 +85,11 @@ import org.hypertrace.core.documentstore.commons.DocStoreConstants; 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.ArrayRelationalFilterExpression; -import org.hypertrace.core.documentstore.expression.impl.ArrayType; import org.hypertrace.core.documentstore.expression.impl.ConstantExpression; 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.impl.KeyExpression; import org.hypertrace.core.documentstore.expression.impl.LogicalExpression; @@ -120,7 +119,6 @@ import org.hypertrace.core.documentstore.utils.Utils; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.extension.ExtensionContext; import org.junit.jupiter.params.ParameterizedTest; @@ -3288,48 +3286,70 @@ void testFlatPostgresCollectionCount(String dataStoreName) { } /** - * Tests IN and NOT_IN operators on primitive (non-JSON) fields in flat collections. These - * operators should use simple SQL IN clause instead of array overlap operator for optimal index - * usage. + * Tests IN operator on flat collection fields (top-level columns and JSONB fields) with + * type-specific optimization. + * + *

Flat collection schema: + * + *

+ * + *

Expected SQL patterns for top-level columns: + * + *

+ * + *

Expected SQL patterns for JSONB fields with JsonFieldType: + * + *

*/ @ParameterizedTest @ArgumentsSource(PostgresProvider.class) - void testFlatPostgresCollectionInAndNotInOperators(String dataStoreName) { + void testNestedCollectionInOperatorOnJsonPrimitiveFields(String dataStoreName) { Datastore datastore = datastoreMap.get(dataStoreName); - Collection flatCollection = + Collection collection = datastore.getCollectionForType(FLAT_COLLECTION_NAME, DocumentType.FLAT); - // Test 1: IN operator on _id field - // Expected: 3 documents (IDs 1, 3, 5) - Query idInQuery = + // Test 1: IN operator on top-level STRING column (item) + // Find documents where item is "Soap" OR "Shampoo" + // Expected SQL: "item" IN ('Soap', 'Shampoo') + Query itemInQuery = Query.builder() .setFilter( RelationalExpression.of( - IdentifierExpression.of("_id"), + IdentifierExpression.of("item"), IN, - ConstantExpression.ofNumbers(List.of(1, 3, 5)))) + ConstantExpression.ofStrings(List.of("Soap", "Shampoo")))) .build(); - long idInCount = flatCollection.count(idInQuery); - assertEquals(3, idInCount, "IN operator on _id should find 3 documents"); + long itemInCount = collection.count(itemInQuery); + assertEquals(5, itemInCount); // 3 Soap + 2 Shampoo documents - // Test 2: IN operator on item field (string) - // Expected: 5 documents (IDs 1, 3, 4 for Shampoo and 1, 5, 8 for Soap) - Query itemInQuery = + // Test 2: IN operator on top-level NUMBER column (quantity) + // Find documents where quantity is 5 OR 10 + // Expected SQL: "quantity" IN (5, 10) + Query quantityInQuery = Query.builder() .setFilter( RelationalExpression.of( - IdentifierExpression.of("item"), + IdentifierExpression.of("quantity"), IN, - ConstantExpression.ofStrings(List.of("Soap", "Shampoo")))) + ConstantExpression.ofNumbers(List.of(5, 10)))) .build(); - long itemInCount = flatCollection.count(itemInQuery); - assertEquals( - 5, itemInCount, "IN operator on item should find 5 documents (3 Soap + 2 Shampoo)"); + long quantityInCount = collection.count(quantityInQuery); + assertEquals(5, quantityInCount); // quantity=5: _id 5,6,8; quantity=10: _id 3,7 - // Test 3: IN operator on price field (numeric) - // Expected: 5 documents (IDs 1, 8 for price=10 and 3, 4 for price=5) + // Test 3: IN operator on top-level NUMBER column (price) + // Find documents where price is 5 OR 10 + // Expected SQL: "price" IN (5, 10) Query priceInQuery = Query.builder() .setFilter( @@ -3339,25 +3359,27 @@ void testFlatPostgresCollectionInAndNotInOperators(String dataStoreName) { ConstantExpression.ofNumbers(List.of(5, 10)))) .build(); - long priceInCount = flatCollection.count(priceInQuery); - assertEquals(4, priceInCount, "IN operator on price should find 4 documents"); + long priceInCount = collection.count(priceInQuery); + assertEquals(4, priceInCount); // price=10: _id 1,8; price=5: _id 3,4 - // Test 4: NOT_IN operator on _id field - // Expected: 7 documents (all except IDs 1, 3, 5) - Query idNotInQuery = + // Test 4: IN operator on JSONB STRING field (props.brand) with type optimization + // Find documents where props.brand is "Dettol" OR "Sunsilk" + // Expected SQL: "props" ->> 'brand' IN ('Dettol', 'Sunsilk') + Query nestedInQuery = Query.builder() .setFilter( RelationalExpression.of( - IdentifierExpression.of("_id"), - NOT_IN, - ConstantExpression.ofNumbers(List.of(1, 3, 5)))) + JsonIdentifierExpression.of("props", JsonFieldType.STRING, "brand"), + IN, + ConstantExpression.ofStrings(List.of("Dettol", "Sunsilk")))) .build(); - long idNotInCount = flatCollection.count(idNotInQuery); - assertEquals(7, idNotInCount, "NOT_IN operator on _id should find 7 documents"); + long nestedInCount = collection.count(nestedInQuery); + assertEquals(2, nestedInCount); // _id 1 (Dettol) + _id 3 (Sunsilk) - // Test 5: NOT_IN operator on item field - // Expected: 5 documents (all except Soap items: IDs 2, 3, 4, 6, 7, 9, 10) + // Test 5: NOT_IN operator on top-level STRING column (item) + // Find documents where item is NOT "Soap" + // Expected SQL: "item" NOT IN ('Soap') OR "item" IS NULL Query itemNotInQuery = Query.builder() .setFilter( @@ -3367,12 +3389,11 @@ void testFlatPostgresCollectionInAndNotInOperators(String dataStoreName) { ConstantExpression.ofStrings(List.of("Soap")))) .build(); - long itemNotInCount = flatCollection.count(itemNotInQuery); - assertEquals(7, itemNotInCount, "NOT_IN operator on item should find 7 documents"); + long itemNotInCount = collection.count(itemNotInQuery); + assertEquals(7, itemNotInCount); // All 10 docs minus 3 Soap docs = 7 // Test 6: Combined IN with other filters (AND) - // Filter: _id IN (1, 3, 5, 7) AND price >= 10 - // Expected: 2 documents (ID 1 with price=10, ID 5 with price=20) + // Filter: item IN ("Soap", "Shampoo") AND quantity >= 5 Query combinedQuery = Query.builder() .setFilter( @@ -3380,156 +3401,51 @@ void testFlatPostgresCollectionInAndNotInOperators(String dataStoreName) { .operator(LogicalOperator.AND) .operand( RelationalExpression.of( - IdentifierExpression.of("_id"), + IdentifierExpression.of("item"), IN, - ConstantExpression.ofNumbers(List.of(1, 3, 5, 7)))) + ConstantExpression.ofStrings(List.of("Soap", "Shampoo")))) .operand( RelationalExpression.of( - IdentifierExpression.of("price"), GTE, ConstantExpression.of(10))) + IdentifierExpression.of("quantity"), GTE, ConstantExpression.of(5))) .build()) .build(); - long combinedCount = flatCollection.count(combinedQuery); - assertEquals(2, combinedCount, "Combined IN with >= filter should find 2 documents"); + long combinedCount = collection.count(combinedQuery); + assertTrue(combinedCount > 0, "Combined IN with >= filter should find documents"); } /** - * Tests IN and NOT_IN operators on array fields in flat collections. Array fields use the - * PostgreSQL array overlap operator (&&) for IN operations, which checks if the array contains - * ANY of the provided values. + * Tests querying JSONB nested fields with JsonIdentifierExpression and JsonFieldType for + * optimized SQL generation. * - *

This test is parameterized to test three scenarios: 1. ArrayIdentifierExpression WITH - * ArrayType - optimized queries with type-aware casting 2. ArrayIdentifierExpression WITHOUT - * ArrayType - fallback with text[] casting both sides 3. IdentifierExpression - backward - * compatibility with text[] casting both sides - */ - @ParameterizedTest - @ArgumentsSource(PostgresArrayTypeProvider.class) - void testFlatPostgresCollectionInAndNotInOperatorsForArrays( - String dataStoreName, String expressionType) { - Datastore datastore = datastoreMap.get(dataStoreName); - Collection flatCollection = - datastore.getCollectionForType(FLAT_COLLECTION_NAME, DocumentType.FLAT); - - String typeDesc = - expressionType.equals("WITH_TYPE") - ? "WITH ArrayType (optimized)" - : "WITHOUT ArrayType (fallback)"; - - // Test 1: IN operator on tags array field (string array) - // Find documents where tags contains "hygiene" OR "grooming" - // Expected: IDs 1, 5, 8 (hygiene) + IDs 6, 7 (grooming) = 5 documents - Query tagsInQuery = - Query.builder() - .setFilter( - RelationalExpression.of( - expressionType.equals("WITH_TYPE") - ? ArrayIdentifierExpression.of("tags", ArrayType.TEXT) - : ArrayIdentifierExpression.of("tags"), - IN, - ConstantExpression.ofStrings(List.of("hygiene", "grooming")))) - .build(); - - long tagsInCount = flatCollection.count(tagsInQuery); - assertEquals( - 5, - tagsInCount, - String.format( - "IN operator on tags array %s should find 5 documents with hygiene or grooming", - typeDesc)); - - // Test 2: IN operator on numbers array field (numeric array) - // Find documents where numbers array contains 1 OR 10 - // Expected: ID 1 has {1,2,3}, ID 2 has {10,20} = 2 documents - Query numbersInQuery = - Query.builder() - .setFilter( - RelationalExpression.of( - expressionType.equals("WITH_TYPE") - ? ArrayIdentifierExpression.of("numbers", ArrayType.INTEGER) - : ArrayIdentifierExpression.of("numbers"), - IN, - ConstantExpression.ofNumbers(List.of(1, 10)))) - .build(); - - long numbersInCount = flatCollection.count(numbersInQuery); - assertEquals( - 2, - numbersInCount, - String.format("IN operator on numbers array %s should find 2 documents", typeDesc)); - - // Test 3: NOT_IN operator on tags array field - // Find documents where tags does NOT contain "hygiene" - // Expected: All documents except IDs 1, 5, 8 = 7 documents - // Note: This includes NULL tags (ID 9) and empty array (ID 10) - Query tagsNotInQuery = - Query.builder() - .setFilter( - RelationalExpression.of( - expressionType.equals("WITH_TYPE") - ? ArrayIdentifierExpression.of("tags", ArrayType.TEXT) - : ArrayIdentifierExpression.of("tags"), - NOT_IN, - ConstantExpression.ofStrings(List.of("hygiene")))) - .build(); - - long tagsNotInCount = flatCollection.count(tagsNotInQuery); - assertEquals( - 7, - tagsNotInCount, - String.format( - "NOT_IN operator on tags array %s should find 7 documents without hygiene", - typeDesc)); - - // Test 4: Combined array IN with scalar filter - // Find documents where tags contains "premium" AND price >= 5 - // Expected: ID 1 (premium, price=10) + ID 3 (premium, price=5) = 2 documents - Query combinedArrayQuery = - Query.builder() - .setFilter( - LogicalExpression.builder() - .operator(LogicalOperator.AND) - .operand( - RelationalExpression.of( - expressionType.equals("WITH_TYPE") - ? ArrayIdentifierExpression.of("tags", ArrayType.TEXT) - : ArrayIdentifierExpression.of("tags"), - IN, - ConstantExpression.ofStrings(List.of("premium")))) - .operand( - RelationalExpression.of( - IdentifierExpression.of("price"), GTE, ConstantExpression.of(5))) - .build()) - .build(); - - long combinedArrayCount = flatCollection.count(combinedArrayQuery); - assertEquals( - 2, - combinedArrayCount, - String.format("Combined array IN with >= filter %s should find 2 documents", typeDesc)); - } - - /** - * This test is disabled for now because flat collections do not support search on nested - * queries in JSONB fields (ex. props.brand) + *

JsonFieldType is required for all JSONB IN/NOT_IN operations to generate optimal SQL. + * + *

Test coverage: * - * @param dataStoreName the datastore name, in this case, Postgres - * @throws IOException + *

*/ @ParameterizedTest @ArgumentsSource(PostgresProvider.class) - @Disabled void testFlatPostgresCollectionNestedFieldQuery(String dataStoreName) throws IOException { Datastore datastore = datastoreMap.get(dataStoreName); Collection flatCollection = datastore.getCollectionForType(FLAT_COLLECTION_NAME, DocumentType.FLAT); - // Test querying nested field in props JSONB column - filter by brand + // Test 1: EQ operator on JSONB STRING field (props.brand) with type optimization + // Expected SQL: "props" ->> 'brand' = 'Dettol' Query brandQuery = Query.builder() .setFilter( RelationalExpression.of( - IdentifierExpression.of("props.brand"), EQ, ConstantExpression.of("Dettol"))) + JsonIdentifierExpression.of("props", JsonFieldType.STRING, "brand"), + EQ, + ConstantExpression.of("Dettol"))) .build(); CloseableIterator brandIterator = flatCollection.find(brandQuery); @@ -3542,15 +3458,17 @@ void testFlatPostgresCollectionNestedFieldQuery(String dataStoreName) throws IOE } brandIterator.close(); - // Should have 1 Dettol document (ID 1) + // Should have 1 Dettol document (_id=1) assertEquals(1, brandCount); - // Test querying deeply nested field - filter by seller city + // Test 2: Deeply nested JSONB field access (props.seller.address.city) + // Expected SQL: "props" -> 'seller' -> 'address' ->> 'city' = 'Mumbai' Query cityQuery = Query.builder() .setFilter( RelationalExpression.of( - IdentifierExpression.of("props.seller.address.city"), + JsonIdentifierExpression.of( + "props", JsonFieldType.STRING, "seller", "address", "city"), EQ, ConstantExpression.of("Mumbai"))) .build(); @@ -3565,10 +3483,64 @@ void testFlatPostgresCollectionNestedFieldQuery(String dataStoreName) throws IOE } cityIterator.close(); - // Should have 2 Mumbai documents (IDs 1, 3) + // Should have 2 Mumbai documents (_id=1, _id=3) assertEquals(2, cityCount); - // Test count with nested field filter + // Test 3: IN operator on JSONB STRING field with type optimization + // Expected SQL: "props" ->> 'brand' IN ('Dettol', 'Sunsilk', 'Lifebuoy') + Query brandInQuery = + Query.builder() + .setFilter( + RelationalExpression.of( + JsonIdentifierExpression.of("props", JsonFieldType.STRING, "brand"), + IN, + ConstantExpression.ofStrings(List.of("Dettol", "Sunsilk", "Lifebuoy")))) + .build(); + + long brandInCount = flatCollection.count(brandInQuery); + assertEquals(3, brandInCount); // _id=1 (Dettol), _id=3 (Sunsilk), _id=5 (Lifebuoy) + + // Test 4: Combined filter - JSONB field + top-level column + // Filter: brand = "Dettol" AND price = 10 + // Expected SQL: "props" ->> 'brand' = 'Dettol' AND "price" = 10 + Query combinedQuery = + Query.builder() + .setFilter( + LogicalExpression.builder() + .operator(LogicalOperator.AND) + .operand( + RelationalExpression.of( + JsonIdentifierExpression.of("props", JsonFieldType.STRING, "brand"), + EQ, + ConstantExpression.of("Dettol"))) + .operand( + RelationalExpression.of( + IdentifierExpression.of("price"), EQ, ConstantExpression.of(10))) + .build()) + .build(); + + long combinedCount = flatCollection.count(combinedQuery); + assertEquals(1, combinedCount); // Only _id=1 matches both conditions + + // Test 5: NOT_IN operator on JSONB STRING field with type optimization + // Find documents where brand is NOT "Dettol" (should find Sunsilk and Lifebuoy) + // Expected SQL: NOT ("props" ->> 'brand' IN ('Dettol')) OR "props" ->> 'brand' IS NULL + Query brandNotInQuery = + Query.builder() + .setFilter( + RelationalExpression.of( + JsonIdentifierExpression.of("props", JsonFieldType.STRING, "brand"), + NOT_IN, + ConstantExpression.ofStrings(List.of("Dettol")))) + .build(); + + long brandNotInCount = flatCollection.count(brandNotInQuery); + // Docs with brand: _id=1 (Dettol), _id=3 (Sunsilk), _id=5 (Lifebuoy) + // Docs with NULL props or no brand: _id=2, 4, 6, 7, 8, 9, 10 + // NOT_IN "Dettol" should return: 2 (Sunsilk, Lifebuoy) + 7 (NULL/missing) = 9 + assertEquals(9, brandNotInCount); + + // Test 6: Verify count methods with JSONB fields long brandCountQuery = flatCollection.count(brandQuery); assertEquals(1, brandCountQuery); @@ -4414,7 +4386,7 @@ void testJsonbArrayContainsOperators(String dataStoreName) { Query.builder() .setFilter( RelationalExpression.of( - JsonIdentifierExpression.of("props", "colors"), + JsonIdentifierExpression.of("props", JsonFieldType.STRING_ARRAY, "colors"), CONTAINS, ConstantExpression.of("Green"))) .build(); @@ -4431,7 +4403,8 @@ void testJsonbArrayContainsOperators(String dataStoreName) { .operator(LogicalOperator.AND) .operand( RelationalExpression.of( - JsonIdentifierExpression.of("props", "colors"), + JsonIdentifierExpression.of( + "props", JsonFieldType.STRING_ARRAY, "colors"), NOT_CONTAINS, ConstantExpression.of("Green"))) .operand( @@ -4463,7 +4436,7 @@ void testJsonbScalarInOperators(String dataStoreName) { Query.builder() .setFilter( RelationalExpression.of( - JsonIdentifierExpression.of("props", "brand"), + JsonIdentifierExpression.of("props", JsonFieldType.STRING, "brand"), IN, ConstantExpression.ofStrings(List.of("Dettol", "Lifebuoy")))) .build(); @@ -4480,7 +4453,7 @@ void testJsonbScalarInOperators(String dataStoreName) { .operator(LogicalOperator.AND) .operand( RelationalExpression.of( - JsonIdentifierExpression.of("props", "brand"), + JsonIdentifierExpression.of("props", JsonFieldType.STRING, "brand"), NOT_IN, ConstantExpression.ofStrings(List.of("Dettol")))) .operand( @@ -4510,7 +4483,7 @@ void testJsonbScalarEqualityOperators(String dataStoreName) { Query.builder() .setFilter( RelationalExpression.of( - JsonIdentifierExpression.of("props", "brand"), + JsonIdentifierExpression.of("props", JsonFieldType.STRING, "brand"), EQ, ConstantExpression.of("Dettol"))) .build(); @@ -4524,7 +4497,7 @@ void testJsonbScalarEqualityOperators(String dataStoreName) { Query.builder() .setFilter( RelationalExpression.of( - JsonIdentifierExpression.of("props", "brand"), + JsonIdentifierExpression.of("props", JsonFieldType.STRING, "brand"), NEQ, ConstantExpression.of("Dettol"))) .build(); @@ -4551,7 +4524,8 @@ void testJsonbNumericComparisonOperators(String dataStoreName) { Query.builder() .setFilter( RelationalExpression.of( - JsonIdentifierExpression.of("props", "seller", "address", "pincode"), + JsonIdentifierExpression.of( + "props", JsonFieldType.NUMBER, "seller", "address", "pincode"), GT, ConstantExpression.of(500000))) .build(); @@ -4565,7 +4539,8 @@ void testJsonbNumericComparisonOperators(String dataStoreName) { Query.builder() .setFilter( RelationalExpression.of( - JsonIdentifierExpression.of("props", "seller", "address", "pincode"), + JsonIdentifierExpression.of( + "props", JsonFieldType.NUMBER, "seller", "address", "pincode"), LT, ConstantExpression.of(500000))) .build(); @@ -4579,7 +4554,8 @@ void testJsonbNumericComparisonOperators(String dataStoreName) { Query.builder() .setFilter( RelationalExpression.of( - JsonIdentifierExpression.of("props", "seller", "address", "pincode"), + JsonIdentifierExpression.of( + "props", JsonFieldType.NUMBER, "seller", "address", "pincode"), GTE, ConstantExpression.of(700000))) .build(); @@ -4593,7 +4569,8 @@ void testJsonbNumericComparisonOperators(String dataStoreName) { Query.builder() .setFilter( RelationalExpression.of( - JsonIdentifierExpression.of("props", "seller", "address", "pincode"), + JsonIdentifierExpression.of( + "props", JsonFieldType.NUMBER, "seller", "address", "pincode"), LTE, ConstantExpression.of(400004))) .build(); diff --git a/document-store/src/main/java/org/hypertrace/core/documentstore/expression/impl/JsonArrayIdentifierExpression.java b/document-store/src/main/java/org/hypertrace/core/documentstore/expression/impl/JsonArrayIdentifierExpression.java deleted file mode 100644 index efde92d8..00000000 --- a/document-store/src/main/java/org/hypertrace/core/documentstore/expression/impl/JsonArrayIdentifierExpression.java +++ /dev/null @@ -1,50 +0,0 @@ -package org.hypertrace.core.documentstore.expression.impl; - -import java.util.List; -import lombok.EqualsAndHashCode; -import org.hypertrace.core.documentstore.postgres.utils.BasicPostgresSecurityValidator; - -/** - * Represents an identifier expression for array-typed fields inside JSONB columns. This allows - * parsers to apply array-specific logic (e.g., jsonb_array_length checks for EXISTS operators to - * exclude empty arrays). - * - *

Example: For a JSONB column "attributes" with a nested array field "tags": - * - *

{"attributes": {"tags": ["value1", "value2"]}}
- * - * Use: {@code JsonArrayIdentifierExpression.of("attributes", "tags")} - */ -@EqualsAndHashCode(callSuper = true) -public class JsonArrayIdentifierExpression extends JsonIdentifierExpression { - - public static JsonArrayIdentifierExpression of( - final String columnName, final String... pathElements) { - if (pathElements == null || pathElements.length == 0) { - throw new IllegalArgumentException("JSON path cannot be null or empty for array field"); - } - return of(columnName, List.of(pathElements)); - } - - public static JsonArrayIdentifierExpression of( - final String columnName, final List jsonPath) { - // Validate inputs - BasicPostgresSecurityValidator.getDefault().validateIdentifier(columnName); - - if (jsonPath == null || jsonPath.isEmpty()) { - throw new IllegalArgumentException("JSON path cannot be null or empty for array field"); - } - - BasicPostgresSecurityValidator.getDefault().validateJsonPath(jsonPath); - - List unmodifiablePath = List.copyOf(jsonPath); - - // Construct full name for compatibility: "customAttr.myAttribute" - String fullName = columnName + "." + String.join(".", unmodifiablePath); - return new JsonArrayIdentifierExpression(fullName, columnName, unmodifiablePath); - } - - private JsonArrayIdentifierExpression(String name, String columnName, List jsonPath) { - super(name, columnName, jsonPath); - } -} diff --git a/document-store/src/main/java/org/hypertrace/core/documentstore/expression/impl/JsonFieldType.java b/document-store/src/main/java/org/hypertrace/core/documentstore/expression/impl/JsonFieldType.java new file mode 100644 index 00000000..af4527d3 --- /dev/null +++ b/document-store/src/main/java/org/hypertrace/core/documentstore/expression/impl/JsonFieldType.java @@ -0,0 +1,13 @@ +package org.hypertrace.core.documentstore.expression.impl; + +/** Represents the type of JSON fields in flat collections */ +public enum JsonFieldType { + STRING, + NUMBER, + BOOLEAN, + STRING_ARRAY, + NUMBER_ARRAY, + BOOLEAN_ARRAY, + OBJECT_ARRAY, + OBJECT +} diff --git a/document-store/src/main/java/org/hypertrace/core/documentstore/expression/impl/JsonIdentifierExpression.java b/document-store/src/main/java/org/hypertrace/core/documentstore/expression/impl/JsonIdentifierExpression.java index 88191739..b6b729e3 100644 --- a/document-store/src/main/java/org/hypertrace/core/documentstore/expression/impl/JsonIdentifierExpression.java +++ b/document-store/src/main/java/org/hypertrace/core/documentstore/expression/impl/JsonIdentifierExpression.java @@ -1,6 +1,7 @@ package org.hypertrace.core.documentstore.expression.impl; import java.util.List; +import java.util.Optional; import lombok.EqualsAndHashCode; import lombok.Getter; import org.hypertrace.core.documentstore.parser.FieldTransformationVisitor; @@ -20,6 +21,7 @@ public class JsonIdentifierExpression extends IdentifierExpression { String columnName; // e.g., "customAttr" (the top-level JSONB column) List jsonPath; // e.g., ["myAttribute", "nestedField"] + JsonFieldType fieldType; // Optional: PRIMITIVE or ARRAY for optimization public static JsonIdentifierExpression of(final String columnName) { throw new IllegalArgumentException( @@ -34,7 +36,20 @@ public static JsonIdentifierExpression of(final String columnName, final String. return of(columnName, List.of(pathElements)); } + public static JsonIdentifierExpression of( + final String columnName, final JsonFieldType fieldType, final String... pathElements) { + if (pathElements == null || pathElements.length == 0) { + throw new IllegalArgumentException("JSON path cannot be null or empty"); + } + return of(columnName, fieldType, List.of(pathElements)); + } + public static JsonIdentifierExpression of(final String columnName, final List jsonPath) { + return of(columnName, null, jsonPath); + } + + public static JsonIdentifierExpression of( + final String columnName, final JsonFieldType fieldType, final List jsonPath) { BasicPostgresSecurityValidator.getDefault().validateIdentifier(columnName); if (jsonPath == null || jsonPath.isEmpty()) { @@ -47,13 +62,20 @@ public static JsonIdentifierExpression of(final String columnName, final List jsonPath) { + protected JsonIdentifierExpression( + String name, String columnName, List jsonPath, JsonFieldType fieldType) { super(name); this.columnName = columnName; this.jsonPath = jsonPath; + this.fieldType = fieldType; + } + + /** Returns the JSON field type if specified, empty otherwise */ + public Optional getFieldType() { + return Optional.ofNullable(fieldType); } /** diff --git a/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/query/v1/parser/filter/PostgresInParserSelector.java b/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/query/v1/parser/filter/PostgresInParserSelector.java index a7a86460..74d1776d 100644 --- a/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/query/v1/parser/filter/PostgresInParserSelector.java +++ b/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/query/v1/parser/filter/PostgresInParserSelector.java @@ -7,6 +7,7 @@ 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.parser.SelectTypeExpressionVisitor; import org.hypertrace.core.documentstore.postgres.query.v1.parser.filter.nonjson.field.PostgresInRelationalFilterParserArrayField; @@ -14,8 +15,13 @@ class PostgresInParserSelector implements SelectTypeExpressionVisitor { + // Parsers for different expression types private static final PostgresInRelationalFilterParserInterface jsonFieldInFilterParser = - new PostgresInRelationalFilterParser(); + new PostgresInRelationalFilterParser(); // Fallback for JSON without type info + private static final PostgresInRelationalFilterParserInterface jsonPrimitiveInFilterParser = + new PostgresInRelationalFilterParserJsonPrimitive(); // Optimized for JSON primitives + private static final PostgresInRelationalFilterParserInterface jsonArrayInFilterParser = + new PostgresInRelationalFilterParserJsonArray(); // Optimized for JSON arrays private static final PostgresInRelationalFilterParserInterface scalarFieldInFilterParser = new PostgresInRelationalFilterParserScalarField(); private static final PostgresInRelationalFilterParserInterface arrayFieldInFilterParser = @@ -29,7 +35,28 @@ class PostgresInParserSelector implements SelectTypeExpressionVisitor { @Override public PostgresInRelationalFilterParserInterface visit(JsonIdentifierExpression expression) { - return jsonFieldInFilterParser; + // JsonFieldType is required for optimized SQL generation + JsonFieldType fieldType = getFieldType(expression); + + switch (fieldType) { + case STRING: + case NUMBER: + case BOOLEAN: + // Primitives: use ->> (extract as text) with appropriate casting + return jsonPrimitiveInFilterParser; + case STRING_ARRAY: + case NUMBER_ARRAY: + case BOOLEAN_ARRAY: + case OBJECT_ARRAY: + // Typed arrays: use -> with @> and typed jsonb_build_array + return jsonArrayInFilterParser; + case OBJECT: + // Objects: use -> with @> (future: needs separate parser) + throw new UnsupportedOperationException( + "IN operator on OBJECT type is not yet supported. Use primitive or array types."); + default: + throw new IllegalArgumentException("Unsupported JsonFieldType: " + fieldType); + } } @Override @@ -68,4 +95,14 @@ public PostgresInRelationalFilterParserInterface visit(FunctionExpression expres public PostgresInRelationalFilterParserInterface visit(AliasedIdentifierExpression expression) { return isFlatCollection ? scalarFieldInFilterParser : jsonFieldInFilterParser; } + + private static JsonFieldType getFieldType(JsonIdentifierExpression expression) { + return expression + .getFieldType() + .orElseThrow( + () -> + new IllegalArgumentException( + "JsonFieldType must be specified for JsonIdentifierExpression in IN operations. " + + "Use JsonIdentifierExpression.of(column, JsonFieldType.*, path...)")); + } } diff --git a/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/query/v1/parser/filter/PostgresInRelationalFilterParserJsonArray.java b/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/query/v1/parser/filter/PostgresInRelationalFilterParserJsonArray.java new file mode 100644 index 00000000..a0b8b2ad --- /dev/null +++ b/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/query/v1/parser/filter/PostgresInRelationalFilterParserJsonArray.java @@ -0,0 +1,96 @@ +package org.hypertrace.core.documentstore.postgres.query.v1.parser.filter; + +import java.util.stream.Collectors; +import java.util.stream.StreamSupport; +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; + +/** + * Optimized parser for IN operations on JSON array fields with type-specific casting. + * + *

Uses JSONB containment operator (@>) with typed jsonb_build_array for "contains any" + * semantics: + * + *

    + *
  • STRING_ARRAY: {@code "document" -> 'tags' @> jsonb_build_array(?::text)} + *
  • NUMBER_ARRAY: {@code "document" -> 'scores' @> jsonb_build_array(?::numeric)} + *
  • BOOLEAN_ARRAY: {@code "document" -> 'flags' @> jsonb_build_array(?::boolean)} + *
  • OBJECT_ARRAY: {@code "document" -> 'items' @> jsonb_build_array(?::jsonb)} + *
+ * + *

This checks if the JSON array contains ANY of the provided values, using efficient JSONB + * containment instead of defensive type checking. + */ +public class PostgresInRelationalFilterParserJsonArray + implements PostgresInRelationalFilterParserInterface { + + @Override + public String parse( + final RelationalExpression expression, final PostgresRelationalFilterContext context) { + final String parsedLhs = expression.getLhs().accept(context.lhsParser()); + final Iterable parsedRhs = expression.getRhs().accept(context.rhsParser()); + + // Extract field type for typed array handling (guaranteed to be present by selector) + JsonIdentifierExpression jsonExpr = (JsonIdentifierExpression) expression.getLhs(); + JsonFieldType fieldType = + jsonExpr + .getFieldType() + .orElseThrow( + () -> + new IllegalStateException( + "JsonFieldType must be present - this should have been caught by the selector")); + + return prepareFilterStringForInOperator( + parsedLhs, parsedRhs, fieldType, context.getParamsBuilder()); + } + + private String prepareFilterStringForInOperator( + final String parsedLhs, + final Iterable parsedRhs, + final JsonFieldType fieldType, + final Params.Builder paramsBuilder) { + + // Determine the appropriate type cast for jsonb_build_array elements + String typeCast = getTypeCastForArray(fieldType); + + // For JSON arrays, we use the @> containment operator + // Check if ANY of the RHS values is contained in the LHS array + String orConditions = + StreamSupport.stream(parsedRhs.spliterator(), false) + .map( + value -> { + paramsBuilder.addObjectParam(value); + return String.format("%s @> jsonb_build_array(?%s)", parsedLhs, typeCast); + }) + .collect(Collectors.joining(" OR ")); + + // Wrap in parentheses if multiple conditions + return StreamSupport.stream(parsedRhs.spliterator(), false).count() > 1 + ? String.format("(%s)", orConditions) + : orConditions; + } + + /** + * Returns the PostgreSQL type cast string for jsonb_build_array elements based on array type. + * + * @param fieldType The JSON field type (must not be null) + * @return Type cast string (e.g., "::text", "::numeric") + */ + private String getTypeCastForArray(JsonFieldType fieldType) { + switch (fieldType) { + case STRING_ARRAY: + return "::text"; + case NUMBER_ARRAY: + return "::numeric"; + case BOOLEAN_ARRAY: + return "::boolean"; + case OBJECT_ARRAY: + return "::jsonb"; + default: + throw new IllegalArgumentException( + "Unsupported array type: " + fieldType + ". Expected *_ARRAY types."); + } + } +} diff --git a/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/query/v1/parser/filter/PostgresInRelationalFilterParserJsonPrimitive.java b/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/query/v1/parser/filter/PostgresInRelationalFilterParserJsonPrimitive.java new file mode 100644 index 00000000..f381e745 --- /dev/null +++ b/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/query/v1/parser/filter/PostgresInRelationalFilterParserJsonPrimitive.java @@ -0,0 +1,84 @@ +package org.hypertrace.core.documentstore.postgres.query.v1.parser.filter; + +import java.util.stream.Collectors; +import java.util.stream.StreamSupport; +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; + +/** + * Optimized parser for IN operations on JSON primitive fields (string, number, boolean) with proper + * type casting. + * + *

Generates efficient SQL using {@code ->>} operator with appropriate PostgreSQL casting: + * + *

    + *
  • STRING: {@code "document" ->> 'item' IN ('Soap', 'Shampoo')} + *
  • NUMBER: {@code CAST("document" ->> 'price' AS NUMERIC) IN (10, 20)} + *
  • BOOLEAN: {@code CAST("document" ->> 'active' AS BOOLEAN) IN (true, false)} + *
+ * + *

This is much more efficient than the defensive approach that checks both array and scalar + * types, and ensures correct type comparisons. + */ +public class PostgresInRelationalFilterParserJsonPrimitive + implements PostgresInRelationalFilterParserInterface { + + @Override + public String parse( + final RelationalExpression expression, final PostgresRelationalFilterContext context) { + String parsedLhs = expression.getLhs().accept(context.lhsParser()); + final Iterable parsedRhs = expression.getRhs().accept(context.rhsParser()); + + // Extract field type for proper casting (guaranteed to be present by selector) + JsonIdentifierExpression jsonExpr = (JsonIdentifierExpression) expression.getLhs(); + JsonFieldType fieldType = + jsonExpr + .getFieldType() + .orElseThrow( + () -> + new IllegalStateException( + "JsonFieldType must be present - this should have been caught by the selector")); + + // For JSON primitives, we need ->> (text extraction) instead of -> (jsonb extraction) + // The LHS parser generates: "props"->'brand' (returns JSONB) + // We need: "props"->>'brand' (returns TEXT) + // Replace the last -> with ->> for primitive type extraction + int lastArrowIndex = parsedLhs.lastIndexOf("->"); + if (lastArrowIndex != -1) { + parsedLhs = + parsedLhs.substring(0, lastArrowIndex) + "->>" + parsedLhs.substring(lastArrowIndex + 2); + } + + return prepareFilterStringForInOperator( + parsedLhs, parsedRhs, fieldType, context.getParamsBuilder()); + } + + private String prepareFilterStringForInOperator( + final String parsedLhs, + final Iterable parsedRhs, + final JsonFieldType fieldType, + final Params.Builder paramsBuilder) { + + String placeholders = + StreamSupport.stream(parsedRhs.spliterator(), false) + .map( + value -> { + paramsBuilder.addObjectParam(value); + return "?"; + }) + .collect(Collectors.joining(", ")); + + // Apply appropriate casting based on field type + String lhsWithCast = parsedLhs; + if (fieldType == JsonFieldType.NUMBER) { + lhsWithCast = String.format("CAST(%s AS NUMERIC)", parsedLhs); + } else if (fieldType == JsonFieldType.BOOLEAN) { + lhsWithCast = String.format("CAST(%s AS BOOLEAN)", parsedLhs); + } + // STRING or null fieldType: no casting needed + + return String.format("%s IN (%s)", lhsWithCast, placeholders); + } +} diff --git a/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/query/v1/parser/filter/PostgresNotInParserSelector.java b/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/query/v1/parser/filter/PostgresNotInParserSelector.java index a86b249d..ee86f767 100644 --- a/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/query/v1/parser/filter/PostgresNotInParserSelector.java +++ b/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/query/v1/parser/filter/PostgresNotInParserSelector.java @@ -7,6 +7,7 @@ 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.parser.SelectTypeExpressionVisitor; import org.hypertrace.core.documentstore.postgres.query.v1.parser.filter.nonjson.field.PostgresInRelationalFilterParserArrayField; @@ -14,8 +15,13 @@ class PostgresNotInParserSelector implements SelectTypeExpressionVisitor { + // Parsers for different expression types private static final PostgresInRelationalFilterParserInterface jsonFieldInFilterParser = - new PostgresInRelationalFilterParser(); + new PostgresInRelationalFilterParser(); // Fallback for JSON without type info + private static final PostgresInRelationalFilterParserInterface jsonPrimitiveInFilterParser = + new PostgresInRelationalFilterParserJsonPrimitive(); // Optimized for JSON primitives + private static final PostgresInRelationalFilterParserInterface jsonArrayInFilterParser = + new PostgresInRelationalFilterParserJsonArray(); // Optimized for JSON arrays private static final PostgresInRelationalFilterParserInterface scalarFieldInFilterParser = new PostgresInRelationalFilterParserScalarField(); private static final PostgresInRelationalFilterParserInterface arrayFieldInFilterParser = @@ -29,7 +35,35 @@ class PostgresNotInParserSelector implements SelectTypeExpressionVisitor { @Override public PostgresInRelationalFilterParserInterface visit(JsonIdentifierExpression expression) { - return jsonFieldInFilterParser; + // JsonFieldType is required for optimized SQL generation + JsonFieldType fieldType = + expression + .getFieldType() + .orElseThrow( + () -> + new IllegalArgumentException( + "JsonFieldType must be specified for JsonIdentifierExpression in NOT_IN operations. " + + "Use JsonIdentifierExpression.of(column, JsonFieldType.*, path...)")); + + switch (fieldType) { + case STRING: + case NUMBER: + case BOOLEAN: + // Primitives: use ->> (extract as text) with appropriate casting + return jsonPrimitiveInFilterParser; + case STRING_ARRAY: + case NUMBER_ARRAY: + case BOOLEAN_ARRAY: + case OBJECT_ARRAY: + // Typed arrays: use -> with @> and typed jsonb_build_array + return jsonArrayInFilterParser; + case OBJECT: + // Objects: use -> with @> (future: needs separate parser) + throw new UnsupportedOperationException( + "NOT_IN operator on OBJECT type is not yet supported. Use primitive or array types."); + default: + throw new IllegalArgumentException("Unsupported JsonFieldType: " + fieldType); + } } @Override diff --git a/document-store/src/test/java/org/hypertrace/core/documentstore/expression/impl/JsonArrayIdentifierExpressionTest.java b/document-store/src/test/java/org/hypertrace/core/documentstore/expression/impl/JsonArrayIdentifierExpressionTest.java deleted file mode 100644 index 250888a1..00000000 --- a/document-store/src/test/java/org/hypertrace/core/documentstore/expression/impl/JsonArrayIdentifierExpressionTest.java +++ /dev/null @@ -1,259 +0,0 @@ -package org.hypertrace.core.documentstore.expression.impl; - -import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.util.Collections; -import java.util.List; -import org.junit.jupiter.api.Test; - -class JsonArrayIdentifierExpressionTest { - - @Test - void testOfWithVarargs() { - JsonArrayIdentifierExpression expression = - JsonArrayIdentifierExpression.of("attributes", "tags"); - - assertEquals("attributes", expression.getColumnName()); - assertEquals(List.of("tags"), expression.getJsonPath()); - } - - @Test - void testOfWithMultiplePathElements() { - JsonArrayIdentifierExpression expression = - JsonArrayIdentifierExpression.of("attributes", "nested", "array", "field"); - - assertEquals("attributes", expression.getColumnName()); - assertEquals(List.of("nested", "array", "field"), expression.getJsonPath()); - } - - @Test - void testOfWithList() { - JsonArrayIdentifierExpression expression = - JsonArrayIdentifierExpression.of("attributes", List.of("certifications")); - - assertEquals("attributes", expression.getColumnName()); - assertEquals(List.of("certifications"), expression.getJsonPath()); - } - - @Test - void testOfWithNullPathElementsThrowsException() { - IllegalArgumentException exception = - assertThrows( - IllegalArgumentException.class, - () -> JsonArrayIdentifierExpression.of("attributes", (String[]) null)); - - assertEquals("JSON path cannot be null or empty for array field", exception.getMessage()); - } - - @Test - void testOfWithEmptyPathElementsThrowsException() { - IllegalArgumentException exception = - assertThrows( - IllegalArgumentException.class, () -> JsonArrayIdentifierExpression.of("attributes")); - - assertEquals( - "JSON path cannot be null or empty. Use of(columnName, path...) instead.", - exception.getMessage()); - } - - @Test - void testOfWithNullListThrowsException() { - IllegalArgumentException exception = - assertThrows( - IllegalArgumentException.class, - () -> JsonArrayIdentifierExpression.of("attributes", (List) null)); - - assertEquals("JSON path cannot be null or empty for array field", exception.getMessage()); - } - - @Test - void testOfWithEmptyListThrowsException() { - IllegalArgumentException exception = - assertThrows( - IllegalArgumentException.class, - () -> JsonArrayIdentifierExpression.of("attributes", Collections.emptyList())); - - assertEquals("JSON path cannot be null or empty for array field", exception.getMessage()); - } - - @Test - void testEqualsAndHashCode() { - JsonArrayIdentifierExpression expr1 = JsonArrayIdentifierExpression.of("attributes", "tags"); - JsonArrayIdentifierExpression expr2 = JsonArrayIdentifierExpression.of("attributes", "tags"); - - // Test equality - assertEquals(expr1, expr2, "Expressions with same column and path should be equal"); - - // Test hashCode - assertEquals( - expr1.hashCode(), - expr2.hashCode(), - "Expressions with same column and path should have same hashCode"); - } - - @Test - void testNotEqualsWithDifferentPath() { - JsonArrayIdentifierExpression expr1 = JsonArrayIdentifierExpression.of("attributes", "tags"); - JsonArrayIdentifierExpression expr2 = - JsonArrayIdentifierExpression.of("attributes", "categories"); - - assertNotEquals(expr1, expr2, "Expressions with different paths should not be equal"); - } - - @Test - void testNotEqualsWithDifferentColumn() { - JsonArrayIdentifierExpression expr1 = JsonArrayIdentifierExpression.of("attributes", "tags"); - JsonArrayIdentifierExpression expr2 = JsonArrayIdentifierExpression.of("props", "tags"); - - assertNotEquals(expr1, expr2, "Expressions with different columns should not be equal"); - } - - @Test - void testNotEqualsWithJsonIdentifierExpression() { - JsonArrayIdentifierExpression arrayExpr = - JsonArrayIdentifierExpression.of("attributes", "tags"); - JsonIdentifierExpression scalarExpr = JsonIdentifierExpression.of("attributes", "tags"); - - // Even though they have the same column and path, they are different types - assertNotEquals( - arrayExpr, - scalarExpr, - "JsonArrayIdentifierExpression should not equal JsonIdentifierExpression"); - } - - @Test - void testInheritsFromJsonIdentifierExpression() { - JsonArrayIdentifierExpression expression = - JsonArrayIdentifierExpression.of("attributes", "tags"); - - // Verify it's an instance of parent class - assertEquals( - JsonIdentifierExpression.class, - expression.getClass().getSuperclass(), - "JsonArrayIdentifierExpression should extend JsonIdentifierExpression"); - } - - @Test - void testValidColumnNames() { - // Valid column names should not throw - assertDoesNotThrow(() -> JsonArrayIdentifierExpression.of("attributes", "tags")); - assertDoesNotThrow(() -> JsonArrayIdentifierExpression.of("_internal", "field")); - assertDoesNotThrow(() -> JsonArrayIdentifierExpression.of("customAttr", "array")); - assertDoesNotThrow(() -> JsonArrayIdentifierExpression.of("attr123", "field")); - } - - @Test - void testInvalidColumnNameWithSqlInjection() { - SecurityException exception = - assertThrows( - SecurityException.class, - () -> JsonArrayIdentifierExpression.of("attributes\"; DROP TABLE users; --", "tags")); - - assertTrue(exception.getMessage().contains("invalid")); - } - - @Test - void testInvalidColumnNameStartsWithNumber() { - SecurityException exception = - assertThrows( - SecurityException.class, () -> JsonArrayIdentifierExpression.of("123attr", "tags")); - - assertTrue(exception.getMessage().contains("Must start with a letter or underscore")); - } - - @Test - void testInvalidColumnNameWithSpace() { - SecurityException exception = - assertThrows( - SecurityException.class, () -> JsonArrayIdentifierExpression.of("my column", "tags")); - - assertTrue(exception.getMessage().contains("invalid")); - } - - @Test - void testValidJsonPathWithHyphen() { - assertDoesNotThrow(() -> JsonArrayIdentifierExpression.of("attributes", "user-tags")); - assertDoesNotThrow(() -> JsonArrayIdentifierExpression.of("attributes", "repo-urls")); - } - - @Test - void testValidJsonPathWithDot() { - assertDoesNotThrow(() -> JsonArrayIdentifierExpression.of("attributes", "field.name")); - assertDoesNotThrow( - () -> JsonArrayIdentifierExpression.of("attributes", "user.preferences.tags")); - } - - @Test - void testInvalidJsonPathWithSqlInjection() { - SecurityException exception = - assertThrows( - SecurityException.class, - () -> JsonArrayIdentifierExpression.of("attributes", "tags' OR '1'='1")); - - assertTrue(exception.getMessage().contains("invalid characters")); - } - - @Test - void testInvalidJsonPathWithSemicolon() { - SecurityException exception = - assertThrows( - SecurityException.class, - () -> JsonArrayIdentifierExpression.of("attributes", "field; DROP")); - - assertTrue(exception.getMessage().contains("invalid characters")); - } - - @Test - void testJsonPathMaxDepthExceeded() { - String[] deepPath = new String[101]; // Max is 100 - for (int i = 0; i < deepPath.length; i++) { - deepPath[i] = "level" + i; - } - - SecurityException exception = - assertThrows( - SecurityException.class, - () -> JsonArrayIdentifierExpression.of("attributes", deepPath)); - - assertTrue(exception.getMessage().contains("exceeds maximum depth")); - } - - @Test - void testJsonPathWithEmptyElement() { - SecurityException exception = - assertThrows( - SecurityException.class, - () -> JsonArrayIdentifierExpression.of("attributes", "nested", "", "field")); - - assertTrue(exception.getMessage().contains("null or empty")); - } - - @Test - void testMultipleInstancesWithSamePathAreEqual() { - JsonArrayIdentifierExpression expr1 = - JsonArrayIdentifierExpression.of("attributes", "certifications"); - JsonArrayIdentifierExpression expr2 = - JsonArrayIdentifierExpression.of("attributes", "certifications"); - JsonArrayIdentifierExpression expr3 = - JsonArrayIdentifierExpression.of("attributes", "certifications"); - - assertEquals(expr1, expr2); - assertEquals(expr2, expr3); - assertEquals(expr1, expr3); - assertEquals(expr1.hashCode(), expr2.hashCode()); - assertEquals(expr2.hashCode(), expr3.hashCode()); - } - - @Test - void testNestedArrayPath() { - JsonArrayIdentifierExpression expression = - JsonArrayIdentifierExpression.of("attributes", "nested", "deep", "arrays"); - - assertEquals("attributes", expression.getColumnName()); - assertEquals(List.of("nested", "deep", "arrays"), expression.getJsonPath()); - } -} diff --git a/document-store/src/test/java/org/hypertrace/core/documentstore/postgres/query/v1/parser/filter/PostgresInParserSelectorTest.java b/document-store/src/test/java/org/hypertrace/core/documentstore/postgres/query/v1/parser/filter/PostgresInParserSelectorTest.java index 8f843a30..2bee6ea4 100644 --- a/document-store/src/test/java/org/hypertrace/core/documentstore/postgres/query/v1/parser/filter/PostgresInParserSelectorTest.java +++ b/document-store/src/test/java/org/hypertrace/core/documentstore/postgres/query/v1/parser/filter/PostgresInParserSelectorTest.java @@ -10,6 +10,7 @@ import org.hypertrace.core.documentstore.expression.impl.ConstantExpression; 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; @@ -40,10 +41,11 @@ void testVisitArrayIdentifierExpression_nestedCollection() { @Test void testVisitJsonIdentifierExpression() { PostgresInParserSelector selector = new PostgresInParserSelector(true); - JsonIdentifierExpression expr = JsonIdentifierExpression.of("customAttr", "field"); + JsonIdentifierExpression expr = + JsonIdentifierExpression.of("customAttr", JsonFieldType.STRING, "field"); PostgresInRelationalFilterParserInterface result = selector.visit(expr); assertNotNull(result); - assertInstanceOf(PostgresInRelationalFilterParser.class, result); + assertInstanceOf(PostgresInRelationalFilterParserJsonPrimitive.class, result); } @Test diff --git a/document-store/src/test/java/org/hypertrace/core/documentstore/postgres/query/v1/parser/filter/PostgresNotInParserSelectorTest.java b/document-store/src/test/java/org/hypertrace/core/documentstore/postgres/query/v1/parser/filter/PostgresNotInParserSelectorTest.java index 20c4b668..418fa929 100644 --- a/document-store/src/test/java/org/hypertrace/core/documentstore/postgres/query/v1/parser/filter/PostgresNotInParserSelectorTest.java +++ b/document-store/src/test/java/org/hypertrace/core/documentstore/postgres/query/v1/parser/filter/PostgresNotInParserSelectorTest.java @@ -10,6 +10,7 @@ import org.hypertrace.core.documentstore.expression.impl.ConstantExpression; 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; @@ -40,10 +41,11 @@ void testVisitArrayIdentifierExpression_nestedCollection() { @Test void testVisitJsonIdentifierExpression() { PostgresNotInParserSelector selector = new PostgresNotInParserSelector(true); - JsonIdentifierExpression expr = JsonIdentifierExpression.of("customAttr", "field"); + JsonIdentifierExpression expr = + JsonIdentifierExpression.of("customAttr", JsonFieldType.STRING, "field"); PostgresInRelationalFilterParserInterface result = selector.visit(expr); assertNotNull(result); - assertInstanceOf(PostgresInRelationalFilterParser.class, result); + assertInstanceOf(PostgresInRelationalFilterParserJsonPrimitive.class, result); } @Test