ruby-on-railshas-many-throughhas-manyrails-activerecord

Does has_many association attribute setter always persist its value to a database and is it possible to make it not to?


I have the following script which demonstrates me that has_many roles= attribute always works in a persistent manner.

My questions are:

1) What is the reason behind this behavior: why has_many attributes are persisted right at the moment when they've been set? Why this difference from regular attributes behavior (name in the following script) ?

2) Can I write my custom roles= setter so I could use fx assign_attributes for a bunch of models attributes (including roles=) without roles association to be persisted? I would appreciate an example if it is possible in Rails > 3.2 ?

Here is the script:

gem 'rails', '>=3.2.0' # change as required
gem 'sqlite3'

require 'active_record'
require 'logger'

puts "Active Record #{ActiveRecord::VERSION::STRING}"
ActiveRecord::Base.logger = Logger.new(STDERR)

ActiveRecord::Base.establish_connection(
  :adapter  => 'sqlite3',
  :database => ':memory:'
)

ActiveRecord::Schema.define do
  create_table :users, :force => true do |t|
    t.string :name
  end

  create_table :user_roles, :force => true do |t|
    t.integer :user_id
    t.integer :role_id
  end

  create_table :roles, :force => true do |t|
    t.string :name
  end
end

# Create the minimal set of models to reproduce the bug
class User < ActiveRecord::Base
  has_many :user_roles
  has_many :roles, :through => :user_roles
end

class UserRole < ActiveRecord::Base
  belongs_to :user
  belongs_to :role
end

class Role < ActiveRecord::Base
end

r = Role.create(:name => 'admin')
u = User.create

# roles= persists its value, name= does not
u.assign_attributes({ :roles => [r], :name => 'Stanislaw' })

# The same behavior is produced by:
# u.attributes=
# u.roles=

puts "name attribute: #{u.name}"
puts "many roles #{u.roles}"

u.reload

puts "name attribute: #{u.name}"
puts "many roles #{u.roles}" # I see admin role and I want to achieve behavior that I would not see it

Solution

  • Associations are not the same as attributes. For example with a has_many association all you are doing when you assign is setting the foreign key on the belongs_to side.

    class User < ActiveRecord::Base
      has_many :posts
    end
    
    class Post < ActiveRecord::Base
      belongs_to :user
    end
    
    p = Post.create
    u = User.create
    u.posts << p # this line will simply update p.user_id with u.id
    

    In your example with the join table assigning a role to a user will create a UserRole record and with the user_id/role_id records set. This happens because you declared the has_many :through

    As for preventing this behavior, you could use a virtual attribute that stores the unpersisted roles until you save the record, then create the associations.

    class User < ActiveRecord::Base
      attr_accessor :unpersisted_roles
      attr_accessible :unpersisted_roles
    
      after_save :assign_roles
    
      def assign_roles
        self.roles << @unpersisted_roles if defined(@unpersisted_roles)
      end
    end
    
    r = Role.create
    u = User.create
    u.attributes = {:unpersisted_roles => [r]}
    u.save # roles get persisted here
    

    This is only a simple example, actual code might need to be more complicated or require diving deeper into AR's interface to get it working without too many side effects.

    If you could give some insight as to why your wanting to not persist the association I might be able to suggest a more specific course of action.



    Update

    In reference to Issue #3 with some comments where changes were made.

    module SimpleRoles
      module Many
        module Persistence
          class << self
            def included base
              base.class_eval %{
                has_many :user_roles
                has_many :roles, :through => :user_roles
                # Add a callback to persist the roles
                after_create :persist_roles
              }
            end
          end
    
          def roles
            # Apply unpersisted roles in case we want to access them before saving
            super.map(&:name).map(&:to_sym) + (@unpersisted_roles || [])
          end
    
          def roles= *rolez
            rolez.to_symbols!.flatten!
    
            # if we're already persisted then go ahead and save
            # otherwise stash them in an ivar array
            if persisted?
              super retrieve_roles(rolez)
            else
              @unpersisted_roles = rolez
            end
          end
    
          private
    
          # Our callback method that sets the roles, this will
          # work since persisted? is true when this runs.
          def persist_roles
            self.roles = @unpersisted_roles
          end
    
          def retrieve_roles rolez
            raise "Not a valid role!" if (rolez - config.valid_roles).size > 0
    
            rolez.map do |rolle|
              begin
                Role.find_by_name! rolle.to_s
              rescue
                raise "Couldn't find Role for #{rolle}. Maybe you need to re-run migrations?"
              end
            end
          end
    
          def config
            SimpleRoles::Configuration
          end
        end
      end
    end