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:

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

assign_nested_attributes(association_name, attributes, options = {}) click to toggle source

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
mark_for_destruction() click to toggle source
# File lib/active_model/datastore/nested_attr.rb, line 85
def mark_for_destruction
  @marked_for_destruction = true
end
marked_for_destruction?() click to toggle source
# File lib/active_model/datastore/nested_attr.rb, line 89
def marked_for_destruction?
  @marked_for_destruction
end
nested_attributes?() click to toggle source
# File lib/active_model/datastore/nested_attr.rb, line 93
def nested_attributes?
  nested_attributes.is_a?(Array) && !nested_attributes.empty?
end
nested_errors() click to toggle source
# 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
nested_model_class_names() click to toggle source
# 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
nested_models() click to toggle source

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

assign_to_or_mark_for_destruction(record, attributes) click to toggle source

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
call_reject_if(attributes, options) click to toggle source

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
destroy_flag?(hash) click to toggle source

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
reject_new_record?(attributes, options) click to toggle source

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
validate_attributes(attributes) click to toggle source
# 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