From 4720829a7d335cd36b27be9c9ffda794e5aebf03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Clrathod=E2=80=9D?= Date: Thu, 27 Aug 2026 15:41:44 +0530 Subject: [PATCH 1/6] Add ALL and ONE array filter operators for MongoDB and Postgres Add two new ArrayOperator values for filtering on array-valued attributes: - ALL: array attribute must contain every value specified in the filter - ONE: array attribute must contain exactly one element, and that element must be one of the specified values MongoDB: ALL uses $setIsSubset with an $ifNull guard; ONE combines $size == 1 with $in on the first element via $arrayElemAt. Postgres: native array columns use @> (ALL) and array_length + && (ONE); JSONB array paths use jsonb_typeof-guarded @> containment and jsonb_array_length respectively. Both parsers require the inner filter to carry a constant value list; non-constant RHS expressions throw UnsupportedOperationException since these are set-level operators, not per-element predicates like ANY. Co-authored-by: Cursor --- .../ArrayFiltersQueryIntegrationTest.java | 81 ++++++++ .../array_match_all_result.json | 10 + .../array_match_one_result.json | 6 + .../array_operators/array_match_test.json | 31 +++ .../expression/operators/ArrayOperator.java | 6 +- .../query/parser/MongoArrayFilterParser.java | 102 +++++++++- .../PostgresFilterTypeExpressionVisitor.java | 168 +++++++++++++++- .../parser/MongoArrayFilterParserTest.java | 179 ++++++++++++++++++ .../query/v1/PostgresQueryParserTest.java | 164 ++++++++++++++++ 9 files changed, 741 insertions(+), 6 deletions(-) create mode 100644 document-store/src/integrationTest/resources/query/array_operators/array_match_all_result.json create mode 100644 document-store/src/integrationTest/resources/query/array_operators/array_match_one_result.json create mode 100644 document-store/src/integrationTest/resources/query/array_operators/array_match_test.json create mode 100644 document-store/src/test/java/org/hypertrace/core/documentstore/mongo/query/parser/MongoArrayFilterParserTest.java diff --git a/document-store/src/integrationTest/java/org/hypertrace/core/documentstore/ArrayFiltersQueryIntegrationTest.java b/document-store/src/integrationTest/java/org/hypertrace/core/documentstore/ArrayFiltersQueryIntegrationTest.java index 318bafa67..e6f066a51 100644 --- a/document-store/src/integrationTest/java/org/hypertrace/core/documentstore/ArrayFiltersQueryIntegrationTest.java +++ b/document-store/src/integrationTest/java/org/hypertrace/core/documentstore/ArrayFiltersQueryIntegrationTest.java @@ -2,7 +2,9 @@ import static org.hypertrace.core.documentstore.expression.impl.LogicalExpression.and; import static org.hypertrace.core.documentstore.expression.impl.LogicalExpression.not; +import static org.hypertrace.core.documentstore.expression.operators.ArrayOperator.ALL; import static org.hypertrace.core.documentstore.expression.operators.ArrayOperator.ANY; +import static org.hypertrace.core.documentstore.expression.operators.ArrayOperator.ONE; import static org.hypertrace.core.documentstore.model.config.DatabaseType.MONGO; import static org.hypertrace.core.documentstore.model.config.DatabaseType.POSTGRES; import static org.hypertrace.core.documentstore.utils.Utils.MONGO_STORE; @@ -11,6 +13,7 @@ import com.google.common.io.Resources; import java.io.IOException; import java.util.Iterator; +import java.util.List; import java.util.Map; import java.util.Spliterator; import java.util.Spliterators; @@ -344,6 +347,84 @@ void getDocumentsWithEnvironmentIdsSubsetOfGivenList(final String dataStoreName) JSONAssert.assertEquals(expected, actual, JSONCompareMode.LENIENT); } + /** + * Tests MATCH_ALL semantics: documents whose array attribute contains every value specified in + * the filter. + */ + @ParameterizedTest + @ArgumentsSource(AllProvider.class) + void getDocumentsContainingAllGivenValues(final String dataStoreName) + throws JSONException, IOException { + final String testCollectionName = "array_match_test"; + final Datastore datastore = datastoreMap.get(dataStoreName); + final Map testDocuments = + Utils.buildDocumentsFromResource("query/array_operators/array_match_test.json"); + datastore.deleteCollection(testCollectionName); + datastore.createCollection(testCollectionName, null); + final Collection collection = datastore.getCollection(testCollectionName); + collection.bulkUpsert(testDocuments); + + final Query query = + Query.builder() + .setFilter( + ArrayRelationalFilterExpression.builder() + .operator(ALL) + .filter( + RelationalExpression.of( + IdentifierExpression.of("tags"), + RelationalOperator.IN, + ConstantExpression.ofStrings(List.of("red", "blue")))) + .build()) + .build(); + + final Iterator documents = collection.aggregate(query); + final String expected = readResource("array_match_all_result.json"); + final String actual = iteratorToJson(documents); + + datastore.deleteCollection(testCollectionName); + + JSONAssert.assertEquals(expected, actual, JSONCompareMode.LENIENT); + } + + /** + * Tests MATCH_ONE semantics: documents whose array attribute has exactly one element, and that + * element is one of the values specified in the filter. + */ + @ParameterizedTest + @ArgumentsSource(AllProvider.class) + void getDocumentsWithExactlyOneElementMatchingGivenValues(final String dataStoreName) + throws JSONException, IOException { + final String testCollectionName = "array_match_test"; + final Datastore datastore = datastoreMap.get(dataStoreName); + final Map testDocuments = + Utils.buildDocumentsFromResource("query/array_operators/array_match_test.json"); + datastore.deleteCollection(testCollectionName); + datastore.createCollection(testCollectionName, null); + final Collection collection = datastore.getCollection(testCollectionName); + collection.bulkUpsert(testDocuments); + + final Query query = + Query.builder() + .setFilter( + ArrayRelationalFilterExpression.builder() + .operator(ONE) + .filter( + RelationalExpression.of( + IdentifierExpression.of("tags"), + RelationalOperator.IN, + ConstantExpression.ofStrings(List.of("red", "blue")))) + .build()) + .build(); + + final Iterator documents = collection.aggregate(query); + final String expected = readResource("array_match_one_result.json"); + final String actual = iteratorToJson(documents); + + datastore.deleteCollection(testCollectionName); + + JSONAssert.assertEquals(expected, actual, JSONCompareMode.LENIENT); + } + private String readResource(final String fileName) { try { return new String( diff --git a/document-store/src/integrationTest/resources/query/array_operators/array_match_all_result.json b/document-store/src/integrationTest/resources/query/array_operators/array_match_all_result.json new file mode 100644 index 000000000..d9fd9221a --- /dev/null +++ b/document-store/src/integrationTest/resources/query/array_operators/array_match_all_result.json @@ -0,0 +1,10 @@ +[ + { + "name": "Document A", + "tags": ["red", "blue"] + }, + { + "name": "Document B", + "tags": ["red", "blue", "green"] + } +] diff --git a/document-store/src/integrationTest/resources/query/array_operators/array_match_one_result.json b/document-store/src/integrationTest/resources/query/array_operators/array_match_one_result.json new file mode 100644 index 000000000..2f5de65f9 --- /dev/null +++ b/document-store/src/integrationTest/resources/query/array_operators/array_match_one_result.json @@ -0,0 +1,6 @@ +[ + { + "name": "Document C", + "tags": ["red"] + } +] diff --git a/document-store/src/integrationTest/resources/query/array_operators/array_match_test.json b/document-store/src/integrationTest/resources/query/array_operators/array_match_test.json new file mode 100644 index 000000000..29b1a1a07 --- /dev/null +++ b/document-store/src/integrationTest/resources/query/array_operators/array_match_test.json @@ -0,0 +1,31 @@ +[ + { + "_id": 1, + "name": "Document A", + "tags": ["red", "blue"] + }, + { + "_id": 2, + "name": "Document B", + "tags": ["red", "blue", "green"] + }, + { + "_id": 3, + "name": "Document C", + "tags": ["red"] + }, + { + "_id": 4, + "name": "Document D", + "tags": ["yellow"] + }, + { + "_id": 5, + "name": "Document E", + "tags": [] + }, + { + "_id": 6, + "name": "Document F" + } +] diff --git a/document-store/src/main/java/org/hypertrace/core/documentstore/expression/operators/ArrayOperator.java b/document-store/src/main/java/org/hypertrace/core/documentstore/expression/operators/ArrayOperator.java index b018032e1..319741858 100644 --- a/document-store/src/main/java/org/hypertrace/core/documentstore/expression/operators/ArrayOperator.java +++ b/document-store/src/main/java/org/hypertrace/core/documentstore/expression/operators/ArrayOperator.java @@ -2,5 +2,9 @@ public enum ArrayOperator { ANY, - // Can support ALL and NONE later + // Array attribute must contain every value specified in the filter + ALL, + // Array attribute must contain exactly one element, and that element must be one of the values + // specified in the filter + ONE, } diff --git a/document-store/src/main/java/org/hypertrace/core/documentstore/mongo/query/parser/MongoArrayFilterParser.java b/document-store/src/main/java/org/hypertrace/core/documentstore/mongo/query/parser/MongoArrayFilterParser.java index cab7312c6..5269ac4e7 100644 --- a/document-store/src/main/java/org/hypertrace/core/documentstore/mongo/query/parser/MongoArrayFilterParser.java +++ b/document-store/src/main/java/org/hypertrace/core/documentstore/mongo/query/parser/MongoArrayFilterParser.java @@ -6,10 +6,14 @@ import static org.hypertrace.core.documentstore.mongo.query.parser.filter.MongoStandardExprRelationalFilterParser.EXPR; import com.google.common.collect.Maps; +import java.util.List; import java.util.Map; import java.util.Optional; import org.hypertrace.core.documentstore.expression.impl.ArrayFilterExpression; +import org.hypertrace.core.documentstore.expression.impl.ConstantExpression; +import org.hypertrace.core.documentstore.expression.impl.RelationalExpression; import org.hypertrace.core.documentstore.expression.operators.ArrayOperator; +import org.hypertrace.core.documentstore.expression.type.FilterTypeExpression; import org.hypertrace.core.documentstore.expression.type.SelectTypeExpression; import org.hypertrace.core.documentstore.mongo.MongoUtils; import org.hypertrace.core.documentstore.mongo.query.parser.filter.MongoRelationalFilterParserFactory.MongoRelationalFilterContext; @@ -21,6 +25,12 @@ class MongoArrayFilterParser { private static final String IF_NULL = "$ifNull"; private static final String AS = "as"; private static final String IN = "in"; + private static final String SET_IS_SUBSET = "$setIsSubset"; + private static final String SIZE = "$size"; + private static final String EQ = "$eq"; + private static final String IN_OPERATOR = "$in"; + private static final String ARRAY_ELEM_AT = "$arrayElemAt"; + private static final String AND = "$and"; private static final Map OPERATOR_MAP = Maps.immutableEnumMap(Map.ofEntries(entry(ANY, ANY_ELEMENT_TRUE))); @@ -39,6 +49,17 @@ class MongoArrayFilterParser { } Map parse(final ArrayFilterExpression arrayFilterExpression) { + switch (arrayFilterExpression.getOperator()) { + case ALL: + return parseAllOperator(arrayFilterExpression); + case ONE: + return parseOneOperator(arrayFilterExpression); + default: + return parseAnyOperator(arrayFilterExpression); + } + } + + private Map parseAnyOperator(final ArrayFilterExpression arrayFilterExpression) { final String operator = Optional.ofNullable(OPERATOR_MAP.get(arrayFilterExpression.getOperator())) .orElseThrow( @@ -103,9 +124,84 @@ Map parse(final ArrayFilterExpression arrayFilterExpression) { entry(INPUT, Map.of(IF_NULL, new Object[] {mapInput, new Object[0]})), entry(AS, alias), entry(IN, filter)))); + return wrapInExprIfNeeded(arrayFilter); + } + + /* + { + "$expr": { + "$setIsSubset": [ + ["Blue", "Green"], + { "$ifNull": ["$colors", []] } + ] + } + } + */ + private Map parseAllOperator(final ArrayFilterExpression arrayFilterExpression) { + final Object mapInput = getDollarPrefixedArraySource(arrayFilterExpression); + final List values = getFilterValues(arrayFilterExpression); + + final Map setIsSubset = + Map.of( + SET_IS_SUBSET, + List.of(values, Map.of(IF_NULL, new Object[] {mapInput, new Object[0]}))); + return wrapInExprIfNeeded(setIsSubset); + } + + /* + { + "$expr": { + "$and": [ + { "$eq": [{ "$size": { "$ifNull": ["$colors", []] } }, 1] }, + { "$in": [{ "$arrayElemAt": [{ "$ifNull": ["$colors", []] }, 0] }, ["Blue", "Green"]] } + ] + } + } + */ + private Map parseOneOperator(final ArrayFilterExpression arrayFilterExpression) { + final Object mapInput = getDollarPrefixedArraySource(arrayFilterExpression); + final List values = getFilterValues(arrayFilterExpression); + final Map arrayWithDefault = + Map.of(IF_NULL, new Object[] {mapInput, new Object[0]}); + + final Map sizeIsOne = Map.of(EQ, List.of(Map.of(SIZE, arrayWithDefault), 1)); + final Map firstElementMatches = + Map.of(IN_OPERATOR, List.of(Map.of(ARRAY_ELEM_AT, List.of(arrayWithDefault, 0)), values)); + + return wrapInExprIfNeeded(Map.of(AND, List.of(sizeIsOne, firstElementMatches))); + } + + private String getDollarPrefixedArraySource(final ArrayFilterExpression arrayFilterExpression) { + final MongoSelectTypeExpressionParser wrappingParser = + new MongoDollarPrefixingIdempotentParser(relationalFilterContext.lhsParser()); + return arrayFilterExpression.getArraySource().accept(wrappingParser); + } + + private List getFilterValues(final ArrayFilterExpression arrayFilterExpression) { + final FilterTypeExpression filter = arrayFilterExpression.getFilter(); + if (!(filter instanceof RelationalExpression)) { + throw new UnsupportedOperationException( + "Array operator " + + arrayFilterExpression.getOperator() + + " only supports a relational filter with a constant list of values, got: " + + filter); + } + + final SelectTypeExpression rhs = ((RelationalExpression) filter).getRhs(); + if (!(rhs instanceof ConstantExpression)) { + throw new UnsupportedOperationException( + "Array operator " + + arrayFilterExpression.getOperator() + + " requires a constant list of values, got: " + + rhs); + } + + final Object value = ((ConstantExpression) rhs).getValue(); + return value instanceof List ? (List) value : List.of(value); + } + + private Map wrapInExprIfNeeded(final Map filter) { // If already wrapped inside `$expr` avoid wrapping again - return INSIDE_EXPR.equals(relationalFilterContext.location()) - ? arrayFilter - : Map.of(EXPR, arrayFilter); + return INSIDE_EXPR.equals(relationalFilterContext.location()) ? filter : Map.of(EXPR, filter); } } diff --git a/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/query/v1/vistors/PostgresFilterTypeExpressionVisitor.java b/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/query/v1/vistors/PostgresFilterTypeExpressionVisitor.java index cab7713b7..8de9c1fe8 100644 --- a/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/query/v1/vistors/PostgresFilterTypeExpressionVisitor.java +++ b/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/query/v1/vistors/PostgresFilterTypeExpressionVisitor.java @@ -8,6 +8,9 @@ import static org.hypertrace.core.documentstore.postgres.utils.PostgresUtils.encodeAliasForNestedField; import static org.hypertrace.core.documentstore.postgres.utils.PostgresUtils.prepareParsedNonCompositeFilter; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.List; import java.util.Optional; import java.util.stream.Collector; import java.util.stream.Collectors; @@ -15,6 +18,7 @@ import org.apache.commons.lang3.StringUtils; import org.hypertrace.core.documentstore.DocumentType; import org.hypertrace.core.documentstore.Key; +import org.hypertrace.core.documentstore.expression.impl.ArrayFilterExpression; import org.hypertrace.core.documentstore.expression.impl.ArrayRelationalFilterExpression; import org.hypertrace.core.documentstore.expression.impl.ConstantExpression; import org.hypertrace.core.documentstore.expression.impl.DocumentArrayFilterExpression; @@ -24,6 +28,7 @@ import org.hypertrace.core.documentstore.expression.impl.RelationalExpression; import org.hypertrace.core.documentstore.expression.operators.LogicalOperator; import org.hypertrace.core.documentstore.expression.type.FilterTypeExpression; +import org.hypertrace.core.documentstore.expression.type.SelectTypeExpression; import org.hypertrace.core.documentstore.parser.FilterTypeExpressionVisitor; import org.hypertrace.core.documentstore.postgres.query.v1.PostgresQueryParser; import org.hypertrace.core.documentstore.postgres.query.v1.parser.builder.PostgresSelectExpressionParserBuilder; @@ -117,7 +122,10 @@ WHERE TRIM('"' FROM elements::text) = 'Oxygen' switch (expression.getOperator()) { case ANY: return getFilterStringForAnyOperator(expression); - + case ALL: + return getFilterStringForAllOperator(expression); + case ONE: + return getFilterStringForOneOperator(expression); default: throw new UnsupportedOperationException( "Unsupported array operator: " + expression.getOperator()); @@ -137,7 +145,10 @@ FROM jsonb_array_elements(COALESCE(document->'planets', '[]'::jsonb)) AS planet switch (expression.getOperator()) { case ANY: return getFilterStringForAnyOperator(expression); - + case ALL: + return getFilterStringForAllOperator(expression); + case ONE: + return getFilterStringForOneOperator(expression); default: throw new UnsupportedOperationException( "Unsupported array operator: " + expression.getOperator()); @@ -332,4 +343,157 @@ private String getFilterStringForAnyOperator(final DocumentArrayFilterExpression parsedLhs, parsedLhs, alias, parsedFilter); } } + + /* + Native array (flat collection): + COALESCE(tags, ARRAY[]::text[]) @> ? -- bound as a typed array param, e.g. ['Blue','Green'] + + JSONB array (nested collection or JSONB column in a flat collection): + (CASE WHEN jsonb_typeof(colors) = 'array' THEN colors ELSE '[]'::jsonb END) @> ?::jsonb + -- bound as the JSON text '["Blue","Green"]' + */ + private String getFilterStringForAllOperator(final ArrayFilterExpression expression) { + final List values = getArrayOperatorFilterValues(expression); + final ArrayFieldContext fieldContext = getArrayFieldContext(expression); + + if (fieldContext.isNativeArray()) { + final PostgresDataType dataType = resolvePostgresDataType(values); + postgresQueryParser.getParamsBuilder().addArrayParam(values.toArray(), dataType.getSqlType()); + return String.format( + "COALESCE(%s, ARRAY[]%s) @> ?", fieldContext.parsedLhs(), dataType.getArrayTypeCast()); + } + + final String coalescedArray = + String.format( + "(CASE WHEN jsonb_typeof(%s) = 'array' THEN %s ELSE '[]'::jsonb END)", + fieldContext.parsedLhs(), fieldContext.parsedLhs()); + postgresQueryParser.getParamsBuilder().addObjectParam(toJsonArrayString(values)); + return String.format("%s @> ?::jsonb", coalescedArray); + } + + /* + Native array (flat collection): + array_length(COALESCE(tags, ARRAY[]::text[]), 1) = 1 + AND COALESCE(tags, ARRAY[]::text[]) && ? -- bound as a typed array param + + JSONB array (nested collection or JSONB column in a flat collection): + jsonb_array_length() = 1 + AND ( @> ?::jsonb OR @> ?::jsonb ...) + -- one bound single-element JSON array per filter value + */ + private String getFilterStringForOneOperator(final ArrayFilterExpression expression) { + final List values = getArrayOperatorFilterValues(expression); + final ArrayFieldContext fieldContext = getArrayFieldContext(expression); + + if (fieldContext.isNativeArray()) { + final PostgresDataType dataType = resolvePostgresDataType(values); + final String arrayTypeCast = dataType.getArrayTypeCast(); + final String coalescedArray = + String.format("COALESCE(%s, ARRAY[]%s)", fieldContext.parsedLhs(), arrayTypeCast); + postgresQueryParser.getParamsBuilder().addArrayParam(values.toArray(), dataType.getSqlType()); + return String.format("array_length(%s, 1) = 1 AND %s && ?", coalescedArray, coalescedArray); + } + + final String coalescedArray = + String.format( + "(CASE WHEN jsonb_typeof(%s) = 'array' THEN %s ELSE '[]'::jsonb END)", + fieldContext.parsedLhs(), fieldContext.parsedLhs()); + final String matchesAnyValue = + values.isEmpty() + ? "FALSE" + : values.stream() + .map( + value -> { + postgresQueryParser + .getParamsBuilder() + .addObjectParam(toJsonArrayString(List.of(value))); + return String.format("%s @> ?::jsonb", coalescedArray); + }) + .collect(Collectors.joining(" OR ")); + return String.format("jsonb_array_length(%s) = 1 AND (%s)", coalescedArray, matchesAnyValue); + } + + private ArrayFieldContext getArrayFieldContext(final ArrayFilterExpression expression) { + final boolean isFlatCollection = + postgresQueryParser.getPgColTransformer().getDocumentType() == DocumentType.FLAT; + final boolean isJsonbArray = expression.getArraySource() instanceof JsonIdentifierExpression; + final boolean isNativeArray = isFlatCollection && !isJsonbArray; + + final String identifierName = + expression + .getArraySource() + .accept(new PostgresIdentifierExpressionVisitor(postgresQueryParser)); + + final String parsedLhs; + if (isNativeArray) { + parsedLhs = postgresQueryParser.transformField(identifierName).getPgColumn(); + } else { + final PostgresIdentifierExpressionVisitor identifierVisitor = + new PostgresIdentifierExpressionVisitor(postgresQueryParser); + final PostgresSelectTypeExpressionVisitor arrayPathVisitor = + wrappingVisitorProvider == null + ? new PostgresFieldIdentifierExpressionVisitor(identifierVisitor) + : wrappingVisitorProvider.getForNonRelational(identifierVisitor); + parsedLhs = expression.getArraySource().accept(arrayPathVisitor); + } + + return new ArrayFieldContext(parsedLhs, isNativeArray); + } + + private List getArrayOperatorFilterValues(final ArrayFilterExpression expression) { + final FilterTypeExpression filter = expression.getFilter(); + if (!(filter instanceof RelationalExpression)) { + throw new UnsupportedOperationException( + "Array operator " + + expression.getOperator() + + " only supports a relational filter with a constant list of values, got: " + + filter); + } + + final SelectTypeExpression rhs = ((RelationalExpression) filter).getRhs(); + if (!(rhs instanceof ConstantExpression)) { + throw new UnsupportedOperationException( + "Array operator " + + expression.getOperator() + + " requires a constant list of values, got: " + + rhs); + } + + final Object value = ((ConstantExpression) rhs).getValue(); + return value instanceof List ? (List) value : List.of(value); + } + + private PostgresDataType resolvePostgresDataType(final List values) { + return values.stream() + .map(PostgresDataType::fromJavaValue) + .filter(dataType -> dataType != PostgresDataType.UNKNOWN) + .findFirst() + .orElse(PostgresDataType.TEXT); + } + + private String toJsonArrayString(final List values) { + try { + return new ObjectMapper().writeValueAsString(values); + } catch (JsonProcessingException e) { + throw new IllegalArgumentException("Unable to serialize array filter values: " + values, e); + } + } + + private static class ArrayFieldContext { + private final String parsedLhs; + private final boolean isNativeArray; + + ArrayFieldContext(final String parsedLhs, final boolean isNativeArray) { + this.parsedLhs = parsedLhs; + this.isNativeArray = isNativeArray; + } + + String parsedLhs() { + return parsedLhs; + } + + boolean isNativeArray() { + return isNativeArray; + } + } } diff --git a/document-store/src/test/java/org/hypertrace/core/documentstore/mongo/query/parser/MongoArrayFilterParserTest.java b/document-store/src/test/java/org/hypertrace/core/documentstore/mongo/query/parser/MongoArrayFilterParserTest.java new file mode 100644 index 000000000..f7de01644 --- /dev/null +++ b/document-store/src/test/java/org/hypertrace/core/documentstore/mongo/query/parser/MongoArrayFilterParserTest.java @@ -0,0 +1,179 @@ +package org.hypertrace.core.documentstore.mongo.query.parser; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Map; +import org.hypertrace.core.documentstore.expression.impl.ArrayRelationalFilterExpression; +import org.hypertrace.core.documentstore.expression.impl.ConstantExpression; +import org.hypertrace.core.documentstore.expression.impl.IdentifierExpression; +import org.hypertrace.core.documentstore.expression.impl.RelationalExpression; +import org.hypertrace.core.documentstore.expression.operators.ArrayOperator; +import org.hypertrace.core.documentstore.expression.operators.RelationalOperator; +import org.junit.jupiter.api.Test; + +class MongoArrayFilterParserTest { + + private final MongoFilterTypeExpressionParser parser = new MongoFilterTypeExpressionParser(); + + @Test + void testAllOperator() { + final ArrayRelationalFilterExpression expression = + ArrayRelationalFilterExpression.builder() + .operator(ArrayOperator.ALL) + .filter( + RelationalExpression.of( + IdentifierExpression.of("tags"), + RelationalOperator.IN, + ConstantExpression.ofStrings(List.of("Blue", "Green")))) + .build(); + + final Map result = parser.visit(expression); + + // {"$expr": {"$setIsSubset": [["Blue", "Green"], {"$ifNull": ["$tags", []]}]}} + final Map expr = getMap(result, "$expr"); + final List setIsSubset = getList(expr, "$setIsSubset"); + assertEquals(2, setIsSubset.size()); + assertEquals(List.of("Blue", "Green"), setIsSubset.get(0)); + + final Map ifNull = castToMap(setIsSubset.get(1)); + final Object[] ifNullArgs = (Object[]) ifNull.get("$ifNull"); + assertEquals("$tags", ifNullArgs[0]); + assertEquals(0, ((Object[]) ifNullArgs[1]).length); + } + + @Test + void testOneOperator() { + final ArrayRelationalFilterExpression expression = + ArrayRelationalFilterExpression.builder() + .operator(ArrayOperator.ONE) + .filter( + RelationalExpression.of( + IdentifierExpression.of("tags"), + RelationalOperator.IN, + ConstantExpression.ofStrings(List.of("Blue", "Green")))) + .build(); + + final Map result = parser.visit(expression); + + /* + {"$expr": {"$and": [ + {"$eq": [{"$size": {"$ifNull": ["$tags", []]}}, 1]}, + {"$in": [{"$arrayElemAt": [{"$ifNull": ["$tags", []]}, 0]}, ["Blue", "Green"]]} + ]}} + */ + final Map expr = getMap(result, "$expr"); + final List and = getList(expr, "$and"); + assertEquals(2, and.size()); + + final List eq = getList(castToMap(and.get(0)), "$eq"); + final Map size = castToMap(eq.get(0)); + final Map sizeIfNull = castToMap(size.get("$size")); + final Object[] sizeIfNullArgs = (Object[]) sizeIfNull.get("$ifNull"); + assertEquals("$tags", sizeIfNullArgs[0]); + assertEquals(1, eq.get(1)); + + final List in = getList(castToMap(and.get(1)), "$in"); + final Map arrayElemAt = castToMap(in.get(0)); + final List arrayElemAtArgs = getList(arrayElemAt, "$arrayElemAt"); + final Map elemIfNull = castToMap(arrayElemAtArgs.get(0)); + final Object[] elemIfNullArgs = (Object[]) elemIfNull.get("$ifNull"); + assertEquals("$tags", elemIfNullArgs[0]); + assertEquals(0, arrayElemAtArgs.get(1)); + assertEquals(List.of("Blue", "Green"), in.get(1)); + } + + @Test + void testAllOperatorWithSingleValue() { + final ArrayRelationalFilterExpression expression = + ArrayRelationalFilterExpression.builder() + .operator(ArrayOperator.ALL) + .filter( + RelationalExpression.of( + IdentifierExpression.of("tags"), + RelationalOperator.EQ, + ConstantExpression.of("Blue"))) + .build(); + + final Map result = parser.visit(expression); + + final Map expr = getMap(result, "$expr"); + final List setIsSubset = getList(expr, "$setIsSubset"); + assertEquals(List.of("Blue"), setIsSubset.get(0)); + } + + @Test + void testAnyOperatorStillSupported() { + final ArrayRelationalFilterExpression expression = + ArrayRelationalFilterExpression.builder() + .operator(ArrayOperator.ANY) + .filter( + RelationalExpression.of( + IdentifierExpression.of("tags"), + RelationalOperator.IN, + ConstantExpression.ofStrings(List.of("Blue", "Green")))) + .build(); + + // ANY remains supported + final Map result = parser.visit(expression); + assertTrue(result.containsKey("$expr")); + } + + @Test + void testAllOperatorInsideExprLocation() { + final MongoFilterTypeExpressionParser insideExprParser = + new MongoFilterTypeExpressionParser( + org.hypertrace.core.documentstore.mongo.query.parser.filter + .MongoRelationalFilterParserFactory.MongoRelationalFilterContext.builder() + .location( + org.hypertrace.core.documentstore.mongo.query.parser.filter + .MongoRelationalFilterParserFactory.FilterLocation.INSIDE_EXPR) + .build()); + + final ArrayRelationalFilterExpression expression = + ArrayRelationalFilterExpression.builder() + .operator(ArrayOperator.ALL) + .filter( + RelationalExpression.of( + IdentifierExpression.of("tags"), + RelationalOperator.IN, + ConstantExpression.ofStrings(List.of("Blue")))) + .build(); + + final Map result = insideExprParser.visit(expression); + + // already inside $expr, so no additional wrapping + assertTrue(result.containsKey("$setIsSubset")); + } + + @Test + void testOneOperatorRejectsNonConstantRhs() { + final ArrayRelationalFilterExpression expression = + ArrayRelationalFilterExpression.builder() + .operator(ArrayOperator.ONE) + .filter( + RelationalExpression.of( + IdentifierExpression.of("tags"), + RelationalOperator.EQ, + IdentifierExpression.of("otherField"))) + .build(); + + assertThrows(UnsupportedOperationException.class, () -> parser.visit(expression)); + } + + @SuppressWarnings("unchecked") + private Map castToMap(final Object object) { + return (Map) object; + } + + private Map getMap(final Map map, final String key) { + return castToMap(map.get(key)); + } + + @SuppressWarnings("unchecked") + private List getList(final Map map, final String key) { + return (List) map.get(key); + } +} 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 5a42e1cd7..75d36d922 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 @@ -34,6 +34,7 @@ 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.ArrayRelationalFilterExpression; import org.hypertrace.core.documentstore.expression.impl.ConstantExpression; import org.hypertrace.core.documentstore.expression.impl.FunctionExpression; import org.hypertrace.core.documentstore.expression.impl.IdentifierExpression; @@ -43,6 +44,7 @@ import org.hypertrace.core.documentstore.expression.impl.LogicalExpression; import org.hypertrace.core.documentstore.expression.impl.RelationalExpression; import org.hypertrace.core.documentstore.expression.impl.UnnestExpression; +import org.hypertrace.core.documentstore.expression.operators.ArrayOperator; import org.hypertrace.core.documentstore.expression.operators.FunctionOperator; import org.hypertrace.core.documentstore.postgres.Params; import org.hypertrace.core.documentstore.postgres.PostgresTableIdentifier; @@ -1945,4 +1947,166 @@ void testNotInOperatorWithScalarStringField() { assertEquals("text", arrayParam.getSqlType()); assertEquals(2, arrayParam.getValues().length); } + + @Test + void testAllOperatorWithJsonbArrayField() { + Query query = + Query.builder() + .setFilter( + ArrayRelationalFilterExpression.builder() + .operator(ArrayOperator.ALL) + .filter( + RelationalExpression.of( + IdentifierExpression.of("tags"), + IN, + ConstantExpression.ofStrings(List.of("premium", "sale")))) + .build()) + .build(); + + PostgresQueryParser postgresQueryParser = + new PostgresQueryParser(TEST_TABLE, PostgresQueryTransformer.transform(query)); + + String sql = postgresQueryParser.parse(); + assertEquals( + "SELECT * FROM \"testCollection\" " + + "WHERE (CASE WHEN jsonb_typeof(document->'tags') = 'array' " + + "THEN document->'tags' ELSE '[]'::jsonb END) @> ?::jsonb", + sql); + + Params params = postgresQueryParser.getParamsBuilder().build(); + assertEquals(1, params.getObjectParams().size()); + assertEquals("[\"premium\",\"sale\"]", params.getObjectParams().get(1)); + } + + @Test + void testOneOperatorWithJsonbArrayField() { + Query query = + Query.builder() + .setFilter( + ArrayRelationalFilterExpression.builder() + .operator(ArrayOperator.ONE) + .filter( + RelationalExpression.of( + IdentifierExpression.of("tags"), + IN, + ConstantExpression.ofStrings(List.of("premium", "sale")))) + .build()) + .build(); + + PostgresQueryParser postgresQueryParser = + new PostgresQueryParser(TEST_TABLE, PostgresQueryTransformer.transform(query)); + + String sql = postgresQueryParser.parse(); + assertEquals( + "SELECT * FROM \"testCollection\" " + + "WHERE jsonb_array_length((CASE WHEN jsonb_typeof(document->'tags') = 'array' " + + "THEN document->'tags' ELSE '[]'::jsonb END)) = 1 " + + "AND ((CASE WHEN jsonb_typeof(document->'tags') = 'array' " + + "THEN document->'tags' ELSE '[]'::jsonb END) @> ?::jsonb " + + "OR (CASE WHEN jsonb_typeof(document->'tags') = 'array' " + + "THEN document->'tags' ELSE '[]'::jsonb END) @> ?::jsonb)", + sql); + + Params params = postgresQueryParser.getParamsBuilder().build(); + assertEquals(2, params.getObjectParams().size()); + assertEquals("[\"premium\"]", params.getObjectParams().get(1)); + assertEquals("[\"sale\"]", params.getObjectParams().get(2)); + } + + @Test + void testAllOperatorWithNativeArrayField() { + Query query = + Query.builder() + .setFilter( + ArrayRelationalFilterExpression.builder() + .operator(ArrayOperator.ALL) + .filter( + RelationalExpression.of( + ArrayIdentifierExpression.ofStrings("tags"), + IN, + ConstantExpression.ofStrings(List.of("premium", "sale")))) + .build()) + .build(); + + PostgresQueryParser postgresQueryParser = + new PostgresQueryParser( + TEST_TABLE, + PostgresQueryTransformer.transform(query), + new FlatPostgresFieldTransformer()); + + String sql = postgresQueryParser.parse(); + assertEquals( + "SELECT * FROM \"testCollection\" WHERE COALESCE(\"tags\", ARRAY[]::text[]) @> ?", sql); + + Params params = postgresQueryParser.getParamsBuilder().build(); + assertEquals(1, params.getObjectParams().size()); + Params.ArrayParam arrayParam = (Params.ArrayParam) params.getObjectParams().get(1); + assertEquals("text", arrayParam.getSqlType()); + assertEquals(2, arrayParam.getValues().length); + } + + @Test + void testOneOperatorWithNativeArrayField() { + Query query = + Query.builder() + .setFilter( + ArrayRelationalFilterExpression.builder() + .operator(ArrayOperator.ONE) + .filter( + RelationalExpression.of( + ArrayIdentifierExpression.ofStrings("tags"), + IN, + ConstantExpression.ofStrings(List.of("premium", "sale")))) + .build()) + .build(); + + PostgresQueryParser postgresQueryParser = + new PostgresQueryParser( + TEST_TABLE, + PostgresQueryTransformer.transform(query), + new FlatPostgresFieldTransformer()); + + String sql = postgresQueryParser.parse(); + assertEquals( + "SELECT * FROM \"testCollection\" " + + "WHERE array_length(COALESCE(\"tags\", ARRAY[]::text[]), 1) = 1 " + + "AND COALESCE(\"tags\", ARRAY[]::text[]) && ?", + sql); + + Params params = postgresQueryParser.getParamsBuilder().build(); + assertEquals(1, params.getObjectParams().size()); + Params.ArrayParam arrayParam = (Params.ArrayParam) params.getObjectParams().get(1); + assertEquals("text", arrayParam.getSqlType()); + assertEquals(2, arrayParam.getValues().length); + } + + @Test + void testAllOperatorWithNestedJsonbArrayField() { + Query query = + Query.builder() + .setFilter( + ArrayRelationalFilterExpression.builder() + .operator(ArrayOperator.ALL) + .filter( + RelationalExpression.of( + IdentifierExpression.of("scope.environmentScope.environmentIds"), + IN, + ConstantExpression.ofStrings(List.of("env-1", "env-2")))) + .build()) + .build(); + + PostgresQueryParser postgresQueryParser = + new PostgresQueryParser(TEST_TABLE, PostgresQueryTransformer.transform(query)); + + String sql = postgresQueryParser.parse(); + assertEquals( + "SELECT * FROM \"testCollection\" " + + "WHERE (CASE WHEN jsonb_typeof(document->'scope'->'environmentScope'->'environmentIds') = 'array' " + + "THEN document->'scope'->'environmentScope'->'environmentIds' ELSE '[]'::jsonb END) @> ?::jsonb", + sql); + + Params params = postgresQueryParser.getParamsBuilder().build(); + assertEquals(1, params.getObjectParams().size()); + assertEquals("[\"env-1\",\"env-2\"]", params.getObjectParams().get(1)); + } } From 2b47a7133932dab9f46213bd3a7f52269e630b21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Clrathod=E2=80=9D?= Date: Tue, 1 Sep 2026 11:16:22 +0530 Subject: [PATCH 2/6] Address review: compile-time field types, nested arrays, duplicate semantics - Resolve native array element type from the compile-time type info on the field expression (ArrayIdentifierExpression/IdentifierExpression DataType) instead of inferring from filter values; value inference is now only a fallback when no type info is present. The runtime jsonb_typeof guard is retained only for schemaless JSONB/nested array paths. - Add unit + integration tests covering ALL/ONE on nested array fields (e.g. props.colors, scope.environmentScope.environmentIds) for both MongoDB and Postgres. - Add integration test documenting that ALL is set-containment: duplicates in the document array ([red, red] ALL [red]) still match in both backends. Co-authored-by: Cursor --- .../ArrayFiltersQueryIntegrationTest.java | 112 +++++++++++++++ .../array_match_all_single_value_result.json | 18 +++ .../array_operators/array_match_test.json | 5 + .../nested_array_match_all_result.json | 14 ++ .../nested_array_match_one_result.json | 8 ++ .../nested_array_match_test.json | 41 ++++++ .../PostgresFilterTypeExpressionVisitor.java | 41 +++++- .../parser/MongoArrayFilterParserTest.java | 58 ++++++++ .../query/v1/PostgresQueryParserTest.java | 132 ++++++++++++++++++ 9 files changed, 427 insertions(+), 2 deletions(-) create mode 100644 document-store/src/integrationTest/resources/query/array_operators/array_match_all_single_value_result.json create mode 100644 document-store/src/integrationTest/resources/query/array_operators/nested_array_match_all_result.json create mode 100644 document-store/src/integrationTest/resources/query/array_operators/nested_array_match_one_result.json create mode 100644 document-store/src/integrationTest/resources/query/array_operators/nested_array_match_test.json diff --git a/document-store/src/integrationTest/java/org/hypertrace/core/documentstore/ArrayFiltersQueryIntegrationTest.java b/document-store/src/integrationTest/java/org/hypertrace/core/documentstore/ArrayFiltersQueryIntegrationTest.java index e6f066a51..54bae2ab8 100644 --- a/document-store/src/integrationTest/java/org/hypertrace/core/documentstore/ArrayFiltersQueryIntegrationTest.java +++ b/document-store/src/integrationTest/java/org/hypertrace/core/documentstore/ArrayFiltersQueryIntegrationTest.java @@ -425,6 +425,118 @@ void getDocumentsWithExactlyOneElementMatchingGivenValues(final String dataStore JSONAssert.assertEquals(expected, actual, JSONCompareMode.LENIENT); } + /** + * Documents that ALL follows set-containment semantics: duplicates in the document's array do not + * affect the outcome. A document with tags ["red", "red"] matches ALL ["red"] in both MongoDB + * ($setIsSubset treats operands as sets) and Postgres (@> is element-wise containment). + */ + @ParameterizedTest + @ArgumentsSource(AllProvider.class) + void getDocumentsContainingAllGivenValuesWithDuplicatesInArray(final String dataStoreName) + throws JSONException, IOException { + final String testCollectionName = "array_match_test"; + final Datastore datastore = datastoreMap.get(dataStoreName); + final Map testDocuments = + Utils.buildDocumentsFromResource("query/array_operators/array_match_test.json"); + datastore.deleteCollection(testCollectionName); + datastore.createCollection(testCollectionName, null); + final Collection collection = datastore.getCollection(testCollectionName); + collection.bulkUpsert(testDocuments); + + final Query query = + Query.builder() + .setFilter( + ArrayRelationalFilterExpression.builder() + .operator(ALL) + .filter( + RelationalExpression.of( + IdentifierExpression.of("tags"), + RelationalOperator.IN, + ConstantExpression.ofStrings(List.of("red")))) + .build()) + .build(); + + final Iterator documents = collection.aggregate(query); + final String expected = readResource("array_match_all_single_value_result.json"); + final String actual = iteratorToJson(documents); + + datastore.deleteCollection(testCollectionName); + + JSONAssert.assertEquals(expected, actual, JSONCompareMode.LENIENT); + } + + /** Tests MATCH_ALL semantics on an array field nested inside a JSONB sub-document. */ + @ParameterizedTest + @ArgumentsSource(AllProvider.class) + void getNestedArrayDocumentsContainingAllGivenValues(final String dataStoreName) + throws JSONException, IOException { + final String testCollectionName = "nested_array_match_test"; + final Datastore datastore = datastoreMap.get(dataStoreName); + final Map testDocuments = + Utils.buildDocumentsFromResource("query/array_operators/nested_array_match_test.json"); + datastore.deleteCollection(testCollectionName); + datastore.createCollection(testCollectionName, null); + final Collection collection = datastore.getCollection(testCollectionName); + collection.bulkUpsert(testDocuments); + + final Query query = + Query.builder() + .setFilter( + ArrayRelationalFilterExpression.builder() + .operator(ALL) + .filter( + RelationalExpression.of( + IdentifierExpression.of("props.colors"), + RelationalOperator.IN, + ConstantExpression.ofStrings(List.of("red", "blue")))) + .build()) + .build(); + + final Iterator documents = collection.aggregate(query); + final String expected = readResource("nested_array_match_all_result.json"); + final String actual = iteratorToJson(documents); + + datastore.deleteCollection(testCollectionName); + + JSONAssert.assertEquals(expected, actual, JSONCompareMode.LENIENT); + } + + /** Tests MATCH_ONE semantics on an array field nested inside a JSONB sub-document. */ + @ParameterizedTest + @ArgumentsSource(AllProvider.class) + void getNestedArrayDocumentsWithExactlyOneElementMatchingGivenValues(final String dataStoreName) + throws JSONException, IOException { + final String testCollectionName = "nested_array_match_test"; + final Datastore datastore = datastoreMap.get(dataStoreName); + final Map testDocuments = + Utils.buildDocumentsFromResource("query/array_operators/nested_array_match_test.json"); + datastore.deleteCollection(testCollectionName); + datastore.createCollection(testCollectionName, null); + final Collection collection = datastore.getCollection(testCollectionName); + collection.bulkUpsert(testDocuments); + + final Query query = + Query.builder() + .setFilter( + ArrayRelationalFilterExpression.builder() + .operator(ONE) + .filter( + RelationalExpression.of( + IdentifierExpression.of("props.colors"), + RelationalOperator.IN, + ConstantExpression.ofStrings(List.of("red", "blue")))) + .build()) + .build(); + + final Iterator documents = collection.aggregate(query); + final String expected = readResource("nested_array_match_one_result.json"); + final String actual = iteratorToJson(documents); + + datastore.deleteCollection(testCollectionName); + + JSONAssert.assertEquals(expected, actual, JSONCompareMode.LENIENT); + } + private String readResource(final String fileName) { try { return new String( diff --git a/document-store/src/integrationTest/resources/query/array_operators/array_match_all_single_value_result.json b/document-store/src/integrationTest/resources/query/array_operators/array_match_all_single_value_result.json new file mode 100644 index 000000000..0aaff1a50 --- /dev/null +++ b/document-store/src/integrationTest/resources/query/array_operators/array_match_all_single_value_result.json @@ -0,0 +1,18 @@ +[ + { + "name": "Document A", + "tags": ["red", "blue"] + }, + { + "name": "Document B", + "tags": ["red", "blue", "green"] + }, + { + "name": "Document C", + "tags": ["red"] + }, + { + "name": "Document G", + "tags": ["red", "red"] + } +] diff --git a/document-store/src/integrationTest/resources/query/array_operators/array_match_test.json b/document-store/src/integrationTest/resources/query/array_operators/array_match_test.json index 29b1a1a07..fbdcbfadb 100644 --- a/document-store/src/integrationTest/resources/query/array_operators/array_match_test.json +++ b/document-store/src/integrationTest/resources/query/array_operators/array_match_test.json @@ -27,5 +27,10 @@ { "_id": 6, "name": "Document F" + }, + { + "_id": 7, + "name": "Document G", + "tags": ["red", "red"] } ] diff --git a/document-store/src/integrationTest/resources/query/array_operators/nested_array_match_all_result.json b/document-store/src/integrationTest/resources/query/array_operators/nested_array_match_all_result.json new file mode 100644 index 000000000..c6df50722 --- /dev/null +++ b/document-store/src/integrationTest/resources/query/array_operators/nested_array_match_all_result.json @@ -0,0 +1,14 @@ +[ + { + "name": "Document A", + "props": { + "colors": ["red", "blue"] + } + }, + { + "name": "Document B", + "props": { + "colors": ["red", "blue", "green"] + } + } +] diff --git a/document-store/src/integrationTest/resources/query/array_operators/nested_array_match_one_result.json b/document-store/src/integrationTest/resources/query/array_operators/nested_array_match_one_result.json new file mode 100644 index 000000000..08d1ad035 --- /dev/null +++ b/document-store/src/integrationTest/resources/query/array_operators/nested_array_match_one_result.json @@ -0,0 +1,8 @@ +[ + { + "name": "Document C", + "props": { + "colors": ["red"] + } + } +] diff --git a/document-store/src/integrationTest/resources/query/array_operators/nested_array_match_test.json b/document-store/src/integrationTest/resources/query/array_operators/nested_array_match_test.json new file mode 100644 index 000000000..048b09c1a --- /dev/null +++ b/document-store/src/integrationTest/resources/query/array_operators/nested_array_match_test.json @@ -0,0 +1,41 @@ +[ + { + "_id": 1, + "name": "Document A", + "props": { + "colors": ["red", "blue"] + } + }, + { + "_id": 2, + "name": "Document B", + "props": { + "colors": ["red", "blue", "green"] + } + }, + { + "_id": 3, + "name": "Document C", + "props": { + "colors": ["red"] + } + }, + { + "_id": 4, + "name": "Document D", + "props": { + "colors": ["yellow"] + } + }, + { + "_id": 5, + "name": "Document E", + "props": { + "colors": [] + } + }, + { + "_id": 6, + "name": "Document F" + } +] diff --git a/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/query/v1/vistors/PostgresFilterTypeExpressionVisitor.java b/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/query/v1/vistors/PostgresFilterTypeExpressionVisitor.java index 8de9c1fe8..f70eb7954 100644 --- a/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/query/v1/vistors/PostgresFilterTypeExpressionVisitor.java +++ b/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/query/v1/vistors/PostgresFilterTypeExpressionVisitor.java @@ -19,9 +19,12 @@ import org.hypertrace.core.documentstore.DocumentType; import org.hypertrace.core.documentstore.Key; import org.hypertrace.core.documentstore.expression.impl.ArrayFilterExpression; +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.DataType; import org.hypertrace.core.documentstore.expression.impl.DocumentArrayFilterExpression; +import org.hypertrace.core.documentstore.expression.impl.IdentifierExpression; import org.hypertrace.core.documentstore.expression.impl.JsonIdentifierExpression; import org.hypertrace.core.documentstore.expression.impl.KeyExpression; import org.hypertrace.core.documentstore.expression.impl.LogicalExpression; @@ -347,17 +350,22 @@ private String getFilterStringForAnyOperator(final DocumentArrayFilterExpression /* Native array (flat collection): COALESCE(tags, ARRAY[]::text[]) @> ? -- bound as a typed array param, e.g. ['Blue','Green'] + The element type comes from the compile-time type info on the field expression + (ArrayIdentifierExpression), falling back to inference from the filter values. JSONB array (nested collection or JSONB column in a flat collection): (CASE WHEN jsonb_typeof(colors) = 'array' THEN colors ELSE '[]'::jsonb END) @> ?::jsonb -- bound as the JSON text '["Blue","Green"]' + JSONB is schemaless, so the runtime jsonb_typeof guard is retained here to tolerate + JSON null / non-array values; top-level native array columns need no such check. */ private String getFilterStringForAllOperator(final ArrayFilterExpression expression) { final List values = getArrayOperatorFilterValues(expression); final ArrayFieldContext fieldContext = getArrayFieldContext(expression); if (fieldContext.isNativeArray()) { - final PostgresDataType dataType = resolvePostgresDataType(values); + final PostgresDataType dataType = + resolvePostgresDataType(expression.getArraySource(), values); postgresQueryParser.getParamsBuilder().addArrayParam(values.toArray(), dataType.getSqlType()); return String.format( "COALESCE(%s, ARRAY[]%s) @> ?", fieldContext.parsedLhs(), dataType.getArrayTypeCast()); @@ -386,7 +394,8 @@ private String getFilterStringForOneOperator(final ArrayFilterExpression express final ArrayFieldContext fieldContext = getArrayFieldContext(expression); if (fieldContext.isNativeArray()) { - final PostgresDataType dataType = resolvePostgresDataType(values); + final PostgresDataType dataType = + resolvePostgresDataType(expression.getArraySource(), values); final String arrayTypeCast = dataType.getArrayTypeCast(); final String coalescedArray = String.format("COALESCE(%s, ARRAY[]%s)", fieldContext.parsedLhs(), arrayTypeCast); @@ -463,6 +472,34 @@ private List getArrayOperatorFilterValues(final ArrayFilterExpression express return value instanceof List ? (List) value : List.of(value); } + /** + * Resolves the PostgreSQL element type for a native array field, preferring the compile-time type + * carried by the field expression ({@link ArrayIdentifierExpression#getElementDataType()} / + * {@link IdentifierExpression#getDataType()}) and falling back to inference from the filter + * values only when the field carries no type info. + */ + private PostgresDataType resolvePostgresDataType( + final SelectTypeExpression arraySource, final List values) { + final PostgresDataType fieldType = getCompileTimeFieldType(arraySource); + return fieldType != PostgresDataType.UNKNOWN ? fieldType : resolvePostgresDataType(values); + } + + private PostgresDataType getCompileTimeFieldType(final SelectTypeExpression arraySource) { + final DataType dataType; + if (arraySource instanceof ArrayIdentifierExpression) { + dataType = ((ArrayIdentifierExpression) arraySource).getElementDataType(); + } else if (arraySource instanceof IdentifierExpression) { + dataType = ((IdentifierExpression) arraySource).getDataType(); + } else { + return PostgresDataType.UNKNOWN; + } + // JSON has no scalar Postgres mapping for native array casts; treat it like UNSPECIFIED + if (dataType == DataType.UNSPECIFIED || dataType == DataType.JSON) { + return PostgresDataType.UNKNOWN; + } + return PostgresDataType.fromDataType(dataType); + } + private PostgresDataType resolvePostgresDataType(final List values) { return values.stream() .map(PostgresDataType::fromJavaValue) diff --git a/document-store/src/test/java/org/hypertrace/core/documentstore/mongo/query/parser/MongoArrayFilterParserTest.java b/document-store/src/test/java/org/hypertrace/core/documentstore/mongo/query/parser/MongoArrayFilterParserTest.java index f7de01644..884a510e0 100644 --- a/document-store/src/test/java/org/hypertrace/core/documentstore/mongo/query/parser/MongoArrayFilterParserTest.java +++ b/document-store/src/test/java/org/hypertrace/core/documentstore/mongo/query/parser/MongoArrayFilterParserTest.java @@ -148,6 +148,64 @@ void testAllOperatorInsideExprLocation() { assertTrue(result.containsKey("$setIsSubset")); } + @Test + void testAllOperatorWithNestedArrayField() { + final ArrayRelationalFilterExpression expression = + ArrayRelationalFilterExpression.builder() + .operator(ArrayOperator.ALL) + .filter( + RelationalExpression.of( + IdentifierExpression.of("scope.environmentScope.environmentIds"), + RelationalOperator.IN, + ConstantExpression.ofStrings(List.of("env-1", "env-2")))) + .build(); + + final Map result = parser.visit(expression); + + final Map expr = getMap(result, "$expr"); + final List setIsSubset = getList(expr, "$setIsSubset"); + assertEquals(List.of("env-1", "env-2"), setIsSubset.get(0)); + + final Map ifNull = castToMap(setIsSubset.get(1)); + final Object[] ifNullArgs = (Object[]) ifNull.get("$ifNull"); + assertEquals("$scope.environmentScope.environmentIds", ifNullArgs[0]); + assertEquals(0, ((Object[]) ifNullArgs[1]).length); + } + + @Test + void testOneOperatorWithNestedArrayField() { + final ArrayRelationalFilterExpression expression = + ArrayRelationalFilterExpression.builder() + .operator(ArrayOperator.ONE) + .filter( + RelationalExpression.of( + IdentifierExpression.of("scope.environmentScope.environmentIds"), + RelationalOperator.IN, + ConstantExpression.ofStrings(List.of("env-1", "env-2")))) + .build(); + + final Map result = parser.visit(expression); + + final Map expr = getMap(result, "$expr"); + final List and = getList(expr, "$and"); + assertEquals(2, and.size()); + + final List eq = getList(castToMap(and.get(0)), "$eq"); + final Map size = castToMap(eq.get(0)); + final Map sizeIfNull = castToMap(size.get("$size")); + final Object[] sizeIfNullArgs = (Object[]) sizeIfNull.get("$ifNull"); + assertEquals("$scope.environmentScope.environmentIds", sizeIfNullArgs[0]); + assertEquals(1, eq.get(1)); + + final List in = getList(castToMap(and.get(1)), "$in"); + final Map arrayElemAt = castToMap(in.get(0)); + final List arrayElemAtArgs = getList(arrayElemAt, "$arrayElemAt"); + final Map elemIfNull = castToMap(arrayElemAtArgs.get(0)); + final Object[] elemIfNullArgs = (Object[]) elemIfNull.get("$ifNull"); + assertEquals("$scope.environmentScope.environmentIds", elemIfNullArgs[0]); + assertEquals(List.of("env-1", "env-2"), in.get(1)); + } + @Test void testOneOperatorRejectsNonConstantRhs() { final ArrayRelationalFilterExpression expression = 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 75d36d922..bb89b4007 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 @@ -2109,4 +2109,136 @@ void testAllOperatorWithNestedJsonbArrayField() { assertEquals(1, params.getObjectParams().size()); assertEquals("[\"env-1\",\"env-2\"]", params.getObjectParams().get(1)); } + + @Test + void testOneOperatorWithNestedJsonbArrayField() { + Query query = + Query.builder() + .setFilter( + ArrayRelationalFilterExpression.builder() + .operator(ArrayOperator.ONE) + .filter( + RelationalExpression.of( + IdentifierExpression.of("scope.environmentScope.environmentIds"), + IN, + ConstantExpression.ofStrings(List.of("env-1", "env-2")))) + .build()) + .build(); + + PostgresQueryParser postgresQueryParser = + new PostgresQueryParser(TEST_TABLE, PostgresQueryTransformer.transform(query)); + + String sql = postgresQueryParser.parse(); + assertEquals( + "SELECT * FROM \"testCollection\" " + + "WHERE jsonb_array_length((CASE WHEN jsonb_typeof(document->'scope'->'environmentScope'->'environmentIds') = 'array' " + + "THEN document->'scope'->'environmentScope'->'environmentIds' ELSE '[]'::jsonb END)) = 1 " + + "AND ((CASE WHEN jsonb_typeof(document->'scope'->'environmentScope'->'environmentIds') = 'array' " + + "THEN document->'scope'->'environmentScope'->'environmentIds' ELSE '[]'::jsonb END) @> ?::jsonb " + + "OR (CASE WHEN jsonb_typeof(document->'scope'->'environmentScope'->'environmentIds') = 'array' " + + "THEN document->'scope'->'environmentScope'->'environmentIds' ELSE '[]'::jsonb END) @> ?::jsonb)", + sql); + + Params params = postgresQueryParser.getParamsBuilder().build(); + assertEquals(2, params.getObjectParams().size()); + assertEquals("[\"env-1\"]", params.getObjectParams().get(1)); + assertEquals("[\"env-2\"]", params.getObjectParams().get(2)); + } + + @Test + void testAllOperatorPrefersCompileTimeFieldTypeOverValueInference() { + // The field is declared as a long array, but the filter values are Integers. The + // compile-time type info on the field expression must win over value-based inference. + Query query = + Query.builder() + .setFilter( + ArrayRelationalFilterExpression.builder() + .operator(ArrayOperator.ALL) + .filter( + RelationalExpression.of( + ArrayIdentifierExpression.ofLongs("ids"), + IN, + ConstantExpression.ofNumbers(List.of(1, 2)))) + .build()) + .build(); + + PostgresQueryParser postgresQueryParser = + new PostgresQueryParser( + TEST_TABLE, + PostgresQueryTransformer.transform(query), + new FlatPostgresFieldTransformer()); + + String sql = postgresQueryParser.parse(); + assertEquals( + "SELECT * FROM \"testCollection\" WHERE COALESCE(\"ids\", ARRAY[]::int8[]) @> ?", sql); + + Params params = postgresQueryParser.getParamsBuilder().build(); + Params.ArrayParam arrayParam = (Params.ArrayParam) params.getObjectParams().get(1); + assertEquals("int8", arrayParam.getSqlType()); + } + + @Test + void testOneOperatorPrefersCompileTimeFieldTypeOverValueInference() { + Query query = + Query.builder() + .setFilter( + ArrayRelationalFilterExpression.builder() + .operator(ArrayOperator.ONE) + .filter( + RelationalExpression.of( + ArrayIdentifierExpression.ofLongs("ids"), + IN, + ConstantExpression.ofNumbers(List.of(1, 2)))) + .build()) + .build(); + + PostgresQueryParser postgresQueryParser = + new PostgresQueryParser( + TEST_TABLE, + PostgresQueryTransformer.transform(query), + new FlatPostgresFieldTransformer()); + + String sql = postgresQueryParser.parse(); + assertEquals( + "SELECT * FROM \"testCollection\" " + + "WHERE array_length(COALESCE(\"ids\", ARRAY[]::int8[]), 1) = 1 " + + "AND COALESCE(\"ids\", ARRAY[]::int8[]) && ?", + sql); + + Params params = postgresQueryParser.getParamsBuilder().build(); + Params.ArrayParam arrayParam = (Params.ArrayParam) params.getObjectParams().get(1); + assertEquals("int8", arrayParam.getSqlType()); + } + + @Test + void testAllOperatorFallsBackToValueInferenceWithoutFieldTypeInfo() { + // A plain IdentifierExpression carries no compile-time type info, so the array element + // type is inferred from the filter values. + Query query = + Query.builder() + .setFilter( + ArrayRelationalFilterExpression.builder() + .operator(ArrayOperator.ALL) + .filter( + RelationalExpression.of( + IdentifierExpression.of("tags"), + IN, + ConstantExpression.ofStrings(List.of("premium", "sale")))) + .build()) + .build(); + + PostgresQueryParser postgresQueryParser = + new PostgresQueryParser( + TEST_TABLE, + PostgresQueryTransformer.transform(query), + new FlatPostgresFieldTransformer()); + + String sql = postgresQueryParser.parse(); + assertEquals( + "SELECT * FROM \"testCollection\" WHERE COALESCE(\"tags\", ARRAY[]::text[]) @> ?", sql); + + Params params = postgresQueryParser.getParamsBuilder().build(); + Params.ArrayParam arrayParam = (Params.ArrayParam) params.getObjectParams().get(1); + assertEquals("text", arrayParam.getSqlType()); + } } From 7a22e1efdd3065ec77565d8ccdefa9f409ebfc15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Clrathod=E2=80=9D?= Date: Tue, 1 Sep 2026 11:26:26 +0530 Subject: [PATCH 3/6] Move ALL/ONE integration tests to DocStoreQueryV1Test Consolidate the ALL/ONE integration tests into DocStoreQueryV1Test as a nested ArrayMatchAllOneOperatorTest class, per review feedback: - Nested JSONB array path (props.colors) covered on both MongoDB and Postgres via the shared document collection - Native array columns (tags TEXT[], flags BOOLEAN[]) covered on the flat collection with both typed (compile-time DataType) and untyped (value-inference fallback) ArrayIdentifierExpression variants - JSONB array column (props.colors) covered on the flat collection - Duplicate-containing arrays ([red, red] ALL [red] -> true) covered via a dedicated collection, documenting set-containment semantics on real DBs Co-authored-by: Cursor --- .../ArrayFiltersQueryIntegrationTest.java | 193 --------------- .../documentstore/DocStoreQueryV1Test.java | 219 ++++++++++++++++++ .../array_match_all_result.json | 10 - .../array_match_all_single_value_result.json | 18 -- .../array_match_one_result.json | 6 - .../nested_array_match_all_result.json | 14 -- .../nested_array_match_one_result.json | 8 - .../nested_array_match_test.json | 41 ---- 8 files changed, 219 insertions(+), 290 deletions(-) delete mode 100644 document-store/src/integrationTest/resources/query/array_operators/array_match_all_result.json delete mode 100644 document-store/src/integrationTest/resources/query/array_operators/array_match_all_single_value_result.json delete mode 100644 document-store/src/integrationTest/resources/query/array_operators/array_match_one_result.json delete mode 100644 document-store/src/integrationTest/resources/query/array_operators/nested_array_match_all_result.json delete mode 100644 document-store/src/integrationTest/resources/query/array_operators/nested_array_match_one_result.json delete mode 100644 document-store/src/integrationTest/resources/query/array_operators/nested_array_match_test.json diff --git a/document-store/src/integrationTest/java/org/hypertrace/core/documentstore/ArrayFiltersQueryIntegrationTest.java b/document-store/src/integrationTest/java/org/hypertrace/core/documentstore/ArrayFiltersQueryIntegrationTest.java index 54bae2ab8..318bafa67 100644 --- a/document-store/src/integrationTest/java/org/hypertrace/core/documentstore/ArrayFiltersQueryIntegrationTest.java +++ b/document-store/src/integrationTest/java/org/hypertrace/core/documentstore/ArrayFiltersQueryIntegrationTest.java @@ -2,9 +2,7 @@ import static org.hypertrace.core.documentstore.expression.impl.LogicalExpression.and; import static org.hypertrace.core.documentstore.expression.impl.LogicalExpression.not; -import static org.hypertrace.core.documentstore.expression.operators.ArrayOperator.ALL; import static org.hypertrace.core.documentstore.expression.operators.ArrayOperator.ANY; -import static org.hypertrace.core.documentstore.expression.operators.ArrayOperator.ONE; import static org.hypertrace.core.documentstore.model.config.DatabaseType.MONGO; import static org.hypertrace.core.documentstore.model.config.DatabaseType.POSTGRES; import static org.hypertrace.core.documentstore.utils.Utils.MONGO_STORE; @@ -13,7 +11,6 @@ import com.google.common.io.Resources; import java.io.IOException; import java.util.Iterator; -import java.util.List; import java.util.Map; import java.util.Spliterator; import java.util.Spliterators; @@ -347,196 +344,6 @@ void getDocumentsWithEnvironmentIdsSubsetOfGivenList(final String dataStoreName) JSONAssert.assertEquals(expected, actual, JSONCompareMode.LENIENT); } - /** - * Tests MATCH_ALL semantics: documents whose array attribute contains every value specified in - * the filter. - */ - @ParameterizedTest - @ArgumentsSource(AllProvider.class) - void getDocumentsContainingAllGivenValues(final String dataStoreName) - throws JSONException, IOException { - final String testCollectionName = "array_match_test"; - final Datastore datastore = datastoreMap.get(dataStoreName); - final Map testDocuments = - Utils.buildDocumentsFromResource("query/array_operators/array_match_test.json"); - datastore.deleteCollection(testCollectionName); - datastore.createCollection(testCollectionName, null); - final Collection collection = datastore.getCollection(testCollectionName); - collection.bulkUpsert(testDocuments); - - final Query query = - Query.builder() - .setFilter( - ArrayRelationalFilterExpression.builder() - .operator(ALL) - .filter( - RelationalExpression.of( - IdentifierExpression.of("tags"), - RelationalOperator.IN, - ConstantExpression.ofStrings(List.of("red", "blue")))) - .build()) - .build(); - - final Iterator documents = collection.aggregate(query); - final String expected = readResource("array_match_all_result.json"); - final String actual = iteratorToJson(documents); - - datastore.deleteCollection(testCollectionName); - - JSONAssert.assertEquals(expected, actual, JSONCompareMode.LENIENT); - } - - /** - * Tests MATCH_ONE semantics: documents whose array attribute has exactly one element, and that - * element is one of the values specified in the filter. - */ - @ParameterizedTest - @ArgumentsSource(AllProvider.class) - void getDocumentsWithExactlyOneElementMatchingGivenValues(final String dataStoreName) - throws JSONException, IOException { - final String testCollectionName = "array_match_test"; - final Datastore datastore = datastoreMap.get(dataStoreName); - final Map testDocuments = - Utils.buildDocumentsFromResource("query/array_operators/array_match_test.json"); - datastore.deleteCollection(testCollectionName); - datastore.createCollection(testCollectionName, null); - final Collection collection = datastore.getCollection(testCollectionName); - collection.bulkUpsert(testDocuments); - - final Query query = - Query.builder() - .setFilter( - ArrayRelationalFilterExpression.builder() - .operator(ONE) - .filter( - RelationalExpression.of( - IdentifierExpression.of("tags"), - RelationalOperator.IN, - ConstantExpression.ofStrings(List.of("red", "blue")))) - .build()) - .build(); - - final Iterator documents = collection.aggregate(query); - final String expected = readResource("array_match_one_result.json"); - final String actual = iteratorToJson(documents); - - datastore.deleteCollection(testCollectionName); - - JSONAssert.assertEquals(expected, actual, JSONCompareMode.LENIENT); - } - - /** - * Documents that ALL follows set-containment semantics: duplicates in the document's array do not - * affect the outcome. A document with tags ["red", "red"] matches ALL ["red"] in both MongoDB - * ($setIsSubset treats operands as sets) and Postgres (@> is element-wise containment). - */ - @ParameterizedTest - @ArgumentsSource(AllProvider.class) - void getDocumentsContainingAllGivenValuesWithDuplicatesInArray(final String dataStoreName) - throws JSONException, IOException { - final String testCollectionName = "array_match_test"; - final Datastore datastore = datastoreMap.get(dataStoreName); - final Map testDocuments = - Utils.buildDocumentsFromResource("query/array_operators/array_match_test.json"); - datastore.deleteCollection(testCollectionName); - datastore.createCollection(testCollectionName, null); - final Collection collection = datastore.getCollection(testCollectionName); - collection.bulkUpsert(testDocuments); - - final Query query = - Query.builder() - .setFilter( - ArrayRelationalFilterExpression.builder() - .operator(ALL) - .filter( - RelationalExpression.of( - IdentifierExpression.of("tags"), - RelationalOperator.IN, - ConstantExpression.ofStrings(List.of("red")))) - .build()) - .build(); - - final Iterator documents = collection.aggregate(query); - final String expected = readResource("array_match_all_single_value_result.json"); - final String actual = iteratorToJson(documents); - - datastore.deleteCollection(testCollectionName); - - JSONAssert.assertEquals(expected, actual, JSONCompareMode.LENIENT); - } - - /** Tests MATCH_ALL semantics on an array field nested inside a JSONB sub-document. */ - @ParameterizedTest - @ArgumentsSource(AllProvider.class) - void getNestedArrayDocumentsContainingAllGivenValues(final String dataStoreName) - throws JSONException, IOException { - final String testCollectionName = "nested_array_match_test"; - final Datastore datastore = datastoreMap.get(dataStoreName); - final Map testDocuments = - Utils.buildDocumentsFromResource("query/array_operators/nested_array_match_test.json"); - datastore.deleteCollection(testCollectionName); - datastore.createCollection(testCollectionName, null); - final Collection collection = datastore.getCollection(testCollectionName); - collection.bulkUpsert(testDocuments); - - final Query query = - Query.builder() - .setFilter( - ArrayRelationalFilterExpression.builder() - .operator(ALL) - .filter( - RelationalExpression.of( - IdentifierExpression.of("props.colors"), - RelationalOperator.IN, - ConstantExpression.ofStrings(List.of("red", "blue")))) - .build()) - .build(); - - final Iterator documents = collection.aggregate(query); - final String expected = readResource("nested_array_match_all_result.json"); - final String actual = iteratorToJson(documents); - - datastore.deleteCollection(testCollectionName); - - JSONAssert.assertEquals(expected, actual, JSONCompareMode.LENIENT); - } - - /** Tests MATCH_ONE semantics on an array field nested inside a JSONB sub-document. */ - @ParameterizedTest - @ArgumentsSource(AllProvider.class) - void getNestedArrayDocumentsWithExactlyOneElementMatchingGivenValues(final String dataStoreName) - throws JSONException, IOException { - final String testCollectionName = "nested_array_match_test"; - final Datastore datastore = datastoreMap.get(dataStoreName); - final Map testDocuments = - Utils.buildDocumentsFromResource("query/array_operators/nested_array_match_test.json"); - datastore.deleteCollection(testCollectionName); - datastore.createCollection(testCollectionName, null); - final Collection collection = datastore.getCollection(testCollectionName); - collection.bulkUpsert(testDocuments); - - final Query query = - Query.builder() - .setFilter( - ArrayRelationalFilterExpression.builder() - .operator(ONE) - .filter( - RelationalExpression.of( - IdentifierExpression.of("props.colors"), - RelationalOperator.IN, - ConstantExpression.ofStrings(List.of("red", "blue")))) - .build()) - .build(); - - final Iterator documents = collection.aggregate(query); - final String expected = readResource("nested_array_match_one_result.json"); - final String actual = iteratorToJson(documents); - - datastore.deleteCollection(testCollectionName); - - JSONAssert.assertEquals(expected, actual, JSONCompareMode.LENIENT); - } - private String readResource(final String fileName) { try { return new String( 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 ea8508bfb..21f7abc6b 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 @@ -7141,6 +7141,225 @@ void testDateConstantExpressionInFilterForPostgresThrowsException(String dataSto "DateConstantExpression should throw UnsupportedOperationException for Postgres"); } + @Nested + class ArrayMatchAllOneOperatorTest { + + /* + * props.colors in the shared document collection: + * id 1: [Blue, Green], id 3: [Black], id 5: [Orange, Blue], id 7: [], rest: absent + */ + @ParameterizedTest + @ArgumentsSource(AllProvider.class) + void testAllOnNestedJsonbArrayField(String dataStoreName) { + Collection collection = getCollection(dataStoreName); + + Query allBlueAndGreen = + Query.builder() + .setFilter( + ArrayRelationalFilterExpression.builder() + .operator(ArrayOperator.ALL) + .filter( + RelationalExpression.of( + IdentifierExpression.of("props.colors"), + IN, + ConstantExpression.ofStrings(List.of("Blue", "Green")))) + .build()) + .build(); + // Only id 1 contains both Blue and Green + assertEquals(1, collection.count(allBlueAndGreen)); + + Query allBlue = + Query.builder() + .setFilter( + ArrayRelationalFilterExpression.builder() + .operator(ArrayOperator.ALL) + .filter( + RelationalExpression.of( + IdentifierExpression.of("props.colors"), + IN, + ConstantExpression.ofStrings(List.of("Blue")))) + .build()) + .build(); + // ids 1 and 5 contain Blue + assertEquals(2, collection.count(allBlue)); + } + + @ParameterizedTest + @ArgumentsSource(AllProvider.class) + void testOneOnNestedJsonbArrayField(String dataStoreName) { + Collection collection = getCollection(dataStoreName); + + Query oneBlackOrWhite = + Query.builder() + .setFilter( + ArrayRelationalFilterExpression.builder() + .operator(ArrayOperator.ONE) + .filter( + RelationalExpression.of( + IdentifierExpression.of("props.colors"), + IN, + ConstantExpression.ofStrings(List.of("Black", "White")))) + .build()) + .build(); + // Only id 3 has exactly one element, and it is Black + assertEquals(1, collection.count(oneBlackOrWhite)); + + Query oneBlue = + Query.builder() + .setFilter( + ArrayRelationalFilterExpression.builder() + .operator(ArrayOperator.ONE) + .filter( + RelationalExpression.of( + IdentifierExpression.of("props.colors"), + IN, + ConstantExpression.ofStrings(List.of("Blue")))) + .build()) + .build(); + // No single-element array contains Blue + assertEquals(0, collection.count(oneBlue)); + } + + /* + * tags (TEXT[]) in the flat collection: + * id 1: {hygiene, personal-care, premium} is the only array containing both hygiene + * and premium + * flags (BOOLEAN[]): id 8: {true} is the only single-element array + */ + @ParameterizedTest + @ArgumentsSource(PostgresArrayTypeProvider.class) + void testAllOnNativeArrayColumn(String dataStoreName, String expressionType) { + Collection flatCollection = getFlatCollection(dataStoreName); + ArrayIdentifierExpression tags = + "WITH_TYPE".equals(expressionType) + ? ArrayIdentifierExpression.ofStrings("tags") + : ArrayIdentifierExpression.of("tags"); + + Query query = + Query.builder() + .setFilter( + ArrayRelationalFilterExpression.builder() + .operator(ArrayOperator.ALL) + .filter( + RelationalExpression.of( + tags, + IN, + ConstantExpression.ofStrings(List.of("hygiene", "premium")))) + .build()) + .build(); + + assertEquals(1, flatCollection.count(query)); + } + + @ParameterizedTest + @ArgumentsSource(PostgresArrayTypeProvider.class) + void testOneOnNativeArrayColumn(String dataStoreName, String expressionType) { + Collection flatCollection = getFlatCollection(dataStoreName); + ArrayIdentifierExpression flags = + "WITH_TYPE".equals(expressionType) + ? ArrayIdentifierExpression.ofBooleans("flags") + : ArrayIdentifierExpression.of("flags"); + + Query query = + Query.builder() + .setFilter( + ArrayRelationalFilterExpression.builder() + .operator(ArrayOperator.ONE) + .filter( + RelationalExpression.of( + flags, IN, ConstantExpression.ofBooleans(List.of(true, false)))) + .build()) + .build(); + + // Only id 8 has a single-element flags array + assertEquals(1, flatCollection.count(query)); + } + + /* + * props.colors JSONB array column in the flat collection: + * id 1: [Blue, Green], id 3: [Black], id 5: [Orange, Blue], id 7: [] + */ + @ParameterizedTest + @ArgumentsSource(PostgresProvider.class) + void testAllOnJsonbArrayColumn(String dataStoreName) { + Collection flatCollection = getFlatCollection(dataStoreName); + + Query query = + Query.builder() + .setFilter( + ArrayRelationalFilterExpression.builder() + .operator(ArrayOperator.ALL) + .filter( + RelationalExpression.of( + JsonIdentifierExpression.of("props", "colors"), + IN, + ConstantExpression.ofStrings(List.of("Blue", "Green")))) + .build()) + .build(); + + // Only id 1 contains both Blue and Green + assertEquals(1, flatCollection.count(query)); + } + + @ParameterizedTest + @ArgumentsSource(PostgresProvider.class) + void testOneOnJsonbArrayColumn(String dataStoreName) { + Collection flatCollection = getFlatCollection(dataStoreName); + + Query query = + Query.builder() + .setFilter( + ArrayRelationalFilterExpression.builder() + .operator(ArrayOperator.ONE) + .filter( + RelationalExpression.of( + JsonIdentifierExpression.of("props", "colors"), + IN, + ConstantExpression.ofStrings(List.of("Black", "White")))) + .build()) + .build(); + + // Only id 3 has exactly one element, and it is Black + assertEquals(1, flatCollection.count(query)); + } + + /** + * Documents the set-containment semantics of ALL: duplicates in the document's array do not + * affect the outcome. ["red", "red"] ALL ["red"] is true both in MongoDB ($setIsSubset treats + * its operands as sets) and in Postgres (@> is element-wise containment). + */ + @ParameterizedTest + @ArgumentsSource(AllProvider.class) + void testAllWithDuplicatesInDocumentArray(String dataStoreName) throws IOException { + String testCollectionName = "array_match_test"; + Datastore datastore = datastoreMap.get(dataStoreName); + Map testDocuments = + Utils.buildDocumentsFromResource("query/array_operators/array_match_test.json"); + datastore.deleteCollection(testCollectionName); + datastore.createCollection(testCollectionName, null); + Collection collection = datastore.getCollection(testCollectionName); + collection.bulkUpsert(testDocuments); + + Query query = + Query.builder() + .setFilter( + ArrayRelationalFilterExpression.builder() + .operator(ArrayOperator.ALL) + .filter( + RelationalExpression.of( + IdentifierExpression.of("tags"), + IN, + ConstantExpression.ofStrings(List.of("red")))) + .build()) + .build(); + + // Documents A [red, blue], B [red, blue, green], C [red] and G [red, red] all match + assertEquals(4, collection.count(query)); + + datastore.deleteCollection(testCollectionName); + } + } + private static Collection getCollection(final String dataStoreName) { return getCollection(dataStoreName, COLLECTION_NAME); } diff --git a/document-store/src/integrationTest/resources/query/array_operators/array_match_all_result.json b/document-store/src/integrationTest/resources/query/array_operators/array_match_all_result.json deleted file mode 100644 index d9fd9221a..000000000 --- a/document-store/src/integrationTest/resources/query/array_operators/array_match_all_result.json +++ /dev/null @@ -1,10 +0,0 @@ -[ - { - "name": "Document A", - "tags": ["red", "blue"] - }, - { - "name": "Document B", - "tags": ["red", "blue", "green"] - } -] diff --git a/document-store/src/integrationTest/resources/query/array_operators/array_match_all_single_value_result.json b/document-store/src/integrationTest/resources/query/array_operators/array_match_all_single_value_result.json deleted file mode 100644 index 0aaff1a50..000000000 --- a/document-store/src/integrationTest/resources/query/array_operators/array_match_all_single_value_result.json +++ /dev/null @@ -1,18 +0,0 @@ -[ - { - "name": "Document A", - "tags": ["red", "blue"] - }, - { - "name": "Document B", - "tags": ["red", "blue", "green"] - }, - { - "name": "Document C", - "tags": ["red"] - }, - { - "name": "Document G", - "tags": ["red", "red"] - } -] diff --git a/document-store/src/integrationTest/resources/query/array_operators/array_match_one_result.json b/document-store/src/integrationTest/resources/query/array_operators/array_match_one_result.json deleted file mode 100644 index 2f5de65f9..000000000 --- a/document-store/src/integrationTest/resources/query/array_operators/array_match_one_result.json +++ /dev/null @@ -1,6 +0,0 @@ -[ - { - "name": "Document C", - "tags": ["red"] - } -] diff --git a/document-store/src/integrationTest/resources/query/array_operators/nested_array_match_all_result.json b/document-store/src/integrationTest/resources/query/array_operators/nested_array_match_all_result.json deleted file mode 100644 index c6df50722..000000000 --- a/document-store/src/integrationTest/resources/query/array_operators/nested_array_match_all_result.json +++ /dev/null @@ -1,14 +0,0 @@ -[ - { - "name": "Document A", - "props": { - "colors": ["red", "blue"] - } - }, - { - "name": "Document B", - "props": { - "colors": ["red", "blue", "green"] - } - } -] diff --git a/document-store/src/integrationTest/resources/query/array_operators/nested_array_match_one_result.json b/document-store/src/integrationTest/resources/query/array_operators/nested_array_match_one_result.json deleted file mode 100644 index 08d1ad035..000000000 --- a/document-store/src/integrationTest/resources/query/array_operators/nested_array_match_one_result.json +++ /dev/null @@ -1,8 +0,0 @@ -[ - { - "name": "Document C", - "props": { - "colors": ["red"] - } - } -] diff --git a/document-store/src/integrationTest/resources/query/array_operators/nested_array_match_test.json b/document-store/src/integrationTest/resources/query/array_operators/nested_array_match_test.json deleted file mode 100644 index 048b09c1a..000000000 --- a/document-store/src/integrationTest/resources/query/array_operators/nested_array_match_test.json +++ /dev/null @@ -1,41 +0,0 @@ -[ - { - "_id": 1, - "name": "Document A", - "props": { - "colors": ["red", "blue"] - } - }, - { - "_id": 2, - "name": "Document B", - "props": { - "colors": ["red", "blue", "green"] - } - }, - { - "_id": 3, - "name": "Document C", - "props": { - "colors": ["red"] - } - }, - { - "_id": 4, - "name": "Document D", - "props": { - "colors": ["yellow"] - } - }, - { - "_id": 5, - "name": "Document E", - "props": { - "colors": [] - } - }, - { - "_id": 6, - "name": "Document F" - } -] From 4fe1c0814f5b43e06ccf74bf3100f331c3df86eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Clrathod=E2=80=9D?= Date: Tue, 1 Sep 2026 11:39:21 +0530 Subject: [PATCH 4/6] Add negative, deeply-nested and order-independence tests for ALL/ONE Negative coverage: - ALL/ONE reject a non-constant RHS with UnsupportedOperationException in both Mongo and Postgres parsers - Empty value lists are rejected at construction by ConstantExpression - Integration: non-array JSONB values do not match and do not error on Postgres, exercising the jsonb_typeof guard Semantics documentation via integration tests on both datastores: - ALL is order-independent: [red, blue] ALL [blue, red] matches - ALL/ONE on a three-level nested array field (props.metadata.colors), including docs with missing intermediate objects Co-authored-by: Cursor --- .../documentstore/DocStoreQueryV1Test.java | 124 ++++++++++++++++++ .../nested_array_match_test.json | 58 ++++++++ .../parser/MongoArrayFilterParserTest.java | 15 +++ .../query/v1/PostgresQueryParserTest.java | 50 +++++++ 4 files changed, 247 insertions(+) create mode 100644 document-store/src/integrationTest/resources/query/array_operators/nested_array_match_test.json 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 21f7abc6b..1a4bb36c4 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 @@ -7323,6 +7323,130 @@ void testOneOnJsonbArrayColumn(String dataStoreName) { assertEquals(1, flatCollection.count(query)); } + /** + * A non-array JSONB value (here props.brand, a string) must simply not match - the jsonb_typeof + * guard turns it into an empty array instead of failing the query. Postgres-only: MongoDB's + * $setIsSubset rejects non-array operands outright. + */ + @ParameterizedTest + @ArgumentsSource(PostgresProvider.class) + void testAllAndOneOnNonArrayJsonbValueDoNotMatch(String dataStoreName) { + Collection collection = getCollection(dataStoreName); + + Query allQuery = + Query.builder() + .setFilter( + ArrayRelationalFilterExpression.builder() + .operator(ArrayOperator.ALL) + .filter( + RelationalExpression.of( + IdentifierExpression.of("props.brand"), + IN, + ConstantExpression.ofStrings(List.of("Dettol")))) + .build()) + .build(); + assertEquals(0, collection.count(allQuery)); + + Query oneQuery = + Query.builder() + .setFilter( + ArrayRelationalFilterExpression.builder() + .operator(ArrayOperator.ONE) + .filter( + RelationalExpression.of( + IdentifierExpression.of("props.brand"), + IN, + ConstantExpression.ofStrings(List.of("Dettol")))) + .build()) + .build(); + assertEquals(0, collection.count(oneQuery)); + } + + /** + * ALL/ONE on an array field nested three levels deep (props.metadata.colors), including + * documents with missing intermediate objects, which must simply not match. + */ + @ParameterizedTest + @ArgumentsSource(AllProvider.class) + void testAllAndOneOnDeeplyNestedArrayField(String dataStoreName) throws IOException { + String testCollectionName = "nested_array_match_test"; + Datastore datastore = datastoreMap.get(dataStoreName); + Map testDocuments = + Utils.buildDocumentsFromResource("query/array_operators/nested_array_match_test.json"); + datastore.deleteCollection(testCollectionName); + datastore.createCollection(testCollectionName, null); + Collection collection = datastore.getCollection(testCollectionName); + collection.bulkUpsert(testDocuments); + + Query allQuery = + Query.builder() + .setFilter( + ArrayRelationalFilterExpression.builder() + .operator(ArrayOperator.ALL) + .filter( + RelationalExpression.of( + IdentifierExpression.of("props.metadata.colors"), + IN, + ConstantExpression.ofStrings(List.of("red", "blue")))) + .build()) + .build(); + // Documents A [red, blue] and B [red, blue, green]; C-F miss at least one value or the + // field itself, G has no props at all + assertEquals(2, collection.count(allQuery)); + + Query oneQuery = + Query.builder() + .setFilter( + ArrayRelationalFilterExpression.builder() + .operator(ArrayOperator.ONE) + .filter( + RelationalExpression.of( + IdentifierExpression.of("props.metadata.colors"), + IN, + ConstantExpression.ofStrings(List.of("red", "blue")))) + .build()) + .build(); + // Only document C has exactly one element, and it is red + assertEquals(1, collection.count(oneQuery)); + + datastore.deleteCollection(testCollectionName); + } + + /** + * Documents that ALL is order-independent: ["red", "blue"] ALL ["blue", "red"] is true in both + * backends ($setIsSubset and @> are both set containment). + */ + @ParameterizedTest + @ArgumentsSource(AllProvider.class) + void testAllIsOrderIndependent(String dataStoreName) throws IOException { + String testCollectionName = "array_match_test"; + Datastore datastore = datastoreMap.get(dataStoreName); + Map testDocuments = + Utils.buildDocumentsFromResource("query/array_operators/array_match_test.json"); + datastore.deleteCollection(testCollectionName); + datastore.createCollection(testCollectionName, null); + Collection collection = datastore.getCollection(testCollectionName); + collection.bulkUpsert(testDocuments); + + Query query = + Query.builder() + .setFilter( + ArrayRelationalFilterExpression.builder() + .operator(ArrayOperator.ALL) + .filter( + RelationalExpression.of( + IdentifierExpression.of("tags"), + IN, + ConstantExpression.ofStrings(List.of("blue", "red")))) + .build()) + .build(); + + // Documents A [red, blue] and B [red, blue, green] match despite the reversed filter order + assertEquals(2, collection.count(query)); + + datastore.deleteCollection(testCollectionName); + } + /** * Documents the set-containment semantics of ALL: duplicates in the document's array do not * affect the outcome. ["red", "red"] ALL ["red"] is true both in MongoDB ($setIsSubset treats diff --git a/document-store/src/integrationTest/resources/query/array_operators/nested_array_match_test.json b/document-store/src/integrationTest/resources/query/array_operators/nested_array_match_test.json new file mode 100644 index 000000000..f99ed2628 --- /dev/null +++ b/document-store/src/integrationTest/resources/query/array_operators/nested_array_match_test.json @@ -0,0 +1,58 @@ +[ + { + "_id": 1, + "name": "Document A", + "props": { + "metadata": { + "colors": ["red", "blue"] + } + } + }, + { + "_id": 2, + "name": "Document B", + "props": { + "metadata": { + "colors": ["red", "blue", "green"] + } + } + }, + { + "_id": 3, + "name": "Document C", + "props": { + "metadata": { + "colors": ["red"] + } + } + }, + { + "_id": 4, + "name": "Document D", + "props": { + "metadata": { + "colors": ["yellow"] + } + } + }, + { + "_id": 5, + "name": "Document E", + "props": { + "metadata": { + "colors": [] + } + } + }, + { + "_id": 6, + "name": "Document F", + "props": { + "metadata": {} + } + }, + { + "_id": 7, + "name": "Document G" + } +] diff --git a/document-store/src/test/java/org/hypertrace/core/documentstore/mongo/query/parser/MongoArrayFilterParserTest.java b/document-store/src/test/java/org/hypertrace/core/documentstore/mongo/query/parser/MongoArrayFilterParserTest.java index 884a510e0..08dd6f286 100644 --- a/document-store/src/test/java/org/hypertrace/core/documentstore/mongo/query/parser/MongoArrayFilterParserTest.java +++ b/document-store/src/test/java/org/hypertrace/core/documentstore/mongo/query/parser/MongoArrayFilterParserTest.java @@ -221,6 +221,21 @@ void testOneOperatorRejectsNonConstantRhs() { assertThrows(UnsupportedOperationException.class, () -> parser.visit(expression)); } + @Test + void testAllOperatorRejectsNonConstantRhs() { + final ArrayRelationalFilterExpression expression = + ArrayRelationalFilterExpression.builder() + .operator(ArrayOperator.ALL) + .filter( + RelationalExpression.of( + IdentifierExpression.of("tags"), + RelationalOperator.IN, + IdentifierExpression.of("otherField"))) + .build(); + + assertThrows(UnsupportedOperationException.class, () -> parser.visit(expression)); + } + @SuppressWarnings("unchecked") private Map castToMap(final Object object) { return (Map) object; 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 bb89b4007..34c37d44c 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 @@ -27,6 +27,7 @@ import static org.hypertrace.core.documentstore.expression.operators.SortOrder.ASC; import static org.hypertrace.core.documentstore.expression.operators.SortOrder.DESC; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; import java.io.IOException; import java.util.List; @@ -2241,4 +2242,53 @@ void testAllOperatorFallsBackToValueInferenceWithoutFieldTypeInfo() { Params.ArrayParam arrayParam = (Params.ArrayParam) params.getObjectParams().get(1); assertEquals("text", arrayParam.getSqlType()); } + + @Test + void testAllOperatorRejectsNonConstantRhs() { + Query query = + Query.builder() + .setFilter( + ArrayRelationalFilterExpression.builder() + .operator(ArrayOperator.ALL) + .filter( + RelationalExpression.of( + IdentifierExpression.of("tags"), + IN, + IdentifierExpression.of("otherField"))) + .build()) + .build(); + + PostgresQueryParser postgresQueryParser = + new PostgresQueryParser(TEST_TABLE, PostgresQueryTransformer.transform(query)); + + assertThrows(UnsupportedOperationException.class, postgresQueryParser::parse); + } + + @Test + void testOneOperatorRejectsNonConstantRhs() { + Query query = + Query.builder() + .setFilter( + ArrayRelationalFilterExpression.builder() + .operator(ArrayOperator.ONE) + .filter( + RelationalExpression.of( + IdentifierExpression.of("tags"), + IN, + IdentifierExpression.of("otherField"))) + .build()) + .build(); + + PostgresQueryParser postgresQueryParser = + new PostgresQueryParser(TEST_TABLE, PostgresQueryTransformer.transform(query)); + + assertThrows(UnsupportedOperationException.class, postgresQueryParser::parse); + } + + @Test + void testArrayOperatorsRejectEmptyValueList() { + // Empty value lists are rejected at construction time by ConstantExpression itself, so they + // can never reach the ALL/ONE parsers + assertThrows(IllegalArgumentException.class, () -> ConstantExpression.ofStrings(List.of())); + } } From c1b13477b6e67936c89bf23d9c7506d2be075f6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Clrathod=E2=80=9D?= Date: Tue, 1 Sep 2026 12:07:01 +0530 Subject: [PATCH 5/6] Simplify ALL/ONE SQL, guard Mongo against non-array values, document ONE semantics - Native Postgres arrays: drop COALESCE - NULL arrays are excluded by WHERE semantics anyway, and the unwrapped column reference keeps the filter GIN-indexable (SARGable) - JSONB ONE: replace the per-value OR chain with a single <@ containment against the full filter list (with exactly one element, membership and containment are equivalent) - one bound param instead of N - Mongo: guard ALL/ONE with $cond/$isArray so documents holding a non-array scalar no longer error out ($setIsSubset/$size reject non-array operands), matching the Postgres jsonb_typeof behavior; subsumes $ifNull - Document that ONE counts raw elements, not distinct values ([red, red] ONE [red] is false), with an integration test on both stores; non-array scalar test now runs on Mongo too Co-authored-by: Cursor --- .../documentstore/DocStoreQueryV1Test.java | 44 +++++++++++++++-- .../expression/operators/ArrayOperator.java | 12 +++-- .../query/parser/MongoArrayFilterParser.java | 24 ++++++---- .../PostgresFilterTypeExpressionVisitor.java | 47 ++++++++----------- .../parser/MongoArrayFilterParserTest.java | 44 ++++++++--------- .../query/v1/PostgresQueryParserTest.java | 38 +++++---------- 6 files changed, 115 insertions(+), 94 deletions(-) diff --git a/document-store/src/integrationTest/java/org/hypertrace/core/documentstore/DocStoreQueryV1Test.java b/document-store/src/integrationTest/java/org/hypertrace/core/documentstore/DocStoreQueryV1Test.java index 1a4bb36c4..f9fb5099d 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 @@ -7324,13 +7324,12 @@ void testOneOnJsonbArrayColumn(String dataStoreName) { } /** - * A non-array JSONB value (here props.brand, a string) must simply not match - the jsonb_typeof - * guard turns it into an empty array instead of failing the query. Postgres-only: MongoDB's - * $setIsSubset rejects non-array operands outright. + * A non-array value (here props.brand, a string) must simply not match instead of failing the + * query - Postgres uses the jsonb_typeof guard, MongoDB an $isArray guard. */ @ParameterizedTest - @ArgumentsSource(PostgresProvider.class) - void testAllAndOneOnNonArrayJsonbValueDoNotMatch(String dataStoreName) { + @ArgumentsSource(AllProvider.class) + void testAllAndOneOnNonArrayValueDoNotMatch(String dataStoreName) { Collection collection = getCollection(dataStoreName); Query allQuery = @@ -7482,6 +7481,41 @@ void testAllWithDuplicatesInDocumentArray(String dataStoreName) throws IOExcepti datastore.deleteCollection(testCollectionName); } + + /** + * Documents that ONE counts raw elements, not distinct values: a document with tags ["red", + * "red"] does NOT match ONE ["red"] because the array has two elements. + */ + @ParameterizedTest + @ArgumentsSource(AllProvider.class) + void testOneWithDuplicatesInDocumentArray(String dataStoreName) throws IOException { + String testCollectionName = "array_match_test"; + Datastore datastore = datastoreMap.get(dataStoreName); + Map testDocuments = + Utils.buildDocumentsFromResource("query/array_operators/array_match_test.json"); + datastore.deleteCollection(testCollectionName); + datastore.createCollection(testCollectionName, null); + Collection collection = datastore.getCollection(testCollectionName); + collection.bulkUpsert(testDocuments); + + Query query = + Query.builder() + .setFilter( + ArrayRelationalFilterExpression.builder() + .operator(ArrayOperator.ONE) + .filter( + RelationalExpression.of( + IdentifierExpression.of("tags"), + IN, + ConstantExpression.ofStrings(List.of("red")))) + .build()) + .build(); + + // Only document C [red] matches; G [red, red] has two elements and is excluded + assertEquals(1, collection.count(query)); + + datastore.deleteCollection(testCollectionName); + } } private static Collection getCollection(final String dataStoreName) { diff --git a/document-store/src/main/java/org/hypertrace/core/documentstore/expression/operators/ArrayOperator.java b/document-store/src/main/java/org/hypertrace/core/documentstore/expression/operators/ArrayOperator.java index 319741858..83e6d78fd 100644 --- a/document-store/src/main/java/org/hypertrace/core/documentstore/expression/operators/ArrayOperator.java +++ b/document-store/src/main/java/org/hypertrace/core/documentstore/expression/operators/ArrayOperator.java @@ -2,9 +2,15 @@ public enum ArrayOperator { ANY, - // Array attribute must contain every value specified in the filter + /** + * Array attribute must contain every value specified in the filter. Set-containment semantics: + * order and duplicates are irrelevant on both sides, e.g. [red, red] ALL [red] is true. + */ ALL, - // Array attribute must contain exactly one element, and that element must be one of the values - // specified in the filter + /** + * Array attribute must contain exactly one element, and that element must be one of the values + * specified in the filter. The cardinality check is on the raw element count, not distinct + * values, e.g. [red, red] ONE [red] is false because the array has two elements. + */ ONE, } diff --git a/document-store/src/main/java/org/hypertrace/core/documentstore/mongo/query/parser/MongoArrayFilterParser.java b/document-store/src/main/java/org/hypertrace/core/documentstore/mongo/query/parser/MongoArrayFilterParser.java index 5269ac4e7..51be260a8 100644 --- a/document-store/src/main/java/org/hypertrace/core/documentstore/mongo/query/parser/MongoArrayFilterParser.java +++ b/document-store/src/main/java/org/hypertrace/core/documentstore/mongo/query/parser/MongoArrayFilterParser.java @@ -23,6 +23,8 @@ class MongoArrayFilterParser { private static final String MAP = "$map"; private static final String INPUT = "input"; private static final String IF_NULL = "$ifNull"; + private static final String COND = "$cond"; + private static final String IS_ARRAY = "$isArray"; private static final String AS = "as"; private static final String IN = "in"; private static final String SET_IS_SUBSET = "$setIsSubset"; @@ -132,7 +134,7 @@ private Map parseAnyOperator(final ArrayFilterExpression arrayFi "$expr": { "$setIsSubset": [ ["Blue", "Green"], - { "$ifNull": ["$colors", []] } + { "$cond": [{ "$isArray": "$colors" }, "$colors", []] } ] } } @@ -142,9 +144,7 @@ private Map parseAllOperator(final ArrayFilterExpression arrayFi final List values = getFilterValues(arrayFilterExpression); final Map setIsSubset = - Map.of( - SET_IS_SUBSET, - List.of(values, Map.of(IF_NULL, new Object[] {mapInput, new Object[0]}))); + Map.of(SET_IS_SUBSET, List.of(values, arrayOrEmpty(mapInput))); return wrapInExprIfNeeded(setIsSubset); } @@ -152,8 +152,8 @@ private Map parseAllOperator(final ArrayFilterExpression arrayFi { "$expr": { "$and": [ - { "$eq": [{ "$size": { "$ifNull": ["$colors", []] } }, 1] }, - { "$in": [{ "$arrayElemAt": [{ "$ifNull": ["$colors", []] }, 0] }, ["Blue", "Green"]] } + { "$eq": [{ "$size": { "$cond": [{ "$isArray": "$colors" }, "$colors", []] } }, 1] }, + { "$in": [{ "$arrayElemAt": [{ "$cond": [{ "$isArray": "$colors" }, "$colors", []] }, 0] }, ["Blue", "Green"]] } ] } } @@ -161,8 +161,7 @@ private Map parseAllOperator(final ArrayFilterExpression arrayFi private Map parseOneOperator(final ArrayFilterExpression arrayFilterExpression) { final Object mapInput = getDollarPrefixedArraySource(arrayFilterExpression); final List values = getFilterValues(arrayFilterExpression); - final Map arrayWithDefault = - Map.of(IF_NULL, new Object[] {mapInput, new Object[0]}); + final Map arrayWithDefault = arrayOrEmpty(mapInput); final Map sizeIsOne = Map.of(EQ, List.of(Map.of(SIZE, arrayWithDefault), 1)); final Map firstElementMatches = @@ -171,6 +170,15 @@ private Map parseOneOperator(final ArrayFilterExpression arrayFi return wrapInExprIfNeeded(Map.of(AND, List.of(sizeIsOne, firstElementMatches))); } + /* + * Guards against missing, null and non-array (e.g. scalar) field values: $setIsSubset/$size + * error out on a non-array operand, whereas Postgres simply does not match such documents. + * $isArray is false for null/missing values, so this also subsumes $ifNull. + */ + private Map arrayOrEmpty(final Object mapInput) { + return Map.of(COND, List.of(Map.of(IS_ARRAY, mapInput), mapInput, List.of())); + } + private String getDollarPrefixedArraySource(final ArrayFilterExpression arrayFilterExpression) { final MongoSelectTypeExpressionParser wrappingParser = new MongoDollarPrefixingIdempotentParser(relationalFilterContext.lhsParser()); diff --git a/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/query/v1/vistors/PostgresFilterTypeExpressionVisitor.java b/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/query/v1/vistors/PostgresFilterTypeExpressionVisitor.java index f70eb7954..ec8228ec3 100644 --- a/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/query/v1/vistors/PostgresFilterTypeExpressionVisitor.java +++ b/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/query/v1/vistors/PostgresFilterTypeExpressionVisitor.java @@ -349,7 +349,9 @@ private String getFilterStringForAnyOperator(final DocumentArrayFilterExpression /* Native array (flat collection): - COALESCE(tags, ARRAY[]::text[]) @> ? -- bound as a typed array param, e.g. ['Blue','Green'] + tags @> ? -- bound as a typed array param, e.g. ['Blue','Green'] + No COALESCE: a NULL array makes the predicate evaluate to NULL, which excludes the row + anyway, and the unwrapped column reference keeps the filter GIN-indexable (SARGable). The element type comes from the compile-time type info on the field expression (ArrayIdentifierExpression), falling back to inference from the filter values. @@ -367,27 +369,27 @@ private String getFilterStringForAllOperator(final ArrayFilterExpression express final PostgresDataType dataType = resolvePostgresDataType(expression.getArraySource(), values); postgresQueryParser.getParamsBuilder().addArrayParam(values.toArray(), dataType.getSqlType()); - return String.format( - "COALESCE(%s, ARRAY[]%s) @> ?", fieldContext.parsedLhs(), dataType.getArrayTypeCast()); + return String.format("%s @> ?", fieldContext.parsedLhs()); } - final String coalescedArray = + final String guardedArray = String.format( "(CASE WHEN jsonb_typeof(%s) = 'array' THEN %s ELSE '[]'::jsonb END)", fieldContext.parsedLhs(), fieldContext.parsedLhs()); postgresQueryParser.getParamsBuilder().addObjectParam(toJsonArrayString(values)); - return String.format("%s @> ?::jsonb", coalescedArray); + return String.format("%s @> ?::jsonb", guardedArray); } /* Native array (flat collection): - array_length(COALESCE(tags, ARRAY[]::text[]), 1) = 1 - AND COALESCE(tags, ARRAY[]::text[]) && ? -- bound as a typed array param + array_length(tags, 1) = 1 AND tags && ? -- bound as a typed array param + NULL-safe without COALESCE: both predicates evaluate to NULL for NULL arrays. JSONB array (nested collection or JSONB column in a flat collection): - jsonb_array_length() = 1 - AND ( @> ?::jsonb OR @> ?::jsonb ...) - -- one bound single-element JSON array per filter value + jsonb_array_length() = 1 AND <@ ?::jsonb + -- bound as the JSON text '["Blue","Green"]' + With exactly one element, "the element is one of the filter values" is equivalent to the + array being contained in the filter values (<@) - one bound param, no OR chain. */ private String getFilterStringForOneOperator(final ArrayFilterExpression expression) { final List values = getArrayOperatorFilterValues(expression); @@ -396,30 +398,19 @@ private String getFilterStringForOneOperator(final ArrayFilterExpression express if (fieldContext.isNativeArray()) { final PostgresDataType dataType = resolvePostgresDataType(expression.getArraySource(), values); - final String arrayTypeCast = dataType.getArrayTypeCast(); - final String coalescedArray = - String.format("COALESCE(%s, ARRAY[]%s)", fieldContext.parsedLhs(), arrayTypeCast); postgresQueryParser.getParamsBuilder().addArrayParam(values.toArray(), dataType.getSqlType()); - return String.format("array_length(%s, 1) = 1 AND %s && ?", coalescedArray, coalescedArray); + return String.format( + "array_length(%s, 1) = 1 AND %s && ?", + fieldContext.parsedLhs(), fieldContext.parsedLhs()); } - final String coalescedArray = + final String guardedArray = String.format( "(CASE WHEN jsonb_typeof(%s) = 'array' THEN %s ELSE '[]'::jsonb END)", fieldContext.parsedLhs(), fieldContext.parsedLhs()); - final String matchesAnyValue = - values.isEmpty() - ? "FALSE" - : values.stream() - .map( - value -> { - postgresQueryParser - .getParamsBuilder() - .addObjectParam(toJsonArrayString(List.of(value))); - return String.format("%s @> ?::jsonb", coalescedArray); - }) - .collect(Collectors.joining(" OR ")); - return String.format("jsonb_array_length(%s) = 1 AND (%s)", coalescedArray, matchesAnyValue); + postgresQueryParser.getParamsBuilder().addObjectParam(toJsonArrayString(values)); + return String.format( + "jsonb_array_length(%s) = 1 AND %s <@ ?::jsonb", guardedArray, guardedArray); } private ArrayFieldContext getArrayFieldContext(final ArrayFilterExpression expression) { diff --git a/document-store/src/test/java/org/hypertrace/core/documentstore/mongo/query/parser/MongoArrayFilterParserTest.java b/document-store/src/test/java/org/hypertrace/core/documentstore/mongo/query/parser/MongoArrayFilterParserTest.java index 08dd6f286..0a81b4cd1 100644 --- a/document-store/src/test/java/org/hypertrace/core/documentstore/mongo/query/parser/MongoArrayFilterParserTest.java +++ b/document-store/src/test/java/org/hypertrace/core/documentstore/mongo/query/parser/MongoArrayFilterParserTest.java @@ -32,16 +32,13 @@ void testAllOperator() { final Map result = parser.visit(expression); - // {"$expr": {"$setIsSubset": [["Blue", "Green"], {"$ifNull": ["$tags", []]}]}} + // {"$expr": {"$setIsSubset": [["Blue", "Green"], {"$cond": [{"$isArray": "$tags"}, "$tags", + // []]}]}} final Map expr = getMap(result, "$expr"); final List setIsSubset = getList(expr, "$setIsSubset"); assertEquals(2, setIsSubset.size()); assertEquals(List.of("Blue", "Green"), setIsSubset.get(0)); - - final Map ifNull = castToMap(setIsSubset.get(1)); - final Object[] ifNullArgs = (Object[]) ifNull.get("$ifNull"); - assertEquals("$tags", ifNullArgs[0]); - assertEquals(0, ((Object[]) ifNullArgs[1]).length); + assertArrayGuard(setIsSubset.get(1), "$tags"); } @Test @@ -60,8 +57,8 @@ void testOneOperator() { /* {"$expr": {"$and": [ - {"$eq": [{"$size": {"$ifNull": ["$tags", []]}}, 1]}, - {"$in": [{"$arrayElemAt": [{"$ifNull": ["$tags", []]}, 0]}, ["Blue", "Green"]]} + {"$eq": [{"$size": {"$cond": [{"$isArray": "$tags"}, "$tags", []]}}, 1]}, + {"$in": [{"$arrayElemAt": [{"$cond": [{"$isArray": "$tags"}, "$tags", []]}, 0]}, ["Blue", "Green"]]} ]}} */ final Map expr = getMap(result, "$expr"); @@ -70,17 +67,13 @@ void testOneOperator() { final List eq = getList(castToMap(and.get(0)), "$eq"); final Map size = castToMap(eq.get(0)); - final Map sizeIfNull = castToMap(size.get("$size")); - final Object[] sizeIfNullArgs = (Object[]) sizeIfNull.get("$ifNull"); - assertEquals("$tags", sizeIfNullArgs[0]); + assertArrayGuard(size.get("$size"), "$tags"); assertEquals(1, eq.get(1)); final List in = getList(castToMap(and.get(1)), "$in"); final Map arrayElemAt = castToMap(in.get(0)); final List arrayElemAtArgs = getList(arrayElemAt, "$arrayElemAt"); - final Map elemIfNull = castToMap(arrayElemAtArgs.get(0)); - final Object[] elemIfNullArgs = (Object[]) elemIfNull.get("$ifNull"); - assertEquals("$tags", elemIfNullArgs[0]); + assertArrayGuard(arrayElemAtArgs.get(0), "$tags"); assertEquals(0, arrayElemAtArgs.get(1)); assertEquals(List.of("Blue", "Green"), in.get(1)); } @@ -165,11 +158,7 @@ void testAllOperatorWithNestedArrayField() { final Map expr = getMap(result, "$expr"); final List setIsSubset = getList(expr, "$setIsSubset"); assertEquals(List.of("env-1", "env-2"), setIsSubset.get(0)); - - final Map ifNull = castToMap(setIsSubset.get(1)); - final Object[] ifNullArgs = (Object[]) ifNull.get("$ifNull"); - assertEquals("$scope.environmentScope.environmentIds", ifNullArgs[0]); - assertEquals(0, ((Object[]) ifNullArgs[1]).length); + assertArrayGuard(setIsSubset.get(1), "$scope.environmentScope.environmentIds"); } @Test @@ -192,17 +181,13 @@ void testOneOperatorWithNestedArrayField() { final List eq = getList(castToMap(and.get(0)), "$eq"); final Map size = castToMap(eq.get(0)); - final Map sizeIfNull = castToMap(size.get("$size")); - final Object[] sizeIfNullArgs = (Object[]) sizeIfNull.get("$ifNull"); - assertEquals("$scope.environmentScope.environmentIds", sizeIfNullArgs[0]); + assertArrayGuard(size.get("$size"), "$scope.environmentScope.environmentIds"); assertEquals(1, eq.get(1)); final List in = getList(castToMap(and.get(1)), "$in"); final Map arrayElemAt = castToMap(in.get(0)); final List arrayElemAtArgs = getList(arrayElemAt, "$arrayElemAt"); - final Map elemIfNull = castToMap(arrayElemAtArgs.get(0)); - final Object[] elemIfNullArgs = (Object[]) elemIfNull.get("$ifNull"); - assertEquals("$scope.environmentScope.environmentIds", elemIfNullArgs[0]); + assertArrayGuard(arrayElemAtArgs.get(0), "$scope.environmentScope.environmentIds"); assertEquals(List.of("env-1", "env-2"), in.get(1)); } @@ -236,6 +221,15 @@ void testAllOperatorRejectsNonConstantRhs() { assertThrows(UnsupportedOperationException.class, () -> parser.visit(expression)); } + /** Asserts the {"$cond": [{"$isArray": path}, path, []]} guard for the given field path. */ + private void assertArrayGuard(final Object guard, final String expectedPath) { + final List condArgs = getList(castToMap(guard), "$cond"); + assertEquals(3, condArgs.size()); + assertEquals(Map.of("$isArray", expectedPath), condArgs.get(0)); + assertEquals(expectedPath, condArgs.get(1)); + assertEquals(List.of(), condArgs.get(2)); + } + @SuppressWarnings("unchecked") private Map castToMap(final Object object) { return (Map) object; 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 34c37d44c..8d1e320e2 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 @@ -2002,16 +2002,13 @@ void testOneOperatorWithJsonbArrayField() { "SELECT * FROM \"testCollection\" " + "WHERE jsonb_array_length((CASE WHEN jsonb_typeof(document->'tags') = 'array' " + "THEN document->'tags' ELSE '[]'::jsonb END)) = 1 " - + "AND ((CASE WHEN jsonb_typeof(document->'tags') = 'array' " - + "THEN document->'tags' ELSE '[]'::jsonb END) @> ?::jsonb " - + "OR (CASE WHEN jsonb_typeof(document->'tags') = 'array' " - + "THEN document->'tags' ELSE '[]'::jsonb END) @> ?::jsonb)", + + "AND (CASE WHEN jsonb_typeof(document->'tags') = 'array' " + + "THEN document->'tags' ELSE '[]'::jsonb END) <@ ?::jsonb", sql); Params params = postgresQueryParser.getParamsBuilder().build(); - assertEquals(2, params.getObjectParams().size()); - assertEquals("[\"premium\"]", params.getObjectParams().get(1)); - assertEquals("[\"sale\"]", params.getObjectParams().get(2)); + assertEquals(1, params.getObjectParams().size()); + assertEquals("[\"premium\",\"sale\"]", params.getObjectParams().get(1)); } @Test @@ -2036,8 +2033,7 @@ void testAllOperatorWithNativeArrayField() { new FlatPostgresFieldTransformer()); String sql = postgresQueryParser.parse(); - assertEquals( - "SELECT * FROM \"testCollection\" WHERE COALESCE(\"tags\", ARRAY[]::text[]) @> ?", sql); + assertEquals("SELECT * FROM \"testCollection\" WHERE \"tags\" @> ?", sql); Params params = postgresQueryParser.getParamsBuilder().build(); assertEquals(1, params.getObjectParams().size()); @@ -2070,8 +2066,7 @@ void testOneOperatorWithNativeArrayField() { String sql = postgresQueryParser.parse(); assertEquals( "SELECT * FROM \"testCollection\" " - + "WHERE array_length(COALESCE(\"tags\", ARRAY[]::text[]), 1) = 1 " - + "AND COALESCE(\"tags\", ARRAY[]::text[]) && ?", + + "WHERE array_length(\"tags\", 1) = 1 AND \"tags\" && ?", sql); Params params = postgresQueryParser.getParamsBuilder().build(); @@ -2134,16 +2129,13 @@ void testOneOperatorWithNestedJsonbArrayField() { "SELECT * FROM \"testCollection\" " + "WHERE jsonb_array_length((CASE WHEN jsonb_typeof(document->'scope'->'environmentScope'->'environmentIds') = 'array' " + "THEN document->'scope'->'environmentScope'->'environmentIds' ELSE '[]'::jsonb END)) = 1 " - + "AND ((CASE WHEN jsonb_typeof(document->'scope'->'environmentScope'->'environmentIds') = 'array' " - + "THEN document->'scope'->'environmentScope'->'environmentIds' ELSE '[]'::jsonb END) @> ?::jsonb " - + "OR (CASE WHEN jsonb_typeof(document->'scope'->'environmentScope'->'environmentIds') = 'array' " - + "THEN document->'scope'->'environmentScope'->'environmentIds' ELSE '[]'::jsonb END) @> ?::jsonb)", + + "AND (CASE WHEN jsonb_typeof(document->'scope'->'environmentScope'->'environmentIds') = 'array' " + + "THEN document->'scope'->'environmentScope'->'environmentIds' ELSE '[]'::jsonb END) <@ ?::jsonb", sql); Params params = postgresQueryParser.getParamsBuilder().build(); - assertEquals(2, params.getObjectParams().size()); - assertEquals("[\"env-1\"]", params.getObjectParams().get(1)); - assertEquals("[\"env-2\"]", params.getObjectParams().get(2)); + assertEquals(1, params.getObjectParams().size()); + assertEquals("[\"env-1\",\"env-2\"]", params.getObjectParams().get(1)); } @Test @@ -2170,8 +2162,7 @@ void testAllOperatorPrefersCompileTimeFieldTypeOverValueInference() { new FlatPostgresFieldTransformer()); String sql = postgresQueryParser.parse(); - assertEquals( - "SELECT * FROM \"testCollection\" WHERE COALESCE(\"ids\", ARRAY[]::int8[]) @> ?", sql); + assertEquals("SELECT * FROM \"testCollection\" WHERE \"ids\" @> ?", sql); Params params = postgresQueryParser.getParamsBuilder().build(); Params.ArrayParam arrayParam = (Params.ArrayParam) params.getObjectParams().get(1); @@ -2201,9 +2192,7 @@ void testOneOperatorPrefersCompileTimeFieldTypeOverValueInference() { String sql = postgresQueryParser.parse(); assertEquals( - "SELECT * FROM \"testCollection\" " - + "WHERE array_length(COALESCE(\"ids\", ARRAY[]::int8[]), 1) = 1 " - + "AND COALESCE(\"ids\", ARRAY[]::int8[]) && ?", + "SELECT * FROM \"testCollection\" " + "WHERE array_length(\"ids\", 1) = 1 AND \"ids\" && ?", sql); Params params = postgresQueryParser.getParamsBuilder().build(); @@ -2235,8 +2224,7 @@ void testAllOperatorFallsBackToValueInferenceWithoutFieldTypeInfo() { new FlatPostgresFieldTransformer()); String sql = postgresQueryParser.parse(); - assertEquals( - "SELECT * FROM \"testCollection\" WHERE COALESCE(\"tags\", ARRAY[]::text[]) @> ?", sql); + assertEquals("SELECT * FROM \"testCollection\" WHERE \"tags\" @> ?", sql); Params params = postgresQueryParser.getParamsBuilder().build(); Params.ArrayParam arrayParam = (Params.ArrayParam) params.getObjectParams().get(1); From ce97a5d3194c1c8d12b288c8ce8de3507d01486f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Clrathod=E2=80=9D?= Date: Tue, 1 Sep 2026 12:22:30 +0530 Subject: [PATCH 6/6] Rename ArrayOperator.ONE to EXACTLY_ONE Aligns with the service-level MATCH_EXACTLY_ONE name and reads unambiguously ("exactly one element, in the given set"). Also notes a future EXACTLY (set-equality) operator in the enum javadoc. Co-authored-by: Cursor --- .../documentstore/DocStoreQueryV1Test.java | 20 +++++++++---------- .../expression/operators/ArrayOperator.java | 6 ++++-- .../query/parser/MongoArrayFilterParser.java | 2 +- .../PostgresFilterTypeExpressionVisitor.java | 4 ++-- .../parser/MongoArrayFilterParserTest.java | 6 +++--- .../query/v1/PostgresQueryParserTest.java | 12 +++++------ 6 files changed, 26 insertions(+), 24 deletions(-) diff --git a/document-store/src/integrationTest/java/org/hypertrace/core/documentstore/DocStoreQueryV1Test.java b/document-store/src/integrationTest/java/org/hypertrace/core/documentstore/DocStoreQueryV1Test.java index f9fb5099d..9a0f078db 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 @@ -7193,7 +7193,7 @@ void testOneOnNestedJsonbArrayField(String dataStoreName) { Query.builder() .setFilter( ArrayRelationalFilterExpression.builder() - .operator(ArrayOperator.ONE) + .operator(ArrayOperator.EXACTLY_ONE) .filter( RelationalExpression.of( IdentifierExpression.of("props.colors"), @@ -7208,7 +7208,7 @@ void testOneOnNestedJsonbArrayField(String dataStoreName) { Query.builder() .setFilter( ArrayRelationalFilterExpression.builder() - .operator(ArrayOperator.ONE) + .operator(ArrayOperator.EXACTLY_ONE) .filter( RelationalExpression.of( IdentifierExpression.of("props.colors"), @@ -7264,7 +7264,7 @@ void testOneOnNativeArrayColumn(String dataStoreName, String expressionType) { Query.builder() .setFilter( ArrayRelationalFilterExpression.builder() - .operator(ArrayOperator.ONE) + .operator(ArrayOperator.EXACTLY_ONE) .filter( RelationalExpression.of( flags, IN, ConstantExpression.ofBooleans(List.of(true, false)))) @@ -7310,7 +7310,7 @@ void testOneOnJsonbArrayColumn(String dataStoreName) { Query.builder() .setFilter( ArrayRelationalFilterExpression.builder() - .operator(ArrayOperator.ONE) + .operator(ArrayOperator.EXACTLY_ONE) .filter( RelationalExpression.of( JsonIdentifierExpression.of("props", "colors"), @@ -7350,7 +7350,7 @@ void testAllAndOneOnNonArrayValueDoNotMatch(String dataStoreName) { Query.builder() .setFilter( ArrayRelationalFilterExpression.builder() - .operator(ArrayOperator.ONE) + .operator(ArrayOperator.EXACTLY_ONE) .filter( RelationalExpression.of( IdentifierExpression.of("props.brand"), @@ -7362,7 +7362,7 @@ void testAllAndOneOnNonArrayValueDoNotMatch(String dataStoreName) { } /** - * ALL/ONE on an array field nested three levels deep (props.metadata.colors), including + * ALL/EXACTLY_ONE on an array field nested three levels deep (props.metadata.colors), including * documents with missing intermediate objects, which must simply not match. */ @ParameterizedTest @@ -7397,7 +7397,7 @@ void testAllAndOneOnDeeplyNestedArrayField(String dataStoreName) throws IOExcept Query.builder() .setFilter( ArrayRelationalFilterExpression.builder() - .operator(ArrayOperator.ONE) + .operator(ArrayOperator.EXACTLY_ONE) .filter( RelationalExpression.of( IdentifierExpression.of("props.metadata.colors"), @@ -7483,8 +7483,8 @@ void testAllWithDuplicatesInDocumentArray(String dataStoreName) throws IOExcepti } /** - * Documents that ONE counts raw elements, not distinct values: a document with tags ["red", - * "red"] does NOT match ONE ["red"] because the array has two elements. + * Documents that EXACTLY_ONE counts raw elements, not distinct values: a document with tags + * ["red", "red"] does NOT match EXACTLY_ONE ["red"] because the array has two elements. */ @ParameterizedTest @ArgumentsSource(AllProvider.class) @@ -7502,7 +7502,7 @@ void testOneWithDuplicatesInDocumentArray(String dataStoreName) throws IOExcepti Query.builder() .setFilter( ArrayRelationalFilterExpression.builder() - .operator(ArrayOperator.ONE) + .operator(ArrayOperator.EXACTLY_ONE) .filter( RelationalExpression.of( IdentifierExpression.of("tags"), diff --git a/document-store/src/main/java/org/hypertrace/core/documentstore/expression/operators/ArrayOperator.java b/document-store/src/main/java/org/hypertrace/core/documentstore/expression/operators/ArrayOperator.java index 83e6d78fd..331729705 100644 --- a/document-store/src/main/java/org/hypertrace/core/documentstore/expression/operators/ArrayOperator.java +++ b/document-store/src/main/java/org/hypertrace/core/documentstore/expression/operators/ArrayOperator.java @@ -10,7 +10,9 @@ public enum ArrayOperator { /** * Array attribute must contain exactly one element, and that element must be one of the values * specified in the filter. The cardinality check is on the raw element count, not distinct - * values, e.g. [red, red] ONE [red] is false because the array has two elements. + * values, e.g. [red, red] EXACTLY_ONE [red] is false because the array has two elements. */ - ONE, + EXACTLY_ONE, + // Future consideration: an EXACTLY operator for set equality - the array contains exactly the + // filter values, no more and no less. } diff --git a/document-store/src/main/java/org/hypertrace/core/documentstore/mongo/query/parser/MongoArrayFilterParser.java b/document-store/src/main/java/org/hypertrace/core/documentstore/mongo/query/parser/MongoArrayFilterParser.java index 51be260a8..776d98ab4 100644 --- a/document-store/src/main/java/org/hypertrace/core/documentstore/mongo/query/parser/MongoArrayFilterParser.java +++ b/document-store/src/main/java/org/hypertrace/core/documentstore/mongo/query/parser/MongoArrayFilterParser.java @@ -54,7 +54,7 @@ Map parse(final ArrayFilterExpression arrayFilterExpression) { switch (arrayFilterExpression.getOperator()) { case ALL: return parseAllOperator(arrayFilterExpression); - case ONE: + case EXACTLY_ONE: return parseOneOperator(arrayFilterExpression); default: return parseAnyOperator(arrayFilterExpression); diff --git a/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/query/v1/vistors/PostgresFilterTypeExpressionVisitor.java b/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/query/v1/vistors/PostgresFilterTypeExpressionVisitor.java index ec8228ec3..aa4c9c92f 100644 --- a/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/query/v1/vistors/PostgresFilterTypeExpressionVisitor.java +++ b/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/query/v1/vistors/PostgresFilterTypeExpressionVisitor.java @@ -127,7 +127,7 @@ WHERE TRIM('"' FROM elements::text) = 'Oxygen' return getFilterStringForAnyOperator(expression); case ALL: return getFilterStringForAllOperator(expression); - case ONE: + case EXACTLY_ONE: return getFilterStringForOneOperator(expression); default: throw new UnsupportedOperationException( @@ -150,7 +150,7 @@ FROM jsonb_array_elements(COALESCE(document->'planets', '[]'::jsonb)) AS planet return getFilterStringForAnyOperator(expression); case ALL: return getFilterStringForAllOperator(expression); - case ONE: + case EXACTLY_ONE: return getFilterStringForOneOperator(expression); default: throw new UnsupportedOperationException( diff --git a/document-store/src/test/java/org/hypertrace/core/documentstore/mongo/query/parser/MongoArrayFilterParserTest.java b/document-store/src/test/java/org/hypertrace/core/documentstore/mongo/query/parser/MongoArrayFilterParserTest.java index 0a81b4cd1..c2b06d88a 100644 --- a/document-store/src/test/java/org/hypertrace/core/documentstore/mongo/query/parser/MongoArrayFilterParserTest.java +++ b/document-store/src/test/java/org/hypertrace/core/documentstore/mongo/query/parser/MongoArrayFilterParserTest.java @@ -45,7 +45,7 @@ void testAllOperator() { void testOneOperator() { final ArrayRelationalFilterExpression expression = ArrayRelationalFilterExpression.builder() - .operator(ArrayOperator.ONE) + .operator(ArrayOperator.EXACTLY_ONE) .filter( RelationalExpression.of( IdentifierExpression.of("tags"), @@ -165,7 +165,7 @@ void testAllOperatorWithNestedArrayField() { void testOneOperatorWithNestedArrayField() { final ArrayRelationalFilterExpression expression = ArrayRelationalFilterExpression.builder() - .operator(ArrayOperator.ONE) + .operator(ArrayOperator.EXACTLY_ONE) .filter( RelationalExpression.of( IdentifierExpression.of("scope.environmentScope.environmentIds"), @@ -195,7 +195,7 @@ void testOneOperatorWithNestedArrayField() { void testOneOperatorRejectsNonConstantRhs() { final ArrayRelationalFilterExpression expression = ArrayRelationalFilterExpression.builder() - .operator(ArrayOperator.ONE) + .operator(ArrayOperator.EXACTLY_ONE) .filter( RelationalExpression.of( IdentifierExpression.of("tags"), 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 8d1e320e2..facac3cf3 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 @@ -1985,7 +1985,7 @@ void testOneOperatorWithJsonbArrayField() { Query.builder() .setFilter( ArrayRelationalFilterExpression.builder() - .operator(ArrayOperator.ONE) + .operator(ArrayOperator.EXACTLY_ONE) .filter( RelationalExpression.of( IdentifierExpression.of("tags"), @@ -2048,7 +2048,7 @@ void testOneOperatorWithNativeArrayField() { Query.builder() .setFilter( ArrayRelationalFilterExpression.builder() - .operator(ArrayOperator.ONE) + .operator(ArrayOperator.EXACTLY_ONE) .filter( RelationalExpression.of( ArrayIdentifierExpression.ofStrings("tags"), @@ -2112,7 +2112,7 @@ void testOneOperatorWithNestedJsonbArrayField() { Query.builder() .setFilter( ArrayRelationalFilterExpression.builder() - .operator(ArrayOperator.ONE) + .operator(ArrayOperator.EXACTLY_ONE) .filter( RelationalExpression.of( IdentifierExpression.of("scope.environmentScope.environmentIds"), @@ -2175,7 +2175,7 @@ void testOneOperatorPrefersCompileTimeFieldTypeOverValueInference() { Query.builder() .setFilter( ArrayRelationalFilterExpression.builder() - .operator(ArrayOperator.ONE) + .operator(ArrayOperator.EXACTLY_ONE) .filter( RelationalExpression.of( ArrayIdentifierExpression.ofLongs("ids"), @@ -2258,7 +2258,7 @@ void testOneOperatorRejectsNonConstantRhs() { Query.builder() .setFilter( ArrayRelationalFilterExpression.builder() - .operator(ArrayOperator.ONE) + .operator(ArrayOperator.EXACTLY_ONE) .filter( RelationalExpression.of( IdentifierExpression.of("tags"), @@ -2276,7 +2276,7 @@ void testOneOperatorRejectsNonConstantRhs() { @Test void testArrayOperatorsRejectEmptyValueList() { // Empty value lists are rejected at construction time by ConstantExpression itself, so they - // can never reach the ALL/ONE parsers + // can never reach the ALL/EXACTLY_ONE parsers assertThrows(IllegalArgumentException.class, () -> ConstantExpression.ofStrings(List.of())); } }