Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save yctay/751c5c5c19c94f892fd2 to your computer and use it in GitHub Desktop.
Save yctay/751c5c5c19c94f892fd2 to your computer and use it in GitHub Desktop.

Revisions

  1. @mhuggins mhuggins revised this gist Dec 4, 2014. 1 changed file with 3 additions and 0 deletions.
    3 changes: 3 additions & 0 deletions sample_class.rb
    Original file line number Diff line number Diff line change
    @@ -1,8 +1,11 @@
    # 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
  2. @mhuggins mhuggins renamed this gist Dec 4, 2014. 1 changed file with 2 additions and 0 deletions.
    2 changes: 2 additions & 0 deletions gistfile1.txt → sample_class.rb
    Original file line number Diff line number Diff line change
    @@ -1,3 +1,5 @@
    # app/models/sample_class.rb

    class SampleClass
    include MultiparameterAttributeAssignment

  3. @mhuggins mhuggins revised this gist Dec 4, 2014. 2 changed files with 7 additions and 3 deletions.
    7 changes: 7 additions & 0 deletions gistfile1.txt
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,7 @@
    class SampleClass
    include MultiparameterAttributeAssignment

    def self.class_for_attribute(name)
    Time if %w[created_at updated_at].include?(name)
    end
    end
    3 changes: 0 additions & 3 deletions multiparameter_attribute_assignment.rb
    Original file line number Diff line number Diff line change
    @@ -1,9 +1,6 @@
    # app/models/concerns/multiparameter_attribute_assignment.rb

    require 'active_support/concern'

    module MultiparameterAttributeAssignment
    extend ActiveSupport::Concern
    include ActiveModel::ForbiddenAttributesProtection

    def initialize(params = {})
  4. @mhuggins mhuggins created this gist Jan 20, 2014.
    199 changes: 199 additions & 0 deletions multiparameter_attribute_assignment.rb
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,199 @@
    # app/models/concerns/multiparameter_attribute_assignment.rb

    require 'active_support/concern'

    module MultiparameterAttributeAssignment
    extend ActiveSupport::Concern
    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
    75 changes: 75 additions & 0 deletions multiparameter_attributes.rb
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,75 @@
    # 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