diff --git a/CHANGELOG.md b/CHANGELOG.md index 4778f59..0b3e72b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,15 @@ Unreleased Changes ------------------ +* Feature - Allow put, update, and delete item options to be passed through to + the underlying client calls. + +* Feature - Add an `original_error` accessor to `Errors::ConditionalWriteFailed` + which contains the `Aws::DynamoDB::Errors::ConditionalCheckFailedException` + error. If `:return_values_on_condition_check_failure` was provided to a put, + update, or delete item call, this error will contain the item data that failed + the condition check. + * Issue - Fix default value for String/Numeric Sets to be unset. (#133) * Feature - Set required Ruby version to >= 2.3 (#134) @@ -25,7 +34,8 @@ Unreleased Changes 2.9.0 (2022-11-16) ------------------ -* Feature - Add support for inheritance. Aws Record models can now be extended using standard ruby inheritance (#80). +* Feature - Add support for inheritance. Aws Record models can now be extended + using standard ruby inheritance (#80). 2.8.0 (2022-10-12) ------------------ @@ -50,7 +60,8 @@ Unreleased Changes 2.5.0 (2020-10-13) ------------------ -* Feature - `Aws::Record::BuildableSearch` - Support queries yielding heterogeneous results using `multi_model_filter` (#107) +* Feature - `Aws::Record::BuildableSearch` - Support queries yielding + heterogeneous results using `multi_model_filter` (#107) 2.4.1 (2020-05-29) ------------------ @@ -61,53 +72,89 @@ Unreleased Changes 2.4.0 (2019-07-16) ------------------ -* Feature - Aws::Record::BuildableSearch - Adds support for query and scan builders using substitution expressions. This allows for streamlined and expressive queries and scans using aws-record. +* Feature - Aws::Record::BuildableSearch - Adds support for query and scan + builders using substitution expressions. This allows for streamlined and + expressive queries and scans using aws-record. 2.3.0 (2019-02-08) ------------------ -* Feature - Aws::Record::Transactions - Adds support for transactional find and transactional get requests. You can learn more about these new APIs in the [documentation](https://docs.aws.amazon.com/awssdkrubyrecord/api/Aws/Record/Transactions.html). +* Feature - Aws::Record::Transactions - Adds support for transactional find and + transactional get requests. You can learn more about these new APIs in + the [documentation](https://docs.aws.amazon.com/awssdkrubyrecord/api/Aws/Record/Transactions.html). 2.2.0 (2018-12-05) ------------------ -* Feature - Aws::Record::TableConfig - Adds support for the "PAY_PER_REQUEST" billing mode in table configurations. +* Feature - Aws::Record::TableConfig - Adds support for the "PAY_PER_REQUEST" + billing mode in table configurations. 2.1.2 (2018-11-15) ------------------ -* Issue - Aws::Record::Marshalers::EpochTimeMarshaler - Fixed a bug where epoch time objects didn't properly marshal from database entries. +* Issue - Aws::Record::Marshalers::EpochTimeMarshaler - Fixed a bug where epoch + time objects didn't properly marshal from database entries. 2.1.1 (2018-07-10) ------------------ -* Feature - Aws::Record::TableConfig - Adds `:ttl_attribute` to the TableConfig DSL. When used with `epoch_time_attr` attributes or other attributes stored as epoch time, your TableConfig migrations will enable TTL on your DynamoDB table, and will use your specified attribute as the TTL attribute. +* Feature - Aws::Record::TableConfig - Adds `:ttl_attribute` to the TableConfig + DSL. When used with `epoch_time_attr` attributes or other attributes stored as + epoch time, your TableConfig migrations will enable TTL on your DynamoDB + table, and will use your specified attribute as the TTL attribute. -* Feature - Aws::Record::Marshalers::EpochTimeMarshaler - Adds the `epoch_time_attr`, which behaves much like `time_attr` except the Amazon DynamoDB storage type is numeric, and the serialized value is epoch seconds. +* Feature - Aws::Record::Marshalers::EpochTimeMarshaler - Adds + the `epoch_time_attr`, which behaves much like `time_attr` except the Amazon + DynamoDB storage type is numeric, and the serialized value is epoch seconds. 2.1.0 (2018-06-25) ------------------ -* Feature - Aws::Record - Add the `persisted?`, `new_record?`, and `destroyed?` methods to `Aws::Record`, which supports use cases where you'd like to see if a record has just been newly initialized, or has been deleted or was a preexisting record retrieved from DynamoDB. Note that these methods are present in `ActiveModel::Model` so you should require that module before `Aws::Record` +* Feature - Aws::Record - Add the `persisted?`, `new_record?`, and `destroyed?` + methods to `Aws::Record`, which supports use cases where you'd like to see if + a record has just been newly initialized, or has been deleted or was a + preexisting record retrieved from DynamoDB. Note that these methods are + present in `ActiveModel::Model` so you should require that module + before `Aws::Record` -* Feature - Aws::Record - Add the `assign_attributes`, `update`, and `update!` methods to `Aws::Record` which supports the use case where the user might want to mass assign or update a records attributes by hash. `update!` also ensures that a `ValidationError` is thrown on an invalid update +* Feature - Aws::Record - Add the `assign_attributes`, `update`, and `update!` + methods to `Aws::Record` which supports the use case where the user might want + to mass assign or update a records attributes by hash. `update!` also ensures + that a `ValidationError` is thrown on an invalid update -* Upgrading - If you already include `ActiveModel::Model` on your models the new `persisted?`, `new_record?` and `destroyed?` methods will not function properly unless you include `ActiveModel::Model` before `Aws::Record`. Additionally, new methods could lead to collisions if you happened to have attributes such as `:update` or `:assign_attributes`. In such a case, you would want to version lock below `2.1.0`, or use the `:database_attribute_name` property and change your attribute name in code. +* Upgrading - If you already include `ActiveModel::Model` on your models the + new `persisted?`, `new_record?` and `destroyed?` methods will not function + properly unless you include `ActiveModel::Model` before `Aws::Record`. + Additionally, new methods could lead to collisions if you happened to have + attributes such as `:update` or `:assign_attributes`. In such a case, you + would want to version lock below `2.1.0`, or use + the `:database_attribute_name` property and change your attribute name in + code. 2.0.2 (2018-06-08) ------------------ -* Feature - Aws::Record::Marshalers::TimeMarshaler - Adds the `time_attr` method to AWS Record models, which uses `Time` as the underlying type. +* Feature - Aws::Record::Marshalers::TimeMarshaler - Adds the `time_attr` method + to AWS Record models, which uses `Time` as the underlying type. 2.0.1 (2017-10-27) ------------------ -* Feature - Aws::Record::ItemCollection - Add the `#page` and `#last_evaluated_key` methods to `Aws::Record::ItemCollection`. This helps to support use cases where you'd like to control the result set size with the `:limit` parameter, or if you want to expose pagination capabilities to an outside caller, for example a list-type operation exposed in a web API. +* Feature - Aws::Record::ItemCollection - Add the `#page` + and `#last_evaluated_key` methods to `Aws::Record::ItemCollection`. This helps + to support use cases where you'd like to control the result set size with + the `:limit` parameter, or if you want to expose pagination capabilities to an + outside caller, for example a list-type operation exposed in a web API. 2.0.0 (2017-08-29) ------------------ -* Upgrading - Aws::Record - Support version 3 of the AWS SDK for Ruby. This is being released as major version 2 of `aws-record`, though the APIs remain the same. Do note, however, that we've changed our SDK dependency to only depend on `aws-sdk-dynamodb`. This means that if you were depending on other service clients transitively via `aws-record`, you will need to add dependencies on the appropriate service gems when upgrading. +* Upgrading - Aws::Record - Support version 3 of the AWS SDK for Ruby. This is + being released as major version 2 of `aws-record`, though the APIs remain the + same. Do note, however, that we've changed our SDK dependency to only depend + on `aws-sdk-dynamodb`. This means that if you were depending on other service + clients transitively via `aws-record`, you will need to add dependencies on + the appropriate service gems when upgrading. 1.1.1 (2017-06-16) ------------------ @@ -169,18 +216,21 @@ Unreleased Changes 1.0.0.pre.10 (2016-08-03) ------------------ -* Feature - Aws::Record - Refactored tracking of model attributes, key attributes, - and item data to use internal classes over module composition. Dirty tracking is +* Feature - Aws::Record - Refactored tracking of model attributes, key + attributes, + and item data to use internal classes over module composition. Dirty tracking + is also handled more consistently across attributes, and turning on/off of dirty tracking is only possible at the model level (not for individual attributes). 1.0.0.pre.9 (2016-07-22) ------------------ -* Feature - Aws::Record::Attribute - Added support for default values at the attribute - level. +* Feature - Aws::Record::Attribute - Added support for default values at the + attribute level. -* Feature - Aws::Record::Marshalers - Removed the marshalers in the `Aws::Attributes` +* Feature - Aws::Record::Marshalers - Removed the marshalers in + the `Aws::Attributes` namespace, replacing them with instantiated marshaler objects. This enables more functionality in marshalers such as the Date/DateTime marshalers. diff --git a/lib/aws-record/record/errors.rb b/lib/aws-record/record/errors.rb index 5287c67..be50d0d 100644 --- a/lib/aws-record/record/errors.rb +++ b/lib/aws-record/record/errors.rb @@ -16,7 +16,17 @@ class KeyMissing < RecordError; end class NotFound < RecordError; end # Raised when a conditional write fails. - class ConditionalWriteFailed < RecordError; end + # Provides access to the original ConditionalCheckFailedException error + # which may have item data if the return values option was used. + class ConditionalWriteFailed < RecordError + def initialize(message, original_error) + @original_error = original_error + super(message) + end + + # @return [Aws::DynamoDB::Errors::ConditionalCheckFailedException] + attr_reader :original_error + end # Raised when a validation hook call to +:valid?+ fails. class ValidationError < RecordError; end diff --git a/lib/aws-record/record/item_operations.rb b/lib/aws-record/record/item_operations.rb index 2b56dc4..5e4b4ca 100644 --- a/lib/aws-record/record/item_operations.rb +++ b/lib/aws-record/record/item_operations.rb @@ -21,10 +21,17 @@ def self.included(sub_class) # You can use the +:force+ option to perform a simple put/overwrite # without conditional validation or update logic. # - # @param [Hash] opts + # @param [Hash] opts Options to pass through to the + # {http://docs.aws.amazon.com/sdk-for-ruby/v3/api/Aws/DynamoDB/Client.html#put_item-instance_method + # Aws::DynamoDB::Client#put_item} call or the + # {http://docs.aws.amazon.com/sdk-for-ruby/v3/api/Aws/DynamoDB/Client.html#update_item-instance_method + # Aws::DynamoDB::Client#update_item} call. +:put_item+ is used when + # +:force+ is true or when the item is new. +:update_item+ is used when + # the item is not new. # @option opts [Boolean] :force if true, will save as a put operation and # overwrite any existing item on the remote end. Otherwise, and by # default, will either perform a conditional put or an update call. + # # @raise [Aws::Record::Errors::KeyMissing] if a required key attribute # does not have a value within this item instance. # @raise [Aws::Record::Errors::ConditionalWriteFailed] if a conditional @@ -52,10 +59,17 @@ def save!(opts = {}) # You can use the +:force+ option to perform a simple put/overwrite # without conditional validation or update logic. # - # @param [Hash] opts + # @param [Hash] opts Options to pass through to the + # {http://docs.aws.amazon.com/sdk-for-ruby/v3/api/Aws/DynamoDB/Client.html#put_item-instance_method + # Aws::DynamoDB::Client#put_item} call or the + # {http://docs.aws.amazon.com/sdk-for-ruby/v3/api/Aws/DynamoDB/Client.html#update_item-instance_method + # Aws::DynamoDB::Client#update_item} call. +:put_item+ is used when + # +:force+ is true or when the item is new. +:update_item+ is used when + # the item is not new. # @option opts [Boolean] :force if true, will save as a put operation and # overwrite any existing item on the remote end. Otherwise, and by # default, will either perform a conditional put or an update call. + # # @return false if the record is invalid as defined by an attempt to call # +valid?+ on this item, if that method exists. Otherwise, returns client # call return value. @@ -133,10 +147,17 @@ def assign_attributes(opts) # # @param [Hash] new_params Contains the new parameters for the model. # - # @param [Hash] opts + # @param [Hash] opts Options to pass through to the + # {http://docs.aws.amazon.com/sdk-for-ruby/v3/api/Aws/DynamoDB/Client.html#put_item-instance_method + # Aws::DynamoDB::Client#put_item} call or the + # {http://docs.aws.amazon.com/sdk-for-ruby/v3/api/Aws/DynamoDB/Client.html#update_item-instance_method + # Aws::DynamoDB::Client#update_item} call. +:put_item+ is used when + # +:force+ is true or when the item is new. +:update_item+ is used when + # the item is not new. # @option opts [Boolean] :force if true, will save as a put operation and # overwrite any existing item on the remote end. Otherwise, and by # default, will either perform a conditional put or an update call. + # # @return false if the record is invalid as defined by an attempt to call # +valid?+ on this item, if that method exists. Otherwise, returns client # call return value. @@ -155,11 +176,17 @@ def update(new_params, opts = {}) # table # # @param [Hash] new_params Contains the new parameters for the model. - # - # @param [Hash] opts + # @param [Hash] opts Options to pass through to the + # {http://docs.aws.amazon.com/sdk-for-ruby/v3/api/Aws/DynamoDB/Client.html#put_item-instance_method + # Aws::DynamoDB::Client#put_item} call or the + # {http://docs.aws.amazon.com/sdk-for-ruby/v3/api/Aws/DynamoDB/Client.html#update_item-instance_method + # Aws::DynamoDB::Client#update_item} call. +:put_item+ is used when + # +:force+ is true or when the item is new. +:update_item+ is used when + # the item is not new. # @option opts [Boolean] :force if true, will save as a put operation and # overwrite any existing item on the remote end. Otherwise, and by # default, will either perform a conditional put or an update call. + # # @return The update mode if the update is successful # # @raise [Aws::Record::Errors::ValidationError] if any new values @@ -173,11 +200,16 @@ def update!(new_params, opts = {}) # instance in Amazon DynamoDB. Uses the # {http://docs.aws.amazon.com/sdk-for-ruby/v3/api/Aws/DynamoDB/Client.html#delete_item-instance_method # Aws::DynamoDB::Client#delete_item} API. - def delete! - dynamodb_client.delete_item( + # + # @param [Hash] opts Options to pass through to the + # {http://docs.aws.amazon.com/sdk-for-ruby/v3/api/Aws/DynamoDB/Client.html#delete_item-instance_method + # Aws::DynamoDB::Client#delete_item} call. + def delete!(opts = {}) + delete_opts = { table_name: self.class.table_name, key: key_values - ) + } + dynamodb_client.delete_item(opts.merge(delete_opts)) instance_variable_get('@data').destroyed = true end @@ -213,25 +245,28 @@ def _invalid_record?(_opts) end def _perform_save(opts) - force = opts[:force] + force = opts.delete(:force) expect_new = expect_new_item? if force - dynamodb_client.put_item( + put_opts = { table_name: self.class.table_name, item: _build_item_for_save - ) + } + dynamodb_client.put_item(opts.merge(put_opts)) elsif expect_new put_opts = { table_name: self.class.table_name, item: _build_item_for_save }.merge(prevent_overwrite_expression) begin - dynamodb_client.put_item(put_opts) - rescue Aws::DynamoDB::Errors::ConditionalCheckFailedException - raise Errors::ConditionalWriteFailed, - 'Conditional #put_item call failed! Check that conditional write ' \ - 'conditions are met, or include the :force option to clobber ' \ - 'the remote item.' + dynamodb_client.put_item(opts.merge(put_opts)) + rescue Aws::DynamoDB::Errors::ConditionalCheckFailedException => e + raise Errors::ConditionalWriteFailed.new( + 'Conditional #put_item call failed! Check that conditional write ' \ + 'conditions are met, or include the :force option to clobber ' \ + 'the remote item.', + e + ) end else update_pairs = _dirty_changes_for_update @@ -241,20 +276,20 @@ def _perform_save(opts) ) if update_tuple uex, exp_attr_names, exp_attr_values = update_tuple - request_opts = { + update_opts = { table_name: self.class.table_name, key: key_values, update_expression: uex, expression_attribute_names: exp_attr_names } - request_opts[:expression_attribute_values] = exp_attr_values unless exp_attr_values.empty? - dynamodb_client.update_item(request_opts) + update_opts[:expression_attribute_values] = exp_attr_values unless exp_attr_values.empty? else - dynamodb_client.update_item( + update_opts = { table_name: self.class.table_name, key: key_values - ) + } end + dynamodb_client.update_item(opts.merge(update_opts)) end data = instance_variable_get('@data') data.destroyed = false @@ -460,12 +495,16 @@ def find(opts) # supports the options you are including, and avoid adding options not # recognized by the underlying client to avoid runtime exceptions. # - # @param [Hash] opts Options to pass through to the DynamoDB #get_item - # request. The +:key+ option is a special case where attributes are - # serialized and translated for you similar to the #find method. + # @param [Hash] opts Options to pass through to the + # {http://docs.aws.amazon.com/sdk-for-ruby/v3/api/Aws/DynamoDB/Client.html#get_item-instance_method + # Aws::DynamoDB::Client#get_item} request. The +:key+ option is a + # special case where attributes are serialized and translated for you + # similar to the #find method. # @option opts [Hash] :key attribute-value pairs for the key you wish to # search for. + # # @return [Aws::Record] builds and returns an instance of your model. + # # @raise [Aws::Record::Errors::KeyMissing] if your option parameters do # not include all table keys. def find_with_opts(opts) @@ -478,11 +517,11 @@ def find_with_opts(opts) request_key[attr_name] = attributes.attribute_for(attr_sym) .serialize(key[attr_sym]) end - request_opts = { + get_opts = { table_name: table_name, key: request_key }.merge(opts) - resp = dynamodb_client.get_item(request_opts) + resp = dynamodb_client.get_item(get_opts) if resp.item.nil? nil else @@ -546,6 +585,7 @@ def find_all(keys) # wish to perform. You must include all key attributes for a valid # call, then you may optionally include any other attributes that you # wish to update. + # # @raise [Aws::Record::Errors::KeyMissing] if your option parameters do # not include all table keys. def update(opts) @@ -559,18 +599,18 @@ def update(opts) attr_name = attributes.storage_name_for(attr_sym) key[attr_name] = attributes.attribute_for(attr_sym).serialize(value) end - request_opts = { + update_opts = { table_name: table_name, key: key } update_tuple = _build_update_expression(opts) unless update_tuple.nil? uex, exp_attr_names, exp_attr_values = update_tuple - request_opts[:update_expression] = uex - request_opts[:expression_attribute_names] = exp_attr_names - request_opts[:expression_attribute_values] = exp_attr_values unless exp_attr_values.empty? + update_opts[:update_expression] = uex + update_opts[:expression_attribute_names] = exp_attr_names + update_opts[:expression_attribute_values] = exp_attr_values unless exp_attr_values.empty? end - dynamodb_client.update_item(request_opts) + dynamodb_client.update_item(update_opts) end private diff --git a/spec/aws-record/record/item_operations_spec.rb b/spec/aws-record/record/item_operations_spec.rb index ffe1b26..0fe4651 100644 --- a/spec/aws-record/record/item_operations_spec.rb +++ b/spec/aws-record/record/item_operations_spec.rb @@ -68,6 +68,31 @@ module Record ) end + it 'passes through options to #update_item and #put_item' do + klass.configure_client(client: stub_client) + item = klass.new + item.id = 1 + item.date = '2015-12-14' + item.body = 'Hello!' + # new record + item.save!(table_name: 'notused', return_values: 'ALL_OLD') + # forced + item.save!(force: true, table_name: 'notused', return_values: 'UPDATED_OLD') + # not updated tuple + item.save!(table_name: 'notused', return_values: 'ALL_NEW') + # updated tuple + item.clean! + item.body = 'Goodbye!' + item.save!(table_name: 'notused', return_values: 'UPDATED_NEW') + + expect(api_requests).to match [ + hash_including(table_name: 'TestTable', return_values: 'ALL_OLD'), + hash_including(table_name: 'TestTable', return_values: 'UPDATED_OLD'), + hash_including(table_name: 'TestTable', return_values: 'ALL_NEW'), + hash_including(table_name: 'TestTable', return_values: 'UPDATED_NEW') + ] + end + it 'raises an error when you try to save! without setting keys' do klass.configure_client(client: stub_client) no_keys = klass.new @@ -176,6 +201,31 @@ module Record ) end + it 'passes through options to #update_item and #put_item' do + klass.configure_client(client: stub_client) + item = klass.new + item.id = 1 + item.date = '2015-12-14' + item.body = 'Hello!' + # new record + item.save(table_name: 'notused', return_values: 'ALL_OLD') + # forced + item.save(force: true, table_name: 'notused', return_values: 'UPDATED_OLD') + # not updated tuple + item.save(table_name: 'notused', return_values: 'ALL_NEW') + # updated tuple + item.clean! + item.body = 'Goodbye!' + item.save(table_name: 'notused', return_values: 'UPDATED_NEW') + + expect(api_requests).to match [ + hash_including(table_name: 'TestTable', return_values: 'ALL_OLD'), + hash_including(table_name: 'TestTable', return_values: 'UPDATED_OLD'), + hash_including(table_name: 'TestTable', return_values: 'ALL_NEW'), + hash_including(table_name: 'TestTable', return_values: 'UPDATED_NEW') + ] + end + it 'raises an exception when the conditional check fails' do stub_client.stub_responses( :put_item, @@ -186,7 +236,12 @@ module Record item.id = 1 item.date = '2015-12-14' item.body = 'Hello!' - expect { item.save }.to raise_error(Errors::ConditionalWriteFailed) + expect { item.save }.to raise_error do |error| + expect(error).to be_a(Errors::ConditionalWriteFailed) + expect(error.original_error).to be_a( + Aws::DynamoDB::Errors::ConditionalCheckFailedException + ) + end expect(api_requests).to eq( [ { @@ -359,18 +414,7 @@ module Record consistent_read: true } klass.find_with_opts(find_opts) - expect(api_requests).to eq( - [ - { - table_name: 'TestTable', - key: { - 'id' => { n: '5' }, - 'MyDate' => { s: '2015-12-15' } - }, - consistent_read: true - } - ] - ) + expect(api_requests).to match([hash_including(consistent_read: true)]) end end @@ -394,7 +438,7 @@ module Record end end - describe '#update' do + describe '.update' do it 'can find and update an item from Amazon DynamoDB' do klass.configure_client(client: stub_client) klass.update(id: 1, date: '2016-05-18', body: 'New', bool: true) @@ -509,6 +553,17 @@ module Record ] ) end + + it 'passes through options to #delete_item' do + klass.configure_client(client: stub_client) + item = klass.new + item.id = 3 + item.date = '2015-12-17' + item.delete!(table_name: 'notused', return_values: 'ALL_OLD') + expect(api_requests).to include( + hash_including(table_name: 'TestTable', return_values: 'ALL_OLD') + ) + end end describe 'save after delete scenarios' do