diff --git a/.changes/next-release/feature-AmazonDynamoDB-939bac2.json b/.changes/next-release/feature-AmazonDynamoDB-939bac2.json new file mode 100644 index 000000000000..3373a9496c8b --- /dev/null +++ b/.changes/next-release/feature-AmazonDynamoDB-939bac2.json @@ -0,0 +1,6 @@ +{ + "type": "feature", + "category": "Amazon DynamoDB", + "contributor": "akiesler", + "description": "Add additional logical operator ('and' and 'or') methods to DynamoDB Expression" +} diff --git a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/Expression.java b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/Expression.java index 1721bc75f17d..fa0f69ad9ed3 100644 --- a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/Expression.java +++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/Expression.java @@ -15,9 +15,13 @@ package software.amazon.awssdk.enhanced.dynamodb; +import java.util.Arrays; +import java.util.Collection; import java.util.Collections; import java.util.HashMap; +import java.util.LinkedList; import java.util.Map; +import java.util.stream.Collectors; import software.amazon.awssdk.annotations.NotThreadSafe; import software.amazon.awssdk.annotations.SdkPublicApi; import software.amazon.awssdk.annotations.ThreadSafe; @@ -46,6 +50,9 @@ @SdkPublicApi @ThreadSafe public final class Expression { + public static final String AND = "AND"; + public static final String OR = "OR"; + private final String expression; private final Map expressionValues; private final Map expressionNames; @@ -67,7 +74,7 @@ public static Builder builder() { } /** - * Coalesces two complete expressions into a single expression. The expression string will be joined using the + * Coalesces two complete expressions into a single expression. The expression string will be joined using the * supplied join token, and the ExpressionNames and ExpressionValues maps will be merged. * @param expression1 The first expression to coalesce * @param expression2 The second expression to coalesce @@ -93,6 +100,61 @@ public static Expression join(Expression expression1, Expression expression2, St .build(); } + /** + * @see #join(String, Collection) + */ + public static Expression and(Collection expressions) { + return join(AND, expressions); + } + + /** + * @see #join(String, Collection) + */ + public static Expression or(Collection expressions) { + return join(OR, expressions); + } + + /** + * @see #join(String, Collection) + */ + public static Expression join(String joinToken, Expression... expressions) { + return join(joinToken, Arrays.asList(expressions)); + } + + /** + * Coalesces multiple complete expressions into a single expression. The expression string will be joined using the + * supplied join token, and the ExpressionNames and ExpressionValues maps will be merged. + * @param joinToken The join token to be used to join the expression strings (e.g.: 'AND', 'OR') + * @param expressions The expressions to coalesce + * @return The coalesced expression + * @throws IllegalArgumentException if a conflict occurs when merging ExpressionNames or ExpressionValues + */ + public static Expression join(String joinToken, Collection expressions) { + joinToken = joinToken.trim(); + if (expressions.isEmpty()) { + return null; + } + + if (expressions.size() == 1) { + return expressions.toArray(new Expression[] {})[0]; + } + + joinToken = ") " + joinToken + " ("; + String expression = expressions.stream() + .map(Expression::expression) + .collect(Collectors.joining(joinToken, "(", ")")); + + Builder builder = Expression.builder() + .expression(expression); + + expressions.forEach(expr -> { + builder.mergeExpressionValues(expr.expressionValues()) + .mergeExpressionNames(expr.expressionNames()); + }); + + return builder.build(); + } + /** * Coalesces two expression strings into a single expression string. The expression string will be joined using the * supplied join token. @@ -198,6 +260,28 @@ public Expression and(Expression expression) { return join(this, expression, " AND "); } + /** + * Coalesces multiple complete expressions into a single expression joined by 'AND'. + * + * @see #join(String, Collection) + */ + public Expression and(Expression... expressions) { + LinkedList expressionList = new LinkedList<>(Arrays.asList(expressions)); + expressionList.addFirst(this); + return join(AND, expressionList); + } + + /** + * Coalesces multiple complete expressions into a single expression joined by 'OR'. + * + * @see #join(String, Collection) + */ + public Expression or(Expression... expressions) { + LinkedList expressionList = new LinkedList<>(Arrays.asList(expressions)); + expressionList.addFirst(this); + return join(OR, expressionList); + } + @Override public boolean equals(Object o) { if (this == o) { @@ -255,6 +339,33 @@ public Builder expressionValues(Map expressionValues) { return this; } + /** + * Merge the given ExpressionValues into the builders existing ExpressionValues + * @param expressionValues The values to merge into the ExpressionValues map + * @throws IllegalArgumentException if a conflict occurs when merging ExpressionValues + */ + public Builder mergeExpressionValues(Map expressionValues) { + if (this.expressionValues == null) { + return expressionValues(expressionValues); + } + + if (expressionValues == null) { + return this; + } + + expressionValues.forEach((key, value) -> { + AttributeValue oldValue = this.expressionValues.put(key, value); + + if (oldValue != null && !oldValue.equals(value)) { + throw new IllegalArgumentException( + String.format("Attempt to coalesce expressions with conflicting expression values. " + + "Expression value key = '%s'", key)); + } + }); + + return this; + } + /** * Adds a single element to the optional 'expression values' token map */ @@ -275,6 +386,33 @@ public Builder expressionNames(Map expressionNames) { return this; } + /** + * Merge the given ExpressionNames into the builders existing ExpressionNames + * @param expressionNames The values to merge into the ExpressionNames map + * @throws IllegalArgumentException if a conflict occurs when merging ExpressionNames + */ + public Builder mergeExpressionNames(Map expressionNames) { + if (this.expressionNames == null) { + return expressionNames(expressionNames); + } + + if (expressionNames == null) { + return this; + } + + expressionNames.forEach((key, value) -> { + String oldValue = this.expressionNames.put(key, value); + + if (oldValue != null && !oldValue.equals(value)) { + throw new IllegalArgumentException( + String.format("Attempt to coalesce expressions with conflicting expression names. " + + "Expression name key = '%s'", key)); + } + }); + + return this; + } + /** * Adds a single element to the optional 'expression names' token map */ diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/ExpressionTest.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/ExpressionTest.java index c5c394e89bbb..aa18fe7f553b 100644 --- a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/ExpressionTest.java +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/ExpressionTest.java @@ -144,4 +144,242 @@ public void joinValues_conflictingKey() { exception.expectMessage("two"); Expression.joinValues(values1, values2); } + + @Test + public void join_correctlyJoins() { + Map names1 = new HashMap<>(); + names1.put("one", "1"); + Map values1 = new HashMap<>(); + values1.put("one", EnhancedAttributeValue.fromString("1").toAttributeValue()); + Expression expression1 = Expression.builder() + .expression("one") + .expressionNames(names1) + .expressionValues(values1) + .build(); + + Map names2 = new HashMap<>(); + names2.put("two", "2"); + Map values2 = new HashMap<>(); + values2.put("two", EnhancedAttributeValue.fromString("2").toAttributeValue()); + Expression expression2 = Expression.builder() + .expression("two") + .expressionNames(names2) + .expressionValues(values2) + .build(); + + Map names3 = new HashMap<>(); + names3.put("three", "3"); + Map values3 = new HashMap<>(); + values3.put("three", EnhancedAttributeValue.fromString("3").toAttributeValue()); + Expression expression3 = Expression.builder() + .expression("three") + .expressionNames(names3) + .expressionValues(values3) + .build(); + + Expression joinedExpression = Expression.join(Expression.AND, expression1, expression2, expression3); + + String expectedExpression = "(one) AND (two) AND (three)"; + assertThat(joinedExpression.expression(), is(expectedExpression)); + + final Map names = joinedExpression.expressionNames(); + assertThat(names.size(), is(3)); + assertThat(names, hasEntry("one", "1")); + assertThat(names, hasEntry("two", "2")); + assertThat(names, hasEntry("three", "3")); + + final Map values = joinedExpression.expressionValues(); + assertThat(values.size(), is(3)); + assertThat(values, hasEntry("one", AttributeValue.fromS("1"))); + assertThat(values, hasEntry("two", AttributeValue.fromS("2"))); + assertThat(values, hasEntry("three", AttributeValue.fromS("3"))); + } + + @Test + public void join_conflictingKey() { + Map names1 = new HashMap<>(); + names1.put("one", "1"); + Map values1 = new HashMap<>(); + values1.put("one", EnhancedAttributeValue.fromString("1").toAttributeValue()); + Expression expression1 = Expression.builder() + .expression("one") + .expressionNames(names1) + .expressionValues(values1) + .build(); + + Map names2 = new HashMap<>(); + names2.put("one", "2"); + Map values2 = new HashMap<>(); + values2.put("one", EnhancedAttributeValue.fromString("2").toAttributeValue()); + Expression expression2 = Expression.builder() + .expression("two") + .expressionNames(names2) + .expressionValues(values2) + .build(); + + Map names3 = new HashMap<>(); + names3.put("one", "3"); + Map values3 = new HashMap<>(); + values3.put("one", EnhancedAttributeValue.fromString("3").toAttributeValue()); + Expression expression3 = Expression.builder() + .expression("three") + .expressionNames(names3) + .expressionValues(values3) + .build(); + + exception.expect(IllegalArgumentException.class); + exception.expectMessage("on"); + Expression.join(Expression.AND, expression1, expression2, expression3); + } + + @Test + public void and_correctlyJoins() { + Map names1 = new HashMap<>(); + names1.put("one", "1"); + Map values1 = new HashMap<>(); + values1.put("one", EnhancedAttributeValue.fromString("1").toAttributeValue()); + Expression expression1 = Expression.builder() + .expression("one") + .expressionNames(names1) + .expressionValues(values1) + .build(); + + Map names2 = new HashMap<>(); + names2.put("two", "2"); + Map values2 = new HashMap<>(); + values2.put("two", EnhancedAttributeValue.fromString("2").toAttributeValue()); + Expression expression2 = Expression.builder() + .expression("two") + .expressionNames(names2) + .expressionValues(values2) + .build(); + + Map names3 = new HashMap<>(); + names3.put("three", "3"); + Map values3 = new HashMap<>(); + values3.put("three", EnhancedAttributeValue.fromString("3").toAttributeValue()); + Expression expression3 = Expression.builder() + .expression("three") + .expressionNames(names3) + .expressionValues(values3) + .build(); + + Expression joinedExpression = expression1.and(expression2, expression3); + + String expectedExpression = "(one) AND (two) AND (three)"; + assertThat(joinedExpression.expression(), is(expectedExpression)); + + final Map names = joinedExpression.expressionNames(); + assertThat(names.size(), is(3)); + assertThat(names, hasEntry("one", "1")); + assertThat(names, hasEntry("two", "2")); + assertThat(names, hasEntry("three", "3")); + + final Map values = joinedExpression.expressionValues(); + assertThat(values.size(), is(3)); + assertThat(values, hasEntry("one", AttributeValue.fromS("1"))); + assertThat(values, hasEntry("two", AttributeValue.fromS("2"))); + assertThat(values, hasEntry("three", AttributeValue.fromS("3"))); + } + + @Test + public void or_correctlyJoins() { + Map names1 = new HashMap<>(); + names1.put("one", "1"); + Map values1 = new HashMap<>(); + values1.put("one", EnhancedAttributeValue.fromString("1").toAttributeValue()); + Expression expression1 = Expression.builder() + .expression("one") + .expressionNames(names1) + .expressionValues(values1) + .build(); + + Map names2 = new HashMap<>(); + names2.put("two", "2"); + Map values2 = new HashMap<>(); + values2.put("two", EnhancedAttributeValue.fromString("2").toAttributeValue()); + Expression expression2 = Expression.builder() + .expression("two") + .expressionNames(names2) + .expressionValues(values2) + .build(); + + Map names3 = new HashMap<>(); + names3.put("three", "3"); + Map values3 = new HashMap<>(); + values3.put("three", EnhancedAttributeValue.fromString("3").toAttributeValue()); + Expression expression3 = Expression.builder() + .expression("three") + .expressionNames(names3) + .expressionValues(values3) + .build(); + + Expression joinedExpression = expression1.or(expression2, expression3); + + String expectedExpression = "(one) OR (two) OR (three)"; + assertThat(joinedExpression.expression(), is(expectedExpression)); + + final Map names = joinedExpression.expressionNames(); + assertThat(names.size(), is(3)); + assertThat(names, hasEntry("one", "1")); + assertThat(names, hasEntry("two", "2")); + assertThat(names, hasEntry("three", "3")); + + final Map values = joinedExpression.expressionValues(); + assertThat(values.size(), is(3)); + assertThat(values, hasEntry("one", AttributeValue.fromS("1"))); + assertThat(values, hasEntry("two", AttributeValue.fromS("2"))); + assertThat(values, hasEntry("three", AttributeValue.fromS("3"))); + } + + @Test + public void compounded_expressions_correctlyJoins() { + Map names1 = new HashMap<>(); + names1.put("one", "1"); + Map values1 = new HashMap<>(); + values1.put("one", EnhancedAttributeValue.fromString("1").toAttributeValue()); + Expression expression1 = Expression.builder() + .expression("one") + .expressionNames(names1) + .expressionValues(values1) + .build(); + + Map names2 = new HashMap<>(); + names2.put("two", "2"); + Map values2 = new HashMap<>(); + values2.put("two", EnhancedAttributeValue.fromString("2").toAttributeValue()); + Expression expression2 = Expression.builder() + .expression("two") + .expressionNames(names2) + .expressionValues(values2) + .build(); + + Map names3 = new HashMap<>(); + names3.put("three", "3"); + Map values3 = new HashMap<>(); + values3.put("three", EnhancedAttributeValue.fromString("3").toAttributeValue()); + Expression expression3 = Expression.builder() + .expression("three") + .expressionNames(names3) + .expressionValues(values3) + .build(); + + Expression joinedExpression = expression1.and(expression2.or(expression3)); + + String expectedExpression = "(one) AND ((two) OR (three))"; + assertThat(joinedExpression.expression(), is(expectedExpression)); + + final Map names = joinedExpression.expressionNames(); + assertThat(names.size(), is(3)); + assertThat(names, hasEntry("one", "1")); + assertThat(names, hasEntry("two", "2")); + assertThat(names, hasEntry("three", "3")); + + final Map values = joinedExpression.expressionValues(); + assertThat(values.size(), is(3)); + assertThat(values, hasEntry("one", AttributeValue.fromS("1"))); + assertThat(values, hasEntry("two", AttributeValue.fromS("2"))); + assertThat(values, hasEntry("three", AttributeValue.fromS("3"))); + } + }