From 313b44002c798fba81e2142eb19e418e83ea1303 Mon Sep 17 00:00:00 2001 From: Vladimir Orany Date: Fri, 17 May 2024 16:44:40 +0200 Subject: [PATCH] More Powerful Query Methods (#230) * More Powerful Query Methods * wait for data to be persisted * using unique table names for tests --- docs/guide/src/docs/asciidoc/dynamodb.adoc | 23 +- .../awssdk/dynamodb/ServiceIntroduction.java | 137 ++++++++- .../dynamodb/annotation/Consistent.java | 39 +++ .../dynamodb/annotation/Descending.java | 38 +++ .../awssdk/dynamodb/annotation/Filter.java | 274 ++++++++++++++++++ .../awssdk/dynamodb/annotation/Index.java | 39 +++ ...AdvancedQueryOnDeclarativeServiceTest.java | 85 ++++++ .../dynamodb/DeclarativeServiceTest.java | 3 + .../dynamodb/DynamoDBEntityService.java | 104 ++++++- .../awssdk/dynamodb/DynamoDBLocalTest.java | 1 + .../annotation/FilterOperatorSpec.groovy | 257 ++++++++++++++++ 11 files changed, 983 insertions(+), 17 deletions(-) create mode 100644 subprojects/micronaut-amazon-awssdk-dynamodb/src/main/java/com/agorapulse/micronaut/amazon/awssdk/dynamodb/annotation/Consistent.java create mode 100644 subprojects/micronaut-amazon-awssdk-dynamodb/src/main/java/com/agorapulse/micronaut/amazon/awssdk/dynamodb/annotation/Descending.java create mode 100644 subprojects/micronaut-amazon-awssdk-dynamodb/src/main/java/com/agorapulse/micronaut/amazon/awssdk/dynamodb/annotation/Filter.java create mode 100644 subprojects/micronaut-amazon-awssdk-dynamodb/src/main/java/com/agorapulse/micronaut/amazon/awssdk/dynamodb/annotation/Index.java create mode 100644 subprojects/micronaut-amazon-awssdk-dynamodb/src/test/groovy/com/agorapulse/micronaut/amazon/awssdk/dynamodb/AdvancedQueryOnDeclarativeServiceTest.java create mode 100644 subprojects/micronaut-amazon-awssdk-dynamodb/src/test/groovy/com/agorapulse/micronaut/amazon/awssdk/dynamodb/annotation/FilterOperatorSpec.groovy diff --git a/docs/guide/src/docs/asciidoc/dynamodb.adoc b/docs/guide/src/docs/asciidoc/dynamodb.adoc index 542d4c73b..7581336c9 100644 --- a/docs/guide/src/docs/asciidoc/dynamodb.adoc +++ b/docs/guide/src/docs/asciidoc/dynamodb.adoc @@ -564,6 +564,28 @@ Instead you can annotate any method with `@Query` annotation to make it * batch delete method if its name begins with `delete` * otherwise an advanced query method +For DynamoDB v2 you can use `@Index`, `@Consistent` `@Descending` and `@Fitler` annotations to further customize the query without the need of creating a custom query class and use it with `@Query` annotation. + +[source,java,indent=0,options="nowrap"] +.Advanced Query Annotations +---- +include::{root-dir}/subprojects/micronaut-amazon-awssdk-dynamodb/src/test/groovy/com/agorapulse/micronaut/amazon/awssdk/dynamodb/DynamoDBEntityService.java[tags=advanced-query-methods] +---- +<1> You can use `@Consistent` annotation to make the query consistent +<2> You can use `@Descending` annotation to sort the results in descending order +<3> You can use `@Index` annotation to specify the index to use to query the table +<4> Any parameters that are not partition or sort keys are used as filter conditions +<5> If the parameter is annotated with `@Nullable` then the filter condition is only applied when the parameter is not `null` +<6> You can use `@Filter` annotation to specify the filter condition operator +<7> You can use `@Filter` annotation to specify the attribute name +<8> You can combine `@SortKey` and `@Filter` annotations to specify the sort key condition +<9> Only`EQ`, `LE`, `LT`, `GE`, `GT`, `BETWEEN` and `BEGINS_WITH` operators are supported +<10> You can also use `@Filter` annotation to specify the sort key name + +TIP: The operator `EQ` is used by default if `@Filter` annotation is not present. This makes it special and the service introduction tries to find the appropriate operation based on the actual value. For collections or arrays, `inList` operation is actually used. If the actual value is `null` then `isNull` operation is used. For other types, `eq` operation is used. For sort keys, `eq` operation is always used. + +For more complex queries you can use `@Query` annotation with a class implementing `QueryFunction` interface: + [source,groovy,indent=0,options="nowrap",role="primary"] .Groovy (AWS SDK 2.x) ---- @@ -581,7 +603,6 @@ include::{root-dir}/subprojects/micronaut-amazon-awssdk-dynamodb/src/test/groovy <7> Only `rangeIndex` property will be populated in the entities returned <8> The arguments have no special meaning but you can use them in the query. The method must return either `Publisher` or `List` of entities. - [source,java,indent=0,options="nowrap",role="secondary"] .Java ---- diff --git a/subprojects/micronaut-amazon-awssdk-dynamodb/src/main/java/com/agorapulse/micronaut/amazon/awssdk/dynamodb/ServiceIntroduction.java b/subprojects/micronaut-amazon-awssdk-dynamodb/src/main/java/com/agorapulse/micronaut/amazon/awssdk/dynamodb/ServiceIntroduction.java index 97100b5bf..dfd4763bf 100644 --- a/subprojects/micronaut-amazon-awssdk-dynamodb/src/main/java/com/agorapulse/micronaut/amazon/awssdk/dynamodb/ServiceIntroduction.java +++ b/subprojects/micronaut-amazon-awssdk-dynamodb/src/main/java/com/agorapulse/micronaut/amazon/awssdk/dynamodb/ServiceIntroduction.java @@ -17,7 +17,11 @@ */ package com.agorapulse.micronaut.amazon.awssdk.dynamodb; +import com.agorapulse.micronaut.amazon.awssdk.dynamodb.annotation.Consistent; +import com.agorapulse.micronaut.amazon.awssdk.dynamodb.annotation.Descending; +import com.agorapulse.micronaut.amazon.awssdk.dynamodb.annotation.Filter; import com.agorapulse.micronaut.amazon.awssdk.dynamodb.annotation.HashKey; +import com.agorapulse.micronaut.amazon.awssdk.dynamodb.annotation.Index; import com.agorapulse.micronaut.amazon.awssdk.dynamodb.annotation.PartitionKey; import com.agorapulse.micronaut.amazon.awssdk.dynamodb.annotation.Query; import com.agorapulse.micronaut.amazon.awssdk.dynamodb.annotation.RangeKey; @@ -25,9 +29,11 @@ import com.agorapulse.micronaut.amazon.awssdk.dynamodb.annotation.Service; import com.agorapulse.micronaut.amazon.awssdk.dynamodb.annotation.SortKey; import com.agorapulse.micronaut.amazon.awssdk.dynamodb.annotation.Update; +import com.agorapulse.micronaut.amazon.awssdk.dynamodb.builder.Builders; import com.agorapulse.micronaut.amazon.awssdk.dynamodb.builder.DetachedQuery; import com.agorapulse.micronaut.amazon.awssdk.dynamodb.builder.DetachedScan; import com.agorapulse.micronaut.amazon.awssdk.dynamodb.builder.DetachedUpdate; +import com.agorapulse.micronaut.amazon.awssdk.dynamodb.builder.QueryBuilder; import com.agorapulse.micronaut.amazon.awssdk.dynamodb.builder.UpdateBuilder; import io.micronaut.aop.MethodInterceptor; import io.micronaut.aop.MethodInvocationContext; @@ -35,13 +41,15 @@ import io.micronaut.core.async.publisher.Publishers; import io.micronaut.core.type.Argument; import io.micronaut.core.type.MutableArgumentValue; +import jakarta.inject.Singleton; import org.reactivestreams.Publisher; import reactor.core.publisher.Flux; import software.amazon.awssdk.services.dynamodb.model.ResourceNotFoundException; -import jakarta.inject.Singleton; import java.util.Collections; +import java.util.LinkedHashMap; import java.util.Map; +import java.util.function.Consumer; /** * Introduction for {@link com.agorapulse.micronaut.amazon.awssdk.dynamodb.annotation.Service} annotation. @@ -54,9 +62,19 @@ public class ServiceIntroduction implements MethodInterceptor { private static final String HASH = "hash"; private static final String RANGE = "range"; - private static class PartitionAndSort { + private static class FilterArgument { + Argument firstArgument; + Argument secondArgument; + String name; + boolean required; + Filter.Operator operator; + } + + private static class QueryArguments { Argument partitionKey; - Argument sortKey; + FilterArgument sortKey; + Map filters = new LinkedHashMap<>(); + boolean isValid() { return partitionKey != null; @@ -67,11 +85,11 @@ Object getPartitionValue(Map> params) { } Object getSortValue(Map> params) { - return sortKey == null ? null : params.get(sortKey.getName()).getValue(); + return sortKey == null ? null : params.get(sortKey.firstArgument.getName()).getValue(); } Publisher getSortAttributeValues(Map> params) { - return sortKey == null ? Flux.empty() : toPublisher(Object.class, sortKey, params); + return sortKey == null ? Flux.empty() : toPublisher(Object.class, sortKey.firstArgument, params); } } @@ -110,7 +128,7 @@ private static Publisher toPublisher(Class type, Argument itemArgum } if (itemArgument.getType().isArray() && type.isAssignableFrom(itemArgument.getType().getComponentType())) { - return Flux.fromArray((T[])item); + return Flux.fromArray((T[]) item); } if (Iterable.class.isAssignableFrom(itemArgument.getType()) && type.isAssignableFrom(itemArgument.getTypeParameters()[0].getType())) { @@ -197,10 +215,20 @@ private Object doIntercept(MethodInvocationContext context, } if (methodName.startsWith("query") || methodName.startsWith("findAll") || methodName.startsWith("list") || methodName.startsWith("count")) { - PartitionAndSort partitionAndSort = findHashAndRange(context.getArguments(), service); + String index = context.getTargetMethod().isAnnotationPresent(Index.class) ? context.getTargetMethod().getAnnotation(Index.class).value() : null; + boolean consistent = context.getTargetMethod().isAnnotationPresent(Consistent.class) && context.getTargetMethod().getAnnotation(Consistent.class).value(); + boolean descending = context.getTargetMethod().isAnnotationPresent(Descending.class) && context.getTargetMethod().getAnnotation(Descending.class).value(); + + QueryArguments partitionAndSort = findHashAndRange(context.getArguments(), service); if (methodName.startsWith("count")) { + if (index != null || consistent || descending || !partitionAndSort.filters.isEmpty() || partitionAndSort.sortKey != null && partitionAndSort.sortKey.operator != Filter.Operator.EQ) { + return service.countUsingQuery(generateQuery(context, partitionAndSort, index, consistent, descending)); + } return service.count(partitionAndSort.getPartitionValue(context.getParameters()), partitionAndSort.getSortValue(context.getParameters())); } + if (index != null || consistent || descending || !partitionAndSort.filters.isEmpty() || partitionAndSort.sortKey != null && partitionAndSort.sortKey.operator != Filter.Operator.EQ) { + return publisherOrIterable(service.query(generateQuery(context, partitionAndSort, index, consistent, descending)), context.getReturnType().getType()); + } return publisherOrIterable( service.findAll(partitionAndSort.getPartitionValue(context.getParameters()), partitionAndSort.getSortValue(context.getParameters())), context.getReturnType().getType() @@ -257,7 +285,7 @@ private Object handleDelete(DynamoDbService service, MethodInvocationCont throw new UnsupportedOperationException("Method expects at most 2 parameters - partition key and sort key, an item or items"); } - PartitionAndSort partitionAndSort = findHashAndRange(args, service); + QueryArguments partitionAndSort = findHashAndRange(args, service); service.delete(partitionAndSort.getPartitionValue(params), partitionAndSort.getSortValue(params)); return 1; } @@ -270,7 +298,7 @@ private Object handleGet(DynamoDbService service, MethodInvocationContext throw new UnsupportedOperationException("Method expects at most 2 parameters - partition key and sort key or sort keys"); } - PartitionAndSort partitionAndSort = findHashAndRange(args, service); + QueryArguments partitionAndSort = findHashAndRange(args, service); Object partitionValue = partitionAndSort.getPartitionValue(params); if (partitionAndSort.sortKey == null) { @@ -278,9 +306,9 @@ private Object handleGet(DynamoDbService service, MethodInvocationContext } if ( - partitionAndSort.sortKey.getType().isArray() - || Iterable.class.isAssignableFrom(partitionAndSort.sortKey.getType()) - || Publisher.class.isAssignableFrom(partitionAndSort.sortKey.getType()) + partitionAndSort.sortKey.firstArgument.getType().isArray() + || Iterable.class.isAssignableFrom(partitionAndSort.sortKey.firstArgument.getType()) + || Publisher.class.isAssignableFrom(partitionAndSort.sortKey.firstArgument.getType()) ) { Publisher all = service.getAll(partitionValue, partitionAndSort.getSortAttributeValues(params)); return publisherOrIterable(all, context.getReturnType().getType()); @@ -289,8 +317,8 @@ private Object handleGet(DynamoDbService service, MethodInvocationContext return service.get(partitionValue, partitionAndSort.getSortValue(params)); } - private PartitionAndSort findHashAndRange(Argument[] arguments, DynamoDbService table) { - PartitionAndSort names = new PartitionAndSort(); + private QueryArguments findHashAndRange(Argument[] arguments, DynamoDbService table) { + QueryArguments names = new QueryArguments(); for (Argument argument : arguments) { if ( argument.isAnnotationPresent(SortKey.class) @@ -299,7 +327,15 @@ private PartitionAndSort findHashAndRange(Argument[] arguments, DynamoDbServi || argument.getName().toLowerCase().contains(RANGE) || argument.getName().equals(table.getTable().tableSchema().tableMetadata().primarySortKey().orElse(SORT)) ) { - names.sortKey = argument; + if (names.sortKey == null) { + names.sortKey = new FilterArgument(); + names.sortKey.name = getArgumentName(argument); + if (names.sortKey.firstArgument == null) { + fillFirstArgument(argument, names.sortKey); + } else { + names.sortKey.secondArgument = argument; + } + } } else if ( argument.isAnnotationPresent(PartitionKey.class) || argument.isAnnotationPresent(HashKey.class) @@ -308,6 +344,20 @@ private PartitionAndSort findHashAndRange(Argument[] arguments, DynamoDbServi || argument.getName().equals(table.getTable().tableSchema().tableMetadata().primaryPartitionKey()) ) { names.partitionKey = argument; + } else { + String name = getArgumentName(argument); + + FilterArgument filterArgument = names.filters.computeIfAbsent(name, argName -> { + FilterArgument arg = new FilterArgument(); + arg.name = argName; + return arg; + }); + + if (filterArgument.firstArgument == null) { + fillFirstArgument(argument, filterArgument); + } else { + filterArgument.secondArgument = argument; + } } } @@ -318,4 +368,61 @@ private PartitionAndSort findHashAndRange(Argument[] arguments, DynamoDbServi return names; } + private static void fillFirstArgument(Argument argument, FilterArgument filterArgument) { + filterArgument.firstArgument = argument; + filterArgument.required = !argument.isNullable(); + filterArgument.operator = argument.isAnnotationPresent(Filter.class) + ? argument.getAnnotation(Filter.class).enumValue("value", Filter.Operator.class).orElse(Filter.Operator.EQ) + : Filter.Operator.EQ; + } + + private static String getArgumentName(Argument argument) { + return argument.isAnnotationPresent(Filter.class) + ? argument.getAnnotation(Filter.class).stringValue("name").orElse(argument.getName()) + : argument.getName(); + } + + private Consumer> generateQuery(MethodInvocationContext context, QueryArguments partitionAndSort, String index, boolean consistent, boolean descending) { + return q -> { + if (index != null) { + q.index(index); + } + + q.partitionKey(partitionAndSort.getPartitionValue(context.getParameters())); + + Object sortValue = partitionAndSort.getSortValue(context.getParameters()); + Object secondSortValue = partitionAndSort.sortKey == null || partitionAndSort.sortKey.secondArgument == null ? null : context.getParameters().get(partitionAndSort.sortKey.secondArgument.getName()).getValue(); + + if (sortValue != null) { + q.sortKey(s -> partitionAndSort.sortKey.operator.apply(s, partitionAndSort.sortKey.name, sortValue, secondSortValue)); + } + + if (consistent) { + q.consistent(Builders.Read.READ); + } + + if (descending) { + q.order(Builders.Sort.DESC); + } + + if (!partitionAndSort.filters.isEmpty()) { + partitionAndSort.filters.forEach((name, filter) -> { + Object firstValue = context.getParameters().get(filter.firstArgument.getName()).getValue(); + Object secondValue = filter.secondArgument == null ? null : context.getParameters().get(filter.secondArgument.getName()).getValue(); + + if (firstValue == null && !filter.required) { + return; + } + + q.filter(f -> filter.operator.apply( + f, + name, + firstValue, + secondValue) + ); + }); + } + }; + } + } diff --git a/subprojects/micronaut-amazon-awssdk-dynamodb/src/main/java/com/agorapulse/micronaut/amazon/awssdk/dynamodb/annotation/Consistent.java b/subprojects/micronaut-amazon-awssdk-dynamodb/src/main/java/com/agorapulse/micronaut/amazon/awssdk/dynamodb/annotation/Consistent.java new file mode 100644 index 000000000..c505c279e --- /dev/null +++ b/subprojects/micronaut-amazon-awssdk-dynamodb/src/main/java/com/agorapulse/micronaut/amazon/awssdk/dynamodb/annotation/Consistent.java @@ -0,0 +1,39 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright 2018-2024 Agorapulse. + * + * 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 + * + * https://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 com.agorapulse.micronaut.amazon.awssdk.dynamodb.annotation; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Annotation for defining if the query should be consistent. + + */ +@Inherited +@Documented +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.ANNOTATION_TYPE, ElementType.METHOD}) +public @interface Consistent { + + boolean value() default true; + +} diff --git a/subprojects/micronaut-amazon-awssdk-dynamodb/src/main/java/com/agorapulse/micronaut/amazon/awssdk/dynamodb/annotation/Descending.java b/subprojects/micronaut-amazon-awssdk-dynamodb/src/main/java/com/agorapulse/micronaut/amazon/awssdk/dynamodb/annotation/Descending.java new file mode 100644 index 000000000..5213777ba --- /dev/null +++ b/subprojects/micronaut-amazon-awssdk-dynamodb/src/main/java/com/agorapulse/micronaut/amazon/awssdk/dynamodb/annotation/Descending.java @@ -0,0 +1,38 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright 2018-2024 Agorapulse. + * + * 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 + * + * https://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 com.agorapulse.micronaut.amazon.awssdk.dynamodb.annotation; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Annotation for defining if the query should be sorted descending. + */ +@Inherited +@Documented +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.ANNOTATION_TYPE, ElementType.METHOD}) +public @interface Descending { + + boolean value() default true; + +} diff --git a/subprojects/micronaut-amazon-awssdk-dynamodb/src/main/java/com/agorapulse/micronaut/amazon/awssdk/dynamodb/annotation/Filter.java b/subprojects/micronaut-amazon-awssdk-dynamodb/src/main/java/com/agorapulse/micronaut/amazon/awssdk/dynamodb/annotation/Filter.java new file mode 100644 index 000000000..6e43c1028 --- /dev/null +++ b/subprojects/micronaut-amazon-awssdk-dynamodb/src/main/java/com/agorapulse/micronaut/amazon/awssdk/dynamodb/annotation/Filter.java @@ -0,0 +1,274 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright 2018-2024 Agorapulse. + * + * 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 + * + * https://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 com.agorapulse.micronaut.amazon.awssdk.dynamodb.annotation; + +import com.agorapulse.micronaut.amazon.awssdk.dynamodb.builder.FilterConditionCollector; +import com.agorapulse.micronaut.amazon.awssdk.dynamodb.builder.KeyConditionCollector; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import java.util.Arrays; +import java.util.Collection; + +@Inherited +@Documented +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.ANNOTATION_TYPE, ElementType.PARAMETER}) +public @interface Filter { + + Operator value() default Operator.EQ; + + String name() default ""; + + enum Operator { + + IN_LIST { + @Override + public void apply(FilterConditionCollector collector, String attributeOrIndex, Object firstValue, Object secondValue) { + collector.inList(attributeOrIndex, (Collection) firstValue); + } + + @Override + public void apply(KeyConditionCollector collector, String attributeOrIndex, Object firstValue, Object secondValue) { + throw new UnsupportedOperationException("IN_LIST is not supported for key conditions"); + } + }, + + EQ { + @Override + public void apply(FilterConditionCollector collector, String attributeOrIndex, Object firstValue, Object secondValue) { + if (firstValue == null) { + collector.isNull(attributeOrIndex); + } else if (firstValue instanceof Collection) { + collector.inList(attributeOrIndex, (Collection) firstValue); + } else if (firstValue.getClass().isArray()) { + collector.inList(attributeOrIndex, Arrays.asList((Object[]) firstValue)); + } else { + collector.eq(attributeOrIndex, firstValue); + } + } + + @Override + public void apply(KeyConditionCollector collector, String attributeOrIndex, Object firstValue, Object secondValue) { + collector.eq(firstValue); + } + }, + + NE { + @Override + public void apply(FilterConditionCollector collector, String attributeOrIndex, Object firstValue, Object secondValue) { + collector.ne(attributeOrIndex, firstValue); + } + + @Override + public void apply(KeyConditionCollector collector, String attributeOrIndex, Object firstValue, Object secondValue) { + throw new UnsupportedOperationException("NE is not supported for key conditions"); + } + }, + + LE { + @Override + public void apply(FilterConditionCollector collector, String attributeOrIndex, Object firstValue, Object secondValue) { + collector.le(attributeOrIndex, firstValue); + } + + @Override + public void apply(KeyConditionCollector collector, String attributeOrIndex, Object firstValue, Object secondValue) { + collector.le(firstValue); + } + + }, + + LT { + @Override + public void apply(FilterConditionCollector collector, String attributeOrIndex, Object firstValue, Object secondValue) { + collector.lt(attributeOrIndex, firstValue); + } + + @Override + public void apply(KeyConditionCollector collector, String attributeOrIndex, Object firstValue, Object secondValue) { + collector.lt(firstValue); + } + }, + + GE { + @Override + public void apply(FilterConditionCollector collector, String attributeOrIndex, Object firstValue, Object secondValue) { + collector.ge(attributeOrIndex, firstValue); + } + + @Override + public void apply(KeyConditionCollector collector, String attributeOrIndex, Object firstValue, Object secondValue) { + collector.ge(firstValue); + } + }, + + GT { + @Override + public void apply(FilterConditionCollector collector, String attributeOrIndex, Object firstValue, Object secondValue) { + collector.gt(attributeOrIndex, firstValue); + } + + @Override + public void apply(KeyConditionCollector collector, String attributeOrIndex, Object firstValue, Object secondValue) { + collector.gt(firstValue); + } + }, + + SIZE_EQ { + @Override + public void apply(FilterConditionCollector collector, String attributeOrIndex, Object firstValue, Object secondValue) { + collector.sizeEq(attributeOrIndex, firstValue); + } + + @Override + public void apply(KeyConditionCollector collector, String attributeOrIndex, Object firstValue, Object secondValue) { + throw new UnsupportedOperationException("SIZE_EQ is not supported for key conditions"); + } + }, + + SIZE_NE { + @Override + public void apply(FilterConditionCollector collector, String attributeOrIndex, Object firstValue, Object secondValue) { + collector.sizeNe(attributeOrIndex, firstValue); + } + + @Override + public void apply(KeyConditionCollector collector, String attributeOrIndex, Object firstValue, Object secondValue) { + throw new UnsupportedOperationException("SIZE_NE is not supported for key conditions"); + } + }, + + SIZE_LE { + @Override + public void apply(FilterConditionCollector collector, String attributeOrIndex, Object firstValue, Object secondValue) { + collector.sizeLe(attributeOrIndex, firstValue); + } + + @Override + public void apply(KeyConditionCollector collector, String attributeOrIndex, Object firstValue, Object secondValue) { + throw new UnsupportedOperationException("SIZE_LE is not supported for key conditions"); + } + }, + + SIZE_LT { + @Override + public void apply(FilterConditionCollector collector, String attributeOrIndex, Object firstValue, Object secondValue) { + collector.sizeLt(attributeOrIndex, firstValue); + } + + @Override + public void apply(KeyConditionCollector collector, String attributeOrIndex, Object firstValue, Object secondValue) { + throw new UnsupportedOperationException("SIZE_LT is not supported for key conditions"); + } + }, + + SIZE_GE { + @Override + public void apply(FilterConditionCollector collector, String attributeOrIndex, Object firstValue, Object secondValue) { + collector.sizeGe(attributeOrIndex, firstValue); + } + + @Override + public void apply(KeyConditionCollector collector, String attributeOrIndex, Object firstValue, Object secondValue) { + throw new UnsupportedOperationException("SIZE_GE is not supported for key conditions"); + } + }, + + SIZE_GT { + @Override + public void apply(FilterConditionCollector collector, String attributeOrIndex, Object firstValue, Object secondValue) { + collector.sizeGt(attributeOrIndex, firstValue); + } + + @Override + public void apply(KeyConditionCollector collector, String attributeOrIndex, Object firstValue, Object secondValue) { + throw new UnsupportedOperationException("SIZE_GT is not supported for key conditions"); + } + }, + + BETWEEN { + @Override + public void apply(FilterConditionCollector collector, String attributeOrIndex, Object firstValue, Object secondValue) { + collector.between(attributeOrIndex, firstValue, secondValue); + } + + @Override + public void apply(KeyConditionCollector collector, String attributeOrIndex, Object firstValue, Object secondValue) { + collector.between(firstValue, secondValue); + } + }, + + CONTAINS { + @Override + public void apply(FilterConditionCollector collector, String attributeOrIndex, Object firstValue, Object secondValue) { + collector.contains(attributeOrIndex, firstValue); + } + + @Override + public void apply(KeyConditionCollector collector, String attributeOrIndex, Object firstValue, Object secondValue) { + throw new UnsupportedOperationException("CONTAINS is not supported for key conditions"); + } + }, + + NOT_CONTAINS { + @Override + public void apply(FilterConditionCollector collector, String attributeOrIndex, Object firstValue, Object secondValue) { + collector.notContains(attributeOrIndex, firstValue); + } + + @Override + public void apply(KeyConditionCollector collector, String attributeOrIndex, Object firstValue, Object secondValue) { + throw new UnsupportedOperationException("NOT_CONTAINS is not supported for key conditions"); + } + }, + + TYPE_OF { + @Override + public void apply(FilterConditionCollector collector, String attributeOrIndex, Object firstValue, Object secondValue) { + collector.typeOf(attributeOrIndex, (Class) firstValue); + } + + @Override + public void apply(KeyConditionCollector collector, String attributeOrIndex, Object firstValue, Object secondValue) { + throw new UnsupportedOperationException("TYPE_OF is not supported for key conditions"); + } + }, + + BEGINS_WITH { + @Override + public void apply(FilterConditionCollector collector, String attributeOrIndex, Object firstValue, Object secondValue) { + collector.beginsWith(attributeOrIndex, (String) firstValue); + } + + @Override + public void apply(KeyConditionCollector collector, String attributeOrIndex, Object firstValue, Object secondValue) { + collector.beginsWith(String.valueOf(firstValue)); + } + }; + + public abstract void apply(FilterConditionCollector collector, String attributeOrIndex, Object firstValue, Object secondValue); + public abstract void apply(KeyConditionCollector collector, String attributeOrIndex, Object firstValue, Object secondValue); + + } + +} diff --git a/subprojects/micronaut-amazon-awssdk-dynamodb/src/main/java/com/agorapulse/micronaut/amazon/awssdk/dynamodb/annotation/Index.java b/subprojects/micronaut-amazon-awssdk-dynamodb/src/main/java/com/agorapulse/micronaut/amazon/awssdk/dynamodb/annotation/Index.java new file mode 100644 index 000000000..cf1cb37a6 --- /dev/null +++ b/subprojects/micronaut-amazon-awssdk-dynamodb/src/main/java/com/agorapulse/micronaut/amazon/awssdk/dynamodb/annotation/Index.java @@ -0,0 +1,39 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright 2018-2024 Agorapulse. + * + * 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 + * + * https://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 com.agorapulse.micronaut.amazon.awssdk.dynamodb.annotation; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Annotation for defining an index of the underlying database query or scan operation + + */ +@Inherited +@Documented +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.ANNOTATION_TYPE, ElementType.METHOD}) +public @interface Index { + + String value(); + +} diff --git a/subprojects/micronaut-amazon-awssdk-dynamodb/src/test/groovy/com/agorapulse/micronaut/amazon/awssdk/dynamodb/AdvancedQueryOnDeclarativeServiceTest.java b/subprojects/micronaut-amazon-awssdk-dynamodb/src/test/groovy/com/agorapulse/micronaut/amazon/awssdk/dynamodb/AdvancedQueryOnDeclarativeServiceTest.java new file mode 100644 index 000000000..e61cb283a --- /dev/null +++ b/subprojects/micronaut-amazon-awssdk-dynamodb/src/test/groovy/com/agorapulse/micronaut/amazon/awssdk/dynamodb/AdvancedQueryOnDeclarativeServiceTest.java @@ -0,0 +1,85 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright 2018-2024 Agorapulse. + * + * 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 + * + * https://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 com.agorapulse.micronaut.amazon.awssdk.dynamodb; + +import io.micronaut.test.extensions.junit5.annotation.MicronautTest; +import jakarta.inject.Inject; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Date; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +@MicronautTest +public class AdvancedQueryOnDeclarativeServiceTest { + + private static final Instant REFERENCE_DATE = Instant.ofEpochMilli(1358487600000L); + + @Inject DynamoDBEntityService s; + + @Test + public void testJavaService() { + assertNotNull(s.save(createEntity("1", "1", "foo", 1, Date.from(REFERENCE_DATE)))); + assertNotNull(s.save(createEntity("1", "2", "bar", 1, Date.from(REFERENCE_DATE.plus(1, ChronoUnit.DAYS))))); + assertNotNull(s.save(createEntity("1", "3", "foo", 2, Date.from(REFERENCE_DATE)))); + assertNotNull(s.save(createEntity("1", "4", "bar", 2, Date.from(REFERENCE_DATE.plus(1, ChronoUnit.DAYS))))); + assertNotNull(s.save(createEntity("1", "5", "foo", 3, Date.from(REFERENCE_DATE.plus(2, ChronoUnit.DAYS))))); + assertNotNull(s.save(createEntity("1", "6", "foo", null, Date.from(REFERENCE_DATE.plus(3, ChronoUnit.DAYS))))); + assertNotNull(s.save(createEntity("2", "1", "bar", 3, Date.from(REFERENCE_DATE)))); + + assertEquals(2, s.countAllByNumber("1", 1)); + assertEquals(1, s.countAllByNumber("1", null)); + assertEquals(6, s.countAllByOptionalNumber("1", null)); + + List allByNumber = s.findAllByNumber("1", 1); + assertEquals(2, allByNumber.size()); + + DynamoDBEntity first = allByNumber.get(0); + assertEquals("1", first.getParentId()); + assertEquals("2", first.getId()); + assertEquals("bar", first.getRangeIndex()); + assertEquals(1, first.getNumber()); + + assertEquals(3, s.findAllByNumberGreaterThan("1", 1).size()); + assertEquals(5, s.findAllByNumberGreaterThanEqual("1", 1).size()); + assertEquals(2, s.findAllByNumberLowerThan("1", 2).size()); + assertEquals(4, s.findAllByNumberLowerThanEqual("1", 2).size()); + assertEquals(5, s.findAllByNumberNot("1", 3).size()); + assertEquals(5, s.findAllByNumberIsType("1", Number.class).size()); + assertEquals(4, s.findAllByNumberIn("1", List.of(1, 2)).size()); + assertEquals(4, s.findAllByNumberInArray("1", 1, 2).size()); + assertEquals(4, s.findAllByNumberInExplicit("1", List.of(1, 2)).size()); + assertEquals(4, s.findAllByNumberBetween("1", 1, 2).size()); + assertEquals(4, s.findAllByRangeBeginsWith("1", "f").size()); + } + + + private DynamoDBEntity createEntity(String parentId, String id, String rangeIndex, Integer number, Date date) { + DynamoDBEntity entity = new DynamoDBEntity(); + entity.setParentId(parentId); + entity.setId(id); + entity.setRangeIndex(rangeIndex); + entity.setDate(date); + entity.setNumber(number); + return entity; + } + +} diff --git a/subprojects/micronaut-amazon-awssdk-dynamodb/src/test/groovy/com/agorapulse/micronaut/amazon/awssdk/dynamodb/DeclarativeServiceTest.java b/subprojects/micronaut-amazon-awssdk-dynamodb/src/test/groovy/com/agorapulse/micronaut/amazon/awssdk/dynamodb/DeclarativeServiceTest.java index f8d77ee1d..10e6ea54a 100644 --- a/subprojects/micronaut-amazon-awssdk-dynamodb/src/test/groovy/com/agorapulse/micronaut/amazon/awssdk/dynamodb/DeclarativeServiceTest.java +++ b/subprojects/micronaut-amazon-awssdk-dynamodb/src/test/groovy/com/agorapulse/micronaut/amazon/awssdk/dynamodb/DeclarativeServiceTest.java @@ -18,6 +18,7 @@ package com.agorapulse.micronaut.amazon.awssdk.dynamodb; +import io.micronaut.context.annotation.Property; import io.micronaut.test.extensions.junit5.annotation.MicronautTest; import org.junit.jupiter.api.Test; import reactor.core.publisher.Flux; @@ -33,6 +34,7 @@ // tag::header[] @MicronautTest // <1> +@Property(name = "test.table.name", value = "DynamoDBDeclarativeJava") public class DeclarativeServiceTest { // end::header[] @@ -66,6 +68,7 @@ public void testJavaService() { assertEquals(2, s.count("1")); assertEquals(1, s.count("1", "1")); assertEquals(1, s.countByRangeIndex("1", "bar")); + assertEquals(1, s.countByRangeIndexUsingAnnotation("1", "bar")); assertEquals(2, s.countByDates("1", Date.from(REFERENCE_DATE.minus(1, ChronoUnit.DAYS)), Date.from(REFERENCE_DATE.plus(2, ChronoUnit.DAYS)))); assertEquals(1, s.countByDates("3", Date.from(REFERENCE_DATE.plus(9, ChronoUnit.DAYS)), Date.from(REFERENCE_DATE.plus(20, ChronoUnit.DAYS)))); diff --git a/subprojects/micronaut-amazon-awssdk-dynamodb/src/test/groovy/com/agorapulse/micronaut/amazon/awssdk/dynamodb/DynamoDBEntityService.java b/subprojects/micronaut-amazon-awssdk-dynamodb/src/test/groovy/com/agorapulse/micronaut/amazon/awssdk/dynamodb/DynamoDBEntityService.java index 23e8718b7..e45367ec8 100644 --- a/subprojects/micronaut-amazon-awssdk-dynamodb/src/test/groovy/com/agorapulse/micronaut/amazon/awssdk/dynamodb/DynamoDBEntityService.java +++ b/subprojects/micronaut-amazon-awssdk-dynamodb/src/test/groovy/com/agorapulse/micronaut/amazon/awssdk/dynamodb/DynamoDBEntityService.java @@ -19,6 +19,7 @@ import com.agorapulse.micronaut.amazon.awssdk.dynamodb.annotation.*; import com.agorapulse.micronaut.amazon.awssdk.dynamodb.builder.*; +import jakarta.annotation.Nullable; import org.reactivestreams.Publisher; import java.util.Date; @@ -27,7 +28,7 @@ // tag::all[] // tag::header[] -@Service(value = DynamoDBEntity.class, tableName = "DynamoDBJava") // <1> +@Service(value = DynamoDBEntity.class, tableName = "${test.table.name:DynamoDBJava}") // <1> public interface DynamoDBEntityService { // end::header[] @@ -67,6 +68,10 @@ public DetachedQuery query(Map arguments) { @Query(EqRangeIndex.class) int countByRangeIndex(String hashKey, String rangeKey); + @Consistent + @Index(DynamoDBEntity.RANGE_INDEX) + int countByRangeIndexUsingAnnotation(String hashKey, String rangeKey); + class BetweenDateIndex implements QueryFunction { @Override @@ -177,6 +182,103 @@ public ScanBuilder scan(Map args) { Publisher scanAllByRangeIndex(String foo); // <5> // end::sample-scan[] + // CHECKSTYLE:OFF + // tag::advanced-query-methods[] + @Consistent // <1> + @Descending // <2> + @Index(DynamoDBEntity.DATE_INDEX) // <3> + List findAllByNumber( + @PartitionKey String parentId, + Integer number // <4> + ); + + int countAllByOptionalNumber( + @PartitionKey String parentId, + @Nullable Integer number // <5> + ); + + List findAllByNumberGreaterThan( + @PartitionKey String parentId, + @Filter( // <6> + value = Filter.Operator.GT, + name = "number" // <7> + ) Integer theNumber + ); + + @Index(DynamoDBEntity.RANGE_INDEX) + List findAllByRangeBeginsWith( + @PartitionKey String parentId, + @SortKey // <8> + @Filter( + value = Filter.Operator.BEGINS_WITH, // <9> + name = "rangeIndex" // <10> + ) + String rangeIndexPrefix + ); + // end::advanced-query-methods[] + // CHECKSTYLE:ON + + List findAllByNumberGreaterThanEqual( + @PartitionKey String parentId, + @Filter(Filter.Operator.GE) Integer number + ); + + List findAllByNumberLowerThan( + @PartitionKey String parentId, + @Filter(Filter.Operator.LT) Integer number + ); + + List findAllByNumberLowerThanEqual( + @PartitionKey String parentId, + @Filter(Filter.Operator.LE) Integer number + ); + + List findAllByNumberNot( + @PartitionKey String parentId, + @Filter(Filter.Operator.NE) Integer number + ); + + List findAllByNumberIn( + @PartitionKey String parentId, + List number + ); + + List findAllByNumberInArray( + @PartitionKey String parentId, + Integer... number + ); + + List findAllByNumberInExplicit( + @PartitionKey String parentId, + @Filter(Filter.Operator.IN_LIST) List number + ); + + List findAllByNumberIsType( + @PartitionKey String parentId, + @Filter(value = Filter.Operator.TYPE_OF, name = "number") Class type + ); + + List findAllByNumberBetween( + @PartitionKey String parentId, + @Filter(value = Filter.Operator.BETWEEN, name = "number") Integer numberFrom, + @Filter(name = "number") Integer numberTo + ); + + @Index(DynamoDBEntity.RANGE_INDEX) + List findAllByRangeContains( + @PartitionKey String parentId, + @SortKey @Filter(value = Filter.Operator.CONTAINS, name = "rangeIndex") String string + ); + + @Index(DynamoDBEntity.RANGE_INDEX) + List findAllByRangeNotContains( + @PartitionKey String parentId, + @SortKey @Filter(value = Filter.Operator.NOT_CONTAINS, name = "rangeIndex") String string + ); + + int countAllByNumber(@PartitionKey String parentId, Integer number); + + // tag::footer[] } // end::footer[] diff --git a/subprojects/micronaut-amazon-awssdk-dynamodb/src/test/groovy/com/agorapulse/micronaut/amazon/awssdk/dynamodb/DynamoDBLocalTest.java b/subprojects/micronaut-amazon-awssdk-dynamodb/src/test/groovy/com/agorapulse/micronaut/amazon/awssdk/dynamodb/DynamoDBLocalTest.java index 58373f60b..e3de78e4f 100644 --- a/subprojects/micronaut-amazon-awssdk-dynamodb/src/test/groovy/com/agorapulse/micronaut/amazon/awssdk/dynamodb/DynamoDBLocalTest.java +++ b/subprojects/micronaut-amazon-awssdk-dynamodb/src/test/groovy/com/agorapulse/micronaut/amazon/awssdk/dynamodb/DynamoDBLocalTest.java @@ -37,6 +37,7 @@ @Property(name = "localstack.containers.dynamodb.image", value = "amazon/dynamodb-local") @Property(name = "localstack.containers.dynamodb.tag", value = "1.20.0") @Property(name = "localstack.containers.dynamodb.port", value = "8000") +@Property(name = "test.table.name", value = "DynamoDBJavaLocal") public class DynamoDBLocalTest { // end::header[] diff --git a/subprojects/micronaut-amazon-awssdk-dynamodb/src/test/groovy/com/agorapulse/micronaut/amazon/awssdk/dynamodb/annotation/FilterOperatorSpec.groovy b/subprojects/micronaut-amazon-awssdk-dynamodb/src/test/groovy/com/agorapulse/micronaut/amazon/awssdk/dynamodb/annotation/FilterOperatorSpec.groovy new file mode 100644 index 000000000..e0452d051 --- /dev/null +++ b/subprojects/micronaut-amazon-awssdk-dynamodb/src/test/groovy/com/agorapulse/micronaut/amazon/awssdk/dynamodb/annotation/FilterOperatorSpec.groovy @@ -0,0 +1,257 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright 2018-2024 Agorapulse. + * + * 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 + * + * https://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 com.agorapulse.micronaut.amazon.awssdk.dynamodb.annotation + +import com.agorapulse.micronaut.amazon.awssdk.dynamodb.builder.FilterConditionCollector +import com.agorapulse.micronaut.amazon.awssdk.dynamodb.builder.KeyConditionCollector +import spock.lang.Specification + +class FilterOperatorSpec extends Specification { + + void "operator #operator is not supported for sort keys"(Filter.Operator operator) { + when: + operator.apply(Mock(KeyConditionCollector), 'sortKey', 'value', null) + then: + thrown(UnsupportedOperationException) + where: + operator << (Filter.Operator.values() - [ + Filter.Operator.EQ, + Filter.Operator.LE, + Filter.Operator.LT, + Filter.Operator.GE, + Filter.Operator.GT, + Filter.Operator.BETWEEN, + Filter.Operator.BEGINS_WITH, + ]) + } + + void 'inList is called for operator IN_LIST'() { + given: + FilterConditionCollector collector = Mock(FilterConditionCollector) + when: + Filter.Operator.IN_LIST.apply(collector, 'key', [1, 2, 3], null) + then: + 1 * collector.inList('key', [1, 2, 3]) + } + + void 'ne is called for operator NE'() { + given: + FilterConditionCollector collector = Mock(FilterConditionCollector) + when: + Filter.Operator.NE.apply(collector, 'key', 'value', null) + then: + 1 * collector.ne('key', 'value') + } + + void 'le is called for operator LE'() { + given: + FilterConditionCollector collector = Mock(FilterConditionCollector) + KeyConditionCollector keyConditionCollector = Mock(KeyConditionCollector) + when: + Filter.Operator.LE.apply(collector, 'key', 'value', null) + Filter.Operator.LE.apply(keyConditionCollector, 'key', 'value', null) + + then: + 1 * collector.le('key', 'value') + 1 * keyConditionCollector.le('value') + } + + void 'lt is called for operator LT'() { + given: + FilterConditionCollector collector = Mock(FilterConditionCollector) + KeyConditionCollector keyConditionCollector = Mock(KeyConditionCollector) + when: + Filter.Operator.LT.apply(collector, 'key', 'value', null) + Filter.Operator.LT.apply(keyConditionCollector, 'key', 'value', null) + + then: + 1 * collector.lt('key', 'value') + 1 * keyConditionCollector.lt('value') + } + + void 'ge is called for operator GE'() { + given: + FilterConditionCollector collector = Mock(FilterConditionCollector) + KeyConditionCollector keyConditionCollector = Mock(KeyConditionCollector) + when: + Filter.Operator.GE.apply(collector, 'key', 'value', null) + Filter.Operator.GE.apply(keyConditionCollector, 'key', 'value', null) + + then: + 1 * collector.ge('key', 'value') + 1 * keyConditionCollector.ge('value') + } + + void 'gt is called for operator GT'() { + given: + FilterConditionCollector collector = Mock(FilterConditionCollector) + KeyConditionCollector keyConditionCollector = Mock(KeyConditionCollector) + when: + Filter.Operator.GT.apply(collector, 'key', 'value', null) + Filter.Operator.GT.apply(keyConditionCollector, 'key', 'value', null) + + then: + 1 * collector.gt('key', 'value') + 1 * keyConditionCollector.gt('value') + } + + void 'beginsWith is called for operator BEGINS_WITH'() { + given: + FilterConditionCollector collector = Mock(FilterConditionCollector) + KeyConditionCollector keyConditionCollector = Mock(KeyConditionCollector) + when: + Filter.Operator.BEGINS_WITH.apply(collector, 'key', 'value', null) + Filter.Operator.BEGINS_WITH.apply(keyConditionCollector, 'key', 'value', null) + then: + 1 * collector.beginsWith('key', 'value') + 1 * keyConditionCollector.beginsWith('value') + } + + void 'between is called for operator BETWEEN'() { + given: + FilterConditionCollector collector = Mock(FilterConditionCollector) + KeyConditionCollector keyConditionCollector = Mock(KeyConditionCollector) + when: + Filter.Operator.BETWEEN.apply(collector, 'key', 'first', 'second') + Filter.Operator.BETWEEN.apply(keyConditionCollector, 'key', 'first', 'second') + then: + 1 * collector.between('key', 'first', 'second') + 1 * keyConditionCollector.between('first', 'second') + } + + void 'sizeEq is called for operator SIZE_EQ'() { + given: + FilterConditionCollector collector = Mock(FilterConditionCollector) + when: + Filter.Operator.SIZE_EQ.apply(collector, 'key', 5, null) + then: + 1 * collector.sizeEq('key', 5) + } + + void 'sizeGt is called for operator SIZE_GT'() { + given: + FilterConditionCollector collector = Mock(FilterConditionCollector) + when: + Filter.Operator.SIZE_GT.apply(collector, 'key', 5, null) + then: + 1 * collector.sizeGt('key', 5) + } + + void 'sizeGe is called for operator SIZE_GE'() { + given: + FilterConditionCollector collector = Mock(FilterConditionCollector) + when: + Filter.Operator.SIZE_GE.apply(collector, 'key', 5, null) + then: + 1 * collector.sizeGe('key', 5) + } + + void 'sizeLt is called for operator SIZE_LT'() { + given: + FilterConditionCollector collector = Mock(FilterConditionCollector) + when: + Filter.Operator.SIZE_LT.apply(collector, 'key', 5, null) + then: + 1 * collector.sizeLt('key', 5) + } + + void 'sizeLe is called for operator SIZE_LE'() { + given: + FilterConditionCollector collector = Mock(FilterConditionCollector) + when: + Filter.Operator.SIZE_LE.apply(collector, 'key', 5, null) + then: + 1 * collector.sizeLe('key', 5) + } + + void 'sizeNe is called for operator SIZE_NE'() { + given: + FilterConditionCollector collector = Mock(FilterConditionCollector) + when: + Filter.Operator.SIZE_NE.apply(collector, 'key', 5, null) + then: + 1 * collector.sizeNe('key', 5) + } + + void 'contains is called for operator CONTAINS'() { + given: + FilterConditionCollector collector = Mock(FilterConditionCollector) + when: + Filter.Operator.CONTAINS.apply(collector, 'key', 'value', null) + then: + 1 * collector.contains('key', 'value') + } + + void 'notContains is called for operator NOT_CONTAINS'() { + given: + FilterConditionCollector collector = Mock(FilterConditionCollector) + when: + Filter.Operator.NOT_CONTAINS.apply(collector, 'key', 'value', null) + then: + 1 * collector.notContains('key', 'value') + } + + void 'typeOf is called for operator TYPE_OF'() { + given: + FilterConditionCollector collector = Mock(FilterConditionCollector) + when: + Filter.Operator.TYPE_OF.apply(collector, 'key', String, null) + then: + 1 * collector.typeOf('key', String) + } + + void 'eq is called for simple object and EQ'() { + given: + FilterConditionCollector collector = Mock(FilterConditionCollector) + KeyConditionCollector keyConditionCollector = Mock(KeyConditionCollector) + when: + Filter.Operator.EQ.apply(collector, 'key', 'value', null) + Filter.Operator.EQ.apply(keyConditionCollector, 'key', 'value', null) + then: + 1 * collector.eq('key', 'value') + 1 * keyConditionCollector.eq('value') + } + + void 'inList is called for collection and EQ'() { + given: + FilterConditionCollector collector = Mock(FilterConditionCollector) + when: + Filter.Operator.EQ.apply(collector, 'key', [1, 2, 3], null) + then: + 1 * collector.inList('key', [1, 2, 3]) + } + + void 'inList is called for an array and EQ'() { + given: + FilterConditionCollector collector = Mock(FilterConditionCollector) + when: + Filter.Operator.EQ.apply(collector, 'key', [1, 2, 3] as Integer[], null) + then: + 1 * collector.inList('key', [1, 2, 3]) + } + + void 'isNull is called for eq operator and null value'() { + given: + FilterConditionCollector collector = Mock(FilterConditionCollector) + when: + Filter.Operator.EQ.apply(collector, 'key', null, null) + then: + 1 * collector.isNull('key') + } + +}