diff --git a/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/clause/filter/ValueComparisonOperator.java b/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/clause/filter/ValueComparisonOperator.java index 9455ab2f8e..48602d86d7 100644 --- a/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/clause/filter/ValueComparisonOperator.java +++ b/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/clause/filter/ValueComparisonOperator.java @@ -6,12 +6,13 @@ */ public enum ValueComparisonOperator implements FilterOperator { EQ("$eq"), + NE("$ne"), IN("$in"), + NIN("$nin"), GT("$gt"), GTE("$gte"), LT("$lt"), LTE("$lte"); - /*NE("$ne");*/ private String operator; diff --git a/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/deserializers/FilterClauseDeserializer.java b/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/deserializers/FilterClauseDeserializer.java index 2b76e255ad..98037c3dc2 100644 --- a/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/deserializers/FilterClauseDeserializer.java +++ b/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/deserializers/FilterClauseDeserializer.java @@ -161,7 +161,7 @@ private void validate( switch (valueComparisonOperator) { case IN -> { if (filterOperation.operand().value() instanceof List list) { - if (list.size() > operationsConfig.defaultPageSize()) { + if (list.size() > operationsConfig.maxInOperatorValueSize()) { throw new JsonApiException( ErrorCode.INVALID_FILTER_EXPRESSION, "$in operator must have at most " @@ -173,17 +173,27 @@ private void validate( ErrorCode.INVALID_FILTER_EXPRESSION, "$in operator must have `ARRAY`"); } } + case NIN -> { + if (filterOperation.operand().value() instanceof List list) { + if (list.size() > operationsConfig.maxInOperatorValueSize()) { + throw new JsonApiException( + ErrorCode.INVALID_FILTER_EXPRESSION, + "$nin operator must have at most " + + operationsConfig.maxInOperatorValueSize() + + " values"); + } + } else { + throw new JsonApiException( + ErrorCode.INVALID_FILTER_EXPRESSION, "$nin operator must have `ARRAY`"); + } + } } } if (filterOperation.operator() instanceof ElementComparisonOperator elementComparisonOperator) { switch (elementComparisonOperator) { case EXISTS: - if (filterOperation.operand().value() instanceof Boolean b) { - if (!b) - throw new JsonApiException( - ErrorCode.INVALID_FILTER_EXPRESSION, "$exists operator supports only true"); - } else { + if (!(filterOperation.operand().value() instanceof Boolean)) { throw new JsonApiException( ErrorCode.INVALID_FILTER_EXPRESSION, "$exists operator must have `BOOLEAN`"); } diff --git a/src/main/java/io/stargate/sgv2/jsonapi/api/v1/GeneralResource.java b/src/main/java/io/stargate/sgv2/jsonapi/api/v1/GeneralResource.java index 6355eb9a8a..b44b5f8bee 100644 --- a/src/main/java/io/stargate/sgv2/jsonapi/api/v1/GeneralResource.java +++ b/src/main/java/io/stargate/sgv2/jsonapi/api/v1/GeneralResource.java @@ -74,7 +74,6 @@ public GeneralResource(MeteredCommandProcessor meteredCommandProcessor) { }))) @POST public Uni> postCommand(@NotNull @Valid GeneralCommand command) { - // call processor return meteredCommandProcessor .processCommand(CommandContext.empty(), command) diff --git a/src/main/java/io/stargate/sgv2/jsonapi/config/OperationsConfig.java b/src/main/java/io/stargate/sgv2/jsonapi/config/OperationsConfig.java index 6a84ba5076..866b9fb0f9 100644 --- a/src/main/java/io/stargate/sgv2/jsonapi/config/OperationsConfig.java +++ b/src/main/java/io/stargate/sgv2/jsonapi/config/OperationsConfig.java @@ -85,10 +85,7 @@ public interface OperationsConfig { @WithDefault("20") int maxDocumentInsertCount(); - /** - * @return Maximum size of _id values array that can be sent in $in operator 100 - * command. - */ + /** @return Maximum size of values array that can be sent in $in/$nin operator */ @Max(100) @Positive @WithDefault("100") diff --git a/src/main/java/io/stargate/sgv2/jsonapi/config/constants/DocumentConstants.java b/src/main/java/io/stargate/sgv2/jsonapi/config/constants/DocumentConstants.java index 3d0c198136..3d908260c6 100644 --- a/src/main/java/io/stargate/sgv2/jsonapi/config/constants/DocumentConstants.java +++ b/src/main/java/io/stargate/sgv2/jsonapi/config/constants/DocumentConstants.java @@ -15,6 +15,9 @@ interface Fields { */ String DATA_CONTAINS = "array_contains"; + /** Text map support _id $ne and _id $nin on both atomic value and array element */ + String QUERY_TEXT_MAP_COLUMN_NAME = "query_text_values"; + /** Physical table column name that stores the vector field. */ String VECTOR_SEARCH_INDEX_COLUMN_NAME = "query_vector_value"; diff --git a/src/main/java/io/stargate/sgv2/jsonapi/service/operation/model/CountOperation.java b/src/main/java/io/stargate/sgv2/jsonapi/service/operation/model/CountOperation.java index 20bfd4a5f5..10114b35b1 100644 --- a/src/main/java/io/stargate/sgv2/jsonapi/service/operation/model/CountOperation.java +++ b/src/main/java/io/stargate/sgv2/jsonapi/service/operation/model/CountOperation.java @@ -32,7 +32,7 @@ public Uni> execute(QueryExecutor queryExecutor) { } private SimpleStatement buildSelectQuery() { - List> expressions = + final List> expressions = ExpressionBuilder.buildExpressions(logicalExpression, null); List collect = new ArrayList<>(); if (expressions != null && !expressions.isEmpty() && expressions.get(0) != null) { diff --git a/src/main/java/io/stargate/sgv2/jsonapi/service/operation/model/ReadOperation.java b/src/main/java/io/stargate/sgv2/jsonapi/service/operation/model/ReadOperation.java index f31f942294..8ce9521e96 100644 --- a/src/main/java/io/stargate/sgv2/jsonapi/service/operation/model/ReadOperation.java +++ b/src/main/java/io/stargate/sgv2/jsonapi/service/operation/model/ReadOperation.java @@ -80,12 +80,13 @@ default Uni findDocument( .items(queries.stream()) .onItem() .transformToUniAndMerge( - query -> { + simpleStatement -> { if (vectorSearch) { return queryExecutor.executeVectorSearch( - query, Optional.ofNullable(pageState), pageSize); + simpleStatement, Optional.ofNullable(pageState), pageSize); } else { - return queryExecutor.executeRead(query, Optional.ofNullable(pageState), pageSize); + return queryExecutor.executeRead( + simpleStatement, Optional.ofNullable(pageState), pageSize); } }) .onItem() diff --git a/src/main/java/io/stargate/sgv2/jsonapi/service/operation/model/impl/DBFilterBase.java b/src/main/java/io/stargate/sgv2/jsonapi/service/operation/model/impl/DBFilterBase.java index 57375df844..f24ffbc92e 100644 --- a/src/main/java/io/stargate/sgv2/jsonapi/service/operation/model/impl/DBFilterBase.java +++ b/src/main/java/io/stargate/sgv2/jsonapi/service/operation/model/impl/DBFilterBase.java @@ -1,6 +1,6 @@ package io.stargate.sgv2.jsonapi.service.operation.model.impl; -import static io.stargate.sgv2.jsonapi.config.constants.DocumentConstants.Fields.DATA_CONTAINS; +import static io.stargate.sgv2.jsonapi.config.constants.DocumentConstants.Fields.*; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ArrayNode; @@ -10,7 +10,6 @@ import io.stargate.bridge.proto.QueryOuterClass; import io.stargate.sgv2.api.common.cql.builder.BuiltCondition; import io.stargate.sgv2.api.common.cql.builder.Predicate; -import io.stargate.sgv2.jsonapi.config.constants.DocumentConstants; import io.stargate.sgv2.jsonapi.exception.ErrorCode; import io.stargate.sgv2.jsonapi.exception.JsonApiException; import io.stargate.sgv2.jsonapi.service.cqldriver.serializer.CQLBindValues; @@ -20,10 +19,7 @@ import io.stargate.sgv2.jsonapi.util.JsonUtil; import java.math.BigDecimal; import java.time.Instant; -import java.util.Date; -import java.util.List; -import java.util.Map; -import java.util.Objects; +import java.util.*; import java.util.function.Supplier; import java.util.stream.Collectors; @@ -71,10 +67,20 @@ public enum Operator { */ MAP_EQUALS, /** - * This represents eq operation for array element or automic value operation against + * This represents ne to be run against map type index columns like array_size, sub_doc_equals + * and array_equals. + */ + MAP_NOT_EQUALS, + /** + * This represents eq operation for array element or atomic value operation against * array_contains */ EQ, + /** + * This represents NE operation for array element or atomic value operation against + * array_contains + */ + NE, /** * This represents greater than to be run against map type index columns for number and date * type @@ -134,11 +140,21 @@ public BuiltCondition get() { DATA_CONTAINS, Predicate.CONTAINS, new JsonTerm(getHashValue(new DocValueHasher(), key, value))); + case NE: + return BuiltCondition.of( + DATA_CONTAINS, + Predicate.NOT_CONTAINS, + new JsonTerm(getHashValue(new DocValueHasher(), key, value))); case MAP_EQUALS: return BuiltCondition.of( BuiltCondition.LHS.mapAccess(columnName, Values.NULL), Predicate.EQ, new JsonTerm(key, value)); + case MAP_NOT_EQUALS: + return BuiltCondition.of( + BuiltCondition.LHS.mapAccess(columnName, Values.NULL), + Predicate.NEQ, + new JsonTerm(key, value)); case GT: return BuiltCondition.of( BuiltCondition.LHS.mapAccess(columnName, Values.NULL), @@ -251,7 +267,8 @@ boolean canAddField() { public static class IDFilter extends DBFilterBase { public enum Operator { EQ, - IN; + NE, + IN } protected final IDFilter.Operator operator; @@ -262,7 +279,7 @@ public IDFilter(IDFilter.Operator operator, DocumentId value) { } public IDFilter(IDFilter.Operator operator, List values) { - super(DocumentConstants.Fields.DOC_ID); + super(DOC_ID); this.operator = operator; this.values = values; } @@ -294,7 +311,25 @@ public List getAll() { BuiltCondition.LHS.column("key"), Predicate.EQ, new JsonTerm(CQLBindValues.getDocumentIdValue(values.get(0))))); - + case NE: + final DocumentId documentId = (DocumentId) values.get(0); + if (documentId.value() instanceof BigDecimal numberId) { + return List.of( + BuiltCondition.of( + BuiltCondition.LHS.mapAccess("query_dbl_values", Values.NULL), + Predicate.NEQ, + new JsonTerm(DOC_ID, numberId))); + } else if (documentId.value() instanceof String strId) { + return List.of( + BuiltCondition.of( + BuiltCondition.LHS.mapAccess("query_text_values", Values.NULL), + Predicate.NEQ, + new JsonTerm(DOC_ID, strId))); + } else { + throw new JsonApiException( + ErrorCode.UNSUPPORTED_FILTER_DATA_TYPE, + String.format("Unsupported $ne operand value : %s", documentId.value())); + } case IN: if (values.isEmpty()) return List.of(); return values.stream() @@ -305,7 +340,6 @@ public List getAll() { Predicate.EQ, new JsonTerm(CQLBindValues.getDocumentIdValue(v)))) .collect(Collectors.toList()); - default: throw new JsonApiException( ErrorCode.UNSUPPORTED_FILTER_OPERATION, @@ -328,9 +362,7 @@ boolean canAddField() { } } - /** - * based on values of fields other than document id: for filtering on non-id field use InFilter. - */ + /** non_id($in, $nin), _id($nin) */ public static class InFilter extends DBFilterBase { private final List arrayValue; protected final InFilter.Operator operator; @@ -344,9 +376,10 @@ JsonNode asJson(JsonNodeFactory nodeFactory) { boolean canAddField() { return false; } - // IN operator for non-id field filtering + public enum Operator { - IN; + IN, + NIN, } public InFilter(InFilter.Operator operator, String path, List arrayValue) { @@ -386,7 +419,46 @@ public List getAll() { Predicate.CONTAINS, new JsonTerm(getHashValue(new DocValueHasher(), getPath(), v)))) .collect(Collectors.toList()); - + case NIN: + if (values.isEmpty()) return List.of(); + if (!this.getPath().equals(DOC_ID)) { + return values.stream() + .map( + v -> + BuiltCondition.of( + DATA_CONTAINS, + Predicate.NOT_CONTAINS, + new JsonTerm(getHashValue(new DocValueHasher(), getPath(), v)))) + .collect(Collectors.toList()); + } else { + // can not use stream here, since lambda parameter casting is not allowed + List conditions = new ArrayList<>(); + for (Object value : values) { + if (value instanceof DocumentId) { + Object docIdValue = ((DocumentId) value).value(); + if (docIdValue instanceof BigDecimal numberId) { + BuiltCondition condition = + BuiltCondition.of( + BuiltCondition.LHS.mapAccess("query_dbl_values", Values.NULL), + Predicate.NEQ, + new JsonTerm(DOC_ID, numberId)); + conditions.add(condition); + } else if (docIdValue instanceof String strId) { + BuiltCondition condition = + BuiltCondition.of( + BuiltCondition.LHS.mapAccess("query_text_values", Values.NULL), + Predicate.NEQ, + new JsonTerm(DOC_ID, strId)); + conditions.add(condition); + } else { + throw new JsonApiException( + ErrorCode.UNSUPPORTED_FILTER_DATA_TYPE, + String.format("Unsupported $nin operand value: %s", docIdValue)); + } + } + } + return conditions; + } default: throw new JsonApiException( ErrorCode.UNSUPPORTED_FILTER_OPERATION, @@ -398,7 +470,8 @@ public List getAll() { /** DB filter / condition for testing a set value */ public abstract static class SetFilterBase extends DBFilterBase { public enum Operator { - CONTAINS; + CONTAINS, + NOT_CONTAINS; } protected final String columnName; @@ -433,6 +506,8 @@ public BuiltCondition get() { switch (operator) { case CONTAINS: return BuiltCondition.of(columnName, Predicate.CONTAINS, new JsonTerm(value)); + case NOT_CONTAINS: + return BuiltCondition.of(columnName, Predicate.NOT_CONTAINS, new JsonTerm(value)); default: throw new JsonApiException( ErrorCode.UNSUPPORTED_FILTER_OPERATION, @@ -447,8 +522,8 @@ public BuiltCondition get() { *

NOTE: cannot do != null until we get NOT CONTAINS in the DB for set */ public static class IsNullFilter extends SetFilterBase { - public IsNullFilter(String path) { - super("query_null_values", path, path, Operator.CONTAINS); + public IsNullFilter(String path, SetFilterBase.Operator operator) { + super("query_null_values", path, path, operator); } @Override @@ -463,13 +538,13 @@ boolean canAddField() { } /** - * Filter for document where a field exists + * Filter for document where a field exists or not * - *

NOTE: cannot do != null until we get NOT CONTAINS in the DB for set + *

NOTE: cannot do != null until we get NOT CONTAINS in the DB for set ?????TODO */ public static class ExistsFilter extends SetFilterBase { public ExistsFilter(String path, boolean existFlag) { - super("exist_keys", path, path, Operator.CONTAINS); + super("exist_keys", path, path, existFlag ? Operator.CONTAINS : Operator.NOT_CONTAINS); } @Override @@ -519,13 +594,16 @@ boolean canAddField() { return false; } } - /** Filter for document where array matches (data in same order) as the array in request */ public static class ArrayEqualsFilter extends MapFilterBase { private final List arrayValue; - public ArrayEqualsFilter(DocValueHasher hasher, String path, List arrayData) { - super("query_text_values", path, Operator.MAP_EQUALS, getHash(hasher, arrayData)); + public ArrayEqualsFilter( + DocValueHasher hasher, + String path, + List arrayData, + MapFilterBase.Operator operator) { + super("query_text_values", path, operator, getHash(hasher, arrayData)); this.arrayValue = arrayData; } @@ -547,8 +625,12 @@ boolean canAddField() { public static class SubDocEqualsFilter extends MapFilterBase { private final Map subDocValue; - public SubDocEqualsFilter(DocValueHasher hasher, String path, Map subDocData) { - super("query_text_values", path, Operator.MAP_EQUALS, getHash(hasher, subDocData)); + public SubDocEqualsFilter( + DocValueHasher hasher, + String path, + Map subDocData, + MapFilterBase.Operator operator) { + super("query_text_values", path, operator, getHash(hasher, subDocData)); this.subDocValue = subDocData; } diff --git a/src/main/java/io/stargate/sgv2/jsonapi/service/operation/model/impl/ExpressionBuilder.java b/src/main/java/io/stargate/sgv2/jsonapi/service/operation/model/impl/ExpressionBuilder.java index 28199b0c93..19956163a0 100644 --- a/src/main/java/io/stargate/sgv2/jsonapi/service/operation/model/impl/ExpressionBuilder.java +++ b/src/main/java/io/stargate/sgv2/jsonapi/service/operation/model/impl/ExpressionBuilder.java @@ -31,16 +31,14 @@ public static List> buildExpressions( buildExpressionRecursive(logicalExpression, additionalIdFilter, idFilters); List> expressions = buildExpressionWithId(additionalIdFilter, expressionWithoutId, idFilters); - return expressions; } + // buildExpressionWithId only handles IDFilter ($eq, $ne, $in) private static List> buildExpressionWithId( DBFilterBase.IDFilter additionalIdFilter, Expression expressionWithoutId, List idFilters) { - List> expressionsWithId = new ArrayList<>(); - if (idFilters.size() > 1) { throw new JsonApiException( ErrorCode.FILTER_MULTIPLE_ID_FILTER, ErrorCode.FILTER_MULTIPLE_ID_FILTER.getMessage()); @@ -48,7 +46,8 @@ private static List> buildExpressionWithId( if (idFilters.isEmpty() && additionalIdFilter == null) { // no idFilters in filter clause and no additionalIdFilter if (expressionWithoutId == null) { - return null; // should find nothing + // no valid non_id filters (eg. "name":{"$nin" : []} ) and no id filter + return Collections.singletonList(null); // should find everything } else { return List.of(expressionWithoutId); } @@ -57,16 +56,19 @@ private static List> buildExpressionWithId( // have an idFilter DBFilterBase.IDFilter idFilter = additionalIdFilter != null ? additionalIdFilter : idFilters.get(0); + + // _id: {$in: []} should find nothing in the entire query + // since _id can not work with $or, entire $and should find nothing if (idFilter.operator == DBFilterBase.IDFilter.Operator.IN && idFilter.getAll().isEmpty()) { return null; // should find nothing } - // idFilter's operator is IN or EQ, for both, we can follow the split query logic + + // idFilter's operator is IN/EQ/NE, for both, split into n query logic List inSplit = idFilters.isEmpty() ? new ArrayList<>() : idFilters.get(0).getAll(); if (additionalIdFilter != null) { inSplit = additionalIdFilter.getAll(); // override the existed id filter } - // split n queries by id return inSplit.stream() .map( idCondition -> { @@ -96,19 +98,41 @@ private static Expression buildExpressionRecursive( conditionExpressions.add(subExpressionCondition); } + // if seeing $in, set hasInFilterThisLevel as true boolean hasInFilterThisLevel = false; + // if seeing $nin, set hasNinFilterThisLevel as true + boolean hasNinFilterThisLevel = false; boolean inFilterThisLevelWithEmptyArray = true; + boolean ninFilterThisLevelWithEmptyArray = true; + // second for loop, is to iterate all subComparisonExpression for (ComparisonExpression comparisonExpression : logicalExpression.comparisonExpressions) { for (DBFilterBase dbFilter : comparisonExpression.getDbFilters()) { if (dbFilter instanceof DBFilterBase.InFilter inFilter) { - hasInFilterThisLevel = true; + if (inFilter.operator.equals(DBFilterBase.InFilter.Operator.IN)) { + hasInFilterThisLevel = true; + } else if (inFilter.operator.equals(DBFilterBase.InFilter.Operator.NIN)) { + hasNinFilterThisLevel = true; + } List inFilterConditions = inFilter.getAll(); if (!inFilterConditions.isEmpty()) { - inFilterThisLevelWithEmptyArray = false; + // store information of an empty array happens with $in or $nin + if (inFilter.operator.equals(DBFilterBase.InFilter.Operator.IN)) { + inFilterThisLevelWithEmptyArray = false; + } else if (inFilter.operator.equals(DBFilterBase.InFilter.Operator.NIN)) { + ninFilterThisLevelWithEmptyArray = false; + } List> inConditionsVariables = inFilterConditions.stream().map(Variable::of).toList(); - conditionExpressions.add(ExpressionUtils.orOf(inConditionsVariables)); + // non_id $in:["A","B"] -> array_contains contains A or array_contains contains B + // non_id $nin:["A","B"] -> array_contains not contains A and array_contains not + // contains B + // _id $nin: ["A","B"] -> query_text_values['_id'] != A and query_text_values['_id'] != + // B + conditionExpressions.add( + inFilter.operator.equals(DBFilterBase.InFilter.Operator.IN) + ? ExpressionUtils.orOf(inConditionsVariables) + : ExpressionUtils.andOf(inConditionsVariables)); } } else if (dbFilter instanceof DBFilterBase.IDFilter idFilter) { if (additionalIdFilter == null) { @@ -119,16 +143,43 @@ private static Expression buildExpressionRecursive( } } } - // current logicalExpression is empty (implies sub-logicalExpression and - // sub-comparisonExpression are all empty) - if (conditionExpressions.isEmpty()) { - return null; + + // when having an empty array $nin, if $nin occurs within an $or logic, entire $or should match + // everything + if (hasNinFilterThisLevel + && ninFilterThisLevelWithEmptyArray + && logicalExpression.getLogicalRelation().equals(LogicalExpression.LogicalOperator.OR)) { + // TODO: find a better CQL TRUE placeholder + conditionExpressions.clear(); + conditionExpressions.add( + Variable.of( + new DBFilterBase.IsNullFilter( + "something user never use", DBFilterBase.SetFilterBase.Operator.NOT_CONTAINS) + .get())); + return ExpressionUtils.buildExpression( + conditionExpressions, logicalExpression.getLogicalRelation().getOperator()); } + // when having an empty array $in, if $in occurs within an $and logic, entire $and should match // nothing if (hasInFilterThisLevel && inFilterThisLevelWithEmptyArray && logicalExpression.getLogicalRelation().equals(LogicalExpression.LogicalOperator.AND)) { + // TODO: find a better CQL FALSE placeholder + conditionExpressions.clear(); + conditionExpressions.add( + Variable.of( + new DBFilterBase.IsNullFilter( + "something user never use", DBFilterBase.SetFilterBase.Operator.CONTAINS) + .get())); + return ExpressionUtils.buildExpression( + conditionExpressions, logicalExpression.getLogicalRelation().getOperator()); + // return null; + } + + // current logicalExpression is empty (implies sub-logicalExpression and + // sub-comparisonExpression are all empty) + if (conditionExpressions.isEmpty()) { return null; } diff --git a/src/main/java/io/stargate/sgv2/jsonapi/service/operation/model/impl/FindOperation.java b/src/main/java/io/stargate/sgv2/jsonapi/service/operation/model/impl/FindOperation.java index 667e482a93..2cd8c535d7 100644 --- a/src/main/java/io/stargate/sgv2/jsonapi/service/operation/model/impl/FindOperation.java +++ b/src/main/java/io/stargate/sgv2/jsonapi/service/operation/model/impl/FindOperation.java @@ -396,7 +396,7 @@ public ReadDocument getNewDocument() { * buildConditions method. */ private List buildSelectQueries(DBFilterBase.IDFilter additionalIdFilter) { - List> expressions = + final List> expressions = ExpressionBuilder.buildExpressions(logicalExpression, additionalIdFilter); if (expressions == null) { // find nothing return List.of(); @@ -505,7 +505,7 @@ private QueryOuterClass.Query getVectorSearchQueryByExpression( * buildConditions method. */ private List buildSortedSelectQueries(DBFilterBase.IDFilter additionalIdFilter) { - List> expressions = + final List> expressions = ExpressionBuilder.buildExpressions(logicalExpression, additionalIdFilter); if (expressions == null) { // find nothing return List.of(); diff --git a/src/main/java/io/stargate/sgv2/jsonapi/service/operation/model/impl/ReadAndUpdateOperation.java b/src/main/java/io/stargate/sgv2/jsonapi/service/operation/model/impl/ReadAndUpdateOperation.java index 7cd2aba049..f173401dc0 100644 --- a/src/main/java/io/stargate/sgv2/jsonapi/service/operation/model/impl/ReadAndUpdateOperation.java +++ b/src/main/java/io/stargate/sgv2/jsonapi/service/operation/model/impl/ReadAndUpdateOperation.java @@ -147,7 +147,6 @@ private Uni processUpdate( // perform update operation and save only if data is modified. .flatMap( readDocument -> { - // if there is no document return null item if (readDocument == null) { return Uni.createFrom().nullItem(); @@ -179,7 +178,6 @@ private Uni processUpdate( // Have to do this because shredder adds _id field to the document if it doesn't exist JsonNode updatedDocument = writableShreddedDocument.docJsonNode(); - // update the document return updatedDocument(queryExecutor, writableShreddedDocument) diff --git a/src/main/java/io/stargate/sgv2/jsonapi/service/resolver/model/impl/matcher/FilterableResolver.java b/src/main/java/io/stargate/sgv2/jsonapi/service/resolver/model/impl/matcher/FilterableResolver.java index 56891b9be3..ee2a755bed 100644 --- a/src/main/java/io/stargate/sgv2/jsonapi/service/resolver/model/impl/matcher/FilterableResolver.java +++ b/src/main/java/io/stargate/sgv2/jsonapi/service/resolver/model/impl/matcher/FilterableResolver.java @@ -42,6 +42,7 @@ public abstract class FilterableResolver { private static final Object SIZE_GROUP = new Object(); private static final Object ARRAY_EQUALS = new Object(); private static final Object SUB_DOC_EQUALS = new Object(); + @Inject DocumentLimitsConfig docLimits; @Inject @@ -59,14 +60,19 @@ public FilterableResolver() { .capture(ID_GROUP_IN) .compareValues("_id", EnumSet.of(ValueComparisonOperator.IN), JsonType.ARRAY); - // NOTE - can only do eq ops on fields until SAI changes matchRules .addMatchRule(FilterableResolver::findDynamic, FilterMatcher.MatchStrategy.GREEDY) .matcher() .capture(ID_GROUP) - .compareValues("_id", EnumSet.of(ValueComparisonOperator.EQ), JsonType.DOCUMENT_ID) + .compareValues( + "_id", + EnumSet.of(ValueComparisonOperator.EQ, ValueComparisonOperator.NE), + JsonType.DOCUMENT_ID) .capture(ID_GROUP_IN) - .compareValues("_id", EnumSet.of(ValueComparisonOperator.IN), JsonType.ARRAY) + .compareValues( + "_id", + EnumSet.of(ValueComparisonOperator.IN, ValueComparisonOperator.NIN), + JsonType.ARRAY) .capture(ID_GROUP_RANGE) .compareValues( "_id", @@ -77,28 +83,40 @@ public FilterableResolver() { ValueComparisonOperator.LTE), JsonType.DOCUMENT_ID) .capture(DYNAMIC_GROUP_IN) - .compareValues("*", EnumSet.of(ValueComparisonOperator.IN), JsonType.ARRAY) + .compareValues( + "*", + EnumSet.of(ValueComparisonOperator.IN, ValueComparisonOperator.NIN), + JsonType.ARRAY) .capture(DYNAMIC_NUMBER_GROUP) .compareValues( "*", EnumSet.of( ValueComparisonOperator.EQ, + ValueComparisonOperator.NE, ValueComparisonOperator.GT, ValueComparisonOperator.GTE, ValueComparisonOperator.LT, ValueComparisonOperator.LTE), JsonType.NUMBER) .capture(DYNAMIC_TEXT_GROUP) - .compareValues("*", EnumSet.of(ValueComparisonOperator.EQ), JsonType.STRING) + .compareValues( + "*", + EnumSet.of(ValueComparisonOperator.EQ, ValueComparisonOperator.NE), + JsonType.STRING) .capture(DYNAMIC_BOOL_GROUP) - .compareValues("*", EnumSet.of(ValueComparisonOperator.EQ), JsonType.BOOLEAN) + .compareValues( + "*", + EnumSet.of(ValueComparisonOperator.EQ, ValueComparisonOperator.NE), + JsonType.BOOLEAN) .capture(DYNAMIC_NULL_GROUP) - .compareValues("*", EnumSet.of(ValueComparisonOperator.EQ), JsonType.NULL) + .compareValues( + "*", EnumSet.of(ValueComparisonOperator.EQ, ValueComparisonOperator.NE), JsonType.NULL) .capture(DYNAMIC_DATE_GROUP) .compareValues( "*", EnumSet.of( ValueComparisonOperator.EQ, + ValueComparisonOperator.NE, ValueComparisonOperator.GT, ValueComparisonOperator.GTE, ValueComparisonOperator.LT, @@ -111,9 +129,13 @@ public FilterableResolver() { .capture(SIZE_GROUP) .compareValues("*", EnumSet.of(ArrayComparisonOperator.SIZE), JsonType.NUMBER) .capture(ARRAY_EQUALS) - .compareValues("*", EnumSet.of(ValueComparisonOperator.EQ), JsonType.ARRAY) + .compareValues( + "*", EnumSet.of(ValueComparisonOperator.EQ, ValueComparisonOperator.NE), JsonType.ARRAY) .capture(SUB_DOC_EQUALS) - .compareValues("*", EnumSet.of(ValueComparisonOperator.EQ), JsonType.SUB_DOC); + .compareValues( + "*", + EnumSet.of(ValueComparisonOperator.EQ, ValueComparisonOperator.NE), + JsonType.SUB_DOC); } protected LogicalExpression resolve(CommandContext commandContext, T command) { @@ -131,7 +153,6 @@ protected LogicalExpression resolve(CommandContext commandContext, T command) { } public static List findById(CaptureExpression captureExpression) { - List filters = new ArrayList<>(); for (FilterOperation filterOperation : captureExpression.filterOperations()) { if (captureExpression.marker() == ID_GROUP) { @@ -156,18 +177,48 @@ public static List findNoFilter(CaptureExpression captureExpressio public static List findDynamic(CaptureExpression captureExpression) { List filters = new ArrayList<>(); for (FilterOperation filterOperation : captureExpression.filterOperations()) { - if (captureExpression.marker() == ID_GROUP) { - filters.add( - new DBFilterBase.IDFilter( - DBFilterBase.IDFilter.Operator.EQ, (DocumentId) filterOperation.operand().value())); + switch ((ValueComparisonOperator) filterOperation.operator()) { + case EQ: + filters.add( + new DBFilterBase.IDFilter( + DBFilterBase.IDFilter.Operator.EQ, + (DocumentId) filterOperation.operand().value())); + break; + case NE: + filters.add( + new DBFilterBase.IDFilter( + DBFilterBase.IDFilter.Operator.NE, + (DocumentId) filterOperation.operand().value())); + break; + default: + throw new JsonApiException( + ErrorCode.UNSUPPORTED_FILTER_DATA_TYPE, + String.format( + "Unsupported filter operator %s ", filterOperation.operator().getOperator())); + } } - if (captureExpression.marker() == ID_GROUP_IN) { - filters.add( - new DBFilterBase.IDFilter( - DBFilterBase.IDFilter.Operator.IN, - (List) filterOperation.operand().value())); + switch ((ValueComparisonOperator) filterOperation.operator()) { + case IN: + filters.add( + new DBFilterBase.IDFilter( + DBFilterBase.IDFilter.Operator.IN, + (List) filterOperation.operand().value())); + break; + case NIN: + filters.add( + new DBFilterBase.InFilter( + getInFilterBaseOperator(filterOperation.operator()), + captureExpression.path(), + (List) filterOperation.operand().value())); + break; + default: + throw new JsonApiException( + ErrorCode.UNSUPPORTED_FILTER_DATA_TYPE, + String.format( + "Unsupported filter operator %s ", filterOperation.operator().getOperator())); + } } if (captureExpression.marker() == ID_GROUP_RANGE) { @@ -176,14 +227,14 @@ public static List findDynamic(CaptureExpression captureExpression filters.add( new DBFilterBase.NumberFilter( DocumentConstants.Fields.DOC_ID, - getDBFilterBaseOperator(filterOperation.operator()), + getMapFilterBaseOperator(filterOperation.operator()), bdv)); } - if (value.value() instanceof Map mv) { + if (value.value() instanceof Map) { filters.add( new DBFilterBase.DateFilter( DocumentConstants.Fields.DOC_ID, - getDBFilterBaseOperator(filterOperation.operator()), + getMapFilterBaseOperator(filterOperation.operator()), JsonUtil.createDateFromDocumentId(value))); } } @@ -191,7 +242,7 @@ public static List findDynamic(CaptureExpression captureExpression if (captureExpression.marker() == DYNAMIC_GROUP_IN) { filters.add( new DBFilterBase.InFilter( - DBFilterBase.InFilter.Operator.IN, + getInFilterBaseOperator(filterOperation.operator()), captureExpression.path(), (List) filterOperation.operand().value())); } @@ -200,7 +251,7 @@ public static List findDynamic(CaptureExpression captureExpression filters.add( new DBFilterBase.TextFilter( captureExpression.path(), - DBFilterBase.MapFilterBase.Operator.EQ, + getMapFilterBaseOperator(filterOperation.operator()), (String) filterOperation.operand().value())); } @@ -208,7 +259,7 @@ public static List findDynamic(CaptureExpression captureExpression filters.add( new DBFilterBase.BoolFilter( captureExpression.path(), - DBFilterBase.MapFilterBase.Operator.EQ, + getMapFilterBaseOperator(filterOperation.operator()), (Boolean) filterOperation.operand().value())); } @@ -216,31 +267,29 @@ public static List findDynamic(CaptureExpression captureExpression filters.add( new DBFilterBase.NumberFilter( captureExpression.path(), - getDBFilterBaseOperator(filterOperation.operator()), + getMapFilterBaseOperator(filterOperation.operator()), (BigDecimal) filterOperation.operand().value())); } if (captureExpression.marker() == DYNAMIC_NULL_GROUP) { - filters.add(new DBFilterBase.IsNullFilter(captureExpression.path())); + filters.add( + new DBFilterBase.IsNullFilter( + captureExpression.path(), getSetFilterBaseOperator(filterOperation.operator()))); } if (captureExpression.marker() == DYNAMIC_DATE_GROUP) { filters.add( new DBFilterBase.DateFilter( captureExpression.path(), - getDBFilterBaseOperator(filterOperation.operator()), + getMapFilterBaseOperator(filterOperation.operator()), (Date) filterOperation.operand().value())); } if (captureExpression.marker() == EXISTS_GROUP) { Boolean bool = (Boolean) filterOperation.operand().value(); - if (bool) { - filters.add(new DBFilterBase.ExistsFilter(captureExpression.path(), bool)); - } else { - throw new JsonApiException( - ErrorCode.UNSUPPORTED_FILTER_DATA_TYPE, "$exists is supported only with true option"); - } + filters.add(new DBFilterBase.ExistsFilter(captureExpression.path(), bool)); } + if (captureExpression.marker() == ALL_GROUP) { final DocValueHasher docValueHasher = new DocValueHasher(); List objects = (List) filterOperation.operand().value(); @@ -260,7 +309,10 @@ public static List findDynamic(CaptureExpression captureExpression new DBFilterBase.ArrayEqualsFilter( new DocValueHasher(), captureExpression.path(), - (List) filterOperation.operand().value())); + (List) filterOperation.operand().value(), + filterOperation.operator().equals(ValueComparisonOperator.EQ) + ? DBFilterBase.MapFilterBase.Operator.MAP_EQUALS + : DBFilterBase.MapFilterBase.Operator.MAP_NOT_EQUALS)); } if (captureExpression.marker() == SUB_DOC_EQUALS) { @@ -268,18 +320,23 @@ public static List findDynamic(CaptureExpression captureExpression new DBFilterBase.SubDocEqualsFilter( new DocValueHasher(), captureExpression.path(), - (Map) filterOperation.operand().value())); + (Map) filterOperation.operand().value(), + filterOperation.operator().equals(ValueComparisonOperator.EQ) + ? DBFilterBase.MapFilterBase.Operator.MAP_EQUALS + : DBFilterBase.MapFilterBase.Operator.MAP_NOT_EQUALS)); } } return filters; } - private static DBFilterBase.MapFilterBase.Operator getDBFilterBaseOperator( - FilterOperator filterOperation) { - switch ((ValueComparisonOperator) filterOperation) { + private static DBFilterBase.MapFilterBase.Operator getMapFilterBaseOperator( + FilterOperator filterOperator) { + switch ((ValueComparisonOperator) filterOperator) { case EQ: return DBFilterBase.MapFilterBase.Operator.EQ; + case NE: + return DBFilterBase.MapFilterBase.Operator.NE; case GT: return DBFilterBase.MapFilterBase.Operator.GT; case GTE: @@ -291,7 +348,35 @@ private static DBFilterBase.MapFilterBase.Operator getDBFilterBaseOperator( default: throw new JsonApiException( ErrorCode.UNSUPPORTED_FILTER_DATA_TYPE, - String.format("Unsupported filter operator %s ", filterOperation.getOperator())); + String.format("Unsupported filter operator %s ", filterOperator.getOperator())); + } + } + + private static DBFilterBase.InFilter.Operator getInFilterBaseOperator( + FilterOperator filterOperator) { + switch ((ValueComparisonOperator) filterOperator) { + case IN: + return DBFilterBase.InFilter.Operator.IN; + case NIN: + return DBFilterBase.InFilter.Operator.NIN; + default: + throw new JsonApiException( + ErrorCode.UNSUPPORTED_FILTER_DATA_TYPE, + String.format("Unsupported filter operator %s ", filterOperator.getOperator())); + } + } + + private static DBFilterBase.SetFilterBase.Operator getSetFilterBaseOperator( + FilterOperator filterOperator) { + switch ((ValueComparisonOperator) filterOperator) { + case EQ: + return DBFilterBase.SetFilterBase.Operator.CONTAINS; + case NE: + return DBFilterBase.SetFilterBase.Operator.NOT_CONTAINS; + default: + throw new JsonApiException( + ErrorCode.UNSUPPORTED_FILTER_DATA_TYPE, + String.format("Unsupported filter operator %s ", filterOperator.getOperator())); } } } diff --git a/src/test/java/io/stargate/sgv2/jsonapi/api/model/command/deserializers/FilterClauseDeserializerTest.java b/src/test/java/io/stargate/sgv2/jsonapi/api/model/command/deserializers/FilterClauseDeserializerTest.java index ad008d8b90..1e8a6014a2 100644 --- a/src/test/java/io/stargate/sgv2/jsonapi/api/model/command/deserializers/FilterClauseDeserializerTest.java +++ b/src/test/java/io/stargate/sgv2/jsonapi/api/model/command/deserializers/FilterClauseDeserializerTest.java @@ -13,10 +13,7 @@ import io.stargate.sgv2.jsonapi.service.shredding.model.DocumentId; import jakarta.inject.Inject; import java.math.BigDecimal; -import java.util.Date; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; +import java.util.*; import java.util.stream.Stream; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -287,22 +284,6 @@ public void mustHandleDateAsEpochAndOr() throws Exception { }); } - @Test - public void mustHandleExists() throws Exception { - String json = - """ - {"existsPath" : {"$exists": false}} - """; - - Throwable throwable = catchThrowable(() -> objectMapper.readValue(json, FilterClause.class)); - assertThat(throwable) - .isInstanceOf(JsonApiException.class) - .satisfies( - t -> { - assertThat(t.getMessage()).isEqualTo("$exists operator supports only true"); - }); - } - @Test public void mustHandleAll() throws Exception { String json = @@ -499,6 +480,76 @@ ValueComparisonOperator.EQ, new JsonLiteral(value, JsonType.SUB_DOC))), .isEqualTo(expectedResult.getPath()); } + @Test + public void mustHandleArrayNe() throws Exception { + String json = """ + {"col" : {"$ne": ["1","2"]}} + """; + final ComparisonExpression expectedResult = + new ComparisonExpression( + "col", + List.of( + new ValueComparisonOperation( + ValueComparisonOperator.NE, + new JsonLiteral(List.of("1", "2"), JsonType.ARRAY))), + null); + FilterClause filterClause = objectMapper.readValue(json, FilterClause.class); + assertThat(filterClause.logicalExpression().logicalExpressions).hasSize(0); + assertThat(filterClause.logicalExpression().comparisonExpressions).hasSize(1); + assertThat( + filterClause.logicalExpression().comparisonExpressions.get(0).getFilterOperations()) + .isEqualTo(expectedResult.getFilterOperations()); + assertThat(filterClause.logicalExpression().comparisonExpressions.get(0).getPath()) + .isEqualTo(expectedResult.getPath()); + } + + @Test + public void mustHandleArrayEq() throws Exception { + String json = """ + {"col" : {"$eq": ["3","4"]}} + """; + final ComparisonExpression expectedResult = + new ComparisonExpression( + "col", + List.of( + new ValueComparisonOperation( + ValueComparisonOperator.EQ, + new JsonLiteral(List.of("3", "4"), JsonType.ARRAY))), + null); + FilterClause filterClause = objectMapper.readValue(json, FilterClause.class); + assertThat(filterClause.logicalExpression().logicalExpressions).hasSize(0); + assertThat(filterClause.logicalExpression().comparisonExpressions).hasSize(1); + assertThat( + filterClause.logicalExpression().comparisonExpressions.get(0).getFilterOperations()) + .isEqualTo(expectedResult.getFilterOperations()); + assertThat(filterClause.logicalExpression().comparisonExpressions.get(0).getPath()) + .isEqualTo(expectedResult.getPath()); + } + + @Test + public void mustHandleSubDocNe() throws Exception { + String json = """ + {"sub_doc" : {"$ne" : {"col": 2}}} + """; + Map value = new LinkedHashMap<>(); + value.put("col", new BigDecimal(2)); + final ComparisonExpression expectedResult = + new ComparisonExpression( + "sub_doc", + List.of( + new ValueComparisonOperation( + ValueComparisonOperator.NE, new JsonLiteral(value, JsonType.SUB_DOC))), + null); + FilterClause filterClause = objectMapper.readValue(json, FilterClause.class); + assertThat(filterClause.logicalExpression().logicalExpressions).hasSize(0); + assertThat(filterClause.logicalExpression().comparisonExpressions).hasSize(1); + assertThat( + filterClause.logicalExpression().comparisonExpressions.get(0).getFilterOperations()) + .isEqualTo(expectedResult.getFilterOperations()); + assertThat(filterClause.logicalExpression().comparisonExpressions.get(0).getPath()) + .isEqualTo(expectedResult.getPath()); + } + @Test public void mustHandleIdFieldIn() throws Exception { String json = """ @@ -849,6 +900,28 @@ ValueComparisonOperator.IN, new JsonLiteral(List.of(), JsonType.ARRAY))), .isEqualTo(expectedResult.getPath()); } + @Test + public void mustHandleNinArrayNonEmpty() throws Exception { + String json = """ + {"_id" : {"$nin": []}} + """; + final ComparisonExpression expectedResult = + new ComparisonExpression( + "_id", + List.of( + new ValueComparisonOperation( + ValueComparisonOperator.NIN, new JsonLiteral(List.of(), JsonType.ARRAY))), + null); + FilterClause filterClause = objectMapper.readValue(json, FilterClause.class); + assertThat(filterClause.logicalExpression().logicalExpressions).hasSize(0); + assertThat(filterClause.logicalExpression().comparisonExpressions).hasSize(1); + assertThat( + filterClause.logicalExpression().comparisonExpressions.get(0).getFilterOperations()) + .isEqualTo(expectedResult.getFilterOperations()); + assertThat(filterClause.logicalExpression().comparisonExpressions.get(0).getPath()) + .isEqualTo(expectedResult.getPath()); + } + @Test public void mustHandleInArrayOnly() throws Exception { String json = """ @@ -864,12 +937,26 @@ public void mustHandleInArrayOnly() throws Exception { } @Test - public void mustHandleInArrayOnlyAnd() throws Exception { + public void mustHandleNinArrayOnly() throws Exception { + String json = """ + {"_id" : {"$nin": "random"}} + """; + Throwable throwable = catchThrowable(() -> objectMapper.readValue(json, FilterClause.class)); + assertThat(throwable) + .isInstanceOf(JsonApiException.class) + .satisfies( + t -> { + assertThat(t.getMessage()).isEqualTo("$nin operator must have `ARRAY`"); + }); + } + + @Test + public void mustHandleNinArrayOnlyAnd() throws Exception { String json = """ { "$and": [ - {"_id" : {"$in": "aaa"}}, + {"age" : {"$nin": "aaa"}}, { "name": "testName" } @@ -881,7 +968,7 @@ public void mustHandleInArrayOnlyAnd() throws Exception { .isInstanceOf(JsonApiException.class) .satisfies( t -> { - assertThat(t.getMessage()).isEqualTo("$in operator must have `ARRAY`"); + assertThat(t.getMessage()).isEqualTo("$nin operator must have `ARRAY`"); }); } @@ -905,6 +992,26 @@ public void mustHandleInArrayWithBigArray() throws Exception { }); } + @Test + public void mustHandleNinArrayWithBigArray() throws Exception { + // String array with 100 unique numbers + String json = + """ + {"_id" : {"$nin": ["0","1","2","3","4","5","6","7","8","9","10","11","12","13","14","15","16","17","18","19","20","21","22","23","24","25","26","27","28","29","30","31","32","33","34","35","36","37","38","39","40","41","42","43","44","45","46","47","48","49","50","51","52","53","54","55","56","57","58","59","60","61","62","63","64","65","66","67","68","69","70","71","72","73","74","75","76","77","78","79","80","81","82","83","84","85","86","87","88","89","90","91","92","93","94","95","96","97","98","99","100"]}} + """; + Throwable throwable = catchThrowable(() -> objectMapper.readValue(json, FilterClause.class)); + assertThat(throwable) + .isInstanceOf(JsonApiException.class) + .satisfies( + t -> { + assertThat(t.getMessage()) + .isEqualTo( + "$nin operator must have at most " + + operationsConfig.maxInOperatorValueSize() + + " values"); + }); + } + @Test public void multipleIdFilterAndOr() throws Exception { String json = diff --git a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/CountIntegrationTest.java b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/CountIntegrationTest.java index 88e3e1a639..1b942f4bbd 100644 --- a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/CountIntegrationTest.java +++ b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/CountIntegrationTest.java @@ -2,10 +2,8 @@ import static io.restassured.RestAssured.given; import static io.stargate.sgv2.common.IntegrationTestUtils.getAuthToken; -import static org.hamcrest.Matchers.hasSize; import static org.hamcrest.Matchers.is; import static org.hamcrest.Matchers.nullValue; -import static org.hamcrest.Matchers.startsWith; import io.quarkus.test.common.QuarkusTestResource; import io.quarkus.test.junit.QuarkusIntegrationTest; @@ -337,10 +335,9 @@ public void withExistFalseOperator() { .post(CollectionResource.BASE_PATH, namespaceName, collectionName) .then() .statusCode(200) - .body("errors", hasSize(1)) - .body("errors[0].exceptionClass", is("JsonApiException")) - .body("errors[0].errorCode", is("INVALID_FILTER_EXPRESSION")) - .body("errors[0].message", is("$exists operator supports only true")); + .body("status.count", is(4)) + .body("data", is(nullValue())) + .body("errors", is(nullValue())); } @Test @@ -762,10 +759,9 @@ public void withNEComparisonOperator() { .post(CollectionResource.BASE_PATH, namespaceName, collectionName) .then() .statusCode(200) - .body("errors", hasSize(1)) - .body("errors[0].exceptionClass", is("JsonApiException")) - .body("errors[0].errorCode", is("UNSUPPORTED_FILTER_OPERATION")) - .body("errors[0].message", startsWith("Unsupported filter operator $ne")); + .body("status.count", is(4)) + .body("data", is(nullValue())) + .body("errors", is(nullValue())); } @Test diff --git a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/FindIntegrationTest.java b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/FindIntegrationTest.java index 8e41de2820..aee534ef33 100644 --- a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/FindIntegrationTest.java +++ b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/FindIntegrationTest.java @@ -11,19 +11,24 @@ import io.stargate.sgv2.jsonapi.config.DocumentLimitsConfig; import io.stargate.sgv2.jsonapi.config.constants.HttpConstants; import io.stargate.sgv2.jsonapi.testresource.DseTestResource; -import org.junit.jupiter.api.ClassOrderer; -import org.junit.jupiter.api.MethodOrderer; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Order; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.TestClassOrder; -import org.junit.jupiter.api.TestMethodOrder; +import org.junit.jupiter.api.*; @QuarkusIntegrationTest @QuarkusTestResource(DseTestResource.class) @TestClassOrder(ClassOrderer.OrderAnnotation.class) public class FindIntegrationTest extends AbstractCollectionIntegrationTestBase { + private void insert(String json) { + given() + .header(HttpConstants.AUTHENTICATION_TOKEN_HEADER_NAME, getAuthToken()) + .contentType(ContentType.JSON) + .body(json) + .when() + .post(CollectionResource.BASE_PATH, namespaceName, collectionName) + .then() + .statusCode(200); + } + @Nested @TestMethodOrder(MethodOrderer.OrderAnnotation.class) @Order(1) @@ -39,7 +44,9 @@ public void setUp() { "_id": "doc1", "username": "user1", "active_user" : true, - "date" : {"$date": 1672531200000} + "date" : {"$date": 1672531200000}, + "age" : 20, + "null_column": null } } } @@ -116,17 +123,6 @@ public void setUp() { """); } - private void insert(String json) { - given() - .header(HttpConstants.AUTHENTICATION_TOKEN_HEADER_NAME, getAuthToken()) - .contentType(ContentType.JSON) - .body(json) - .when() - .post(CollectionResource.BASE_PATH, namespaceName, collectionName) - .then() - .statusCode(200); - } - @Test public void wrongNamespace() { String json = @@ -228,7 +224,9 @@ public void byId() { "_id": "doc1", "username": "user1", "active_user" : true, - "date" : {"$date": 1672531200000} + "date" : {"$date": 1672531200000}, + "age" : 20, + "null_column": null } """)); } @@ -264,7 +262,9 @@ public void byIdEmptyProjection() { "_id": "doc1", "username": "user1", "active_user" : true, - "date" : {"$date": 1672531200000} + "date" : {"$date": 1672531200000}, + "age" : 20, + "null_column": null } """)); } @@ -300,7 +300,9 @@ public void byIdEmptySort() { "_id": "doc1", "username": "user1", "active_user" : true, - "date" : {"$date": 1672531200000} + "date" : {"$date": 1672531200000}, + "age" : 20, + "null_column": null } """)) .body("data.documents", hasSize(1)); @@ -338,390 +340,6 @@ public void byDateId() { .body("data.documents", hasSize(1)); } - @Test - public void inCondition() { - String json = - """ - { - "find": { - "filter" : {"_id" : {"$in": ["doc1", "doc4"]}} - } - } - """; - - // findOne resolves any one of the resolved documents. So the order of the documents in the - // $in clause is not guaranteed. - String expected1 = - """ - {"_id":"doc1", "username":"user1", "active_user":true, "date" : {"$date": 1672531200000}} - """; - String expected2 = - """ - {"_id":"doc4", "username":"user4", "indexedObject":{"0":"value_0","1":"value_1"}} - """; - - given() - .header(HttpConstants.AUTHENTICATION_TOKEN_HEADER_NAME, getAuthToken()) - .contentType(ContentType.JSON) - .body(json) - .when() - .post(CollectionResource.BASE_PATH, namespaceName, collectionName) - .then() - .statusCode(200) - .body("data.documents", hasSize(2)) - .body("status", is(nullValue())) - .body("errors", is(nullValue())) - .body("data.documents", containsInAnyOrder(jsonEquals(expected1), jsonEquals(expected2))); - } - - @Test - public void inConditionWithOtherCondition() { - String json = - """ - { - "find": { - "filter" : {"_id" : {"$in": ["doc1", "doc4"]}, "username" : "user1" } - } - } - """; - String expected1 = - "{\"_id\":\"doc1\", \"username\":\"user1\", \"active_user\":true, \"date\" : {\"$date\": 1672531200000}}"; - given() - .header(HttpConstants.AUTHENTICATION_TOKEN_HEADER_NAME, getAuthToken()) - .contentType(ContentType.JSON) - .body(json) - .when() - .post(CollectionResource.BASE_PATH, namespaceName, collectionName) - .then() - .statusCode(200) - .body("data.documents", hasSize(1)) - .body("status", is(nullValue())) - .body("errors", is(nullValue())) - .body("data.documents[0]", jsonEquals(expected1)); - } - - @Test - public void idInConditionEmptyArray() { - String json = - """ - { - "find": { - "filter" : {"_id" : {"$in": []}} - } - } - """; - - given() - .header(HttpConstants.AUTHENTICATION_TOKEN_HEADER_NAME, getAuthToken()) - .contentType(ContentType.JSON) - .body(json) - .when() - .post(CollectionResource.BASE_PATH, namespaceName, collectionName) - .then() - .statusCode(200) - .body("data.documents", hasSize(0)) - .body("status", is(nullValue())) - .body("errors", is(nullValue())); - } - - @Test - public void nonIDInConditionEmptyArray() { - String json = - """ - { - "find": { - "filter" : { - "username" : {"$in" : []} - } - } - } - """; - given() - .header(HttpConstants.AUTHENTICATION_TOKEN_HEADER_NAME, getAuthToken()) - .contentType(ContentType.JSON) - .body(json) - .when() - .post(CollectionResource.BASE_PATH, namespaceName, collectionName) - .then() - .statusCode(200) - .body("data.documents", hasSize(0)) - .body("status", is(nullValue())) - .body("errors", is(nullValue())); - } - - @Test - public void nonIDInConditionEmptyArrayAnd() { - String json = - """ - { - "find": { - "filter" : { - "$and": [ - { - "age": { - "$in": [] - } - }, - { - "username": "user1" - } - ] - } - } - } - """; - given() - .header(HttpConstants.AUTHENTICATION_TOKEN_HEADER_NAME, getAuthToken()) - .contentType(ContentType.JSON) - .body(json) - .when() - .post(CollectionResource.BASE_PATH, namespaceName, collectionName) - .then() - .statusCode(200) - .body("data.documents", hasSize(0)) - .body("status", is(nullValue())) - .body("errors", is(nullValue())); - } - - @Test - public void nonIDInConditionEmptyArrayOr() { - String json = - """ - { - "find": { - "filter" : { - "$or": [ - { - "age": { - "$in": [] - } - }, - { - "username": "user1" - } - ] - } - } - } - """; - String expected1 = - "{\"_id\":\"doc1\", \"username\":\"user1\", \"active_user\":true, \"date\" : {\"$date\": 1672531200000}}"; - given() - .header(HttpConstants.AUTHENTICATION_TOKEN_HEADER_NAME, getAuthToken()) - .contentType(ContentType.JSON) - .body(json) - .when() - .post(CollectionResource.BASE_PATH, namespaceName, collectionName) - .then() - .statusCode(200) - .body("data.documents", hasSize(1)) - .body("status", is(nullValue())) - .body("errors", is(nullValue())) - .body("data.documents[0]", jsonEquals(expected1)); - } - - @Test - public void inOperatorEmptyArrayWithAdditionalFilters() { - String json = - """ - { - "find": { - "filter" : {"username": "user1", "_id" : {"$in": []}} - } - } - """; - - given() - .header(HttpConstants.AUTHENTICATION_TOKEN_HEADER_NAME, getAuthToken()) - .contentType(ContentType.JSON) - .body(json) - .when() - .post(CollectionResource.BASE_PATH, namespaceName, collectionName) - .then() - .statusCode(200) - .body("data.documents", hasSize(0)) - .body("status", is(nullValue())) - .body("errors", is(nullValue())); - } - - @Test - public void inConditionNonArrayArray() { - String json = - """ - { - "find": { - "filter" : {"_id" : {"$in": true}} - } - } - """; - - given() - .header(HttpConstants.AUTHENTICATION_TOKEN_HEADER_NAME, getAuthToken()) - .contentType(ContentType.JSON) - .body(json) - .when() - .post(CollectionResource.BASE_PATH, namespaceName, collectionName) - .then() - .statusCode(200) - .body("status", is(nullValue())) - .body("data", is(nullValue())) - .body("errors", is(notNullValue())) - .body("errors", hasSize(1)) - .body("errors[0].message", is("$in operator must have `ARRAY`")) - .body("errors[0].exceptionClass", is("JsonApiException")) - .body("errors[0].errorCode", is("INVALID_FILTER_EXPRESSION")); - } - - @Test - public void inConditionNonIdField() { - String json = - """ - { - "find": { - "filter" : { - "username" : {"$in" : ["user1", "user10"]} - } - } - } - """; - String expected1 = - "{\"_id\":\"doc1\", \"username\":\"user1\", \"active_user\":true, \"date\" : {\"$date\": 1672531200000}}"; - given() - .header(HttpConstants.AUTHENTICATION_TOKEN_HEADER_NAME, getAuthToken()) - .contentType(ContentType.JSON) - .body(json) - .when() - .post(CollectionResource.BASE_PATH, namespaceName, collectionName) - .then() - .statusCode(200) - .body("data.documents", hasSize(1)) - .body("status", is(nullValue())) - .body("errors", is(nullValue())) - .body("data.documents[0]", jsonEquals(expected1)); - } - - @Test - public void inConditionNonIdFieldMulti() { - String json = - """ - { - "find": { - "filter" : { - "username" : {"$in" : ["user1", "user4"]} - } - } - } - """; - String expected1 = - """ - {"_id":"doc1", "username":"user1", "active_user":true, "date" : {"$date": 1672531200000}} - """; - String expected2 = - """ - {"_id":"doc4", "username":"user4", "indexedObject":{"0":"value_0","1":"value_1"}} - """; - given() - .header(HttpConstants.AUTHENTICATION_TOKEN_HEADER_NAME, getAuthToken()) - .contentType(ContentType.JSON) - .body(json) - .when() - .post(CollectionResource.BASE_PATH, namespaceName, collectionName) - .then() - .statusCode(200) - .body("data.documents", hasSize(2)) - .body("status", is(nullValue())) - .body("errors", is(nullValue())) - .body("data.documents", containsInAnyOrder(jsonEquals(expected1), jsonEquals(expected2))); - } - - @Test - public void inConditionNonIdFieldIdField() { - String json = - """ - { - "find": { - "filter" : { - "username" : {"$in" : ["user1", "user10"]}, - "_id" : {"$in" : ["doc1", "???"]} - } - } - } - """; - String expected1 = - "{\"_id\":\"doc1\", \"username\":\"user1\", \"active_user\":true, \"date\" : {\"$date\": 1672531200000}}"; - given() - .header(HttpConstants.AUTHENTICATION_TOKEN_HEADER_NAME, getAuthToken()) - .contentType(ContentType.JSON) - .body(json) - .when() - .post(CollectionResource.BASE_PATH, namespaceName, collectionName) - .then() - .statusCode(200) - .body("data.documents", hasSize(1)) - .body("status", is(nullValue())) - .body("errors", is(nullValue())) - .body("data.documents[0]", jsonEquals(expected1)); - } - - @Test - public void inConditionNonIdFieldIdFieldSort() { - String json = - """ - { - "find": { - "filter" : { - "username" : {"$in" : ["user1", "user10"]}, - "_id" : {"$in" : ["doc1", "???"]} - }, - "sort": { "username": -1 } - } - } - """; - String expected1 = - "{\"_id\":\"doc1\", \"username\":\"user1\", \"active_user\":true, \"date\" : {\"$date\": 1672531200000}}"; - given() - .header(HttpConstants.AUTHENTICATION_TOKEN_HEADER_NAME, getAuthToken()) - .contentType(ContentType.JSON) - .body(json) - .when() - .post(CollectionResource.BASE_PATH, namespaceName, collectionName) - .then() - .statusCode(200) - .body("data.documents", hasSize(1)) - .body("status", is(nullValue())) - .body("errors", is(nullValue())) - .body("data.documents[0]", jsonEquals(expected1)); - } - - @Test - public void inConditionWithDuplicateValues() { - String json = - """ - { - "find": { - "filter" : { - "username" : {"$in" : ["user1", "user1"]}, - "_id" : {"$in" : ["doc1", "???"]} - } - } - } - """; - String expected1 = - "{\"_id\":\"doc1\", \"username\":\"user1\", \"active_user\":true, \"date\" : {\"$date\": 1672531200000}}"; - given() - .header(HttpConstants.AUTHENTICATION_TOKEN_HEADER_NAME, getAuthToken()) - .contentType(ContentType.JSON) - .body(json) - .when() - .post(CollectionResource.BASE_PATH, namespaceName, collectionName) - .then() - .statusCode(200) - .body("data.documents", hasSize(1)) - .body("status", is(nullValue())) - .body("errors", is(nullValue())) - .body("data.documents[0]", jsonEquals(expected1)); - } - @Test public void byIdWithProjection() { String json = @@ -764,8 +382,8 @@ public void byColumn() { String expected = """ - {"_id":"doc1", "username":"user1", "active_user":true, "date" : {"$date": 1672531200000}} - """; + {"_id":"doc1", "username":"user1", "active_user":true, "date" : {"$date": 1672531200000}, "age" : 20, "null_column": null} + """; given() .header(HttpConstants.AUTHENTICATION_TOKEN_HEADER_NAME, getAuthToken()) .contentType(ContentType.JSON) @@ -825,8 +443,8 @@ public void withEqComparisonOperator() { String expected = """ - {"_id":"doc1", "username":"user1", "active_user":true, "date" : {"$date": 1672531200000}} - """; + {"_id":"doc1", "username":"user1", "active_user":true, "date" : {"$date": 1672531200000}, "age" : 20, "null_column": null} + """; given() .header(HttpConstants.AUTHENTICATION_TOKEN_HEADER_NAME, getAuthToken()) .contentType(ContentType.JSON) @@ -949,6 +567,39 @@ public void withExistFalseOperator() { } """; + String expected2 = + """ + {"_id":"doc2", "username":"user2", "subdoc":{"id":"abc"},"array":["value1"]} + """; + String expected3 = + """ + {"_id": "doc3","username": "user3","tags" : ["tag1", "tag2", "tag1234567890123456789012345", null, 1, true], "nestedArray" : [["tag1", "tag2"], ["tag1234567890123456789012345", null]]} + """; + + String expected4 = + """ + { + "_id": "doc4", + "username":"user4", + "indexedObject" : { "0": "value_0", "1": "value_1" } + } + """; + String expected5 = + """ + { + "_id": "doc5", + "username": "user5", + "sub_doc" : { "a": 5, "b": { "c": "v1", "d": false } } + } + """; + String expected6 = + """ + { + "_id": {"$date": 6}, + "user-name": "user6" + } + """; + given() .header(HttpConstants.AUTHENTICATION_TOKEN_HEADER_NAME, getAuthToken()) .contentType(ContentType.JSON) @@ -958,11 +609,16 @@ public void withExistFalseOperator() { .then() .statusCode(200) .body("status", is(nullValue())) - .body("data", is(nullValue())) - .body("errors", hasSize(1)) - .body("errors[0].message", is("$exists operator supports only true")) - .body("errors[0].exceptionClass", is("JsonApiException")) - .body("errors[0].errorCode", is("INVALID_FILTER_EXPRESSION")); + .body("errors", is(nullValue())) + .body("data.documents", hasSize(5)) + .body( + "data.documents", + containsInAnyOrder( + jsonEquals(expected2), + jsonEquals(expected3), + jsonEquals(expected4), + jsonEquals(expected5), + jsonEquals(expected6))); } @Test @@ -978,8 +634,8 @@ public void withExistOperator() { String expected = """ - {"_id":"doc1", "username":"user1", "active_user":true, "date" : {"$date": 1672531200000}} - """; + {"_id":"doc1", "username":"user1", "active_user":true, "date" : {"$date": 1672531200000}, "age" : 20, "null_column": null} + """; given() .header(HttpConstants.AUTHENTICATION_TOKEN_HEADER_NAME, getAuthToken()) .contentType(ContentType.JSON) @@ -1379,33 +1035,6 @@ public void withEqOperatorNestedArrayNoMatch() { .body("data.documents", hasSize(0)); } - @Test - public void withNEComparisonOperator() { - String json = - """ - { - "find": { - "filter" : {"username" : {"$ne" : "user1"}} - } - } - """; - - given() - .header(HttpConstants.AUTHENTICATION_TOKEN_HEADER_NAME, getAuthToken()) - .contentType(ContentType.JSON) - .body(json) - .when() - .post(CollectionResource.BASE_PATH, namespaceName, collectionName) - .then() - .statusCode(200) - .body("status", is(nullValue())) - .body("data", is(nullValue())) - .body("errors", hasSize(1)) - .body("errors[0].message", startsWith("Unsupported filter operator $ne")) - .body("errors[0].exceptionClass", is("JsonApiException")) - .body("errors[0].errorCode", is("UNSUPPORTED_FILTER_OPERATION")); - } - @Test public void byBooleanColumn() { String json = @@ -1419,8 +1048,8 @@ public void byBooleanColumn() { String expected = """ - {"_id":"doc1", "username":"user1", "active_user":true, "date" : {"$date": 1672531200000}} - """; + {"_id":"doc1", "username":"user1", "active_user":true, "date" : {"$date": 1672531200000}, "age" : 20, "null_column": null} + """; given() .header(HttpConstants.AUTHENTICATION_TOKEN_HEADER_NAME, getAuthToken()) .contentType(ContentType.JSON) @@ -1449,8 +1078,8 @@ public void byDateColumn() { String expected = """ - {"_id":"doc1", "username":"user1", "active_user":true, "date" : {"$date": 1672531200000}} - """; + {"_id":"doc1", "username":"user1", "active_user":true, "date" : {"$date": 1672531200000}, "age" : 20, "null_column": null} + """; given() .header(HttpConstants.AUTHENTICATION_TOKEN_HEADER_NAME, getAuthToken()) .contentType(ContentType.JSON) @@ -1484,8 +1113,8 @@ public void simpleOr() { String expected1 = """ - {"_id":"doc1", "username":"user1", "active_user":true, "date" : {"$date": 1672531200000}} - """; + {"_id":"doc1", "username":"user1", "active_user":true, "date" : {"$date": 1672531200000}, "age" : 20, "null_column": null} + """; String expected2 = """ {"_id":"doc2", "username":"user2", "subdoc":{"id":"abc"},"array":["value1"]} @@ -1500,7 +1129,8 @@ public void simpleOr() { .statusCode(200) .body("status", is(nullValue())) .body("errors", is(nullValue())) - .body("data.documents", hasSize(2)); + .body("data.documents", hasSize(2)) + .body("data.documents", containsInAnyOrder(jsonEquals(expected1), jsonEquals(expected2))); } @Test @@ -1711,6 +1341,392 @@ private static String createJsonStringWithNFilterFields(int numberOfFields) { return sb.toString(); } + + @Test + public void NeText() { + String json = + """ + { + "find": { + "filter" : {"username" : {"$ne" : "user1"}} + } + } + """; + String expected2 = + """ + {"_id":"doc2", "username":"user2", "subdoc":{"id":"abc"},"array":["value1"]} + """; + String expected3 = + """ + {"_id": "doc3","username": "user3","tags" : ["tag1", "tag2", "tag1234567890123456789012345", null, 1, true], "nestedArray" : [["tag1", "tag2"], ["tag1234567890123456789012345", null]]} + """; + + String expected4 = + """ + { + "_id": "doc4", + "username":"user4", + "indexedObject" : { "0": "value_0", "1": "value_1" } + } + """; + String expected5 = + """ + { + "_id": "doc5", + "username": "user5", + "sub_doc" : { "a": 5, "b": { "c": "v1", "d": false } } + } + """; + String expected6 = + """ + { + "_id": {"$date": 6}, + "user-name": "user6" + } + """; + given() + .header(HttpConstants.AUTHENTICATION_TOKEN_HEADER_NAME, getAuthToken()) + .contentType(ContentType.JSON) + .body(json) + .when() + .post(CollectionResource.BASE_PATH, namespaceName, collectionName) + .then() + .statusCode(200) + .body("status", is(nullValue())) + .body("errors", is(nullValue())) + .body("data.documents", hasSize(5)) + .body( + "data.documents", + containsInAnyOrder( + jsonEquals(expected2), + jsonEquals(expected3), + jsonEquals(expected4), + jsonEquals(expected5), + jsonEquals(expected6))); + } + + @Test + public void NeNumber() { + String json = + """ + { + "find": { + "filter" : {"age" : {"$ne" : 20}} + } + } + """; + String expected2 = + """ + {"_id":"doc2", "username":"user2", "subdoc":{"id":"abc"},"array":["value1"]} + """; + String expected3 = + """ + {"_id": "doc3","username": "user3","tags" : ["tag1", "tag2", "tag1234567890123456789012345", null, 1, true], "nestedArray" : [["tag1", "tag2"], ["tag1234567890123456789012345", null]]} + """; + + String expected4 = + """ + { + "_id": "doc4", + "username":"user4", + "indexedObject" : { "0": "value_0", "1": "value_1" } + } + """; + String expected5 = + """ + { + "_id": "doc5", + "username": "user5", + "sub_doc" : { "a": 5, "b": { "c": "v1", "d": false } } + } + """; + String expected6 = + """ + { + "_id": {"$date": 6}, + "user-name": "user6" + } + """; + given() + .header(HttpConstants.AUTHENTICATION_TOKEN_HEADER_NAME, getAuthToken()) + .contentType(ContentType.JSON) + .body(json) + .when() + .post(CollectionResource.BASE_PATH, namespaceName, collectionName) + .then() + .statusCode(200) + .body("status", is(nullValue())) + .body("errors", is(nullValue())) + .body("data.documents", hasSize(5)) + .body( + "data.documents", + containsInAnyOrder( + jsonEquals(expected2), + jsonEquals(expected3), + jsonEquals(expected4), + jsonEquals(expected5), + jsonEquals(expected6))); + } + + @Test + public void NeBool() { + String json = + """ + { + "find": { + "filter" : {"active_user" : {"$ne" : true}} + } + } + """; + String expected2 = + """ + {"_id":"doc2", "username":"user2", "subdoc":{"id":"abc"},"array":["value1"]} + """; + String expected3 = + """ + {"_id": "doc3","username": "user3","tags" : ["tag1", "tag2", "tag1234567890123456789012345", null, 1, true], "nestedArray" : [["tag1", "tag2"], ["tag1234567890123456789012345", null]]} + """; + + String expected4 = + """ + { + "_id": "doc4", + "username":"user4", + "indexedObject" : { "0": "value_0", "1": "value_1" } + } + """; + String expected5 = + """ + { + "_id": "doc5", + "username": "user5", + "sub_doc" : { "a": 5, "b": { "c": "v1", "d": false } } + } + """; + String expected6 = + """ + { + "_id": {"$date": 6}, + "user-name": "user6" + } + """; + given() + .header(HttpConstants.AUTHENTICATION_TOKEN_HEADER_NAME, getAuthToken()) + .contentType(ContentType.JSON) + .body(json) + .when() + .post(CollectionResource.BASE_PATH, namespaceName, collectionName) + .then() + .statusCode(200) + .body("status", is(nullValue())) + .body("errors", is(nullValue())) + .body("data.documents", hasSize(5)) + .body( + "data.documents", + containsInAnyOrder( + jsonEquals(expected2), + jsonEquals(expected3), + jsonEquals(expected4), + jsonEquals(expected5), + jsonEquals(expected6))); + } + + @Test + public void NeNull() { + String json = + """ + { + "find": { + "filter" : {"null_column" : {"$ne" : null}} + } + } + """; + String expected2 = + """ + {"_id":"doc2", "username":"user2", "subdoc":{"id":"abc"},"array":["value1"]} + """; + String expected3 = + """ + {"_id": "doc3","username": "user3","tags" : ["tag1", "tag2", "tag1234567890123456789012345", null, 1, true], "nestedArray" : [["tag1", "tag2"], ["tag1234567890123456789012345", null]]} + """; + + String expected4 = + """ + { + "_id": "doc4", + "username":"user4", + "indexedObject" : { "0": "value_0", "1": "value_1" } + } + """; + String expected5 = + """ + { + "_id": "doc5", + "username": "user5", + "sub_doc" : { "a": 5, "b": { "c": "v1", "d": false } } + } + """; + String expected6 = + """ + { + "_id": {"$date": 6}, + "user-name": "user6" + } + """; + given() + .header(HttpConstants.AUTHENTICATION_TOKEN_HEADER_NAME, getAuthToken()) + .contentType(ContentType.JSON) + .body(json) + .when() + .post(CollectionResource.BASE_PATH, namespaceName, collectionName) + .then() + .statusCode(200) + .body("status", is(nullValue())) + .body("errors", is(nullValue())) + .body("data.documents", hasSize(5)) + .body( + "data.documents", + containsInAnyOrder( + jsonEquals(expected2), + jsonEquals(expected3), + jsonEquals(expected4), + jsonEquals(expected5), + jsonEquals(expected6))); + } + + @Test + public void NeDate() { + String json = + """ + { + "find": { + "filter" : {"date" : {"$ne" : {"$date" : 1672531200000}}} + } + } + """; + String expected2 = + """ + {"_id":"doc2", "username":"user2", "subdoc":{"id":"abc"},"array":["value1"]} + """; + String expected3 = + """ + {"_id": "doc3","username": "user3","tags" : ["tag1", "tag2", "tag1234567890123456789012345", null, 1, true], "nestedArray" : [["tag1", "tag2"], ["tag1234567890123456789012345", null]]} + """; + + String expected4 = + """ + { + "_id": "doc4", + "username":"user4", + "indexedObject" : { "0": "value_0", "1": "value_1" } + } + """; + String expected5 = + """ + { + "_id": "doc5", + "username": "user5", + "sub_doc" : { "a": 5, "b": { "c": "v1", "d": false } } + } + """; + String expected6 = + """ + { + "_id": {"$date": 6}, + "user-name": "user6" + } + """; + given() + .header(HttpConstants.AUTHENTICATION_TOKEN_HEADER_NAME, getAuthToken()) + .contentType(ContentType.JSON) + .body(json) + .when() + .post(CollectionResource.BASE_PATH, namespaceName, collectionName) + .then() + .statusCode(200) + .body("status", is(nullValue())) + .body("errors", is(nullValue())) + .body("data.documents", hasSize(5)) + .body( + "data.documents", + containsInAnyOrder( + jsonEquals(expected2), + jsonEquals(expected3), + jsonEquals(expected4), + jsonEquals(expected5), + jsonEquals(expected6))); + } + + @Test + public void NeSubdoc() { + String json = + """ + { + "find": { + "filter" : {"subdoc" : {"$ne" : {"id":"abc"}}} + } + } + """; + String expected1 = + """ + { + "_id": "doc1", + "username": "user1", + "active_user" : true, + "date" : {"$date": 1672531200000}, + "age" : 20, + "null_column": null + } + """; + + String expected3 = + """ + {"_id": "doc3","username": "user3","tags" : ["tag1", "tag2", "tag1234567890123456789012345", null, 1, true], "nestedArray" : [["tag1", "tag2"], ["tag1234567890123456789012345", null]]} + """; + + String expected4 = + """ + { + "_id": "doc4", + "username":"user4", + "indexedObject" : { "0": "value_0", "1": "value_1" } + } + """; + String expected5 = + """ + { + "_id": "doc5", + "username": "user5", + "sub_doc" : { "a": 5, "b": { "c": "v1", "d": false } } + } + """; + String expected6 = + """ + { + "_id": {"$date": 6}, + "user-name": "user6" + } + """; + given() + .header(HttpConstants.AUTHENTICATION_TOKEN_HEADER_NAME, getAuthToken()) + .contentType(ContentType.JSON) + .body(json) + .when() + .post(CollectionResource.BASE_PATH, namespaceName, collectionName) + .then() + .statusCode(200) + .body("status", is(nullValue())) + .body("errors", is(nullValue())) + .body("data.documents", hasSize(5)) + .body( + "data.documents", + containsInAnyOrder( + jsonEquals(expected1), + jsonEquals(expected3), + jsonEquals(expected4), + jsonEquals(expected5), + jsonEquals(expected6))); + } } @Nested diff --git a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/FindOneIntegrationTest.java b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/FindOneIntegrationTest.java index f43d65633e..633bd7cf86 100644 --- a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/FindOneIntegrationTest.java +++ b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/FindOneIntegrationTest.java @@ -354,6 +354,31 @@ public void inConditionNonArrayArray() { .body("errors[0].errorCode", is("INVALID_FILTER_EXPRESSION")); } + @Test + public void ninConditionNonArrayArray() { + String json = + """ + { + "findOne": { + "filter" : {"_id" : {"$nin": false}} + } + } + """; + given() + .header(HttpConstants.AUTHENTICATION_TOKEN_HEADER_NAME, getAuthToken()) + .contentType(ContentType.JSON) + .body(json) + .when() + .post(CollectionResource.BASE_PATH, namespaceName, collectionName) + .then() + .statusCode(200) + .body("errors", is(notNullValue())) + .body("errors", hasSize(1)) + .body("errors[0].message", is("$nin operator must have `ARRAY`")) + .body("errors[0].exceptionClass", is("JsonApiException")) + .body("errors[0].errorCode", is("INVALID_FILTER_EXPRESSION")); + } + @Test public void inConditionNonIdField() { String json = @@ -545,12 +570,9 @@ public void withExistsOperatorFalse() { .post(CollectionResource.BASE_PATH, namespaceName, collectionName) .then() .statusCode(200) - .body("data", is(nullValue())) + .body("data.document", is(not(nullValue()))) .body("status", is(nullValue())) - .body("errors", hasSize(1)) - .body("errors[0].message", is("$exists operator supports only true")) - .body("errors[0].exceptionClass", is("JsonApiException")) - .body("errors[0].errorCode", is("INVALID_FILTER_EXPRESSION")); + .body("errors", is(nullValue())); } @Test diff --git a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/InAndNinIntegrationTest.java b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/InAndNinIntegrationTest.java new file mode 100644 index 0000000000..84160f0286 --- /dev/null +++ b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/InAndNinIntegrationTest.java @@ -0,0 +1,696 @@ +package io.stargate.sgv2.jsonapi.api.v1; + +import static io.restassured.RestAssured.given; +import static io.stargate.sgv2.common.IntegrationTestUtils.getAuthToken; +import static net.javacrumbs.jsonunit.JsonMatchers.jsonEquals; +import static org.hamcrest.Matchers.*; +import static org.hamcrest.Matchers.nullValue; + +import io.quarkus.test.common.QuarkusTestResource; +import io.quarkus.test.junit.QuarkusIntegrationTest; +import io.restassured.http.ContentType; +import io.stargate.sgv2.jsonapi.config.constants.HttpConstants; +import io.stargate.sgv2.jsonapi.testresource.DseTestResource; +import org.junit.jupiter.api.*; + +@QuarkusIntegrationTest +@QuarkusTestResource(DseTestResource.class) +@TestClassOrder(ClassOrderer.OrderAnnotation.class) +class InAndNinIntegrationTest extends AbstractCollectionIntegrationTestBase { + + private void insert(String json) { + given() + .header(HttpConstants.AUTHENTICATION_TOKEN_HEADER_NAME, getAuthToken()) + .contentType(ContentType.JSON) + .body(json) + .when() + .post(CollectionResource.BASE_PATH, namespaceName, collectionName) + .then() + .statusCode(200); + } + + @Test + @Order(1) + public void setUp() { + insert( + """ + { + "insertOne": { + "document": { + "_id": "doc1", + "username": "user1", + "active_user" : true, + "date" : {"$date": 1672531200000}, + "age" : 20, + "null_column": null + } + } + } + """); + + insert( + """ + { + "insertOne": { + "document": { + "_id": "doc2", + "username": "user2", + "subdoc" : { + "id" : "abc" + }, + "array" : [ + "value1" + ] + } + } + } + """); + + insert( + """ + { + "insertOne": { + "document": { + "_id": "doc3", + "username": "user3", + "tags" : ["tag1", "tag2", "tag1234567890123456789012345", null, 1, true], + "nestedArray" : [["tag1", "tag2"], ["tag1234567890123456789012345", null]] + } + } + } + """); + + insert( + """ + { + "insertOne": { + "document": { + "_id": "doc4", + "username" : "user4", + "indexedObject" : { "0": "value_0", "1": "value_1" } + } + } + } + """); + + insert( + """ + { + "insertOne": { + "document": { + "_id": "doc5", + "username": "user5", + "sub_doc" : { "a": 5, "b": { "c": "v1", "d": false } } + } + } + } + """); + + insert( + """ + { + "insertOne": { + "document": { + "_id": {"$date": 6}, + "username": "user6" + } + } + } + """); + } + + @Nested + @TestMethodOrder(MethodOrderer.OrderAnnotation.class) + @Order(2) + class In { + + @Test + public void inCondition() { + String json = + """ + { + "find": { + "filter" : {"_id" : {"$in": ["doc1", "doc4"]}} + } + } + """; + + // findOne resolves any one of the resolved documents. So the order of the documents in the + // $in clause is not guaranteed. + String expected1 = + """ + {"_id":"doc1", "username":"user1", "active_user":true, "date" : {"$date": 1672531200000}, "age" : 20, "null_column": null} + """; + String expected2 = + """ + {"_id":"doc4", "username":"user4", "indexedObject":{"0":"value_0","1":"value_1"}} + """; + + given() + .header(HttpConstants.AUTHENTICATION_TOKEN_HEADER_NAME, getAuthToken()) + .contentType(ContentType.JSON) + .body(json) + .when() + .post(CollectionResource.BASE_PATH, namespaceName, collectionName) + .then() + .statusCode(200) + .body("data.documents", hasSize(2)) + .body("status", is(nullValue())) + .body("errors", is(nullValue())) + .body("data.documents", containsInAnyOrder(jsonEquals(expected1), jsonEquals(expected2))); + } + + @Test + public void inConditionWithOtherCondition() { + String json = + """ + { + "find": { + "filter" : {"_id" : {"$in": ["doc1", "doc4"]}, "username" : "user1" } + } + } + """; + String expected1 = + """ + {"_id":"doc1", "username":"user1", "active_user":true, "date" : {"$date": 1672531200000}, "age" : 20, "null_column": null} + """; + given() + .header(HttpConstants.AUTHENTICATION_TOKEN_HEADER_NAME, getAuthToken()) + .contentType(ContentType.JSON) + .body(json) + .when() + .post(CollectionResource.BASE_PATH, namespaceName, collectionName) + .then() + .statusCode(200) + .body("data.documents", hasSize(1)) + .body("status", is(nullValue())) + .body("errors", is(nullValue())) + .body("data.documents[0]", jsonEquals(expected1)); + } + + @Test + public void idInConditionEmptyArray() { + String json = + """ + { + "find": { + "filter" : {"_id" : {"$in": []}} + } + } + """; + + given() + .header(HttpConstants.AUTHENTICATION_TOKEN_HEADER_NAME, getAuthToken()) + .contentType(ContentType.JSON) + .body(json) + .when() + .post(CollectionResource.BASE_PATH, namespaceName, collectionName) + .then() + .statusCode(200) + .body("data.documents", hasSize(0)) + .body("status", is(nullValue())) + .body("errors", is(nullValue())); + } + + @Test + public void nonIDInConditionEmptyArray() { + String json = + """ + { + "find": { + "filter" : { + "username" : {"$in" : []} + } + } + } + """; + given() + .header(HttpConstants.AUTHENTICATION_TOKEN_HEADER_NAME, getAuthToken()) + .contentType(ContentType.JSON) + .body(json) + .when() + .post(CollectionResource.BASE_PATH, namespaceName, collectionName) + .then() + .statusCode(200) + .body("data.documents", hasSize(0)) + .body("status", is(nullValue())) + .body("errors", is(nullValue())); + } + + @Test + public void nonIDInConditionEmptyArrayAnd() { + String json = + """ + { + "find": { + "filter" : { + "$and": [ + { + "age": { + "$in": [] + } + }, + { + "username": "user1" + } + ] + } + } + } + """; + given() + .header(HttpConstants.AUTHENTICATION_TOKEN_HEADER_NAME, getAuthToken()) + .contentType(ContentType.JSON) + .body(json) + .when() + .post(CollectionResource.BASE_PATH, namespaceName, collectionName) + .then() + .statusCode(200) + .body("data.documents", hasSize(0)) + .body("status", is(nullValue())) + .body("errors", is(nullValue())); + } + + @Test + public void nonIDInConditionEmptyArrayOr() { + String json = + """ + { + "find": { + "filter" : { + "$or": [ + { + "age": { + "$in": [] + } + }, + { + "username": "user1" + } + ] + } + } + } + """; + String expected1 = + """ + {"_id":"doc1", "username":"user1", "active_user":true, "date" : {"$date": 1672531200000}, "age" : 20, "null_column": null} + """; + given() + .header(HttpConstants.AUTHENTICATION_TOKEN_HEADER_NAME, getAuthToken()) + .contentType(ContentType.JSON) + .body(json) + .when() + .post(CollectionResource.BASE_PATH, namespaceName, collectionName) + .then() + .statusCode(200) + .body("data.documents", hasSize(1)) + .body("status", is(nullValue())) + .body("errors", is(nullValue())) + .body("data.documents[0]", jsonEquals(expected1)); + } + + @Test + public void inOperatorEmptyArrayWithAdditionalFilters() { + String json = + """ + { + "find": { + "filter" : {"username": "user1", "_id" : {"$in": []}} + } + } + """; + + given() + .header(HttpConstants.AUTHENTICATION_TOKEN_HEADER_NAME, getAuthToken()) + .contentType(ContentType.JSON) + .body(json) + .when() + .post(CollectionResource.BASE_PATH, namespaceName, collectionName) + .then() + .statusCode(200) + .body("data.documents", hasSize(0)) + .body("status", is(nullValue())) + .body("errors", is(nullValue())); + } + + @Test + public void inConditionNonArrayArray() { + String json = + """ + { + "find": { + "filter" : {"_id" : {"$in": true}} + } + } + """; + + given() + .header(HttpConstants.AUTHENTICATION_TOKEN_HEADER_NAME, getAuthToken()) + .contentType(ContentType.JSON) + .body(json) + .when() + .post(CollectionResource.BASE_PATH, namespaceName, collectionName) + .then() + .statusCode(200) + .body("status", is(nullValue())) + .body("data", is(nullValue())) + .body("errors", is(notNullValue())) + .body("errors", hasSize(1)) + .body("errors[0].message", is("$in operator must have `ARRAY`")) + .body("errors[0].exceptionClass", is("JsonApiException")) + .body("errors[0].errorCode", is("INVALID_FILTER_EXPRESSION")); + } + + @Test + public void inConditionNonIdField() { + String json = + """ + { + "find": { + "filter" : { + "username" : {"$in" : ["user1", "user10"]} + } + } + } + """; + String expected1 = + """ + {"_id":"doc1", "username":"user1", "active_user":true, "date" : {"$date": 1672531200000}, "age" : 20, "null_column": null} + """; + given() + .header(HttpConstants.AUTHENTICATION_TOKEN_HEADER_NAME, getAuthToken()) + .contentType(ContentType.JSON) + .body(json) + .when() + .post(CollectionResource.BASE_PATH, namespaceName, collectionName) + .then() + .statusCode(200) + .body("data.documents", hasSize(1)) + .body("status", is(nullValue())) + .body("errors", is(nullValue())) + .body("data.documents[0]", jsonEquals(expected1)); + } + + @Test + public void inConditionNonIdFieldMulti() { + String json = + """ + { + "find": { + "filter" : { + "username" : {"$in" : ["user1", "user4"]} + } + } + } + """; + String expected1 = + """ + {"_id":"doc1", "username":"user1", "active_user":true, "date" : {"$date": 1672531200000}, "age" : 20, "null_column": null} + """; + String expected2 = + """ + {"_id":"doc4", "username":"user4", "indexedObject":{"0":"value_0","1":"value_1"}} + """; + given() + .header(HttpConstants.AUTHENTICATION_TOKEN_HEADER_NAME, getAuthToken()) + .contentType(ContentType.JSON) + .body(json) + .when() + .post(CollectionResource.BASE_PATH, namespaceName, collectionName) + .then() + .statusCode(200) + .body("data.documents", hasSize(2)) + .body("status", is(nullValue())) + .body("errors", is(nullValue())) + .body("data.documents", containsInAnyOrder(jsonEquals(expected1), jsonEquals(expected2))); + } + + @Test + public void inConditionNonIdFieldIdField() { + String json = + """ + { + "find": { + "filter" : { + "username" : {"$in" : ["user1", "user10"]}, + "_id" : {"$in" : ["doc1", "???"]} + } + } + } + """; + String expected1 = + """ + {"_id":"doc1", "username":"user1", "active_user":true, "date" : {"$date": 1672531200000}, "age" : 20, "null_column": null} + """; + given() + .header(HttpConstants.AUTHENTICATION_TOKEN_HEADER_NAME, getAuthToken()) + .contentType(ContentType.JSON) + .body(json) + .when() + .post(CollectionResource.BASE_PATH, namespaceName, collectionName) + .then() + .statusCode(200) + .body("data.documents", hasSize(1)) + .body("status", is(nullValue())) + .body("errors", is(nullValue())) + .body("data.documents[0]", jsonEquals(expected1)); + } + + @Test + public void inConditionNonIdFieldIdFieldSort() { + String json = + """ + { + "find": { + "filter" : { + "username" : {"$in" : ["user1", "user10"]}, + "_id" : {"$in" : ["doc1", "???"]} + }, + "sort": { "username": -1 } + } + } + """; + String expected1 = + """ + {"_id":"doc1", "username":"user1", "active_user":true, "date" : {"$date": 1672531200000}, "age" : 20, "null_column": null} + """; + given() + .header(HttpConstants.AUTHENTICATION_TOKEN_HEADER_NAME, getAuthToken()) + .contentType(ContentType.JSON) + .body(json) + .when() + .post(CollectionResource.BASE_PATH, namespaceName, collectionName) + .then() + .statusCode(200) + .body("data.documents", hasSize(1)) + .body("status", is(nullValue())) + .body("errors", is(nullValue())) + .body("data.documents[0]", jsonEquals(expected1)); + } + + @Test + public void inConditionWithDuplicateValues() { + String json = + """ + { + "find": { + "filter" : { + "username" : {"$in" : ["user1", "user1"]}, + "_id" : {"$in" : ["doc1", "???"]} + } + } + } + """; + String expected1 = + """ + {"_id":"doc1", "username":"user1", "active_user":true, "date" : {"$date": 1672531200000}, "age" : 20, "null_column": null} + """; + given() + .header(HttpConstants.AUTHENTICATION_TOKEN_HEADER_NAME, getAuthToken()) + .contentType(ContentType.JSON) + .body(json) + .when() + .post(CollectionResource.BASE_PATH, namespaceName, collectionName) + .then() + .statusCode(200) + .body("data.documents", hasSize(1)) + .body("status", is(nullValue())) + .body("errors", is(nullValue())) + .body("data.documents[0]", jsonEquals(expected1)); + } + } + + @Nested + @TestMethodOrder(MethodOrderer.OrderAnnotation.class) + @Order(3) + class Nin { + + @Test + public void nonIdSimpleNinCondition() { + String json = + """ + { + "find": { + "filter" : {"username" : {"$nin": ["user2", "user3","user4","user5","user6"]}} + } + } + """; + + String expected1 = + """ + {"_id":"doc1", "username":"user1", "active_user":true, "date" : {"$date": 1672531200000}, "age" : 20, "null_column": null} + """; + + given() + .header(HttpConstants.AUTHENTICATION_TOKEN_HEADER_NAME, getAuthToken()) + .contentType(ContentType.JSON) + .body(json) + .when() + .post(CollectionResource.BASE_PATH, namespaceName, collectionName) + .then() + .statusCode(200) + .body("data.documents", hasSize(1)) + .body("status", is(nullValue())) + .body("errors", is(nullValue())) + .body("data.documents[0]", jsonEquals(expected1)); + } + + @Test + public void nonIdNinEmptyArray() { + String json = + """ + { + "find": { + "filter" : {"username" : {"$nin": []}} + } + } + """; + + // should find everything + given() + .header(HttpConstants.AUTHENTICATION_TOKEN_HEADER_NAME, getAuthToken()) + .contentType(ContentType.JSON) + .body(json) + .when() + .post(CollectionResource.BASE_PATH, namespaceName, collectionName) + .then() + .statusCode(200) + .body("data.documents", hasSize(6)) + .body("status", is(nullValue())) + .body("errors", is(nullValue())); + } + + @Test + public void idNinEmptyArray() { + String json = + """ + { + "find": { + "filter" : {"_id" : {"$nin": []}} + } + } + """; + + // should find everything + given() + .header(HttpConstants.AUTHENTICATION_TOKEN_HEADER_NAME, getAuthToken()) + .contentType(ContentType.JSON) + .body(json) + .when() + .post(CollectionResource.BASE_PATH, namespaceName, collectionName) + .then() + .statusCode(200) + .body("data.documents", hasSize(6)) + .body("status", is(nullValue())) + .body("errors", is(nullValue())); + } + } + + @Nested + @TestMethodOrder(MethodOrderer.OrderAnnotation.class) + @Order(4) + class Combination { + + @Test + public void nonIdInEmptyAndNonIdNinEmptyAnd() { + String json = + """ + { + "find": { + "filter" : {"username" : {"$in": []}, "age": {"$nin" : []}} + } + } + """; + + // should find nothing + given() + .header(HttpConstants.AUTHENTICATION_TOKEN_HEADER_NAME, getAuthToken()) + .contentType(ContentType.JSON) + .body(json) + .when() + .post(CollectionResource.BASE_PATH, namespaceName, collectionName) + .then() + .statusCode(200) + .body("data.documents", hasSize(0)) + .body("status", is(nullValue())) + .body("errors", is(nullValue())); + } + + @Test + public void nonIdInEmptyOrNonIdNinEmptyOr() { + String json = + """ + { + "find": { + "filter" :{ + "$or" : + [ + {"username" : {"$in": []}}, + {"age": {"$nin" : []}} + ] + } + } + } + """; + + // should find everything + given() + .header(HttpConstants.AUTHENTICATION_TOKEN_HEADER_NAME, getAuthToken()) + .contentType(ContentType.JSON) + .body(json) + .when() + .post(CollectionResource.BASE_PATH, namespaceName, collectionName) + .then() + .statusCode(200) + .body("data.documents", hasSize(6)) + .body("status", is(nullValue())) + .body("errors", is(nullValue())); + } + + @Test + public void nonIdInEmptyAndIdNinEmptyAnd() { + String json = + """ + { + "find": { + "filter" : {"username" : {"$in": []}, "_id": {"$nin" : []}} + } + } + """; + + // should find nothing + given() + .header(HttpConstants.AUTHENTICATION_TOKEN_HEADER_NAME, getAuthToken()) + .contentType(ContentType.JSON) + .body(json) + .when() + .post(CollectionResource.BASE_PATH, namespaceName, collectionName) + .then() + .statusCode(200) + .body("data.documents", hasSize(0)) + .body("status", is(nullValue())) + .body("errors", is(nullValue())); + } + } +} diff --git a/src/test/java/io/stargate/sgv2/jsonapi/service/operation/model/impl/FindOperationTest.java b/src/test/java/io/stargate/sgv2/jsonapi/service/operation/model/impl/FindOperationTest.java index 7b029fdc68..8648bff53e 100644 --- a/src/test/java/io/stargate/sgv2/jsonapi/service/operation/model/impl/FindOperationTest.java +++ b/src/test/java/io/stargate/sgv2/jsonapi/service/operation/model/impl/FindOperationTest.java @@ -1221,41 +1221,83 @@ public void findWithArrayEqualFilter() throws Exception { return Uni.createFrom().item(results); }); - /* - ValidatingStargateBridge.QueryAssert candidatesAssert = - withQuery(collectionReadCql, Values.of("tags"), Values.of(hash)) - .withPageSize(1) - .withColumnSpec( - List.of( - QueryOuterClass.ColumnSpec.newBuilder() - .setName("key") - .setType(TypeSpecs.tuple(TypeSpecs.TINYINT, TypeSpecs.VARCHAR)) - .build(), - QueryOuterClass.ColumnSpec.newBuilder() - .setName("tx_id") - .setType(TypeSpecs.UUID) - .build(), - QueryOuterClass.ColumnSpec.newBuilder() - .setName("doc_json") - .setType(TypeSpecs.VARCHAR) - .build())) - .returning( - List.of( - List.of( - Values.of( - CustomValueSerializers.getDocumentIdValue( - DocumentId.fromString("doc1"))), - Values.of(UUID.randomUUID()), - Values.of(doc1)))); - - */ + LogicalExpression implicitAnd = LogicalExpression.and(); + implicitAnd.comparisonExpressions.add(new ComparisonExpression(null, null, null)); + List filters = + List.of( + new DBFilterBase.ArrayEqualsFilter( + new DocValueHasher(), + "tags", + List.of("tag1", "tag2"), + DBFilterBase.MapFilterBase.Operator.MAP_EQUALS)); + implicitAnd.comparisonExpressions.get(0).setDBFilters(filters); + + FindOperation operation = + FindOperation.unsortedSingle( + COMMAND_CONTEXT, + implicitAnd, + DocumentProjector.identityProjector(), + ReadType.DOCUMENT, + objectMapper); + + Supplier execute = + operation + .execute(queryExecutor) + .subscribe() + .withSubscriber(UniAssertSubscriber.create()) + .awaitItem() + .getItem(); + + // assert query execution + assertThat(callCount.get()).isEqualTo(1); + + // then result + CommandResult result = execute.get(); + assertThat(result.data().getResponseDocuments()) + .hasSize(1) + .containsOnly(objectMapper.readTree(doc1)); + assertThat(result.status()).isNullOrEmpty(); + assertThat(result.errors()).isNullOrEmpty(); + } + + @Test + public void findWithArrayNotEqualFilter() throws Exception { + String collectionReadCql = + "SELECT key, tx_id, doc_json FROM \"%s\".\"%s\" WHERE query_text_values[?] != ? LIMIT 1" + .formatted(KEYSPACE_NAME, COLLECTION_NAME); + + String doc1 = + """ + { + "_id": "doc1", + "username": "user1", + "registration_active" : true, + "tags" : ["tag1","tag3"] } + } + """; + + final String tagsHash = new DocValueHasher().getHash(List.of("tag1", "tag3")).hash(); + SimpleStatement stmt = SimpleStatement.newInstance(collectionReadCql, "tags", tagsHash); + List rows = Arrays.asList(resultRow(0, "doc1", UUID.randomUUID(), doc1)); + AsyncResultSet results = new MockAsyncResultSet(KEY_TXID_JSON_COLUMNS, rows, null); + final AtomicInteger callCount = new AtomicInteger(); + QueryExecutor queryExecutor = mock(QueryExecutor.class); + when(queryExecutor.executeRead(eq(stmt), any(), anyInt())) + .then( + invocation -> { + callCount.incrementAndGet(); + return Uni.createFrom().item(results); + }); LogicalExpression implicitAnd = LogicalExpression.and(); implicitAnd.comparisonExpressions.add(new ComparisonExpression(null, null, null)); List filters = List.of( new DBFilterBase.ArrayEqualsFilter( - new DocValueHasher(), "tags", List.of("tag1", "tag2"))); + new DocValueHasher(), + "tags", + List.of("tag1", "tag3"), + DBFilterBase.MapFilterBase.Operator.MAP_NOT_EQUALS)); implicitAnd.comparisonExpressions.get(0).setDBFilters(filters); FindOperation operation = @@ -1314,41 +1356,82 @@ public void findWithSubDocEqualFilter() throws Exception { return Uni.createFrom().item(results); }); - /* - ValidatingStargateBridge.QueryAssert candidatesAssert = - withQuery(collectionReadCql, Values.of("sub_doc"), Values.of(hash)) - .withPageSize(1) - .withColumnSpec( - List.of( - QueryOuterClass.ColumnSpec.newBuilder() - .setName("key") - .setType(TypeSpecs.tuple(TypeSpecs.TINYINT, TypeSpecs.VARCHAR)) - .build(), - QueryOuterClass.ColumnSpec.newBuilder() - .setName("tx_id") - .setType(TypeSpecs.UUID) - .build(), - QueryOuterClass.ColumnSpec.newBuilder() - .setName("doc_json") - .setType(TypeSpecs.VARCHAR) - .build())) - .returning( - List.of( - List.of( - Values.of( - CustomValueSerializers.getDocumentIdValue( - DocumentId.fromString("doc1"))), - Values.of(UUID.randomUUID()), - Values.of(doc1)))); - - */ + LogicalExpression implicitAnd = LogicalExpression.and(); + implicitAnd.comparisonExpressions.add(new ComparisonExpression(null, null, null)); + List filters = + List.of( + new DBFilterBase.SubDocEqualsFilter( + new DocValueHasher(), + "sub_doc", + Map.of("col", "val"), + DBFilterBase.MapFilterBase.Operator.MAP_EQUALS)); + implicitAnd.comparisonExpressions.get(0).setDBFilters(filters); + + FindOperation operation = + FindOperation.unsortedSingle( + COMMAND_CONTEXT, + implicitAnd, + DocumentProjector.identityProjector(), + ReadType.DOCUMENT, + objectMapper); + + Supplier execute = + operation + .execute(queryExecutor) + .subscribe() + .withSubscriber(UniAssertSubscriber.create()) + .awaitItem() + .getItem(); + + // assert query execution + assertThat(callCount.get()).isEqualTo(1); + + // then result + CommandResult result = execute.get(); + assertThat(result.data().getResponseDocuments()) + .hasSize(1) + .containsOnly(objectMapper.readTree(doc1)); + assertThat(result.status()).isNullOrEmpty(); + assertThat(result.errors()).isNullOrEmpty(); + } + + @Test + public void findWithSubDocNotEqualFilter() throws Exception { + String collectionReadCql = + "SELECT key, tx_id, doc_json FROM \"%s\".\"%s\" WHERE query_text_values[?] != ? LIMIT 1" + .formatted(KEYSPACE_NAME, COLLECTION_NAME); + + String doc1 = + """ + { + "_id": "doc1", + "username": "user1", + "registration_active" : true, + "sub_doc" : {"col":"invalid"} + } + """; + final String hash = new DocValueHasher().getHash(Map.of("col", "val")).hash(); + SimpleStatement stmt = SimpleStatement.newInstance(collectionReadCql, "sub_doc", hash); + List rows = Arrays.asList(resultRow(0, "doc1", UUID.randomUUID(), doc1)); + AsyncResultSet results = new MockAsyncResultSet(KEY_TXID_JSON_COLUMNS, rows, null); + final AtomicInteger callCount = new AtomicInteger(); + QueryExecutor queryExecutor = mock(QueryExecutor.class); + when(queryExecutor.executeRead(eq(stmt), any(), anyInt())) + .then( + invocation -> { + callCount.incrementAndGet(); + return Uni.createFrom().item(results); + }); LogicalExpression implicitAnd = LogicalExpression.and(); implicitAnd.comparisonExpressions.add(new ComparisonExpression(null, null, null)); List filters = List.of( new DBFilterBase.SubDocEqualsFilter( - new DocValueHasher(), "sub_doc", Map.of("col", "val"))); + new DocValueHasher(), + "sub_doc", + Map.of("col", "val"), + DBFilterBase.MapFilterBase.Operator.MAP_NOT_EQUALS)); implicitAnd.comparisonExpressions.get(0).setDBFilters(filters); FindOperation operation = diff --git a/src/test/java/io/stargate/sgv2/jsonapi/service/operation/model/impl/ReadAndUpdateOperationRetryTest.java b/src/test/java/io/stargate/sgv2/jsonapi/service/operation/model/impl/ReadAndUpdateOperationRetryTest.java index 042b90ea83..39b22a46ac 100644 --- a/src/test/java/io/stargate/sgv2/jsonapi/service/operation/model/impl/ReadAndUpdateOperationRetryTest.java +++ b/src/test/java/io/stargate/sgv2/jsonapi/service/operation/model/impl/ReadAndUpdateOperationRetryTest.java @@ -137,7 +137,6 @@ public void findOneAndUpdateWithRetry() throws Exception { SimpleStatement stmt1 = SimpleStatement.newInstance( collectionReadCql, "username " + new DocValueHasher().getHash("user1").hash()); - List rows1 = Arrays.asList(resultRow(0, "doc1", tx_id1, doc1)); AsyncResultSet results1 = new MockAsyncResultSet(KEY_TXID_JSON_COLUMNS, rows1, null); final AtomicInteger selectQueryAssert = new AtomicInteger(); @@ -148,7 +147,7 @@ public void findOneAndUpdateWithRetry() throws Exception { return Uni.createFrom().item(results1); }); - // read2 + // read2 collectionReadCql = "SELECT key, tx_id, doc_json FROM \"%s\".\"%s\" WHERE (key = ? AND array_contains CONTAINS ?) LIMIT 1" .formatted(KEYSPACE_NAME, COLLECTION_NAME); @@ -158,7 +157,6 @@ public void findOneAndUpdateWithRetry() throws Exception { collectionReadCql, boundKeyForStatement("doc1"), "username " + new DocValueHasher().getHash("user1").hash()); - List rows2 = Arrays.asList(resultRow(0, "doc1", tx_id2, doc1)); AsyncResultSet results2 = new MockAsyncResultSet(KEY_TXID_JSON_COLUMNS, rows2, null); final AtomicInteger reReadQueryAssert = new AtomicInteger(); @@ -730,7 +728,7 @@ public void findAndUpdateWithRetryPartialFailure() throws Exception { assertThat(failedUpdateRetryFirstQueryAssert.get()).isEqualTo(3); assertThat(updateSecondQueryAssert.get()).isEqualTo(1); - // then result + // then result CommandResult result = execute.get(); assertThat(result.status()) .hasSize(2) diff --git a/src/test/java/io/stargate/sgv2/jsonapi/service/operation/model/impl/ReadAndUpdateOperationTest.java b/src/test/java/io/stargate/sgv2/jsonapi/service/operation/model/impl/ReadAndUpdateOperationTest.java index b7fc4082ee..8793cbd65e 100644 --- a/src/test/java/io/stargate/sgv2/jsonapi/service/operation/model/impl/ReadAndUpdateOperationTest.java +++ b/src/test/java/io/stargate/sgv2/jsonapi/service/operation/model/impl/ReadAndUpdateOperationTest.java @@ -13,7 +13,6 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ObjectNode; -import io.quarkus.logging.Log; import io.quarkus.test.junit.QuarkusTest; import io.quarkus.test.junit.TestProfile; import io.smallrye.mutiny.Uni; @@ -719,7 +718,6 @@ public void happyPathReplaceUpsert() throws Exception { // then result CommandResult result = execute.get(); - Log.error("stat " + result.status()); assertThat(result.status()) .hasSize(3) .containsEntry(CommandStatus.MATCHED_COUNT, 0)