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 0ff2b650..0c3991c5 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 @@ -62,17 +62,20 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.typesafe.config.Config; import com.typesafe.config.ConfigFactory; import java.io.IOException; import java.util.HashMap; +import java.util.HashSet; import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.Random; +import java.util.Set; import java.util.Spliterator; import java.util.UUID; import java.util.concurrent.Callable; @@ -85,6 +88,7 @@ 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.ConstantExpression; import org.hypertrace.core.documentstore.expression.impl.FunctionExpression; @@ -4581,6 +4585,197 @@ void testJsonbNumericComparisonOperators(String dataStoreName) { } } + @Nested + class FlatCollectionArrayBehaviourTest { + + /** + * Test EXISTS filter on top-level arrays. It should only return arrays that are non-empty (have + * at-least one element) + */ + @ParameterizedTest + @ArgumentsSource(PostgresProvider.class) + void testExistsFilterOnArray(String dataStoreName) throws JsonProcessingException { + Datastore datastore = datastoreMap.get(dataStoreName); + Collection flatCollection = + datastore.getCollectionForType(FLAT_COLLECTION_NAME, DocumentType.FLAT); + + // Query using EXISTS on array field (simulating ArrayIdentifierExpression behavior) + // tags column has: NULL (row 9), empty '{}' (rows 10, 11, 13), non-empty (rows 1-8, 12, 14) + // Using EXISTS with 'null' parameter (matching entity-service pattern) + Query query = + Query.builder() + .addSelection(IdentifierExpression.of("item")) + .addSelection(IdentifierExpression.of("tags")) + .setFilter( + RelationalExpression.of( + ArrayIdentifierExpression.of("tags"), EXISTS, ConstantExpression.of("null"))) + .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 that ALL returned documents have non-empty arrays + JsonNode tags = json.get("tags"); + assertTrue( + tags.isArray() && !tags.isEmpty(), "tags should be non-empty array, but was: " + tags); + } + + // Should return only documents with non-empty arrays + // From test data: rows 1-8 have non-empty arrays (8 docs) + // Plus rows 9, 10 have non-empty arrays (2 docs) + // Total: 10 documents + assertEquals(8, count, "Should return a total of 10 docs that have non-empty tags"); + } + + /** + * Test NOT_EXISTS filter on top-level arrays. This validates that NOT_EXISTS on array fields + * returns both NULL and empty arrays, excluding only non-empty arrays. + */ + @ParameterizedTest + @ArgumentsSource(PostgresProvider.class) + void testNotExistsFilterOnArrays(String dataStoreName) throws JsonProcessingException { + Datastore datastore = datastoreMap.get(dataStoreName); + Collection flatCollection = + datastore.getCollectionForType(FLAT_COLLECTION_NAME, DocumentType.FLAT); + + // Query using NOT_EXISTS on array field (simulating ArrayIdentifierExpression behavior) + // Using NOT_EXISTS with 'null' parameter (matching entity-service pattern) + Query query = + Query.builder() + .addSelection(IdentifierExpression.of("item")) + .addSelection(IdentifierExpression.of("tags")) + .setFilter( + RelationalExpression.of( + ArrayIdentifierExpression.of("tags"), + NOT_EXISTS, + ConstantExpression.of("null"))) + .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 that ALL returned documents have NULL or empty arrays + JsonNode tags = json.get("tags"); + assertTrue( + tags == null || !tags.isArray() || tags.isEmpty(), + "tags should be NULL or empty array, but was: " + tags); + } + + // Should return documents with NULL or empty arrays + // From test data: row 9 (NULL), rows 10, 11, 13 (empty arrays) + // Total: 4 documents + assertEquals(2, count, "Should return at 4 documents with NULL or empty tags"); + } + + /** + * Test EXISTS filter on JSONB arrays. Should only return non-empty arrays (with at-least one + * element). + */ + @ParameterizedTest + @ArgumentsSource(PostgresProvider.class) + void testExistsFilterOnJsonArrays(String dataStoreName) throws JsonProcessingException { + Datastore datastore = datastoreMap.get(dataStoreName); + Collection flatCollection = + datastore.getCollectionForType(FLAT_COLLECTION_NAME, DocumentType.FLAT); + + // Query using EXISTS on JSONB array field + // props.colors has: non-empty (rows 1, 3, 5), empty (row 7), NULL (rest) + Query query = + Query.builder() + .addSelection(IdentifierExpression.of("item")) + .addSelection(JsonIdentifierExpression.of("props", "colors")) + .setFilter( + RelationalExpression.of( + JsonIdentifierExpression.of("props", JsonFieldType.STRING_ARRAY, "colors"), + EXISTS, + ConstantExpression.of("null"))) + .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 that ALL returned documents have non-empty arrays in props.colors + JsonNode props = json.get("props"); + assertTrue(props.isObject(), "props should be a JSON object"); + + JsonNode colors = props.get("colors"); + assertTrue( + colors.isArray() && !colors.isEmpty(), + "colors should be non-empty array, but was: " + colors); + } + + // Should return rows 1, 2, 3 which have non-empty colors arrays + assertEquals(3, count, "Should return exactly 3 documents with non-empty colors"); + } + + /** + * Test NOT_EXISTS filter on JSONB arrays. This validates that NOT_EXISTS on array fields inside + * JSONB returns documents where the field is NULL, the parent object is NULL, or the array is + * empty. + */ + @ParameterizedTest + @ArgumentsSource(PostgresProvider.class) + void testNotExistsFilterOnJsonArrays(String dataStoreName) throws JsonProcessingException { + Datastore datastore = datastoreMap.get(dataStoreName); + Collection flatCollection = + datastore.getCollectionForType(FLAT_COLLECTION_NAME, DocumentType.FLAT); + + // Query using NOT_EXISTS on JSONB array field + // Test with props.colors field + Query query = + Query.builder() + .addSelection(IdentifierExpression.of("item")) + .addSelection(JsonIdentifierExpression.of("props", "colors")) + .setFilter( + RelationalExpression.of( + JsonIdentifierExpression.of("props", JsonFieldType.STRING_ARRAY, "colors"), + NOT_EXISTS, + ConstantExpression.of("null"))) + .build(); + + Iterator results = flatCollection.find(query); + + int count = 0; + Set returnedItems = new HashSet<>(); + while (results.hasNext()) { + Document doc = results.next(); + JsonNode json = new ObjectMapper().readTree(doc.toJson()); + count++; + + String item = json.get("item").asText(); + returnedItems.add(item); + + // Verify that returned documents have NULL parent, missing field, or empty arrays + JsonNode props = json.get("props"); + if (props != null && props.isObject()) { + JsonNode colors = props.get("colors"); + assertTrue( + colors == null || !colors.isArray() || colors.isEmpty(), + "colors should be NULL or empty array for item: " + item + ", but was: " + colors); + } + // NULL props is also valid (if props is null, then props->colours is null too) + } + + // Should include documents where props is NULL or props.colors is NULL/empty + assertTrue(count > 0, "Should return at least some documents"); + assertTrue( + returnedItems.contains("Comb"), "Should include Comb (has empty colors array in props)"); + } + } + @Nested class BulkUpdateTest { diff --git a/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/query/v1/parser/filter/PostgresExistsRelationalFilterParser.java b/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/query/v1/parser/filter/PostgresExistsRelationalFilterParser.java index 98d046c6..224704c4 100644 --- a/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/query/v1/parser/filter/PostgresExistsRelationalFilterParser.java +++ b/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/query/v1/parser/filter/PostgresExistsRelationalFilterParser.java @@ -1,16 +1,75 @@ package org.hypertrace.core.documentstore.postgres.query.v1.parser.filter; import org.hypertrace.core.documentstore.expression.impl.ConstantExpression; +import org.hypertrace.core.documentstore.expression.impl.JsonIdentifierExpression; import org.hypertrace.core.documentstore.expression.impl.RelationalExpression; +import org.hypertrace.core.documentstore.postgres.query.v1.parser.filter.PostgresFieldTypeDetector.FieldCategory; class PostgresExistsRelationalFilterParser implements PostgresRelationalFilterParser { + @Override public String parse( final RelationalExpression expression, final PostgresRelationalFilterContext context) { final String parsedLhs = expression.getLhs().accept(context.lhsParser()); + // If true: + // Regular fields -> IS NOT NULL + // Arrays -> IS NOT NULL and cardinality(...) > 0, + // JSONB arrays: Optimized GIN index query with containment check + // If false: + // Regular fields -> IS NULL + // Arrays -> IS NULL OR cardinality(...) = 0, + // JSONB arrays: COALESCE with array length check final boolean parsedRhs = !ConstantExpression.of(false).equals(expression.getRhs()); - return parsedRhs - ? String.format("%s IS NOT NULL", parsedLhs) - : String.format("%s IS NULL", parsedLhs); + + FieldCategory category = expression.getLhs().accept(new PostgresFieldTypeDetector()); + + switch (category) { + case ARRAY: + // First-class PostgreSQL array columns (text[], int[], etc.) + return parsedRhs + // We don't need to check that LHS is NOT NULL because WHERE cardinality(NULL) will not + // be included in the result set + ? String.format("(cardinality(%s) > 0)", parsedLhs) + : String.format("COALESCE(cardinality(%s), 0) = 0", parsedLhs); + + case JSONB_ARRAY: + { + JsonIdentifierExpression jsonExpr = (JsonIdentifierExpression) expression.getLhs(); + String baseColumn = wrapWithDoubleQuotes(jsonExpr.getColumnName()); + String nestedPath = String.join(".", jsonExpr.getJsonPath()); + return parsedRhs + // This is type-safe and will use the GIN index on parent JSONB col + ? String.format( + "(%s @> '{\"" + nestedPath + "\": []}' AND jsonb_array_length(%s) > 0)", + baseColumn, + parsedLhs) + // Return the number of elements in a JSONB array, default value of 0 if the array is + // NULL + : String.format("COALESCE(jsonb_array_length(%s), 0) = 0", parsedLhs); + } + + case JSONB_SCALAR: + { + // JSONB scalar fields - use ? operator for GIN index optimization + JsonIdentifierExpression jsonExpr = (JsonIdentifierExpression) expression.getLhs(); + String baseColumn = wrapWithDoubleQuotes(jsonExpr.getColumnName()); + String nestedPath = String.join(".", jsonExpr.getJsonPath()); + + return parsedRhs + ? String.format("%s ? '%s'", baseColumn, nestedPath) + : String.format("NOT (%s ? '%s')", baseColumn, nestedPath); + } + + case SCALAR: + default: + // Regular scalar fields - use standard NULL checks + return parsedRhs + ? String.format("%s IS NOT NULL", parsedLhs) + : String.format("%s IS NULL", parsedLhs); + } + } + + private String wrapWithDoubleQuotes(String identifier) { + return "\"" + identifier + "\""; } } diff --git a/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/query/v1/parser/filter/PostgresFieldTypeDetector.java b/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/query/v1/parser/filter/PostgresFieldTypeDetector.java new file mode 100644 index 00000000..3a54cc1f --- /dev/null +++ b/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/query/v1/parser/filter/PostgresFieldTypeDetector.java @@ -0,0 +1,94 @@ +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.JsonFieldType; +import org.hypertrace.core.documentstore.expression.impl.JsonIdentifierExpression; +import org.hypertrace.core.documentstore.parser.SelectTypeExpressionVisitor; + +/** + * Visitor to detect the category of a field expression for array-aware SQL generation. + * + *

Categorizes fields into four types: + * + *

    + *
  • SCALAR: Regular non-JSON fields + *
  • ARRAY: Native PostgreSQL arrays (text[], integer[], boolean[], etc.) + *
  • JSONB_SCALAR: Scalar fields inside JSONB columns (strings, numbers, booleans, + * objects) + *
  • JSONB_ARRAY: Arrays inside JSONB columns with JsonFieldType annotation + *
+ * + *

This categorization is used by EXISTS/NOT_EXISTS parsers to generate appropriate SQL: + * + *

    + *
  • SCALAR: {@code IS NOT NULL / IS NULL} + *
  • ARRAY: {@code IS NOT NULL AND cardinality(...) > 0} + *
  • JSONB_SCALAR: {@code "col" ? 'field'} (uses GIN index) + *
  • JSONB_ARRAY: {@code "col" @> '{field:[]}' AND jsonb_array_length(...) > 0} (uses GIN index) + *
+ */ +class PostgresFieldTypeDetector implements SelectTypeExpressionVisitor { + + /** Field category for determining appropriate SQL generation strategy */ + enum FieldCategory { + SCALAR, // Regular non-JSON fields + ARRAY, // Native PostgreSQL arrays (text[], int[], etc.) + JSONB_SCALAR, // Scalar fields inside JSONB columns + JSONB_ARRAY // Arrays inside JSONB columns + } + + @Override + public FieldCategory visit(ArrayIdentifierExpression expression) { + return FieldCategory.ARRAY; + } + + @Override + public FieldCategory visit(JsonIdentifierExpression expression) { + return expression + .getFieldType() + .filter( + type -> + type == JsonFieldType.STRING_ARRAY + || type == JsonFieldType.NUMBER_ARRAY + || type == JsonFieldType.BOOLEAN_ARRAY + || type == JsonFieldType.OBJECT_ARRAY) + .map(type -> FieldCategory.JSONB_ARRAY) + .orElse(FieldCategory.JSONB_SCALAR); + } + + @Override + public FieldCategory visit(IdentifierExpression expression) { + return FieldCategory.SCALAR; + } + + @Override + public FieldCategory visit(AggregateExpression expression) { + return FieldCategory.SCALAR; + } + + @Override + public FieldCategory visit(ConstantExpression expression) { + return FieldCategory.SCALAR; + } + + @Override + public FieldCategory visit(DocumentConstantExpression expression) { + return FieldCategory.SCALAR; + } + + @Override + public FieldCategory visit(FunctionExpression expression) { + return FieldCategory.SCALAR; + } + + @Override + public FieldCategory visit(AliasedIdentifierExpression expression) { + return FieldCategory.SCALAR; + } +} diff --git a/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/query/v1/parser/filter/PostgresNotExistsRelationalFilterParser.java b/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/query/v1/parser/filter/PostgresNotExistsRelationalFilterParser.java index d365d196..2d558a02 100644 --- a/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/query/v1/parser/filter/PostgresNotExistsRelationalFilterParser.java +++ b/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/query/v1/parser/filter/PostgresNotExistsRelationalFilterParser.java @@ -1,16 +1,80 @@ package org.hypertrace.core.documentstore.postgres.query.v1.parser.filter; import org.hypertrace.core.documentstore.expression.impl.ConstantExpression; +import org.hypertrace.core.documentstore.expression.impl.JsonIdentifierExpression; import org.hypertrace.core.documentstore.expression.impl.RelationalExpression; +import org.hypertrace.core.documentstore.postgres.query.v1.parser.filter.PostgresFieldTypeDetector.FieldCategory; class PostgresNotExistsRelationalFilterParser implements PostgresRelationalFilterParser { + @Override public String parse( final RelationalExpression expression, final PostgresRelationalFilterContext context) { final String parsedLhs = expression.getLhs().accept(context.lhsParser()); + // If true (RHS = false): + // Regular fields -> IS NOT NULL + // Arrays -> IS NOT NULL AND cardinality(...) > 0 + // JSONB arrays: Optimized GIN index query with containment check + // If false (RHS = true or other): + // Regular fields -> IS NULL + // Arrays -> IS NULL OR cardinality(...) = 0 + // JSONB arrays: COALESCE with array length check final boolean parsedRhs = ConstantExpression.of(false).equals(expression.getRhs()); - return parsedRhs - ? String.format("%s IS NOT NULL", parsedLhs) - : String.format("%s IS NULL", parsedLhs); + + FieldCategory category = expression.getLhs().accept(new PostgresFieldTypeDetector()); + + switch (category) { + case ARRAY: + // For first-class array fields, only return those arrays that are not null and have + // at-least 1 element in it (so exclude NULL or empty arrays). This is to match Mongo's + // behavior + return parsedRhs + ? String.format("(cardinality(%s) > 0)", parsedLhs) + // More efficient than: %s IS NULL OR cardinality(%s) = 0)? as we can create + // an index on the COALESCE function itself which will return in a single + // index seek rather than two index seeks in the OR query + : String.format("COALESCE(cardinality(%s), 0) = 0", parsedLhs); + + case JSONB_ARRAY: + { + // Arrays inside JSONB columns - use optimized GIN index queries + JsonIdentifierExpression jsonExpr = (JsonIdentifierExpression) expression.getLhs(); + String baseColumn = wrapWithDoubleQuotes(jsonExpr.getColumnName()); + String nestedPath = String.join(".", jsonExpr.getJsonPath()); + + return parsedRhs + ? String.format( + "(%s @> '{\"" + nestedPath + "\": []}' AND jsonb_array_length(%s) > 0)", + baseColumn, + parsedLhs) + : String.format("COALESCE(jsonb_array_length(%s), 0) = 0", parsedLhs); + } + + case JSONB_SCALAR: + { + // JSONB scalar fields - use ? operator for GIN index optimization + JsonIdentifierExpression jsonExpr = (JsonIdentifierExpression) expression.getLhs(); + String baseColumn = wrapWithDoubleQuotes(jsonExpr.getColumnName()); + String nestedPath = String.join(".", jsonExpr.getJsonPath()); + + return parsedRhs + // Uses the GIN index on the parent JSONB col + ? String.format("%s ? '%s'", baseColumn, nestedPath) + // Does not use the GIN index but is more computationally efficient than doing a IS + // NULL check + : String.format("NOT (%s ? '%s')", baseColumn, nestedPath); + } + + case SCALAR: + default: + // Regular scalar fields - use standard NULL checks + return parsedRhs + ? String.format("%s IS NOT NULL", parsedLhs) + : String.format("%s IS NULL", parsedLhs); + } + } + + private String wrapWithDoubleQuotes(String identifier) { + return "\"" + identifier + "\""; } } diff --git a/document-store/src/test/java/org/hypertrace/core/documentstore/postgres/query/v1/PostgresQueryParserTest.java b/document-store/src/test/java/org/hypertrace/core/documentstore/postgres/query/v1/PostgresQueryParserTest.java index 0bdac88e..0c41a793 100644 --- a/document-store/src/test/java/org/hypertrace/core/documentstore/postgres/query/v1/PostgresQueryParserTest.java +++ b/document-store/src/test/java/org/hypertrace/core/documentstore/postgres/query/v1/PostgresQueryParserTest.java @@ -15,12 +15,14 @@ import static org.hypertrace.core.documentstore.expression.operators.LogicalOperator.OR; 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.GT; import static org.hypertrace.core.documentstore.expression.operators.RelationalOperator.GTE; import static org.hypertrace.core.documentstore.expression.operators.RelationalOperator.IN; import static org.hypertrace.core.documentstore.expression.operators.RelationalOperator.LTE; 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; import static org.hypertrace.core.documentstore.expression.operators.SortOrder.ASC; import static org.hypertrace.core.documentstore.expression.operators.SortOrder.DESC; @@ -31,9 +33,13 @@ import org.hypertrace.core.documentstore.JSONDocument; import org.hypertrace.core.documentstore.SingleValueKey; import org.hypertrace.core.documentstore.expression.impl.AggregateExpression; +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.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; import org.hypertrace.core.documentstore.expression.impl.RelationalExpression; @@ -50,6 +56,7 @@ import org.hypertrace.core.documentstore.query.SelectionSpec; import org.hypertrace.core.documentstore.query.Sort; import org.hypertrace.core.documentstore.query.SortingSpec; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; public class PostgresQueryParserTest { @@ -1551,4 +1558,207 @@ void testContainsWithFlatCollectionNonJsonField() { assertEquals(1, params.getObjectParams().size()); assertEquals(List.of("java"), params.getObjectParams().get(1)); } + + @Nested + class FlatCollectionExistsNotExistsParserTest { + + @Test + void testExistsOnTopLevelScalarField() { + Query query = + Query.builder() + .setFilter( + RelationalExpression.of( + IdentifierExpression.of("status"), EXISTS, ConstantExpression.of("null"))) + .build(); + + PostgresQueryParser postgresQueryParser = + new PostgresQueryParser( + TEST_TABLE, + PostgresQueryTransformer.transform(query), + new FlatPostgresFieldTransformer()); + + String sql = postgresQueryParser.parse(); + assertEquals("SELECT * FROM \"testCollection\" WHERE \"status\" IS NOT NULL", sql); + + Params params = postgresQueryParser.getParamsBuilder().build(); + assertEquals(0, params.getObjectParams().size()); + } + + @Test + void testExistsOnTopLevelArrayField() { + // Query on IS_NOT_EMPTY, returns arrays with length > 0 + Query query = + Query.builder() + .setFilter( + RelationalExpression.of( + ArrayIdentifierExpression.of("tags", ArrayType.TEXT), + EXISTS, + ConstantExpression.of("null"))) + .build(); + + PostgresQueryParser postgresQueryParser = + new PostgresQueryParser( + TEST_TABLE, + PostgresQueryTransformer.transform(query), + new FlatPostgresFieldTransformer()); + + String sql = postgresQueryParser.parse(); + assertEquals("SELECT * FROM \"testCollection\" WHERE (cardinality(\"tags\") > 0)", sql); + + Params params = postgresQueryParser.getParamsBuilder().build(); + assertEquals(0, params.getObjectParams().size()); + } + + @Test + void testExistsOnJsonbScalarField() { + Query query = + Query.builder() + .setFilter( + RelationalExpression.of( + JsonIdentifierExpression.of("customAttribute", JsonFieldType.STRING, "brand"), + EXISTS, + ConstantExpression.of("null"))) + .build(); + + PostgresQueryParser postgresQueryParser = + new PostgresQueryParser( + TEST_TABLE, + PostgresQueryTransformer.transform(query), + new FlatPostgresFieldTransformer()); + + String sql = postgresQueryParser.parse(); + assertEquals("SELECT * FROM \"testCollection\" WHERE \"customAttribute\" ? 'brand'", sql); + + Params params = postgresQueryParser.getParamsBuilder().build(); + assertEquals(0, params.getObjectParams().size()); + } + + @Test + void testExistsOnJsonbArrayField() { + // Query on IS_NOT_EMPTY, returns arrays with length > 0 + Query query = + Query.builder() + .setFilter( + RelationalExpression.of( + JsonIdentifierExpression.of("props", JsonFieldType.STRING_ARRAY, "colors"), + EXISTS, + ConstantExpression.of("null"))) + .build(); + + PostgresQueryParser postgresQueryParser = + new PostgresQueryParser( + TEST_TABLE, + PostgresQueryTransformer.transform(query), + new FlatPostgresFieldTransformer()); + + String sql = postgresQueryParser.parse(); + assertEquals( + "SELECT * FROM \"testCollection\" WHERE (\"props\" @> '{\"colors\": []}' AND jsonb_array_length(\"props\"->'colors') > 0)", + sql); + + Params params = postgresQueryParser.getParamsBuilder().build(); + assertEquals(0, params.getObjectParams().size()); + } + + @Test + void testNotExistsOnScalarField() { + Query query = + Query.builder() + .setFilter( + RelationalExpression.of( + IdentifierExpression.of("status"), NOT_EXISTS, ConstantExpression.of("null"))) + .build(); + + PostgresQueryParser postgresQueryParser = + new PostgresQueryParser( + TEST_TABLE, + PostgresQueryTransformer.transform(query), + new FlatPostgresFieldTransformer()); + + String sql = postgresQueryParser.parse(); + assertEquals("SELECT * FROM \"testCollection\" WHERE \"status\" IS NULL", sql); + + Params params = postgresQueryParser.getParamsBuilder().build(); + assertEquals(0, params.getObjectParams().size()); + } + + @Test + void testNotExistsOnArrayField() { + // Query on IS_NOT_EMPTY, returns arrays with length > 0 + Query query = + Query.builder() + .setFilter( + RelationalExpression.of( + ArrayIdentifierExpression.of("tags", ArrayType.TEXT), + NOT_EXISTS, + ConstantExpression.of("null"))) + .build(); + + PostgresQueryParser postgresQueryParser = + new PostgresQueryParser( + TEST_TABLE, + PostgresQueryTransformer.transform(query), + new FlatPostgresFieldTransformer()); + + String sql = postgresQueryParser.parse(); + assertEquals( + "SELECT * FROM \"testCollection\" WHERE COALESCE(cardinality(\"tags\"), 0) = 0", sql); + + Params params = postgresQueryParser.getParamsBuilder().build(); + assertEquals(0, params.getObjectParams().size()); + } + + @Test + void testNotExistsOnJsonbScalarField() { + Query query = + Query.builder() + .setFilter( + RelationalExpression.of( + JsonIdentifierExpression.of("customAttribute", JsonFieldType.STRING, "brand"), + NOT_EXISTS, + ConstantExpression.of("null"))) + .build(); + + PostgresQueryParser postgresQueryParser = + new PostgresQueryParser( + TEST_TABLE, + PostgresQueryTransformer.transform(query), + new FlatPostgresFieldTransformer()); + + String sql = postgresQueryParser.parse(); + assertEquals( + "SELECT * FROM \"testCollection\" WHERE NOT (\"customAttribute\" ? 'brand')", sql); + + Params params = postgresQueryParser.getParamsBuilder().build(); + assertEquals(0, params.getObjectParams().size()); + } + + @Test + void testNotExistsOnJsonbArrayField() { + // this is a IS_EMPTY query in a nested jsonb array. Returns arrays that are either NULL or + // empty arrays + Query query = + Query.builder() + .setFilter( + RelationalExpression.of( + JsonIdentifierExpression.of("props", JsonFieldType.STRING_ARRAY, "colors"), + NOT_EXISTS, + ConstantExpression.of("null"))) + .build(); + + PostgresQueryParser postgresQueryParser = + new PostgresQueryParser( + TEST_TABLE, + PostgresQueryTransformer.transform(query), + new FlatPostgresFieldTransformer()); + + String sql = postgresQueryParser.parse(); + assertEquals( + "SELECT * FROM \"testCollection\" WHERE COALESCE(jsonb_array_length(\"props\"->'colors'), 0) = 0", + sql); + + Params params = postgresQueryParser.getParamsBuilder().build(); + assertEquals(0, params.getObjectParams().size()); + } + } } diff --git a/document-store/src/test/java/org/hypertrace/core/documentstore/postgres/query/v1/parser/filter/PostgresExistsRelationalFilterParserTest.java b/document-store/src/test/java/org/hypertrace/core/documentstore/postgres/query/v1/parser/filter/PostgresExistsRelationalFilterParserTest.java new file mode 100644 index 00000000..2fbade12 --- /dev/null +++ b/document-store/src/test/java/org/hypertrace/core/documentstore/postgres/query/v1/parser/filter/PostgresExistsRelationalFilterParserTest.java @@ -0,0 +1,178 @@ +package org.hypertrace.core.documentstore.postgres.query.v1.parser.filter; + +import static org.hypertrace.core.documentstore.expression.operators.RelationalOperator.EXISTS; +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 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.IdentifierExpression; +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.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 PostgresExistsRelationalFilterParserTest { + + private PostgresExistsRelationalFilterParser parser; + private PostgresRelationalFilterContext context; + private PostgresSelectTypeExpressionVisitor lhsParser; + + @BeforeEach + void setUp() { + parser = new PostgresExistsRelationalFilterParser(); + context = mock(PostgresRelationalFilterContext.class); + lhsParser = mock(PostgresSelectTypeExpressionVisitor.class); + when(context.lhsParser()).thenReturn(lhsParser); + } + + @Test + void testParse_arrayField_rhsTrue() { + // Test EXISTS on array with RHS = true + ArrayIdentifierExpression lhs = ArrayIdentifierExpression.of("tags", ArrayType.TEXT); + ConstantExpression rhs = ConstantExpression.of("null"); // Any non-false value + RelationalExpression expression = RelationalExpression.of(lhs, EXISTS, rhs); + + when(lhsParser.visit(any(ArrayIdentifierExpression.class))).thenReturn("\"tags\""); + + String result = parser.parse(expression, context); + + assertEquals( + "(cardinality(\"tags\") > 0)", + result, + "EXISTS with RHS=true on ARRAY should check cardinality > 0"); + } + + @Test + void testParse_arrayField_rhsFalse() { + // Test EXISTS on array with RHS = false + ArrayIdentifierExpression lhs = ArrayIdentifierExpression.of("tags", ArrayType.TEXT); + ConstantExpression rhs = ConstantExpression.of(false); + RelationalExpression expression = RelationalExpression.of(lhs, EXISTS, rhs); + + when(lhsParser.visit(any(ArrayIdentifierExpression.class))).thenReturn("\"tags\""); + + String result = parser.parse(expression, context); + + assertEquals( + "COALESCE(cardinality(\"tags\"), 0) = 0", + result, + "EXISTS with RHS=false on ARRAY should use COALESCE for NULL or empty check"); + } + + @Test + void testParse_jsonbArrayField_rhsTrue() { + // Test EXISTS on JSONB array with RHS = true + JsonIdentifierExpression lhs = + JsonIdentifierExpression.of("props", JsonFieldType.STRING_ARRAY, "colors"); + ConstantExpression rhs = ConstantExpression.of("null"); + RelationalExpression expression = RelationalExpression.of(lhs, EXISTS, rhs); + + when(lhsParser.visit(any(JsonIdentifierExpression.class))) + .thenReturn("document->'props'->'colors'"); + + String result = parser.parse(expression, context); + + assertEquals( + "(\"props\" @> '{\"colors\": []}' AND jsonb_array_length(document->'props'->'colors') > 0)", + result, + "EXISTS with RHS=true on JSONB_ARRAY should use optimized GIN index containment query"); + } + + @Test + void testParse_jsonbArrayField_rhsFalse() { + // Test EXISTS on JSONB array with RHS = false + JsonIdentifierExpression lhs = + JsonIdentifierExpression.of("props", JsonFieldType.NUMBER_ARRAY, "scores"); + ConstantExpression rhs = ConstantExpression.of(false); + RelationalExpression expression = RelationalExpression.of(lhs, EXISTS, rhs); + + when(lhsParser.visit(any(JsonIdentifierExpression.class))) + .thenReturn("document->'props'->'scores'"); + + String result = parser.parse(expression, context); + + assertEquals( + "COALESCE(jsonb_array_length(document->'props'->'scores'), 0) = 0", + result, + "EXISTS with RHS=false on JSONB_ARRAY should use COALESCE for NULL or empty arrays"); + } + + @Test + void testParse_scalarField_rhsTrue() { + // Test EXISTS on scalar field with RHS = true + IdentifierExpression lhs = IdentifierExpression.of("item"); + ConstantExpression rhs = ConstantExpression.of("null"); + RelationalExpression expression = RelationalExpression.of(lhs, EXISTS, rhs); + + when(lhsParser.visit(any(IdentifierExpression.class))).thenReturn("document->>'item'"); + + String result = parser.parse(expression, context); + + assertEquals( + "document->>'item' IS NOT NULL", + result, + "EXISTS with RHS=true on SCALAR should check IS NOT NULL"); + } + + @Test + void testParse_scalarField_rhsFalse() { + // Test EXISTS on scalar field with RHS = false + IdentifierExpression lhs = IdentifierExpression.of("item"); + ConstantExpression rhs = ConstantExpression.of(false); + RelationalExpression expression = RelationalExpression.of(lhs, EXISTS, rhs); + + when(lhsParser.visit(any(IdentifierExpression.class))).thenReturn("document->>'item'"); + + String result = parser.parse(expression, context); + + assertEquals( + "document->>'item' IS NULL", + result, + "EXISTS with RHS=false on SCALAR should check IS NULL"); + } + + @Test + void testParse_jsonScalarField_rhsTrue() { + // Test EXISTS on JSON scalar (non-array) field with RHS = true + JsonIdentifierExpression lhs = + JsonIdentifierExpression.of("customAttribute", JsonFieldType.STRING, "brand"); + ConstantExpression rhs = ConstantExpression.of("null"); + RelationalExpression expression = RelationalExpression.of(lhs, EXISTS, rhs); + + when(lhsParser.visit(any(JsonIdentifierExpression.class))) + .thenReturn("\"customAttribute\"->>'brand'"); + + String result = parser.parse(expression, context); + + assertEquals( + "\"customAttribute\" ? 'brand'", + result, + "EXISTS with RHS=true on JSON scalar should use ? operator for GIN index"); + } + + @Test + void testParse_jsonScalarField_rhsFalse() { + // Test EXISTS on JSON scalar (non-array) field with RHS = false + JsonIdentifierExpression lhs = + JsonIdentifierExpression.of("customAttribute", JsonFieldType.STRING, "brand"); + ConstantExpression rhs = ConstantExpression.of(false); + RelationalExpression expression = RelationalExpression.of(lhs, EXISTS, rhs); + + when(lhsParser.visit(any(JsonIdentifierExpression.class))) + .thenReturn("\"customAttribute\"->>'brand'"); + + String result = parser.parse(expression, context); + + assertEquals( + "NOT (\"customAttribute\" ? 'brand')", + result, + "EXISTS with RHS=false on JSON scalar should use negated ? operator"); + } +} diff --git a/document-store/src/test/java/org/hypertrace/core/documentstore/postgres/query/v1/parser/filter/PostgresFieldTypeDetectorTest.java b/document-store/src/test/java/org/hypertrace/core/documentstore/postgres/query/v1/parser/filter/PostgresFieldTypeDetectorTest.java new file mode 100644 index 00000000..69ebc1c1 --- /dev/null +++ b/document-store/src/test/java/org/hypertrace/core/documentstore/postgres/query/v1/parser/filter/PostgresFieldTypeDetectorTest.java @@ -0,0 +1,158 @@ +package org.hypertrace.core.documentstore.postgres.query.v1.parser.filter; + +import static org.junit.jupiter.api.Assertions.assertEquals; +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.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.hypertrace.core.documentstore.postgres.query.v1.parser.filter.PostgresFieldTypeDetector.FieldCategory; +import org.junit.jupiter.api.Test; + +class PostgresFieldTypeDetectorTest { + + private final PostgresFieldTypeDetector detector = new PostgresFieldTypeDetector(); + + @Test + void testVisitArrayIdentifierExpression_returnsArray() { + ArrayIdentifierExpression expr = ArrayIdentifierExpression.of("tags", ArrayType.TEXT); + FieldCategory result = detector.visit(expr); + assertNotNull(result); + assertEquals(FieldCategory.ARRAY, result, "ArrayIdentifierExpression should return ARRAY"); + } + + @Test + void testVisitJsonIdentifierExpression_stringArray_returnsJsonbArray() { + JsonIdentifierExpression expr = + JsonIdentifierExpression.of("props", JsonFieldType.STRING_ARRAY, "colors"); + FieldCategory result = detector.visit(expr); + assertNotNull(result); + assertEquals( + FieldCategory.JSONB_ARRAY, + result, + "JsonIdentifierExpression with STRING_ARRAY should return JSONB_ARRAY"); + } + + @Test + void testVisitJsonIdentifierExpression_numberArray_returnsJsonbArray() { + JsonIdentifierExpression expr = + JsonIdentifierExpression.of("props", JsonFieldType.NUMBER_ARRAY, "scores"); + FieldCategory result = detector.visit(expr); + assertNotNull(result); + assertEquals( + FieldCategory.JSONB_ARRAY, + result, + "JsonIdentifierExpression with NUMBER_ARRAY should return JSONB_ARRAY"); + } + + @Test + void testVisitJsonIdentifierExpression_booleanArray_returnsJsonbArray() { + JsonIdentifierExpression expr = + JsonIdentifierExpression.of("props", JsonFieldType.BOOLEAN_ARRAY, "flags"); + FieldCategory result = detector.visit(expr); + assertNotNull(result); + assertEquals( + FieldCategory.JSONB_ARRAY, + result, + "JsonIdentifierExpression with BOOLEAN_ARRAY should return JSONB_ARRAY"); + } + + @Test + void testVisitJsonIdentifierExpression_objectArray_returnsJsonbArray() { + JsonIdentifierExpression expr = + JsonIdentifierExpression.of("props", JsonFieldType.OBJECT_ARRAY, "items"); + FieldCategory result = detector.visit(expr); + assertNotNull(result); + assertEquals( + FieldCategory.JSONB_ARRAY, + result, + "JsonIdentifierExpression with OBJECT_ARRAY should return JSONB_ARRAY"); + } + + @Test + void testVisitJsonIdentifierExpression_stringScalar_returnsScalar() { + JsonIdentifierExpression expr = + JsonIdentifierExpression.of("props", JsonFieldType.STRING, "brand"); + FieldCategory result = detector.visit(expr); + assertNotNull(result); + assertEquals( + FieldCategory.JSONB_SCALAR, + result, + "JsonIdentifierExpression with STRING should return SCALAR"); + } + + @Test + void testVisitJsonIdentifierExpression_noFieldType_returnsScalar() { + JsonIdentifierExpression expr = JsonIdentifierExpression.of("props", "field"); + FieldCategory result = detector.visit(expr); + assertNotNull(result); + assertEquals( + FieldCategory.JSONB_SCALAR, + result, + "JsonIdentifierExpression without field type should return SCALAR"); + } + + @Test + void testVisitIdentifierExpression_returnsScalar() { + IdentifierExpression expr = IdentifierExpression.of("item"); + FieldCategory result = detector.visit(expr); + assertNotNull(result); + assertEquals(FieldCategory.SCALAR, result, "IdentifierExpression should return SCALAR"); + } + + @Test + void testVisitAggregateExpression_returnsScalar() { + AggregateExpression expr = + AggregateExpression.of(AggregationOperator.COUNT, IdentifierExpression.of("item")); + FieldCategory result = detector.visit(expr); + assertNotNull(result); + assertEquals(FieldCategory.SCALAR, result, "AggregateExpression should return SCALAR"); + } + + @Test + void testVisitConstantExpression_returnsScalar() { + ConstantExpression expr = ConstantExpression.of("test"); + FieldCategory result = detector.visit(expr); + assertNotNull(result); + assertEquals(FieldCategory.SCALAR, result, "ConstantExpression should return SCALAR"); + } + + @Test + void testVisitDocumentConstantExpression_returnsScalar() { + ConstantExpression.DocumentConstantExpression expr = + (ConstantExpression.DocumentConstantExpression) + ConstantExpression.of((org.hypertrace.core.documentstore.Document) null); + FieldCategory result = detector.visit(expr); + assertNotNull(result); + assertEquals(FieldCategory.SCALAR, result, "DocumentConstantExpression should return SCALAR"); + } + + @Test + void testVisitFunctionExpression_returnsScalar() { + FunctionExpression expr = + FunctionExpression.builder() + .operator(FunctionOperator.LENGTH) + .operand(IdentifierExpression.of("item")) + .build(); + FieldCategory result = detector.visit(expr); + assertNotNull(result); + assertEquals(FieldCategory.SCALAR, result, "FunctionExpression should return SCALAR"); + } + + @Test + void testVisitAliasedIdentifierExpression_returnsScalar() { + AliasedIdentifierExpression expr = + AliasedIdentifierExpression.builder().name("item").contextAlias("i").build(); + FieldCategory result = detector.visit(expr); + assertNotNull(result); + assertEquals(FieldCategory.SCALAR, result, "AliasedIdentifierExpression should return SCALAR"); + } +} diff --git a/document-store/src/test/java/org/hypertrace/core/documentstore/postgres/query/v1/parser/filter/PostgresNotExistsRelationalFilterParserTest.java b/document-store/src/test/java/org/hypertrace/core/documentstore/postgres/query/v1/parser/filter/PostgresNotExistsRelationalFilterParserTest.java new file mode 100644 index 00000000..db1b6701 --- /dev/null +++ b/document-store/src/test/java/org/hypertrace/core/documentstore/postgres/query/v1/parser/filter/PostgresNotExistsRelationalFilterParserTest.java @@ -0,0 +1,178 @@ +package org.hypertrace.core.documentstore.postgres.query.v1.parser.filter; + +import static org.hypertrace.core.documentstore.expression.operators.RelationalOperator.NOT_EXISTS; +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 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.IdentifierExpression; +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.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 PostgresNotExistsRelationalFilterParserTest { + + private PostgresNotExistsRelationalFilterParser parser; + private PostgresRelationalFilterContext context; + private PostgresSelectTypeExpressionVisitor lhsParser; + + @BeforeEach + void setUp() { + parser = new PostgresNotExistsRelationalFilterParser(); + context = mock(PostgresRelationalFilterContext.class); + lhsParser = mock(PostgresSelectTypeExpressionVisitor.class); + when(context.lhsParser()).thenReturn(lhsParser); + } + + @Test + void testParse_arrayField_rhsFalse() { + // Test NOT_EXISTS on array with RHS = false (means NOT_EXISTS should be true) + ArrayIdentifierExpression lhs = ArrayIdentifierExpression.of("tags", ArrayType.TEXT); + ConstantExpression rhs = ConstantExpression.of(false); + RelationalExpression expression = RelationalExpression.of(lhs, NOT_EXISTS, rhs); + + when(lhsParser.visit(any(ArrayIdentifierExpression.class))).thenReturn("\"tags\""); + + String result = parser.parse(expression, context); + + assertEquals( + "(cardinality(\"tags\") > 0)", + result, + "NOT_EXISTS with RHS=false on ARRAY should check cardinality > 0"); + } + + @Test + void testParse_arrayField_rhsTrue() { + // Test NOT_EXISTS on array with RHS = true (means NOT_EXISTS should be false) + ArrayIdentifierExpression lhs = ArrayIdentifierExpression.of("tags", ArrayType.TEXT); + ConstantExpression rhs = ConstantExpression.of("null"); // Any non-false value + RelationalExpression expression = RelationalExpression.of(lhs, NOT_EXISTS, rhs); + + when(lhsParser.visit(any(ArrayIdentifierExpression.class))).thenReturn("\"tags\""); + + String result = parser.parse(expression, context); + + assertEquals( + "COALESCE(cardinality(\"tags\"), 0) = 0", + result, + "NOT_EXISTS with RHS=true on ARRAY should use COALESCE for NULL or empty check"); + } + + @Test + void testParse_jsonbArrayField_rhsFalse() { + // Test NOT_EXISTS on JSONB array with RHS = false + JsonIdentifierExpression lhs = + JsonIdentifierExpression.of("props", JsonFieldType.STRING_ARRAY, "colors"); + ConstantExpression rhs = ConstantExpression.of(false); + RelationalExpression expression = RelationalExpression.of(lhs, NOT_EXISTS, rhs); + + when(lhsParser.visit(any(JsonIdentifierExpression.class))) + .thenReturn("document->'props'->'colors'"); + + String result = parser.parse(expression, context); + + assertEquals( + "(\"props\" @> '{\"colors\": []}' AND jsonb_array_length(document->'props'->'colors') > 0)", + result, + "NOT_EXISTS with RHS=false on JSONB_ARRAY should use optimized GIN index containment query"); + } + + @Test + void testParse_jsonbArrayField_rhsTrue() { + // Test NOT_EXISTS on JSONB array with RHS = true + JsonIdentifierExpression lhs = + JsonIdentifierExpression.of("props", JsonFieldType.BOOLEAN_ARRAY, "flags"); + ConstantExpression rhs = ConstantExpression.of("null"); + RelationalExpression expression = RelationalExpression.of(lhs, NOT_EXISTS, rhs); + + when(lhsParser.visit(any(JsonIdentifierExpression.class))) + .thenReturn("document->'props'->'flags'"); + + String result = parser.parse(expression, context); + + assertEquals( + "COALESCE(jsonb_array_length(document->'props'->'flags'), 0) = 0", + result, + "NOT_EXISTS with RHS=true on JSONB_ARRAY should use COALESCE for NULL or empty arrays"); + } + + @Test + void testParse_scalarField_rhsFalse() { + // Test NOT_EXISTS on scalar field with RHS = false + IdentifierExpression lhs = IdentifierExpression.of("item"); + ConstantExpression rhs = ConstantExpression.of(false); + RelationalExpression expression = RelationalExpression.of(lhs, NOT_EXISTS, rhs); + + when(lhsParser.visit(any(IdentifierExpression.class))).thenReturn("document->>'item'"); + + String result = parser.parse(expression, context); + + assertEquals( + "document->>'item' IS NOT NULL", + result, + "NOT_EXISTS with RHS=false on SCALAR should check IS NOT NULL"); + } + + @Test + void testParse_scalarField_rhsTrue() { + // Test NOT_EXISTS on scalar field with RHS = true + IdentifierExpression lhs = IdentifierExpression.of("item"); + ConstantExpression rhs = ConstantExpression.of("null"); + RelationalExpression expression = RelationalExpression.of(lhs, NOT_EXISTS, rhs); + + when(lhsParser.visit(any(IdentifierExpression.class))).thenReturn("document->>'item'"); + + String result = parser.parse(expression, context); + + assertEquals( + "document->>'item' IS NULL", + result, + "NOT_EXISTS with RHS=true on SCALAR should check IS NULL"); + } + + @Test + void testParse_jsonScalarField_rhsFalse() { + // Test NOT_EXISTS on JSON scalar (non-array) field with RHS = false + JsonIdentifierExpression lhs = + JsonIdentifierExpression.of("customAttribute", JsonFieldType.STRING, "brand"); + ConstantExpression rhs = ConstantExpression.of(false); + RelationalExpression expression = RelationalExpression.of(lhs, NOT_EXISTS, rhs); + + when(lhsParser.visit(any(JsonIdentifierExpression.class))) + .thenReturn("\"customAttribute\"->>'brand'"); + + String result = parser.parse(expression, context); + + assertEquals( + "\"customAttribute\" ? 'brand'", + result, + "NOT_EXISTS with RHS=false on JSON scalar should use ? operator for GIN index"); + } + + @Test + void testParse_jsonScalarField_rhsTrue() { + // Test NOT_EXISTS on JSON scalar (non-array) field with RHS = true + JsonIdentifierExpression lhs = + JsonIdentifierExpression.of("customAttribute", JsonFieldType.STRING, "brand"); + ConstantExpression rhs = ConstantExpression.of("null"); + RelationalExpression expression = RelationalExpression.of(lhs, NOT_EXISTS, rhs); + + when(lhsParser.visit(any(JsonIdentifierExpression.class))) + .thenReturn("\"customAttribute\"->>'brand'"); + + String result = parser.parse(expression, context); + + assertEquals( + "NOT (\"customAttribute\" ? 'brand')", + result, + "NOT_EXISTS with RHS=true on JSON scalar should use negated ? operator"); + } +}