Rails 6 | Mass Attribute Assignment Without Protection?

89 Views Asked by At

I am attempting to work-around a performance issue with a 3rd party library (acts_as_taggable_on). For the one model that uses this extension, it has a N+1 Query problem where it will make one or more extra queries per record to go grab the tags for that record as part of the record initialization process.

I'm doing some large custom queries - and it would both over-complicate and make less efficient the queries if I were to include the tags as part of the query. What I would like to do is to manually "eager load" nil as the tags for each record in this case.

Now, when I perform a custom query on the table for which the model uses the acts_as_taggable_on extension, and I LEFT JOIN the tags table, I can detect in the after_initialize callback that the "tags" attribute exists and manually muck with the association to make it think that the data has already been loaded accordingly to avoid any additional queries.

class MyTaggableModel < ActiveRecord::Base
  has_and_belongs_to_many :some_other_model, source: some_other, source_type: 'SomeOtherModel'

  acts_as_taggable_on :tags
  accepts_nested_attributes_for :tags
...
  after_initialize do |model|
    attrs = model.attributes.to_h

    if attrs.key?('tags')
      data = attrs['tags']
      records = data&.any? ? data.map { |t| ActsAsTaggableOn::Tag.new(t) } : []
      association = model.association(:tags)
      association.loaded!
      association.target.concat(records)
      records.each { |r| association.set_inverse_instance(model) }

      model.instance_variable_set(:@tag_list, 
                                  ActsAsTaggableOn::TagList.new(records.map(&:name)))
    end
  end
...
end

The above works like a charm! However, if I perform a custom query on another table that LEFT JOINs the above table, I run into trouble. Namely: I can't seem to pass the attribute 'tags': nil to the model when I new it...

class SomeOtherModel < ActiveRecord::Base
  has_and_belongs_to_many :my_taggable_models
  ...
  after_initialize do |model|
    attrs = model.attributes.to_h
    if attrs.key?('my_taggable_models')
      records = attrs['my_taggable_models'].map { |m|
        m['tags'] = nil unless m.key?('tags')
        MyTaggableModel.new(m) 
      }
      association = model.association(:my_taggable_models)
      association.loaded!
      association.target.concat(records)
      records.each { |r| association.set_inverse_instance(model) }
    end
  end
  ...
end

The problem is that even though I'm defaulting the tags attribute to nil above when instantiating new MyTaggableModel instances, rails is behind the scenes overwritting me and stripping out the tags from the attributes - preventing the after_initialization callback within the MyTaggableModel class from doing its thing and marking the tags association as already loaded + assigning the tagList attribute to a new, empty ActsAsTaggableOn::TagList instance.

Apparently in previous versions of rails you could do something like model.assign_attributes({ ... }, :without_protection => true) and you could bypass the mass-assignment security. I have been looking around, but I have not been able to find how to do this in Rails 6. Is there another method for fully de serializing an ActiveRecord object graph or for manually pre/eager loading data? Please advise! Thank you :)


EDIT: I have found a temporary work-around, if I instantiate each MyTaggableModel instance using a block like the one below, then I can eliminate any subsequent tags/taggings queries. However, this is not an ideal solution as then I need to duplicate the logic already present in the MyTaggableModel class in any other model that needs to join to it while repressing the N+1 Queries.

        MyTaggableModel.new do |mtm|
          mtm.attributes = mtm_attrs

          taggings = mtm.association(:taggings)
          taggings.loaded!
          
          tag_records = []
          tags = mtm.association(:tags)
          tags.loaded!
          tags.target.concat(tag_records)

          mtm.instance_variable_set(:@tag_list, 
            ActsAsTaggableOn::TagList.new(tag_records.map(&:name)))
        end
1

There are 1 best solutions below

3
Allacassar On

There is one somewhat plausible solution that comes to mind. there is a gem 'protected_attributes_continued' which adds the class methods attr_accessible and attr_protected to declare white or black lists of attributes. This might help you to allow mass assignment for some special fields in your db and basically works like without_protection, but for limited fields.

Hope it helps :)


EDIT

after reading your edit note: you dont need to write this in every model that joins your table, you can write a Concern with that code and then just include it into any model that need that table joined.

And even more - you can write an abstract functionality that can work with any model provided to it, cause it looks like you only need to work with one and the same field named tag.