module ActiveModel::Datastore::NestedAttr
ActiveModel
Datastore
Nested Attributes¶ ↑
Adds support for nested attributes to ActiveModel
. Heavily inspired by Rails ActiveRecord::NestedAttributes.
Nested attributes allow you to save attributes on associated records along with the parent. It's used in conjunction with fields_for to build the nested form elements.
See Rails ActionView::Helpers::FormHelper::fields_for for more info.
NOTE: Unlike ActiveRecord, the way that the relationship is modeled between the parent and child is not enforced. With NoSQL the relationship could be defined by any attribute, or with denormalization exist within the same entity. This library provides a way for the objects to be associated yet saved to the datastore in any way that you choose.
You enable nested attributes by defining an :attr_accessor
on the parent with the pluralized name of the child model.
Nesting also requires that a +<association_name>_attributes=+ writer method is defined in your parent model. If an object with an association is instantiated with a params hash, and that hash has a key for the association, Rails will call the +<association_name>_attributes=+ method on that object. Within the writer method call assign_nested_attributes
, passing in the association name and attributes.
Let's say we have a parent Recipe with Ingredient children.
Start by defining within the Recipe model:
-
an attr_accessor of
:ingredients
-
a writer method named
ingredients_attributes=
-
the
validates_associated
method can be used to validate the nested objects
Example:
class Recipe attr_accessor :ingredients validates :ingredients, presence: true validates_associated :ingredients def ingredients_attributes=(attributes) assign_nested_attributes(:ingredients, attributes) end end
You may also set a :reject_if
proc to silently ignore any new record hashes if they fail to pass your criteria. For example:
class Recipe def ingredients_attributes=(attributes) reject_proc = proc { |attributes| attributes['name'].blank? } assign_nested_attributes(:ingredients, attributes, reject_if: reject_proc) end end
Alternatively, :reject_if
also accepts a symbol for using methods:
class Recipe def ingredients_attributes=(attributes) reject_proc = proc { |attributes| attributes['name'].blank? } assign_nested_attributes(:ingredients, attributes, reject_if: reject_recipes) end def reject_recipes(attributes) attributes['name'].blank? end end
Within the parent model valid?
will validate the parent and associated children and nested_models
will return the child objects. If the nested form submitted params contained a truthy _destroy
key, the appropriate nested_models
will have marked_for_destruction
set to True.
Created by Bryce McLean on 2016-12-06.
Constants
- UNASSIGNABLE_KEYS
Public Instance Methods
Assigns the given nested child attributes.
Attribute hashes with an :id
value matching an existing associated object will update that object. Hashes without an :id
value will build a new object for the association. Hashes with a matching :id
value and a :_destroy
key set to a truthy value will mark the matched object for destruction.
Pushes a key of the association name onto the parent object's nested_attributes
attribute. The nested_attributes
can be used for determining when the parent has associated children.
@param [Symbol] association_name The attribute name of the associated children. @param [ActiveSupport::HashWithIndifferentAccess, ActionController::Parameters] attributes
The attributes provided by Rails ActionView. Typically new objects will arrive as ActiveSupport::HashWithIndifferentAccess and updates as ActionController::Parameters.
@param [Hash] options The options to control how nested attributes are applied.
@option options [Proc, Symbol] :reject_if Allows you to specify a Proc or a Symbol
pointing
to a method that checks whether a record should be built for a certain attribute hash. The hash is passed to the supplied Proc or the method and it should return either +true+ or +false+. Passing +:all_blank+ instead of a Proc will create a proc that will reject a record where all the attributes are blank.
The following example will update the amount of the ingredient with ID 1, build a new associated ingredient with the amount of 45, and mark the associated ingredient with ID 2 for destruction.
assign_nested_attributes(:ingredients, { '0' => { id: '1', amount: '123' }, '1' => { amount: '45' }, '2' => { id: '2', _destroy: true } })
# File lib/active_model/datastore/nested_attr.rb, line 155 def assign_nested_attributes(association_name, attributes, options = {}) attributes = validate_attributes(attributes) association_name = association_name.to_sym send("#{association_name}=", []) if send(association_name).nil? attributes.each_value do |params| if params['id'].blank? unless reject_new_record?(params, options) new = association_name.to_c.new(params.except(*UNASSIGNABLE_KEYS)) send(association_name).push(new) end else existing = send(association_name).detect { |record| record.id.to_s == params['id'].to_s } assign_to_or_mark_for_destruction(existing, params) end end (self.nested_attributes ||= []).push(association_name) end
# File lib/active_model/datastore/nested_attr.rb, line 85 def mark_for_destruction @marked_for_destruction = true end
# File lib/active_model/datastore/nested_attr.rb, line 89 def marked_for_destruction? @marked_for_destruction end
# File lib/active_model/datastore/nested_attr.rb, line 93 def nested_attributes? nested_attributes.is_a?(Array) && !nested_attributes.empty? end
# File lib/active_model/datastore/nested_attr.rb, line 112 def nested_errors errors = [] if nested_attributes? nested_attributes.each do |attr| send(attr.to_sym).each { |child| errors << child.errors } end end errors end
# File lib/active_model/datastore/nested_attr.rb, line 106 def nested_model_class_names entity_kinds = [] nested_models.each { |x| entity_kinds << x.class.name } if nested_attributes? entity_kinds.uniq end
For each attribute name in nested_attributes extract and return the nested model objects.
# File lib/active_model/datastore/nested_attr.rb, line 100 def nested_models model_entities = [] nested_attributes.each { |attr| model_entities << send(attr.to_sym) } if nested_attributes? model_entities.flatten end
Private Instance Methods
Updates an object with attributes or marks it for destruction if has_destroy_flag?.
# File lib/active_model/datastore/nested_attr.rb, line 190 def assign_to_or_mark_for_destruction(record, attributes) record.assign_attributes(attributes.except(*UNASSIGNABLE_KEYS)) record.mark_for_destruction if destroy_flag?(attributes) end
Determines if a record with the particular attributes
should be rejected by calling the reject_if Symbol
or Proc (if provided in options).
Returns false if there is a destroy_flag
on the attributes.
# File lib/active_model/datastore/nested_attr.rb, line 216 def call_reject_if(attributes, options) return false if destroy_flag?(attributes) attributes = attributes.with_indifferent_access blank_proc = proc { |attrs| attrs.all? { |_key, value| value.blank? } } options[:reject_if] = blank_proc if options[:reject_if] == :all_blank case callback = options[:reject_if] when Symbol method(callback).arity.zero? ? send(callback) : send(callback, attributes) when Proc callback.call(attributes) else false end end
Determines if a hash contains a truthy _destroy key.
# File lib/active_model/datastore/nested_attr.rb, line 198 def destroy_flag?(hash) [true, 1, '1', 't', 'T', 'true', 'TRUE'].include?(hash['_destroy']) end
Determines if a new record should be rejected by checking if a :reject_if
option exists and evaluates to true
.
# File lib/active_model/datastore/nested_attr.rb, line 206 def reject_new_record?(attributes, options) call_reject_if(attributes, options) end
# File lib/active_model/datastore/nested_attr.rb, line 178 def validate_attributes(attributes) attributes = attributes.to_h if attributes.respond_to?(:permitted?) unless attributes.is_a?(Hash) raise ArgumentError, "Hash expected, got #{attributes.class.name} (#{attributes.inspect})" end attributes end