-
-
Save mhuggins/6c3d343fd800cf88f28e to your computer and use it in GitHub Desktop.
| # app/models/concerns/multiparameter_attribute_assignment.rb | |
| module MultiparameterAttributeAssignment | |
| include ActiveModel::ForbiddenAttributesProtection | |
| def initialize(params = {}) | |
| assign_attributes(params) | |
| end | |
| def assign_attributes(new_attributes) | |
| multi_parameter_attributes = [] | |
| attributes = sanitize_for_mass_assignment(new_attributes.stringify_keys) | |
| attributes.each do |k, v| | |
| if k.include?('(') | |
| multi_parameter_attributes << [ k, v ] | |
| else | |
| send("#{k}=", v) | |
| end | |
| end | |
| assign_multiparameter_attributes(multi_parameter_attributes) unless multi_parameter_attributes.empty? | |
| end | |
| alias attributes= assign_attributes | |
| protected | |
| def attribute_assignment_error_class | |
| ActiveModel::AttributeAssignmentError | |
| end | |
| def multiparameter_assignment_errors_class | |
| ActiveModel::MultiparameterAssignmentErrors | |
| end | |
| def unknown_attribute_error_class | |
| ActiveModel::UnknownAttributeError | |
| end | |
| def assign_multiparameter_attributes(pairs) | |
| execute_callstack_for_multiparameter_attributes( | |
| extract_callstack_for_multiparameter_attributes(pairs) | |
| ) | |
| end | |
| def execute_callstack_for_multiparameter_attributes(callstack) | |
| errors = [] | |
| callstack.each do |name, values_with_empty_parameters| | |
| begin | |
| raise unknown_attribute_error_class, "unknown attribute: #{name}" unless respond_to?("#{name}=") | |
| send("#{name}=", MultiparameterAttribute.new(self, name, values_with_empty_parameters).read_value) | |
| rescue => ex | |
| errors << attribute_assignment_error_class.new("error on assignment #{values_with_empty_parameters.values.inspect} to #{name} (#{ex.message})", ex, name) | |
| end | |
| end | |
| unless errors.empty? | |
| error_descriptions = errors.map { |ex| ex.message }.join(',') | |
| raise multiparameter_assignment_errors_class.new(errors), "#{errors.size} error(s) on assignment of multiparameter attributes [#{error_descriptions}]" | |
| end | |
| end | |
| def extract_callstack_for_multiparameter_attributes(pairs) | |
| attributes = {} | |
| pairs.each do |(multiparameter_name, value)| | |
| attribute_name = multiparameter_name.split('(').first | |
| attributes[attribute_name] ||= {} | |
| parameter_value = value.empty? ? nil : type_cast_attribute_value(multiparameter_name, value) | |
| attributes[attribute_name][find_parameter_position(multiparameter_name)] ||= parameter_value | |
| end | |
| attributes | |
| end | |
| def type_cast_attribute_value(multiparameter_name, value) | |
| multiparameter_name =~ /\([0-9]*([if])\)/ ? value.send("to_#{$1}") : value | |
| end | |
| def find_parameter_position(multiparameter_name) | |
| multiparameter_name.scan(/\(([0-9]*).*\)/).first.first.to_i | |
| end | |
| end | |
| class MultiparameterAttribute | |
| attr_reader :object, :name, :values | |
| def initialize(object, name, values) | |
| @object = object | |
| @name = name | |
| @values = values | |
| end | |
| def class_for_attribute | |
| object.class_for_attribute(name) | |
| end | |
| def read_value | |
| return if values.values.compact.empty? | |
| klass = class_for_attribute | |
| if klass.nil? | |
| raise ActiveModel::UnexpectedMultiparameterValueError, | |
| "Did not expect a multiparameter value for #{name}. " + | |
| 'You may be passing the wrong value, or you need to modify ' + | |
| 'class_for_attribute so that it returns the right class for ' + | |
| "#{name}." | |
| elsif klass == Time | |
| read_time | |
| elsif klass == Date | |
| read_date | |
| else | |
| read_other(klass) | |
| end | |
| end | |
| private | |
| def instantiate_time_object(set_values) | |
| Time.zone.local(*set_values) | |
| end | |
| def read_time | |
| validate_required_parameters!([1,2,3]) | |
| return if blank_date_parameter? | |
| max_position = extract_max_param(6) | |
| set_values = values.values_at(*(1..max_position)) | |
| # If Time bits are not there, then default to 0 | |
| (3..5).each { |i| set_values[i] = set_values[i].presence || 0 } | |
| instantiate_time_object(set_values) | |
| end | |
| def read_date | |
| return if blank_date_parameter? | |
| set_values = values.values_at(1,2,3) | |
| begin | |
| Date.new(*set_values) | |
| rescue ArgumentError # if Date.new raises an exception on an invalid date | |
| instantiate_time_object(set_values).to_date # we instantiate Time object and convert it back to a date thus using Time's logic in handling invalid dates | |
| end | |
| end | |
| def read_other(klass) | |
| max_position = extract_max_param | |
| positions = (1..max_position) | |
| validate_required_parameters!(positions) | |
| set_values = values.values_at(*positions) | |
| klass.new(*set_values) | |
| end | |
| def blank_date_parameter? | |
| (1..3).any? { |position| values[position].blank? } | |
| end | |
| def validate_required_parameters!(positions) | |
| if missing_parameter = positions.detect { |position| !values.key?(position) } | |
| raise ArgumentError.new("Missing Parameter - #{name}(#{missing_parameter})") | |
| end | |
| end | |
| def extract_max_param(upper_cap = 100) | |
| [values.keys.max, upper_cap].min | |
| end | |
| end | |
| class ActiveModel::AttributeAssignmentError < StandardError | |
| attr_reader :exception, :attribute | |
| def initialize(message, exception, attribute) | |
| super(message) | |
| @exception = exception | |
| @attribute = attribute | |
| end | |
| end | |
| class ActiveModel::MultiparameterAssignmentErrors < StandardError | |
| attr_reader :errors | |
| def initialize(errors) | |
| @errors = errors | |
| end | |
| end | |
| class ActiveModel::UnexpectedMultiparameterValueError < StandardError | |
| end | |
| class ActiveModel::UnknownAttributeError < NoMethodError | |
| end |
| # spec/support/examples/multiparameter_attributes.rb | |
| shared_examples_for 'a model with multiparameter date attributes' do |factory, *attributes| | |
| subject { build factory } | |
| attributes.each do |attribute| | |
| it 'should assign date when all date parts are valid' do | |
| subject.assign_attributes(multiparameter_date(attribute, 2006, 12, 1)) | |
| expect( subject.send(attribute) ).to eq Date.new(2006, 12, 1) | |
| end | |
| it 'should not assign date when missing year part' do | |
| subject.assign_attributes(multiparameter_date(attribute, nil, 12, 1)) | |
| expect( subject.send(attribute) ).to be_nil | |
| end | |
| it 'should not assign date when missing month part' do | |
| subject.assign_attributes(multiparameter_date(attribute, 2006, nil, 1)) | |
| expect( subject.send(attribute) ).to be_nil | |
| end | |
| it 'should not assign date when missing day part' do | |
| subject.assign_attributes(multiparameter_date(attribute, 2006, 12, nil)) | |
| expect( subject.send(attribute) ).to be_nil | |
| end | |
| it 'should not assign date when missing year and month parts' do | |
| subject.assign_attributes(multiparameter_date(attribute, nil, nil, 1)) | |
| expect( subject.send(attribute) ).to be_nil | |
| end | |
| it 'should not assign date when missing year and day parts' do | |
| subject.assign_attributes(multiparameter_date(attribute, nil, 12, nil)) | |
| expect( subject.send(attribute) ).to be_nil | |
| end | |
| it 'should not assign date when missing month and day parts' do | |
| subject.assign_attributes(multiparameter_date(attribute, 2006, nil, nil)) | |
| expect( subject.send(attribute) ).to be_nil | |
| end | |
| it 'should not assign date when missing all date parts' do | |
| subject.assign_attributes(multiparameter_date(attribute, nil, nil, nil)) | |
| expect( subject.send(attribute) ).to be_nil | |
| end | |
| it 'should raise error when month part is invalid' do | |
| expect { | |
| subject.assign_attributes(multiparameter_date(attribute, 2006, 99, 1)) | |
| }.to raise_error ActiveModel::MultiparameterAssignmentErrors | |
| end | |
| it 'should raise error when day part is invalid' do | |
| expect { | |
| subject.assign_attributes(multiparameter_date(attribute, 2006, 12, 99)) | |
| }.to raise_error ActiveModel::MultiparameterAssignmentErrors | |
| end | |
| end | |
| it 'should raise error when assigning to invalid attribute' do | |
| expect { | |
| subject.assign_attributes(multiparameter_date(:some_unreasonably_existing_attribute, 2006, 12, 1)) | |
| }.to raise_error ActiveModel::MultiparameterAssignmentErrors | |
| end | |
| private | |
| def multiparameter_date(attribute, year, month, day) | |
| { | |
| "#{attribute}(1i)" => year.to_s, | |
| "#{attribute}(2i)" => month.to_s, | |
| "#{attribute}(3i)" => day.to_s, | |
| } | |
| end | |
| end |
| # app/models/sample_class.rb | |
| class SampleClass | |
| include ActiveModel::Model | |
| include MultiparameterAttributeAssignment | |
| attr_accessor :name, :created_at, :updated_at | |
| def self.class_for_attribute(name) | |
| Time if %w[created_at updated_at].include?(name) | |
| end | |
| end |
Thanks a lot for this gist!
The only modification I had to made was to turn the method class_for_attribute (in the class including the concern) into an instance method (the MultiparameterAttributeAssignment module was expecting it to be a method of the object instead of a method of the class).
thanks for this
For those coming here from rails/rails#8189 (like me), rails/rails#21533 has landed in Rails 5.0.
@bdewater how would you implement this with Rails 5?
Has anyone been able to make this work in Rails 5? I am honestly stumped. All the workarounds seem to be for Rails 4, but multi-parameter dates still don't work in Rails 5 ActiveModel::Model.
Edit: Nevermind, I was being dumb. Needed to permit the multiparameter attributes.
Not working for me by this error.
ActiveModel::MultiparameterAssignmentErrors (1 error(s) on assignment of multiparameter attributes [error on assignment [2018, 4, 10] to first_work_date (undefined method `class_for_attribute' for MultiparameterAttribute:Class
But it worked when I fixed the method of line 98 as follows.
# app/models/concerns/multiparameter_attribute_assignment.rb
def class_for_attribute
object.class.class_for_attribute(name)
endrails: 5.1.5
Thanks for sharing this workaround, however, Rails provides a way to address this scenario, check this out: https://stackoverflow.com/a/63704849/6690151
@aleonmon It didn't at the time I wrote this up. 😄
THANK YOU!!!