Last active
September 5, 2020 19:41
-
-
Save mhuggins/6c3d343fd800cf88f28e to your computer and use it in GitHub Desktop.
MultiparameterAttributeAssignment
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
@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
Thanks for sharing this workaround, however, Rails provides a way to address this scenario, check this out: https://stackoverflow.com/a/63704849/6690151