package org.rakam.aws.dynamodb.user;
import com.amazonaws.services.dynamodbv2.AmazonDynamoDBClient;
import com.amazonaws.services.dynamodbv2.model.AttributeDefinition;
import com.amazonaws.services.dynamodbv2.model.AttributeValue;
import com.amazonaws.services.dynamodbv2.model.BatchWriteItemRequest;
import com.amazonaws.services.dynamodbv2.model.ComparisonOperator;
import com.amazonaws.services.dynamodbv2.model.Condition;
import com.amazonaws.services.dynamodbv2.model.CreateTableRequest;
import com.amazonaws.services.dynamodbv2.model.DeleteItemRequest;
import com.amazonaws.services.dynamodbv2.model.DescribeTableResult;
import com.amazonaws.services.dynamodbv2.model.GetItemResult;
import com.amazonaws.services.dynamodbv2.model.KeySchemaElement;
import com.amazonaws.services.dynamodbv2.model.ProvisionedThroughput;
import com.amazonaws.services.dynamodbv2.model.PutItemRequest;
import com.amazonaws.services.dynamodbv2.model.PutRequest;
import com.amazonaws.services.dynamodbv2.model.QueryRequest;
import com.amazonaws.services.dynamodbv2.model.ResourceNotFoundException;
import com.amazonaws.services.dynamodbv2.model.ScalarAttributeType;
import com.amazonaws.services.dynamodbv2.model.UpdateItemRequest;
import com.amazonaws.services.dynamodbv2.model.WriteRequest;
import com.facebook.presto.sql.tree.Expression;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.BooleanNode;
import com.fasterxml.jackson.databind.node.DoubleNode;
import com.fasterxml.jackson.databind.node.NullNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.fasterxml.jackson.databind.node.TextNode;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet;
import io.netty.handler.codec.http.HttpResponseStatus;
import org.rakam.aws.AWSConfig;
import org.rakam.collection.FieldType;
import org.rakam.collection.SchemaField;
import org.rakam.plugin.user.AbstractUserService.BatchUserOperationRequest.BatchUserOperations;
import org.rakam.plugin.user.ISingleUserBatchOperation;
import org.rakam.plugin.user.User;
import org.rakam.plugin.user.UserStorage;
import org.rakam.report.QueryResult;
import org.rakam.util.JsonHelper;
import org.rakam.util.RakamException;
import javax.annotation.PostConstruct;
import javax.inject.Inject;
import java.time.Duration;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
import static com.amazonaws.services.dynamodbv2.model.KeyType.HASH;
import static com.amazonaws.services.dynamodbv2.model.KeyType.RANGE;
import static org.rakam.collection.FieldType.STRING;
public class DynamodbUserStorage
implements UserStorage
{
private final AmazonDynamoDBClient dynamoDBClient;
private static final List<KeySchemaElement> PROJECT_KEYSCHEMA = ImmutableList.of(
new KeySchemaElement().withKeyType(HASH).withAttributeName("project"),
new KeySchemaElement().withKeyType(RANGE).withAttributeName("id")
);
private static final Set<AttributeDefinition> ATTRIBUTES = ImmutableSet.of(
new AttributeDefinition().withAttributeName("project").withAttributeType(ScalarAttributeType.S),
new AttributeDefinition().withAttributeName("id").withAttributeType(ScalarAttributeType.S)
);
private final DynamodbUserConfig tableConfig;
@Inject
public DynamodbUserStorage(AWSConfig config, DynamodbUserConfig tableConfig)
{
dynamoDBClient = new AmazonDynamoDBClient(config.getCredentials());
dynamoDBClient.setRegion(config.getAWSRegion());
if (config.getDynamodbEndpoint() != null) {
dynamoDBClient.setEndpoint(config.getDynamodbEndpoint());
}
this.tableConfig = tableConfig;
}
@PostConstruct
public void setup()
{
try {
DescribeTableResult result = dynamoDBClient.describeTable(tableConfig.getTableName());
if (!result.getTable().getKeySchema().equals(PROJECT_KEYSCHEMA) || !ImmutableSet.copyOf(result.getTable().getAttributeDefinitions()).equals(ATTRIBUTES)) {
throw new IllegalStateException("Invalid schema for user storage dynamodb table. " +
"Please remove existing table or change dynamodb table.");
}
}
catch (ResourceNotFoundException e) {
dynamoDBClient.createTable(new CreateTableRequest().withTableName(tableConfig.getTableName())
.withKeySchema(PROJECT_KEYSCHEMA)
.withAttributeDefinitions(ATTRIBUTES)
.withProvisionedThroughput(new ProvisionedThroughput()
.withReadCapacityUnits(3L)
.withWriteCapacityUnits(3L)));
}
}
@Override
public Object create(String project, Object id, ObjectNode properties)
{
dynamoDBClient.putItem(new PutItemRequest().withTableName(tableConfig.getTableName())
.withItem(generatePutRequest(project, id, properties)));
return id;
}
private Map<String, AttributeValue> generatePutRequest(String project, Object id, ObjectNode properties)
{
Map<String, AttributeValue> builder = new HashMap<>();
builder.put("project", new AttributeValue(project));
builder.put("id", new AttributeValue(id.toString()));
Map<String, AttributeValue> props = new HashMap<>();
Iterator<Entry<String, JsonNode>> fields = properties.fields();
while (fields.hasNext()) {
Entry<String, JsonNode> next = fields.next();
props.put(next.getKey(), convertAttributeValue(next.getValue()));
}
builder.put("properties", new AttributeValue().withM(props));
return builder;
}
private AttributeValue convertAttributeValue(JsonNode value)
{
AttributeValue attr = new AttributeValue();
if (value.isTextual()) {
attr.setS(value.textValue());
}
else if (value.isNumber()) {
attr.setN(value.asText());
}
else if (value.isBoolean()) {
attr.setBOOL(value.asBoolean());
}
else if (value.isArray()) {
attr.setSS(IntStream.range(0,
value.size()).mapToObj(i -> value.get(i).asText())
.collect(Collectors.toList()));
}
else if (value.isObject()) {
Map<String, AttributeValue> map = new HashMap<>(value.size());
Iterator<Entry<String, JsonNode>> fields = value.fields();
while (fields.hasNext()) {
Entry<String, JsonNode> next = fields.next();
map.put(next.getKey(), convertAttributeValue(next.getValue()));
}
attr.setM(map);
}
else if (!value.isNull()) {
throw new IllegalStateException();
}
return attr;
}
@Override
public List<Object> batchCreate(String project, List<User> users)
{
List<WriteRequest> collect = users.stream()
.map(user -> new WriteRequest(new PutRequest(generatePutRequest(project, user.id, user.properties))))
.collect(Collectors.toList());
dynamoDBClient.batchWriteItem(new BatchWriteItemRequest().withRequestItems(ImmutableMap.of(project, collect)));
return null;
}
@Override
public CompletableFuture<QueryResult> searchUsers(String project, List<String> columns, Expression filterExpression, List<EventFilter> eventFilter, Sorting sortColumn, long limit, String offset)
{
QueryRequest scanRequest = new QueryRequest()
.withTableName(tableConfig.getTableName());
if (columns != null && !columns.isEmpty()) {
scanRequest.withAttributesToGet(columns.stream().map(e -> "properties." + e).collect(Collectors.toList()));
}
scanRequest.withKeyConditions(ImmutableMap.of("project", new Condition()
.withComparisonOperator(ComparisonOperator.EQ)
.withAttributeValueList(new AttributeValue(project))));
final ImmutableMap.Builder<String, String> nameBuilder = ImmutableMap.builder();
final ImmutableMap.Builder<String, AttributeValue> valueBuilder = ImmutableMap.builder();
final char[] variable = {'a', 'a'};
if (filterExpression != null) {
DynamodbFilterQueryFormatter formatter = new DynamodbFilterQueryFormatter(variable, nameBuilder, valueBuilder);
String expression = formatter.process(filterExpression, false);
scanRequest.withFilterExpression(expression);
ImmutableMap<String, String> names = nameBuilder.build();
if (!names.isEmpty()) {
scanRequest.withExpressionAttributeNames(names);
}
ImmutableMap<String, AttributeValue> values = valueBuilder.build();
if (!values.isEmpty()) {
scanRequest.withExpressionAttributeValues(values);
}
}
scanRequest.withLimit(Math.toIntExact(limit));
List<Map<String, AttributeValue>> scan = dynamoDBClient.query(scanRequest).getItems();
Set<String> set = new HashSet<>();
for (Map<String, AttributeValue> entry : scan) {
for (Entry<String, AttributeValue> property : entry.get("properties").getM().entrySet()) {
set.add(property.getKey());
}
}
List<SchemaField> schemaFields = ImmutableList.<SchemaField>builder()
.add(new SchemaField("id", STRING))
.addAll(set.stream().map(e -> new SchemaField(e, STRING)).collect(Collectors.toList()))
.build();
List<List<Object>> result = new ArrayList<>();
for (Map<String, AttributeValue> entry : scan) {
Map<String, AttributeValue> properties = entry.get("properties").getM();
ArrayList<Object> row = new ArrayList<>();
row.add(getJsonValue(entry.get("id")));
row.addAll(set.stream()
.map(name -> getJsonValue(properties.get(name)))
.collect(Collectors.toList()));
result.add(row);
}
return CompletableFuture.completedFuture(new QueryResult(schemaFields, result));
}
@Override
public void createSegment(String project, String name, String tableName, Expression filterExpression, List<EventFilter> eventFilter, Duration interval)
{
throw new RakamException("Unsupported", HttpResponseStatus.BAD_REQUEST);
}
@Override
public List<SchemaField> getMetadata(String project)
{
return ImmutableList.of(new SchemaField("id", STRING));
}
@Override
public CompletableFuture<User> getUser(String project, Object userId)
{
return CompletableFuture.supplyAsync(() -> {
GetItemResult item = dynamoDBClient.getItem("users", ImmutableMap.of(
"project", new AttributeValue(project),
"id", new AttributeValue(userId.toString())
));
Map<String, AttributeValue> attrs = item.getItem().get("properties").getM();
ObjectNode obj = JsonHelper.jsonObject();
for (Entry<String, AttributeValue> entry : attrs.entrySet()) {
obj.set(entry.getKey(), getJsonValue(entry.getValue()));
}
return new User(userId, null, obj);
});
}
private JsonNode getJsonValue(AttributeValue value)
{
if (value == null) {
return NullNode.getInstance();
}
if (value.getBOOL() != null) {
return value.getBOOL() ? BooleanNode.TRUE : BooleanNode.FALSE;
}
else if (value.getS() != null) {
return TextNode.valueOf(value.getS());
}
else if (value.getN() != null) {
double v = Double.parseDouble(value.getN());
return DoubleNode.valueOf(v);
}
else if (value.getL() != null) {
ArrayNode arr = JsonHelper.jsonArray();
for (AttributeValue attributeValue : value.getL()) {
arr.add(getJsonValue(attributeValue));
}
return arr;
}
else if (value.getM() != null) {
ObjectNode obj = JsonHelper.jsonObject();
for (Entry<String, AttributeValue> attributeValue : value.getM().entrySet()) {
obj.set(attributeValue.getKey(), getJsonValue(attributeValue.getValue()));
}
return obj;
}
else {
if (!value.isNULL()) {
throw new IllegalStateException();
}
else {
return NullNode.getInstance();
}
}
}
private FieldType getType(AttributeValue value)
{
if (value.isBOOL()) {
return FieldType.BOOLEAN;
}
else if (value.getS() != null) {
return STRING;
}
else if (value.getN() != null) {
return FieldType.DOUBLE;
}
else if (value.getL() != null) {
return FieldType.ARRAY_STRING;
}
else if (value.getM() != null) {
return FieldType.MAP_STRING;
}
else {
if (!value.isNULL()) {
throw new IllegalStateException();
}
else {
return STRING;
}
}
}
@Override
public void setUserProperties(String project, Object user, ObjectNode properties)
{
create(project, user, properties);
}
@Override
public void setUserPropertiesOnce(String project, Object user, ObjectNode properties)
{
applyOperations(project,
ImmutableList.of(new BatchUserOperations(user, null, properties, null, null, null)));
}
@Override
public void applyOperations(String project, List<? extends ISingleUserBatchOperation> operations)
{
Map<String, String> nameMap = new HashMap<>();
Map<String, AttributeValue> valueMap = new HashMap<>();
StringBuilder setBuilder = null;
StringBuilder addBuilder = null;
StringBuilder unsetBuilder = null;
List<UpdateItemRequest> setOnceBuilder = null;
char nameCur = 'a';
char valueCur = 'a';
for (ISingleUserBatchOperation operation : operations) {
for (Entry<String, Double> entry : operation.getIncrementProperties().entrySet()) {
if (addBuilder == null) {
addBuilder = new StringBuilder();
}
else {
addBuilder.append(", ");
}
String name = new String(new char[] {'#', nameCur++});
String value = new String(new char[] {':', valueCur++});
addBuilder.append("properties." + name + " " + value);
nameMap.put(name, entry.getKey());
valueMap.put(value, new AttributeValue().withN(entry.getValue().toString()));
}
Iterator<Entry<String, JsonNode>> fields = operation.getSetProperties().fields();
while (fields.hasNext()) {
Entry<String, JsonNode> next = fields.next();
if (setBuilder == null) {
setBuilder = new StringBuilder();
}
else {
setBuilder.append(", ");
}
String name = new String(new char[] {'#', nameCur++});
String value = new String(new char[] {':', valueCur++});
addBuilder.append("properties." + name + " = " + value);
nameMap.put(name, next.getKey());
valueMap.put(value, convertAttributeValue(next.getValue()));
}
Iterator<Entry<String, JsonNode>> setOncefields = operation.getSetPropertiesOnce().fields();
while (setOncefields.hasNext()) {
Entry<String, JsonNode> next = fields.next();
if (setOnceBuilder == null) {
setOnceBuilder = new ArrayList<>();
}
setOnceBuilder.add(new UpdateItemRequest()
.withUpdateExpression("SET properties.#a = :a")
.withExpressionAttributeNames(ImmutableMap.of("#a", next.getKey()))
.withExpressionAttributeValues(ImmutableMap.of(":a", convertAttributeValue(next.getValue())))
.withTableName(tableConfig.getTableName())
.withKey(ImmutableMap.of("project", new AttributeValue(project), "id",
new AttributeValue(operation.getUser().toString())))
);
}
for (String unsetProperty : operation.getUnsetProperties()) {
if (unsetBuilder == null) {
unsetBuilder = new StringBuilder();
}
else {
unsetBuilder.append(" , ");
}
String name = new String(new char[] {'#', nameCur++});
unsetBuilder.append("properties." + name);
nameMap.put(name, unsetProperty);
}
StringBuilder query = new StringBuilder();
if (setBuilder != null) {
query.append("SET ").append(setBuilder);
}
if (unsetBuilder != null) {
query.append("DELETE ").append(unsetBuilder);
}
if (addBuilder != null) {
query.append("ADD ").append(addBuilder);
}
dynamoDBClient.updateItem(new UpdateItemRequest()
.withTableName(tableConfig.getTableName())
.withKey(ImmutableMap.of("project", new AttributeValue(project), "id",
new AttributeValue(operation.getUser().toString())))
.withUpdateExpression(query.toString()));
if (setOnceBuilder != null) {
setOnceBuilder.forEach(dynamoDBClient::updateItem);
}
}
}
@Override
public void incrementProperty(String project, Object user, String property, double value)
{
applyOperations(project, ImmutableList.of(new BatchUserOperations(user,
null, null, ImmutableMap.of(property, value), null, null)));
}
@Override
public void dropProjectIfExists(String project)
{
dynamoDBClient.deleteItem(new DeleteItemRequest()
.withKey(ImmutableMap.of("project", new AttributeValue(project))));
}
@Override
public void unsetProperties(String project, Object user, List<String> properties)
{
applyOperations(project, ImmutableList.of(
new BatchUserOperations(user, null, null, null, properties, null)));
}
}