Quest For the Spring Data DynamoDB Grail

By | December 11, 2020
DynamoDB logo by AWS - Amazon Web Service, CC BY-SA 4.0.

DynamoDB logo by AWS – Amazon Web Service, CC BY-SA 4.0.

The following are a few words and some simple examples showing how to use Spring Data DynamoDB to store and retrieve data in a DynamoDB database. In the example, which consists of a few tests, I will use a local instance of DynamoDB running in a container. No prior knowledge of DynamoDB is assumed, though the primary focus of this article is not DynamoDB itself and I will not go into detail on this topic.

The complete example project can be found on GitHub.

Background

DynamoDB is a fully managed NoSQL database that supports both key-value and document data structures available in Amazon Web Services, AWS.

Amazon has a Java SDK that can be used with DynamoDB. Coming from a Spring background and having used Spring Data JPA, I went looking for something in the Spring Data project. There are sub-project for MongoDB, Redis, Couchbase and Neo4J among other things but I did not find anything for DynamoDB. Much to my surprise I did find a Spring Data DynamoDB project on GitHub – well, actually there are three; the first and second seems more or less abandoned, while the third is the most recently updated. All these three projects have one and the same origin.
Despite their names, none of these projects are official Spring Data projects, as far as I know.

Prerequisites

Since the tests use Testcontainers to run a local instance of DynamoDB in a container, Docker needs to be installed and running.

Create the Project

The example project is a Spring Boot project and was created using the Spring Initializr using this link.

Dependencies

These are a few words on some of the more notable dependencies of the example project.

Testcontainers

When starting on writing the example code for this article, Testcontainers 1.15.0 hadn’t been released and there was an issue with version 1.14.0 in conjunction with Docker Desktop for Mac. Fortunately the issue has been fixed in version 1.15.0 and while writing this article, version 1.15.0 was released.

<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>junit-jupiter</artifactId>
    <version>1.15.0</version>
    <scope>test</scope>
</dependency>

What happened with version Testcontainers version 1.14.3 was that it was not able to properly start the Ryuk helper container when gRPC FUSE for file sharing was enabled in Docker Desktop for Mac. Disabling gRPC FUSE file sharing in the Docker Desktop for Mac preferences would also alleviate the issue.

Spring Data DynamoDB

The next dependency that has to be added manually is the Spring Data DynamoDB dependency. Initially I tried the derjust Spring Data DynamoDB dependency but I ran into several issues since the project has not been updated for a recent version of the Spring framework and Spring Data. After some searching I found the boostchicken fork of Spring Data DynamoDB which works significantly better with an up-to-date version of Spring Boot and seems to be actively maintained.

<dependency>
    <groupId>io.github.boostchicken</groupId>
    <artifactId>spring-data-dynamodb</artifactId>
    <version>5.2.5</version>
</dependency>

Entity Classes

The example contains two abstract and two concrete entity classes.

Example program entity classes.
Example program entity classes.

All entity classes are annotated with the Lombok annotations @Data and @NoArgsConstructor.

EntityWithStringId

The EntityWithStringId class is the base class for entities which have a string id, which in this example are all entity classes.

package se.ivankrizsan.springdata.dynamodb.domain;

import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBAttribute;
import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBAutoGenerateStrategy;
import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBAutoGeneratedKey;
import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBAutoGeneratedTimestamp;
import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBHashKey;
import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBMapperFieldModel;
import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBTable;
import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBTyped;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.joda.time.DateTime;

/**
 * Abstract base class for entities that has a string id.
 * Note that base classes which contain annotated entity attributes must be
 * annotated with the @DynamoDBTable annotation or else the annotated attributes
 * will not be discovered.
 *
 * @author Ivan Krizsan
 */
@Data
@NoArgsConstructor
@DynamoDBTable(tableName = "EntityWithStringId")
public abstract class EntityWithStringId {
    /* Constant(s): */

    /* Instance variable(s): */
    /** Entity id. */
    @DynamoDBHashKey
    @DynamoDBAutoGeneratedKey
    protected String id;
    /** Auto-generated timestamp at which the shape was created or updated. */
    @DynamoDBAutoGeneratedTimestamp(strategy = DynamoDBAutoGenerateStrategy.ALWAYS)
    @DynamoDBTyped(DynamoDBMapperFieldModel.DynamoDBAttributeType.N)
    @DynamoDBAttribute
    protected DateTime lastUpdateTime;
}

Note that:

  • All DynamoDB annotation imports originate from the Amazon AWS SDK.
    To be more specific, the annotations are from the aws-java-sdk-dynamodb dependency. This means that arranging the entity classes like in this example is possible even when not using Spring Data DynamoDB.
  • The class is annotated with the @DynamoDBTable.
    There will not be a table for this entity type since it is abstract but since there are attributes in the class annotated with DynamoDB annotations, the class needs to be annotated with the @DynamoDBTable annotation. The tableName attribute of the annotation is required, so it has to be specified despite not being used.
  • The id property is annotated with the @DynamoDBHashKey annotation.
    This marks the property as a hash-key, which is similar to an id in an entity.
  • The id property is annotated with the @DynamoDBAutoGeneratedKey annotation.
    This annotation, which may only be applied to string-typed properties, will cause the key to be assigned a random UUID when an entity is persisted.
  • The lastUpdateTime property is annotated with the @DynamoDBAttribute annotation.
    The @DynamoDBAttribute annotation marks a property of a class as an attribute in a DynamoDB table.
  • The lastUpdateTime property is also annotated with the @DynamoDBAutoGeneratedTimestamp annotation.
    This will cause DynamoDB to automatically generate a time-stamp whenever an entity is created or updated (ALWAYS) in the database.
  • Finally the lastUpdateTime property is also annotated with the @DynamoDBTyped(DynamoDBMapperFieldModel.DynamoDBAttributeType.N) annotation.
    This instructs DynamoDB to store the value of the property as a numeric value in the database.

Shape

The abstract shape entity defines some common properties common to all shapes. The properties are an x-coordinate, a y-coordinate and a colour.

package se.ivankrizsan.springdata.dynamodb.domain;

import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBAttribute;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import lombok.ToString;

/**
 * Abstract base class for shape entities located at a position in a two-dimensional
 * coordinate system and that has a colour.
 * Note that since the parent class is annotated with @DynamoDBTable, this class
 * does not need to be annotated with this annotation since it is inherited.
 *
 * @author Ivan Krizsan
 */
@Data
@ToString(callSuper = true)
@NoArgsConstructor
@EqualsAndHashCode(callSuper = true)
public abstract class Shape extends EntityWithStringId {
    /* Constant(s): */

    /* Instance variable(s): */
    /** Shape location x-coordinate. */
    @DynamoDBAttribute
    protected int x;
    /** Shape location y-coordinate. */
    @DynamoDBAttribute
    protected int y;
    /** Shape colour. */
    @DynamoDBAttribute
    protected String colour;

    /**
     * Sets the position of the shape to the supplied coordinates.
     *
     * @param inX X-coordinate of shape position.
     * @param inY Y-coordinate of shape position.
     */
    public void setPosition(final int inX, final int inY) {
        x = inX;
        y = inY;
    }
}

Note that:

  • The Shape class is not annotated with the @DynamoDBTable annotation.
    The annotation is inherited and since the superclass is annotated, there is no need to annotate the Shape class.
  • The class is annotated with the @ToString annotation.
    This is a Lombok annotation added for debugging purposes, to produce better output when logging an entity.
  • The class is also annotated with the @EqualsAndHashCode annotation.
    This is also a Lombok annotation and it instructs Lombok to add equals and hashCode methods that also calls the corresponding superclass methods.

Circle and Rectangle

Finally, there are two concrete entity classes in the example program; Circle and Rectangle.

package se.ivankrizsan.springdata.dynamodb.domain;

import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBAttribute;
import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBTable;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import lombok.ToString;

/**
 * Represents a circle shape with a given radius.
 *
 * @author Ivan Krizsan
 */
@Data
@NoArgsConstructor
@ToString(callSuper = true)
@EqualsAndHashCode(callSuper = true)
@DynamoDBTable(tableName = "circles")
public class Circle extends Shape {
    /* Constant(s): */
    public static final int DEFAULT_RADIUS = 10;

    /* Instance variable(s): */
    /** Circle radius. */
    @DynamoDBAttribute
    protected int radius = DEFAULT_RADIUS;
}
package se.ivankrizsan.springdata.dynamodb.domain;

import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBAttribute;
import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBTable;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import lombok.ToString;

/**
 * Represents a rectangle shape with a given height and width.
 *
 * @author Ivan Krizsan
 */
@Data
@NoArgsConstructor
@ToString(callSuper = true)
@EqualsAndHashCode(callSuper = true)
@DynamoDBTable(tableName = "rectangles")
public class Rectangle extends Shape {
    /* Constant(s): */
    public static final int DEFAULT_WIDTH = 10;
    public static final int DEFAULT_HEIGHT = 10;

    /* Instance variable(s): */
    /** Rectangle height. */
    @DynamoDBAttribute
    protected int height = DEFAULT_HEIGHT;
    /** Rectangle width. */
    @DynamoDBAttribute
    protected int width = DEFAULT_WIDTH;
}

Note that:

  • Both classes are annotated with the @DynamoDBTable annotation.
    The reason for this is to override the name of the tables in which the respective entities are stored.

Repositories

So far the example has made do without Spring Data DynamoDB, but this will change as the repositories are introduced. As with, for example Spring Data JPA, the circle and rectangle repositories are just two interfaces without any code.

package se.ivankrizsan.springdata.dynamodb.repositories;

import org.socialsignin.spring.data.dynamodb.repository.EnableScan;
import org.springframework.data.repository.CrudRepository;
import se.ivankrizsan.springdata.dynamodb.domain.Circle;

import java.util.List;

/**
 * DynamoDB repository containing {@code Circle}s.
 *
 * @author Ivan Krizsan
 * @see Circle
 */
@EnableScan
public interface CirclesRepository extends CrudRepository<Circle, String> {

    /**
     * Finds circles that which colour matches the supplied colour.
     *
     * @param colour Colour to match.
     * @return Circles which colour match.
     */
    List<Circle> findCirclesByColour(final String colour);
}
package se.ivankrizsan.springdata.dynamodb.repositories;

import org.socialsignin.spring.data.dynamodb.repository.EnableScan;
import org.springframework.data.repository.CrudRepository;
import se.ivankrizsan.springdata.dynamodb.domain.Rectangle;

/**
 * DynamoDB repository containing {@code Rectangle}s.
 *
 * @author Ivan Krizsan
 * @see Rectangle
 */
@EnableScan
public interface RectanglesRepository extends CrudRepository<Rectangle, String> {
}

Note that:

  • The @EnableScan annotation annotating each repository interface is from Spring Data DynamoDB.
  • The CrudRepository interface extended by both of the above interfaces is from Spring Data.
    To be more precise, the interface is located in the spring-data-commons dependency.
  • The CirclesRepository interface contains a method named findCirclesByColour.
    This is a custom query method which will allow for searching for circles based on their colour. As with Spring Data JPA, all that is needed is to declare a query method in the repository interface according to certain rules and Spring Data DynamoDB will issue an appropriate query when the repository method is invoked.

Persistence Configuration

It should be noted that the persistence configuration will not necessarily suffice for a production application running in AWS.

package se.ivankrizsan.springdata.dynamodb.demo;

import com.amazonaws.auth.AWSCredentials;
import com.amazonaws.auth.AWSStaticCredentialsProvider;
import com.amazonaws.auth.BasicAWSCredentials;
import com.amazonaws.client.builder.AwsClientBuilder;
import com.amazonaws.services.dynamodbv2.AmazonDynamoDB;
import com.amazonaws.services.dynamodbv2.AmazonDynamoDBClientBuilder;
import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBMapperConfig;
import org.socialsignin.spring.data.dynamodb.repository.config.EnableDynamoDBRepositories;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.repository.query.QueryLookupStrategy;
import se.ivankrizsan.springdata.dynamodb.repositories.CirclesRepository;

/**
 * Persistence configuration.
 *
 * @author Ivan Krizsan
 */
@Configuration
@EnableDynamoDBRepositories(basePackageClasses = CirclesRepository.class)
public class PersistenceConfiguration {
    /* Constant(s): */

    /* Dependencies: */
    @Value(("${amazon.dynamodb.endpoint}"))
    protected String mDynamoDBEndpoint;
    @Value("${amazon.aws.accesskey}")
    protected String mAWSAccessKey;
    @Value("${amazon.aws.secretkey}")
    protected String mAWSSecretKey;
    @Value("${amazon.dynamodb.tablenameprefix}")
    protected String mDynamoDBTableNamePrefix;
    @Value("${amazon.aws.region}")
    protected String mAWSRegion;

    /**
     * Creates a bean containing basic AWS credentials.
     *
     * @return AWS credentials bean.
     */
    @Bean
    public AWSCredentials awsCredentials() {
        return new BasicAWSCredentials(mAWSAccessKey, mAWSSecretKey);
    }

    /**
     * Creates a DynamoDB client bean for the DynamoDB instance with the supplied credentials
     * and being available at the endpoint injected into this configuration.
     * Must be named "amazonDynamoDB", otherwise Spring Data DynamoDB initialization will fail.
     *
     * @param inAWSCredentials AWS credentials.
     * @return DynamoDB client bean.
     */
    @Bean(destroyMethod = "shutdown")
    public AmazonDynamoDB amazonDynamoDB(final AWSCredentials inAWSCredentials) {
        return AmazonDynamoDBClientBuilder
            .standard()
            .withCredentials(new AWSStaticCredentialsProvider(inAWSCredentials))
            .withEndpointConfiguration(new AwsClientBuilder.EndpointConfiguration(mDynamoDBEndpoint,
                mAWSRegion))
            .build();
    }

    /**
     * Creates a DynamoDB mapper configuration bean in order to prepend all tables names with
     * an application-specific prefix.
     * Note that the name of the bean has to be dynamoDB-DynamoDBMapperConfig in order to
     * override the DynamoDB mapper configuration bean from Spring Data DynamoDB.
     *
     * @return DynamoDB mapper configuration.
     */
    @Bean(name = "dynamoDB-DynamoDBMapperConfig")
    public DynamoDBMapperConfig dynamoDBMapperConfig() {
        return new DynamoDBMapperConfig.Builder()
            .withTableNameOverride(
                DynamoDBMapperConfig.TableNameOverride.withTableNamePrefix(mDynamoDBTableNamePrefix))
            .build();
    }
}

Note that:

  • The persistence configuration class is annotated with the @EnableDynamoDBRepositories annotation.
    This is a Spring Data DynamoDB annotation which tells Spring Data DynamoDB in which package(s) the repositories are located.
  • The awsCredentials bean is a bean that contains the basic AWS access credentials.
    These credentials consists of the AWS access key and the associated AWS secret key.
  • The amazonDynamoDB bean method creates a DynamoDB client bean.
    In this version of the bean, the AWS credentials, AWS region and DynamoDB endpoint are configuration properties injected into the configuration class.
  • Finally there is the dynamoDBMapperConfig bean.
    This bean is created in order to prepend all table names with an application-specific prefix. Note that the name of the bean has to be dynamoDB-DynamoDBMapperConfig in order to override the DynamoDB mapper configuration bean from Spring Data DynamoDB.
    Given this configuration bean, a DynamoDB mapper, which is another type of DynamoDB client, will be created. More details on the DynamoDB mapper client will follow later.

Persistence Test Configuration

The persistence test configuration has no connection to Spring Data DynamoDB but shows how a local instance of DynamoDB is started in a container. This local instance is used when running the tests, in order to test against a real DynamoDB instance.

package se.ivankrizsan.springdata.dynamodb.demo;

import com.amazonaws.auth.AWSCredentials;
import com.amazonaws.auth.AWSStaticCredentialsProvider;
import com.amazonaws.client.builder.AwsClientBuilder;
import com.amazonaws.services.dynamodbv2.AmazonDynamoDB;
import com.amazonaws.services.dynamodbv2.AmazonDynamoDBClientBuilder;
import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.containers.wait.strategy.HostPortWaitStrategy;
import org.testcontainers.utility.DockerImageName;

/**
 * Persistence configuration used by tests.
 *
 * @author Ivan Krizsan
 */
@Configuration
public class PersistenceTestConfiguration {
    /* Constant(s): */
    private static final Logger LOGGER =
        LoggerFactory.getLogger(PersistenceTestConfiguration.class);
    protected final static int DYNAMODB_PORT = 8000;

    /* Dependencies: */


    /**
     * Creates a local DynamoDB instance running in a Docker container.
     * Ensures that the container is started before bean creation completes, in
     * order to be able to obtain the host port on which the DynamoDB instance is available.
     *
     * @return DynamoDB Testcontainers container.
     */
    @Bean
    public GenericContainer dynamoDBContainer() {
        final GenericContainer theDynamoDBContainer =
            new GenericContainer(DockerImageName.parse("amazon/dynamodb-local:latest"))
                .withExposedPorts(DYNAMODB_PORT);
        theDynamoDBContainer.waitingFor(new HostPortWaitStrategy());
        theDynamoDBContainer.start();
        return theDynamoDBContainer;
    }

    /**
     * Creates a DynamoDB client bean for the DynamoDB instance running in the supplied
     * container with the supplied credentials.
     * Must be named "amazonDynamoDB", otherwise Spring Data DynamoDB initialization will fail.
     *
     * @param inDynamoDBCredentials DynamoDB credentials.
     * @param inDynamoDBContainer DynamoDB Testcontainers container.
     * @return DynamoDB client bean.
     */
    @Bean(destroyMethod = "shutdown")
    public AmazonDynamoDB amazonDynamoDB(
        final AWSCredentials inDynamoDBCredentials,
        @Qualifier("dynamoDBContainer") final GenericContainer inDynamoDBContainer) {

        /* Construct the DynamoDB instance URL pointing at the Testcontainers container. */
        final String theDynamoDbEndpoint = "http://"
            + inDynamoDBContainer.getHost()
            + ":"
            + inDynamoDBContainer.getMappedPort(DYNAMODB_PORT);

        LOGGER.info("DynamoDB endpoint URL: {}", theDynamoDbEndpoint);

        final AmazonDynamoDB theDynamoDBClient = AmazonDynamoDBClientBuilder
            .standard()
            .withCredentials(new AWSStaticCredentialsProvider(inDynamoDBCredentials))
            .withEndpointConfiguration(new AwsClientBuilder.EndpointConfiguration(
                theDynamoDbEndpoint,
                ""))
            .build();

        return theDynamoDBClient;
    }

    /**
     * Creates a DynamoDB mapper bean using the supplied DynamoDB client.
     * Note that the bean name must be dynamoDB-DynamoDBMapper in order to
     * override the DynamoDB mapper bean from Spring Data DynamoDB.
     *
     * @param inDynamoDBClient DynamoDB client to be used by mapper.
     * @return DynamoDB mapper bean.
     */
    @Bean(name = "dynamoDB-DynamoDBMapper")
    public DynamoDBMapper dynamoDBMapper(final AmazonDynamoDB inDynamoDBClient) {
        return new DynamoDBMapper(inDynamoDBClient);
    }
}

Note that:

  • The dynamoDBContainer bean method creates a Testcontainer instance that starts an instance in a container and waits until being able to establish a connection to a port of the container. One port, port 8000, of the container will be exposed.
  • The amazonDynamoDB bean method creates a DynamoDB client bean.
    The reason for overriding this bean and having the dynamoDBContainer bean injected is in order to be able to get the host/IP address and port used to connect the client to the containerized DynamoDB instance. This bean can be used to, for example, create tables in DynamoDB but is not used in this example.
  • The dynamoDBMapper bean method creates a DynamoDB mapper, which is another type of DynamoDB client.
    The mapper is the regular way to persist and retrieve Java objects to/from DynamoDB if Spring Data DynamoDB hadn’t been used. Note that the bean name must be dynamoDB-DynamoDBMapper in order to override the DynamoDB mapper bean from Spring Data DynamoDB.

Tests

The example project contains one single test class that contains the example-tests:

package se.ivankrizsan.springdata.dynamodb.demo;

import com.amazonaws.services.dynamodbv2.AmazonDynamoDB;
import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBMapper;
import com.amazonaws.services.dynamodbv2.model.CreateTableRequest;
import com.amazonaws.services.dynamodbv2.model.GlobalSecondaryIndex;
import com.amazonaws.services.dynamodbv2.model.ProvisionedThroughput;
import com.amazonaws.services.dynamodbv2.util.TableUtils;
import org.apache.commons.collections4.IterableUtils;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.TestPropertySource;
import se.ivankrizsan.springdata.dynamodb.domain.Circle;
import se.ivankrizsan.springdata.dynamodb.domain.Rectangle;
import se.ivankrizsan.springdata.dynamodb.repositories.CirclesRepository;
import se.ivankrizsan.springdata.dynamodb.repositories.RectanglesRepository;

import java.util.HashMap;
import java.util.List;
import java.util.Map;

/**
 * Examples of persistence in DynamoDB with Spring Data DynamoDB.
 *
 * @author Ivan Krizsan
 */
@SpringBootTest(classes = {
    PersistenceConfiguration.class,
    PersistenceTestConfiguration.class
})
@TestPropertySource("classpath:/application.properties")
class DynamoDBPersistenceTests {
    /* Constant(s): */
    private static final Logger LOGGER = LoggerFactory.getLogger(DynamoDBPersistenceTests.class);
    protected final static int CIRCLE_RADIUS = 11;
    protected final static String CIRCLE_COLOUR = "blue";
    protected final static String RECTANGLE_COLOUR = "red";
    protected final static int MANY_CIRCLES_COUNT = 500;
    protected final static String[] COLOURS = { "red", "green", "blue", "purple", "black", "white" };

    /* Instance variable(s): */
    @Autowired
    protected CirclesRepository mCirclesRepository;
    @Autowired
    protected RectanglesRepository mRectanglesRepository;
    @Autowired
    protected DynamoDBMapper mDynamoDBMapper;
    @Autowired
    protected AmazonDynamoDB mAmazonDynamoDB;

    /**
     * Cleans up after each test by deleting the contents of the database tables.
     */
    @AfterEach
    public void cleanup() {
        LOGGER.info("Deleting contents of database tables");
        mCirclesRepository.deleteAll();
        mRectanglesRepository.deleteAll();
    }

    /**
     * Tests persisting one circle and retrieving all circles.
     * Expected result:
     * Retrieving all circles should yield one single circle.
     * The retrieved circle should be identical to the persisted one.
     */
    @Test
    public void persistOneCircleTest() {
        /* Create a circle to be persisted. */
        final Circle theOriginalCircle = createCircle();

        /* Persist the circle. */
        final Circle theExpectedCircle = mCirclesRepository.save(theOriginalCircle);
        LOGGER.info("Circle last updated time: {}", theExpectedCircle.getLastUpdateTime());

        /* Find all circles in the repository. */
        final List<Circle> theCirclesList = IterableUtils.toList(mCirclesRepository.findAll());

        /* Verify the retrieved circle and some of its properties. */
        Assertions.assertEquals(
            1,
            theCirclesList.size(), "One circle should have been persisted");
        final Circle theFoundCircle = theCirclesList.get(0);
        LOGGER.info("Found circle last updated time: {}", theFoundCircle.getLastUpdateTime());
        Assertions.assertEquals(theExpectedCircle, theFoundCircle, "Circle properties should match");
    }

    /**
     * Tests persisting one rectangle and retrieving all rectangles.
     * Expected result:
     * Retrieving all rectangles should yield one single rectangle.
     * The retrieved rectangle should be identical to the persisted one.
     */
    @Test
    public void persistOneRectangleTest() {
        /* Create a rectangle to be persisted. */
        final Rectangle theOriginalRectangle = createRectangle();

        /* Persist the rectangle. */
        final Rectangle theExpectedRectangle = mRectanglesRepository.save(theOriginalRectangle);
        LOGGER.info("Rectangle last updated time: {}", theExpectedRectangle.getLastUpdateTime());

        /* Find all rectangles in the repository. */
        final List<Rectangle> theRectanglesList = IterableUtils.toList(mRectanglesRepository.findAll());

        /* Verify the retrieved rectangle and its properties. */
        Assertions.assertEquals(
            1,
            theRectanglesList.size(), "One rectangle should have been persisted");
        final Rectangle theFoundRectangle = theRectanglesList.get(0);
        LOGGER.info("Found rectangle last updated time: {}", theFoundRectangle.getLastUpdateTime());
        Assertions.assertEquals(theExpectedRectangle, theFoundRectangle, "Rectangle properties should match");
    }

    /**
     * Tests persisting many circles and retrieving all circles.
     * Expected result:
     * Retrieving all circles should yield the same number of circles as persisted.
     * The retrieved circles should be identical to the persisted ones.
     */
    @Test
    public void persistManyCirclesTest() {
        /* Persist the circles. */
        final Map<Integer, Circle> thePersistedCircles = new HashMap<>();
        for (int i = 1; i < MANY_CIRCLES_COUNT + 1; i++) {
            /* Create a circle to be persisted. */
            final Circle theOriginalCircle = new Circle();
            theOriginalCircle.setPosition(12 + i, 14 + i);
            theOriginalCircle.setColour(CIRCLE_COLOUR);
            theOriginalCircle.setRadius(i);

            final Circle thePersistedCircle = mCirclesRepository.save(theOriginalCircle);

            /* Use the radius as key as each circle has a different radius. */
            thePersistedCircles.put(thePersistedCircle.getRadius(), thePersistedCircle);
        }

        /* Find all circles in the repository. */
        final List<Circle> theFoundCircles = IterableUtils.toList(mCirclesRepository.findAll());

        /* Verify the number of persisted circles. */
        Assertions.assertEquals(
            MANY_CIRCLES_COUNT,
            theFoundCircles.size(), "A lot of circles should have been persisted");

        /* Verify properties of the circles. */
        for (Circle theActualCircle : theFoundCircles) {
            final Circle theExpectedCircle = thePersistedCircles.get(theActualCircle.getRadius());
            Assertions.assertEquals(theExpectedCircle,
                theActualCircle,
                "Circle properties should match");
        }
    }

    /**
     * Tests persisting one circle and one rectangle and retrieving all the
     * circles and rectangles.
     * Expected result:
     * There should be one circle and one rectangle retrieved.
     * The retrieved entities should be identical to the persisted ones.
     */
    @Test
    public void persistMultipleEntityTypesTest() {
        /* Create and persist one rectangle. */
        final Rectangle theOriginalRectangle = createRectangle();
        final Rectangle theExpectedRectangle = mRectanglesRepository.save(theOriginalRectangle);

        /* Create and persist one circle. */
        final Circle theOriginalCircle = createCircle();
        final Circle theExpectedCircle = mCirclesRepository.save(theOriginalCircle);

        /* Retrieve all circles and rectangles. */
        final List<Circle> theCirclesList = IterableUtils.toList(mCirclesRepository.findAll());
        final List<Rectangle> theRectanglesList = IterableUtils.toList(mRectanglesRepository.findAll());

        /* Verify the retrieved circle and some of its properties. */
        Assertions.assertEquals(
            1,
            theCirclesList.size(), "One circle should have been persisted");
        final Circle theFoundCircle = theCirclesList.get(0);
        Assertions.assertEquals(theExpectedCircle, theFoundCircle, "Circle properties should match");

        /* Verify the retrieved rectangle and its properties. */
        Assertions.assertEquals(
            1,
            theRectanglesList.size(), "One rectangle should have been persisted");
        final Rectangle theFoundRectangle = theRectanglesList.get(0);
        Assertions.assertEquals(theExpectedRectangle, theFoundRectangle, "Rectangle properties should match");

    }

    /**
     * Tests finding circles by colour.
     * Uses a custom query method declared in the repository.
     * Expected result:
     * One circle should be found.
     * The colour of the found circle should match the sought after colour.
     */
    @Test
    public void findCirclesByColourTest() {
        /* Create and persist circles of different colour. */
        for (String theColour : COLOURS) {
            final Circle theCircle = createCircle();
            theCircle.setColour(theColour);
            mCirclesRepository.save(theCircle);
        }

        /* Find all blue circles in the database. */
        final List<Circle> theBlueCircles = mCirclesRepository.findCirclesByColour(CIRCLE_COLOUR);

        /* Verify that only one circle was found and that it indeed is blue. */
        Assertions.assertEquals(
            1,
            theBlueCircles.size(),
            "Only one circle should have a matching colour");
        final Circle theFoundCircle = theBlueCircles.get(0);
        Assertions.assertEquals(
            CIRCLE_COLOUR,
            theFoundCircle.getColour(),
            "The colour of the found circle should be blue");
    }

    /**
     * Creates a DynamoDB table for the supplied entity type.
     * Not currently used, but included as an example showing how to create a DynamoDB
     * table programmatically.
     *
     * @param inEntityType Entity type for which to create table.
     */
    protected void createDynamoDBTableForEntityType(final Class inEntityType) {
        LOGGER.info("About to create table for entity type {}", inEntityType.getSimpleName());
        try {
            /* Prepare create entity table request. */
            final CreateTableRequest theCreateTableRequest =
                mDynamoDBMapper.generateCreateTableRequest(inEntityType);
            final ProvisionedThroughput theEntityTableProvisionedThroughput =
                new ProvisionedThroughput(10L, 10L);
            theCreateTableRequest.setProvisionedThroughput(
                theEntityTableProvisionedThroughput);

            /* Set provisioned throughput for global secondary indexes, if any. */
            final List<GlobalSecondaryIndex> theEntityTableGlobalSecondaryIndexes =
                theCreateTableRequest.getGlobalSecondaryIndexes();
            if (theEntityTableGlobalSecondaryIndexes != null && theEntityTableGlobalSecondaryIndexes.size() > 0) {
                theCreateTableRequest
                    .getGlobalSecondaryIndexes()
                    .forEach(v -> v.setProvisionedThroughput(theEntityTableProvisionedThroughput));
            }

            /* Create table in which to persist entities. */
            TableUtils.createTableIfNotExists(mAmazonDynamoDB, theCreateTableRequest);
            TableUtils.waitUntilActive(mAmazonDynamoDB, theCreateTableRequest.getTableName());
            LOGGER.info("Table {} now available", theCreateTableRequest.getTableName());
        } catch (final Exception theException) {
            LOGGER.info("Exception occurred creating table for type {}: {} ", inEntityType.getSimpleName(), theException.getMessage());
        }
    }

    /**
     * Creates a new rectangle setting its properties.
     *
     * @return A new rectangle.
     */
    protected Rectangle createRectangle() {
        final Rectangle theRectangle = new Rectangle();
        theRectangle.setPosition(15, 17);
        theRectangle.setHeight(20);
        theRectangle.setWidth(40);
        theRectangle.setColour(RECTANGLE_COLOUR);
        return theRectangle;
    }

    /**
     * Create a new circle setting its properties.
     *
     * @return A new circle.
     */
    protected Circle createCircle() {
        final Circle theOriginalCircle = new Circle();
        theOriginalCircle.setRadius(CIRCLE_RADIUS);
        theOriginalCircle.setPosition(12, 14);
        theOriginalCircle.setColour(CIRCLE_COLOUR);
        return theOriginalCircle;
    }
}

Note that:

  • The test-class is annotated with @SpringBootTest.
    This is a regular Spring Boot test with the configuration classes PersistenceConfiguration and PersistenceTestConfiguration.
  • There is no before-method that creates database tables.
    Since the spring.data.dynamodb.entity2ddl.auto property is set to create-only in the application.properties file, Spring Data DynamoDB will automatically create tables for the different repositories it finds in the same manner as, for example, Spring Data JPA.
  • There is a method named cleanup annotated with @AfterEach.
    This method deletes all the entities persisted in the two repositories of the application after each tests.
  • There are two test-methods named persistOneCircleTest and persistOneRectangleTest.
    These methods tests the trivial case of persisting one entity and retrieving all entities, verifying that only a single entity matching the persisted entity is retrieved.
  • There is a test-method persistManyCirclesTest.
    This test persist multiple circle entities, retrieves all circles and verifies that the retrieved circles match the original circles.
  • There is a test-method named persistMultipleEntityTypesTest.
    Here, circles and rectangles are persisted and all entities are retrieved and verified similar to what we’ve seen in the earlier test-methods.
  • There is a test-method named findCirclesByColourTest.
    In this method, a custom query method declared in the circle repository is used to search for circles with a particular colour. Recall from the Shape entity class that the colour of a shape is just a regular DynamoDB attribute.
  • There is a method named createDynamoDBTableForEntityType.
    As the name indicates, the method creates a table in the DynamoDB for the supplied entity type. This method is not used in the example tests, but was preserved to show how to create tables in DynamoDB without relying on Spring Data DynamoDB.

Running the Tests

Before running the tests, do remember to start Docker if you are not using a Linux operating system!
When run, the tests should all pass. Logs similar to this will appear in the console:

2020-12-11 09:34:58.151  INFO 3109 --- [           main] 🐳 [amazon/dynamodb-local:latest]        : Creating container for image: amazon/dynamodb-local:latest
2020-12-11 09:34:58.194  INFO 3109 --- [           main] 🐳 [amazon/dynamodb-local:latest]        : Starting container with ID: ec31b6d2b5e9af2418665ba8cfabed84075f4fbdee284339c68509a97fd0e6dd
2020-12-11 09:34:58.474  INFO 3109 --- [           main] 🐳 [amazon/dynamodb-local:latest]        : Container amazon/dynamodb-local:latest is starting: ec31b6d2b5e9af2418665ba8cfabed84075f4fbdee284339c68509a97fd0e6dd
2020-12-11 09:34:59.708  INFO 3109 --- [           main] 🐳 [amazon/dynamodb-local:latest]        : Container amazon/dynamodb-local:latest started in PT3.000631S
2020-12-11 09:34:59.744  INFO 3109 --- [           main] s.i.s.d.d.PersistenceTestConfiguration   : DynamoDB endpoint URL: http://localhost:32771
2020-12-11 09:35:00.381  INFO 3109 --- [           main] o.s.s.d.d.r.s.DynamoDBRepositoryFactory  : Spring Data DynamoDB Version: 5.2.5 (2.2)
2020-12-11 09:35:00.381  INFO 3109 --- [           main] o.s.s.d.d.r.s.DynamoDBRepositoryFactory  : Spring Data Version:          2.3.5.RELEASE
2020-12-11 09:35:00.381  INFO 3109 --- [           main] o.s.s.d.d.r.s.DynamoDBRepositoryFactory  : AWS SDK Version:              1.11.664
2020-12-11 09:35:00.381  INFO 3109 --- [           main] o.s.s.d.d.r.s.DynamoDBRepositoryFactory  : Java Version:                 11.0.5 - OpenJDK 64-Bit Server VM 11.0.5+10-LTS
2020-12-11 09:35:00.381  INFO 3109 --- [           main] o.s.s.d.d.r.s.DynamoDBRepositoryFactory  : Platform Details:             Mac OS X
2020-12-11 09:35:00.381  WARN 3109 --- [           main] o.s.s.d.d.r.s.DynamoDBRepositoryFactory  : This Spring Data DynamoDB implementation might not be compatible with the available Spring Data classes on the classpath!
NoDefClassFoundExceptions or similar might occur!
2020-12-11 09:35:00.518  INFO 3109 --- [           main] d.d.r.u.Entity2DynamoDBTableSynchronizer : Checking repository classes with DynamoDB tables circles, rectangles for ContextRefreshedEvent
2020-12-11 09:35:01.112  INFO 3109 --- [           main] s.i.s.d.demo.DynamoDBPersistenceTests    : Started DynamoDBPersistenceTests in 5.056 seconds (JVM running for 6.13)
2020-12-11 09:35:05.429  INFO 3109 --- [           main] s.i.s.d.demo.DynamoDBPersistenceTests    : Deleting contents of database tables
2020-12-11 09:35:06.068  INFO 3109 --- [           main] s.i.s.d.demo.DynamoDBPersistenceTests    : Deleting contents of database tables
2020-12-11 09:35:06.102  INFO 3109 --- [           main] s.i.s.d.demo.DynamoDBPersistenceTests    : Rectangle last updated time: 2020-12-11T09:35:06.094+01:00
2020-12-11 09:35:06.112  INFO 3109 --- [           main] s.i.s.d.demo.DynamoDBPersistenceTests    : Found rectangle last updated time: 2020-12-11T09:35:06.094+01:00
2020-12-11 09:35:06.113  INFO 3109 --- [           main] s.i.s.d.demo.DynamoDBPersistenceTests    : Deleting contents of database tables
2020-12-11 09:35:06.137  INFO 3109 --- [           main] s.i.s.d.demo.DynamoDBPersistenceTests    : Circle last updated time: 2020-12-11T09:35:06.132+01:00
2020-12-11 09:35:06.142  INFO 3109 --- [           main] s.i.s.d.demo.DynamoDBPersistenceTests    : Found circle last updated time: 2020-12-11T09:35:06.132+01:00
2020-12-11 09:35:06.143  INFO 3109 --- [           main] s.i.s.d.demo.DynamoDBPersistenceTests    : Deleting contents of database tables
2020-12-11 09:35:06.184  INFO 3109 --- [           main] s.i.s.d.demo.DynamoDBPersistenceTests    : Deleting contents of database tables
2020-12-11 09:35:06.215  INFO 3109 --- [extShutdownHook] d.d.r.u.Entity2DynamoDBTableSynchronizer : Checking repository classes with DynamoDB tables circles, rectangles for ContextClosedEvent

Note the highlighted log row in the above output. Apparently I have used a version of Spring Boot, which in turn uses a version of Spring Data that is newer than the version which Spring Data DynamoDB was built against. Examining the Spring Data DynamoDB pom, one discovers that, at the time of writing, the most recent version of Spring Data DynamoDB was built against Spring Data 2.3.2.RELEASE. While I have had no issues so far, this may be something to pay attention to.

Final Words

Spring Data DynamoDB enables developers like myself that has experience with Spring Data JPA to quite easily get started using DynamoDB. Despite not being an official Spring/Pivotal project, the authors have managed to achieve the same “look-and-feel”. It made me happy to discover that there is a fork of Spring Data DynamoDB that is actively maintained, but since this seem to be a one-man-show, one should be aware of the risk that the project may become abandoned and one should not count on it being updated at the same pace as the regular Spring projects.
With that said, the project is open-source and, as far as I know, contributions are very welcome.

Happy coding!

Leave a Reply

Your email address will not be published. Required fields are marked *