Skip to content

Commit

Permalink
More Powerful Query Methods (#230)
Browse files Browse the repository at this point in the history
* More Powerful Query Methods

* wait for data to be persisted

* using unique table names for tests
  • Loading branch information
musketyr authored May 17, 2024
1 parent 23d58a9 commit 313b440
Show file tree
Hide file tree
Showing 11 changed files with 983 additions and 17 deletions.
23 changes: 22 additions & 1 deletion docs/guide/src/docs/asciidoc/dynamodb.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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)
----
Expand All @@ -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
----
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,31 +17,39 @@
*/
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;
import com.agorapulse.micronaut.amazon.awssdk.dynamodb.annotation.Scan;
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;
import io.micronaut.core.annotation.AnnotationValue;
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.
Expand All @@ -54,9 +62,19 @@ public class ServiceIntroduction implements MethodInterceptor<Object, Object> {
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<String, FilterArgument> filters = new LinkedHashMap<>();


boolean isValid() {
return partitionKey != null;
Expand All @@ -67,11 +85,11 @@ Object getPartitionValue(Map<String, MutableArgumentValue<?>> params) {
}

Object getSortValue(Map<String, MutableArgumentValue<?>> params) {
return sortKey == null ? null : params.get(sortKey.getName()).getValue();
return sortKey == null ? null : params.get(sortKey.firstArgument.getName()).getValue();
}

Publisher<?> getSortAttributeValues(Map<String, MutableArgumentValue<?>> params) {
return sortKey == null ? Flux.empty() : toPublisher(Object.class, sortKey, params);
return sortKey == null ? Flux.empty() : toPublisher(Object.class, sortKey.firstArgument, params);
}

}
Expand Down Expand Up @@ -110,7 +128,7 @@ private static <T> Publisher<T> toPublisher(Class<T> 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())) {
Expand Down Expand Up @@ -197,10 +215,20 @@ private <T> Object doIntercept(MethodInvocationContext<Object, Object> 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()
Expand Down Expand Up @@ -257,7 +285,7 @@ private <T> Object handleDelete(DynamoDbService<T> 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;
}
Expand All @@ -270,17 +298,17 @@ private <T> Object handleGet(DynamoDbService<T> 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) {
return service.get(partitionValue, null);
}

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<T> all = service.getAll(partitionValue, partitionAndSort.getSortAttributeValues(params));
return publisherOrIterable(all, context.getReturnType().getType());
Expand All @@ -289,8 +317,8 @@ private <T> Object handleGet(DynamoDbService<T> 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)
Expand All @@ -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)
Expand All @@ -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;
}
}
}

Expand All @@ -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 <T> Consumer<QueryBuilder<T>> generateQuery(MethodInvocationContext<Object, Object> 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)
);
});
}
};
}

}
Original file line number Diff line number Diff line change
@@ -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;

}
Original file line number Diff line number Diff line change
@@ -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;

}
Loading

0 comments on commit 313b440

Please sign in to comment.