James Strocel
James Strocel

Reputation: 113

Cannot use datetime_select with Mongoid

Every time I try to use Datetime_select in a view, the app throws an attribute error.

Mongoid::Errors::UnknownAttribute:

   Problem:
     Attempted to set a value for 'fromtime(1i)' which is not allowed on the model Event.
   Summary:
     Without including Mongoid::Attributes::Dynamic in your model and the attribute does not already exist in the attributes hash, attempting to call Event#fromtime(1i)= for it is not allowed. This is also triggered by passing the attribute to any method that accepts an attributes hash, and is raised instead of getting a NoMethodError.
   Resolution:
     You can include Mongoid::Attributes::Dynamic if you expect to be writing values for undefined fields often.

The solution I come across most often was to include Mongoid::MultiParameterAttributes in the model. Unfortunately that module has been removed! https://github.com/mongoid/mongoid/issues/2954

I have tried forking the gem and re-adding the MultiparameterAttributes Module, but the gem won't read the code from the lib file. Is there any way to use DateTime_select with Mongoid?

Upvotes: 8

Views: 1807

Answers (2)

Daniel Viglione
Daniel Viglione

Reputation: 9407

Unfortunately, Multi-Parameter Assignment is an implemention in ActiveRecord and not ActiveModel. Mongoid, therefore, must have their own implementation, but they dropped support of this feature, leaving it to ActiveSupport and ActiveModel to pick up the slack. Well, look at the Rails source and it remains in ActiveRecord.

Luckily, you can hook in your own implementation in the process_attributes method, which is invoked when attributes are being assigned on Mongoid objects, such as during the creation or update actions.

To test it out, just create a config/initializer/multi_parameter_attributes.rb and add the code below which would add the necessary functionality into the Mongoid::Document module:

module Mongoid

 module MultiParameterAttributes

  module Errors

  class AttributeAssignmentError < Mongoid::Errors::MongoidError
    attr_reader :exception, :attribute

    def initialize(message, exception, attribute)
      @exception = exception
      @attribute = attribute
      @message = message
    end
  end

  class MultiparameterAssignmentErrors < Mongoid::Errors::MongoidError
    attr_reader :errors

    def initialize(errors)
      @errors = errors
    end
  end
end

def process_attributes(attrs = nil)
  if attrs
    errors = []
    attributes = attrs.class.new
    attributes.permit! if attrs.respond_to?(:permitted?) && attrs.permitted?
    multi_parameter_attributes = {}
    attrs.each_pair do |key, value|
      if key =~ /\A([^\(]+)\((\d+)([if])\)$/
        key, index = $1, $2.to_i
        (multi_parameter_attributes[key] ||= {})[index] = value.empty? ? nil : value.send("to_#{$3}")
      else
        attributes[key] = value
      end
    end

    multi_parameter_attributes.each_pair do |key, values|
      begin
        values = (values.keys.min..values.keys.max).map { |i| values[i] }
        field = self.class.fields[database_field_name(key)]
        attributes[key] = instantiate_object(field, values)
      rescue => e
        errors << Errors::AttributeAssignmentError.new(
            "error on assignment #{values.inspect} to #{key}", e, key
        )
      end
    end

    unless errors.empty?
      raise Errors::MultiparameterAssignmentErrors.new(errors),
            "#{errors.size} error(s) on assignment of multiparameter attributes"
    end

    super attributes
  else
    super
  end
end

protected

def instantiate_object(field, values_with_empty_parameters)
  return nil if values_with_empty_parameters.all? { |v| v.nil? }
  values = values_with_empty_parameters.collect { |v| v.nil? ? 1 : v }
  klass = field.type
  if klass == DateTime || klass == Date || klass == Time
    field.mongoize(values)
  elsif klass
    klass.new(*values)
  else
    values
  end
end
  end
  module Document
    include MultiParameterAttributes
  end
end

So what does this code do? We create a data structure, multi_parameter_attributes, which will store any attributes that match the following regex pattern: /\A([^(]+)((\d+)([if]))$/. \A matches beginning of string. Typically you are used to seeing ^ to match beginning of string, but \A and its counterpart \Z will match irrespective of newline characters. We have 3 capturing groups. The first, ([^(]+), will match all characters that are not a left paranthesis. In the string 'starttime(1i)', it will capture 'starttime'. The second capturing group, (\d+), will capture the digits. So '1' in 'starttime(1i)'. The third capturing group, ([if]), will capture an i or f. The i refers to an integer value.

Now typically a datetime field has many parts like below:

starttime(1i) => 2019
starttime(2i) => 6
starttime(3i) => 28
starttime(4i) => 19
starttime(5i) => 18

Consequently, we are iterating through the attributes hash in order to build our data structure into multi_parameter_attributes:

attrs.each_pair do |key, value|
  ...
end

Remember we used capturing groups in the regex. We can use Ruby's $1, $2, etc. global variables to reference the captured groups later. key is the name of the attribute, e.g. starttime. index refers to the part of the attribute in the datetime, such as year, month, day, etc. And $3 holds the i in the third captured group, because we want to take the string value and convert it to an integer:

key, index = $1, $2.to_i
(multi_parameter_attributes[key] ||= {})[index] = value.empty? ? nil : value.send("to_#{$3}")

Ultimately, we end up with a nice data structure like this:

{ starttime: { 1 => 2019, 2 => 6, 3 => 28, 4 => 19, 5 => 18 } }

Now we do something intelligent to get the actual date parts:

 values = (values.keys.min..values.keys.max).map { |i| values[i] }

This would give us:

[2019, 6, 28, 19, 18] 

Well, now we have the date we want. The rest of it is using the Mongoid API to generate a field object to store the date.

Upvotes: 2

jordelver
jordelver

Reputation: 8432

You need to include include Mongoid::MultiParameterAttributes in your Mongoid model.

See this GitHub issue on the problem.

I couldn't find it documented anywhere in particular.~

That'll teach me for not reading properly! This gem seems to be the solution though.

Upvotes: 4

Related Questions