-
-
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)
end
rails: 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!!!