Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with
or
.
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.