From 8ce4ac4709592adb60716a4bc02440dbeb718121 Mon Sep 17 00:00:00 2001 From: Dominic Beger Date: Thu, 7 Nov 2024 19:07:46 +0100 Subject: [PATCH] [D-326] Store accessor support for assignable_values --- .github/workflows/test.yml | 2 +- lib/assignable_values.rb | 1 + lib/assignable_values/active_record.rb | 17 +- .../restriction/scalar_attribute.rb | 16 +- .../restriction/store_accessor_attribute.rb | 29 +++ spec/assignable_values/active_record_spec.rb | 211 +++++++++++++++++- spec/support/database.rb | 1 + spec/support/i18n.yml | 7 + 8 files changed, 272 insertions(+), 12 deletions(-) create mode 100644 lib/assignable_values/active_record/restriction/store_accessor_attribute.rb diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8783200..fb45421 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-20.04 services: mysql: - image: mysql:5.6 + image: mysql:5.7 env: MYSQL_ROOT_PASSWORD: password ports: diff --git a/lib/assignable_values.rb b/lib/assignable_values.rb index b663884..34fe565 100644 --- a/lib/assignable_values.rb +++ b/lib/assignable_values.rb @@ -5,5 +5,6 @@ require 'assignable_values/active_record/restriction/base' require 'assignable_values/active_record/restriction/belongs_to_association' require 'assignable_values/active_record/restriction/scalar_attribute' +require 'assignable_values/active_record/restriction/store_accessor_attribute' require 'assignable_values/humanized_value' require 'assignable_values/humanizable_string' diff --git a/lib/assignable_values/active_record.rb b/lib/assignable_values/active_record.rb index b2ede63..dbc54fa 100644 --- a/lib/assignable_values/active_record.rb +++ b/lib/assignable_values/active_record.rb @@ -4,7 +4,14 @@ module ActiveRecord private def assignable_values_for(property, options = {}, &values) - restriction_type = belongs_to_association?(property) ? Restriction::BelongsToAssociation : Restriction::ScalarAttribute + restriction_type = if belongs_to_association?(property) + Restriction::BelongsToAssociation + elsif store_accessor_attribute?(property) + Restriction::StoreAccessorAttribute + else + Restriction::ScalarAttribute + end + restriction_type.new(self, property, options, &values) end @@ -13,6 +20,14 @@ def belongs_to_association?(property) reflection && reflection.macro == :belongs_to end + def store_accessor_attribute?(property) + store_identifier_of(property).present? + end + + def store_identifier_of(property) + stored_attributes.find { |_, attrs| attrs.include?(property.to_sym) }&.first + end + end end diff --git a/lib/assignable_values/active_record/restriction/scalar_attribute.rb b/lib/assignable_values/active_record/restriction/scalar_attribute.rb index 602cc02..6ec40af 100644 --- a/lib/assignable_values/active_record/restriction/scalar_attribute.rb +++ b/lib/assignable_values/active_record/restriction/scalar_attribute.rb @@ -107,10 +107,10 @@ def decorate_values(values, klass) end def has_previously_saved_value?(record) - if record.respond_to?(:attribute_in_database) - !record.new_record? # Rails >= 5.1 - else - !record.new_record? && record.respond_to?(value_was_method) # Rails <= 5.0 + if record.respond_to?(:attribute_in_database) # Rails >= 5.1 + !record.new_record? + else # Rails <= 5.0 + !record.new_record? && record.respond_to?(value_was_method) end end @@ -119,10 +119,10 @@ def previously_saved_value(record) end def value_was(record) - if record.respond_to?(:attribute_in_database) - record.attribute_in_database(:"#{property}") # Rails >= 5.1 - else - record.send(value_was_method) # Rails <= 5.0 + if record.respond_to?(:attribute_in_database) # Rails >= 5.1 + record.attribute_in_database(:"#{property}") + else # Rails <= 5.0 + record.send(value_was_method) end end diff --git a/lib/assignable_values/active_record/restriction/store_accessor_attribute.rb b/lib/assignable_values/active_record/restriction/store_accessor_attribute.rb new file mode 100644 index 0000000..641fb5c --- /dev/null +++ b/lib/assignable_values/active_record/restriction/store_accessor_attribute.rb @@ -0,0 +1,29 @@ +module AssignableValues + module ActiveRecord + module Restriction + class StoreAccessorAttribute < ScalarAttribute + + private + + def store_identifier + @model.stored_attributes.find { |_, attrs| attrs.include?(property.to_sym) }&.first + end + + def value_was_method + :"#{store_identifier}_was" + end + + def value_was(record) + accessor = if record.respond_to?(:attribute_in_database) # Rails >= 5.1 + record.attribute_in_database(:"#{store_identifier}") + else # Rails <= 5.0 + record.send(value_was_method) + end + + accessor.with_indifferent_access[property] + end + + end + end + end +end diff --git a/spec/assignable_values/active_record_spec.rb b/spec/assignable_values/active_record_spec.rb index 01aa4e7..5d8dc98 100644 --- a/spec/assignable_values/active_record_spec.rb +++ b/spec/assignable_values/active_record_spec.rb @@ -146,7 +146,6 @@ def save_without_validation(record) errors = record.errors[:genre] error = errors.respond_to?(:first) ? errors.first : errors # the return value sometimes was a string, sometimes an Array in Rails error.should == I18n.t('errors.messages.inclusion') - error.should == 'is not included in the list' end it 'should not allow nil for the attribute value' do @@ -261,7 +260,7 @@ def save_without_validation(record) end - context 'if the :allow_blank option is set to a lambda ' do + context 'if the :allow_blank option is set to a lambda' do before :each do @klass = Song.disposable_copy do @@ -370,6 +369,214 @@ def save_without_validation(record) end + context 'when validating scalar attributes from a store_accessor' do + + context 'without options' do + + before :each do + @klass = Song.disposable_copy do + store :metadata, accessors: [:format], coder: JSON + + assignable_values_for :format do + %w[mp3 wav] + end + end + end + + it 'should validate that the attribute is allowed' do + @klass.new(:format => 'mp3').should be_valid + @klass.new(:format => 'disallowed value').should_not be_valid + end + + it 'should use the same error message as validates_inclusion_of' do + record = @klass.new(:format => 'disallowed value') + record.valid? + errors = record.errors[:format] + error = errors.respond_to?(:first) ? errors.first : errors # the return value sometimes was a string, sometimes an Array in Rails + error.should == I18n.t('errors.messages.inclusion') + end + + it 'should not allow nil for the attribute value' do + @klass.new(:format => nil).should_not be_valid + end + + it 'should allow a previously saved value even if that value is no longer allowed' do + record = @klass.create!(:format => 'mp3') + + record.update_column(:metadata, { 'format' => 'pretend previously valid value' }) # update without validations for the sake of this test + record.reload.should be_valid + end + + it 'should allow a previously saved, blank format value even if that value is no longer allowed' do + record = @klass.create!(:format => 'mp3') + + record.update_column(:metadata, { 'format' => nil }) # update without validations for the sake of this test + record.reload.should be_valid + end + + it 'should not allow nil (the "previous value") if the record was never saved' do + record = @klass.new(:format => nil) + record.should_not be_valid + end + + it 'should generate a method returning the humanized value' do + song = @klass.new(:format => 'mp3') + song.humanized_format.should == 'MP3-Codec' + end + + it 'should generate a method returning the humanized value, which is nil when the value is blank' do + song = @klass.new + song.format = nil + song.humanized_format.should be_nil + song.format = '' + song.humanized_format.should be_nil + end + + it 'should generate an instance method to retrieve the humanization of any given value' do + song = @klass.new(:format => 'mp3') + song.humanized_format('wav').should == 'WAV-Codec' + end + + it 'should generate a class method to retrieve the humanization of any given value' do + @klass.humanized_format('wav').should == 'WAV-Codec' + end + + context 'for multiple: true' do + before :each do + @klass = Song.disposable_copy do + store :metadata, accessors: [:instruments], coder: JSON + + assignable_values_for :instruments, multiple: true do + %w[piano drums guitar] + end + end + end + + it 'allows multiple assignments' do + song = @klass.new(:instruments => %w[guitar drums]) + song.should be_valid + end + + it 'should raise when trying to humanize a value without an argument' do + song = @klass.new + proc { song.humanized_instrument }.should raise_error(ArgumentError) + end + + it 'should generate an instance method to retrieve the humanization of any given value' do + song = @klass.new(:instruments => 'drums') + song.humanized_instrument('piano').should == 'Piano' + end + + it 'should generate a class method to retrieve the humanization of any given value' do + @klass.humanized_instrument('piano').should == 'Piano' + end + + it 'should generate an instance method to retrieve the humanizations of all current values' do + song = @klass.new + song.instruments = nil + song.humanized_instruments.should == nil + song.instruments = [] + song.humanized_instruments.should == [] + song.instruments = ['piano', 'drums'] + song.humanized_instruments.should == ['Piano', 'Drums'] + end + end + end + + context 'if the :allow_blank option is set to true' do + + before :each do + @klass = Song.disposable_copy do + + store :metadata, accessors: [:format], coder: JSON + + assignable_values_for :format, :allow_blank => true do + %w[mp3 wav] + end + end + end + + it 'should allow nil for the attribute value' do + @klass.new(:format => nil).should be_valid + end + + it 'should allow an empty string as value' do + @klass.new(:format => '').should be_valid + end + + end + + context 'if the :allow_blank option is set to a symbol that refers to an instance method' do + + before :each do + @klass = Song.disposable_copy do + + store :metadata, accessors: [:format], coder: JSON + + attr_accessor :format_may_be_blank + + assignable_values_for :format, :allow_blank => :format_may_be_blank do + %w[mp3 wav] + end + + end + end + + it 'should call that method to determine if a blank value is allowed' do + @klass.new(:format => '', :format_may_be_blank => true).should be_valid + @klass.new(:format => '', :format_may_be_blank => false).should_not be_valid + end + + end + + context 'if the :allow_blank option is set to a lambda' do + + before :each do + @klass = Song.disposable_copy do + + store :metadata, accessors: [:format], coder: JSON + + attr_accessor :format_may_be_blank + + assignable_values_for :format, :allow_blank => lambda { format_may_be_blank } do + %w[mp3 wav] + end + + end + end + + it 'should evaluate that lambda in the record context to determine if a blank value is allowed' do + @klass.new(:format => '', :format_may_be_blank => true).should be_valid + @klass.new(:format => '', :format_may_be_blank => false).should_not be_valid + end + + end + + context 'if the :message option is set to a string' do + + before :each do + @klass = Song.disposable_copy do + + store :metadata, accessors: [:format], coder: JSON + + assignable_values_for :format, :message => 'should be something different' do + %w[mp3 wav] + end + end + end + + it 'should use this string as a custom error message' do + record = @klass.new(:format => 'disallowed value') + record.valid? + errors = record.errors[:format] + error = errors.respond_to?(:first) ? errors.first : errors # the return value sometimes was a string, sometimes an Array in Rails + error.should == 'should be something different' + end + + end + + end + context 'when validating belongs_to associations' do it 'should validate that the association is allowed' do diff --git a/spec/support/database.rb b/spec/support/database.rb index 21e3a08..098a2fa 100644 --- a/spec/support/database.rb +++ b/spec/support/database.rb @@ -11,6 +11,7 @@ t.integer :year t.integer :duration t.string :multi_genres, :array => true + t.json :metadata end create_table :vinyl_recordings do |t| diff --git a/spec/support/i18n.yml b/spec/support/i18n.yml index 14085a1..2274e3b 100644 --- a/spec/support/i18n.yml +++ b/spec/support/i18n.yml @@ -9,9 +9,16 @@ en: assignable_values: song: + format: + mp3: 'MP3-Codec' + wav: 'WAV-Codec' genre: pop: 'Pop music' rock: 'Rock music' + instruments: + drums: 'Drums' + guitar: 'Guitar' + piano: 'Piano' multi_genres: pop: 'Pop music' rock: 'Rock music'