io.dataspray.singletable.DynamoMapperImpl Maven / Gradle / Ivy
Go to download
Show more of this group Show more artifacts with this name
Show all versions of single-table Show documentation
Show all versions of single-table Show documentation
DynamoDB best practices encourages a single-table design that allows multiple record types to reside within the
same table. The goal of this library is to improve the experience of Java developers and make it safer to define
non-conflicting schema of each record, serializing and deserializing automatically and working with secondary
indexes.
The newest version!
// SPDX-FileCopyrightText: 2019-2022 Matus Faro
// SPDX-License-Identifier: Apache-2.0
package io.dataspray.singletable;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Charsets;
import com.google.common.base.Preconditions;
import com.google.common.base.Strings;
import com.google.common.base.Throwables;
import com.google.common.collect.*;
import com.google.common.hash.Hashing;
import com.google.gson.Gson;
import com.google.gson.annotations.SerializedName;
import com.google.gson.reflect.TypeToken;
import io.dataspray.singletable.DynamoConvertersProxy.*;
import io.dataspray.singletable.builder.*;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import lombok.Value;
import lombok.extern.slf4j.Slf4j;
import software.amazon.awscdk.services.dynamodb.AttributeType;
import software.amazon.awscdk.services.dynamodb.GlobalSecondaryIndexProps;
import software.amazon.awscdk.services.dynamodb.LocalSecondaryIndexProps;
import software.amazon.awssdk.core.internal.waiters.DefaultWaiter;
import software.amazon.awssdk.core.waiters.Waiter;
import software.amazon.awssdk.core.waiters.WaiterAcceptor;
import software.amazon.awssdk.core.waiters.WaiterResponse;
import software.amazon.awssdk.services.dynamodb.DynamoDbClient;
import software.amazon.awssdk.services.dynamodb.model.*;
import software.amazon.awssdk.services.dynamodb.waiters.internal.WaitersRuntime;
import software.constructs.Construct;
import javax.annotation.Nullable;
import java.lang.reflect.*;
import java.util.*;
import java.util.Map.Entry;
import java.util.function.BiConsumer;
import java.util.function.BiFunction;
import java.util.function.Function;
import java.util.stream.Collectors;
import java.util.stream.LongStream;
import java.util.stream.Stream;
import static com.google.common.base.Preconditions.*;
import static io.dataspray.singletable.TableType.*;
@Slf4j
class DynamoMapperImpl implements DynamoMapper {
private final String tableName;
private final String indexPrefix;
private final Gson gson;
private final Converters converters;
private final MarshallerAttrVal gsonMarshallerAttrVal;
private final Function gsonUnMarshallerAttrVal;
@VisibleForTesting
final Map rangePrefixToDynamoTable;
DynamoMapperImpl(
@Nullable String tableName,
@Nullable String tablePrefix,
Gson gson,
@Nullable List> overrideTypeConverters,
@Nullable List> overrideCollectionTypeConverters
) {
this.tableName = tableName != null ? tableName : tablePrefix + Primary.name().toLowerCase();
this.indexPrefix = tableName != null ? tableName : tablePrefix;
this.gson = gson;
this.converters = DynamoConvertersProxy.proxy(
overrideTypeConverters == null ? List.of() : overrideTypeConverters,
overrideCollectionTypeConverters == null ? List.of() : overrideCollectionTypeConverters);
this.gsonMarshallerAttrVal = o -> AttributeValue.fromS(gson.toJson(o));
this.gsonUnMarshallerAttrVal = k -> a -> gson.fromJson(a.s(), k);
this.rangePrefixToDynamoTable = Maps.newHashMap();
}
@Override
public String getTableName() {
return getTableOrIndexName(Primary, -1);
}
@Override
public software.amazon.awscdk.services.dynamodb.Table createCdkTable(Construct scope, String stackId, int lsiCount, int gsiCount) {
software.amazon.awscdk.services.dynamodb.Table table = software.amazon.awscdk.services.dynamodb.Table.Builder.create(scope, stackId + "-singletable-table")
.tableName(getTableName())
.partitionKey(software.amazon.awscdk.services.dynamodb.Attribute.builder()
.name(getPartitionKeyName(Primary, -1)).type(AttributeType.STRING).build())
.sortKey(software.amazon.awscdk.services.dynamodb.Attribute.builder()
.name(getRangeKeyName(Primary, -1)).type(AttributeType.STRING).build())
.billingMode(software.amazon.awscdk.services.dynamodb.BillingMode.PAY_PER_REQUEST)
.timeToLiveAttribute(SingleTable.TTL_IN_EPOCH_SEC_ATTR_NAME)
.build();
LongStream.range(1, lsiCount + 1).forEach(indexNumber -> {
table.addLocalSecondaryIndex(LocalSecondaryIndexProps.builder()
.indexName(getTableOrIndexName(Lsi, indexNumber))
.projectionType(software.amazon.awscdk.services.dynamodb.ProjectionType.ALL)
.sortKey(software.amazon.awscdk.services.dynamodb.Attribute.builder()
.name(getRangeKeyName(Lsi, indexNumber))
.type(AttributeType.STRING).build())
.build());
});
LongStream.range(1, gsiCount + 1).forEach(indexNumber -> {
table.addGlobalSecondaryIndex(GlobalSecondaryIndexProps.builder()
.indexName(getTableOrIndexName(Gsi, indexNumber))
.projectionType(software.amazon.awscdk.services.dynamodb.ProjectionType.ALL)
.partitionKey(software.amazon.awscdk.services.dynamodb.Attribute.builder()
.name(getPartitionKeyName(Gsi, indexNumber))
.type(AttributeType.STRING).build())
.sortKey(software.amazon.awscdk.services.dynamodb.Attribute.builder()
.name(getRangeKeyName(Gsi, indexNumber))
.type(AttributeType.STRING).build())
.build());
});
return table;
}
@Override
public void createTableIfNotExists(DynamoDbClient dynamo, int lsiCount, int gsiCount) {
String tableName = getTableName();
TableDescription tableDescription;
try {
tableDescription = dynamo.describeTable(DescribeTableRequest.builder().tableName(tableName).build()).table();
} catch (ResourceNotFoundException ex) {
tableDescription = createTable(dynamo, lsiCount, gsiCount);
}
updateTableIndexes(dynamo, lsiCount, gsiCount, tableDescription);
updateTableTtl(dynamo);
}
private TableDescription createTable(DynamoDbClient dynamo, int lsiCount, int gsiCount) {
String tableName = getTableName();
ArrayList primaryKeySchemas = Lists.newArrayList();
ArrayList primaryAttributeDefinitions = Lists.newArrayList();
ArrayList localSecondaryIndexes = Lists.newArrayList();
ArrayList globalSecondaryIndexes = Lists.newArrayList();
primaryKeySchemas.add(KeySchemaElement.builder()
.attributeName(getPartitionKeyName(Primary, -1))
.keyType(KeyType.HASH).build());
primaryAttributeDefinitions.add(AttributeDefinition.builder()
.attributeName(getPartitionKeyName(Primary, -1))
.attributeType(ScalarAttributeType.S).build());
primaryKeySchemas.add(KeySchemaElement.builder()
.attributeName(getRangeKeyName(Primary, -1))
.keyType(KeyType.RANGE).build());
primaryAttributeDefinitions.add(AttributeDefinition.builder()
.attributeName(getRangeKeyName(Primary, -1))
.attributeType(ScalarAttributeType.S)
.build());
LongStream.range(1, lsiCount + 1).forEach(indexNumber -> {
localSecondaryIndexes.add(LocalSecondaryIndex.builder()
.indexName(getTableOrIndexName(Lsi, indexNumber))
.projection(Projection.builder()
.projectionType(ProjectionType.ALL).build())
.keySchema(ImmutableList.of(
KeySchemaElement.builder()
.attributeName(getPartitionKeyName(Lsi, indexNumber))
.keyType(KeyType.HASH).build(),
KeySchemaElement.builder()
.attributeName(getRangeKeyName(Lsi, indexNumber))
.keyType(KeyType.RANGE).build())).build());
primaryAttributeDefinitions.add(AttributeDefinition.builder()
.attributeName(getRangeKeyName(Lsi, indexNumber))
.attributeType(ScalarAttributeType.S).build());
});
LongStream.range(1, gsiCount + 1).forEach(indexNumber -> {
globalSecondaryIndexes.add(GlobalSecondaryIndex.builder()
.indexName(getTableOrIndexName(Gsi, indexNumber))
.projection(Projection.builder()
.projectionType(ProjectionType.ALL).build())
.keySchema(ImmutableList.of(
KeySchemaElement.builder()
.attributeName(getPartitionKeyName(Gsi, indexNumber))
.keyType(KeyType.HASH).build(),
KeySchemaElement.builder()
.attributeName(getRangeKeyName(Gsi, indexNumber))
.keyType(KeyType.RANGE).build())).build());
primaryAttributeDefinitions.add(AttributeDefinition.builder()
.attributeName(getPartitionKeyName(Gsi, indexNumber))
.attributeType(ScalarAttributeType.S).build());
primaryAttributeDefinitions.add(AttributeDefinition.builder()
.attributeName(getRangeKeyName(Gsi, indexNumber))
.attributeType(ScalarAttributeType.S).build());
});
CreateTableRequest.Builder createTableRequestBuilder = CreateTableRequest.builder()
.tableName(tableName)
.keySchema(primaryKeySchemas)
.attributeDefinitions(primaryAttributeDefinitions)
.billingMode(BillingMode.PAY_PER_REQUEST);
if (!localSecondaryIndexes.isEmpty()) {
createTableRequestBuilder.localSecondaryIndexes(localSecondaryIndexes);
}
if (!globalSecondaryIndexes.isEmpty()) {
createTableRequestBuilder.globalSecondaryIndexes(globalSecondaryIndexes);
}
dynamo.createTable(createTableRequestBuilder.build());
log.info("Table {} creating...", tableName);
WaiterResponse response = dynamo.waiter().waitUntilTableExists(DescribeTableRequest.builder()
.tableName(tableName)
.build());
response.matched().exception().ifPresent(ex -> Throwables.propagate(ex));
log.info("Table {} created", tableName);
return response.matched().response().orElseThrow().table();
}
private void updateTableIndexes(DynamoDbClient dynamo, int lsiCount, int gsiCount, TableDescription tableDescription) {
String tableName = getTableName();
int lsiCountActual = tableDescription.localSecondaryIndexes().size();
checkArgument(lsiCount == lsiCountActual, "Requested %s LSIs but table already has %s LSIs, LSIs cannot be changed without dropping the table.", lsiCount, lsiCountActual);
Map gsisToDelete = tableDescription.globalSecondaryIndexes().stream()
.collect(Collectors.toMap(
GlobalSecondaryIndexDescription::indexName,
i -> i
));
Set gsiIndexesToCreate = Sets.newHashSet();
LongStream.range(1, gsiCount + 1).forEach(indexNumber -> {
String gsiName = getTableOrIndexName(Gsi, indexNumber);
if (gsisToDelete.remove(gsiName) == null) {
gsiIndexesToCreate.add(indexNumber);
}
});
if (gsiIndexesToCreate.isEmpty() && gsisToDelete.isEmpty()) {
return;
}
Map primaryAttributeDefinitions = tableDescription.attributeDefinitions().stream()
.collect(Collectors.toMap(
AttributeDefinition::attributeName,
i -> i));
gsisToDelete.forEach((gsiNameToDelete, gsiToDelete) -> {
log.info("Table {} deleting GSI index: {}", tableName, gsiNameToDelete);
gsiToDelete.keySchema().stream()
.map(KeySchemaElement::attributeName)
.forEach(primaryAttributeDefinitions::remove);
dynamo.updateTable(UpdateTableRequest.builder()
.tableName(tableName)
.attributeDefinitions(ImmutableList.copyOf(primaryAttributeDefinitions.values()))
.globalSecondaryIndexUpdates(GlobalSecondaryIndexUpdate.builder()
.delete(DeleteGlobalSecondaryIndexAction.builder()
.indexName(gsiNameToDelete)
.build()).build()).build());
waitUntilGsiDeleted(dynamo, tableName, gsiNameToDelete);
});
gsiIndexesToCreate.forEach(indexNumber -> {
String gsiNameToCreate = getTableOrIndexName(Gsi, indexNumber);
log.info("Table {} creating GSI index: {}", tableName, gsiNameToCreate);
primaryAttributeDefinitions.put(getPartitionKeyName(Gsi, indexNumber), AttributeDefinition.builder()
.attributeName(getPartitionKeyName(Gsi, indexNumber))
.attributeType(ScalarAttributeType.S).build());
primaryAttributeDefinitions.put(getRangeKeyName(Gsi, indexNumber), AttributeDefinition.builder()
.attributeName(getRangeKeyName(Gsi, indexNumber))
.attributeType(ScalarAttributeType.S).build());
dynamo.updateTable(UpdateTableRequest.builder()
.tableName(tableName)
.attributeDefinitions(ImmutableList.copyOf(primaryAttributeDefinitions.values()))
.globalSecondaryIndexUpdates(GlobalSecondaryIndexUpdate.builder()
.create(CreateGlobalSecondaryIndexAction.builder()
.indexName(gsiNameToCreate)
.projection(Projection.builder()
.projectionType(ProjectionType.ALL).build())
.keySchema(ImmutableList.of(
KeySchemaElement.builder()
.attributeName(getPartitionKeyName(Gsi, indexNumber))
.keyType(KeyType.HASH).build(),
KeySchemaElement.builder()
.attributeName(getRangeKeyName(Gsi, indexNumber))
.keyType(KeyType.RANGE).build()))
.build()).build()).build());
waitUntilGsiCreated(dynamo, tableName, gsiNameToCreate);
});
}
private void updateTableTtl(DynamoDbClient dynamo) {
String tableName = getTableName();
TimeToLiveDescription desc = dynamo.describeTimeToLive(DescribeTimeToLiveRequest.builder()
.tableName(tableName)
.build())
.timeToLiveDescription();
boolean tableTtlExists = (TimeToLiveStatus.ENABLED.equals(desc.timeToLiveStatus())
|| TimeToLiveStatus.ENABLING.equals(desc.timeToLiveStatus()))
&& SingleTable.TTL_IN_EPOCH_SEC_ATTR_NAME.equals(desc.attributeName());
if (!tableTtlExists) {
dynamo.updateTimeToLive(UpdateTimeToLiveRequest.builder()
.tableName(tableName)
.timeToLiveSpecification(TimeToLiveSpecification.builder()
.enabled(true)
.attributeName(SingleTable.TTL_IN_EPOCH_SEC_ATTR_NAME).build()).build());
log.info("Table {} Updated TTL", tableName);
}
}
private WaiterResponse waitUntilGsiCreated(DynamoDbClient dynamo, String tableName, String indexName) {
Waiter.Builder builder = DefaultWaiter.builder()
.addAcceptor(WaiterAcceptor.successOnResponseAcceptor(response -> getIndexStatus(response, indexName).map(IndexStatus.ACTIVE::equals).orElse(false)))
.addAcceptor(WaiterAcceptor.retryOnResponseAcceptor(response -> getIndexStatus(response, indexName).map(IndexStatus.CREATING::equals).orElse(false)))
.addAcceptor(WaiterAcceptor.errorOnResponseAcceptor(response -> getIndexStatus(response, indexName).map(IndexStatus.DELETING::equals).orElse(false)))
.addAcceptor(WaiterAcceptor.errorOnResponseAcceptor(response -> getIndexStatus(response, indexName).isEmpty()));
WaitersRuntime.DEFAULT_ACCEPTORS.forEach(builder::addAcceptor);
return builder.build().run(() -> dynamo.describeTable(DescribeTableRequest.builder()
.tableName(tableName)
.build()));
}
private WaiterResponse waitUntilGsiDeleted(DynamoDbClient dynamo, String tableName, String indexName) {
Waiter.Builder builder = DefaultWaiter.builder()
.addAcceptor(WaiterAcceptor.successOnResponseAcceptor(response -> getIndexStatus(response, indexName).isEmpty()))
.addAcceptor(WaiterAcceptor.retryOnResponseAcceptor(response -> getIndexStatus(response, indexName).map(IndexStatus.DELETING::equals).orElse(false)))
.addAcceptor(WaiterAcceptor.errorOnResponseAcceptor(response -> getIndexStatus(response, indexName).map(IndexStatus.CREATING::equals).orElse(false)))
.addAcceptor(WaiterAcceptor.errorOnResponseAcceptor(response -> getIndexStatus(response, indexName).map(IndexStatus.ACTIVE::equals).orElse(false)));
WaitersRuntime.DEFAULT_ACCEPTORS.forEach(builder::addAcceptor);
return builder.build().run(() -> dynamo.describeTable(DescribeTableRequest.builder()
.tableName(tableName)
.build()));
}
private Optional getIndexStatus(DescribeTableResponse response, String indexName) {
return response.table()
.globalSecondaryIndexes()
.stream()
.filter(i -> indexName.equals(i.indexName()))
.map(GlobalSecondaryIndexDescription::indexStatus)
.findAny();
}
@Override
public TableSchema parseTableSchema(Class objClazz) {
return parseSchema(Primary, -1, objClazz, false);
}
@Override
public IndexSchema parseLocalSecondaryIndexSchema(long indexNumber, Class objClazz) {
return parseSchema(Lsi, indexNumber, objClazz, false);
}
@Override
public IndexSchema parseGlobalSecondaryIndexSchema(long indexNumber, Class objClazz) {
return parseSchema(Gsi, indexNumber, objClazz, false);
}
@Override
public ShardedTableSchema parseShardedTableSchema(Class objClazz) {
return parseSchema(Primary, -1, objClazz, true);
}
@Override
public ShardedIndexSchema parseShardedLocalSecondaryIndexSchema(long indexNumber, Class objClazz) {
return parseSchema(Lsi, indexNumber, objClazz, true);
}
@Override
public ShardedIndexSchema parseShardedGlobalSecondaryIndexSchema(long indexNumber, Class objClazz) {
return parseSchema(Gsi, indexNumber, objClazz, true);
}
private String getTableOrIndexName(TableType type, long indexNumber) {
return type == Primary
? tableName
: (indexPrefix + type.name().toLowerCase() + indexNumber);
}
private String getPartitionKeyName(TableType type, long indexNumber) {
return type == Primary || type == Lsi
? "pk"
: type.name().toLowerCase() + "pk" + indexNumber;
}
private String getRangeKeyName(TableType type, long indexNumber) {
return type == Primary
? "sk"
: type.name().toLowerCase() + "sk" + indexNumber;
}
public String fieldMap(T obj, Field field) {
try {
return gson.toJson(checkNotNull(field.get(obj)));
} catch (IllegalAccessException ex) {
throw new RuntimeException(ex);
}
}
public String mapMap(Map values, String partitionKey) {
return gson.toJson(checkNotNull(values.get(partitionKey), "Partition key missing value for %s", partitionKey));
}
private Function getPartitionKeyValueObjGetter(Field[] partitionKeyFields, Field[] shardKeyFields, int shardCount, String shardPrefix) {
return getPartitionKeyValueGetter(partitionKeyFields, shardKeyFields, shardCount, shardPrefix, this::fieldMap);
}
private Function
© 2015 - 2025 Weber Informatics LLC | Privacy Policy