diff --git a/README.md b/README.md index 1652f61d..ff32ff17 100644 --- a/README.md +++ b/README.md @@ -963,7 +963,7 @@ To idempotently create-but-not-update a record, apply the `unless_exists` condit to its keys when you upsert. ```ruby -Address.upsert(id, { city: 'Chicago' }, if: { unless_exists: [:id] }) +Address.upsert(id, { city: 'Chicago' }, { unless_exists: [:id] }) ``` ### Deleting diff --git a/lib/dynamoid/adapter_plugin/aws_sdk_v3.rb b/lib/dynamoid/adapter_plugin/aws_sdk_v3.rb index d4d3f641..d3687299 100644 --- a/lib/dynamoid/adapter_plugin/aws_sdk_v3.rb +++ b/lib/dynamoid/adapter_plugin/aws_sdk_v3.rb @@ -631,10 +631,7 @@ def expected_stanza(conditions = nil) conditions.delete(:unless_exists).try(:each) do |col| expected[col.to_s][:exists] = false end - conditions.delete(:if_exists).try(:each) do |col, val| - expected[col.to_s][:exists] = true - expected[col.to_s][:value] = val - end + conditions.delete(:if).try(:each) do |col, val| expected[col.to_s][:value] = val end diff --git a/lib/dynamoid/persistence.rb b/lib/dynamoid/persistence.rb index c2baf40d..b82dabdd 100644 --- a/lib/dynamoid/persistence.rb +++ b/lib/dynamoid/persistence.rb @@ -271,7 +271,7 @@ def update!(hash_key, range_key_value = nil, attrs) # meets the specified conditions. Conditions can be specified as a +Hash+ # with +:if+ key: # - # User.update_fields('1', { age: 26 }, if: { version: 1 }) + # User.update_fields('1', { age: 26 }, { if: { version: 1 } }) # # Here +User+ model has an integer +version+ field and the document will # be updated only if the +version+ attribute currently has value 1. @@ -279,6 +279,13 @@ def update!(hash_key, range_key_value = nil, attrs) # If a document with specified hash and range keys doesn't exist or # conditions were specified and failed the method call returns +nil+. # + # To check if some attribute (or attributes) isn't stored in a DynamoDB + # item (e.g. it wasn't set explicitly) there is another condition - + # +unless_exists+: + # + # user = User.create(name: 'Tylor') + # User.update_fields(user.id, { age: 18 }, { unless_exists: [:age] }) + # # +update_fields+ uses the +UpdateItem+ operation so it saves changes and # loads an updated document back with one HTTP request. # @@ -323,11 +330,18 @@ def update_fields(hash_key_value, range_key_value = nil, attrs = {}, conditions # meets the specified conditions. Conditions can be specified as a +Hash+ # with +:if+ key: # - # User.upsert('1', { age: 26 }, if: { version: 1 }) + # User.upsert('1', { age: 26 }, { if: { version: 1 } }) # # Here +User+ model has an integer +version+ field and the document will # be updated only if the +version+ attribute currently has value 1. # + # To check if some attribute (or attributes) isn't stored in a DynamoDB + # item (e.g. it wasn't set explicitly) there is another condition - + # +unless_exists+: + # + # user = User.create(name: 'Tylor') + # User.upsert(user.id, { age: 18 }, { unless_exists: [:age] }) + # # If conditions were specified and failed the method call returns +nil+. # # +upsert+ uses the +UpdateItem+ operation so it saves changes and loads @@ -621,6 +635,15 @@ def update_attribute(attribute, value) # t.add(age: 1) # end # + # To check if some attribute (or attributes) isn't stored in a DynamoDB + # item (e.g. it wasn't set explicitly) there is another condition - + # +unless_exists+: + # + # user = User.create(name: 'Tylor') + # user.update!(unless_exists: [:age]) do |t| + # t.set(age: 18) + # end + # # If a document doesn't meet conditions it raises # +Dynamoid::Errors::StaleObjectError+ exception. # @@ -717,6 +740,15 @@ def update!(conditions = {}) # t.add(age: 1) # end # + # To check if some attribute (or attributes) isn't stored in a DynamoDB + # item (e.g. it wasn't set explicitly) there is another condition - + # +unless_exists+: + # + # user = User.create(name: 'Tylor') + # user.update(unless_exists: [:age]) do |t| + # t.set(age: 18) + # end + # # If a document doesn't meet conditions it just returns +false+. Otherwise it returns +true+. # # It will increment the +lock_version+ attribute if a table has the column, diff --git a/lib/dynamoid/persistence/save.rb b/lib/dynamoid/persistence/save.rb index b9f58aa1..f586bc06 100644 --- a/lib/dynamoid/persistence/save.rb +++ b/lib/dynamoid/persistence/save.rb @@ -89,8 +89,8 @@ def options_to_update_item end conditions = {} - conditions[:if_exists] ||= {} - conditions[:if_exists][@model.class.hash_key] = @model.hash_key + conditions[:if] ||= {} + conditions[:if][@model.class.hash_key] = @model.hash_key # Add an optimistic locking check if the lock_version column exists if @model.class.attributes[:lock_version] diff --git a/lib/dynamoid/persistence/update_fields.rb b/lib/dynamoid/persistence/update_fields.rb index 63e48b4b..20396594 100644 --- a/lib/dynamoid/persistence/update_fields.rb +++ b/lib/dynamoid/persistence/update_fields.rb @@ -54,8 +54,8 @@ def options_to_update_item end conditions = @conditions.deep_dup - conditions[:if_exists] ||= {} - conditions[:if_exists][@model_class.hash_key] = @partition_key + conditions[:if] ||= {} + conditions[:if][@model_class.hash_key] = @partition_key options[:conditions] = conditions options diff --git a/spec/dynamoid/persistence_spec.rb b/spec/dynamoid/persistence_spec.rb index 0d8522f7..7ddeb5fc 100644 --- a/spec/dynamoid/persistence_spec.rb +++ b/spec/dynamoid/persistence_spec.rb @@ -1555,27 +1555,84 @@ def around_update_callback end context 'condition specified' do - it 'updates when model matches conditions' do - obj = document_class.create(title: 'Old title', version: 1) + describe 'if condition' do + it 'updates when model matches conditions' do + obj = document_class.create(title: 'Old title', version: 1) - expect { - document_class.update_fields(obj.id, { title: 'New title' }, if: { version: 1 }) - }.to change { document_class.find(obj.id).title }.to('New title') - end + expect { + document_class.update_fields(obj.id, { title: 'New title' }, if: { version: 1 }) + }.to change { document_class.find(obj.id).title }.to('New title') + end - it 'does not update when model does not match conditions' do - obj = document_class.create(title: 'Old title', version: 1) + it 'does not update when model does not match conditions' do + obj = document_class.create(title: 'Old title', version: 1) + + expect { + result = document_class.update_fields(obj.id, { title: 'New title' }, if: { version: 6 }) + }.not_to change { document_class.find(obj.id).title } + end + + it 'returns nil when model does not match conditions' do + obj = document_class.create(title: 'Old title', version: 1) - expect { result = document_class.update_fields(obj.id, { title: 'New title' }, if: { version: 6 }) - }.not_to change { document_class.find(obj.id).title } + expect(result).to eq nil + end end - it 'returns nil when model does not match conditions' do - obj = document_class.create(title: 'Old title', version: 1) + describe 'unless_exists condition' do + it 'updates when item does not have specified attribute' do + # not specifying field value means (by default) the attribute will be + # skipped and not persisted in DynamoDB + obj = document_class.create(title: 'Old title') + expect(raw_attributes(obj).keys).to contain_exactly(:id, :title, :created_at, :updated_at) + + expect { + document_class.update_fields(obj.id, { title: 'New title' }, { unless_exists: [:version] }) + }.to change { document_class.find(obj.id).title }.to('New title') + end + + it 'does not update when model has specified attribute' do + obj = document_class.create(title: 'Old title', version: 1) + expect(raw_attributes(obj).keys).to contain_exactly(:id, :title, :version, :created_at, :updated_at) - result = document_class.update_fields(obj.id, { title: 'New title' }, if: { version: 6 }) - expect(result).to eq nil + expect { + result = document_class.update_fields(obj.id, { title: 'New title' }, { unless_exists: [:version] }) + }.not_to change { document_class.find(obj.id).title } + end + + context 'when multiple attribute names' do + it 'updates when item does not have all the specified attributes' do + # not specifying field value means (by default) the attribute will be + # skipped and not persisted in DynamoDB + obj = document_class.create(title: 'Old title') + expect(raw_attributes(obj).keys).to contain_exactly(:id, :title, :created_at, :updated_at) + + expect { + document_class.update_fields(obj.id, { title: 'New title' }, { unless_exists: [:version, :published_on] }) + }.to change { document_class.find(obj.id).title }.to('New title') + end + + it 'does not update when model has all the specified attributes' do + obj = document_class.create(title: 'Old title', version: 1, published_on: '2018-02-23'.to_date) + expect(raw_attributes(obj).keys).to contain_exactly(:id, :title, :version, :published_on, :created_at, :updated_at) + + expect { + result = document_class.update_fields(obj.id, { title: 'New title' }, { unless_exists: [:version, :published_on] }) + }.not_to change { document_class.find(obj.id).title } + end + + it 'does not update when model has at least one specified attribute' do + # not specifying field value means (by default) the attribute will be + # skipped and not persisted in DynamoDB + obj = document_class.create(title: 'Old title', version: 1) + expect(raw_attributes(obj).keys).to contain_exactly(:id, :title, :version, :created_at, :updated_at) + + expect { + result = document_class.update_fields(obj.id, { title: 'New title' }, { unless_exists: [:version, :published_on] }) + }.not_to change { document_class.find(obj.id).title } + end + end end end @@ -1770,27 +1827,84 @@ def around_update_callback end context 'conditions specified' do - it 'updates when model matches conditions' do - obj = document_class.create(title: 'Old title', version: 1) + describe 'if condition' do + it 'updates when model matches conditions' do + obj = document_class.create(title: 'Old title', version: 1) - expect { - document_class.upsert(obj.id, { title: 'New title' }, if: { version: 1 }) - }.to change { document_class.find(obj.id).title }.to('New title') - end + expect { + document_class.upsert(obj.id, { title: 'New title' }, if: { version: 1 }) + }.to change { document_class.find(obj.id).title }.to('New title') + end - it 'does not update when model does not match conditions' do - obj = document_class.create(title: 'Old title', version: 1) + it 'does not update when model does not match conditions' do + obj = document_class.create(title: 'Old title', version: 1) + + expect { + result = document_class.upsert(obj.id, { title: 'New title' }, if: { version: 6 }) + }.not_to change { document_class.find(obj.id).title } + end + + it 'returns nil when model does not match conditions' do + obj = document_class.create(title: 'Old title', version: 1) - expect { result = document_class.upsert(obj.id, { title: 'New title' }, if: { version: 6 }) - }.not_to change { document_class.find(obj.id).title } + expect(result).to eq nil + end end - it 'returns nil when model does not match conditions' do - obj = document_class.create(title: 'Old title', version: 1) + describe 'unless_exists condition' do + it 'updates when item does not have specified attribute' do + # not specifying field value means (by default) the attribute will be + # skipped and not persisted in DynamoDB + obj = document_class.create(title: 'Old title') + expect(raw_attributes(obj).keys).to contain_exactly(:id, :title, :created_at, :updated_at) - result = document_class.upsert(obj.id, { title: 'New title' }, if: { version: 6 }) - expect(result).to eq nil + expect { + document_class.upsert(obj.id, { title: 'New title' }, { unless_exists: [:version] }) + }.to change { document_class.find(obj.id).title }.to('New title') + end + + it 'does not update when model has specified attribute' do + obj = document_class.create(title: 'Old title', version: 1) + expect(raw_attributes(obj).keys).to contain_exactly(:id, :title, :version, :created_at, :updated_at) + + expect { + result = document_class.upsert(obj.id, { title: 'New title' }, { unless_exists: [:version] }) + }.not_to change { document_class.find(obj.id).title } + end + + context 'when multiple attribute names' do + it 'updates when item does not have all the specified attributes' do + # not specifying field value means (by default) the attribute will be + # skipped and not persisted in DynamoDB + obj = document_class.create(title: 'Old title') + expect(raw_attributes(obj).keys).to contain_exactly(:id, :title, :created_at, :updated_at) + + expect { + document_class.upsert(obj.id, { title: 'New title' }, { unless_exists: [:version, :published_on] }) + }.to change { document_class.find(obj.id).title }.to('New title') + end + + it 'does not update when model has all the specified attributes' do + obj = document_class.create(title: 'Old title', version: 1, published_on: '2018-02-23'.to_date) + expect(raw_attributes(obj).keys).to contain_exactly(:id, :title, :version, :published_on, :created_at, :updated_at) + + expect { + result = document_class.upsert(obj.id, { title: 'New title' }, { unless_exists: [:version, :published_on] }) + }.not_to change { document_class.find(obj.id).title } + end + + it 'does not update when model has at least one specified attribute' do + # not specifying field value means (by default) the attribute will be + # skipped and not persisted in DynamoDB + obj = document_class.create(title: 'Old title', version: 1) + expect(raw_attributes(obj).keys).to contain_exactly(:id, :title, :version, :created_at, :updated_at) + + expect { + result = document_class.upsert(obj.id, { title: 'New title' }, { unless_exists: [:version, :published_on] }) + }.not_to change { document_class.find(obj.id).title } + end + end end end @@ -4085,27 +4199,9 @@ def before_save_callback; end end describe '#update' do - before do + it 'supports add/delete/set operations on a field' do @tweet = Tweet.create(tweet_id: 1, group: 'abc', count: 5, tags: Set.new(%w[db sql]), user_name: 'John') - end - - it 'runs before_update callbacks when doing #update' do - expect_any_instance_of(CamelCase).to receive(:doing_before_update).once.and_return(true) - - CamelCase.create(color: 'blue').update do |t| - t.set(color: 'red') - end - end - - it 'runs after_update callbacks when doing #update' do - expect_any_instance_of(CamelCase).to receive(:doing_after_update).once.and_return(true) - - CamelCase.create(color: 'blue').update do |t| - t.set(color: 'red') - end - end - it 'supports add/delete/set operations on a field' do @tweet.update do |t| t.add(count: 3) t.delete(tags: Set.new(['db'])) @@ -4117,22 +4213,101 @@ def before_save_callback; end expect(@tweet.user_name).to eq 'Alex' end - it 'checks the conditions on update' do - expect( - @tweet.update(if: { count: 5 }) do |t| - t.add(count: 3) + context 'condition specified' do + let(:document_class) do + new_class do + field :title + field :version, :integer + field :published_on, :date end - ).to eql true - expect(@tweet.count).to eql 8 - expect(Tweet.find(@tweet.tweet_id, range_key: @tweet.group).count).to eql 8 + end - expect( - @tweet.update(if: { count: 5 }) do |t| - t.add(count: 3) + describe 'if condition' do + it 'updates when model matches conditions' do + obj = document_class.create(title: 'Old title', version: 1) + + expect { + obj.update(if: { version: 1 }) { |t| t.set(title: 'New title') } + }.to change { document_class.find(obj.id).title }.to('New title') end - ).to eql false - expect(@tweet.count).to eql 8 - expect(Tweet.find(@tweet.tweet_id, range_key: @tweet.group).count).to eql 8 + + it 'returns true when model matches conditions' do + obj = document_class.create(title: 'Old title', version: 1) + + result = obj.update(if: { version: 1 }) { |t| t.set(title: 'New title') } + expect(result).to eq true + end + + it 'does not update when model does not match conditions' do + obj = document_class.create(title: 'Old title', version: 1) + + expect { + obj.update(if: { version: 6 }) { |t| t.set(title: 'New title') } + }.not_to change { document_class.find(obj.id).title } + end + + it 'returns false when model does not match conditions' do + obj = document_class.create(title: 'Old title', version: 1) + + result = obj.update(if: { version: 6 }) { |t| t.set(title: 'New title') } + expect(result).to eq false + end + end + + describe 'unless_exists condition' do + it 'updates when item does not have specified attribute' do + # not specifying field value means (by default) the attribute will be + # skipped and not persisted in DynamoDB + obj = document_class.create(title: 'Old title') + expect(raw_attributes(obj).keys).to contain_exactly(:id, :title, :created_at, :updated_at) + + expect { + obj.update(unless_exists: [:version]) { |t| t.set(title: 'New title') } + }.to change { document_class.find(obj.id).title }.to('New title') + end + + it 'does not update when model has specified attribute' do + obj = document_class.create(title: 'Old title', version: 1) + expect(raw_attributes(obj).keys).to contain_exactly(:id, :title, :version, :created_at, :updated_at) + + expect { + obj.update(unless_exists: [:version]) { |t| t.set(title: 'New title') } + }.not_to change { document_class.find(obj.id).title } + end + + context 'when multiple attribute names' do + it 'updates when item does not have all the specified attributes' do + # not specifying field value means (by default) the attribute will be + # skipped and not persisted in DynamoDB + obj = document_class.create(title: 'Old title') + expect(raw_attributes(obj).keys).to contain_exactly(:id, :title, :created_at, :updated_at) + + expect { + obj.update(unless_exists: [:version, :published_on]) { |t| t.set(title: 'New title') } + }.to change { document_class.find(obj.id).title }.to('New title') + end + + it 'does not update when model has all the specified attributes' do + obj = document_class.create(title: 'Old title', version: 1, published_on: '2018-02-23'.to_date) + expect(raw_attributes(obj).keys).to contain_exactly(:id, :title, :version, :published_on, :created_at, :updated_at) + + expect { + obj.update(unless_exists: [:version, :published_on]) { |t| t.set(title: 'New title') } + }.not_to change { document_class.find(obj.id).title } + end + + it 'does not update when model has at least one specified attribute' do + # not specifying field value means (by default) the attribute will be + # skipped and not persisted in DynamoDB + obj = document_class.create(title: 'Old title', version: 1) + expect(raw_attributes(obj).keys).to contain_exactly(:id, :title, :version, :created_at, :updated_at) + + expect { + obj.update(unless_exists: [:version, :published_on]) { |t| t.set(title: 'New title') } + }.not_to change { document_class.find(obj.id).title } + end + end + end end it 'prevents concurrent saves to tables with a lock_version' do