blob: fd61a647cc85909a61dacc354eca4bf6237926ee [file] [log] [blame] [view] [edit]
## Overview
Mid-level DynamoDB mapper/abstraction for Java using the v2 AWS SDK.
## Getting Started
All the examples below use a fictional Customer class. This class is
completely made up and not part of this library. Any search or key
values used are also completely arbitrary.
### Initialization
1. Create or use a java class for mapping records to and from the
database table. At a minimum you must annotate the class so that
it can be used as a DynamoDb bean, and also the property that
represents the primary partition key of the table. Here's an example:-
```java
@DynamoDbBean
public class Customer {
private String accountId;
private int subId; // primitive types are supported
private String name;
private Instant createdDate;
@DynamoDbPartitionKey
public String getAccountId() { return this.accountId; }
public void setAccountId(String accountId) { this.accountId = accountId; }
@DynamoDbSortKey
public int getSubId() { return this.subId; }
public void setSubId(int subId) { this.subId = subId; }
// Defines a GSI (customers_by_name) with a partition key of 'name'
@DynamoDbSecondaryPartitionKey(indexNames = "customers_by_name")
public String getName() { return this.name; }
public void setName(String name) { this.name = name; }
// Defines an LSI (customers_by_date) with a sort key of 'createdDate' and also declares the
// same attribute as a sort key for the GSI named 'customers_by_name'
@DynamoDbSecondarySortKey(indexNames = {"customers_by_date", "customers_by_name"})
public Instant getCreatedDate() { return this.createdDate; }
public void setCreatedDate(Instant createdDate) { this.createdDate = createdDate; }
}
```
2. Create a TableSchema for your class. For this example we are using a static constructor method on TableSchema that
will scan your annotated class and infer the table structure and attributes :
```java
static final TableSchema<Customer> CUSTOMER_TABLE_SCHEMA = TableSchema.fromClass(Customer.class);
```
If you would prefer to skip the slightly costly bean inference for a faster solution, you can instead declare your
schema directly and let the compiler do the heavy lifting. If you do it this way, your class does not need to follow
bean naming standards nor does it need to be annotated. This example is equivalent to the bean example :
```java
static final TableSchema<Customer> CUSTOMER_TABLE_SCHEMA =
TableSchema.builder(Customer.class)
.newItemSupplier(Customer::new)
.addAttribute(String.class, a -> a.name("account_id")
.getter(Customer::getAccountId)
.setter(Customer::setAccountId)
.tags(primaryPartitionKey()))
.addAttribute(Integer.class, a -> a.name("sub_id")
.getter(Customer::getSubId)
.setter(Customer::setSubId)
.tags(primarySortKey()))
.addAttribute(String.class, a -> a.name("name")
.getter(Customer::getName)
.setter(Customer::setName)
.tags(secondaryPartitionKey("customers_by_name")))
.addAttribute(Instant.class, a -> a.name("created_date")
.getter(Customer::getCreatedDate)
.setter(Customer::setCreatedDate)
.tags(secondarySortKey("customers_by_date"),
secondarySortKey("customers_by_name")))
.build();
```
3. Create a DynamoDbEnhancedClient object that you will use to repeatedly
execute operations against all your tables :-
```java
DynamoDbEnhancedClient enhancedClient = DynamoDbEnhancedClient.builder()
.dynamoDbClient(dynamoDbClient)
.build();
```
4. Create a DynamoDbTable object that you will use to repeatedly execute
operations against a specific table :-
```java
// Maps a physical table with the name 'customers_20190205' to the schema
DynamoDbTable<Customer> customerTable = enhancedClient.table("customers_20190205", CUSTOMER_TABLE_SCHEMA);
```
The name passed to the `table()` method above must match the name of a DynamoDB table if it already exists.
The DynamoDbTable object, customerTable, can now be used to perform the basic operations on the `customers_20190205` table.
If the table does not already exist, the name will be used as the DynamoDB table name on a subsequent `createTable()` method.
### Common primitive operations
These all strongly map to the primitive DynamoDB operations they are
named after. The examples below are the most simple variants of each
operation possible. Each operation can be further customized by passing
in an enhanced request object. These enhanced request objects offer most
of the features available in the low-level DynamoDB SDK client and are
fully documented in the Javadoc of the interfaces referenced in these examples.
```java
// CreateTable
customerTable.createTable();
// GetItem
Customer customer = customerTable.getItem(Key.builder().partitionValue("a123").build());
// UpdateItem
Customer updatedCustomer = customerTable.updateItem(customer);
// PutItem
customerTable.putItem(customer);
// DeleteItem
Customer deletedCustomer = customerTable.deleteItem(Key.builder().partitionValue("a123").sortValue(456).build());
// Query
PageIterable<Customer> customers = customerTable.query(keyEqualTo(k -> k.partitionValue("a123")));
// Scan
PageIterable<Customer> customers = customerTable.scan();
// BatchGetItem
BatchGetResultPageIterable batchResults = enhancedClient.batchGetItem(r -> r.addReadBatch(ReadBatch.builder(Customer.class)
.mappedTableResource(customerTable)
.addGetItem(key1)
.addGetItem(key2)
.addGetItem(key3)
.build()));
// BatchWriteItem
batchResults = enhancedClient.batchWriteItem(r -> r.addWriteBatch(WriteBatch.builder(Customer.class)
.mappedTableResource(customerTable)
.addPutItem(customer)
.addDeleteItem(key1)
.addDeleteItem(key1)
.build()));
// TransactGetItems
transactResults = enhancedClient.transactGetItems(r -> r.addGetItem(customerTable, key1)
.addGetItem(customerTable, key2));
// TransactWriteItems
enhancedClient.transactWriteItems(r -> r.addConditionCheck(customerTable,
i -> i.key(orderKey)
.conditionExpression(conditionExpression))
.addUpdateItem(customerTable, customer)
.addDeleteItem(customerTable, key));
```
### Using secondary indices
Certain operations (Query and Scan) may be executed against a secondary
index. Here's an example of how to do this:
```java
DynamoDbIndex<Customer> customersByName = customerTable.index("customers_by_name");
SdkIterable<Page<Customer>> customersWithName =
customersByName.query(r -> r.queryConditional(keyEqualTo(k -> k.partitionValue("Smith"))));
PageIterable<Customer> pages = PageIterable.create(customersWithName);
```
### Working with immutable data classes
It is possible to have the DynamoDB Enhanced Client map directly to and from immutable data classes in Java. An
immutable class is expected to only have getters and will also be associated with a separate builder class that
is used to construct instances of the immutable data class. The DynamoDB annotation style for immutable classes is
very similar to bean classes :
```java
@DynamoDbImmutable(builder = Customer.Builder.class)
public class Customer {
private final String accountId;
private final int subId;
private final String name;
private final Instant createdDate;
private Customer(Builder b) {
this.accountId = b.accountId;
this.subId = b.subId;
this.name = b.name;
this.createdDate = b.createdDate;
}
// This method will be automatically discovered and used by the TableSchema
public static Builder builder() { return new Builder(); }
@DynamoDbPartitionKey
public String accountId() { return this.accountId; }
@DynamoDbSortKey
public int subId() { return this.subId; }
@DynamoDbSecondaryPartitionKey(indexNames = "customers_by_name")
public String name() { return this.name; }
@DynamoDbSecondarySortKey(indexNames = {"customers_by_date", "customers_by_name"})
public Instant createdDate() { return this.createdDate; }
public static final class Builder {
private String accountId;
private int subId;
private String name;
private Instant createdDate;
private Builder() {}
public Builder accountId(String accountId) { this.accountId = accountId; return this; }
public Builder subId(int subId) { this.subId = subId; return this; }
public Builder name(String name) { this.name = name; return this; }
public Builder createdDate(Instant createdDate) { this.createdDate = createdDate; return this; }
// This method will be automatically discovered and used by the TableSchema
public Customer build() { return new Customer(this); }
}
}
```
The following requirements must be met for a class annotated with @DynamoDbImmutable:
1. Every method on the immutable class that is not an override of Object.class or annotated with @DynamoDbIgnore must
be a getter for an attribute of the database record.
1. Every getter in the immutable class must have a corresponding setter on the builder class that has a case-sensitive
matching name.
1. EITHER: the builder class must have a public default constructor; OR: there must be a public static method named
'builder' on the immutable class that takes no parameters and returns an instance of the builder class.
1. The builder class must have a public method named 'build' that takes no parameters and returns an instance of the
immutable class.
To create a TableSchema for your immutable class, use the static constructor method for immutable classes on TableSchema :
```java
static final TableSchema<Customer> CUSTOMER_TABLE_SCHEMA = TableSchema.fromImmutableClass(Customer.class);
```
There are third-party library that help generate a lot of the boilerplate code associated with immutable objects.
The DynamoDb Enhanced client should work with these libraries as long as they follow the conventions detailed
in this section. Here's an example of the immutable Customer class using Lombok with DynamoDb annotations (note
how Lombok's 'onMethod' feature is leveraged to copy the attribute based DynamoDb annotations onto the generated code):
```java
@Value
@Builder
@DynamoDbImmutable(builder = Customer.CustomerBuilder.class)
public static class Customer {
@Getter(onMethod = @__({@DynamoDbPartitionKey}))
private String accountId;
@Getter(onMethod = @__({@DynamoDbSortKey}))
private int subId;
@Getter(onMethod = @__({@DynamoDbSecondaryPartitionKey(indexNames = "customers_by_name")}))
private String name;
@Getter(onMethod = @__({@DynamoDbSecondarySortKey(indexNames = {"customers_by_date", "customers_by_name"})}))
private Instant createdDate;
}
```
### Non-blocking asynchronous operations
If your application requires non-blocking asynchronous calls to
DynamoDb, then you can use the asynchronous implementation of the
mapper. It's very similar to the synchronous implementation with a few
key differences:
1. When instantiating the mapped database, use the asynchronous version
of the library instead of the synchronous one (you will need to use
an asynchronous DynamoDb client from the SDK as well):
```java
DynamoDbEnhancedAsyncClient enhancedClient =
DynamoDbEnhancedAsyncClient.builder()
.dynamoDbClient(dynamoDbAsyncClient)
.build();
```
2. Operations that return a single data item will return a
CompletableFuture of the result instead of just the result. Your
application can then do other work without having to block on the
result:
```java
CompletableFuture<Customer> result = mappedTable.getItem(r -> r.key(customerKey));
// Perform other work here
return result.join(); // now block and wait for the result
```
3. Operations that return paginated lists of results will return an
SdkPublisher of the results instead of an SdkIterable. Your
application can then subscribe a handler to that publisher and deal
with the results asynchronously without having to block:
```java
PagePublisher<Customer> results = mappedTable.query(r -> r.queryConditional(keyEqualTo(k -> k.partitionValue("Smith"))));
results.subscribe(myCustomerResultsProcessor);
// Perform other work and let the processor handle the results asynchronously
```
## Using extensions
The mapper supports plugin extensions to provide enhanced functionality
beyond the simple primitive mapped operations. Extensions have two hooks, beforeWrite() and
afterRead(); the former can modify a write operation before it happens,
and the latter can modify the results of a read operation after it
happens. Some operations such as UpdateItem perform both a write and
then a read, so call both hooks.
Extensions are loaded in the order they are specified in the enhanced client builder. This load order can be important,
as one extension can be acting on values that have been transformed by a previous extension. The client comes with a set
of pre-written plugin extensions, located in the `/extensions` package. By default (See ExtensionResolver.java) the client loads some of them,
such as VersionedRecordExtension; however, you can override this behavior on the client builder and load any
extensions you like or specify none if you do not want the ones bundled by default.
In this example, a custom extension named 'verifyChecksumExtension' is being loaded after the VersionedRecordExtension
which is usually loaded by default by itself:
```java
DynamoDbEnhancedClientExtension versionedRecordExtension = VersionedRecordExtension.builder().build();
DynamoDbEnhancedClient enhancedClient =
DynamoDbEnhancedClient.builder()
.dynamoDbClient(dynamoDbClient)
.extensions(versionedRecordExtension, verifyChecksumExtension)
.build();
```
### VersionedRecordExtension
This extension is loaded by default and will increment and track a record version number as
records are written to the database. A condition will be added to every
write that will cause the write to fail if the record version number of
the actual persisted record does not match the value that the
application last read. This effectively provides optimistic locking for
record updates, if another process updates a record between the time the
first process has read the record and is writing an update to it then
that write will fail.
To tell the extension which attribute to use to track the record version
number tag a numeric attribute in the TableSchema:
```java
@DynamoDbVersionAttribute
public Integer getVersion() {...};
public void setVersion(Integer version) {...};
```
Or using a StaticTableSchema:
```java
.addAttribute(Integer.class, a -> a.name("version")
.getter(Customer::getVersion)
.setter(Customer::setVersion)
// Apply the 'version' tag to the attribute
.tags(versionAttribute())
```
### AtomicCounterExtension
This extension is loaded by default and will increment numerical attributes each time records are written to the
database. Start and increment values can be specified, if not counters start at 0 and increments by 1.
To tell the extension which attribute is a counter, tag an attribute of type Long in the TableSchema, here using
standard values:
```java
@DynamoDbAtomicCounter
public Long getCounter() {...};
public void setCounter(Long counter) {...};
```
Or using a StaticTableSchema with custom values:
```java
.addAttribute(Integer.class, a -> a.name("counter")
.getter(Customer::getCounter)
.setter(Customer::setCounter)
// Apply the 'atomicCounter' tag to the attribute with start and increment values
.tags(atomicCounter(10L, 5L))
```
### AutoGeneratedTimestampRecordExtension
This extension enables selected attributes to be automatically updated with a current timestamp every time the item
is successfully written to the database. One requirement is the attribute must be of `Instant` type.
This extension is not loaded by default, you need to specify it as custom extension while creating the enhanced
client.
To tell the extension which attribute will be updated with the current timestamp, tag the Instant attribute in
the TableSchema:
```java
@DynamoDbAutoGeneratedTimestampAttribute
public Instant getLastUpdate() {...}
public void setLastUpdate(Instant lastUpdate) {...}
```
If using a StaticTableSchema:
```java
.addAttribute(Instant.class, a -> a.name("lastUpdate")
.getter(Customer::getLastUpdate)
.setter(Customer::setLastUpdate)
// Applying the 'autoGeneratedTimestamp' tag to the attribute
.tags(autoGeneratedTimestampAttribute())
```
## Advanced table schema features
### Explicitly include/exclude attributes in DDB mapping
#### Excluding attributes
Ignore attributes that should not participate in mapping to DDB
Mark the attribute with the @DynamoDbIgnore annotation:
```java
private String internalKey;
@DynamoDbIgnore
public String getInternalKey() { return this.internalKey; }
public void setInternalKey(String internalKey) { return this.internalKey = internalKey;}
```
#### Including attributes
Change the name used to store an attribute in DBB by explicitly marking it with the
@DynamoDbAttribute annotation and supplying a different name:
```java
private String internalKey;
@DynamoDbAttribute("renamedInternalKey")
public String getInternalKey() { return this.internalKey; }
public void setInternalKey(String internalKey) { return this.internalKey = internalKey;}
```
### Control attribute conversion
By default, the table schema provides converters for all primitive and many common Java types
through a default implementation of the AttributeConverterProvider interface. This behavior
can be changed both at the attribute converter provider level as well as for a single attribute.
You can find a list of the available converters in the
[AttributeConverter](https://sdk.amazonaws.com/java/api/latest/software/amazon/awssdk/enhanced/dynamodb/AttributeConverter.html)
interface Javadoc.
#### Provide custom attribute converter providers
You can provide a single AttributeConverterProvider or a chain of ordered AttributeConverterProviders
through the @DynamoDbBean 'converterProviders' annotation. Any custom AttributeConverterProvider must extend the AttributeConverterProvider
interface.
Note that if you supply your own chain of attribute converter providers, you will override
the default converter provider (DefaultAttributeConverterProvider) and must therefore include it in the chain if you wish to
use its attribute converters. It's also possible to annotate the bean with an empty array `{}`, thus
disabling the usage of any attribute converter providers including the default, in which case
all attributes must have their own attribute converters (see below).
Single converter provider:
```java
@DynamoDbBean(converterProviders = ConverterProvider1.class)
public class Customer {
}
```
Chain of converter providers ending with the default (least priority):
```java
@DynamoDbBean(converterProviders = {
ConverterProvider1.class,
ConverterProvider2.class,
DefaultAttributeConverterProvider.class})
public class Customer {
}
```
In the same way, adding a chain of attribute converter providers directly to a StaticTableSchema:
```java
private static final StaticTableSchema<Customer> CUSTOMER_TABLE_SCHEMA =
StaticTableSchema.builder(Customer.class)
.newItemSupplier(Customer::new)
.addAttribute(String.class, a -> a.name("name")
a.getter(Customer::getName)
a.setter(Customer::setName))
.attributeConverterProviders(converterProvider1, converterProvider2)
.build();
```
#### Override the mapping of a single attribute
Supply an AttributeConverter when creating the attribute to directly override any
converters provided by the table schema AttributeConverterProviders. Note that you will
only add a custom converter for that attribute; other attributes, even of the same
type, will not use that converter unless explicitly specified for those other attributes.
Example:
```java
@DynamoDbBean
public class Customer {
private String name;
@DynamoDbConvertedBy(CustomAttributeConverter.class)
public String getName() { return this.name; }
public void setName(String name) { this.name = name;}
}
```
For StaticTableSchema:
```java
private static final StaticTableSchema<Customer> CUSTOMER_TABLE_SCHEMA =
StaticTableSchema.builder(Customer.class)
.newItemSupplier(Customer::new)
.addAttribute(String.class, a -> a.name("name")
a.getter(Customer::getName)
a.setter(Customer::setName)
a.attributeConverter(customAttributeConverter))
.build();
```
### Changing update behavior of attributes
It is possible to customize the update behavior as applicable to individual attributes when an 'update' operation is
performed (e.g. UpdateItem or an update within TransactWriteItems).
For example, say like you wanted to store a 'created on' timestamp on your record, but only wanted its value to be
written if there is no existing value for the attribute stored in the database then you would use the
WRITE_IF_NOT_EXISTS update behavior. Here is an example using a bean:
```java
@DynamoDbBean
public class Customer extends GenericRecord {
private String id;
private Instant createdOn;
@DynamoDbPartitionKey
public String getId() { return this.id; }
public void setId(String id) { this.name = id; }
@DynamoDbUpdateBehavior(UpdateBehavior.WRITE_IF_NOT_EXISTS)
public Instant getCreatedOn() { return this.createdOn; }
public void setCreatedOn(Instant createdOn) { this.createdOn = createdOn; }
}
```
Same example using a static table schema:
```java
static final TableSchema<Customer> CUSTOMER_TABLE_SCHEMA =
TableSchema.builder(Customer.class)
.newItemSupplier(Customer::new)
.addAttribute(String.class, a -> a.name("id")
.getter(Customer::getId)
.setter(Customer::setId)
.tags(primaryPartitionKey()))
.addAttribute(Instant.class, a -> a.name("createdOn")
.getter(Customer::getCreatedOn)
.setter(Customer::setCreatedOn)
.tags(updateBehavior(UpdateBehavior.WRITE_IF_NOT_EXISTS)))
.build();
```
### Flat map attributes from another class
If the attributes for your table record are spread across several
different Java objects, either through inheritance or composition, the
static TableSchema implementation gives you a method of flat mapping
those attributes and rolling them up into a single schema.
#### Using inheritance
To accomplish flat map using inheritance, the only requirement is that
both classes are annotated as a DynamoDb bean:
```java
@DynamoDbBean
public class Customer extends GenericRecord {
private String name;
private GenericRecord record;
public String getName() { return this.name; }
public void setName(String name) { this.name = name;}
public GenericRecord getRecord() { return this.record; }
public void setRecord(GenericRecord record) { this.record = record;}
}
@DynamoDbBean
public abstract class GenericRecord {
private String id;
private String createdDate;
public String getId() { return this.id; }
public void setId(String id) { this.id = id;}
public String getCreatedDate() { return this.createdDate; }
public void setCreatedDate(String createdDate) { this.createdDate = createdDate;}
}
```
For StaticTableSchema, use the 'extend' feature to achieve the same effect:
```java
@Data
public class Customer extends GenericRecord {
private String name;
}
@Data
public abstract class GenericRecord {
private String id;
private String createdDate;
}
private static final StaticTableSchema<GenericRecord> GENERIC_RECORD_SCHEMA =
StaticTableSchema.builder(GenericRecord.class)
// The partition key will be inherited by the top level mapper
.addAttribute(String.class, a -> a.name("id")
.getter(GenericRecord::getId)
.setter(GenericRecord::setId)
.tags(primaryPartitionKey()))
.addAttribute(String.class, a -> a.name("created_date")
.getter(GenericRecord::getCreatedDate)
.setter(GenericRecord::setCreatedDate))
.build();
private static final StaticTableSchema<Customer> CUSTOMER_TABLE_SCHEMA =
StaticTableSchema.builder(Customer.class)
.newItemSupplier(Customer::new)
.addAttribute(String.class, a -> a.name("name")
.getter(Customer::getName)
.setter(Customer::setName))
.extend(GENERIC_RECORD_SCHEMA) // All the attributes of the GenericRecord schema are added to Customer
.build();
```
#### Using composition
Using composition, the @DynamoDbFlatten annotation flat maps the composite class:
```java
@DynamoDbBean
public class Customer {
private String name;
private GenericRecord record;
public String getName() { return this.name; }
public void setName(String name) { this.name = name;}
@DynamoDbFlatten
public GenericRecord getRecord() { return this.record; }
public void setRecord(GenericRecord record) { this.record = record;}
}
@DynamoDbBean
public class GenericRecord {
private String id;
private String createdDate;
public String getId() { return this.id; }
public void setId(String id) { this.id = id;}
public String getCreatedDate() { return this.createdDate; }
public void setCreatedDate(String createdDate) { this.createdDate = createdDate;}
}
```
You can flatten as many different eligible classes as you like using the flatten annotation.
The only constraints are that attributes must not have the same name when they are being rolled
together, and there must never be more than one partition key, sort key or table name.
Flat map composite classes using StaticTableSchema:
```java
@Data
public class Customer{
private String name;
private GenericRecord recordMetadata;
//getters and setters for all attributes
}
@Data
public class GenericRecord {
private String id;
private String createdDate;
//getters and setters for all attributes
}
private static final StaticTableSchema<GenericRecord> GENERIC_RECORD_SCHEMA =
StaticTableSchema.builder(GenericRecord.class)
.addAttribute(String.class, a -> a.name("id")
.getter(GenericRecord::getId)
.setter(GenericRecord::setId)
.tags(primaryPartitionKey()))
.addAttribute(String.class, a -> a.name("created_date")
.getter(GenericRecord::getCreatedDate)
.setter(GenericRecord::setCreatedDate))
.build();
private static final StaticTableSchema<Customer> CUSTOMER_TABLE_SCHEMA =
StaticTableSchema.builder(Customer.class)
.newItemSupplier(Customer::new)
.addAttribute(String.class, a -> a.name("name")
.getter(Customer::getName)
.setter(Customer::setName))
// Because we are flattening a component object, we supply a getter and setter so the
// mapper knows how to access it
.flatten(GENERIC_RECORD_SCHEMA, Customer::getRecordMetadata, Customer::setRecordMetadata)
.build();
```
Just as for annotations, you can flatten as many different eligible classes as you like using the
builder pattern.