diff options
Diffstat (limited to 'activerecord/lib/active_record/autosave_association.rb')
-rw-r--r-- | activerecord/lib/active_record/autosave_association.rb | 349 |
1 files changed, 349 insertions, 0 deletions
diff --git a/activerecord/lib/active_record/autosave_association.rb b/activerecord/lib/active_record/autosave_association.rb new file mode 100644 index 0000000000..741aa2acbe --- /dev/null +++ b/activerecord/lib/active_record/autosave_association.rb @@ -0,0 +1,349 @@ +module ActiveRecord + # AutosaveAssociation is a module that takes care of automatically saving + # your associations when the parent is saved. In addition to saving, it + # also destroys any associations 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 1 transaction. This should never leave the + # database in an inconsistent state after, for instance, mass assigning + # attributes and saving them. + # + # 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. + # + # === One-to-one Example + # + # Consider a Post model with one Author: + # + # class Post + # has_one :author, :autosave => true + # end + # + # Saving changes to the parent and its associated model can now be performed + # automatically _and_ atomically: + # + # post = Post.find(1) + # post.title # => "The current global position of migrating ducks" + # post.author.name # => "alloy" + # + # post.title = "On the migration of ducks" + # post.author.name = "Eloy Duran" + # + # post.save + # post.reload + # post.title # => "On the migration of ducks" + # post.author.name # => "Eloy Duran" + # + # Destroying an associated model, as part of the parent's save action, is as + # simple as marking it for destruction: + # + # post.author.mark_for_destruction + # post.author.marked_for_destruction? # => true + # + # Note that the model is _not_ yet removed from the database: + # id = post.author.id + # Author.find_by_id(id).nil? # => false + # + # post.save + # post.reload.author # => nil + # + # Now it _is_ removed from the database: + # Author.find_by_id(id).nil? # => true + # + # === One-to-many Example + # + # Consider a Post model with many Comments: + # + # class Post + # has_many :comments, :autosave => true + # end + # + # Saving changes to the parent and its associated model can now be performed + # automatically _and_ atomically: + # + # post = Post.find(1) + # post.title # => "The current global position of migrating ducks" + # post.comments.first.body # => "Wow, awesome info thanks!" + # post.comments.last.body # => "Actually, your article should be named differently." + # + # post.title = "On the migration of ducks" + # post.comments.last.body = "Actually, your article should be named differently. [UPDATED]: You are right, thanks." + # + # post.save + # post.reload + # post.title # => "On the migration of ducks" + # post.comments.last.body # => "Actually, your article should be named differently. [UPDATED]: You are right, thanks." + # + # Destroying one of the associated models members, 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 + # + # Validation is performed on the parent as usual, but also on all autosave + # enabled associations. If any of the associations fail validation, its + # error messages will be applied on the parents errors object and validation + # of the parent will fail. + # + # Consider a Post model with Author which validates the presence of its name + # attribute: + # + # class Post + # has_one :author, :autosave => true + # end + # + # class Author + # validates_presence_of :name + # end + # + # post = Post.find(1) + # post.author.name = '' + # post.save # => false + # post.errors # => #<ActiveRecord::Errors:0x174498c @errors={"author_name"=>["can't be blank"]}, @base=#<Post ...>> + # + # No validations will be performed on the associated models when validations + # are skipped for the parent: + # + # post = Post.find(1) + # post.author.name = '' + # post.save(false) # => true + module AutosaveAssociation + ASSOCIATION_TYPES = %w{ has_one belongs_to has_many has_and_belongs_to_many } + + def self.included(base) + base.class_eval do + base.extend(ClassMethods) + alias_method_chain :reload, :autosave_associations + + ASSOCIATION_TYPES.each do |type| + base.send("valid_keys_for_#{type}_association") << :autosave + end + end + end + + module ClassMethods + private + + # def belongs_to(name, options = {}) + # super + # add_autosave_association_callbacks(reflect_on_association(name)) + # end + ASSOCIATION_TYPES.each do |type| + module_eval %{ + def #{type}(name, options = {}) + super + add_autosave_association_callbacks(reflect_on_association(name)) + end + } + end + + # Adds a validate and save callback for the association as specified by + # the +reflection+. + def add_autosave_association_callbacks(reflection) + save_method = "autosave_associated_records_for_#{reflection.name}" + validation_method = "validate_associated_records_for_#{reflection.name}" + validate validation_method + + case reflection.macro + when :has_many, :has_and_belongs_to_many + before_save :before_save_collection_association + + define_method(save_method) { 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 + + define_method(validation_method) { validate_collection_association(reflection) } + else + case reflection.macro + when :has_one + define_method(save_method) { save_has_one_association(reflection) } + after_save save_method + when :belongs_to + define_method(save_method) { save_belongs_to_association(reflection) } + before_save save_method + end + define_method(validation_method) { validate_single_association(reflection) } + end + end + end + + # Reloads the attributes of the object as usual and removes a mark for destruction. + def reload_with_autosave_associations(options = nil) + @marked_for_destruction = false + reload_without_autosave_associations(options) + end + + # Marks this record to be destroyed as part of the parents save transaction. + # This does _not_ actually destroy the record yet, rather it will be destroyed when <tt>parent.save</tt> is called. + # + # Only useful if the <tt>:autosave</tt> 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 <tt>:autosave</tt> option on the parent is enabled for this associated model. + def marked_for_destruction? + @marked_for_destruction + 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 + elsif association.loaded? + autosave ? association : association.select { |record| record.new_record? } + else + autosave ? association.target : association.target.select { |record| record.new_record? } + end + end + + # Validate the association if <tt>:validate</tt> or <tt>:autosave</tt> is + # turned on for the association specified by +reflection+. + def validate_single_association(reflection) + if reflection.options[:validate] == true || reflection.options[:autosave] == true + if (association = association_instance_get(reflection.name)) && !association.target.nil? + association_valid?(reflection, association) + end + end + end + + # Validate the associated records if <tt>:validate</tt> or + # <tt>:autosave</tt> is turned on for the association specified by + # +reflection+. + def validate_collection_association(reflection) + if reflection.options[:validate] != false && 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, <tt>self</tt>, if it wasn't. Skips any <tt>:autosave</tt> + # enabled records if they're marked_for_destruction?. + def association_valid?(reflection, association) + unless valid = association.valid? + if reflection.options[:autosave] + unless association.marked_for_destruction? + association.errors.each do |attribute, message| + attribute = "#{reflection.name}_#{attribute}" + errors.add(attribute, message) unless errors.on(attribute) + end + 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 + # <tt>:autosave</tt> 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 + # ActiveRecord::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| + if autosave && record.marked_for_destruction? + association.destroy(record) + elsif @new_record_before_save || record.new_record? + if autosave + association.send(:insert_record, record, false, false) + else + association.send(:insert_record, record) + end + elsif autosave + record.save(false) + end + end + end + + # reconstruct the SQL queries now that we know the owner's id + association.send(:construct_sql) if association.respond_to?(:construct_sql) + end + end + + # Saves the associated record if it's new or <tt>:autosave</tt> is enabled + # on the association. + # + # In addition, it will destroy the association if it was marked for + # destruction with mark_for_destruction. + # + # This all happens inside a transaction, _if_ the Transactions module is included into + # ActiveRecord::Base after the AutosaveAssociation module, which it does by default. + def save_has_one_association(reflection) + if (association = association_instance_get(reflection.name)) && !association.target.nil? + if reflection.options[:autosave] && association.marked_for_destruction? + association.destroy + elsif new_record? || association.new_record? || association[reflection.primary_key_name] != id || reflection.options[:autosave] + association[reflection.primary_key_name] = id + association.save(false) + end + end + end + + # Saves the associated record if it's new or <tt>:autosave</tt> is enabled + # on the association. + # + # In addition, it will destroy the association if it was marked for + # destruction with mark_for_destruction. + # + # This all happens inside a transaction, _if_ the Transactions module is included into + # ActiveRecord::Base after the AutosaveAssociation module, which it does by default. + def save_belongs_to_association(reflection) + if association = association_instance_get(reflection.name) + if reflection.options[:autosave] && association.marked_for_destruction? + association.destroy + else + association.save(false) if association.new_record? || reflection.options[:autosave] + + if association.updated? + self[reflection.primary_key_name] = association.id + # TODO: Removing this code doesn't seem to matter… + if reflection.options[:polymorphic] + self[reflection.options[:foreign_type]] = association.class.base_class.name.to_s + end + end + end + end + end + end +end
\ No newline at end of file |