Search code examples
rubypostgresqlruby-on-rails-4cancan

Adding element to postgres array field fails while replacing the whole array works


I have an User model which has an array of roles.

From my schema.db:

create_table "users", force: true do |t|
  t.string   "roles",         array: true

My model looks like this:

class User < ActiveRecord::Base
  ROLES = %w(superadmin sysadmin secretary)

  validate :allowed_roles
  after_initialize :initialize_roles, if: :new_record?

  private

  def allowed_roles
    roles.each do |role|
      errors.add(:roles, :invalid) unless ROLES.include?(role)
    end
  end

  def initialize_roles
    write_attribute(:roles, []) if read_attribute(:roles).blank?
  end

Problem is when I try to add another role from console like user.roles << "new_role" then user.save! says true and asking user.roles gives me my wanted output. But when I ask User.find(user_id).roles then I get the previous state without "new_role" in it.

For ex.

user.roles
  => ["superadmin"]
user.roles << "secretary"
  => ["superadmin", "secretary"]
user.save!
  => true
user.roles
  => ["superadmin", "secretary"]
User.find(<user_id>).roles
  => ["superadmin"]

When replacing the whole array, it works as I want:

user.roles
  => ["superadmin"]
user.roles = ["superadmin", "secretary"]
user.save!
  => true
user.roles
  => ["superadmin", "secretary"]
User.find(<user_id>).roles
  => ["superadmin", "secretary"]

I'm using rails 4 and postgresql, roles are for cancancan gem.

Changing other fields like user.name for ex works like expected. I made quite a lot of digging in google, but no help.


Solution

  • Active Record tracks which columns have changed and only saves these to the database. This change tracking works by hooking onto the setter methods - mutating an object inplace isn't detected. For example

    user.roles << "superuser"
    

    wouldn't be detected as a change.

    There are 2 ways around this. One is never to change any Active Record object attribute in place. In your case this would mean the slight clumsier

    user.roles += ["superuser"]
    

    If you can't/won't do this then you must tell Active Record what you have done, for example

    user.roles.gsub!(...)
    user.roles_will_change!
    

    lets Active Record know that the roles attribute has changed and needs to be updated.

    It would be nicer if Active Record dealt better with this - when change tracking came in array columns weren't supported (mysql had the lion's share of the attention at the time)

    Yet another approach would be to mark such columns as always needing saving (much like what happens with serialised attributes) but you'd need to monkey patch activerecord for that.