Skip to content

Commit

Permalink
Add :accessible option to Associations for allowing mass assignments …
Browse files Browse the repository at this point in the history
…using hash. [#474 state:resolved]

Allows nested Hashes (i.e. from nested forms) to hydrate the appropriate
ActiveRecord models.

class Post < ActiveRecord::Base
  belongs_to :author,   :accessible => true
  has_many   :comments, :accessible => true
end

post = Post.create({
  :title    => 'Accessible Attributes',
  :author   => { :name => 'David Dollar' },
  :comments => [
    { :body => 'First Post!' },
    { :body => 'Nested Hashes are great!' }
  ]
})

post.comments << { :body => 'Another Comment' }

Signed-off-by: Pratik Naik <pratiknaik@gmail.com>
  • Loading branch information
ddollar authored and lifo committed Jul 14, 2008
1 parent c6f397c commit e0750d6
Show file tree
Hide file tree
Showing 6 changed files with 149 additions and 5 deletions.
18 changes: 18 additions & 0 deletions activerecord/CHANGELOG
@@ -1,5 +1,23 @@
*Edge*

* Add :accessible option to associations for allowing (opt-in) mass assignment. #474. [David Dollar] Example :

class Post < ActiveRecord::Base
belongs_to :author, :accessible => true
has_many :comments, :accessible => true
end

post = Post.create({
:title => 'Accessible Attributes',
:author => { :name => 'David Dollar' },
:comments => [
{ :body => 'First Post!' },
{ :body => 'Nested Hashes are great!' }
]
})

post.comments << { :body => 'Another Comment' }

* Add :tokenizer option to validates_length_of to specify how to split up the attribute string. #507. [David Lowenfels] Example :

# Ensure essay contains at least 100 words.
Expand Down
14 changes: 10 additions & 4 deletions activerecord/lib/active_record/associations.rb
Expand Up @@ -692,6 +692,7 @@ module ClassMethods
# * <tt>:uniq</tt> - If true, duplicates will be omitted from the collection. Useful in conjunction with <tt>:through</tt>.
# * <tt>:readonly</tt> - If true, all the associated objects are readonly through the association.
# * <tt>:validate</tt> - If false, don't validate the associated objects when saving the parent object. true by default.
# * <tt>:accessible</tt> - Mass assignment is allowed for this assocation (similar to <tt>ActiveRecord::Base#attr_accessible</tt>).
#
# Option examples:
# has_many :comments, :order => "posted_on"
Expand Down Expand Up @@ -774,6 +775,7 @@ def has_many(association_id, options = {}, &extension)
# association is a polymorphic +belongs_to+.
# * <tt>:readonly</tt> - If true, the associated object is readonly through the association.
# * <tt>:validate</tt> - If false, don't validate the associated object when saving the parent object. +false+ by default.
# * <tt>:accessible</tt> - Mass assignment is allowed for this assocation (similar to <tt>ActiveRecord::Base#attr_accessible</tt>).
#
# Option examples:
# has_one :credit_card, :dependent => :destroy # destroys the associated credit card
Expand Down Expand Up @@ -863,6 +865,7 @@ def has_one(association_id, options = {})
# to the +attr_readonly+ list in the associated classes (e.g. <tt>class Post; attr_readonly :comments_count; end</tt>).
# * <tt>:readonly</tt> - If true, the associated object is readonly through the association.
# * <tt>:validate</tt> - If false, don't validate the associated objects when saving the parent object. +false+ by default.
# * <tt>:accessible</tt> - Mass assignment is allowed for this assocation (similar to <tt>ActiveRecord::Base#attr_accessible</tt>).
#
# Option examples:
# belongs_to :firm, :foreign_key => "client_of"
Expand Down Expand Up @@ -1034,6 +1037,7 @@ def belongs_to(association_id, options = {})
# but not include the joined columns. Do not forget to include the primary and foreign keys, otherwise it will raise an error.
# * <tt>:readonly</tt> - If true, all the associated objects are readonly through the association.
# * <tt>:validate</tt> - If false, don't validate the associated objects when saving the parent object. +true+ by default.
# * <tt>:accessible</tt> - Mass assignment is allowed for this assocation (similar to <tt>ActiveRecord::Base#attr_accessible</tt>).
#
# Option examples:
# has_and_belongs_to_many :projects
Expand Down Expand Up @@ -1109,6 +1113,8 @@ def association_accessor_methods(reflection, association_proxy_class)
association = association_proxy_class.new(self, reflection)
end

new_value = reflection.klass.new(new_value) if reflection.options[:accessible] && new_value.is_a?(Hash)

if association_proxy_class == HasOneThroughAssociation
association.create_through_record(new_value)
self.send(reflection.name, new_value)
Expand Down Expand Up @@ -1357,7 +1363,7 @@ def create_has_many_reflection(association_id, options, &extension)
:finder_sql, :counter_sql,
:before_add, :after_add, :before_remove, :after_remove,
:extend, :readonly,
:validate
:validate, :accessible
)

options[:extend] = create_extension_modules(association_id, extension, options[:extend])
Expand All @@ -1367,7 +1373,7 @@ def create_has_many_reflection(association_id, options, &extension)

def create_has_one_reflection(association_id, options)
options.assert_valid_keys(
:class_name, :foreign_key, :remote, :select, :conditions, :order, :include, :dependent, :counter_cache, :extend, :as, :readonly, :validate, :primary_key
:class_name, :foreign_key, :remote, :select, :conditions, :order, :include, :dependent, :counter_cache, :extend, :as, :readonly, :validate, :primary_key, :accessible
)

create_reflection(:has_one, association_id, options, self)
Expand All @@ -1383,7 +1389,7 @@ def create_has_one_through_reflection(association_id, options)
def create_belongs_to_reflection(association_id, options)
options.assert_valid_keys(
:class_name, :foreign_key, :foreign_type, :remote, :select, :conditions, :include, :dependent,
:counter_cache, :extend, :polymorphic, :readonly, :validate
:counter_cache, :extend, :polymorphic, :readonly, :validate, :accessible
)

reflection = create_reflection(:belongs_to, association_id, options, self)
Expand All @@ -1403,7 +1409,7 @@ def create_has_and_belongs_to_many_reflection(association_id, options, &extensio
:finder_sql, :delete_sql, :insert_sql,
:before_add, :after_add, :before_remove, :after_remove,
:extend, :readonly,
:validate
:validate, :accessible
)

options[:extend] = create_extension_modules(association_id, extension, options[:extend])
Expand Down
Expand Up @@ -97,6 +97,8 @@ def <<(*records)

@owner.transaction do
flatten_deeper(records).each do |record|
record = @reflection.klass.new(record) if @reflection.options[:accessible] && record.is_a?(Hash)

raise_on_type_mismatch(record)
add_record_to_target_with_callbacks(record) do |r|
result &&= insert_record(record) unless @owner.new_record?
Expand Down Expand Up @@ -229,6 +231,10 @@ def uniq(collection = self)
# Replace this collection with +other_array+
# This will perform a diff and delete/add only records that have changed.
def replace(other_array)
other_array.map! do |val|
val.is_a?(Hash) ? @reflection.klass.new(val) : val
end if @reflection.options[:accessible]

other_array.each { |val| raise_on_type_mismatch(val) }

load_target
Expand Down
108 changes: 108 additions & 0 deletions activerecord/test/cases/associations_test.rb
Expand Up @@ -189,6 +189,114 @@ def test_reload_returns_assocition
end
end

def test_belongs_to_mass_assignment
post_attributes = { :title => 'Associations', :body => 'Are They Accessible?' }
author_attributes = { :name => 'David Dollar' }

assert_no_difference 'Author.count' do
assert_raise(ActiveRecord::AssociationTypeMismatch) do
Post.create(post_attributes.merge({:author => author_attributes}))
end
end

assert_difference 'Author.count' do
post = Post.create(post_attributes.merge({:creatable_author => author_attributes}))
assert_equal post.creatable_author.name, author_attributes[:name]
end
end

def test_has_one_mass_assignment
post_attributes = { :title => 'Associations', :body => 'Are They Accessible?' }
comment_attributes = { :body => 'Setter Takes Hash' }

assert_no_difference 'Comment.count' do
assert_raise(ActiveRecord::AssociationTypeMismatch) do
Post.create(post_attributes.merge({:uncreatable_comment => comment_attributes}))
end
end

assert_difference 'Comment.count' do
post = Post.create(post_attributes.merge({:creatable_comment => comment_attributes}))
assert_equal post.creatable_comment.body, comment_attributes[:body]
end
end

def test_has_many_mass_assignment
post = posts(:welcome)
post_attributes = { :title => 'Associations', :body => 'Are They Accessible?' }
comment_attributes = { :body => 'Setter Takes Hash' }

assert_no_difference 'Comment.count' do
assert_raise(ActiveRecord::AssociationTypeMismatch) do
Post.create(post_attributes.merge({:comments => [comment_attributes]}))
end
assert_raise(ActiveRecord::AssociationTypeMismatch) do
post.comments << comment_attributes
end
end

assert_difference 'Comment.count' do
post = Post.create(post_attributes.merge({:creatable_comments => [comment_attributes]}))
assert_equal post.creatable_comments.last.body, comment_attributes[:body]
end

assert_difference 'Comment.count' do
post.creatable_comments << comment_attributes
assert_equal post.comments.last.body, comment_attributes[:body]
end

post.creatable_comments = [comment_attributes, comment_attributes]
assert_equal post.creatable_comments.count, 2
end

def test_has_and_belongs_to_many_mass_assignment
post = posts(:welcome)
post_attributes = { :title => 'Associations', :body => 'Are They Accessible?' }
category_attributes = { :name => 'Accessible Association', :type => 'Category' }

assert_no_difference 'Category.count' do
assert_raise(ActiveRecord::AssociationTypeMismatch) do
Post.create(post_attributes.merge({:categories => [category_attributes]}))
end
assert_raise(ActiveRecord::AssociationTypeMismatch) do
post.categories << category_attributes
end
end

assert_difference 'Category.count' do
post = Post.create(post_attributes.merge({:creatable_categories => [category_attributes]}))
assert_equal post.creatable_categories.last.name, category_attributes[:name]
end

assert_difference 'Category.count' do
post.creatable_categories << category_attributes
assert_equal post.creatable_categories.last.name, category_attributes[:name]
end

post.creatable_categories = [category_attributes, category_attributes]
assert_equal post.creatable_categories.count, 2
end

def test_association_proxy_setter_can_take_hash
special_comment_attributes = { :body => 'Setter Takes Hash' }

post = posts(:welcome)
post.creatable_comment = { :body => 'Setter Takes Hash' }

assert_equal post.creatable_comment.body, special_comment_attributes[:body]
end

def test_association_collection_can_take_hash
post_attributes = { :title => 'Setter Takes', :body => 'Hash' }
david = authors(:david)

post = (david.posts << post_attributes).last
assert_equal post.title, post_attributes[:title]

david.posts = [post_attributes, post_attributes]
assert_equal david.posts.count, 2
end

def setup_dangling_association
josh = Author.create(:name => "Josh")
p = Post.create(:title => "New on Edge", :body => "More cool stuff!", :author => josh)
Expand Down
2 changes: 1 addition & 1 deletion activerecord/test/models/author.rb
@@ -1,5 +1,5 @@
class Author < ActiveRecord::Base
has_many :posts
has_many :posts, :accessible => true
has_many :posts_with_comments, :include => :comments, :class_name => "Post"
has_many :posts_with_comments_sorted_by_comment_id, :include => :comments, :class_name => "Post", :order => 'comments.id'
has_many :posts_with_categories, :include => :categories, :class_name => "Post"
Expand Down
6 changes: 6 additions & 0 deletions activerecord/test/models/post.rb
Expand Up @@ -33,6 +33,12 @@ def find_most_recent
has_and_belongs_to_many :categories
has_and_belongs_to_many :special_categories, :join_table => "categories_posts", :association_foreign_key => 'category_id'

belongs_to :creatable_author, :class_name => 'Author', :accessible => true
has_one :uncreatable_comment, :class_name => 'Comment', :accessible => false, :order => 'id desc'
has_one :creatable_comment, :class_name => 'Comment', :accessible => true, :order => 'id desc'
has_many :creatable_comments, :class_name => 'Comment', :accessible => true, :dependent => :destroy
has_and_belongs_to_many :creatable_categories, :class_name => 'Category', :accessible => true

has_many :taggings, :as => :taggable
has_many :tags, :through => :taggings do
def add_joins_and_select
Expand Down

1 comment on commit e0750d6

@glennpow
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am attempting to utilize mass-assign for my models, but can’t get the update_attributes to work correctly when I have association_collections as child models.
My create methods work fine, since the hash passes in an Array value for these collections. I.E.:

emails => [ {…}, {…} ] )

But, when I try to edit/update my model, the field names in the form naturally have the :id of the email included in them, so that the resulting value passed to the mass-assign is not an Array, but a Hash of this form:

emails => {"5" => {…}, “6” => {…}}

Where the 5 and 6 are the :id values of the respective emails. This gets passed into the update_attributes, which eventually gets to:

AssociationCollection.replace(other_array)

which assumes “emails” to be an Array. Shouldn’t this replace method be “smarter” so that if a Hash is passed in, it will ascertain the :id values from it, and then reassign the model attributes accordingly?
Furthermore, what would happen if there were a combination of updated models (emails) and perhaps one new email (that didn’t have an :id). Does the mass-assign functionality handle this situation?

Any help would be appreciated.

-Glenn

Please sign in to comment.