From ec8f04584479aff895b0b511a7ba1e9d33f84067 Mon Sep 17 00:00:00 2001 From: Eloy Duran Date: Sun, 1 Feb 2009 14:44:30 +1300 Subject: Add support for nested object forms to ActiveRecord and the helpers in ActionPack Signed-Off-By: Michael Koziarski [#1202 state:committed] --- .../lib/active_record/autosave_association.rb | 213 +++++++++++++++++++++ 1 file changed, 213 insertions(+) create mode 100644 activerecord/lib/active_record/autosave_association.rb (limited to 'activerecord/lib/active_record/autosave_association.rb') diff --git a/activerecord/lib/active_record/autosave_association.rb b/activerecord/lib/active_record/autosave_association.rb new file mode 100644 index 0000000000..07660ebd03 --- /dev/null +++ b/activerecord/lib/active_record/autosave_association.rb @@ -0,0 +1,213 @@ +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 # => #["can't be blank"]}, @base=#> + # + # 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 + def self.included(base) + base.class_eval do + alias_method_chain :reload, :autosave_associations + alias_method_chain :save, :autosave_associations + alias_method_chain :valid?, :autosave_associations + + %w{ has_one belongs_to has_many has_and_belongs_to_many }.each do |type| + base.send("valid_keys_for_#{type}_association") << :autosave + end + end + end + + # Saves the parent, self, and any loaded autosave associations. + # 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_with_autosave_associations(perform_validation = true) + returning(save_without_autosave_associations(perform_validation)) do |valid| + if valid + self.class.reflect_on_all_autosave_associations.each do |reflection| + if (association = association_instance_get(reflection.name)) && association.loaded? + if association.is_a?(Array) + association.proxy_target.each do |child| + child.marked_for_destruction? ? child.destroy : child.save(perform_validation) + end + else + association.marked_for_destruction? ? association.destroy : association.save(perform_validation) + end + end + end + end + end + end + + # Returns whether or not the parent, self, and any loaded autosave associations are valid. + def valid_with_autosave_associations? + if valid_without_autosave_associations? + self.class.reflect_on_all_autosave_associations.all? do |reflection| + if (association = association_instance_get(reflection.name)) && association.loaded? + if association.is_a?(Array) + association.proxy_target.all? { |child| autosave_association_valid?(reflection, child) } + else + autosave_association_valid?(reflection, association) + end + else + true # association not loaded yet, so it should be valid + end + end + else + false # self was not valid + end + end + + # Returns whether or not the association is valid and applies any errors to the parent, self, if it wasn't. + def autosave_association_valid?(reflection, association) + returning(association.valid?) do |valid| + association.errors.each do |attribute, message| + errors.add "#{reflection.name}_#{attribute}", message + end unless valid + 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 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 + end +end \ No newline at end of file -- cgit v1.2.3