Skip to content

Instantly share code, notes, and snippets.

@mhuggins
Last active September 5, 2020 19:41
Show Gist options
  • Save mhuggins/6c3d343fd800cf88f28e to your computer and use it in GitHub Desktop.
Save mhuggins/6c3d343fd800cf88f28e to your computer and use it in GitHub Desktop.
MultiparameterAttributeAssignment
# 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
@bdewater
Copy link

For those coming here from rails/rails#8189 (like me), rails/rails#21533 has landed in Rails 5.0.

@jaimerodas
Copy link

@bdewater how would you implement this with Rails 5?

@wmakley
Copy link

wmakley commented Dec 11, 2017

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.

@gomo
Copy link

gomo commented Apr 6, 2018

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

@aleonjob
Copy link

aleonjob commented Sep 2, 2020

Thanks for sharing this workaround, however, Rails provides a way to address this scenario, check this out: https://stackoverflow.com/a/63704849/6690151

@mhuggins
Copy link
Author

mhuggins commented Sep 5, 2020

@aleonmon It didn't at the time I wrote this up. 😄

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment