Reputation: 113
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
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
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