All Downloads are FREE. Search and download functionalities are using the official Maven repository.

software.amazon.awssdk.enhanced.dynamodb.mapper.BeanTableSchema Maven / Gradle / Ivy

/*
 * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
 *
 * Licensed under the Apache License, Version 2.0 (the "License").
 * You may not use this file except in compliance with the License.
 * A copy of the License is located at
 *
 *  http://aws.amazon.com/apache2.0
 *
 * or in the "license" file accompanying this file. This file 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 software.amazon.awssdk.enhanced.dynamodb.mapper;

import java.beans.BeanInfo;
import java.beans.IntrospectionException;
import java.beans.Introspector;
import java.beans.PropertyDescriptor;
import java.lang.annotation.Annotation;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.function.BiConsumer;
import java.util.function.Function;
import java.util.function.Supplier;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import software.amazon.awssdk.annotations.SdkPublicApi;
import software.amazon.awssdk.enhanced.dynamodb.AttributeConverter;
import software.amazon.awssdk.enhanced.dynamodb.AttributeConverterProvider;
import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient;
import software.amazon.awssdk.enhanced.dynamodb.EnhancedType;
import software.amazon.awssdk.enhanced.dynamodb.TableMetadata;
import software.amazon.awssdk.enhanced.dynamodb.TableSchema;
import software.amazon.awssdk.enhanced.dynamodb.internal.mapper.BeanAttributeGetter;
import software.amazon.awssdk.enhanced.dynamodb.internal.mapper.BeanAttributeSetter;
import software.amazon.awssdk.enhanced.dynamodb.internal.mapper.BeanConstructor;
import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.BeanTableSchemaAttributeTag;
import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbAttribute;
import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbBean;
import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbConvertedBy;
import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbFlatten;
import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbIgnore;
import software.amazon.awssdk.services.dynamodb.model.AttributeValue;

/**
 * Implementation of {@link TableSchema} that builds a table schema based on properties and annotations of a bean
 * class. Example:
 * {@code
 * @literal @DynamoDbBean
 * public class CustomerAccount {
 *     private String unencryptedBillingKey;
 *
 *     @literal @DynamoDbPartitionKey
 *     @literal @DynamoDbSecondarySortKey(indexName = "accounts_by_customer")
 *     public String accountId;
 *
 *     @literal @DynamoDbSortKey
 *     @literal @DynamoDbSecondaryPartitionKey(indexName = "accounts_by_customer")
 *     public String customerId;
 *
 *     @literal @DynamoDbAttribute("account_status")
 *     public CustomerAccountStatus status;
 *
 *     @literal @DynamoDbFlatten(dynamoDbBeanClass = Customer.class)
 *     public Customer customer;
 *
 *     public Instant createdOn;
 *
 *     // All public fields must be opted out to not participate in mapping
 *     @literal @DynamoDbIgnore
 *     public String internalKey;
 *
 *     public enum CustomerAccountStatus {
 *         ACTIVE,
 *         CLOSED
 *     }
 * }
 *
 * @literal @DynamoDbBean
 * public class Customer {
 *     public String name;
 *
 *     public List address;
 * }
 * }
 * @param  The type of object that this {@link TableSchema} maps to.
 */
@SdkPublicApi
public final class BeanTableSchema implements TableSchema {
    private static final String ATTRIBUTE_TAG_STATIC_SUPPLIER_NAME = "attributeTagFor";

    private final StaticTableSchema wrappedTableSchema;

    private BeanTableSchema(StaticTableSchema staticTableSchema) {
        this.wrappedTableSchema = staticTableSchema;
    }

    /**
     * Scans a bean class and builds a {@link BeanTableSchema} from it that can be used with the
     * {@link DynamoDbEnhancedClient}.
     * @param beanClass The bean class to build the table schema from.
     * @param  The bean class type.
     * @return An initialized {@link BeanTableSchema}
     */
    public static  BeanTableSchema create(Class beanClass) {
        return new BeanTableSchema<>(createStaticTableSchema(beanClass));
    }

    /**
     * {@inheritDoc}
     * @param attributeMap A map of String to {@link AttributeValue} that contains all the raw attributes to map.
     */
    @Override
    public T mapToItem(Map attributeMap) {
        return wrappedTableSchema.mapToItem(attributeMap);
    }

    /**
     * {@inheritDoc}
     * @param item The modelled Java object to convert into a map of attributes.
     * @param ignoreNulls If set to true; any null values in the Java object will not be added to the output map.
     *                    If set to false; null values in the Java object will be added as {@link AttributeValue} of
     *                    type 'nul' to the output map.
     */
    @Override
    public Map itemToMap(T item, boolean ignoreNulls) {
        return wrappedTableSchema.itemToMap(item, ignoreNulls);
    }

    /**
     * {@inheritDoc}
     * @param item The modelled Java object to extract the map of attributes from.
     * @param attributes A collection of attribute names to extract into the output map.
     */
    @Override
    public Map itemToMap(T item, Collection attributes) {
        return wrappedTableSchema.itemToMap(item, attributes);
    }

    /**
     * {@inheritDoc}
     * @param item The modelled Java object to extract the attribute from.
     * @param key The attribute name describing which attribute to extract.
     */
    @Override
    public AttributeValue attributeValue(T item, String key) {
        return wrappedTableSchema.attributeValue(item, key);
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public TableMetadata tableMetadata() {
        return wrappedTableSchema.tableMetadata();
    }

    @Override
    public EnhancedType itemType() {
        return wrappedTableSchema.itemType();
    }

    private static  StaticTableSchema createStaticTableSchema(Class beanClass) {
        DynamoDbBean dynamoDbBean = beanClass.getAnnotation(DynamoDbBean.class);

        if (dynamoDbBean == null) {
            throw new IllegalArgumentException("A DynamoDb bean class must be annotated with @DynamoDbBean");
        }

        BeanInfo beanInfo;

        try {
            beanInfo = Introspector.getBeanInfo(beanClass);
        } catch (IntrospectionException e) {
            throw new IllegalArgumentException(e);
        }

        Supplier newObjectSupplier = newObjectSupplierForClass(beanClass);

        StaticTableSchema.Builder builder = StaticTableSchema.builder(beanClass)
                                                                .newItemSupplier(newObjectSupplier);

        builder.attributeConverterProviders(createConverterProvidersFromAnnotation(dynamoDbBean));

        List> attributes = new ArrayList<>();

        Arrays.stream(beanInfo.getPropertyDescriptors())
              .filter(BeanTableSchema::isMappableProperty)
              .forEach(propertyDescriptor -> {
                  DynamoDbFlatten dynamoDbFlatten = getPropertyAnnotation(propertyDescriptor, DynamoDbFlatten.class);

                  if (dynamoDbFlatten != null) {
                      builder.flatten(createStaticTableSchema(dynamoDbFlatten.dynamoDbBeanClass()),
                                      getterForProperty(propertyDescriptor, beanClass),
                                      setterForProperty(propertyDescriptor, beanClass));
                  } else {
                      StaticAttribute.Builder attributeBuilder =
                          staticAttributeBuilder(propertyDescriptor, beanClass);

                      Optional attributeConverter =
                              createAttributeConverterFromAnnotation(propertyDescriptor);
                      attributeConverter.ifPresent(attributeBuilder::attributeConverter);

                      addTagsToAttribute(attributeBuilder, propertyDescriptor);
                      attributes.add(attributeBuilder.build());
                  }
              });

        builder.attributes(attributes);

        return builder.build();
    }

    private static List createConverterProvidersFromAnnotation(DynamoDbBean dynamoDbBean) {
        Class[] providerClasses = dynamoDbBean.converterProviders();

        return Arrays.stream(providerClasses)
                .map(c -> (AttributeConverterProvider) newObjectSupplierForClass(c).get())
                .collect(Collectors.toList());
    }

    private static  StaticAttribute.Builder staticAttributeBuilder(PropertyDescriptor propertyDescriptor,
                                                                            Class beanClass) {

        Type propertyType = propertyDescriptor.getReadMethod().getGenericReturnType();
        EnhancedType propertyTypeToken = convertTypeToEnhancedType(propertyType);
        return StaticAttribute.builder(beanClass, propertyTypeToken)
                              .name(attributeNameForProperty(propertyDescriptor))
                              .getter(getterForProperty(propertyDescriptor, beanClass))
                              .setter(setterForProperty(propertyDescriptor, beanClass));
    }

    /**
     * Converts a {@link Type} to an {@link EnhancedType}. Usually {@link EnhancedType#of} is capable of doing this all
     * by itself, but for the BeanTableSchema we want to detect if a parameterized class is being passed without a
     * converter that is actually a {@link DynamoDbBean} in which case we want to capture its schema and add it to the
     * EnhancedType. Unfortunately this means we have to duplicate some of the recursive Type parsing that
     * EnhancedClient otherwise does all by itself.
     */
    @SuppressWarnings("unchecked")
    private static EnhancedType convertTypeToEnhancedType(Type type) {
        Class clazz = null;

        if (type instanceof ParameterizedType) {
            ParameterizedType parameterizedType = (ParameterizedType) type;
            Type rawType = parameterizedType.getRawType();

            if (List.class.equals(rawType)) {
                return EnhancedType.listOf(convertTypeToEnhancedType(parameterizedType.getActualTypeArguments()[0]));
            }

            if (Map.class.equals(rawType)) {
                return EnhancedType.mapOf(EnhancedType.of(parameterizedType.getActualTypeArguments()[0]),
                                          convertTypeToEnhancedType(parameterizedType.getActualTypeArguments()[1]));
            }

            if (rawType instanceof Class) {
                clazz = (Class) rawType;
            }
        } else if (type instanceof Class) {
            clazz = (Class) type;
        }

        if (clazz != null) {
            if (clazz.getAnnotation(DynamoDbBean.class) != null) {
                return EnhancedType.documentOf((Class) clazz,
                                               (TableSchema) createStaticTableSchema(clazz));
            }
        }

        return EnhancedType.of(type);
    }

    private static Optional createAttributeConverterFromAnnotation(
            PropertyDescriptor propertyDescriptor) {
        DynamoDbConvertedBy attributeConverterBean =
                getPropertyAnnotation(propertyDescriptor, DynamoDbConvertedBy.class);
        Optional> optionalClass = Optional.ofNullable(attributeConverterBean)
                                                   .map(DynamoDbConvertedBy::value);
        return optionalClass.map(clazz -> (AttributeConverter) newObjectSupplierForClass(clazz).get());
    }

    /**
     * This method scans all the annotations on a property and looks for a meta-annotation of
     * {@link BeanTableSchemaAttributeTag}. If the meta-annotation is found, it attempts to create
     * an annotation tag based on a standard named static method
     * of the class that tag has been annotated with passing in the original property annotation as an argument.
     */
    private static void addTagsToAttribute(StaticAttribute.Builder attributeBuilder,
                                           PropertyDescriptor propertyDescriptor) {

        propertyAnnotations(propertyDescriptor).forEach(annotation -> {
            BeanTableSchemaAttributeTag beanTableSchemaAttributeTag =
                annotation.annotationType().getAnnotation(BeanTableSchemaAttributeTag.class);

            if (beanTableSchemaAttributeTag != null) {
                Class tagClass = beanTableSchemaAttributeTag.value();

                Method tagMethod;
                try {
                    tagMethod = tagClass.getDeclaredMethod(ATTRIBUTE_TAG_STATIC_SUPPLIER_NAME,
                                                           annotation.annotationType());
                } catch (NoSuchMethodException e) {
                    throw new RuntimeException(
                        String.format("Could not find a static method named '%s' on class '%s' that returns " +
                                          "an AttributeTag for annotation '%s'", ATTRIBUTE_TAG_STATIC_SUPPLIER_NAME,
                                      tagClass, annotation.annotationType()), e);
                }

                if (!Modifier.isStatic(tagMethod.getModifiers())) {
                    throw new RuntimeException(
                        String.format("Could not find a static method named '%s' on class '%s' that returns " +
                                          "an AttributeTag for annotation '%s'", ATTRIBUTE_TAG_STATIC_SUPPLIER_NAME,
                                      tagClass, annotation.annotationType()));
                }

                StaticAttributeTag staticAttributeTag;
                try {
                    staticAttributeTag = (StaticAttributeTag) tagMethod.invoke(null, annotation);
                } catch (IllegalAccessException | InvocationTargetException e) {
                    throw new RuntimeException(
                        String.format("Could not invoke method to create AttributeTag for annotation '%s' on class " +
                                          "'%s'.", annotation.annotationType(), tagClass), e);
                }

                attributeBuilder.addTag(staticAttributeTag);
            }
        });
    }

    private static  Supplier newObjectSupplierForClass(Class clazz) {
        try {
            return BeanConstructor.create(clazz, clazz.getConstructor());
        } catch (NoSuchMethodException e) {
            throw new IllegalArgumentException(
                String.format("Class '%s' appears to have no default constructor thus cannot be used with the " +
                                  "BeanTableSchema", clazz), e);
        }
    }

    private static  Function getterForProperty(PropertyDescriptor propertyDescriptor, Class beanClass) {
        Method readMethod = propertyDescriptor.getReadMethod();
        return BeanAttributeGetter.create(beanClass, readMethod);
    }

    private static  BiConsumer setterForProperty(PropertyDescriptor propertyDescriptor,
                                                             Class beanClass) {
        Method writeMethod = propertyDescriptor.getWriteMethod();
        return BeanAttributeSetter.create(beanClass, writeMethod);
    }

    private static String attributeNameForProperty(PropertyDescriptor propertyDescriptor) {
        DynamoDbAttribute dynamoDbAttribute = getPropertyAnnotation(propertyDescriptor, DynamoDbAttribute.class);
        if (dynamoDbAttribute != null) {
            return dynamoDbAttribute.value();
        }

        return propertyDescriptor.getName();
    }

    private static boolean isMappableProperty(PropertyDescriptor propertyDescriptor) {
        return propertyDescriptor.getReadMethod() != null
            && propertyDescriptor.getWriteMethod() != null
            && getPropertyAnnotation(propertyDescriptor, DynamoDbIgnore.class) == null;
    }

    private static  R getPropertyAnnotation(PropertyDescriptor propertyDescriptor,
                                                                  Class annotationType) {
        R getterAnnotation = propertyDescriptor.getReadMethod().getAnnotation(annotationType);
        R setterAnnotation = propertyDescriptor.getWriteMethod().getAnnotation(annotationType);

        if (getterAnnotation != null) {
            return getterAnnotation;
        }

        return setterAnnotation;
    }

    private static List propertyAnnotations(PropertyDescriptor propertyDescriptor) {
        return Stream.concat(Arrays.stream(propertyDescriptor.getReadMethod().getAnnotations()),
                             Arrays.stream(propertyDescriptor.getWriteMethod().getAnnotations()))
                     .collect(Collectors.toList());
    }
}