Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP
Browse files

Add support for nested object forms to ActiveRecord and the helpers i…

…n ActionPack

Signed-Off-By: Michael Koziarski <michael@koziarski.com>

[#1202 state:committed]
  • Loading branch information...
commit ec8f04584479aff895b0b511a7ba1e9d33f84067 1 parent a02d752
@alloy alloy authored NZKoz committed
View
12 actionpack/CHANGELOG
@@ -1,5 +1,17 @@
*2.3.0 [Edge]*
+* Make the form_for and fields_for helpers support the new Active Record nested update options. #1202 [Eloy Duran]
+
+ <% form_for @person do |person_form| %>
+ ...
+ <% person_form.fields_for :projects do |project_fields| %>
+ <% if project_fields.object.active? %>
+ Name: <%= project_fields.text_field :name %>
+ <% end %>
+ <% end %>
+ <% end %>
+
+
* Added grouped_options_for_select helper method for wrapping option tags in optgroups. #977 [Jon Crawford]
* Implement HTTP Digest authentication. #1230 [Gregg Kellogg, Pratik Naik] Example :
View
196 actionpack/lib/action_view/helpers/form_helper.rb
@@ -269,10 +269,12 @@ def apply_form_for_options!(object_or_array, options) #:nodoc:
options[:url] ||= polymorphic_path(object_or_array)
end
- # Creates a scope around a specific model object like form_for, but doesn't create the form tags themselves. This makes
- # fields_for suitable for specifying additional model objects in the same form:
+ # Creates a scope around a specific model object like form_for, but
+ # doesn't create the form tags themselves. This makes fields_for suitable
+ # for specifying additional model objects in the same form.
+ #
+ # === Generic Examples
#
- # ==== Examples
# <% form_for @person, :url => { :action => "update" } do |person_form| %>
# First name: <%= person_form.text_field :first_name %>
# Last name : <%= person_form.text_field :last_name %>
@@ -282,20 +284,166 @@ def apply_form_for_options!(object_or_array, options) #:nodoc:
# <% end %>
# <% end %>
#
- # ...or if you have an object that needs to be represented as a different parameter, like a Client that acts as a Person:
+ # ...or if you have an object that needs to be represented as a different
+ # parameter, like a Client that acts as a Person:
#
# <% fields_for :person, @client do |permission_fields| %>
# Admin?: <%= permission_fields.check_box :admin %>
# <% end %>
#
- # ...or if you don't have an object, just a name of the parameter
+ # ...or if you don't have an object, just a name of the parameter:
#
# <% fields_for :person do |permission_fields| %>
# Admin?: <%= permission_fields.check_box :admin %>
# <% end %>
#
- # Note: This also works for the methods in FormOptionHelper and DateHelper that are designed to work with an object as base,
- # like FormOptionHelper#collection_select and DateHelper#datetime_select.
+ # Note: This also works for the methods in FormOptionHelper and
+ # DateHelper that are designed to work with an object as base, like
+ # FormOptionHelper#collection_select and DateHelper#datetime_select.
+ #
+ # === Nested Attributes Examples
+ #
+ # When the object belonging to the current scope has a nested attribute
+ # writer for a certain attribute, fields_for will yield a new scope
+ # for that attribute. This allows you to create forms that set or change
+ # the attributes of a parent object and its associations in one go.
+ #
+ # Nested attribute writers are normal setter methods named after an
+ # association. The most common way of defining these writers is either
+ # with +accepts_nested_attributes_for+ in a model definition or by
+ # defining a method with the proper name. For example: the attribute
+ # writer for the association <tt>:address</tt> is called
+ # <tt>address_attributes=</tt>.
+ #
+ # Whether a one-to-one or one-to-many style form builder will be yielded
+ # depends on whether the normal reader method returns a _single_ object
+ # or an _array_ of objects.
+ #
+ # ==== One-to-one
+ #
+ # Consider a Person class which returns a _single_ Address from the
+ # <tt>address</tt> reader method and responds to the
+ # <tt>address_attributes=</tt> writer method:
+ #
+ # class Person
+ # def address
+ # @address
+ # end
+ #
+ # def address_attributes=(attributes)
+ # # Process the attributes hash
+ # end
+ # end
+ #
+ # This model can now be used with a nested fields_for, like so:
+ #
+ # <% form_for @person, :url => { :action => "update" } do |person_form| %>
+ # ...
+ # <% person_form.fields_for :address do |address_fields| %>
+ # Street : <%= address_fields.text_field :street %>
+ # Zip code: <%= address_fields.text_field :zip_code %>
+ # <% end %>
+ # <% end %>
+ #
+ # When address is already an association on a Person you can use
+ # +accepts_nested_attributes_for+ to define the writer method for you:
+ #
+ # class Person < ActiveRecord::Base
+ # has_one :address
+ # accepts_nested_attributes_for :address
+ # end
+ #
+ # If you want to destroy the associated model through the form, you have
+ # to enable it first using the <tt>:allow_destroy</tt> option for
+ # +accepts_nested_attributes_for+:
+ #
+ # class Person < ActiveRecord::Base
+ # has_one :address
+ # accepts_nested_attributes_for :address, :allow_destroy => true
+ # end
+ #
+ # Now, when you use a form element with the <tt>_delete</tt> parameter,
+ # with a value that evaluates to +true+, you will destroy the associated
+ # model (eg. 1, '1', true, or 'true'):
+ #
+ # <% form_for @person, :url => { :action => "update" } do |person_form| %>
+ # ...
+ # <% person_form.fields_for :address do |address_fields| %>
+ # ...
+ # Delete: <%= address_fields.check_box :_delete %>
+ # <% end %>
+ # <% end %>
+ #
+ # ==== One-to-many
+ #
+ # Consider a Person class which returns an _array_ of Project instances
+ # from the <tt>projects</tt> reader method and responds to the
+ # <tt>projects_attributes=</tt> writer method:
+ #
+ # class Person
+ # def projects
+ # [@project1, @project2]
+ # end
+ #
+ # def projects_attributes=(attributes)
+ # # Process the attributes hash
+ # end
+ # end
+ #
+ # This model can now be used with a nested fields_for. The block given to
+ # the nested fields_for call will be repeated for each instance in the
+ # collection:
+ #
+ # <% form_for @person, :url => { :action => "update" } do |person_form| %>
+ # ...
+ # <% person_form.fields_for :projects do |project_fields| %>
+ # <% if project_fields.object.active? %>
+ # Name: <%= project_fields.text_field :name %>
+ # <% end %>
+ # <% end %>
+ # <% end %>
+ #
+ # It's also possible to specify the instance to be used:
+ #
+ # <% form_for @person, :url => { :action => "update" } do |person_form| %>
+ # ...
+ # <% @person.projects.each do |project| %>
+ # <% if project.active? %>
+ # <% person_form.fields_for :projects, project do |project_fields| %>
+ # Name: <%= project_fields.text_field :name %>
+ # <% end %>
+ # <% end %>
+ # <% end %>
+ # <% end %>
+ #
+ # When projects is already an association on Person you can use
+ # +accepts_nested_attributes_for+ to define the writer method for you:
+ #
+ # class Person < ActiveRecord::Base
+ # has_many :projects
+ # accepts_nested_attributes_for :projects
+ # end
+ #
+ # If you want to destroy any of the associated models through the
+ # form, you have to enable it first using the <tt>:allow_destroy</tt>
+ # option for +accepts_nested_attributes_for+:
+ #
+ # class Person < ActiveRecord::Base
+ # has_many :projects
+ # accepts_nested_attributes_for :projects, :allow_destroy => true
+ # end
+ #
+ # This will allow you to specify which models to destroy in the
+ # attributes hash by adding a form element for the <tt>_delete</tt>
+ # parameter with a value that evaluates to +true+
+ # (eg. 1, '1', true, or 'true'):
+ #
+ # <% form_for @person, :url => { :action => "update" } do |person_form| %>
+ # ...
+ # <% person_form.fields_for :projects do |project_fields| %>
+ # Delete: <%= project_fields.check_box :_delete %>
+ # <% end %>
+ # <% end %>
def fields_for(record_or_name_or_array, *args, &block)
raise ArgumentError, "Missing block" unless block_given?
options = args.extract_options!
@@ -760,7 +908,11 @@ def fields_for(record_or_name_or_array, *args, &block)
case record_or_name_or_array
when String, Symbol
- name = "#{object_name}#{index}[#{record_or_name_or_array}]"
+ if nested_attributes_association?(record_or_name_or_array)
+ return fields_for_with_nested_attributes(record_or_name_or_array, args, block)
+ else
+ name = "#{object_name}#{index}[#{record_or_name_or_array}]"
+ end
when Array
object = record_or_name_or_array.last
name = "#{object_name}#{index}[#{ActionController::RecordIdentifier.singular_class_name(object)}]"
@@ -802,6 +954,32 @@ def submit(value = "Save changes", options = {})
def objectify_options(options)
@default_options.merge(options.merge(:object => @object))
end
+
+ def nested_attributes_association?(association_name)
+ @object.respond_to?("#{association_name}_attributes=")
+ end
+
+ def fields_for_with_nested_attributes(association_name, args, block)
+ name = "#{object_name}[#{association_name}_attributes]"
+ association = @object.send(association_name)
+
+ if association.is_a?(Array)
+ children = args.first.respond_to?(:new_record?) ? [args.first] : association
+
+ children.map do |child|
+ child_name = "#{name}[#{ child.new_record? ? new_child_id : child.id }]"
+ @template.fields_for(child_name, child, *args, &block)
+ end.join
+ else
+ @template.fields_for(name, association, *args, &block)
+ end
+ end
+
+ def new_child_id
+ value = (@child_counter ||= 1)
+ @child_counter += 1
+ "new_#{value}"
+ end
end
end
@@ -809,4 +987,4 @@ class Base
cattr_accessor :default_form_builder
self.default_form_builder = ::ActionView::Helpers::FormBuilder
end
-end
+end
View
141 actionpack/test/template/form_helper_test.rb
@@ -15,21 +15,31 @@ def new_record=(boolean)
def new_record?
@new_record
end
+
+ attr_accessor :author
+ def author_attributes=(attributes); end
+
+ attr_accessor :comments
+ def comments_attributes=(attributes); end
end
class Comment
attr_reader :id
attr_reader :post_id
+ def initialize(id = nil, post_id = nil); @id, @post_id = id, post_id end
def save; @id = 1; @post_id = 1 end
def new_record?; @id.nil? end
def to_param; @id; end
def name
- @id.nil? ? 'new comment' : "comment ##{@id}"
+ @id.nil? ? "new #{self.class.name.downcase}" : "#{self.class.name.downcase} ##{@id}"
end
end
-end
-class Comment::Nested < Comment; end
+ class Author < Comment
+ attr_accessor :post
+ def post_attributes=(attributes); end
+ end
+end
class FormHelperTest < ActionView::TestCase
tests ActionView::Helpers::FormHelper
@@ -479,7 +489,7 @@ def test_nested_fields_for_with_index_and_parent_fields
assert_dom_equal expected, output_buffer
end
- def test_nested_fields_for_with_index
+ def test_form_for_with_index_and_nested_fields_for
form_for(:post, @post, :index => 1) do |f|
f.fields_for(:comment, @post) do |c|
concat c.text_field(:title)
@@ -558,6 +568,127 @@ def test_nested_fields_for_with_index_and_auto_index
assert_dom_equal expected, output_buffer
end
+ def test_nested_fields_for_with_a_new_record_on_a_nested_attributes_one_to_one_association
+ @post.author = Author.new
+
+ form_for(:post, @post) do |f|
+ concat f.text_field(:title)
+ f.fields_for(:author) do |af|
+ concat af.text_field(:name)
+ end
+ end
+
+ expected = '<form action="http://www.example.com" method="post">' +
+ '<input name="post[title]" size="30" type="text" id="post_title" value="Hello World" />' +
+ '<input id="post_author_attributes_name" name="post[author_attributes][name]" size="30" type="text" value="new author" />' +
+ '</form>'
+
+ assert_dom_equal expected, output_buffer
+ end
+
+ def test_nested_fields_for_with_an_existing_record_on_a_nested_attributes_one_to_one_association
+ @post.author = Author.new(321)
+
+ form_for(:post, @post) do |f|
+ concat f.text_field(:title)
+ f.fields_for(:author) do |af|
+ concat af.text_field(:name)
+ end
+ end
+
+ expected = '<form action="http://www.example.com" method="post">' +
+ '<input name="post[title]" size="30" type="text" id="post_title" value="Hello World" />' +
+ '<input id="post_author_attributes_name" name="post[author_attributes][name]" size="30" type="text" value="author #321" />' +
+ '</form>'
+
+ assert_dom_equal expected, output_buffer
+ end
+
+ def test_nested_fields_for_with_existing_records_on_a_nested_attributes_collection_association
+ @post.comments = Array.new(2) { |id| Comment.new(id + 1) }
+
+ form_for(:post, @post) do |f|
+ concat f.text_field(:title)
+ @post.comments.each do |comment|
+ f.fields_for(:comments, comment) do |cf|
+ concat cf.text_field(:name)
+ end
+ end
+ end
+
+ expected = '<form action="http://www.example.com" method="post">' +
+ '<input name="post[title]" size="30" type="text" id="post_title" value="Hello World" />' +
+ '<input id="post_comments_attributes_1_name" name="post[comments_attributes][1][name]" size="30" type="text" value="comment #1" />' +
+ '<input id="post_comments_attributes_2_name" name="post[comments_attributes][2][name]" size="30" type="text" value="comment #2" />' +
+ '</form>'
+
+ assert_dom_equal expected, output_buffer
+ end
+
+ def test_nested_fields_for_with_new_records_on_a_nested_attributes_collection_association
+ @post.comments = [Comment.new, Comment.new]
+
+ form_for(:post, @post) do |f|
+ concat f.text_field(:title)
+ @post.comments.each do |comment|
+ f.fields_for(:comments, comment) do |cf|
+ concat cf.text_field(:name)
+ end
+ end
+ end
+
+ expected = '<form action="http://www.example.com" method="post">' +
+ '<input name="post[title]" size="30" type="text" id="post_title" value="Hello World" />' +
+ '<input id="post_comments_attributes_new_1_name" name="post[comments_attributes][new_1][name]" size="30" type="text" value="new comment" />' +
+ '<input id="post_comments_attributes_new_2_name" name="post[comments_attributes][new_2][name]" size="30" type="text" value="new comment" />' +
+ '</form>'
+
+ assert_dom_equal expected, output_buffer
+ end
+
+ def test_nested_fields_for_with_existing_and_new_records_on_a_nested_attributes_collection_association
+ @post.comments = [Comment.new(321), Comment.new]
+
+ form_for(:post, @post) do |f|
+ concat f.text_field(:title)
+ @post.comments.each do |comment|
+ f.fields_for(:comments, comment) do |cf|
+ concat cf.text_field(:name)
+ end
+ end
+ end
+
+ expected = '<form action="http://www.example.com" method="post">' +
+ '<input name="post[title]" size="30" type="text" id="post_title" value="Hello World" />' +
+ '<input id="post_comments_attributes_321_name" name="post[comments_attributes][321][name]" size="30" type="text" value="comment #321" />' +
+ '<input id="post_comments_attributes_new_1_name" name="post[comments_attributes][new_1][name]" size="30" type="text" value="new comment" />' +
+ '</form>'
+
+ assert_dom_equal expected, output_buffer
+ end
+
+ def test_nested_fields_for_on_a_nested_attributes_collection_association_yields_only_builder
+ @post.comments = [Comment.new(321), Comment.new]
+ yielded_comments = []
+
+ form_for(:post, @post) do |f|
+ concat f.text_field(:title)
+ f.fields_for(:comments) do |cf|
+ concat cf.text_field(:name)
+ yielded_comments << cf.object
+ end
+ end
+
+ expected = '<form action="http://www.example.com" method="post">' +
+ '<input name="post[title]" size="30" type="text" id="post_title" value="Hello World" />' +
+ '<input id="post_comments_attributes_321_name" name="post[comments_attributes][321][name]" size="30" type="text" value="comment #321" />' +
+ '<input id="post_comments_attributes_new_1_name" name="post[comments_attributes][new_1][name]" size="30" type="text" value="new comment" />' +
+ '</form>'
+
+ assert_dom_equal expected, output_buffer
+ assert_equal yielded_comments, @post.comments
+ end
+
def test_fields_for
fields_for(:post, @post) do |f|
concat f.text_field(:title)
@@ -974,4 +1105,4 @@ def post_path(post)
def protect_against_forgery?
false
end
-end
+end
View
9 activerecord/CHANGELOG
@@ -1,5 +1,14 @@
*2.3.0/3.0*
+* Add Support for updating deeply nested models from a single form. #1202 [Eloy Duran]
+
+ class Book < ActiveRecord::Base
+ has_one :author
+ has_many :pages
+
+ accepts_nested_attributes_for :author, :pages
+ end
+
* Make after_save callbacks fire only if the record was successfully saved. #1735 [Michael Lovitt]
Previously the callbacks would fire if a before_save cancelled saving.
View
2  activerecord/lib/active_record.rb
@@ -46,6 +46,7 @@ def self.load_all!
autoload :AssociationPreload, 'active_record/association_preload'
autoload :Associations, 'active_record/associations'
autoload :AttributeMethods, 'active_record/attribute_methods'
+ autoload :AutosaveAssociation, 'active_record/autosave_association'
autoload :Base, 'active_record/base'
autoload :Calculations, 'active_record/calculations'
autoload :Callbacks, 'active_record/callbacks'
@@ -55,6 +56,7 @@ def self.load_all!
autoload :Migration, 'active_record/migration'
autoload :Migrator, 'active_record/migration'
autoload :NamedScope, 'active_record/named_scope'
+ autoload :NestedAttributes, 'active_record/nested_attributes'
autoload :Observing, 'active_record/observer'
autoload :QueryCache, 'active_record/query_cache'
autoload :Reflection, 'active_record/reflection'
View
91 activerecord/lib/active_record/associations.rb
@@ -88,6 +88,18 @@ def clear_association_cache #:nodoc:
end unless self.new_record?
end
+ private
+ # Gets the specified association instance if it responds to :loaded?, nil otherwise.
+ def association_instance_get(name)
+ association = instance_variable_get("@#{name}")
+ association if association.respond_to?(:loaded?)
+ end
+
+ # Set the specified association instance.
+ def association_instance_set(name, association)
+ instance_variable_set("@#{name}", association)
+ end
+
# Associations are a set of macro-like class methods for tying objects together through foreign keys. They express relationships like
# "Project has one Project Manager" or "Project belongs to a Portfolio". Each macro adds a number of methods to the class which are
# specialized according to the collection or association symbol and the options hash. It works much the same way as Ruby's own <tt>attr*</tt>
@@ -256,6 +268,10 @@ def clear_association_cache #:nodoc:
# You can manipulate objects and associations before they are saved to the database, but there is some special behavior you should be
# aware of, mostly involving the saving of associated objects.
#
+ # Unless you enable the :autosave option on a <tt>has_one</tt>, <tt>belongs_to</tt>,
+ # <tt>has_many</tt>, or <tt>has_and_belongs_to_many</tt> association,
+ # in which case the members are always saved.
+ #
# === One-to-one associations
#
# * Assigning an object to a +has_one+ association automatically saves that object and the object being replaced (if there is one), in
@@ -752,6 +768,9 @@ module ClassMethods
# If true, all the associated objects are readonly through the association.
# [:validate]
# If false, don't validate the associated objects when saving the parent object. true by default.
+ # [:autosave]
+ # If true, always save any loaded members and destroy members marked for destruction, when saving the parent object. Off by default.
+ #
# Option examples:
# has_many :comments, :order => "posted_on"
# has_many :comments, :include => :author
@@ -865,6 +884,8 @@ def has_many(association_id, options = {}, &extension)
# If true, the associated object is readonly through the association.
# [:validate]
# If false, don't validate the associated object when saving the parent object. +false+ by default.
+ # [:autosave]
+ # If true, always save the associated object or destroy it if marked for destruction, when saving the parent object. Off by default.
#
# Option examples:
# has_one :credit_card, :dependent => :destroy # destroys the associated credit card
@@ -882,13 +903,10 @@ def has_one(association_id, options = {})
else
reflection = create_has_one_reflection(association_id, options)
- ivar = "@#{reflection.name}"
-
method_name = "has_one_after_save_for_#{reflection.name}".to_sym
define_method(method_name) do
- association = instance_variable_get(ivar) if instance_variable_defined?(ivar)
-
- if !association.nil? && (new_record? || association.new_record? || association[reflection.primary_key_name] != id)
+ association = association_instance_get(reflection.name)
+ if association && (new_record? || association.new_record? || association[reflection.primary_key_name] != id)
association[reflection.primary_key_name] = id
association.save(true)
end
@@ -979,6 +997,8 @@ def has_one(association_id, options = {})
# If true, the associated object is readonly through the association.
# [:validate]
# If false, don't validate the associated objects when saving the parent object. +false+ by default.
+ # [:autosave]
+ # If true, always save the associated object or destroy it if marked for destruction, when saving the parent object. Off by default.
#
# Option examples:
# belongs_to :firm, :foreign_key => "client_of"
@@ -991,15 +1011,12 @@ def has_one(association_id, options = {})
def belongs_to(association_id, options = {})
reflection = create_belongs_to_reflection(association_id, options)
- ivar = "@#{reflection.name}"
-
if reflection.options[:polymorphic]
association_accessor_methods(reflection, BelongsToPolymorphicAssociation)
method_name = "polymorphic_belongs_to_before_save_for_#{reflection.name}".to_sym
define_method(method_name) do
- association = instance_variable_get(ivar) if instance_variable_defined?(ivar)
-
+ association = association_instance_get(reflection.name)
if association && association.target
if association.new_record?
association.save(true)
@@ -1019,9 +1036,7 @@ def belongs_to(association_id, options = {})
method_name = "belongs_to_before_save_for_#{reflection.name}".to_sym
define_method(method_name) do
- association = instance_variable_get(ivar) if instance_variable_defined?(ivar)
-
- if !association.nil?
+ if association = association_instance_get(reflection.name)
if association.new_record?
association.save(true)
end
@@ -1196,6 +1211,8 @@ def belongs_to(association_id, options = {})
# If true, all the associated objects are readonly through the association.
# [:validate]
# If false, don't validate the associated objects when saving the parent object. +true+ by default.
+ # [:autosave]
+ # If true, always save any loaded members and destroy members marked for destruction, when saving the parent object. Off by default.
#
# Option examples:
# has_and_belongs_to_many :projects
@@ -1243,33 +1260,30 @@ def join_table_name(first_table_name, second_table_name)
end
def association_accessor_methods(reflection, association_proxy_class)
- ivar = "@#{reflection.name}"
-
define_method(reflection.name) do |*params|
force_reload = params.first unless params.empty?
-
- association = instance_variable_get(ivar) if instance_variable_defined?(ivar)
+ association = association_instance_get(reflection.name)
if association.nil? || force_reload
association = association_proxy_class.new(self, reflection)
retval = association.reload
if retval.nil? and association_proxy_class == BelongsToAssociation
- instance_variable_set(ivar, nil)
+ association_instance_set(reflection.name, nil)
return nil
end
- instance_variable_set(ivar, association)
+ association_instance_set(reflection.name, association)
end
association.target.nil? ? nil : association
end
define_method("loaded_#{reflection.name}?") do
- association = instance_variable_get(ivar) if instance_variable_defined?(ivar)
+ association = association_instance_get(reflection.name)
association && association.loaded?
end
define_method("#{reflection.name}=") do |new_value|
- association = instance_variable_get(ivar) if instance_variable_defined?(ivar)
+ association = association_instance_get(reflection.name)
if association.nil? || association.target != new_value
association = association_proxy_class.new(self, reflection)
@@ -1280,7 +1294,7 @@ def association_accessor_methods(reflection, association_proxy_class)
self.send(reflection.name, new_value)
else
association.replace(new_value)
- instance_variable_set(ivar, new_value.nil? ? nil : association)
+ association_instance_set(reflection.name, new_value.nil? ? nil : association)
end
end
@@ -1288,20 +1302,18 @@ def association_accessor_methods(reflection, association_proxy_class)
return if target.nil? and association_proxy_class == BelongsToAssociation
association = association_proxy_class.new(self, reflection)
association.target = target
- instance_variable_set(ivar, association)
+ association_instance_set(reflection.name, association)
end
end
def collection_reader_method(reflection, association_proxy_class)
define_method(reflection.name) do |*params|
- ivar = "@#{reflection.name}"
-
force_reload = params.first unless params.empty?
- association = instance_variable_get(ivar) if instance_variable_defined?(ivar)
+ association = association_instance_get(reflection.name)
- unless association.respond_to?(:loaded?)
+ unless association
association = association_proxy_class.new(self, reflection)
- instance_variable_set(ivar, association)
+ association_instance_set(reflection.name, association)
end
association.reload if force_reload
@@ -1339,8 +1351,7 @@ def collection_accessor_methods(reflection, association_proxy_class, writer = tr
def add_single_associated_validation_callbacks(association_name)
method_name = "validate_associated_records_for_#{association_name}".to_sym
define_method(method_name) do
- association = instance_variable_get("@#{association_name}")
- if !association.nil?
+ if association = association_instance_get(association_name)
errors.add association_name unless association.target.nil? || association.valid?
end
end
@@ -1350,12 +1361,10 @@ def add_single_associated_validation_callbacks(association_name)
def add_multiple_associated_validation_callbacks(association_name)
method_name = "validate_associated_records_for_#{association_name}".to_sym
- ivar = "@#{association_name}"
-
define_method(method_name) do
- association = instance_variable_get(ivar) if instance_variable_defined?(ivar)
+ association = association_instance_get(association_name)
- if association.respond_to?(:loaded?)
+ if association
if new_record?
association
elsif association.loaded?
@@ -1372,8 +1381,6 @@ def add_multiple_associated_validation_callbacks(association_name)
end
def add_multiple_associated_save_callbacks(association_name)
- ivar = "@#{association_name}"
-
method_name = "before_save_associated_records_for_#{association_name}".to_sym
define_method(method_name) do
@new_record_before_save = new_record?
@@ -1383,13 +1390,13 @@ def add_multiple_associated_save_callbacks(association_name)
method_name = "after_create_or_update_associated_records_for_#{association_name}".to_sym
define_method(method_name) do
- association = instance_variable_get(ivar) if instance_variable_defined?(ivar)
+ association = association_instance_get(association_name)
records_to_save = if @new_record_before_save
association
- elsif association.respond_to?(:loaded?) && association.loaded?
+ elsif association && association.loaded?
association.select { |record| record.new_record? }
- elsif association.respond_to?(:loaded?) && !association.loaded?
+ elsif association && !association.loaded?
association.target.select { |record| record.new_record? }
else
[]
@@ -1407,15 +1414,13 @@ def add_multiple_associated_save_callbacks(association_name)
def association_constructor_method(constructor, reflection, association_proxy_class)
define_method("#{constructor}_#{reflection.name}") do |*params|
- ivar = "@#{reflection.name}"
-
attributees = params.first unless params.empty?
replace_existing = params[1].nil? ? true : params[1]
- association = instance_variable_get(ivar) if instance_variable_defined?(ivar)
+ association = association_instance_get(reflection.name)
- if association.nil?
+ unless association
association = association_proxy_class.new(self, reflection)
- instance_variable_set(ivar, association)
+ association_instance_set(reflection.name, association)
end
if association_proxy_class == HasOneAssociation
View
213 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 # => #<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
+ 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, <tt>self</tt>, 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, <tt>self</tt>, 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, <tt>self</tt>, 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 <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
+ end
+end
View
5 activerecord/lib/active_record/base.rb
@@ -3136,6 +3136,11 @@ def clone_attribute_value(reader_method, attribute_name)
include Dirty
include Callbacks, Observing, Timestamp
include Associations, AssociationPreload, NamedScope
+
+ # AutosaveAssociation needs to be included before Transactions, because we want
+ # #save_with_autosave_associations to be wrapped inside a transaction.
+ include AutosaveAssociation, NestedAttributes
+
include Aggregations, Transactions, Reflection, Calculations, Serialization
end
end
View
279 activerecord/lib/active_record/nested_attributes.rb
@@ -0,0 +1,279 @@
+module ActiveRecord
+ module NestedAttributes #:nodoc:
+ def self.included(base)
+ base.extend(ClassMethods)
+ base.class_inheritable_accessor :reject_new_nested_attributes_procs, :instance_writer => false
+ base.reject_new_nested_attributes_procs = {}
+ end
+
+ # == Nested Attributes
+ #
+ # Nested attributes allow you to save attributes on associated records
+ # through the parent. By default nested attribute updating is turned off,
+ # you can enable it using the accepts_nested_attributes_for class method.
+ # When you enable nested attributes an attribute writer is defined on
+ # the model.
+ #
+ # The attribute writer is named after the association, which means that
+ # in the following example, two new methods are added to your model:
+ # <tt>author_attributes=(attributes)</tt> and
+ # <tt>pages_attributes=(attributes)</tt>.
+ #
+ # class Book < ActiveRecord::Base
+ # has_one :author
+ # has_many :pages
+ #
+ # accepts_nested_attributes_for :author, :pages
+ # end
+ #
+ # Note that the <tt>:autosave</tt> option is automatically enabled on every
+ # association that accepts_nested_attributes_for is used for.
+ #
+ # === One-to-one
+ #
+ # Consider a Member model that has one Avatar:
+ #
+ # class Member < ActiveRecord::Base
+ # has_one :avatar
+ # accepts_nested_attributes_for :avatar
+ # end
+ #
+ # Enabling nested attributes on a one-to-one association allows you to
+ # create the member and avatar in one go:
+ #
+ # params = { 'member' => { 'name' => 'Jack', 'avatar_attributes' => { 'icon' => 'smiling' } } }
+ # member = Member.create(params)
+ # member.avatar.icon #=> 'smiling'
+ #
+ # It also allows you to update the avatar through the member:
+ #
+ # params = { 'member' => { 'avatar_attributes' => { 'icon' => 'sad' } } }
+ # member.update_attributes params['member']
+ # member.avatar.icon #=> 'sad'
+ #
+ # By default you will only be able to set and update attributes on the
+ # associated model. If you want to destroy the associated model through the
+ # attributes hash, you have to enable it first using the
+ # <tt>:allow_destroy</tt> option.
+ #
+ # class Member < ActiveRecord::Base
+ # has_one :avatar
+ # accepts_nested_attributes_for :avatar, :allow_destroy => true
+ # end
+ #
+ # Now, when you add the <tt>_delete</tt> key to the attributes hash, with a
+ # value that evaluates to +true+, you will destroy the associated model:
+ #
+ # member.avatar_attributes = { '_delete' => '1' }
+ # member.avatar.marked_for_destruction? # => true
+ # member.save
+ # member.avatar #=> nil
+ #
+ # Note that the model will _not_ be destroyed until the parent is saved.
+ #
+ # === One-to-many
+ #
+ # Consider a member that has a number of posts:
+ #
+ # class Member < ActiveRecord::Base
+ # has_many :posts
+ # accepts_nested_attributes_for :posts, :reject_if => proc { |attributes| attributes['title'].blank? }
+ # end
+ #
+ # You can now set or update attributes on an associated post model through
+ # the attribute hash.
+ #
+ # For each key in the hash that starts with the string 'new' a new model
+ # will be instantiated. When the proc given with the <tt>:reject_if</tt>
+ # option evaluates to +false+ for a certain attribute hash no record will
+ # be built for that hash.
+ #
+ # params = { 'member' => {
+ # 'name' => 'joe', 'posts_attributes' => {
+ # 'new_12345' => { 'title' => 'Kari, the awesome Ruby documentation browser!' },
+ # 'new_54321' => { 'title' => 'The egalitarian assumption of the modern citizen' },
+ # 'new_67890' => { 'title' => '' } # This one matches the :reject_if proc and will not be instantiated.
+ # }
+ # }}
+ #
+ # member = Member.create(params['member'])
+ # member.posts.length #=> 2
+ # member.posts.first.title #=> 'Kari, the awesome Ruby documentation browser!'
+ # member.posts.second.title #=> 'The egalitarian assumption of the modern citizen'
+ #
+ # When the key for post attributes is an integer, the associated post with
+ # that ID will be updated:
+ #
+ # member.attributes = {
+ # 'name' => 'Joe',
+ # 'posts_attributes' => {
+ # '1' => { 'title' => '[UPDATED] An, as of yet, undisclosed awesome Ruby documentation browser!' },
+ # '2' => { 'title' => '[UPDATED] other post' }
+ # }
+ # }
+ #
+ # By default the associated models are protected from being destroyed. If
+ # you want to destroy any of the associated models through the attributes
+ # hash, you have to enable it first using the <tt>:allow_destroy</tt>
+ # option.
+ #
+ # This will allow you to specify which models to destroy in the attributes
+ # hash by setting the '_delete' attribute to a value that evaluates to
+ # +true+:
+ #
+ # class Member < ActiveRecord::Base
+ # has_many :posts
+ # accepts_nested_attributes_for :posts, :allow_destroy => true
+ # end
+ #
+ # params = {'member' => { 'name' => 'joe', 'posts_attributes' => {
+ # '2' => { '_delete' => '1' }
+ # }}}
+ # member.attributes = params['member']
+ # member.posts.detect { |p| p.id == 2 }.marked_for_destruction? # => true
+ # member.posts.length #=> 2
+ # member.save
+ # member.posts.length # => 1
+ #
+ # === Saving
+ #
+ # All changes to models, including the destruction of those marked for
+ # destruction, are saved and destroyed automatically and atomically when
+ # the parent model is saved. This happens inside the transaction initiated
+ # by the parents save method. See ActiveRecord::AutosaveAssociation.
+ module ClassMethods
+ # Defines an attributes writer for the specified association(s).
+ #
+ # Supported options:
+ # [:allow_destroy]
+ # If true, destroys any members from the attributes hash with a
+ # <tt>_delete</tt> key and a value that converts to +true+
+ # (eg. 1, '1', true, or 'true'). This option is off by default.
+ # [:reject_if]
+ # Allows you to specify a Proc that checks whether a record should be
+ # built for a certain attribute hash. The hash is passed to the Proc
+ # and the Proc should return either +true+ or +false+. When no Proc
+ # is specified a record will be built for all attribute hashes.
+ #
+ # Examples:
+ # accepts_nested_attributes_for :avatar
+ # accepts_nested_attributes_for :avatar, :allow_destroy => true
+ # accepts_nested_attributes_for :avatar, :reject_if => proc { ... }
+ # accepts_nested_attributes_for :avatar, :posts, :allow_destroy => true, :reject_if => proc { ... }
+ def accepts_nested_attributes_for(*attr_names)
+ options = { :allow_destroy => false }
+ options.update(attr_names.extract_options!)
+ options.assert_valid_keys(:allow_destroy, :reject_if)
+
+ attr_names.each do |association_name|
+ if reflection = reflect_on_association(association_name)
+ type = case reflection.macro
+ when :has_one, :belongs_to
+ :one_to_one
+ when :has_many, :has_and_belongs_to_many
+ :collection
+ end
+
+ reflection.options[:autosave] = true
+ self.reject_new_nested_attributes_procs[association_name.to_sym] = options[:reject_if]
+
+ # def pirate_attributes=(attributes)
+ # assign_nested_attributes_for_one_to_one_association(:pirate, attributes, false)
+ # end
+ class_eval %{
+ def #{association_name}_attributes=(attributes)
+ assign_nested_attributes_for_#{type}_association(:#{association_name}, attributes, #{options[:allow_destroy]})
+ end
+ }, __FILE__, __LINE__
+ else
+ raise ArgumentError, "No association found for name `#{association_name}'. Has it been defined yet?"
+ end
+ end
+ end
+ end
+
+ # Returns ActiveRecord::AutosaveAssociation::marked_for_destruction?
+ # It's used in conjunction with fields_for to build a form element
+ # for the destruction of this association.
+ #
+ # See ActionView::Helpers::FormHelper::fields_for for more info.
+ def _delete
+ marked_for_destruction?
+ end
+
+ private
+
+ # Assigns the given attributes to the association. An association will be
+ # build if it doesn't exist yet.
+ def assign_nested_attributes_for_one_to_one_association(association_name, attributes, allow_destroy)
+ if should_destroy_nested_attributes_record?(allow_destroy, attributes)
+ send(association_name).mark_for_destruction
+ else
+ (send(association_name) || send("build_#{association_name}")).attributes = attributes
+ end
+ end
+
+ # Assigns the given attributes to the collection association.
+ #
+ # Keys containing an ID for an associated record will update that record.
+ # Keys starting with <tt>new</tt> will instantiate a new record for that
+ # association.
+ #
+ # For example:
+ #
+ # assign_nested_attributes_for_collection_association(:people, {
+ # '1' => { 'name' => 'Peter' },
+ # 'new_43' => { 'name' => 'John' }
+ # })
+ #
+ # Will update the name of the Person with ID 1 and create a new associated
+ # person with the name 'John'.
+ def assign_nested_attributes_for_collection_association(association_name, attributes, allow_destroy)
+ unless attributes.is_a?(Hash)
+ raise ArgumentError, "Hash expected, got #{attributes.class.name} (#{attributes.inspect})"
+ end
+
+ # Make sure any new records sorted by their id before they're build.
+ sorted_by_id = attributes.sort_by { |id, _| id.is_a?(String) ? id.sub(/^new_/, '').to_i : id }
+
+ sorted_by_id.each do |id, record_attributes|
+ if id.acts_like?(:string) && id.starts_with?('new_')
+ build_new_nested_attributes_record(association_name, record_attributes)
+ else
+ assign_to_or_destroy_nested_attributes_record(association_name, id, record_attributes, allow_destroy)
+ end
+ end
+ end
+
+ # Returns +true+ if <tt>allow_destroy</tt> is enabled and the attributes
+ # contains a truthy value for the key <tt>'_delete'</tt>.
+ #
+ # It will _always_ remove the <tt>'_delete'</tt> key, if present.
+ def should_destroy_nested_attributes_record?(allow_destroy, attributes)
+ ConnectionAdapters::Column.value_to_boolean(attributes.delete('_delete')) && allow_destroy
+ end
+
+ # Builds a new record with the given attributes.
+ #
+ # If a <tt>:reject_if</tt> proc exists for this association, it will be
+ # called with the attributes as its argument. If the proc returns a truthy
+ # value, the record is _not_ build.
+ def build_new_nested_attributes_record(association_name, attributes)
+ if reject_proc = self.class.reject_new_nested_attributes_procs[association_name]
+ return if reject_proc.call(attributes)
+ end
+ send(association_name).build(attributes)
+ end
+
+ # Assigns the attributes to the record specified by +id+. Or marks it for
+ # destruction if #should_destroy_nested_attributes_record? returns +true+.
+ def assign_to_or_destroy_nested_attributes_record(association_name, id, attributes, allow_destroy)
+ record = send(association_name).detect { |record| record.id == id.to_i }
+ if should_destroy_nested_attributes_record?(allow_destroy, attributes)
+ record.mark_for_destruction
+ else
+ record.attributes = attributes
+ end
+ end
+ end
+end
View
5 activerecord/lib/active_record/reflection.rb
@@ -65,6 +65,11 @@ def reflect_on_all_associations(macro = nil)
def reflect_on_association(association)
reflections[association].is_a?(AssociationReflection) ? reflections[association] : nil
end
+
+ # Returns an array of AssociationReflection objects for all associations which have <tt>:autosave</tt> enabled.
+ def reflect_on_all_autosave_associations
+ reflections.values.select { |reflection| reflection.options[:autosave] }
+ end
end
View
1  activerecord/lib/active_record/test_case.rb
@@ -27,6 +27,7 @@ def assert_queries(num = 1)
$queries_executed = []
yield
ensure
+ %w{ BEGIN COMMIT }.each { |x| $queries_executed.delete(x) }
assert_equal num, $queries_executed.size, "#{$queries_executed.size} instead of #{num} queries were executed.#{$queries_executed.size == 0 ? '' : "\nQueries:\n#{$queries_executed.join("\n")}"}"
end
View
386 activerecord/test/cases/autosave_association_test.rb
@@ -0,0 +1,386 @@
+require "cases/helper"
+require "models/pirate"
+require "models/ship"
+require "models/ship_part"
+require "models/bird"
+require "models/parrot"
+require "models/treasure"
+
+class TestAutosaveAssociationsInGeneral < ActiveRecord::TestCase
+ def test_autosave_should_be_a_valid_option_for_has_one
+ assert base.valid_keys_for_has_one_association.include?(:autosave)
+ end
+
+ def test_autosave_should_be_a_valid_option_for_belongs_to
+ assert base.valid_keys_for_belongs_to_association.include?(:autosave)
+ end
+
+ def test_autosave_should_be_a_valid_option_for_has_many
+ assert base.valid_keys_for_has_many_association.include?(:autosave)
+ end
+
+ def test_autosave_should_be_a_valid_option_for_has_and_belongs_to_many
+ assert base.valid_keys_for_has_and_belongs_to_many_association.include?(:autosave)
+ end
+
+ private
+
+ def base
+ ActiveRecord::Base
+ end
+end
+
+class TestDestroyAsPartOfAutosaveAssociation < ActiveRecord::TestCase
+ self.use_transactional_fixtures = false
+
+ def setup
+ @pirate = Pirate.create(:catchphrase => "Don' botharrr talkin' like one, savvy?")
+ @ship = @pirate.create_ship(:name => 'Nights Dirty Lightning')
+ end
+
+ # reload
+ def test_a_marked_for_destruction_record_should_not_be_be_marked_after_reload
+ @pirate.mark_for_destruction
+ @pirate.ship.mark_for_destruction
+
+ assert !@pirate.reload.marked_for_destruction?
+ assert !@pirate.ship.marked_for_destruction?
+ end
+
+ # has_one
+ def test_should_destroy_a_child_association_as_part_of_the_save_transaction_if_it_was_marked_for_destroyal
+ assert !@pirate.ship.marked_for_destruction?
+
+ @pirate.ship.mark_for_destruction
+ id = @pirate.ship.id
+
+ assert @pirate.ship.marked_for_destruction?
+ assert Ship.find_by_id(id)
+
+ @pirate.save
+ assert_nil @pirate.reload.ship
+ assert_nil Ship.find_by_id(id)
+ end
+
+ def test_should_rollback_destructions_if_an_exception_occurred_while_saving_a_child
+ # Stub the save method of the @pirate.ship instance to destroy and then raise an exception
+ class << @pirate.ship
+ def save(*args)
+ super
+ destroy
+ raise 'Oh noes!'
+ end
+ end
+
+ assert_raise(RuntimeError) { assert !@pirate.save }
+ assert_not_nil @pirate.reload.ship
+ end
+
+ # belongs_to
+ def test_should_destroy_a_parent_association_as_part_of_the_save_transaction_if_it_was_marked_for_destroyal
+ assert !@ship.pirate.marked_for_destruction?
+
+ @ship.pirate.mark_for_destruction
+ id = @ship.pirate.id
+
+ assert @ship.pirate.marked_for_destruction?
+ assert Pirate.find_by_id(id)
+
+ @ship.save
+ assert_nil @ship.reload.pirate
+ assert_nil Pirate.find_by_id(id)
+ end
+
+ def test_should_rollback_destructions_if_an_exception_occurred_while_saving_a_parent
+ # Stub the save method of the @ship.pirate instance to destroy and then raise an exception
+ class << @ship.pirate
+ def save(*args)
+ super
+ destroy
+ raise 'Oh noes!'
+ end
+ end
+
+ assert_raise(RuntimeError) { assert !@ship.save }
+ assert_not_nil @ship.reload.pirate
+ end
+
+ # has_many & has_and_belongs_to
+ %w{ parrots birds }.each do |association_name|
+ define_method("test_should_destroy_#{association_name}_as_part_of_the_save_transaction_if_they_were_marked_for_destroyal") do
+ 2.times { |i| @pirate.send(association_name).create!(:name => "#{association_name}_#{i}") }
+
+ assert !@pirate.send(association_name).any? { |child| child.marked_for_destruction? }
+
+ @pirate.send(association_name).each { |child| child.mark_for_destruction }
+ klass = @pirate.send(association_name).first.class
+ ids = @pirate.send(association_name).map(&:id)
+
+ assert @pirate.send(association_name).all? { |child| child.marked_for_destruction? }
+ ids.each { |id| assert klass.find_by_id(id) }
+
+ @pirate.save
+ assert @pirate.reload.send(association_name).empty?
+ ids.each { |id| assert_nil klass.find_by_id(id) }
+ end
+
+ define_method("test_should_rollback_destructions_if_an_exception_occurred_while_saving_#{association_name}") do
+ 2.times { |i| @pirate.send(association_name).create!(:name => "#{association_name}_#{i}") }
+ before = @pirate.send(association_name).map { |c| c }
+
+ # Stub the save method of the first child to destroy and the second to raise an exception
+ class << before.first
+ def save(*args)
+ super
+ destroy
+ end
+ end
+ class << before.last
+ def save(*args)
+ super
+ raise 'Oh noes!'
+ end
+ end
+
+ assert_raise(RuntimeError) { assert !@pirate.save }
+ assert_equal before, @pirate.reload.send(association_name)
+ end
+ end
+end
+
+class TestAutosaveAssociationOnAHasOneAssociation < ActiveRecord::TestCase
+ self.use_transactional_fixtures = false
+
+ def setup
+ @pirate = Pirate.create(:catchphrase => "Don' botharrr talkin' like one, savvy?")
+ @ship = @pirate.create_ship(:name => 'Nights Dirty Lightning')
+ end
+
+ def test_should_still_work_without_an_associated_model
+ @ship.destroy
+ @pirate.reload.catchphrase = "Arr"
+ @pirate.save
+ assert 'Arr', @pirate.reload.catchphrase
+ end
+
+ def test_should_automatically_save_the_associated_model
+ @pirate.ship.name = 'The Vile Insanity'
+ @pirate.save
+ assert_equal 'The Vile Insanity', @pirate.reload.ship.name
+ end
+
+ def test_should_automatically_validate_the_associated_model
+ @pirate.ship.name = ''
+ assert !@pirate.valid?
+ assert !@pirate.errors.on(:ship_name).blank?
+ end
+
+ def test_should_still_allow_to_bypass_validations_on_the_associated_model
+ @pirate.catchphrase = ''
+ @pirate.ship.name = ''
+ @pirate.save(false)
+ assert_equal ['', ''], [@pirate.reload.catchphrase, @pirate.ship.name]
+ end
+
+ def test_should_allow_to_bypass_validations_on_associated_models_at_any_depth
+ 2.times { |i| @pirate.ship.parts.create!(:name => "part #{i}") }
+
+ @pirate.catchphrase = ''
+ @pirate.ship.name = ''
+ @pirate.ship.parts.each { |part| part.name = '' }
+ @pirate.save(false)
+
+ values = [@pirate.reload.catchphrase, @pirate.ship.name, *@pirate.ship.parts.map(&:name)]
+ assert_equal ['', '', '', ''], values
+ end
+
+ def test_should_still_raise_an_ActiveRecordRecord_Invalid_exception_if_we_want_that
+ @pirate.ship.name = ''
+ assert_raise(ActiveRecord::RecordInvalid) do
+ @pirate.save!
+ end
+ end
+
+ def test_should_rollback_any_changes_if_an_exception_occurred_while_saving
+ before = [@pirate.catchphrase, @pirate.ship.name]
+
+ @pirate.catchphrase = 'Arr'
+ @pirate.ship.name = 'The Vile Insanity'
+
+ # Stub the save method of the @pirate.ship instance to raise an exception
+ class << @pirate.ship
+ def save(*args)
+ super
+ raise 'Oh noes!'
+ end
+ end
+
+ assert_raise(RuntimeError) { assert !@pirate.save }
+ assert_equal before, [@pirate.reload.catchphrase, @pirate.ship.name]
+ end
+
+ def test_should_not_load_the_associated_model
+ assert_queries(1) { @pirate.catchphrase = 'Arr'; @pirate.save! }
+ end
+end
+
+class TestAutosaveAssociationOnABelongsToAssociation < ActiveRecord::TestCase
+ self.use_transactional_fixtures = false
+
+ def setup
+ @ship = Ship.create(:name => 'Nights Dirty Lightning')
+ @pirate = @ship.create_pirate(:catchphrase => "Don' botharrr talkin' like one, savvy?")
+ end
+
+ def test_should_still_work_without_an_associated_model
+ @pirate.destroy
+ @ship.reload.name = "The Vile Insanity"
+ @ship.save
+ assert 'The Vile Insanity', @ship.reload.name
+ end
+
+ def test_should_automatically_save_the_associated_model
+ @ship.pirate.catchphrase = 'Arr'
+ @ship.save
+ assert_equal 'Arr', @ship.reload.pirate.catchphrase
+ end
+
+ def test_should_automatically_validate_the_associated_model
+ @ship.pirate.catchphrase = ''
+ assert !@ship.valid?
+ assert !@ship.errors.on(:pirate_catchphrase).blank?
+ end
+
+ def test_should_still_allow_to_bypass_validations_on_the_associated_model
+ @ship.pirate.catchphrase = ''
+ @ship.name = ''
+ @ship.save(false)
+ assert_equal ['', ''], [@ship.reload.name, @ship.pirate.catchphrase]
+ end
+
+ def test_should_still_raise_an_ActiveRecordRecord_Invalid_exception_if_we_want_that
+ @ship.pirate.catchphrase = ''
+ assert_raise(ActiveRecord::RecordInvalid) do
+ @ship.save!
+ end
+ end
+
+ def test_should_rollback_any_changes_if_an_exception_occurred_while_saving
+ before = [@ship.pirate.catchphrase, @ship.name]
+
+ @ship.pirate.catchphrase = 'Arr'
+ @ship.name = 'The Vile Insanity'
+
+ # Stub the save method of the @ship.pirate instance to raise an exception
+ class << @ship.pirate
+ def save(*args)
+ super
+ raise 'Oh noes!'
+ end
+ end
+
+ assert_raise(RuntimeError) { assert !@ship.save }
+ # TODO: Why does using reload on @ship looses the associated pirate?
+ assert_equal before, [@ship.pirate.reload.catchphrase, @ship.reload.name]
+ end
+
+ def test_should_not_load_the_associated_model
+ assert_queries(1) { @ship.name = 'The Vile Insanity'; @ship.save! }
+ end
+end
+
+module AutosaveAssociationOnACollectionAssociationTests
+ def test_should_automatically_save_the_associated_models
+ new_names = ['Grace OMalley', 'Privateers Greed']
+ @pirate.send(@association_name).each_with_index { |child, i| child.name = new_names[i] }
+
+ @pirate.save
+ assert_equal new_names, @pirate.reload.send(@association_name).map(&:name)
+ end
+
+ def test_should_automatically_validate_the_associated_models
+ @pirate.send(@association_name).each { |child| child.name = '' }
+
+ assert !@pirate.valid?
+ assert_equal "can't be blank", @pirate.errors.on("#{@association_name}_name")
+ assert @pirate.errors.on(@association_name).blank?
+ end
+
+ def test_should_still_allow_to_bypass_validations_on_the_associated_models
+ @pirate.catchphrase = ''
+ @pirate.send(@association_name).each { |child| child.name = '' }
+
+ assert @pirate.save(false)
+ assert_equal ['', '', ''], [
+ @pirate.reload.catchphrase,
+ @pirate.send(@association_name).first.name,
+ @pirate.send(@association_name).last.name
+ ]
+ end
+
+ def test_should_rollback_any_changes_if_an_exception_occurred_while_saving
+ before = [@pirate.catchphrase, *@pirate.send(@association_name).map(&:name)]
+ new_names = ['Grace OMalley', 'Privateers Greed']
+
+ @pirate.catchphrase = 'Arr'
+ @pirate.send(@association_name).each_with_index { |child, i| child.name = new_names[i] }
+
+ # Stub the save method of the first child instance to raise an exception
+ class << @pirate.send(@association_name).first
+ def save(*args)
+ super
+ raise 'Oh noes!'
+ end
+ end
+
+ assert_raise(RuntimeError) { assert !@pirate.save }
+ assert_equal before, [@pirate.reload.catchphrase, *@pirate.send(@association_name).map(&:name)]
+ end
+
+ def test_should_still_raise_an_ActiveRecordRecord_Invalid_exception_if_we_want_that
+ @pirate.send(@association_name).each { |child| child.name = '' }
+ assert_raise(ActiveRecord::RecordInvalid) do
+ @pirate.save!
+ end
+ end
+
+ def test_should_not_load_the_associated_models_if_they_were_not_loaded_yet
+ assert_queries(1) { @pirate.catchphrase = 'Arr'; @pirate.save! }
+
+ assert_queries(2) do
+ @pirate.catchphrase = 'Yarr'
+ new_names = ['Grace OMalley', 'Privateers Greed']
+ @pirate.send(@association_name).each_with_index { |child, i| child.name = new_names[i] }
+ @pirate.save!
+ end
+ end
+end
+
+class TestAutosaveAssociationOnAHasManyAssociation < ActiveRecord::TestCase
+ self.use_transactional_fixtures = false
+
+ def setup
+ @association_name = :birds
+
+ @pirate = Pirate.create(:catchphrase => "Don' botharrr talkin' like one, savvy?")
+ @child_1 = @pirate.birds.create(:name => 'Posideons Killer')
+ @child_2 = @pirate.birds.create(:name => 'Killer bandita Dionne')
+ end
+
+ include AutosaveAssociationOnACollectionAssociationTests
+end
+
+class TestAutosaveAssociationOnAHasAndBelongsToManyAssociation < ActiveRecord::TestCase
+ self.use_transactional_fixtures = false
+
+ def setup
+ @association_name = :parrots
+ @habtm = true
+
+ @pirate = Pirate.create(:catchphrase => "Don' botharrr talkin' like one, savvy?")
+ @child_1 = @pirate.parrots.create(:name => 'Posideons Killer')
+ @child_2 = @pirate.parrots.create(:name => 'Killer bandita Dionne')
+ end
+
+ include AutosaveAssociationOnACollectionAssociationTests
+end
View
2  activerecord/test/cases/dirty_test.rb
@@ -166,7 +166,7 @@ def test_attribute_will_change!
def test_association_assignment_changes_foreign_key
pirate = Pirate.create!(:catchphrase => 'jarl')
- pirate.parrot = Parrot.create!
+ pirate.parrot = Parrot.create!(:name => 'Lorre')
assert pirate.changed?
assert_equal %w(parrot_id), pirate.changed
end
View
359 activerecord/test/cases/nested_attributes_test.rb
@@ -0,0 +1,359 @@
+require "cases/helper"
+require "models/pirate"
+require "models/ship"
+require "models/bird"
+require "models/parrot"
+require "models/treasure"
+
+module AssertRaiseWithMessage
+ def assert_raise_with_message(expected_exception, expected_message)
+ begin
+ error_raised = false
+ yield
+ rescue expected_exception => error
+ error_raised = true
+ actual_message = error.message
+ end
+ assert error_raised
+ assert_equal expected_message, actual_message
+ end
+end
+
+class TestNestedAttributesInGeneral < ActiveRecord::TestCase
+ include AssertRaiseWithMessage
+
+ def teardown
+ Pirate.accepts_nested_attributes_for :ship, :allow_destroy => true
+ end
+
+ def test_base_should_have_an_empty_reject_new_nested_attributes_procs
+ assert_equal Hash.new, ActiveRecord::Base.reject_new_nested_attributes_procs
+ end
+
+ def test_should_add_a_proc_to_reject_new_nested_attributes_procs
+ [:parrots, :birds].each do |name|
+ assert_instance_of Proc, Pirate.reject_new_nested_attributes_procs[name]
+ end
+ end
+
+ def test_should_raise_an_ArgumentError_for_non_existing_associations
+ assert_raise_with_message ArgumentError, "No association found for name `honesty'. Has it been defined yet?" do
+ Pirate.accepts_nested_attributes_for :honesty
+ end
+ end
+
+ def test_should_disable_allow_destroy_by_default
+ Pirate.accepts_nested_attributes_for :ship
+
+ pirate = Pirate.create!(:catchphrase => "Don' botharrr talkin' like one, savvy?")
+ ship = pirate.create_ship(:name => 'Nights Dirty Lightning')
+
+ assert_no_difference('Ship.count') do
+ pirate.update_attributes(:ship_attributes => { '_delete' => true })
+ end
+ end
+
+ def test_a_model_should_respond_to_underscore_delete_and_return_if_it_is_marked_for_destruction
+ ship = Ship.create!(:name => 'Nights Dirty Lightning')
+ assert !ship._delete
+ ship.mark_for_destruction
+ assert ship._delete
+ end
+end
+
+class TestNestedAttributesOnAHasOneAssociation < ActiveRecord::TestCase
+ def setup
+ @pirate = Pirate.create!(:catchphrase => "Don' botharrr talkin' like one, savvy?")
+ @ship = @pirate.create_ship(:name => 'Nights Dirty Lightning')
+ end
+
+ def test_should_define_an_attribute_writer_method_for_the_association
+ assert_respond_to @pirate, :ship_attributes=
+ end
+
+ def test_should_automatically_instantiate_an_associated_model_if_there_is_none
+ @ship.destroy
+ @pirate.reload.ship_attributes = { :name => 'Davy Jones Gold Dagger' }
+
+ assert @pirate.ship.new_record?
+ assert_equal 'Davy Jones Gold Dagger', @pirate.ship.name
+ end
+
+ def test_should_take_a_hash_and_assign_the_attributes_to_the_existing_associated_model
+ @pirate.ship_attributes = { :name => 'Davy Jones Gold Dagger' }
+ assert !@pirate.ship.new_record?
+ assert_equal 'Davy Jones Gold Dagger', @pirate.ship.name
+ end
+
+ def test_should_also_work_with_a_HashWithIndifferentAccess
+ @pirate.ship_attributes = HashWithIndifferentAccess.new(:name => 'Davy Jones Gold Dagger')
+ assert !@pirate.ship.new_record?
+ assert_equal 'Davy Jones Gold Dagger', @pirate.ship.name
+ end
+
+ def test_should_work_with_update_attributes_as_well
+ @pirate.update_attributes({ :catchphrase => 'Arr', :ship_attributes => { :name => 'Mister Pablo' } })
+ @pirate.reload
+
+ assert_equal 'Arr', @pirate.catchphrase
+ assert_equal 'Mister Pablo', @pirate.ship.name
+ end
+
+ def test_should_be_possible_to_destroy_the_associated_model
+ @pirate.ship.destroy
+ ['1', 1, 'true', true].each do |true_variable|
+ @pirate.reload.create_ship(:name => 'Mister Pablo')
+ assert_difference('Ship.count', -1) do
+ @pirate.update_attributes(:ship_attributes => { '_delete' => true_variable })
+ end
+ end
+ end
+
+ def test_should_not_destroy_the_associated_model_with_a_non_truthy_argument
+ [nil, '0', 0, 'false', false].each do |false_variable|
+ assert_no_difference('Ship.count') do
+ @pirate.update_attributes(:ship_attributes => { '_delete' => false_variable })
+ end
+ end
+ end
+
+ def test_should_not_destroy_the_associated_model_until_the_parent_is_saved
+ assert_no_difference('Ship.count') do
+ @pirate.attributes = { :ship_attributes => { '_delete' => true } }
+ end
+ assert_difference('Ship.count', -1) { @pirate.save }
+ end
+
+ def test_should_automatically_enable_autosave_on_the_association
+ assert Pirate.reflect_on_association(:ship).options[:autosave]
+ end
+end
+
+class TestNestedAttributesOnABelongsToAssociation < ActiveRecord::TestCase
+ def setup
+ @ship = Ship.create!(:name => 'Nights Dirty Lightning')
+ @pirate = @ship.create_pirate(:catchphrase => "Don' botharrr talkin' like one, savvy?")
+ end
+
+ def test_should_define_an_attribute_writer_method_for_the_association
+ assert_respond_to @ship, :pirate_attributes=
+ end
+
+ def test_should_automatically_instantiate_an_associated_model_if_there_is_none
+ @pirate.destroy
+ @ship.reload.pirate_attributes = { :catchphrase => 'Arr' }
+
+ assert @ship.pirate.new_record?
+ assert_equal 'Arr', @ship.pirate.catchphrase
+ end
+
+ def test_should_take_a_hash_and_assign_the_attributes_to_the_existing_associated_model
+ @ship.pirate_attributes = { :catchphrase => 'Arr' }
+ assert !@ship.pirate.new_record?
+ assert_equal 'Arr', @ship.pirate.catchphrase
+ end
+
+ def test_should_also_work_with_a_HashWithIndifferentAccess
+ @ship.pirate_attributes = HashWithIndifferentAccess.new(:catchphrase => 'Arr')
+ assert !@ship.pirate.new_record?
+ assert_equal 'Arr', @ship.pirate.catchphrase
+ end
+
+ def test_should_work_with_update_attributes_as_well
+ @ship.update_attributes({ :name => 'Mister Pablo', :pirate_attributes => { :catchphrase => 'Arr' } })
+ @ship.reload
+
+ assert_equal 'Mister Pablo', @ship.name
+ assert_equal 'Arr', @ship.pirate.catchphrase
+ end
+
+ def test_should_be_possible_to_destroy_the_associated_model
+ @ship.pirate.destroy
+ ['1', 1, 'true', true].each do |true_variable|
+ @ship.reload.create_pirate(:catchphrase => 'Arr')
+ assert_difference('Pirate.count', -1) do
+ @ship.update_attributes(:pirate_attributes => { '_delete' => true_variable })
+ end
+ end
+ end
+
+ def test_should_not_destroy_the_associated_model_with_a_non_truthy_argument
+ [nil, '', '0', 0, 'false', false].each do |false_variable|
+ assert_no_difference('Pirate.count') do
+ @ship.update_attributes(:pirate_attributes => { '_delete' => false_variable })
+ end
+ end
+ end
+
+ def test_should_not_destroy_the_associated_model_until_the_parent_is_saved
+ assert_no_difference('Pirate.count') do
+ @ship.attributes = { :pirate_attributes => { '_delete' => true } }
+ end
+ assert_difference('Pirate.count', -1) { @ship.save }
+ end
+
+ def test_should_automatically_enable_autosave_on_the_association
+ assert Ship.reflect_on_association(:pirate).options[:autosave]
+ end
+end
+
+module NestedAttributesOnACollectionAssociationTests
+ include AssertRaiseWithMessage
+
+ def test_should_define_an_attribute_writer_method_for_the_association
+ assert_respond_to @pirate, association_setter
+ end
+
+ def test_should_take_a_hash_with_string_keys_and_assign_the_attributes_to_the_associated_models
+ @alternate_params[association_getter].stringify_keys!
+ @pirate.update_attributes @alternate_params
+ assert_equal ['Grace OMalley', 'Privateers Greed'], [@child_1.reload.name, @child_2.reload.name]
+ end
+
+ def test_should_also_work_with_a_HashWithIndifferentAccess
+ @pirate.send(association_setter, HashWithIndifferentAccess.new(@child_1.id => HashWithIndifferentAccess.new(:name => 'Grace OMalley')))
+ @pirate.save
+ assert_equal 'Grace OMalley', @child_1.reload.name
+ end
+
+ def test_should_take_a_hash_with_integer_keys_and_assign_the_attributes_to_the_associated_models
+ @pirate.attributes = @alternate_params
+ assert_equal 'Grace OMalley', @pirate.send(@association_name).first.name
+ assert_equal 'Privateers Greed', @pirate.send(@association_name).last.name
+ end
+
+ def test_should_automatically_build_new_associated_models_for_each_entry_in_a_hash_where_the_id_starts_with_the_string_new_
+ @pirate.send(@association_name).destroy_all
+ @pirate.reload.attributes = { association_getter => { 'new_1' => { :name => 'Grace OMalley' }, 'new_2' => { :name => 'Privateers Greed' }}}
+
+ assert @pirate.send(@association_name).first.new_record?
+ assert_equal 'Grace OMalley', @pirate.send(@association_name).first.name
+
+ assert @pirate.send(@association_name).last.new_record?
+ assert_equal 'Privateers Greed', @pirate.send(@association_name).last.name
+ end
+
+ def test_should_sort_the_hash_by_the_keys_before_building_new_associated_models
+ attributes = ActiveSupport::OrderedHash.new
+ attributes['new_123726353'] = { :name => 'Grace OMalley' }
+ attributes['new_2'] = { :name => 'Privateers Greed' } # 2 is lower then 123726353
+ @pirate.send(association_setter, attributes)
+
+ assert_equal ['Posideons Killer', 'Killer bandita Dionne', 'Privateers Greed', 'Grace OMalley'], @pirate.send(@association_name).map(&:name)
+ end
+
+ def test_should_raise_an_argument_error_if_something_else_than_a_hash_is_passed
+ assert_nothing_raised(ArgumentError) { @pirate.send(association_setter, {}) }
+ assert_nothing_raised(ArgumentError) { @pirate.send(association_setter, ActiveSupport::OrderedHash.new) }
+
+ assert_raise_with_message ArgumentError, 'Hash expected, got String ("foo")' do
+ @pirate.send(association_setter, "foo")
+ end
+ assert_raise_with_message ArgumentError, 'Hash expected, got Array ([:foo, :bar])' do
+ @pirate.send(association_setter, [:foo, :bar])
+ end
+ end
+
+ def test_should_work_with_update_attributes_as_well
+ @pirate.update_attributes({ :catchphrase => 'Arr', association_getter => { @child_1.id => { :name => 'Grace OMalley' }}})
+ assert_equal 'Grace OMalley', @child_1.reload.name
+ end
+
+ def test_should_automatically_reject_any_new_record_if_a_reject_if_proc_exists_and_returns_false
+ @alternate_params[association_getter]["new_12345"] = {}
+ assert_no_difference("@pirate.send(@association_name).length") do
+ @pirate.attributes = @alternate_params
+ end
+ end
+
+ def test_should_update_existing_records_and_add_new_ones_that_have_an_id_that_start_with_the_string_new_
+ @alternate_params[association_getter]['new_12345'] = { :name => 'Buccaneers Servant' }
+ assert_difference('@pirate.send(@association_name).count', +1) do
+ @pirate.update_attributes @alternate_params
+ end
+ assert_equal ['Grace OMalley', 'Privateers Greed', 'Buccaneers Servant'], @pirate.reload.send(@association_name).map(&:name)
+ end
+
+ def test_should_be_possible_to_destroy_a_record
+ ['1', 1, 'true', true].each do |true_variable|
+ record = @pirate.reload.send(@association_name).create!(:name => 'Grace OMalley')
+ @pirate.send(association_setter,
+ @alternate_params[association_getter].merge(record.id => { '_delete' => true_variable })
+ )
+
+ assert_difference('@pirate.send(@association_name).count', -1) do
+ @pirate.save
+ end
+ end
+ end
+
+ def test_should_not_destroy_the_associated_model_with_a_non_truthy_argument
+ [nil, '', '0', 0, 'false', false].each do |false_variable|
+ @alternate_params[association_getter][@child_1.id]['_delete'] = false_variable
+ assert_no_difference('@pirate.send(@association_name).count') do
+ @pirate.update_attributes(@alternate_params)
+ end
+ end
+ end
+
+ def test_should_not_destroy_the_associated_model_until_the_parent_is_saved
+ assert_no_difference('@pirate.send(@association_name).count') do
+ @pirate.send(association_setter, @alternate_params[association_getter].merge(@child_1.id => { '_delete' => true }))
+ end
+ assert_difference('@pirate.send(@association_name).count', -1) { @pirate.save }
+ end
+
+ def test_should_automatically_enable_autosave_on_the_association
+ assert Pirate.reflect_on_association(@association_name).options[:autosave]
+ end
+
+ private
+
+ def association_setter
+ @association_setter ||= "#{@association_name}_attributes=".to_sym
+ end
+
+ def association_getter
+ @association_getter ||= "#{@association_name}_attributes".to_sym
+ end
+end
+
+class TestNestedAttributesOnAHasManyAssociation < ActiveRecord::TestCase
+ def setup
+ @association_type = :has_many
+ @association_name = :birds
+
+ @pirate = Pirate.create!(:catchphrase => "Don' botharrr talkin' like one, savvy?")
+ @child_1 = @pirate.birds.create!(:name => 'Posideons Killer')
+ @child_2 = @pirate.birds.create!(:name => 'Killer bandita Dionne')
+
+ @alternate_params = {
+ :birds_attributes => {
+ @child_1.id => { :name => 'Grace OMalley' },
+ @child_2.id => { :name => 'Privateers Greed' }
+ }
+ }
+ end
+
+ include NestedAttributesOnACollectionAssociationTests
+end
+
+class TestNestedAttributesOnAHasAndBelongsToManyAssociation < ActiveRecord::TestCase
+ def setup
+ @association_type = :has_and_belongs_to_many
+ @association_name = :parrots
+
+ @pirate = Pirate.create!(:catchphrase => "Don' botharrr talkin' like one, savvy?")
+ @child_1 = @pirate.parrots.create!(:name => 'Posideons Killer')
+ @child_2 = @pirate.parrots.create!(:name => 'Killer bandita Dionne')
+
+ @alternate_params = {
+ :parrots_attributes => {
+ @child_1.id => { :name => 'Grace OMalley' },
+ @child_2.id => { :name => 'Privateers Greed' }
+ }
+ }
+ end
+
+ include NestedAttributesOnACollectionAssociationTests
+end
View
9 activerecord/test/cases/reflection_test.rb
@@ -91,6 +91,15 @@ def test_aggregation_reflection
assert_equal Money, Customer.reflect_on_aggregation(:balance).klass
end
+ def test_reflect_on_all_autosave_associations
+ expected = Pirate.reflect_on_all_associations.select { |r| r.options[:autosave] }
+ received = Pirate.reflect_on_all_autosave_associations
+
+ assert !received.empty?
+ assert_not_equal Pirate.reflect_on_all_associations.length, received.length
+ assert_equal expected, received
+ end
+
def test_has_many_reflection
reflection_for_clients = ActiveRecord::Reflection::AssociationReflection.new(:has_many, :clients, { :order => "id", :dependent => :destroy }, Firm)
View
3  activerecord/test/models/bird.rb
@@ -0,0 +1,3 @@
+class Bird < ActiveRecord::Base
+ validates_presence_of :name
+end
View
2  activerecord/test/models/parrot.rb
@@ -4,6 +4,8 @@ class Parrot < ActiveRecord::Base
has_and_belongs_to_many :treasures
has_many :loots, :as => :looter
alias_attribute :title, :name
+
+ validates_presence_of :name
end
class LiveParrot < Parrot
View
7 activerecord/test/models/pirate.rb
@@ -5,5 +5,12 @@ class Pirate < ActiveRecord::Base
has_many :treasure_estimates, :through => :treasures, :source => :price_estimates
+ # These both have :autosave enabled because accepts_nested_attributes_for is used on them.
+ has_one :ship
+ has_many :birds
+
+ accepts_nested_attributes_for :parrots, :birds, :allow_destroy => true, :reject_if => proc { |attributes| attributes.empty? }
+ accepts_nested_attributes_for :ship, :allow_destroy => true
+
validates_presence_of :catchphrase
end
View
7 activerecord/test/models/ship.rb
@@ -1,3 +1,10 @@
class Ship < ActiveRecord::Base
self.record_timestamps = false
+
+ belongs_to :pirate
+ has_many :parts, :class_name => 'ShipPart', :autosave => true
+
+ accepts_nested_attributes_for :pirate, :allow_destroy => true
+
+ validates_presence_of :name
end
View
5 activerecord/test/models/ship_part.rb
@@ -0,0 +1,5 @@
+class ShipPart < ActiveRecord::Base
+ belongs_to :ship
+
+ validates_presence_of :name
+end
View
11 activerecord/test/schema/schema.rb
@@ -55,6 +55,11 @@ def create_table(*args, &block)
t.binary :data
end
+ create_table :birds, :force => true do |t|
+ t.string :name
+ t.integer :pirate_id
+ end
+
create_table :books, :force => true do |t|
t.column :name, :string
end
@@ -356,12 +361,18 @@ def create_table(*args, &block)
create_table :ships, :force => true do |t|
t.string :name
+ t.integer :pirate_id
t.datetime :created_at
t.datetime :created_on
t.datetime :updated_at
t.datetime :updated_on
end
+ create_table :ship_parts, :force => true do |t|
+ t.string :name
+ t.integer :ship_id
+ end
+
create_table :sponsors, :force => true do |t|
t.integer :club_id
t.integer :sponsorable_id

3 comments on commit ec8f045

@tamersalama

Great Feature! Thanks Guys.

I hope I’m not making a fool of myself here :)

It seems that adding the foreign key presence validation to the child instance creates a circular dependency (the child validation is done before the parent id is propagated to the child).


class Person < ActiveRecord::Base
  has_many :children
  accepts_nested_attributes_for :children
end

class Child < ActiveRecord::Base
  belongs_to :person
  
  #Validating that a toy belongs to a person
  validates_presence_of :person_id
end

  p = Person.new(:name => "Smith", :children_attributes => {"new_1" => {:name => "John"}})
  p.valid? #=> false
  p.save  #false
  p.children.first.errors.on(:person_id) #=> "can't be blank"

Is it just late or does it need a work-around?

@alloy

tamersalama: No your not making a fool out of yourself :) Please file a ticket on lighthouse: http://rails.lighthouseapp.com/projects/8994-ruby-on-rails/tickets

@tamersalama

A ticket has been created:

http://rails.lighthouseapp.com/projects/8994-ruby-on-rails/tickets/1943

Please sign in to comment.
Something went wrong with that request. Please try again.