Skip to content

Commit

Permalink
DynamoDb Converters v2 Improvements (#263)
Browse files Browse the repository at this point in the history
* DynamoDb Converters v2 Improvements

- added `@ConvertedJson` to mirror `DynamoDBTypeConvertedJson` functionality
- always present `EmptySafeStringSetConverter` to allow having sets initialized

* DynamoDb Converters v2 Improvements

- added `@ConvertedJson` to mirror `DynamoDBTypeConvertedJson` functionality
- always present `EmptySafeStringSetConverter` to allow having sets initialized
  • Loading branch information
musketyr authored Oct 3, 2024
1 parent 16ac8b4 commit c88ba54
Show file tree
Hide file tree
Showing 17 changed files with 464 additions and 28 deletions.
2 changes: 1 addition & 1 deletion docs/guide/src/docs/asciidoc/dynamodb.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ The entity class is a class which instances represent the items in DynamoDB.

For AWS SDK v2 you don't need to use the https://sdk.amazonaws.com/java/api/latest/software/amazon/awssdk/enhanced/dynamodb/mapper/annotations/package-summary.html[native annotations] but
you fill their counterparts in `com.agorapulse.micronaut.amazon.awssdk.dynamodb.annotation` package. The only requirements is that
the class needs to be annotated either with `@Introspected` or `@DynamoDbBean`.
the class needs to be annotated either with `@Introspected` or `@DynamoDbBean`. There is a replacement for `@DynamoDBTypeConvertedJson` annotation as well - you can use `@ConvertedJson` annotation instead.



Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,14 @@ dependencies {
implementation "space.jasan:groovy-closure-support:$closureSupportVersion"
implementation "io.projectreactor:reactor-core:$projectReactorVersion"

// required by the com.agorapulse.micronaut.amazon.awssdk.dynamodb.convert.ConvertedToJsonAttributeConverter
compileOnly 'io.micronaut:micronaut-jackson-databind'

testAnnotationProcessor project(':micronaut-amazon-awssdk-dynamodb-annotation-processor')
testImplementation project(':micronaut-amazon-awssdk-dynamodb-annotation-processor')
testImplementation project(':micronaut-amazon-awssdk-integration-testing')
testImplementation 'io.micronaut.rxjava2:micronaut-rxjava2'
testImplementation 'io.micronaut:micronaut-jackson-databind'
}

if (project.findProperty('test.aws.dynamodb.v2') == 'async') {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/*
* 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.*;

/**
* Specifies that the property is persisted as JSON string. Requires micronaut-jackson-databind to be on the classpath.
*
*/
@Inherited
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.ANNOTATION_TYPE, ElementType.FIELD, ElementType.METHOD})
public @interface ConvertedJson {

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/*
* 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.convert;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.ObjectReader;
import com.fasterxml.jackson.databind.ObjectWriter;
import io.micronaut.jackson.ObjectMapperFactory;
import software.amazon.awssdk.enhanced.dynamodb.AttributeConverter;
import software.amazon.awssdk.enhanced.dynamodb.AttributeValueType;
import software.amazon.awssdk.enhanced.dynamodb.EnhancedType;
import software.amazon.awssdk.services.dynamodb.model.AttributeValue;

import java.io.IOException;

/**
* Converter which converts objects to JSON strings.
* @param <T> the type of the object
*/
public class ConvertedJsonAttributeConverter<T> implements AttributeConverter<T> {

private static final ObjectWriter OBJECT_WRITER;
private static final ObjectReader OBJECT_READER;

static {
ObjectMapper mapper = new ObjectMapperFactory().objectMapper(null, null);
OBJECT_READER = mapper.reader();
OBJECT_WRITER = mapper.writer();
}

private final Class<T> type;

public ConvertedJsonAttributeConverter(Class<T> type) {
this.type = type;
}

@Override
public AttributeValue transformFrom(T input) {
try {
return AttributeValue.fromS(OBJECT_WRITER.writeValueAsString(input));
} catch (JsonProcessingException e) {
throw new IllegalArgumentException("Cannot write value as JSON: " + input, e);
}
}

@Override
public T transformTo(AttributeValue input) {
try {
return OBJECT_READER.readValue(input.s(), type);
} catch (IOException e) {
throw new IllegalArgumentException("Cannot read value: " + input.s(), e);
}
}

@Override
public EnhancedType<T> type() {
return EnhancedType.of(type);
}

@Override
public AttributeValueType attributeValueType() {
return AttributeValueType.S;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/*
* 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.convert;

import io.micronaut.core.util.CollectionUtils;
import software.amazon.awssdk.enhanced.dynamodb.AttributeConverter;
import software.amazon.awssdk.enhanced.dynamodb.AttributeValueType;
import software.amazon.awssdk.enhanced.dynamodb.EnhancedType;
import software.amazon.awssdk.enhanced.dynamodb.internal.AttributeValues;
import software.amazon.awssdk.services.dynamodb.model.AttributeValue;

import java.util.HashSet;
import java.util.Set;

/**
* Set string datatype in dynamo does not allow empty values.
* Without this custom converter if an empty set is present in entity an error will occur : 'dynamodb an string set may not be empty'
* @see <a href="https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/HowItWorks.NamingRulesDataTypes.html">...</a>
*/
public class EmptySafeStringSetConverter implements AttributeConverter<Set<String>> {
@Override
public AttributeValue transformFrom(Set<String> set) {
return CollectionUtils.isEmpty(set)
? AttributeValues.nullAttributeValue()
: AttributeValue.fromSs(set.stream().toList());
}

@Override
public Set<String> transformTo(AttributeValue rawValue) {
return new HashSet<>(rawValue.ss());
}

@Override
public EnhancedType<Set<String>> type() {
return EnhancedType.setOf(String.class);
}

@Override
public AttributeValueType attributeValueType() {
return AttributeValueType.SS;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@
public class LegacyAttributeConverterProvider implements AttributeConverterProvider {

private final List<AttributeConverter<?>> customConverters = Arrays.asList(
new DateToStringAttributeConverter()
new DateToStringAttributeConverter(),
new EmptySafeStringSetConverter()
);

private final Map<EnhancedType<?>, AttributeConverter<?>> customConvertersMap;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
package com.agorapulse.micronaut.amazon.awssdk.dynamodb.schema;

import com.agorapulse.micronaut.amazon.awssdk.dynamodb.annotation.*;
import com.agorapulse.micronaut.amazon.awssdk.dynamodb.convert.ConvertedJsonAttributeConverter;
import com.agorapulse.micronaut.amazon.awssdk.dynamodb.convert.LegacyAttributeConverterProvider;
import io.micronaut.context.BeanContext;
import io.micronaut.core.annotation.AnnotationMetadataProvider;
Expand Down Expand Up @@ -336,7 +337,9 @@ private static <T, P> Optional<AttributeConverter<P>> createAttributeConverterFr
) {
return findAnnotation(propertyDescriptor, DynamoDbConvertedBy.class, ConvertedBy.class)
.flatMap(AnnotationValue::classValue)
.map(clazz -> (AttributeConverter<P>) fromContextOrNew(clazz, beanContext).get());
.map(clazz -> (AttributeConverter<P>) fromContextOrNew(clazz, beanContext).get())
.or(() -> findAnnotation(propertyDescriptor, ConvertedJson.class)
.map(anno -> (AttributeConverter<P>) new ConvertedJsonAttributeConverter<>(propertyDescriptor.getType())));
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ class DefaultDynamoDBServiceSpec extends Specification {
unknownMethodsService.delete('1', '1', '1')
then:
IllegalArgumentException e3 = thrown(IllegalArgumentException)
e3.message == '''Unknown property somethingElse for DynamoDBEntity{parentId='null', id='null', rangeIndex='null', date=null, number=0, mapProperty={}}'''
e3.message == '''Unknown property somethingElse for DynamoDBEntity{parentId='null', id='null', rangeIndex='null', date=null, number=0, mapProperty={}, stringSetProperty=[]}'''

when:
unknownMethodsService.get('1', '1', '1')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
*/
package com.agorapulse.micronaut.amazon.awssdk.dynamodb;

import com.agorapulse.micronaut.amazon.awssdk.dynamodb.annotation.ConvertedJson;
import com.agorapulse.micronaut.amazon.awssdk.dynamodb.annotation.PartitionKey;
import com.agorapulse.micronaut.amazon.awssdk.dynamodb.annotation.Projection;
import com.agorapulse.micronaut.amazon.awssdk.dynamodb.annotation.SecondaryPartitionKey;
Expand All @@ -26,10 +27,12 @@
import software.amazon.awssdk.services.dynamodb.model.ProjectionType;

import java.util.Date;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;

@Introspected // <1>
public class DynamoDBEntity implements PlaybookAware {
Expand All @@ -45,6 +48,8 @@ public class DynamoDBEntity implements PlaybookAware {
private Integer number = 0;

private Map<String, List<String>> mapProperty = new LinkedHashMap<>();
private Set<String> stringSetProperty = new HashSet<>();
private Options options = new Options();

@PartitionKey // <2>
public String getParentId() {
Expand Down Expand Up @@ -105,6 +110,23 @@ public void setMapProperty(Map<String, List<String>> mapProperty) {
this.mapProperty = mapProperty;
}

public Set<String> getStringSetProperty() {
return stringSetProperty;
}

public void setStringSetProperty(Set<String> stringSetProperty) {
this.stringSetProperty = stringSetProperty;
}

@ConvertedJson
public Options getOptions() {
return options;
}

public void setOptions(Options options) {
this.options = options;
}

//CHECKSTYLE:OFF
@Override
public boolean equals(Object o) {
Expand All @@ -116,12 +138,13 @@ public boolean equals(Object o) {
Objects.equals(rangeIndex, that.rangeIndex) &&
Objects.equals(date, that.date) &&
Objects.equals(number, that.number) &&
Objects.equals(mapProperty, that.mapProperty);
Objects.equals(mapProperty, that.mapProperty) &&
Objects.equals(stringSetProperty, that.stringSetProperty);
}

@Override
public int hashCode() {
return Objects.hash(parentId, id, rangeIndex, date, number, mapProperty);
return Objects.hash(parentId, id, rangeIndex, date, number, mapProperty, stringSetProperty);
}

@Override
Expand All @@ -133,6 +156,7 @@ public String toString() {
", date=" + date +
", number=" + number +
", mapProperty=" + mapProperty +
", stringSetProperty=" + stringSetProperty +
'}';
}
//CHECKSTYLE:ON
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,12 @@ private DynamoDBEntity createEntity(String parentId, String id, String rangeInde
entity.setId(id);
entity.setRangeIndex(rangeIndex);
entity.setDate(date);

Options options = new Options();
options.setOne("one");
options.setTwo("two");
entity.setOptions(options);

return entity;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/*
* 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.core.annotation.Introspected;

import java.util.Objects;

@Introspected
public class Options {

private String one;
private String two;

public String getTwo() {
return two;
}

public void setTwo(String two) {
this.two = two;
}

public String getOne() {
return one;
}

public void setOne(String one) {
this.one = one;
}

@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}

if (o == null || getClass() != o.getClass()) {
return false;
}

Options options = (Options) o;

return Objects.equals(one, options.one) && Objects.equals(two, options.two);
}

@Override
public int hashCode() {
return Objects.hash(one, two);
}

}
Loading

0 comments on commit c88ba54

Please sign in to comment.