module ActiveFedora # = Active Fedora Autosave Association # # +AutosaveAssociation+ is a module that takes care of automatically saving # associacted records when their parent is saved. In addition to saving, it # also destroys any associated records that were marked for destruction. # (See +mark_for_destruction+ and marked_for_destruction?). # # Saving of the parent, its associations, and the destruction of marked # associations, all happen inside a transaction. This should never leave the # database in an inconsistent state. # # If validations for any of the associations fail, their error messages will # be applied to the parent. # # Note that it also means that associations marked for destruction won't # be destroyed directly. They will however still be marked for destruction. # # Note that :autosave => false is not same as not declaring :autosave. # When the :autosave option is not present new associations are saved. # # === One-to-many Example # # When :autosave is not declared new children are saved when their parent is saved: # # class Post # has_many :comments # :autosave option is no declared # end # # post = Post.new(:title => 'ruby rocks') # post.comments.build(:body => 'hello world') # post.save # => saves both post and comment # # post = Post.create(:title => 'ruby rocks') # post.comments.build(:body => 'hello world') # post.save # => saves both post and comment # # post = Post.create(:title => 'ruby rocks') # post.comments.create(:body => 'hello world') # post.save # => saves both post and comment # # When :autosave is true all children is saved, no matter whether they are new records: # # class Post # has_many :comments, :autosave => true # end # # post = Post.create(:title => 'ruby rocks') # post.comments.create(:body => 'hello world') # post.comments[0].body = 'hi everyone' # post.save # => saves both post and comment, with 'hi everyone' as body # # Destroying one of the associated models as part of the parent's save action # is as simple as marking it for destruction: # # post.comments.last.mark_for_destruction # post.comments.last.marked_for_destruction? # => true # post.comments.length # => 2 # # Note that the model is _not_ yet removed from the database: # # id = post.comments.last.id # Comment.find_by_id(id).nil? # => false # # post.save # post.reload.comments.length # => 1 # # Now it _is_ removed from the database: # # Comment.find_by_id(id).nil? # => true # # === Validation # # Children records are validated unless :validate is +false+. module AutosaveAssociation extend ActiveSupport::Concern ASSOCIATION_TYPES = [:has_many, :belongs_to, :has_and_belongs_to_many, :directly_contains, :indirectly_contains].freeze module AssociationBuilderExtension #:nodoc: def self.valid_options [:autosave] end def self.build(model, reflection) model.send(:define_autosave_association_callbacks, reflection) if ASSOCIATION_TYPES.include? reflection.macro end end included do Associations::Builder::Association.extensions << AssociationBuilderExtension end module ClassMethods private def define_non_cyclic_method(name, reflection, &block) define_method(name) do |*_args| result = true @_already_called ||= {} # Loop prevention for validation of associations unless @_already_called[[name, reflection.name]] begin @_already_called[[name, reflection.name]] = true result = instance_eval(&block) ensure @_already_called[[name, reflection.name]] = false end end result end end # Adds validation and save callbacks for the association as specified by # the +reflection+. # # For performance reasons, we don't check whether to validate at runtime. # However the validation and callback methods are lazy and those methods # get created when they are invoked for the very first time. However, # this can change, for instance, when using nested attributes, which is # called _after_ the association has been defined. Since we don't want # the callbacks to get defined multiple times, there are guards that # check if the save or validation methods have already been defined # before actually defining them. def define_autosave_association_callbacks(reflection) save_method = :"autosave_associated_records_for_#{reflection.name}" validation_method = :"validate_associated_records_for_#{reflection.name}" collection = reflection.collection? unless method_defined?(save_method) if collection before_save :before_save_collection_association define_non_cyclic_method(save_method, reflection) { save_collection_association(reflection) } # Doesn't use after_save as that would save associations added in after_create/after_update twice after_create save_method after_update save_method else define_non_cyclic_method(save_method, reflection) { save_belongs_to_association(reflection) } before_save save_method end end if reflection.validate? && !method_defined?(validation_method) method = (collection ? :validate_collection_association : :validate_single_association) define_non_cyclic_method(validation_method, reflection) { send(method, reflection) } validate validation_method end end end # Reloads the attributes of the object as usual and clears marked_for_destruction flag. def reload @marked_for_destruction = false @destroyed_by_association = nil super end # Marks this record to be destroyed as part of the parents save transaction. # This does _not_ actually destroy the record instantly, rather child record will be destroyed # when parent.save is called. # # Only useful if the :autosave option on the parent is enabled for this associated model. def mark_for_destruction @marked_for_destruction = true end # Returns whether or not this record will be destroyed as part of the parents save transaction. # # Only useful if the :autosave option on the parent is enabled for this associated model. def marked_for_destruction? @marked_for_destruction end # Records the association that is being destroyed and destroying this # record in the process. def destroyed_by_association=(reflection) @destroyed_by_association = reflection end # Returns the association for the parent being destroyed. # # Used to avoid updating the counter cache unnecessarily. def destroyed_by_association @destroyed_by_association end # Returns whether or not this record has been changed in any way (including whether # any of its nested autosave associations are likewise changed) def changed_for_autosave? new_record? || changed? || marked_for_destruction? || nested_records_changed_for_autosave? end private # Returns the record for an association collection that should be validated # or saved. If +autosave+ is +false+ only new records will be returned, # unless the parent is/was a new record itself. def associated_records_to_validate_or_save(association, new_record, autosave) if new_record association && association.target elsif autosave association.target.find_all(&:changed_for_autosave?) else association.target.find_all(&:new_record?) end end # go through nested autosave associations that are loaded in memory (without loading # any new ones), and return true if is changed for autosave def nested_records_changed_for_autosave? self.class.reflect_on_all_autosave_associations.any? do |reflection| association = association_instance_get(reflection.name) association && Array(association.target).any?(&:changed_for_autosave?) end end # Validate the association if :validate or :autosave is # turned on for the association. def validate_single_association(reflection) association = association_instance_get(reflection.name) record = association && association.target association_valid?(reflection, record) if record end # Validate the associated records if :validate or # :autosave is turned on for the association specified by # +reflection+. def validate_collection_association(reflection) if association = association_instance_get(reflection.name) if records = associated_records_to_validate_or_save(association, new_record?, reflection.options[:autosave]) records.each { |record| association_valid?(reflection, record) } end end end # Returns whether or not the association is valid and applies any errors to # the parent, self, if it wasn't. Skips any :autosave # enabled records if they're marked_for_destruction? or destroyed. def association_valid?(reflection, record) return true if record.destroyed? || record.marked_for_destruction? unless valid = record.valid? if reflection.options[:autosave] record.errors.each do |attribute, message| attribute = "#{reflection.name}.#{attribute}" errors[attribute] << message errors[attribute].uniq! end else errors.add(reflection.name) end end valid end # Is used as a before_save callback to check while saving a collection # association whether or not the parent was a new record before saving. def before_save_collection_association @new_record_before_save = new_record? true end # Saves any new associated records, or all loaded autosave associations if # :autosave is enabled on the association. # # In addition, it destroys all children that were marked for destruction # with mark_for_destruction. # # This all happens inside a transaction, _if_ the Transactions module is included into # ActiveFedora::Base after the AutosaveAssociation module, which it does by default. def save_collection_association(reflection) if association = association_instance_get(reflection.name) autosave = reflection.options[:autosave] if records = associated_records_to_validate_or_save(association, @new_record_before_save, autosave) records.each do |record| next if record.destroyed? saved = true if autosave && record.marked_for_destruction? association.proxy.destroy(record) elsif autosave != false && (@new_record_before_save || record.new_record?) if autosave saved = association.insert_record(record, false) else association.insert_record(record) end elsif autosave saved = record.save(validate: false) end raise ActiveFedora::Rollback unless saved end end # reconstruct the scope now that we know the owner's id association.send(:reset_scope) if association.respond_to?(:reset_scope) end end # Saves the associated record if it's new or :autosave is enabled. # # In addition, it will destroy the association if it was marked for destruction. def save_belongs_to_association(reflection) association = association_instance_get(reflection.name) record = association && association.load_target if record && !record.destroyed? autosave = reflection.options[:autosave] if autosave && record.marked_for_destruction? record.destroy elsif autosave != false saved = record.save(validate: !autosave) if record.new_record? || (autosave && record.changed_for_autosave?) if association.updated? self[reflection.foreign_key] = record.id association.loaded! end saved if autosave end end end end end