require 'deferring/version' require 'deferring/deferred_association' require 'deferring/deferred_callback_listener' module Deferring # Creates a wrapper around `has_and_belongs_to_many`. A normal habtm # association is created, but this association is wrapped in a # DeferredAssociation. The accessor methods of the original association are # replaced with ones that will defer saving the association until the parent # object has been saved. def deferred_has_and_belongs_to_many(*args) options = args.extract_options! listeners = create_callback_listeners!(options) autosave = options.fetch(:autosave, true) validate = options.fetch(:validate, true) has_and_belongs_to_many(*args, options) generate_deferred_association_methods( args.first.to_s, listeners, autosave: autosave, type: :habtm, validate: validate) end # Creates a wrapper around `has_many`. A normal has many association is # created, but this association is wrapped in a DeferredAssociation. The # accessor methods of the original association are replaced with ones that # will defer saving the association until the parent object has been saved. def deferred_has_many(*args) options = args.extract_options! listeners = create_callback_listeners!(options) inverse_association_name = options[:as] || options[:inverse_of] || self.name.underscore.to_sym autosave = options.fetch(:autosave, true) validate = options.fetch(:validate, true) has_many(*args, options) generate_deferred_association_methods( args.first.to_s, listeners, inverse_association_name: inverse_association_name, autosave: autosave, type: :has_many, dependent: options[:dependent], validate: validate) end def deferred_accepts_nested_attributes_for(*args) options = args.extract_options! reject_if = options.delete(:reject_if) accepts_nested_attributes_for(*args, options) association_name = args.first.to_s # teams_attributes= define_method :"#{association_name}_attributes=" do |attributes_collection| unless attributes_collection.is_a?(Hash) || attributes_collection.is_a?(Array) raise ArgumentError, "Hash or Array expected, got #{attributes_collection.class.name} (#{attributes_collection.inspect})" end # TODO: Implement :limit checks. Check rails source. if attributes_collection.is_a? Hash keys = attributes_collection.keys attributes_collection = if keys.include?('id') || keys.include?(:id) [attributes_collection] else attributes_collection.values end end attributes_collection.each do |attributes| attributes = attributes.with_indifferent_access if attributes['id'].blank? if !send(:"deferred_#{association_name}_reject_new_record?", attributes) send(:"#{association_name}").build(attributes.except(*deferred_unassignable_keys)) end elsif existing_record = send(:"#{association_name}").detect { |record| record.id.to_s == attributes['id'].to_s } if !send(:"deferred_#{association_name}_call_reject_if", attributes) existing_record.attributes = attributes.except(*deferred_unassignable_keys) # TODO: Implement value_to_boolean code from rails for checking _destroy field. if attributes['_destroy'] == '1' && options[:allow_destroy] # remove from existing records and mark for destruction upon # saving send(:"#{association_name}").delete(existing_record) existing_record.mark_for_destruction end end else # new record referenced by id if !send(:"deferred_#{association_name}_call_reject_if", attributes) klass = self.class.reflect_on_association(:"#{association_name}").klass attribute_ids = attributes_collection.map { |a| a['id'] || a[:id] }.compact # TODO: Find out how to get send(:"#{association_name}").scoped.where working new_records = attribute_ids.empty? ? [] : klass.where(klass.primary_key => attribute_ids) new_record = new_records.detect { |record| record.id.to_s == attributes['id'].to_s } send(:"#{association_name}").push(new_record) end end end end # Determines if a new record should be build by checking for # has_destroy_flag? or if a :reject_if proc exists for this # association and evaluates to +true+. define_method :"deferred_#{association_name}_reject_new_record?" do |attributes| # TODO: Implement value_to_boolean code from rails for checking _destroy field. attributes['_destroy'] == '1' || send(:"deferred_#{association_name}_call_reject_if", attributes) end define_method :"deferred_#{association_name}_call_reject_if" do |attributes| return false if attributes['_destroy'] == '1' case callback = reject_if when Symbol method(callback).arity == 0 ? send(callback) : send(callback, attributes) when Proc callback.call(attributes) end end define_method :deferred_unassignable_keys do %w(_destroy id) end end private def generate_deferred_association_methods(association_name, listeners, options = {}) deferred_association_name = :"deferred_#{association_name}" inverse_association_name = options[:inverse_association_name] autosave = options.fetch(:autosave, true) type = options.fetch(:type) validate = options.fetch(:validate, true) dependent = options[:dependent] # Store the original accessor methods of the association. alias_method :"original_#{association_name}", :"#{association_name}" alias_method :"original_#{association_name}=", :"#{association_name}=" # Accessor for our own association. define_method(deferred_association_name) do return instance_variable_get(:"@#{deferred_association_name}") if instance_variable_defined?(:"@#{deferred_association_name}") klass = self.class.reflect_on_association(:"#{association_name}").klass deferred_association = DeferredAssociation.new(send(:"original_#{association_name}"), klass, self, inverse_association_name, dependent) listeners.each do |event_name, callback_method| deferred_association.add_callback_listener(event_name, callback_method) end instance_variable_set(:"@#{deferred_association_name}", deferred_association) end define_method :"#{deferred_association_name}=" do |deferred_association| instance_variable_set(:"@#{deferred_association_name}", deferred_association) end # collection # # Returns an array of all the associated objects. An empty array is returned # if none are found. define_method :"#{association_name}" do send(deferred_association_name) end # collection=objects # # Replaces the collection's content by deleting and adding objects as # appropriate. define_method :"#{association_name}=" do |objects| send(deferred_association_name).objects = objects end # collection_singular_ids= # # Replace the collection by the objects identified by the primary keys in # ids. define_method :"#{association_name.singularize}_ids=" do |ids| ids ||= [] klass = self.class.reflect_on_association(:"#{association_name}").klass objects = klass.find(ids.reject(&:blank?)) send(deferred_association_name).objects = objects end # collection_singular_ids # # Returns an array of the associated objects' ids. define_method :"#{association_name.singularize}_ids" do send(deferred_association_name).ids end # collection_singular_checked attr_accessor :"#{association_name}_checked" # collection_singular_checked= define_method(:"#{association_name}_checked=") do |ids| ids ||= '' send(:"#{association_name.singularize}_ids=", ids.split(',')) end # Prepend #changed_for_autosave? and #reload methods to extend existing # behavior in ActiveRecord. self.prepend(Module.new do define_method :changed_for_autosave? do super() || send(deferred_association_name).changed_for_autosave? end define_method :reload do |*args| super(*args).tap do update_deferred_association(association_name, listeners, inverse_association_name, dependent) end end end) after_validation :"perform_#{deferred_association_name}_validation!" define_method :"perform_#{deferred_association_name}_validation!" do # Do not perform validations for HABTM associations as they are always # validated by Rails upon saving. return true if type == :habtm # Do not perform validation when the association has not been loaded # (performance improvement). return true unless send(deferred_association_name).loaded? # Do not perform validations when validate: false. return true if validate == false all_records_valid = send(deferred_association_name).objects.inject(true) do |valid, record| unless record.valid? valid = false if autosave record.errors.each do |attribute, message| attribute = "#{association_name}.#{attribute}" errors[attribute] << message errors[attribute].uniq! end else errors.add(association_name) end end valid end return true if all_records_valid false end # the save after the parent object has been saved after_save :"perform_#{deferred_association_name}_save!" define_method :"perform_#{deferred_association_name}_save!" do # Send the objects of our delegated association to the original # association and store the result. deferred_association = send(deferred_association_name) if deferred_association.send(:objects_loaded?) send(:"original_#{association_name}").delete(deferred_association.unlinks) unless send(:"original_#{association_name}").push(deferred_association.links) raise ActiveRecord::RecordNotSaved, "Failed to replace #{association_name} because one or more of " \ "the new records could not be saved." end end # Store the new value of the association into our delegated association. update_deferred_association(association_name, listeners, inverse_association_name, dependent) end generate_update_deferred_assocation_method end def generate_update_deferred_assocation_method define_method :update_deferred_association do |name, listeners, inverse_association_name, dependent| klass = self.class.reflect_on_association(:"#{name}").klass deferred_association = DeferredAssociation.new(send(:"original_#{name}"), klass, self, inverse_association_name, dependent) listeners.each do |event_name, callback_method| deferred_association.add_callback_listener(event_name, callback_method) end send(:"deferred_#{name}=", deferred_association) end end def create_callback_listeners!(options) [:before_link, :before_unlink, :after_link, :after_unlink].map do |event_name| callback_method = options.delete(event_name) [event_name, callback_method] if callback_method end.compact end end ActiveRecord::Base.send(:extend, Deferring)