module ActiveRecord::Associations::ClassMethods

Public Instance Methods

add_deletion_callback() click to toggle source
# File lib/has_and_belongs_to_many_with_deferred_save.rb, line 120
def add_deletion_callback
  # this will delete all the association into the join table after obj.destroy,
  # but is only useful/necessary, if the record is not paranoid?
  unless respond_to?(:paranoid?) && paranoid?
    after_destroy do |record|
      begin
        record.save
      rescue Exception => e
        logger.warn "Association cleanup after destroy failed with #{e}"
      end
    end
  end
end
define_id_getter(collection_name, collection_singular_ids) click to toggle source
# File lib/has_many_with_deferred_save.rb, line 76
def define_id_getter(collection_name, collection_singular_ids)
  define_method "#{collection_singular_ids}_with_deferred_save" do
    send(collection_name).map { |e| e[:id] }
  end
  alias_method(:"#{collection_singular_ids}_without_deferred_save", :"#{collection_singular_ids}")
  alias_method(:"#{collection_singular_ids}", :"#{collection_singular_ids}_with_deferred_save")
end
define_id_setter(collection_name, collection_singular_ids) click to toggle source
# File lib/has_many_with_deferred_save.rb, line 62
def define_id_setter(collection_name, collection_singular_ids)
  # only needed for ActiveRecord >= 3.0
  if ActiveRecord::VERSION::STRING >= '3'
    define_method "#{collection_singular_ids}_with_deferred_save=" do |ids|
      ids = Array.wrap(ids).reject(&:blank?)
      new_values = send("#{collection_name}_without_deferred_save").klass.find(ids)
      send("#{collection_name}=", new_values)
    end

    alias_method(:"#{collection_singular_ids}_without_deferred_save=", :"#{collection_singular_ids}=")
    alias_method(:"#{collection_singular_ids}=", :"#{collection_singular_ids}_with_deferred_save=")
  end
end
define_obj_getter(collection_name) click to toggle source
# File lib/has_many_with_deferred_save.rb, line 37
def define_obj_getter(collection_name)
  define_method("#{collection_name}_with_deferred_save") do
    save_in_progress = instance_variable_get "@hmwds_#{collection_name}_save_in_progress"

    # while updating the association, rails loads the association object - this needs to be the original one
    unless save_in_progress
      elements = instance_variable_get "@hmwds_temp_#{collection_name}"
      if elements.nil?
        elements = ArrayToAssociationWrapper.new(send("#{collection_name}_without_deferred_save"))
        elements.defer_association_methods_to self, collection_name
        instance_variable_set "@hmwds_temp_#{collection_name}", elements
      end

      result = elements
    else
      result = send("#{collection_name}_without_deferred_save")
    end

    result
  end

  alias_method(:"#{collection_name}_without_deferred_save", :"#{collection_name}")
  alias_method(:"#{collection_name}", :"#{collection_name}_with_deferred_save")
end
define_obj_setter(collection_name) click to toggle source
# File lib/has_many_with_deferred_save.rb, line 27
def define_obj_setter(collection_name)
  define_method("#{collection_name}_with_deferred_save=") do |objs|
    instance_variable_set "@hmwds_temp_#{collection_name}", objs || []
    attribute_will_change!(collection_name) if objs != send("#{collection_name}_without_deferred_save")
  end

  alias_method(:"#{collection_name}_without_deferred_save=", :"#{collection_name}=")
  alias_method(:"#{collection_name}=", :"#{collection_name}_with_deferred_save=")
end
define_reload_method(collection_name) click to toggle source
# File lib/has_many_with_deferred_save.rb, line 100
def define_reload_method(collection_name)
  define_method "reload_with_deferred_save_for_#{collection_name}" do |*args|
    # Reload from the *database*, discarding any unsaved changes.
    send("reload_without_deferred_save_for_#{collection_name}", *args).tap do
      instance_variable_set "@hmwds_temp_#{collection_name}", nil
    end
  end
  alias_method(:"reload_without_deferred_save_for_#{collection_name}", :reload)
  alias_method(:reload, :"reload_with_deferred_save_for_#{collection_name}")
end
define_update_method(collection_name) click to toggle source
# File lib/has_many_with_deferred_save.rb, line 84
def define_update_method(collection_name)
  define_method "hmwds_update_#{collection_name}" do
    unless frozen?
      elements = instance_variable_get "@hmwds_temp_#{collection_name}"
      unless elements.nil? # nothing has been done with the association
        # save is done automatically, if original behaviour is restored
        instance_variable_set "@hmwds_#{collection_name}_save_in_progress", true
        send("#{collection_name}_without_deferred_save=", elements)
        instance_variable_set "@hmwds_#{collection_name}_save_in_progress", false

        instance_variable_set "@hmwds_temp_#{collection_name}", nil
      end
    end
  end
end
has_and_belongs_to_many_with_deferred_save(*args) click to toggle source

Instructions:

Replace your existing call to has_and_belongs_to_many with has_and_belongs_to_many_with_deferred_save.

Then add a validation method that adds an error if there is something wrong with the (unsaved) collection. This will prevent it from being saved if there are any errors.

Example:

def validate
  if people.size > maximum_occupancy
    errors.add :people, "There are too many people in this room"
  end
end
# File lib/has_and_belongs_to_many_with_deferred_save.rb, line 17
def has_and_belongs_to_many_with_deferred_save(*args)
  collection_name = args[0].to_s
  collection_singular_ids = collection_name.singularize + '_ids'

  return if method_defined?("#{collection_name}_with_deferred_save")

  has_and_belongs_to_many *args

  add_deletion_callback

  attr_accessor :"unsaved_#{collection_name}"
  attr_accessor :"use_original_collection_reader_behavior_for_#{collection_name}"

  define_method "#{collection_name}_with_deferred_save=" do |collection|
    # puts "has_and_belongs_to_many_with_deferred_save: #{collection_name} = #{collection.collect(&:id).join(',')}"
    send "unsaved_#{collection_name}=", collection
  end

  define_method "#{collection_name}_with_deferred_save" do |*method_args|
    if send("use_original_collection_reader_behavior_for_#{collection_name}")
      send("#{collection_name}_without_deferred_save")
    else
      send("initialize_unsaved_#{collection_name}", *method_args) if send("unsaved_#{collection_name}").nil?
      send("unsaved_#{collection_name}")
    end
  end

  alias_method(:"#{collection_name}_without_deferred_save=", :"#{collection_name}=")
  alias_method(:"#{collection_name}=", :"#{collection_name}_with_deferred_save=")
  alias_method(:"#{collection_name}_without_deferred_save", :"#{collection_name}")
  alias_method(:"#{collection_name}", :"#{collection_name}_with_deferred_save")

  define_method "#{collection_singular_ids}_with_deferred_save" do |*method_args|
    if send("use_original_collection_reader_behavior_for_#{collection_name}")
      send("#{collection_singular_ids}_without_deferred_save")
    else
      send("initialize_unsaved_#{collection_name}", *method_args) if send("unsaved_#{collection_name}").nil?
      send("unsaved_#{collection_name}").map { |e| e[:id] }
    end
  end

  alias_method(:"#{collection_singular_ids}_without_deferred_save", :"#{collection_singular_ids}")
  alias_method(:"#{collection_singular_ids}", :"#{collection_singular_ids}_with_deferred_save")

  # only needed for ActiveRecord >= 3.0
  if ActiveRecord::VERSION::STRING >= '3'
    define_method "#{collection_singular_ids}_with_deferred_save=" do |ids|
      ids = Array.wrap(ids).reject(&:blank?)
      reflection_wrapper = send("#{collection_name}_without_deferred_save")
      new_values = reflection_wrapper.klass.find(ids)
      send("#{collection_name}=", new_values)
    end

    alias_method(:"#{collection_singular_ids}_without_deferred_save=", :"#{collection_singular_ids}=")
    alias_method(:"#{collection_singular_ids}=", :"#{collection_singular_ids}_with_deferred_save=")
  end

  define_method "do_#{collection_name}_save!" do
    # Question: Why do we need this @use_original_collection_reader_behavior stuff?
    # Answer: Because AssociationCollection#replace(other_array) performs a diff between current_array and other_array and deletes/adds only
    # records that have changed.
    # In order to perform that diff, it needs to figure out what "current_array" is, so it calls our collection_with_deferred_save, not
    # knowing that we've changed its behavior. It expects that method to return the elements of that collection that are in the *database*
    # (the original behavior), so we have to provide that behavior...  If we didn't provide it, it would end up trying to take the diff of
    # two identical collections so nothing would ever get saved.
    # But we only want the old behavior in this case -- most of the time we want the *new* behavior -- so we use
    # @use_original_collection_reader_behavior as a switch.

    unless send("unsaved_#{collection_name}").nil?
      send "use_original_collection_reader_behavior_for_#{collection_name}=", true

      # vv This is where the actual save occurs vv
      send "#{collection_name}_without_deferred_save=", send("unsaved_#{collection_name}")

      send "use_original_collection_reader_behavior_for_#{collection_name}=", false
    end
    true
  end
  after_save :"do_#{collection_name}_save!"

  define_method "reload_with_deferred_save_for_#{collection_name}" do |*method_args|
    # Reload from the *database*, discarding any unsaved changes.
    send("reload_without_deferred_save_for_#{collection_name}", *method_args).tap do
      send "unsaved_#{collection_name}=", nil
      # /\ If we didn't do this, then when we called reload, it would still have the same (possibly invalid) value of
      # unsaved_collection that it had before the reload.
    end
  end

  alias_method(:"reload_without_deferred_save_for_#{collection_name}", :reload)
  alias_method(:reload, :"reload_with_deferred_save_for_#{collection_name}")

  define_method "initialize_unsaved_#{collection_name}" do |*method_args|
    elements = send("#{collection_name}_without_deferred_save", *method_args)

    # here the association will be duped, so changes to "unsaved_#{collection_name}" will not be saved immediately
    elements = ArrayToAssociationWrapper.new(elements)
    elements.defer_association_methods_to self, collection_name
    send "unsaved_#{collection_name}=", elements
  end
  private :"initialize_unsaved_#{collection_name}"
end
has_many_with_deferred_save(*args) click to toggle source
# File lib/has_many_with_deferred_save.rb, line 4
def has_many_with_deferred_save(*args)
  collection_name = args[0].to_s
  collection_singular_ids = "#{collection_name.singularize}_ids"

  return if method_defined?("#{collection_name}_with_deferred_save")

  has_many *args

  if args[1].is_a?(Hash) && args[1].keys.include?(:through)
    logger.warn "You are using the option :through on #{name}##{collection_name}. This was not tested very much with has_many_with_deferred_save. Please write many tests for your functionality!"
  end

  after_save :"hmwds_update_#{collection_name}"

  define_obj_setter    collection_name
  define_obj_getter    collection_name
  define_id_setter     collection_name, collection_singular_ids
  define_id_getter     collection_name, collection_singular_ids

  define_update_method collection_name
  define_reload_method collection_name
end