DynamoDB: Allow removal of multiple listitems (#5767)

This commit is contained in:
Bert Blommers 2022-12-14 10:07:34 -01:00 committed by GitHub
parent 77cf4e3143
commit cb27b55008
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 151 additions and 5 deletions

View File

@ -1588,6 +1588,7 @@ class DynamoDBBackend(BaseBackend):
table=table,
)
validated_ast = validator.validate()
validated_ast.normalize()
try:
UpdateExpressionExecutor(
validated_ast, item, expression_attribute_names

View File

@ -23,10 +23,10 @@ class Node(metaclass=abc.ABCMeta):
def validate(self):
if self.type == "UpdateExpression":
nr_of_clauses = len(self.find_clauses(UpdateExpressionAddClause))
nr_of_clauses = len(self.find_clauses([UpdateExpressionAddClause]))
if nr_of_clauses > 1:
raise TooManyAddClauses()
set_actions = self.find_clauses(UpdateExpressionSetAction)
set_actions = self.find_clauses([UpdateExpressionSetAction])
# set_attributes = ["attr", "map.attr", attr.list[2], ..]
set_attributes = [s.children[0].to_str() for s in set_actions]
# We currently only check for duplicates
@ -34,13 +34,53 @@ class Node(metaclass=abc.ABCMeta):
if len(set_attributes) != len(set(set_attributes)):
raise DuplicateUpdateExpression(set_attributes)
def find_clauses(self, clause_type):
def normalize(self):
"""
Flatten the Add-/Delete-/Remove-/Set-Action children within this Node
"""
if self.type == "UpdateExpression":
# We can have multiple REMOVE attr[idx] expressions, such as attr[i] and attr[i+2]
# If we remove attr[i] first, attr[i+2] suddenly refers to a different item
# So we sort them in reverse order - we can remove attr[i+2] first, attr[i] still refers to the same item
# Behaviour that is unknown, for now:
# What happens if we SET and REMOVE on the same list - what takes precedence?
# We're assuming this is executed in original order
remove_actions = []
sorted_actions = []
possible_clauses = [
UpdateExpressionAddAction,
UpdateExpressionDeleteAction,
UpdateExpressionRemoveAction,
UpdateExpressionSetAction,
]
for action in self.find_clauses(possible_clauses):
if isinstance(action, UpdateExpressionRemoveAction):
# Keep these separate for now
remove_actions.append(action)
else:
if len(remove_actions) > 0:
# Remove-actions were found earlier
# Now that we have other action-types, that means we've found all possible Remove-actions
# Sort them appropriately
sorted_actions.extend(sorted(remove_actions, reverse=True))
remove_actions.clear()
# Add other actions by insertion order
sorted_actions.append(action)
# Remove actions were found last
if len(remove_actions) > 0:
sorted_actions.extend(sorted(remove_actions, reverse=True))
self.children = sorted_actions
def find_clauses(self, clause_types):
clauses = []
for child in self.children or []:
if isinstance(child, clause_type):
if type(child) in clause_types:
clauses.append(child)
elif isinstance(child, Expression):
clauses.extend(child.find_clauses(clause_type))
clauses.extend(child.find_clauses(clause_types))
return clauses
@ -115,6 +155,16 @@ class UpdateExpressionRemoveAction(UpdateExpressionClause):
RemoveAction => Path
"""
def _get_value(self):
expression_path = self.children[0]
expression_selector = expression_path.children[-1]
return expression_selector.children[0]
def __lt__(self, other):
self_value = self._get_value()
return self_value < other._get_value()
class UpdateExpressionAddActions(UpdateExpressionClause):
"""

View File

@ -273,6 +273,8 @@ class UpdateExpressionExecutor(object):
and process the nodes 1-by-1. If no specific execution for the node type is defined we can execute the children
in order since it will be a container node that is expandable and left child will be first in the statement.
Note that, if `normalize()` is called before, the list of children will be flattened and sorted (if appropriate).
Args:
node(Node):

View File

@ -2721,6 +2721,29 @@ def test_remove_list_index__remove_existing_index():
result["itemlist"].should.equal({"L": [{"S": "bar1"}, {"S": "bar3"}]})
@mock_dynamodb
def test_remove_list_index__remove_multiple_indexes():
table_name = "remove-test"
create_table_with_list(table_name)
dynamodb = boto3.resource("dynamodb", region_name="us-east-1")
table = dynamodb.Table(table_name)
table.put_item(
Item={
"id": "woop",
"bla": ["1", "2", "3", "4", "5"],
},
)
table.update_item(
Key={"id": "woop"}, UpdateExpression="REMOVE bla[0], bla[1], bla[2]"
)
result = table.get_item(Key={"id": "woop"})
item = result["Item"]
assert item["bla"] == ["4", "5"]
@mock_dynamodb
def test_remove_list_index__remove_existing_nested_index():
table_name = "test_list_index_access"

View File

@ -2,6 +2,13 @@ import pytest
from moto.dynamodb.exceptions import IncorrectOperandType, IncorrectDataType
from moto.dynamodb.models import Item, DynamoType
from moto.dynamodb.parsing.ast_nodes import (
UpdateExpression,
UpdateExpressionAddClause,
UpdateExpressionAddAction,
UpdateExpressionRemoveAction,
UpdateExpressionSetAction,
)
from moto.dynamodb.parsing.executors import UpdateExpressionExecutor
from moto.dynamodb.parsing.expressions import UpdateExpressionParser
from moto.dynamodb.parsing.validators import UpdateExpressionValidator
@ -430,3 +437,66 @@ def test_execution_of_delete_element_from_a_string_attribute(table):
assert False, "Must raise exception"
except IncorrectDataType:
assert True
def test_normalize_with_one_action(table):
update_expression = "ADD s :value"
update_expression_ast = UpdateExpressionParser.make(update_expression)
item = Item(
hash_key=DynamoType({"S": "id"}),
range_key=None,
attrs={"id": {"S": "foo2"}, "s": {"SS": ["value1", "value2", "value3"]}},
)
validated_ast = UpdateExpressionValidator(
update_expression_ast,
expression_attribute_names=None,
expression_attribute_values={":value": {"SS": ["value2", "value5"]}},
item=item,
table=table,
).validate()
validated_ast.children.should.have.length_of(1)
validated_ast.children[0].should.be.a(UpdateExpressionAddClause)
validated_ast.normalize()
validated_ast.children.should.have.length_of(1)
validated_ast.children[0].should.be.a(UpdateExpressionAddAction)
def test_normalize_with_multiple_actions__order_is_preserved(table):
update_expression = "ADD s :value REMOVE a[3], a[1], a[2] SET t=:value"
update_expression_ast = UpdateExpressionParser.make(update_expression)
item = Item(
hash_key=DynamoType({"S": "id"}),
range_key=None,
attrs={
"id": {"S": "foo2"},
"a": {"L": [{"S": "val1"}, {"S": "val2"}, {"S": "val3"}, {"S": "val4"}]},
"s": {"SS": ["value1", "value2", "value3"]},
},
)
validated_ast = UpdateExpressionValidator(
update_expression_ast,
expression_attribute_names=None,
expression_attribute_values={":value": {"SS": ["value2", "value5"]}},
item=item,
table=table,
).validate()
validated_ast.children.should.have.length_of(2)
# add clause first
validated_ast.children[0].should.be.a(UpdateExpressionAddClause)
# rest of the expression next
validated_ast.children[1].should.be.a(UpdateExpression)
validated_ast.normalize()
validated_ast.children.should.have.length_of(5)
# add action first
validated_ast.children[0].should.be.a(UpdateExpressionAddAction)
# Removal actions in reverse order
validated_ast.children[1].should.be.a(UpdateExpressionRemoveAction)
validated_ast.children[1]._get_value().should.equal(3)
validated_ast.children[2].should.be.a(UpdateExpressionRemoveAction)
validated_ast.children[2]._get_value().should.equal(2)
validated_ast.children[3].should.be.a(UpdateExpressionRemoveAction)
validated_ast.children[3]._get_value().should.equal(1)
# Set action last, as per insertion order
validated_ast.children[4].should.be.a(UpdateExpressionSetAction)