diff --git a/pom.xml b/pom.xml index 24e8dc3d61..5e5c2722cd 100644 --- a/pom.xml +++ b/pom.xml @@ -97,6 +97,22 @@ test-jar test + + io.quarkus + quarkus-junit5-mockito + test + + + org.mockito + mockito-junit-jupiter + test + + + net.javacrumbs.json-unit + json-unit + 2.35.0 + test + diff --git a/src/main/java/io/stargate/sgv3/docsapi/exception/ErrorCode.java b/src/main/java/io/stargate/sgv3/docsapi/exception/ErrorCode.java index 48453c2977..0fd86b877f 100644 --- a/src/main/java/io/stargate/sgv3/docsapi/exception/ErrorCode.java +++ b/src/main/java/io/stargate/sgv3/docsapi/exception/ErrorCode.java @@ -6,6 +6,8 @@ public enum ErrorCode { /** Command error codes. */ COMMAND_NOT_IMPLEMENTED("The provided command is not implemented."), + DOCUMENT_UNPARSEABLE("Unable to parse the document"), + FILTER_UNRESOLVABLE("Unable to resolve the filter"), SHRED_BAD_DOCUMENT_TYPE("Bad document type to shred"), @@ -18,7 +20,9 @@ public enum ErrorCode { SHRED_UNRECOGNIZED_NODE_TYPE("Unrecognized JSON node type in input document"), - UNSUPPORTED_FILTER_DATA_TYPE("Unsupported filter data type"); + UNSUPPORTED_FILTER_DATA_TYPE("Unsupported filter data type"), + + UNSUPPORTED_FILTER_OPERATION("Unsupported filter operator"); private final String message; diff --git a/src/main/java/io/stargate/sgv3/docsapi/service/bridge/config/DocumentConfig.java b/src/main/java/io/stargate/sgv3/docsapi/service/bridge/config/DocumentConfig.java index e8cd1b627f..5b77acbc6e 100644 --- a/src/main/java/io/stargate/sgv3/docsapi/service/bridge/config/DocumentConfig.java +++ b/src/main/java/io/stargate/sgv3/docsapi/service/bridge/config/DocumentConfig.java @@ -26,9 +26,24 @@ @ConfigMapping(prefix = "stargate.document") public interface DocumentConfig { - /** @return Defines the maximum document page size, defaults to 20. */ - @Max(100) + /** @return Defines the maximum document page size, defaults to 500. */ + @Max(500) + @Positive + @WithDefault("100") + int maxPageSize(); + + /** @return Defines the default document page size, defaults to 20. */ + @Max(500) @Positive @WithDefault("20") - int pageSize(); + int defaultPageSize(); + + /** + * @return Defines the maximum limit of document that can be returned for a request, defaults to + * 1000. + */ + @Max(Integer.MAX_VALUE) + @Positive + @WithDefault("1000") + int maxLimit(); } diff --git a/src/main/java/io/stargate/sgv3/docsapi/service/bridge/executor/QueryExecutor.java b/src/main/java/io/stargate/sgv3/docsapi/service/bridge/executor/QueryExecutor.java index ae6ca64c45..a164e4dec9 100644 --- a/src/main/java/io/stargate/sgv3/docsapi/service/bridge/executor/QueryExecutor.java +++ b/src/main/java/io/stargate/sgv3/docsapi/service/bridge/executor/QueryExecutor.java @@ -52,10 +52,10 @@ public Uni executeRead( } if (pageSize.isPresent()) { - int page = Math.min(pageSize.get(), documentConfig.pageSize()); + int page = Math.min(pageSize.get(), documentConfig.maxPageSize()); params.setPageSize(Int32Value.of(page)); } else { - params.setPageSize(Int32Value.of(documentConfig.pageSize())); + params.setPageSize(Int32Value.of(documentConfig.defaultPageSize())); } return queryBridge( QueryOuterClass.Query.newBuilder(query).setParameters(params).buildPartial()); diff --git a/src/main/java/io/stargate/sgv3/docsapi/service/operation/model/Operation.java b/src/main/java/io/stargate/sgv3/docsapi/service/operation/model/Operation.java index 324aab553d..cd2e673d2b 100644 --- a/src/main/java/io/stargate/sgv3/docsapi/service/operation/model/Operation.java +++ b/src/main/java/io/stargate/sgv3/docsapi/service/operation/model/Operation.java @@ -1,7 +1,6 @@ package io.stargate.sgv3.docsapi.service.operation.model; import io.smallrye.mutiny.Uni; -import io.stargate.sgv3.docsapi.api.model.command.CommandContext; import io.stargate.sgv3.docsapi.api.model.command.CommandResult; import io.stargate.sgv3.docsapi.service.bridge.executor.QueryExecutor; import java.util.function.Supplier; @@ -25,9 +24,5 @@ * OperationExecutor} */ public interface Operation { - - /** @return The context of the command responsible for this operation. */ - CommandContext commandContext(); - Uni> execute(QueryExecutor queryExecutor); } diff --git a/src/main/java/io/stargate/sgv3/docsapi/service/operation/model/ReadOperation.java b/src/main/java/io/stargate/sgv3/docsapi/service/operation/model/ReadOperation.java new file mode 100644 index 0000000000..0db8d793df --- /dev/null +++ b/src/main/java/io/stargate/sgv3/docsapi/service/operation/model/ReadOperation.java @@ -0,0 +1,80 @@ +package io.stargate.sgv3.docsapi.service.operation.model; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.smallrye.mutiny.Uni; +import io.stargate.bridge.grpc.BytesValues; +import io.stargate.bridge.grpc.Values; +import io.stargate.bridge.proto.QueryOuterClass; +import io.stargate.sgv3.docsapi.exception.DocsException; +import io.stargate.sgv3.docsapi.exception.ErrorCode; +import io.stargate.sgv3.docsapi.service.bridge.executor.QueryExecutor; +import io.stargate.sgv3.docsapi.service.operation.model.impl.ReadDocument; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.Optional; + +/** + * ReadOperation interface which all find command operations will use. It also provides the + * implementation to excute and query and parse the result set as {@link FindResponse} + */ +public interface ReadOperation extends Operation { + static String[] documentColumns = {"key", "tx_id", "doc_json"}; + static String[] documentKeyColumns = {"key", "tx_id"}; + + /** + * Default implementation to query and parse the result set + * + * @param queryExecutor + * @param query + * @param pagingState + * @param readDocument This flag is set to false if the read is done to just identify the document + * id and tx_id to perform another DML operation + * @param objectMapper + * @return + */ + default Uni findDocument( + QueryExecutor queryExecutor, + QueryOuterClass.Query query, + String pagingState, + boolean readDocument, + ObjectMapper objectMapper) { + return queryExecutor + .executeRead(query, Optional.ofNullable(pagingState), Optional.empty()) + .onItem() + .transform( + rSet -> { + int remaining = rSet.getRowsCount(); + int colCount = rSet.getColumnsCount(); + List documents = new ArrayList<>(remaining); + Iterator rowIterator = rSet.getRowsList().stream().iterator(); + while (--remaining >= 0 && rowIterator.hasNext()) { + QueryOuterClass.Row row = rowIterator.next(); + ReadDocument document = null; + try { + document = + new ReadDocument( + Values.string(row.getValues(0)), // key + Optional.of(Values.uuid(row.getValues(1))), // tx_id + readDocument + ? objectMapper.readTree(Values.string(row.getValues(2))) + : null); + } catch (JsonProcessingException e) { + throw new DocsException(ErrorCode.DOCUMENT_UNPARSEABLE); + } + documents.add(document); + } + return new FindResponse(documents, extractPagingStateFromResultSet(rSet)); + }); + } + + private String extractPagingStateFromResultSet(QueryOuterClass.ResultSet rSet) { + if (rSet.hasPagingState()) { + return BytesValues.toBase64(rSet.getPagingState()); + } + return null; + } + + public static record FindResponse(List docs, String pagingState) {} +} diff --git a/src/main/java/io/stargate/sgv3/docsapi/service/operation/model/impl/FindOperation.java b/src/main/java/io/stargate/sgv3/docsapi/service/operation/model/impl/FindOperation.java new file mode 100644 index 0000000000..5986ea2082 --- /dev/null +++ b/src/main/java/io/stargate/sgv3/docsapi/service/operation/model/impl/FindOperation.java @@ -0,0 +1,259 @@ +package io.stargate.sgv3.docsapi.service.operation.model.impl; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.smallrye.mutiny.Uni; +import io.stargate.bridge.grpc.Values; +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.api.common.cql.builder.QueryBuilder; +import io.stargate.sgv3.docsapi.api.model.command.CommandContext; +import io.stargate.sgv3.docsapi.api.model.command.CommandResult; +import io.stargate.sgv3.docsapi.exception.DocsException; +import io.stargate.sgv3.docsapi.exception.ErrorCode; +import io.stargate.sgv3.docsapi.service.bridge.executor.QueryExecutor; +import io.stargate.sgv3.docsapi.service.operation.model.ReadOperation; +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.function.Supplier; + +/** + * Full dynamic query generation for any of the types of filtering we can do against the the db + * table. + * + *

Create with a series of filters that are implicitly AND'd together. + */ +public record FindOperation( + CommandContext commandContext, + List filters, + String pagingState, + int limit, + boolean readDocument, + ObjectMapper objectMapper) + implements ReadOperation { + + @Override + public Uni> execute(QueryExecutor queryExecutor) { + QueryOuterClass.Query query = buildSelectQuery(); + return findDocument(queryExecutor, query, pagingState, readDocument, objectMapper) + .onItem() + .transform(docs -> new ReadOperationPage(docs.docs(), docs.pagingState())); + } + + private QueryOuterClass.Query buildSelectQuery() { + List conditions = new ArrayList<>(filters.size()); + for (DBFilterBase filter : filters) { + conditions.add(filter.get()); + } + return new QueryBuilder() + .select() + .column(readDocument ? documentColumns : documentKeyColumns) + .from(commandContext.database(), commandContext.collection()) + .where(conditions) + .limit(limit) + .build(); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + FindOperation that = (FindOperation) o; + return limit == that.limit + && readDocument == that.readDocument + && commandContext.equals(that.commandContext) + && filters.equals(that.filters) + && Objects.equals(pagingState, that.pagingState); + } + + @Override + public int hashCode() { + return Objects.hash(commandContext, filters, pagingState, limit, readDocument); + } + + /** Base for the DB filters / conditions that we want to update the dynamic query */ + public abstract static class DBFilterBase implements Supplier {} + + /** Filter for the map columns we have in the super shredding table. */ + public abstract static class MapFilterBase extends DBFilterBase { + + // NOTE: we can only do eq until SAI indexes are updated , waiting for >, < etc + public enum Operator { + EQ + } + + private final String columnName; + private final String key; + private final Operator operator; + private final T value; + + protected MapFilterBase(String columnName, String key, Operator operator, T value) { + this.columnName = columnName; + this.key = key; + this.operator = operator; + this.value = value; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + MapFilterBase that = (MapFilterBase) o; + return columnName.equals(that.columnName) + && key.equals(that.key) + && operator == that.operator + && value.equals(that.value); + } + + @Override + public int hashCode() { + return Objects.hash(columnName, key, operator, value); + } + + @Override + public BuiltCondition get() { + switch (operator) { + case EQ: + return BuiltCondition.of( + BuiltCondition.LHS.mapAccess(columnName, Values.of(key)), + Predicate.EQ, + getValue(value)); + default: + throw new DocsException( + ErrorCode.UNSUPPORTED_FILTER_OPERATION, + String.format("Unsupported map operation %s on column %s", operator, columnName)); + } + } + } + + /** Filters db documents based on a text field value */ + public static class TextFilter extends MapFilterBase { + public TextFilter(String path, Operator operator, String value) { + super("query_text_values", path, operator, value); + } + } + + /** Filters db documents based on a boolean field value */ + public static class BoolFilter extends MapFilterBase { + public BoolFilter(String path, Operator operator, Boolean value) { + super("query_bool_values", path, operator, value); + } + } + + /** Filters db documents based on a numeric field value */ + public static class NumberFilter extends MapFilterBase { + public NumberFilter(String path, Operator operator, BigDecimal value) { + super("query_dbl_values", path, operator, value); + } + } + + /** Filters db documents based on a document id field value */ + public static class IDFilter extends DBFilterBase { + public enum Operator { + EQ; + } + + protected final Operator operator; + protected final String value; + + public IDFilter(Operator operator, String value) { + this.operator = operator; + this.value = value; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + IDFilter idFilter = (IDFilter) o; + return operator == idFilter.operator && value.equals(idFilter.value); + } + + @Override + public int hashCode() { + return Objects.hash(operator, value); + } + + @Override + public BuiltCondition get() { + switch (operator) { + case EQ: + return BuiltCondition.of(BuiltCondition.LHS.column("key"), Predicate.EQ, getValue(value)); + default: + throw new DocsException( + ErrorCode.UNSUPPORTED_FILTER_OPERATION, + String.format("Unsupported id column operation %s", operator)); + } + } + } + /** + * DB filter / condition for testing a set value Note: we can only do CONTAINS until SAI indexes + * are updated + */ + public abstract static class SetFilterBase extends DBFilterBase { + public enum Operator { + CONTAINS; + } + + protected final String columnName; + protected final T value; + protected final Operator operator; + + protected SetFilterBase(String columnName, T value, Operator operator) { + this.columnName = columnName; + this.value = value; + this.operator = operator; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + SetFilterBase that = (SetFilterBase) o; + return columnName.equals(that.columnName) + && value.equals(that.value) + && operator == that.operator; + } + + @Override + public int hashCode() { + return Objects.hash(columnName, value, operator); + } + + @Override + public BuiltCondition get() { + switch (operator) { + case CONTAINS: + return BuiltCondition.of(columnName, Predicate.CONTAINS, getValue(value)); + default: + throw new DocsException( + ErrorCode.UNSUPPORTED_FILTER_OPERATION, + String.format("Unsupported set operation %s on column %s", operator, columnName)); + } + } + } + + /** + * Filter for document where a field == null + * + *

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, Operator.CONTAINS); + } + } + + private static QueryOuterClass.Value getValue(Object value) { + if (value instanceof String) { + return Values.of((String) value); + } else if (value instanceof BigDecimal) { + return Values.of((BigDecimal) value); + } else if (value instanceof Boolean) { + return Values.of((Boolean) value); + } + return Values.of((String) null); + } +} diff --git a/src/main/java/io/stargate/sgv3/docsapi/service/operation/model/impl/ReadDocument.java b/src/main/java/io/stargate/sgv3/docsapi/service/operation/model/impl/ReadDocument.java new file mode 100644 index 0000000000..2f192574ec --- /dev/null +++ b/src/main/java/io/stargate/sgv3/docsapi/service/operation/model/impl/ReadDocument.java @@ -0,0 +1,14 @@ +package io.stargate.sgv3.docsapi.service.operation.model.impl; + +import com.fasterxml.jackson.databind.JsonNode; +import java.util.Optional; +import java.util.UUID; + +/** + * Represents a document read from the database + * + * @param id Document Id identifying the document + * @param txnId Unique UUID resenting point in time of a document, used for LWT transactions + * @param document JsonNode representation of the document + */ +public record ReadDocument(String id, Optional txnId, JsonNode document) {} diff --git a/src/main/java/io/stargate/sgv3/docsapi/service/operation/model/impl/ReadOperationPage.java b/src/main/java/io/stargate/sgv3/docsapi/service/operation/model/impl/ReadOperationPage.java new file mode 100644 index 0000000000..32bf6bff83 --- /dev/null +++ b/src/main/java/io/stargate/sgv3/docsapi/service/operation/model/impl/ReadOperationPage.java @@ -0,0 +1,21 @@ +package io.stargate.sgv3.docsapi.service.operation.model.impl; + +import com.fasterxml.jackson.databind.JsonNode; +import io.stargate.sgv3.docsapi.api.model.command.CommandResult; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Supplier; + +/** FindOperation response implementing the {@link CommandResult} */ +public record ReadOperationPage(List docs, String pagingState) + implements Supplier { + + @Override + public CommandResult get() { + final List jsonNodes = new ArrayList<>(); + docs.stream().forEach(doc -> jsonNodes.add(doc.document())); + final CommandResult.ResponseData responseData = + new CommandResult.ResponseData(jsonNodes, pagingState); + return new CommandResult(responseData); + } +} diff --git a/src/main/java/io/stargate/sgv3/docsapi/service/resolver/model/impl/FindOneCommandResolver.java b/src/main/java/io/stargate/sgv3/docsapi/service/resolver/model/impl/FindOneCommandResolver.java new file mode 100644 index 0000000000..a9aaeae812 --- /dev/null +++ b/src/main/java/io/stargate/sgv3/docsapi/service/resolver/model/impl/FindOneCommandResolver.java @@ -0,0 +1,32 @@ +package io.stargate.sgv3.docsapi.service.resolver.model.impl; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.stargate.sgv3.docsapi.api.model.command.impl.FindOneCommand; +import io.stargate.sgv3.docsapi.service.resolver.model.impl.matcher.FilterableResolver; +import java.util.Optional; +import javax.enterprise.context.ApplicationScoped; +import javax.inject.Inject; + +/** Resolves the {@link FindOneCommand } */ +@ApplicationScoped +public class FindOneCommandResolver extends FilterableResolver { + + @Inject + public FindOneCommandResolver(ObjectMapper objectMapper) { + super(objectMapper, true, true); + } + + public FindOneCommandResolver() { + this(null); + } + + @Override + public Class getCommandClass() { + return FindOneCommand.class; + } + + @Override + protected Optional getFilteringOption(FindOneCommand command) { + return Optional.of(new FilteringOptions(1, null)); + } +} diff --git a/src/main/java/io/stargate/sgv3/docsapi/service/resolver/model/impl/matcher/CaptureGroup.java b/src/main/java/io/stargate/sgv3/docsapi/service/resolver/model/impl/matcher/CaptureGroup.java new file mode 100644 index 0000000000..ce13fbbfce --- /dev/null +++ b/src/main/java/io/stargate/sgv3/docsapi/service/resolver/model/impl/matcher/CaptureGroup.java @@ -0,0 +1,47 @@ +package io.stargate.sgv3.docsapi.service.resolver.model.impl.matcher; + +import io.stargate.sgv3.docsapi.api.model.command.Command; +import io.stargate.sgv3.docsapi.api.model.command.clause.filter.ComparisonExpression; +import io.stargate.sgv3.docsapi.api.model.command.clause.filter.FilterOperation; +import io.stargate.sgv3.docsapi.api.model.command.clause.filter.FilterOperator; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; + +/** + * The per API request result of running a {@link FilterMatcher.Capture} against a {@link Command}. + * + *

Is identified by the same Marker as the capture, collects the values the capture matched with + * and provides a way for the resolver to pull them out to use in commands. + * + *

Values from all of the operations that matched, they will be multiple values when we start + * using greedy matching e.g. match all the string comparison operations in {"username": "foo", + * "address.street": "bar"} Path in the {@link ComparisonExpression} that was matched, e.g. + * "address.street" + * + *

Created by the {@link CaptureGroups} via a builder + */ +public record CaptureGroup(Map>> captures) { + + void consumeAllCaptures(Consumer> consumer) { + captures.forEach( + (key, operations) -> { + operations.forEach( + operation -> + consumer.accept( + new CaptureExpression( + key, operation.operator(), operation.operand().value()))); + }); + } + + public void withCapture(String path, List> capture) { + captures.put(path, capture); + } + + /** + * Here so we have simple consumer for consumeCaptures. + * + *

May also need to expand this to include the operation. + */ + public static record CaptureExpression(String path, FilterOperator operator, TYPE value) {} +} diff --git a/src/main/java/io/stargate/sgv3/docsapi/service/resolver/model/impl/matcher/CaptureGroups.java b/src/main/java/io/stargate/sgv3/docsapi/service/resolver/model/impl/matcher/CaptureGroups.java new file mode 100644 index 0000000000..96e9fe5058 --- /dev/null +++ b/src/main/java/io/stargate/sgv3/docsapi/service/resolver/model/impl/matcher/CaptureGroups.java @@ -0,0 +1,54 @@ +package io.stargate.sgv3.docsapi.service.resolver.model.impl.matcher; + +import io.stargate.sgv3.docsapi.api.model.command.Command; +import java.util.HashMap; +import java.util.Map; + +/** + * All the {@link CaptureGroup}s we got from matching against a command. + * + *

This is "result" of running the FilterMatcher, and the value we pass to the resolver function + * so it has raw command and all the groups. See + * + *

Each Capture you create from {@link FilterMatcher#capture(Object)} with a Marker is available + * here as a {@link CaptureGroup} via {@link #getGroup(Object)}. + * + *

Created in the {@link FilterMatcher} via a builder + * + *

T - The {@link Command} that is filtered against + */ +public class CaptureGroups { + private final T command; + private final Map> groups; + + public CaptureGroups(T command) { + this.command = command; + this.groups = new HashMap<>(); + } + + public T command() { + return command; + } + + /** + * Get the {@link CaptureGroup} that has the result of the {@link FilterMatcher.Capture} created + * with the supplied marker + * + * @param marker + * @return CaptureGroup + */ + public CaptureGroup getGroup(Object marker) { + return groups.computeIfAbsent(marker, f -> new CaptureGroup(new HashMap<>())); + } + + /** + * Get the {@link CaptureGroup} that has the result of the {@link FilterMatcher.Capture} created + * with the supplied marker + * + * @param marker + * @return CaptureGroup + */ + public CaptureGroup getGroupIfPresent(Object marker) { + return groups.get(marker); + } +} diff --git a/src/main/java/io/stargate/sgv3/docsapi/service/resolver/model/impl/matcher/FilterMatchRule.java b/src/main/java/io/stargate/sgv3/docsapi/service/resolver/model/impl/matcher/FilterMatchRule.java new file mode 100644 index 0000000000..99ffec4423 --- /dev/null +++ b/src/main/java/io/stargate/sgv3/docsapi/service/resolver/model/impl/matcher/FilterMatchRule.java @@ -0,0 +1,27 @@ +package io.stargate.sgv3.docsapi.service.resolver.model.impl.matcher; + +import io.stargate.sgv3.docsapi.api.model.command.Command; +import io.stargate.sgv3.docsapi.api.model.command.CommandContext; +import io.stargate.sgv3.docsapi.api.model.command.Filterable; +import io.stargate.sgv3.docsapi.service.operation.model.Operation; +import java.util.Optional; +import java.util.function.BiFunction; + +/** + * A single rule that if it matches a {@link Filterable} command to create an {@link Operation} + * + *

Use with the {@link FilterMatchRules}, expected to created via that class. resolveFunction + * function to call if the matcher matches. Is only called if the {@link #matcher} matches + * everything and returns a {@link CaptureGroups} + * + *

T - Command type to match + */ +public record FilterMatchRule( + FilterMatcher matcher, + BiFunction, Operation> resolveFunction) + implements BiFunction> { + @Override + public Optional apply(CommandContext commandContext, T command) { + return matcher.apply(command).map(captures -> resolveFunction.apply(commandContext, captures)); + } +} diff --git a/src/main/java/io/stargate/sgv3/docsapi/service/resolver/model/impl/matcher/FilterMatchRules.java b/src/main/java/io/stargate/sgv3/docsapi/service/resolver/model/impl/matcher/FilterMatchRules.java new file mode 100644 index 0000000000..b10b82f23b --- /dev/null +++ b/src/main/java/io/stargate/sgv3/docsapi/service/resolver/model/impl/matcher/FilterMatchRules.java @@ -0,0 +1,73 @@ +package io.stargate.sgv3.docsapi.service.resolver.model.impl.matcher; + +import com.google.common.annotations.VisibleForTesting; +import io.stargate.sgv3.docsapi.api.model.command.Command; +import io.stargate.sgv3.docsapi.api.model.command.CommandContext; +import io.stargate.sgv3.docsapi.api.model.command.Filterable; +import io.stargate.sgv3.docsapi.exception.DocsException; +import io.stargate.sgv3.docsapi.exception.ErrorCode; +import io.stargate.sgv3.docsapi.service.operation.model.Operation; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.function.BiFunction; + +/** + * Applies a series of {@link FilterMatchRule}'s to either create an {@link Operation} + * + *

T - The command type we are resolving against. + */ +public class FilterMatchRules { + + // use the interface rather than MatchRule class so the streaming works. + private final List>> matchRules = + new ArrayList<>(); + /** + * Adds a rule that will result in the specified resolveFunction being called. + * + *

Rules are applied in the order they are added, so add most specific first. + * + *

Caller should then configure the rule as they want, e.g. + * private final FilterMatchRules matchRules = new FilterMatchRules<>(); + * matchRules.addMatchRule(FindOneCommandResolver::findById).matcher + * .capture(ID_GROUP).eq("_id", JsonType.STRING); + * ... + * private static Operation findById(CommandContext commandContext, Captures captures){ + * CaptureGroup captureGroup = captures.getCapture(ID_GROUP); + * return new FindByIdOperation(commandContext, captureGroup.getSingleJsonLiteral().getTypedValue()); + * } + * + * + * @param resolveFunction + * @return + */ + public FilterMatchRule addMatchRule( + BiFunction, Operation> resolveFunction, + FilterMatcher.MatchStrategy matchStrategy) { + FilterMatchRule rule = + new FilterMatchRule(new FilterMatcher<>(matchStrategy), resolveFunction); + matchRules.add(rule); + return rule; + } + + /** + * Applies all the rules to to return an Operation or throw. + * + * @param commandContext + * @param command + * @return + */ + public Operation apply(CommandContext commandContext, T command) { + return matchRules.stream() + .map(e -> e.apply(commandContext, command)) + .filter(Optional::isPresent) + .map(Optional::get) // unwraps the Optional from the resolver function. + .findFirst() + .orElseThrow(() -> new DocsException(ErrorCode.FILTER_UNRESOLVABLE)); + } + + @VisibleForTesting + protected List>> getMatchRules() { + return matchRules; + } +} diff --git a/src/main/java/io/stargate/sgv3/docsapi/service/resolver/model/impl/matcher/FilterMatcher.java b/src/main/java/io/stargate/sgv3/docsapi/service/resolver/model/impl/matcher/FilterMatcher.java new file mode 100644 index 0000000000..a4d0bf418c --- /dev/null +++ b/src/main/java/io/stargate/sgv3/docsapi/service/resolver/model/impl/matcher/FilterMatcher.java @@ -0,0 +1,164 @@ +package io.stargate.sgv3.docsapi.service.resolver.model.impl.matcher; + +import io.stargate.sgv3.docsapi.api.model.command.Command; +import io.stargate.sgv3.docsapi.api.model.command.Filterable; +import io.stargate.sgv3.docsapi.api.model.command.clause.filter.ComparisonExpression; +import io.stargate.sgv3.docsapi.api.model.command.clause.filter.FilterClause; +import io.stargate.sgv3.docsapi.api.model.command.clause.filter.FilterOperation; +import io.stargate.sgv3.docsapi.api.model.command.clause.filter.JsonType; +import io.stargate.sgv3.docsapi.api.model.command.clause.filter.ValueComparisonOperator; +import java.util.ArrayList; +import java.util.EnumSet; +import java.util.List; +import java.util.ListIterator; +import java.util.Optional; +import java.util.function.BiFunction; + +/** + * This class matches the filter clauses against the filter match rules defined. The match rules + * will be defined in order of preference, so first best match will be used for query processing + * + * @param should be a {@link Command} type, which also implements {@link Filterable} + */ +public class FilterMatcher { + + private List captures = new ArrayList<>(); + + public enum MatchStrategy { + EMPTY, + STRICT, // every capture must match once and only once, every expression must match + GREEDY, // capture groups can match zero or more times, every expression must match + } + + private final MatchStrategy strategy; + + FilterMatcher(MatchStrategy strategy) { + this.strategy = strategy; + } + + public Optional> apply(T command) { + FilterClause filter = command.filterClause(); + List unmatchedCaptures = new ArrayList<>(captures); + CaptureGroups captures = new CaptureGroups(command); + if (strategy == MatchStrategy.EMPTY) { + if (filter == null || filter.comparisonExpressions().isEmpty()) { + return Optional.of(captures); + } else { + return Optional.empty(); + } + } + + if (filter == null) { + return Optional.empty(); + } + + List unmatchedExpressions = + new ArrayList<>(filter.comparisonExpressions()); + ListIterator expressionIter = unmatchedExpressions.listIterator(); + while (expressionIter.hasNext()) { + ComparisonExpression comparisonExpression = expressionIter.next(); + ListIterator captureIter = unmatchedCaptures.listIterator(); + while (captureIter.hasNext()) { + Capture capture = captureIter.next(); + List matched = capture.match(comparisonExpression); + if (!matched.isEmpty()) { + captures.getGroup(capture.marker).withCapture(comparisonExpression.path(), matched); + switch (strategy) { + case STRICT: + captureIter.remove(); + expressionIter.remove(); + break; + case GREEDY: + expressionIter.remove(); + break; + } + break; + } + } + } + // these strategies should be abstracted if we have another one, only 2 for now. + switch (strategy) { + case STRICT: + if (unmatchedCaptures.isEmpty() && unmatchedExpressions.isEmpty()) { + // everything group and expression matched + return Optional.of(captures); + } + break; + case GREEDY: + if (unmatchedExpressions.isEmpty()) { + // everything expression matched, some captures may not match + return Optional.of(captures); + } + break; + } + return Optional.empty(); + } + + /** + * Start of the fluent API, create a Capture then add the matching + * + *

See {@link FilterMatchRules#addMatchRule(BiFunction, MatchStrategy)}} + * + * @param marker + * @return + */ + public Capture capture(Object marker) { + final Capture newCapture = new Capture(marker); + captures.add(newCapture); + return newCapture; + } + + /** + * Capture provides a fluent API to build the matchers to apply to the filter. + * + *

**NOTE:** Is a non static class, it is bound to an instance of FilterMatcher to provide the + * fluent API. + */ + public final class Capture { + + private Object marker; + private String matchPath; + private EnumSet operators; + private JsonType type; + + protected Capture(Object marker) { + this.marker = marker; + } + + public List match(ComparisonExpression t) { + return t.match(matchPath, operators, type); + } + + /** + * A shortcut for {@link #compareValues(String, ValueComparisonOperator, JsonType)} e.g. + * .eq("_id", JsonType.STRING); + * + * + * @param path + * @param type + * @return + */ + public FilterMatcher compareValues( + String path, ValueComparisonOperator operator, JsonType type) { + return compareValues(path, EnumSet.of(operator), type); + } + /** + * The path is compared using an operator against a value of a type + * + *

e.g. + * .compare("*", ValueComparisonOperator.GT, JsonType.NUMBER); + * + * + * @param path + * @param type + * @return + */ + public FilterMatcher compareValues( + String path, EnumSet operators, JsonType type) { + this.matchPath = path; + this.operators = operators; + this.type = type; + return FilterMatcher.this; + } + } +} diff --git a/src/main/java/io/stargate/sgv3/docsapi/service/resolver/model/impl/matcher/FilterableResolver.java b/src/main/java/io/stargate/sgv3/docsapi/service/resolver/model/impl/matcher/FilterableResolver.java new file mode 100644 index 0000000000..ef4c37608d --- /dev/null +++ b/src/main/java/io/stargate/sgv3/docsapi/service/resolver/model/impl/matcher/FilterableResolver.java @@ -0,0 +1,215 @@ +package io.stargate.sgv3.docsapi.service.resolver.model.impl.matcher; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.stargate.sgv3.docsapi.api.model.command.Command; +import io.stargate.sgv3.docsapi.api.model.command.CommandContext; +import io.stargate.sgv3.docsapi.api.model.command.Filterable; +import io.stargate.sgv3.docsapi.api.model.command.clause.filter.JsonType; +import io.stargate.sgv3.docsapi.api.model.command.clause.filter.ValueComparisonOperator; +import io.stargate.sgv3.docsapi.exception.DocsException; +import io.stargate.sgv3.docsapi.exception.ErrorCode; +import io.stargate.sgv3.docsapi.service.operation.model.Operation; +import io.stargate.sgv3.docsapi.service.operation.model.impl.FindOperation; +import io.stargate.sgv3.docsapi.service.resolver.model.CommandResolver; +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +/** + * Base for resolvers that are {@link Filterable}, there are a number of commands like find, + * findOne, updateOne that all have a filter. + * + *

There will be some re-use, and some customisation to work out. + * + *

T - type of the command we are resolving + */ +public abstract class FilterableResolver + implements CommandResolver { + + private final FilterMatchRules matchRules = new FilterMatchRules<>(); + + private static final Object ID_GROUP = new Object(); + private static final Object SINGLE_TEXT_GROUP = new Object(); + + private static final Object DYNAMIC_TEXT_GROUP = new Object(); + private static final Object DYNAMIC_NUMBER_GROUP = new Object(); + private static final Object DYNAMIC_BOOL_GROUP = new Object(); + private static final Object DYNAMIC_NULL_GROUP = new Object(); + + private static final Object EMPTY_GROUP = new Object(); + private final boolean findOne; + private final boolean readDocument; + + private final ObjectMapper objectMapper; + + public FilterableResolver(ObjectMapper objectMapper, boolean findOne, boolean readDocument) { + this.objectMapper = objectMapper; + this.findOne = findOne; + this.readDocument = readDocument; + matchRules.addMatchRule(this::findNoFilter, FilterMatcher.MatchStrategy.EMPTY); + + matchRules + .addMatchRule(this::findById, FilterMatcher.MatchStrategy.STRICT) + .matcher() + .capture(ID_GROUP) + .compareValues("_id", ValueComparisonOperator.EQ, JsonType.STRING); + + // NOTE - can only do eq ops on fields until SAI changes + matchRules + .addMatchRule(this::findDynamic, FilterMatcher.MatchStrategy.GREEDY) + .matcher() + .capture(ID_GROUP) + .compareValues("_id", ValueComparisonOperator.EQ, JsonType.STRING) + .capture(DYNAMIC_NUMBER_GROUP) + .compareValues("*", ValueComparisonOperator.EQ, JsonType.NUMBER) + .capture(DYNAMIC_TEXT_GROUP) + .compareValues("*", ValueComparisonOperator.EQ, JsonType.STRING) + .capture(DYNAMIC_BOOL_GROUP) + .compareValues("*", ValueComparisonOperator.EQ, JsonType.BOOLEAN) + .capture(DYNAMIC_NULL_GROUP) + .compareValues("*", ValueComparisonOperator.EQ, JsonType.NULL); + } + + @Override + public abstract Class getCommandClass(); + + @Override + public Operation resolveCommand(CommandContext commandContext, T command) { + return matchRules.apply(commandContext, command); + } + + protected static final class FilteringOptions { + private final int limit; + private final String pagingState; + + public FilteringOptions() { + this(0, null); + } + + public FilteringOptions(int limit, String pagingState) { + this.limit = limit; + this.pagingState = pagingState; + } + + public int getLimit() { + return limit; + } + + public String getPagingState() { + return pagingState; + } + } + + protected abstract Optional getFilteringOption(T command); + + private Operation findById(CommandContext commandContext, CaptureGroups captures) { + List filters = new ArrayList<>(); + + final CaptureGroup idGroup = + (CaptureGroup) captures.getGroupIfPresent(ID_GROUP); + if (idGroup != null) { + idGroup.consumeAllCaptures( + expression -> + filters.add( + new FindOperation.IDFilter( + FindOperation.IDFilter.Operator.EQ, expression.value()))); + } + Optional filteringOptions = getFilteringOption(captures.command()); + if (filteringOptions.isPresent()) { + return new FindOperation( + commandContext, + filters, + filteringOptions.get().pagingState, + filteringOptions.get().limit, + readDocument, + objectMapper); + } + throw new DocsException(ErrorCode.FILTER_UNRESOLVABLE); + } + + private Operation findNoFilter(CommandContext commandContext, CaptureGroups captures) { + Optional filteringOptions = getFilteringOption(captures.command()); + if (filteringOptions.isPresent()) { + return new FindOperation( + commandContext, + List.of(), + filteringOptions.get().pagingState, + filteringOptions.get().limit, + readDocument, + objectMapper); + } + throw new DocsException( + ErrorCode.FILTER_UNRESOLVABLE, "Options need to be returned for filterable of non findOne"); + } + + private Operation findDynamic(CommandContext commandContext, CaptureGroups captures) { + List filters = new ArrayList<>(); + + final CaptureGroup idGroup = + (CaptureGroup) captures.getGroupIfPresent(ID_GROUP); + if (idGroup != null) { + idGroup.consumeAllCaptures( + expression -> + filters.add( + new FindOperation.IDFilter( + FindOperation.IDFilter.Operator.EQ, expression.value()))); + } + + final CaptureGroup textGroup = + (CaptureGroup) captures.getGroupIfPresent(DYNAMIC_TEXT_GROUP); + if (textGroup != null) { + textGroup.consumeAllCaptures( + expression -> + filters.add( + new FindOperation.TextFilter( + expression.path(), + FindOperation.MapFilterBase.Operator.EQ, + expression.value()))); + } + + final CaptureGroup boolGroup = + (CaptureGroup) captures.getGroupIfPresent(DYNAMIC_BOOL_GROUP); + if (boolGroup != null) { + boolGroup.consumeAllCaptures( + expression -> + filters.add( + new FindOperation.BoolFilter( + expression.path(), + FindOperation.MapFilterBase.Operator.EQ, + expression.value()))); + } + + final CaptureGroup numberGroup = + (CaptureGroup) captures.getGroupIfPresent(DYNAMIC_NUMBER_GROUP); + if (numberGroup != null) { + numberGroup.consumeAllCaptures( + expression -> + filters.add( + new FindOperation.NumberFilter( + expression.path(), + FindOperation.MapFilterBase.Operator.EQ, + expression.value()))); + } + + final CaptureGroup nullGroup = + (CaptureGroup) captures.getGroupIfPresent(DYNAMIC_NULL_GROUP); + if (nullGroup != null) { + nullGroup.consumeAllCaptures( + expression -> filters.add(new FindOperation.IsNullFilter(expression.path()))); + } + + Optional filteringOptions = getFilteringOption(captures.command()); + if (filteringOptions.isPresent()) { + return new FindOperation( + commandContext, + filters, + filteringOptions.get().pagingState, + filteringOptions.get().limit, + readDocument, + objectMapper); + } + throw new DocsException( + ErrorCode.FILTER_UNRESOLVABLE, "Options need to be returned for filterable of non findOne"); + } +} diff --git a/src/test/java/io/stargate/sgv3/docsapi/api/v3/CollectionResourceIntegrationTest.java b/src/test/java/io/stargate/sgv3/docsapi/api/v3/CollectionResourceIntegrationTest.java index 8beb6e4b48..d320cf8936 100644 --- a/src/test/java/io/stargate/sgv3/docsapi/api/v3/CollectionResourceIntegrationTest.java +++ b/src/test/java/io/stargate/sgv3/docsapi/api/v3/CollectionResourceIntegrationTest.java @@ -2,8 +2,8 @@ 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.blankString; -import static org.hamcrest.Matchers.empty; import static org.hamcrest.Matchers.is; import static org.hamcrest.Matchers.not; @@ -16,6 +16,7 @@ import io.stargate.sgv2.common.testresource.StargateTestResource; import org.apache.commons.lang3.RandomStringUtils; import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.TestInstance; @@ -56,17 +57,105 @@ public final void createCollection() { @Nested class FindOne { + @BeforeEach + public void setUp() { + String json = + """ + { + "insertOne": { + "document": { + "_id": "doc1", + "username": "user1" + } + } + } + """; + + given() + .header(HttpConstants.AUTHENTICATION_TOKEN_HEADER_NAME, getAuthToken()) + .contentType(ContentType.JSON) + .body(json) + .when() + .post(CollectionResource.BASE_PATH, keyspaceId.asInternal(), collectionName) + .then() + .statusCode(200); + + json = + """ + { + "insertOne": { + "document": { + "_id": "doc2", + "username": "user2" + } + } + } + """; + + given() + .header(HttpConstants.AUTHENTICATION_TOKEN_HEADER_NAME, getAuthToken()) + .contentType(ContentType.JSON) + .body(json) + .when() + .post(CollectionResource.BASE_PATH, keyspaceId.asInternal(), collectionName) + .then() + .statusCode(200); + } + @Test - public void happyPath() { + public void findOneNoFilter() { String json = """ - { - "findOne": { - "sort": ["user.age"] - } - } - """; + { + "findOne": { + } + } + """; + String expected = "{\"username\": \"user1\"}"; + given() + .header(HttpConstants.AUTHENTICATION_TOKEN_HEADER_NAME, getAuthToken()) + .contentType(ContentType.JSON) + .body(json) + .when() + .post(CollectionResource.BASE_PATH, keyspaceId.asInternal(), collectionName) + .then() + .statusCode(200) + .body("data.count", is(1)); + } + @Test + public void findOneById() { + String json = + """ + { + "findOne": { + "filter" : {"_id" : "doc1"} + } + } + """; + String expected = "{\"username\": \"user1\"}"; + given() + .header(HttpConstants.AUTHENTICATION_TOKEN_HEADER_NAME, getAuthToken()) + .contentType(ContentType.JSON) + .body(json) + .when() + .post(CollectionResource.BASE_PATH, keyspaceId.asInternal(), collectionName) + .then() + .statusCode(200) + .body("data.docs[0]", jsonEquals(expected)); + } + + @Test + public void findOneByColumn() { + String json = + """ + { + "findOne": { + "filter" : {"username" : "user1"} + } + } + """; + String expected = "{\"username\": \"user1\"}"; given() .header(HttpConstants.AUTHENTICATION_TOKEN_HEADER_NAME, getAuthToken()) .contentType(ContentType.JSON) @@ -75,9 +164,7 @@ public void happyPath() { .post(CollectionResource.BASE_PATH, keyspaceId.asInternal(), collectionName) .then() .statusCode(200) - .body("errors", is(not(empty()))) - .body("errors[0].errorCode", is("COMMAND_NOT_IMPLEMENTED")) - .body("errors[0].message", is("The command FindOneCommand is not implemented.")); + .body("data.docs[0]", jsonEquals(expected)); } } @@ -90,8 +177,8 @@ public void insertDocument() { { "insertOne": { "document": { - "_id": "doc1", - "username": "aaron" + "_id": "doc3", + "username": "user3" } } } @@ -105,7 +192,7 @@ public void insertDocument() { .post(CollectionResource.BASE_PATH, keyspaceId.asInternal(), collectionName) .then() .statusCode(200) - .body("status.insertedIds[0]", is("doc1")); + .body("status.insertedIds[0]", is("doc3")); } @Test diff --git a/src/test/java/io/stargate/sgv3/docsapi/service/bridge/AbstractValidatingStargateBridgeTest.java b/src/test/java/io/stargate/sgv3/docsapi/service/bridge/AbstractValidatingStargateBridgeTest.java new file mode 100644 index 0000000000..75be49c5a2 --- /dev/null +++ b/src/test/java/io/stargate/sgv3/docsapi/service/bridge/AbstractValidatingStargateBridgeTest.java @@ -0,0 +1,62 @@ +/* + * Copyright The Stargate Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.stargate.sgv3.docsapi.service.bridge; + +import static org.mockito.Mockito.when; + +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.mockito.InjectMock; +import io.stargate.bridge.proto.QueryOuterClass; +import io.stargate.bridge.proto.StargateBridge; +import io.stargate.sgv2.api.common.StargateRequestInfo; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; + +/** + * Provides an infrastructure to mock {@link StargateBridge}. + * + *

Subclasses must be annotated with {@link QuarkusTest}. + */ +public abstract class AbstractValidatingStargateBridgeTest { + + protected ValidatingStargateBridge bridge; + @InjectMock protected StargateRequestInfo requestInfo; + + @BeforeEach + public void createBridge() { + bridge = new ValidatingStargateBridge(); + when(requestInfo.getStargateBridge()).thenReturn(bridge); + } + + @AfterEach + public void checkExpectedExecutions() { + bridge.validate(); + } + + protected ValidatingStargateBridge.QueryExpectation withQuery( + String cql, QueryOuterClass.Value... values) { + return bridge.withQuery(cql, values); + } + + protected ValidatingStargateBridge.QueryExpectation withAnySelectFrom( + String keyspace, String table) { + return bridge.withAnySelectFrom(keyspace, table); + } + + protected void resetExpectations() { + bridge.reset(); + } +} diff --git a/src/test/java/io/stargate/sgv3/docsapi/service/bridge/ValidatingPaginator.java b/src/test/java/io/stargate/sgv3/docsapi/service/bridge/ValidatingPaginator.java new file mode 100644 index 0000000000..6db5d2e376 --- /dev/null +++ b/src/test/java/io/stargate/sgv3/docsapi/service/bridge/ValidatingPaginator.java @@ -0,0 +1,102 @@ +/* + * Copyright The Stargate Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.stargate.sgv3.docsapi.service.bridge; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.nio.ByteBuffer; +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +/** + * @author Dmitri Bourlatchkov + * @author Ivan Senic + */ +public class ValidatingPaginator { + + public static final int MAGIC = 1020304050; + private final int pageSize; + private final int offset; + private int nextOffset = -1; + + private ValidatingPaginator(int pageSize, int offset) { + this.pageSize = pageSize; + this.offset = offset; + } + + public static ValidatingPaginator of(int pageSize) { + return new ValidatingPaginator(pageSize, -1); + } + + public static ValidatingPaginator of(int pageSize, Optional pagingState) { + int offset = + pagingState + .map( + buf -> { + int magic = buf.getInt(); + assertThat(magic).isEqualTo(MAGIC); + return buf.getInt(); + }) + .orElse(0); + + return new ValidatingPaginator(pageSize, offset); + } + + public List filter(List data) { + int from = Math.max(offset, 0); + if (from >= data.size()) { + return Collections.emptyList(); + } + + int to = from + pageSize; + if (to > data.size()) { + to = data.size(); + } else { + // Emulate C* behaviour - if the requested page ends exactly on or before the known data + // boundary we will return a non-null paging state because the data set is not technically + // "exhausted" yet. Only when we try and fail to fetch more data than available, we'll flag + // "end of data" by returning an empty paging state + nextOffset = to; + } + + return data.subList(from, to); + } + + public ByteBuffer pagingState() { + if (nextOffset < 0) { + return null; + } + + return pagingState(nextOffset); + } + + public ByteBuffer pagingStateForRow(int index) { + // for the row just current offset + // + index of the row + // + one to point to the next row + return pagingState(offset + index + 1); + } + + private static ByteBuffer pagingState(int offset) { + ByteBuffer buffer = ByteBuffer.allocate(8); + buffer.putInt(MAGIC); + buffer.putInt(offset); + buffer.rewind(); + return buffer; + } +} diff --git a/src/test/java/io/stargate/sgv3/docsapi/service/bridge/ValidatingStargateBridge.java b/src/test/java/io/stargate/sgv3/docsapi/service/bridge/ValidatingStargateBridge.java new file mode 100644 index 0000000000..dfbe898df4 --- /dev/null +++ b/src/test/java/io/stargate/sgv3/docsapi/service/bridge/ValidatingStargateBridge.java @@ -0,0 +1,376 @@ +package io.stargate.sgv3.docsapi.service.bridge; +/* + * Copyright The Stargate Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import static org.assertj.core.api.Assertions.assertThat; + +import com.google.protobuf.ByteString; +import com.google.protobuf.BytesValue; +import io.smallrye.mutiny.Uni; +import io.stargate.bridge.proto.QueryOuterClass; +import io.stargate.bridge.proto.Schema; +import io.stargate.bridge.proto.StargateBridge; +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Function; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.assertj.core.api.AbstractIntegerAssert; + +/** + * A mock bridge implementation for unit tests. + * + * @author Olivier Michallat + * @author Dmitri Bourlatchkov + * @author Ivan Senic + */ +public class ValidatingStargateBridge implements StargateBridge { + + private final List expectedQueries = new ArrayList<>(); + + public void reset() { + expectedQueries.clear(); + } + + public void validate() { + expectedQueries.forEach(QueryExpectation::validate); + } + + @Override + public Uni executeQuery(QueryOuterClass.Query query) { + QueryExpectation expectation = + findQueryExpectation(query.getCql(), query.getValues().getValuesList()); + return expectation.execute(query.getParameters()); + } + + @Override + public Uni executeQueryWithSchema( + Schema.QueryWithSchema request) { + // Simulate a successful execution (keyspace was up to date) + return executeQuery(request.getQuery()) + .map(response -> Schema.QueryWithSchemaResponse.newBuilder().setResponse(response).build()); + } + + @Override + public Uni executeBatch(QueryOuterClass.Batch batch) { + return batch.getQueriesList().stream() + .map( + query -> { + QueryExpectation expectation = + findQueryExpectation(query.getCql(), query.getValues().getValuesList()); + return expectation.execute(batch.getParameters(), batch.getType()); + }) + // Return the last result + .reduce((first, second) -> second) + .orElseThrow(() -> new AssertionError("Batch should have at least one query")); + } + + private QueryExpectation findQueryExpectation(String cql, List values) { + return expectedQueries.stream() + .filter(q -> q.matches(cql, values)) + .findFirst() + .orElseThrow( + () -> + new AssertionError( + String.format( + "Unexpected query, should have been mocked with withQuery(): %s, Values: %s", + cql, values))); + } + + @Override + public Uni describeKeyspace(Schema.DescribeKeyspaceQuery request) { + throw new UnsupportedOperationException("Not implemented by this mock"); + } + + @Override + public Uni authorizeSchemaReads( + Schema.AuthorizeSchemaReadsRequest request) { + throw new UnsupportedOperationException("Not implemented by this mock"); + } + + @Override + public Uni getSupportedFeatures( + Schema.SupportedFeaturesRequest request) { + throw new UnsupportedOperationException("Not implemented by this mock"); + } + + private QueryExpectation add(QueryExpectation expectation) { + expectedQueries.add(expectation); + return expectation; + } + + public QueryExpectation withQuery(String cql, QueryOuterClass.Value... values) { + return add(new QueryExpectation(Pattern.quote(cql), Arrays.asList(values))); + } + + public QueryExpectation withAnySelectFrom(String keyspace, String table) { + String regex = + """ + SELECT.*FROM.*\\"%s\\"\\.\\"%s\\".* + """.formatted(keyspace, table); + + return add(new QueryExpectation(regex, Collections.emptyList())); + } + + public abstract static class QueryAssert { + + private final AtomicInteger executeCount = new AtomicInteger(); + + public AbstractIntegerAssert assertExecuteCount() { + return assertThat(executeCount.get()); + } + + void executed() { + executeCount.incrementAndGet(); + } + } + + public static class QueryExpectation extends QueryAssert { + + private final Pattern cqlPattern; + private final List values; + private int pageSize = Integer.MAX_VALUE; + private QueryOuterClass.Batch.Type batchType; + private boolean enriched; + private QueryOuterClass.ResumeMode resumeMode; + private QueryOuterClass.Consistency consistency = QueryOuterClass.Consistency.LOCAL_QUORUM; + private List> rows; + private Iterable columnSpec; + private Function, ByteBuffer> comparableKey; + + private QueryExpectation(String cqlRegex, List values) { + this.cqlPattern = Pattern.compile(cqlRegex); + this.values = values; + } + + private QueryExpectation(String cqlRegex) { + this(cqlRegex, Collections.emptyList()); + } + + public QueryExpectation withPageSize(int pageSize) { + this.pageSize = pageSize; + return this; + } + + public QueryExpectation inBatch(QueryOuterClass.Batch.Type batchType) { + this.batchType = batchType; + return this; + } + + public QueryExpectation enriched() { + enriched = true; + return this; + } + + public QueryExpectation withResumeMode(QueryOuterClass.ResumeMode resumeMode) { + this.resumeMode = resumeMode; + return this; + } + + public QueryExpectation withConsistency(QueryOuterClass.Consistency consistency) { + this.consistency = consistency; + return this; + } + + public QueryExpectation withColumnSpec( + Iterable columnSpec) { + this.columnSpec = columnSpec; + return this; + } + + public QueryExpectation withComparableKey( + Function, ByteBuffer> comparableKey) { + this.comparableKey = comparableKey; + return this; + } + + public QueryAssert returningNothing() { + + return returning(Collections.emptyList()); + } + + public QueryAssert returning(List> rows) { + this.rows = rows; + return this; + } + + private boolean matches(String expectedCql, List expectedValues) { + Matcher matcher = cqlPattern.matcher(expectedCql); + boolean valuesEquals = expectedValues.equals(values); + boolean cqlMatches = matcher.matches(); + return cqlMatches && valuesEquals; + } + + private Uni execute( + QueryOuterClass.BatchParameters parameters, QueryOuterClass.Batch.Type actualBatchType) { + QueryOuterClass.Consistency actualConsistency = + Optional.ofNullable(parameters) + .filter(QueryOuterClass.BatchParameters::hasConsistency) + .map(p -> p.getConsistency().getValue()) + .orElse(null); + + return execute(actualBatchType, false, null, actualConsistency, null, null); + } + + private Uni execute(QueryOuterClass.QueryParameters parameters) { + Boolean actualEnriched = + Optional.ofNullable(parameters) + .map(QueryOuterClass.QueryParameters::getEnriched) + .orElse(false); + + QueryOuterClass.ResumeMode actualResumeMode = + Optional.ofNullable(parameters) + .filter(QueryOuterClass.QueryParameters::hasResumeMode) + .map(p -> p.getResumeMode().getValue()) + .orElse(null); + + QueryOuterClass.Consistency actualConsistency = + Optional.ofNullable(parameters) + .filter(QueryOuterClass.QueryParameters::hasConsistency) + .map(p -> p.getConsistency().getValue()) + .orElse(null); + + Integer actualPageSize = + Optional.ofNullable(parameters) + .filter(QueryOuterClass.QueryParameters::hasPageSize) + .map(p -> p.getPageSize().getValue()) + .orElse(null); + + BytesValue actualPagingState = + Optional.ofNullable(parameters) + .filter(QueryOuterClass.QueryParameters::hasPagingState) + .map(QueryOuterClass.QueryParameters::getPagingState) + .orElse(null); + + return execute( + null, + actualEnriched, + actualResumeMode, + actualConsistency, + actualPageSize, + actualPagingState); + } + + private Uni execute( + QueryOuterClass.Batch.Type actualBatchType, + Boolean actualEnriched, + QueryOuterClass.ResumeMode actualResumeMode, + QueryOuterClass.Consistency actualConsistency, + Integer actualPageSize, + BytesValue actualPagingState) { + + // assert batch type + assertThat(actualBatchType) + .as("Batch type for query %s", cqlPattern) + .isEqualTo(this.batchType); + + // assert enriched + assertThat(actualEnriched) + .as("Enriched flag for the query %s", cqlPattern) + .isEqualTo(this.enriched); + + // assert resume mode + assertThat(actualResumeMode) + .as("Resume mode for the query %s", cqlPattern) + .isEqualTo(this.resumeMode); + + // assert consistency + assertThat(actualConsistency) + .as( + "Consistency for the query %s not matching, actual %s, expected %s", + cqlPattern, actualConsistency, this.consistency) + .isEqualTo(this.consistency); + + // resolve and assert page size + int pageSizeUsed; + if (this.pageSize < Integer.MAX_VALUE) { + pageSizeUsed = this.pageSize; + assertThat(actualPageSize) + .as("Page size of %d expected, but query parameters are null.", pageSizeUsed) + .isNotNull(); + assertThat(actualPageSize) + .as("Page size mismatch, expected %d but actual was %d", pageSizeUsed, actualPageSize) + .isEqualTo(pageSizeUsed); + } else { + pageSizeUsed = Optional.ofNullable(actualPageSize).orElse(this.pageSize); + } + + // resolve the paging state + Optional pagingState = + Optional.ofNullable(actualPagingState) + .flatMap( + p -> { + ByteBuffer byteBuffer = actualPagingState.getValue().asReadOnlyByteBuffer(); + return Optional.of(byteBuffer); + }); + ValidatingPaginator paginator = ValidatingPaginator.of(pageSizeUsed, pagingState); + + // mark as executed + executed(); + + QueryOuterClass.ResultSet.Builder resultSet = QueryOuterClass.ResultSet.newBuilder(); + + // filter rows in order to respect the page size + // and get next paging state + List> filterRows = paginator.filter(rows); + ByteBuffer nextPagingState = paginator.pagingState(); + if (null != nextPagingState) { + resultSet.setPagingState( + BytesValue.newBuilder().setValue(ByteString.copyFrom(nextPagingState)).build()); + } + // for each filtered row + for (int i = 0; i < filterRows.size(); i++) { + // figure out the row and add all items + List row = filterRows.get(i); + QueryOuterClass.Row.Builder builder = QueryOuterClass.Row.newBuilder().addAllValues(row); + + // make the page state for a row + ByteBuffer rowPageState = paginator.pagingStateForRow(i); + if (null != rowPageState) { + builder.setPagingState( + BytesValue.newBuilder().setValue(ByteString.copyFrom(rowPageState)).build()); + } + + if (null != comparableKey) { + ByteString value = ByteString.copyFrom(comparableKey.apply(row)); + builder.setComparableBytes(BytesValue.newBuilder().setValue(value)); + } + + resultSet.addRows(builder); + } + + // if columns spec was defined, pass + if (null != columnSpec) { + resultSet.addAllColumns(columnSpec); + } + + return Uni.createFrom() + .item(QueryOuterClass.Response.newBuilder().setResultSet(resultSet).build()); + } + + private void validate() { + assertExecuteCount() + .withFailMessage( + "No queries were executed for this expected pattern: %s, values: %s", + cqlPattern, values) + .isGreaterThanOrEqualTo(1); + } + } +} diff --git a/src/test/java/io/stargate/sgv3/docsapi/service/operation/model/impl/FindOperationTest.java b/src/test/java/io/stargate/sgv3/docsapi/service/operation/model/impl/FindOperationTest.java new file mode 100644 index 0000000000..4a6f9bf9ea --- /dev/null +++ b/src/test/java/io/stargate/sgv3/docsapi/service/operation/model/impl/FindOperationTest.java @@ -0,0 +1,291 @@ +package io.stargate.sgv3.docsapi.service.operation.model.impl; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.TestProfile; +import io.stargate.bridge.grpc.TypeSpecs; +import io.stargate.bridge.grpc.Values; +import io.stargate.bridge.proto.QueryOuterClass; +import io.stargate.sgv2.common.testprofiles.NoGlobalResourcesTestProfile; +import io.stargate.sgv3.docsapi.api.model.command.CommandContext; +import io.stargate.sgv3.docsapi.api.model.command.CommandResult; +import io.stargate.sgv3.docsapi.service.bridge.AbstractValidatingStargateBridgeTest; +import io.stargate.sgv3.docsapi.service.bridge.ValidatingStargateBridge; +import io.stargate.sgv3.docsapi.service.bridge.config.DocumentConfig; +import io.stargate.sgv3.docsapi.service.bridge.executor.QueryExecutor; +import java.util.List; +import java.util.UUID; +import java.util.function.Supplier; +import javax.inject.Inject; +import org.apache.commons.lang3.RandomStringUtils; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@QuarkusTest +@TestProfile(NoGlobalResourcesTestProfile.Impl.class) +public class FindOperationTest extends AbstractValidatingStargateBridgeTest { + + private static final String KEYSPACE_NAME = RandomStringUtils.randomAlphanumeric(16); + private static final String COLLECTION_NAME = RandomStringUtils.randomAlphanumeric(16); + private CommandContext commandContext = new CommandContext(KEYSPACE_NAME, COLLECTION_NAME); + + @Inject QueryExecutor queryExecutor; + @Inject DocumentConfig documentConfig; + @Inject ObjectMapper objectMapper; + + @Nested + class FindOperationsTest { + + @Test + public void findAll() throws Exception { + String collectionReadCql = + "SELECT key, tx_id, doc_json FROM \"%s\".\"%s\" LIMIT %s" + .formatted(KEYSPACE_NAME, COLLECTION_NAME, 1000); + String doc1 = + """ + { + "_id": "doc1", + "username": "user1" + } + """; + String doc2 = + """ + { + "_id": "doc2", + "username": "user2" + } + """; + ValidatingStargateBridge.QueryAssert candidatesAssert = + withQuery(collectionReadCql) + .withPageSize(documentConfig.defaultPageSize()) + .withColumnSpec( + List.of( + QueryOuterClass.ColumnSpec.newBuilder() + .setName("key") + .setType(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("doc1"), Values.of(UUID.randomUUID()), Values.of(doc1)), + List.of(Values.of("doc2"), Values.of(UUID.randomUUID()), Values.of(doc2)))); + FindOperation findOperation = + new FindOperation(commandContext, List.of(), null, 1000, true, objectMapper); + final Supplier execute = + findOperation.execute(queryExecutor).subscribeAsCompletionStage().get(); + CommandResult result = execute.get(); + assertThat(result) + .satisfies( + commandResult -> { + assertThat(result.data()).isNotNull(); + assertThat(result.data().docs()).hasSize(2); + }); + } + + @Test + public void findWithId() throws Exception { + String collectionReadCql = + "SELECT key, tx_id, doc_json FROM \"%s\".\"%s\" WHERE key = ? LIMIT 1" + .formatted(KEYSPACE_NAME, COLLECTION_NAME); + String doc1 = + """ + { + "_id": "doc1", + "username": "user1" + } + """; + ValidatingStargateBridge.QueryAssert candidatesAssert = + withQuery(collectionReadCql, Values.of("doc1")) + .withPageSize(documentConfig.defaultPageSize()) + .withColumnSpec( + List.of( + QueryOuterClass.ColumnSpec.newBuilder() + .setName("key") + .setType(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("doc1"), Values.of(UUID.randomUUID()), Values.of(doc1)))); + FindOperation findOperation = + new FindOperation( + commandContext, + List.of(new FindOperation.IDFilter(FindOperation.IDFilter.Operator.EQ, "doc1")), + null, + 1, + true, + objectMapper); + final Supplier execute = + findOperation.execute(queryExecutor).subscribeAsCompletionStage().get(); + CommandResult result = execute.get(); + assertThat(result) + .satisfies( + commandResult -> { + assertThat(result.data()).isNotNull(); + assertThat(result.data().docs()).hasSize(1); + }); + } + + @Test + public void findWithIdNoData() throws Exception { + String collectionReadCql = + "SELECT key, tx_id, doc_json FROM \"%s\".\"%s\" WHERE key = ? LIMIT 1" + .formatted(KEYSPACE_NAME, COLLECTION_NAME); + ValidatingStargateBridge.QueryAssert candidatesAssert = + withQuery(collectionReadCql, Values.of("doc1")) + .withPageSize(documentConfig.defaultPageSize()) + .withColumnSpec( + List.of( + QueryOuterClass.ColumnSpec.newBuilder() + .setName("key") + .setType(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()); + FindOperation findOperation = + new FindOperation( + commandContext, + List.of(new FindOperation.IDFilter(FindOperation.IDFilter.Operator.EQ, "doc1")), + null, + 1, + true, + objectMapper); + final Supplier execute = + findOperation.execute(queryExecutor).subscribeAsCompletionStage().get(); + CommandResult result = execute.get(); + assertThat(result) + .satisfies( + commandResult -> { + assertThat(result.data()).isNotNull(); + assertThat(result.data().docs()).hasSize(0); + }); + } + + @Test + public void findWithDynamic() 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" + } + """; + ValidatingStargateBridge.QueryAssert candidatesAssert = + withQuery(collectionReadCql, Values.of("username"), Values.of("user1")) + .withPageSize(documentConfig.defaultPageSize()) + .withColumnSpec( + List.of( + QueryOuterClass.ColumnSpec.newBuilder() + .setName("key") + .setType(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("doc1"), Values.of(UUID.randomUUID()), Values.of(doc1)))); + FindOperation findOperation = + new FindOperation( + commandContext, + List.of( + new FindOperation.TextFilter( + "username", FindOperation.MapFilterBase.Operator.EQ, "user1")), + null, + 1, + true, + objectMapper); + final Supplier execute = + findOperation.execute(queryExecutor).subscribeAsCompletionStage().get(); + CommandResult result = execute.get(); + assertThat(result) + .satisfies( + commandResult -> { + assertThat(result.data()).isNotNull(); + assertThat(result.data().docs()).hasSize(1); + }); + } + + @Test + public void findWithNoResult() 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" + } + """; + ValidatingStargateBridge.QueryAssert candidatesAssert = + withQuery(collectionReadCql, Values.of("username"), Values.of("user1")) + .withPageSize(documentConfig.defaultPageSize()) + .withColumnSpec( + List.of( + QueryOuterClass.ColumnSpec.newBuilder() + .setName("key") + .setType(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()); + FindOperation findOperation = + new FindOperation( + commandContext, + List.of( + new FindOperation.TextFilter( + "username", FindOperation.MapFilterBase.Operator.EQ, "user1")), + null, + 1, + true, + objectMapper); + final Supplier execute = + findOperation.execute(queryExecutor).subscribeAsCompletionStage().get(); + CommandResult result = execute.get(); + assertThat(result) + .satisfies( + commandResult -> { + assertThat(result.data()).isNotNull(); + assertThat(result.data().docs()).hasSize(0); + }); + } + } +} diff --git a/src/test/java/io/stargate/sgv3/docsapi/service/resolver/model/impl/FindOneCommandResolverTest.java b/src/test/java/io/stargate/sgv3/docsapi/service/resolver/model/impl/FindOneCommandResolverTest.java new file mode 100644 index 0000000000..7f891cb921 --- /dev/null +++ b/src/test/java/io/stargate/sgv3/docsapi/service/resolver/model/impl/FindOneCommandResolverTest.java @@ -0,0 +1,126 @@ +package io.stargate.sgv3.docsapi.service.resolver.model.impl; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.TestProfile; +import io.stargate.sgv2.common.testprofiles.NoGlobalResourcesTestProfile; +import io.stargate.sgv3.docsapi.api.model.command.CommandContext; +import io.stargate.sgv3.docsapi.api.model.command.impl.FindOneCommand; +import io.stargate.sgv3.docsapi.service.operation.model.Operation; +import io.stargate.sgv3.docsapi.service.operation.model.impl.FindOperation; +import java.util.List; +import javax.inject.Inject; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@QuarkusTest +@TestProfile(NoGlobalResourcesTestProfile.Impl.class) +public class FindOneCommandResolverTest { + @Inject ObjectMapper objectMapper; + + @Nested + class FindOneCommandResolveCommand { + + @Test + public void idFilterCondition() throws Exception { + String json = + """ + { + "findOne": { + "sort": [ + "user.name", + "-user.age" + ], + "filter" : {"_id" : "id"} + } + } + """; + + FindOneCommand findOneCommand = objectMapper.readValue(json, FindOneCommand.class); + final CommandContext commandContext = new CommandContext("database", "collection"); + final Operation operation = + new FindOneCommandResolver().resolveCommand(commandContext, findOneCommand); + FindOperation expected = + new FindOperation( + commandContext, + List.of(new FindOperation.IDFilter(FindOperation.IDFilter.Operator.EQ, "id")), + null, + 1, + true, + objectMapper); + assertThat(operation) + .isInstanceOf(FindOperation.class) + .satisfies( + op -> { + assertThat(op).isEqualTo(expected); + }); + } + + @Test + public void noFilterCondition() throws Exception { + String json = + """ + { + "findOne": { + "sort": [ + "user.name", + "-user.age" + ] + } + } + """; + + FindOneCommand findOneCommand = objectMapper.readValue(json, FindOneCommand.class); + final CommandContext commandContext = new CommandContext("database", "collection"); + final Operation operation = + new FindOneCommandResolver().resolveCommand(commandContext, findOneCommand); + FindOperation expected = + new FindOperation(commandContext, List.of(), null, 1, true, objectMapper); + assertThat(operation) + .isInstanceOf(FindOperation.class) + .satisfies( + op -> { + assertThat(op).isEqualTo(expected); + }); + } + + @Test + public void dynamicFilterCondition() throws Exception { + String json = + """ + { + "findOne": { + "sort": [ + "user.name", + "-user.age" + ], + "filter" : {"col" : "val"} + } + } + """; + + FindOneCommand findOneCommand = objectMapper.readValue(json, FindOneCommand.class); + final CommandContext commandContext = new CommandContext("database", "collection"); + final Operation operation = + new FindOneCommandResolver().resolveCommand(commandContext, findOneCommand); + FindOperation expected = + new FindOperation( + commandContext, + List.of( + new FindOperation.TextFilter( + "col", FindOperation.MapFilterBase.Operator.EQ, "val")), + null, + 1, + true, + objectMapper); + assertThat(operation) + .isInstanceOf(FindOperation.class) + .satisfies( + op -> { + assertThat(op).isEqualTo(expected); + }); + } + } +} diff --git a/src/test/java/io/stargate/sgv3/docsapi/service/resolver/model/impl/matcher/CaptureGroupTest.java b/src/test/java/io/stargate/sgv3/docsapi/service/resolver/model/impl/matcher/CaptureGroupTest.java new file mode 100644 index 0000000000..cfbe05de22 --- /dev/null +++ b/src/test/java/io/stargate/sgv3/docsapi/service/resolver/model/impl/matcher/CaptureGroupTest.java @@ -0,0 +1,61 @@ +package io.stargate.sgv3.docsapi.service.resolver.model.impl.matcher; + +import static org.assertj.core.api.Assertions.assertThat; + +import io.stargate.sgv3.docsapi.api.model.command.clause.filter.FilterOperation; +import io.stargate.sgv3.docsapi.api.model.command.clause.filter.JsonLiteral; +import io.stargate.sgv3.docsapi.api.model.command.clause.filter.JsonType; +import io.stargate.sgv3.docsapi.api.model.command.clause.filter.ValueComparisonOperation; +import io.stargate.sgv3.docsapi.api.model.command.clause.filter.ValueComparisonOperator; +import java.util.AbstractMap; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +public class CaptureGroupTest { + @Nested + class CaptureGroupCreator { + + @Test + public void withCaptureToAddCaptures() throws Exception { + List filters = + List.of( + new ValueComparisonOperation( + ValueComparisonOperator.EQ, new JsonLiteral("abc", JsonType.STRING))); + Map.Entry> expected = + new AbstractMap.SimpleEntry>("test", filters); + CaptureGroup captureGroup = new CaptureGroup(new HashMap<>()); + captureGroup.withCapture("test", filters); + + assertThat(captureGroup.captures().entrySet()).contains(expected); + } + } + + @Nested + class CaptureGroupDBClause { + + @Test + public void addAllCaptures() throws Exception { + List> filters = + List.of( + new ValueComparisonOperation( + ValueComparisonOperator.EQ, new JsonLiteral("val1", JsonType.STRING)), + new ValueComparisonOperation( + ValueComparisonOperator.EQ, new JsonLiteral("val2", JsonType.STRING))); + CaptureGroup captureGroup = + new CaptureGroup(new HashMap>>()); + captureGroup.withCapture("test", filters); + final List response = new ArrayList<>(); + String expected1 = "test:EQ:val1"; + String expected2 = "test:EQ:val2"; + captureGroup.consumeAllCaptures( + consumer -> + response.add( + consumer.path() + ":" + consumer.operator().toString() + ":" + consumer.value())); + assertThat(response).contains(expected1, expected2); + } + } +} diff --git a/src/test/java/io/stargate/sgv3/docsapi/service/resolver/model/impl/matcher/CaptureGroupsTest.java b/src/test/java/io/stargate/sgv3/docsapi/service/resolver/model/impl/matcher/CaptureGroupsTest.java new file mode 100644 index 0000000000..2591bb931f --- /dev/null +++ b/src/test/java/io/stargate/sgv3/docsapi/service/resolver/model/impl/matcher/CaptureGroupsTest.java @@ -0,0 +1,63 @@ +package io.stargate.sgv3.docsapi.service.resolver.model.impl.matcher; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.TestProfile; +import io.stargate.sgv2.common.testprofiles.NoGlobalResourcesTestProfile; +import io.stargate.sgv3.docsapi.api.model.command.Command; +import io.stargate.sgv3.docsapi.api.model.command.clause.filter.FilterOperation; +import io.stargate.sgv3.docsapi.api.model.command.clause.filter.JsonLiteral; +import io.stargate.sgv3.docsapi.api.model.command.clause.filter.JsonType; +import io.stargate.sgv3.docsapi.api.model.command.clause.filter.ValueComparisonOperation; +import io.stargate.sgv3.docsapi.api.model.command.clause.filter.ValueComparisonOperator; +import java.util.AbstractMap; +import java.util.List; +import java.util.Map; +import javax.inject.Inject; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@QuarkusTest +@TestProfile(NoGlobalResourcesTestProfile.Impl.class) +public class CaptureGroupsTest { + @Inject ObjectMapper objectMapper; + + @Nested + class CaptureGroupCreator { + + @Test + public void getGroupTest() throws Exception { + String json = + """ + { + "findOne": { + "sort": [ + "user.name", + "-user.age" + ], + "filter": {"username": "aaron"} + } + } + """; + + Command result = objectMapper.readValue(json, Command.class); + CaptureGroups captureGroups = new CaptureGroups(result); + + CaptureGroup captureGroup = captureGroups.getGroup("TEST"); + assertThat(captureGroup.captures().keySet()).hasSize(0); + + List filters = + List.of( + new ValueComparisonOperation( + ValueComparisonOperator.EQ, new JsonLiteral("abc", JsonType.STRING))); + Map.Entry> expected = + new AbstractMap.SimpleEntry>("test", filters); + captureGroup.withCapture("test", filters); + + captureGroup = captureGroups.getGroup("TEST"); + assertThat(captureGroup.captures().entrySet()).contains(expected); + } + } +} diff --git a/src/test/java/io/stargate/sgv3/docsapi/service/resolver/model/impl/matcher/FilterMatchRuleTest.java b/src/test/java/io/stargate/sgv3/docsapi/service/resolver/model/impl/matcher/FilterMatchRuleTest.java new file mode 100644 index 0000000000..802211c010 --- /dev/null +++ b/src/test/java/io/stargate/sgv3/docsapi/service/resolver/model/impl/matcher/FilterMatchRuleTest.java @@ -0,0 +1,74 @@ +package io.stargate.sgv3.docsapi.service.resolver.model.impl.matcher; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.TestProfile; +import io.smallrye.mutiny.Uni; +import io.stargate.sgv2.common.testprofiles.NoGlobalResourcesTestProfile; +import io.stargate.sgv3.docsapi.api.model.command.CommandContext; +import io.stargate.sgv3.docsapi.api.model.command.CommandResult; +import io.stargate.sgv3.docsapi.api.model.command.clause.filter.JsonType; +import io.stargate.sgv3.docsapi.api.model.command.clause.filter.ValueComparisonOperator; +import io.stargate.sgv3.docsapi.api.model.command.impl.FindOneCommand; +import io.stargate.sgv3.docsapi.service.bridge.executor.QueryExecutor; +import io.stargate.sgv3.docsapi.service.operation.model.Operation; +import java.util.Optional; +import java.util.function.BiFunction; +import java.util.function.Supplier; +import javax.inject.Inject; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@QuarkusTest +@TestProfile(NoGlobalResourcesTestProfile.Impl.class) +public class FilterMatchRuleTest { + @Inject ObjectMapper objectMapper; + + @Nested + class FilterMatchRuleApply { + @Test + public void apply() throws Exception { + String json = + """ + { + "findOne": { + "sort": [ + "user.name", + "-user.age" + ], + "filter" : {"col" : "val"} + } + } + """; + + FindOneCommand findOneCommand = objectMapper.readValue(json, FindOneCommand.class); + FilterMatcher matcher = + new FilterMatcher<>(FilterMatcher.MatchStrategy.GREEDY); + matcher.capture("CAPTURE 1").compareValues("*", ValueComparisonOperator.EQ, JsonType.STRING); + BiFunction, Operation> resolveFunction = + (commandContext, captures) -> { + return new Operation() { + @Override + public Uni> execute(QueryExecutor queryExecutor) { + return null; + } + }; + }; + + FilterMatchRule filterMatchRule = + new FilterMatchRule(matcher, resolveFunction); + Optional response = + filterMatchRule.apply(new CommandContext("database", "collection"), findOneCommand); + assertThat(response).isPresent(); + + matcher = new FilterMatcher<>(FilterMatcher.MatchStrategy.GREEDY); + matcher.capture("CAPTURE 1").compareValues("*", ValueComparisonOperator.EQ, JsonType.NULL); + filterMatchRule = new FilterMatchRule(matcher, resolveFunction); + response = + filterMatchRule.apply(new CommandContext("database", "collection"), findOneCommand); + assertThat(response).isEmpty(); + } + } +} diff --git a/src/test/java/io/stargate/sgv3/docsapi/service/resolver/model/impl/matcher/FilterMatchRulesTest.java b/src/test/java/io/stargate/sgv3/docsapi/service/resolver/model/impl/matcher/FilterMatchRulesTest.java new file mode 100644 index 0000000000..ced422092b --- /dev/null +++ b/src/test/java/io/stargate/sgv3/docsapi/service/resolver/model/impl/matcher/FilterMatchRulesTest.java @@ -0,0 +1,127 @@ +package io.stargate.sgv3.docsapi.service.resolver.model.impl.matcher; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.TestProfile; +import io.smallrye.mutiny.Uni; +import io.stargate.sgv2.common.testprofiles.NoGlobalResourcesTestProfile; +import io.stargate.sgv3.docsapi.api.model.command.CommandContext; +import io.stargate.sgv3.docsapi.api.model.command.CommandResult; +import io.stargate.sgv3.docsapi.api.model.command.clause.filter.JsonType; +import io.stargate.sgv3.docsapi.api.model.command.clause.filter.ValueComparisonOperator; +import io.stargate.sgv3.docsapi.api.model.command.impl.FindOneCommand; +import io.stargate.sgv3.docsapi.service.bridge.executor.QueryExecutor; +import io.stargate.sgv3.docsapi.service.operation.model.Operation; +import java.util.function.BiFunction; +import java.util.function.Supplier; +import javax.inject.Inject; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@QuarkusTest +@TestProfile(NoGlobalResourcesTestProfile.Impl.class) +public class FilterMatchRulesTest { + @Inject ObjectMapper objectMapper; + + @Nested + class FilterMatchRulesApply { + @Test + public void apply() throws Exception { + String json = + """ + { + "findOne": { + "sort": [ + "user.name", + "-user.age" + ], + "filter" : {"col" : "val"} + } + } + """; + + FindOneCommand findOneCommand = objectMapper.readValue(json, FindOneCommand.class); + FilterMatchRules filterMatchRules = new FilterMatchRules(); + BiFunction, Operation> resolveFunction = + (commandContext, captures) -> { + return new Operation() { + @Override + public Uni> execute(QueryExecutor queryExecutor) { + return null; + } + }; + }; + filterMatchRules + .addMatchRule(resolveFunction, FilterMatcher.MatchStrategy.EMPTY) + .matcher() + .capture("EMPTY"); + filterMatchRules + .addMatchRule(resolveFunction, FilterMatcher.MatchStrategy.GREEDY) + .matcher() + .capture("TEST1") + .compareValues("*", ValueComparisonOperator.EQ, JsonType.STRING); + + Operation response = + filterMatchRules.apply(new CommandContext("database", "collection"), findOneCommand); + assertThat(response).isNotNull(); + + json = + """ + { + "findOne": { + "sort": [ + "user.name", + "-user.age" + ] + } + } + """; + + findOneCommand = objectMapper.readValue(json, FindOneCommand.class); + response = + filterMatchRules.apply(new CommandContext("database", "collection"), findOneCommand); + assertThat(response).isNotNull(); + } + + @Test + public void addMatchRule() throws Exception { + String json = + """ + { + "findOne": { + "sort": [ + "user.name", + "-user.age" + ], + "filter" : {"col" : "val"} + } + } + """; + + FindOneCommand findOneCommand = objectMapper.readValue(json, FindOneCommand.class); + FilterMatchRules filterMatchRules = new FilterMatchRules(); + BiFunction, Operation> resolveFunction = + (commandContext, captures) -> { + return new Operation() { + @Override + public Uni> execute(QueryExecutor queryExecutor) { + return null; + } + }; + }; + filterMatchRules + .addMatchRule(resolveFunction, FilterMatcher.MatchStrategy.EMPTY) + .matcher() + .capture("EMPTY"); + filterMatchRules + .addMatchRule(resolveFunction, FilterMatcher.MatchStrategy.GREEDY) + .matcher() + .capture("TEST1") + .compareValues("*", ValueComparisonOperator.EQ, JsonType.STRING); + + assertThat(filterMatchRules.getMatchRules()).hasSize(2); + } + } +} diff --git a/src/test/java/io/stargate/sgv3/docsapi/service/resolver/model/impl/matcher/FilterMatcherTest.java b/src/test/java/io/stargate/sgv3/docsapi/service/resolver/model/impl/matcher/FilterMatcherTest.java new file mode 100644 index 0000000000..8ee65cb01d --- /dev/null +++ b/src/test/java/io/stargate/sgv3/docsapi/service/resolver/model/impl/matcher/FilterMatcherTest.java @@ -0,0 +1,209 @@ +package io.stargate.sgv3.docsapi.service.resolver.model.impl.matcher; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.TestProfile; +import io.stargate.sgv2.common.testprofiles.NoGlobalResourcesTestProfile; +import io.stargate.sgv3.docsapi.api.model.command.clause.filter.JsonType; +import io.stargate.sgv3.docsapi.api.model.command.clause.filter.ValueComparisonOperator; +import io.stargate.sgv3.docsapi.api.model.command.impl.FindOneCommand; +import java.util.Optional; +import javax.inject.Inject; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@QuarkusTest +@TestProfile(NoGlobalResourcesTestProfile.Impl.class) +public class FilterMatcherTest { + @Inject ObjectMapper objectMapper; + + @Nested + class FilterMatcherEmptyApply { + @Test + public void applyWithNoFilter() throws Exception { + String json = + """ + { + "findOne": { + } + } + """; + + FindOneCommand findOneCommand = objectMapper.readValue(json, FindOneCommand.class); + FilterMatcher matcher = + new FilterMatcher<>(FilterMatcher.MatchStrategy.EMPTY); + final Optional> response = matcher.apply(findOneCommand); + assertThat(response.isPresent()).isTrue(); + } + + @Test + public void applyWithFilter() throws Exception { + String json = + """ + { + "findOne": { + "sort": [ + "user.name", + "-user.age" + ], + "filter" : {"col" : "val"} + } + } + """; + + FindOneCommand findOneCommand = objectMapper.readValue(json, FindOneCommand.class); + FilterMatcher matcher = + new FilterMatcher<>(FilterMatcher.MatchStrategy.EMPTY); + final Optional> response = matcher.apply(findOneCommand); + assertThat(response.isPresent()).isFalse(); + } + } + + @Nested + class FilterMatcherStrictApply { + @Test + public void applyWithNoFilter() throws Exception { + String json = + """ + { + "findOne": { + "sort": [ + "user.name", + "-user.age" + ] + } + } + """; + + FindOneCommand findOneCommand = objectMapper.readValue(json, FindOneCommand.class); + FilterMatcher matcher = + new FilterMatcher<>(FilterMatcher.MatchStrategy.STRICT); + matcher.capture("TEST").compareValues("*", ValueComparisonOperator.EQ, JsonType.STRING); + final Optional> response = matcher.apply(findOneCommand); + assertThat(response.isPresent()).isFalse(); + } + + @Test + public void applyWithFilterMatch() throws Exception { + String json = + """ + { + "findOne": { + "sort": [ + "user.name", + "-user.age" + ], + "filter" : {"col" : "val", "col2" : 10} + } + } + """; + + FindOneCommand findOneCommand = objectMapper.readValue(json, FindOneCommand.class); + FilterMatcher matcher = + new FilterMatcher<>(FilterMatcher.MatchStrategy.STRICT); + matcher.capture("CAPTURE 1").compareValues("*", ValueComparisonOperator.EQ, JsonType.STRING); + matcher.capture("CAPTURE 2").compareValues("*", ValueComparisonOperator.EQ, JsonType.NUMBER); + final Optional> response = matcher.apply(findOneCommand); + assertThat(response.isPresent()).isTrue(); + } + + @Test + public void applyWithFilterNotMatch() throws Exception { + String json = + """ + { + "findOne": { + "sort": [ + "user.name", + "-user.age" + ], + "filter" : {"col" : "val"} + } + } + """; + + FindOneCommand findOneCommand = objectMapper.readValue(json, FindOneCommand.class); + FilterMatcher matcher = + new FilterMatcher<>(FilterMatcher.MatchStrategy.STRICT); + matcher.capture("CAPTURE 1").compareValues("*", ValueComparisonOperator.EQ, JsonType.STRING); + matcher.capture("CAPTURE 2").compareValues("*", ValueComparisonOperator.EQ, JsonType.NUMBER); + final Optional> response = matcher.apply(findOneCommand); + assertThat(response.isPresent()).isFalse(); + } + } + + @Nested + class FilterMatcherGreedyApply { + @Test + public void applyWithNoFilter() throws Exception { + String json = + """ + { + "findOne": { + "sort": [ + "user.name", + "-user.age" + ] + } + } + """; + + FindOneCommand findOneCommand = objectMapper.readValue(json, FindOneCommand.class); + FilterMatcher matcher = + new FilterMatcher<>(FilterMatcher.MatchStrategy.GREEDY); + matcher.capture("TEST").compareValues("*", ValueComparisonOperator.EQ, JsonType.STRING); + final Optional> response = matcher.apply(findOneCommand); + assertThat(response.isPresent()).isFalse(); + } + + @Test + public void applyWithFilterMatch() throws Exception { + String json = + """ + { + "findOne": { + "sort": [ + "user.name", + "-user.age" + ], + "filter" : {"col" : "val", "col2" : 10} + } + } + """; + + FindOneCommand findOneCommand = objectMapper.readValue(json, FindOneCommand.class); + FilterMatcher matcher = + new FilterMatcher<>(FilterMatcher.MatchStrategy.GREEDY); + matcher.capture("CAPTURE 1").compareValues("*", ValueComparisonOperator.EQ, JsonType.STRING); + matcher.capture("CAPTURE 2").compareValues("*", ValueComparisonOperator.EQ, JsonType.NUMBER); + final Optional> response = matcher.apply(findOneCommand); + assertThat(response.isPresent()).isTrue(); + } + + @Test + public void applyWithFilterNoMatch() throws Exception { + String json = + """ + { + "findOne": { + "sort": [ + "user.name", + "-user.age" + ], + "filter" : {"col" : "val"} + } + } + """; + + FindOneCommand findOneCommand = objectMapper.readValue(json, FindOneCommand.class); + FilterMatcher matcher = + new FilterMatcher<>(FilterMatcher.MatchStrategy.GREEDY); + matcher.capture("CAPTURE 1").compareValues("*", ValueComparisonOperator.EQ, JsonType.STRING); + matcher.capture("CAPTURE 2").compareValues("*", ValueComparisonOperator.EQ, JsonType.NUMBER); + final Optional> response = matcher.apply(findOneCommand); + assertThat(response.isPresent()).isTrue(); + } + } +}