lib/aws-record/record/transactions.rb (141 lines of code) (raw):
# frozen_string_literal: true
require 'ostruct'
module Aws
module Record
module Transactions
extend ClientConfiguration
class << self
# @example Usage Example
# class TableOne
# include Aws::Record
# string_attr :uuid, hash_key: true
# end
#
# class TableTwo
# include Aws::Record
# string_attr :hk, hash_key: true
# string_attr :rk, range_key: true
# end
#
# results = Aws::Record::Transactions.transact_find(
# transact_items: [
# TableOne.tfind_opts(key: { uuid: "uuid1234" }),
# TableTwo.tfind_opts(key: { hk: "hk1", rk: "rk1"}),
# TableTwo.tfind_opts(key: { hk: "hk2", rk: "rk2"})
# ]
# ) # => results.responses contains nil or marshalled items
# results.responses.map { |r| r.class } # [TableOne, TableTwo, TableTwo]
#
# Provides support for the
# {https://docs.aws.amazon.com/sdk-for-ruby/v3/api/Aws/DynamoDB/Client.html#transact_get_items-instance_method
# Aws::DynamoDB::Client#transact_get_item} for aws-record models.
#
# This method runs a transactional find across multiple DynamoDB
# items, including transactions which get items across multiple actual
# or virtual tables. This call can contain up to 100 item keys.
#
# DynamoDB will reject the request if any of the following is true:
# * A conflicting operation is in the process of updating an item to be read.
# * There is insufficient provisioned capacity for the transaction to be completed.
# * There is a user error, such as an invalid data format.
# * The aggregate size of the items in the transaction cannot exceed 4 MB.
#
# @param [Hash] opts Options to pass through to
# {https://docs.aws.amazon.com/sdk-for-ruby/v3/api/Aws/DynamoDB/Client.html#transact_get_items-instance_method
# Aws::DynamoDB::Client#transact_get_items}, with the exception of the
# +:transact_items+ array, which uses the {ItemOperations.ItemOperationsClassMethods.tfind_opts #tfind_opts}
# operation on your model class to provide extra metadata
# used to marshal your items after retrieval.
# @option opts [Array] :transact_items A set of +#tfind_opts+ results,
# such as those created by the usage example.
# @option opts [Aws::DynamoDB::Client] :client Optionally, you can pass
# in your own client to use for the transaction calls.
# @return [OpenStruct] Structured like the client API response from
# +#transact_get_items+, except that the +responses+ member contains
# +Aws::Record+ items marshaled into the classes used to call
# +#tfind_opts+ in each positional member. See the usage example.
def transact_find(opts)
opts = opts.dup
client = opts.delete(:client) || dynamodb_client
transact_items = opts.delete(:transact_items) # add nil check?
model_classes = []
client_transact_items = transact_items.map do |tfind_opts|
model_class = tfind_opts.delete(:model_class)
model_classes << model_class
tfind_opts
end
request_opts = opts
request_opts[:transact_items] = client_transact_items
client_resp = client.transact_get_items(
request_opts
)
index = -1
ret = OpenStruct.new
ret.consumed_capacity = client_resp.consumed_capacity
ret.missing_items = []
ret.responses = client_resp.responses.map do |item|
index += 1
if item.nil? || item.item.nil?
missing_data = {
model_class: model_classes[index],
key: transact_items[index][:get][:key]
}
ret.missing_items << missing_data
nil
else
# need to translate the item keys
raw_item = item.item
model_class = model_classes[index]
new_item_opts = {}
raw_item.each do |db_name, value|
name = model_class.attributes.db_to_attribute_name(db_name)
new_item_opts[name] = value
end
item = model_class.new(new_item_opts)
item.clean!
item
end
end
ret
end
# @example Usage Example
# class TableOne
# include Aws::Record
# string_attr :uuid, hash_key: true
# string_attr :body
# end
#
# class TableTwo
# include Aws::Record
# string_attr :hk, hash_key: true
# string_attr :rk, range_key: true
# string_attr :body
# end
#
# check_exp = TableOne.transact_check_expression(
# key: { uuid: "foo" },
# condition_expression: "size(#T) <= :v",
# expression_attribute_names: {
# "#T" => "body"
# },
# expression_attribute_values: {
# ":v" => 1024
# }
# )
# new_item = TableTwo.new(hk: "hk1", rk: "rk1", body: "Hello!")
# update_item_1 = TableOne.find(uuid: "bar")
# update_item_1.body = "Updated the body!"
# put_item = TableOne.new(uuid: "foobar", body: "Content!")
# update_item_2 = TableTwo.find(hk: "hk2", rk: "rk2")
# update_item_2.body = "Update!"
# delete_item = TableOne.find(uuid: "to_be_deleted")
#
# Aws::Record::Transactions.transact_write(
# transact_items: [
# { check: check_exp },
# { save: new_item },
# { save: update_item_1 },
# {
# put: put_item,
# condition_expression: "attribute_not_exists(#H)",
# expression_attribute_names: { "#H" => "uuid" },
# return_values_on_condition_check_failure: "ALL_OLD"
# },
# { update: update_item_2 },
# { delete: delete_item }
# ]
# )
#
# Provides support for the
# {https://docs.aws.amazon.com/sdk-for-ruby/v3/api/Aws/DynamoDB/Client.html#transact_write_items-instance_method
# Aws::DynamoDB::Client#transact_write_items} for aws-record models.
#
# This method passes in aws-record items into transactional writes,
# as well as adding the ability to run 'save' commands in a transaction
# while allowing aws-record to determine if a :put or :update operation
# is most appropriate. +#transact_write+ supports 5 different transact
# item modes:
# - save: Behaves much like the +#save+ operation on the item itself.
# If the keys are dirty, and thus it appears to be a new item, will
# create a :put operation with a conditional check on the item's
# existence. Note that you cannot bring your own conditional
# expression in this case. If you wish to force put or add your
# own conditional checks, use the :put operation.
# - put: Does a force put for the given item key and model.
# - update: Does an upsert for the given item.
# - delete: Deletes the given item.
# - check: Takes the result of +#transact_check_expression+,
# performing the specified check as a part of the transaction.
# See {ItemOperations.ItemOperationsClassMethods.transact_check_expression #transact_check_expression}
# for more information.
#
# This call contain up to 100 action requests.
#
# DynamoDB will reject the request if any of the following is true:
# * A condition in one of the condition expressions is not met.
# * An ongoing operation is in the process of updating the same item.
# * There is insufficient provisioned capacity for the transaction to
# be completed.
# * An item size becomes too large (bigger than 400 KB), a local secondary
# index (LSI) becomes too large, or a similar validation error occurs
# because of changes made by the transaction.
# * The aggregate size of the items in the transaction exceeds 4 MB.
# * There is a user error, such as an invalid data format.
#
# @param [Hash] opts Options to pass through to
# {https://docs.aws.amazon.com/sdk-for-ruby/v3/api/Aws/DynamoDB/Client.html#transact_write_items-instance_method
# Aws::DynamoDB::Client#transact_write_items} with the exception of
# :transact_items array, which is transformed to use your item to populate
# the :key, :table_name, :item, and/or :update_expression parameters
# as appropriate. See the usage example for a comprehensive set of combinations.
# @option opts [Array] :transact_items An array of hashes, accepting
# +:save+, +:put+, +:delete+, +:update+, and +:check+ as specified.
# @option opts [Aws::DynamoDB::Client] :client Optionally, you can
# specify a client to use for this transaction call. If not
# specified, the configured client for +Aws::Record::Transactions+
# is used.
def transact_write(opts)
opts = opts.dup
client = opts.delete(:client) || dynamodb_client
dirty_items = []
delete_items = []
# fetch abstraction records
transact_items = _transform_transact_write_items(
opts.delete(:transact_items),
dirty_items,
delete_items
)
opts[:transact_items] = transact_items
resp = client.transact_write_items(opts)
# mark all items clean/destroyed as needed if we didn't raise an exception
dirty_items.each(&:clean!)
delete_items.each { |i| i.instance_variable_get('@data').destroyed = true }
resp
end
private
def _transform_transact_write_items(transact_items, dirty_items, delete_items)
transact_items.map do |item|
# this code will assume users only provided one operation, and
# will fail down the line if that assumption is wrong
if (save_record = item.delete(:save))
dirty_items << save_record
_transform_save_record(save_record, item)
elsif (put_record = item.delete(:put))
dirty_items << put_record
_transform_put_record(put_record, item)
elsif (delete_record = item.delete(:delete))
delete_items << delete_record
_transform_delete_record(delete_record, item)
elsif (update_record = item.delete(:update))
dirty_items << update_record
_transform_update_record(update_record, item)
elsif (check_record = item.delete(:check))
_transform_check_record(check_record, item)
else
raise ArgumentError, 'Invalid transact write item, must include an operation of ' \
"type :save, :update, :delete, :update, or :check - #{item}"
end
end
end
def _transform_save_record(save_record, opts)
# determine if record is considered a new item or not
# then create a put with conditions, or an update
if save_record.send(:expect_new_item?)
safety_expression = save_record.send(:prevent_overwrite_expression)
if opts.include?(:condition_expression)
raise Errors::TransactionalSaveConditionCollision,
'Transactional write includes a :save operation that would ' \
"result in a 'safe put' for the given item, yet a " \
'condition expression was also provided. This is not ' \
'currently supported. You should rewrite this case to use ' \
'a :put transaction, adding the existence check to your ' \
"own condition expression if desired.\n" \
"\tItem: #{JSON.pretty_unparse(save_record.to_h)}\n" \
"\tExtra Options: #{JSON.pretty_unparse(opts)}"
else
opts = opts.merge(safety_expression)
_transform_put_record(save_record, opts)
end
else
_transform_update_record(save_record, opts)
end
end
def _transform_put_record(put_record, opts)
# convert to a straight put
opts[:table_name] = put_record.class.table_name
opts[:item] = put_record.send(:_build_item_for_save)
{ put: opts }
end
def _transform_delete_record(delete_record, opts)
# extract the key from each record to perform a deletion
opts[:table_name] = delete_record.class.table_name
opts[:key] = delete_record.send(:key_values)
{ delete: opts }
end
def _transform_update_record(update_record, opts)
# extract dirty attribute changes to perform an update
opts[:table_name] = update_record.class.table_name
opts[:key] = update_record.send(:key_values)
dirty_changes = update_record.send(:_dirty_changes_for_update)
update_expression_opts = update_record.class.send(
:_build_update_expression,
dirty_changes
)
opts = update_record.class.send(
:_merge_update_expression_opts,
update_expression_opts,
opts
)
{ update: opts }
end
def _transform_check_record(check_record, opts)
# check records are a pass-through
{ condition_check: opts.merge(check_record) }
end
end
end
end
end