Add features to a core class (ActiveRecord) without really adding them

tl; dr

I’ve been keeping a file of extensions to ActiveRecord that I find useful. I’ve blooged about one of them already. In many cases, while poking around our app in the console, I’ve wanted to use some of these features. To the extent that my toys are confined to their own modules, that’s fine, but I feel a little sketchy about adding a list of untested features to ActiveRecord::Base. Other developers in my team start finding and using them, then we discover a bug, and I don’t have time to support it, and we have a mess. So these extensions had not, until recently made it into our app.

However, there came a time when I really wanted one of these features actually used by our app. Specifically, bulk_insert for a JobAccess model – I’ll write a post about it soon. This was going to be a huge time save in a cricitcal part of our app. I was comfortable using my bulk_insert extension for this case beause I knew its behavior well enough to know that it would work with this model. And I wanted to use clean code rather than writing out manual SQL that would be a pain to maintain and update as the model changed. Nevertheless, I still didn’t want it generally available, at least easily, much less other features in my ActiveRecordExtensions module.

So here was my compromise:

JobAccess.activate_secret_extensions.bulk_insert(...)

The use here is that anyone can access the extensions by chaining the activate_secret_extensions method. It’s simply and clean to use. But the method name itself serves as the red flag that this isn’t a core feature and that it might not be the best solution unless you know what you’re doing.

The requirements here are that (1) activate_secret_extensions has to return an object that is like a JobAccess_Relation in all ways except that it has additional extnesions attached, but (2) these extensions have to be limited to this call and should not be added to JobAccess generally. Thus modifying the JobAccess class in place is not an option.

This is ruby, and classes are just objects. Objects have singleton classes (this is a really good read on that if you are looking for one). When I see a task like this, I think of modifying the singleton class. This can work really well to add methods to a specific instance without affecting the class.

But there’s a problem. The instance here is in fact the constant class JobAccess. If we modify its singleton class, we’re modifying the singleton class of JobAccess itself. This violates requirement #2.

So at first I tried things like duping the class. This worked for the single line in question, but has the potential of leading to some major headaches because a lot of things depend on matching an ActiveRecord class or its name.

module Extension; def speak; "softly"; end; end
ExtendedJobAccess = JobAccess.dup
ExtendedJobAccess.include(Extension)
ExtendedJobAccess.first
# => #<ExtendedJobAccess id: ... >
ExtendedJobAccess.first.speak
# => "softly"
JobAccess.first.speak
# NoMethodError: undefined method `speak' for #<JobAccess:0x007fc27b130390>

So far so good…

ExtendedJobAccess.first.is_a? JobAccess
# => false

Less good… this could break a lot of code. Things get even worse with STI models, since these rely on the class name. Consdier TranscriptionService < Service:

ExtendedTranscriptionService = TranscriptionService.dup
TranscriptionService.count
# => 934997
ExtendedTranscriptionService.count
# => 0

You can see why by examining the query:

SELECT COUNT(*) FROM `services` WHERE `services`.`type` IN ('ExtendedTranscriptionService') AND (services.deleted = 0)

There are no elements of the services table that have `TYPE = ‘ExtendedTranscriptionService’. This is a show stopper.

Here’s the approach I settled on, which works quite well. You may have noticed that ActiveRecord blurs the lines between class methods and collection methods. You can define class methods on JobAccess and use them anywhere in a scope chain: JobAccess.scope1.scope2.class_method1.scope3.class_method2 and so on. What’s actually going on is that these class methods on your model get defined as instance methods on a ActiveRecord_Relation model namespaced under your model:

JobAccess.all.class     ## Note: Rails 4 syntax. In Rails 3, use JobAccess.scoped
# => JobAccess::ActiveRecord_Relation

This relation model contains all the methods you probably think of as “class” methods on your model:

JobAccess::ActiveRecord_Relation.instance_methods.include?(:where)
 => true

This opens up a much neater approach. Since any query on the class JobAccess returns an instance of a JobAcess::ActiveRecord_Relation, we can modify the singleton class of this instance and we’re in the clear! The only remaining catch is that we want the methods to exist on JobAccess as well as the relation, but when we’re calling it from JobAccess itself we don’t yet have a relation. So we can create one:

module ActiveRecordExtension
  module Base
    def activate_secret_extensions
      relation = self.is_a?(Class) ? self.all : self
      relation.singleton_class.include(ActiveRecordExtension::SecretExtension)
      return relation
    end
  end
end

ActiveRecord::Base.extend(ActiveRecordExtension::Base)

So now,

  • All of ActiveRecord::Base has access to the method activate_secret_extensions
  • Calling activate_secret_extensions adds any methods defined in ActiveRecordExtension::Base, which is pasted below for reference.
  • ActiveRecord then for free gives us chainable methods for free – this just just like defining class methods on JobAccess, except these method definitions are limited to this specific query. As a bonus, these methods do in fact propagate through the chain, so we only need to call activate_secret_extensions once for a given object in memory.

Here’s my current list of secretly-accepted extensions

module ActiveRecordExtension
  module SecretExtension
    def bulk_insert_sql(attribute_array)
      fields = attribute_array.first.keys
      values = attribute_array.map do |attrs|
        attrs.keys == fields or raise ArgumentError, "Attribute array must all have the same keys. Expected #{fields * ', '}, got #{attrs.keys * ', '}"
        fields.map{|key| self.sanitize(attrs[key])}
      end
      fields_string = "(" + fields.map{|f| "`" + f.to_s.gsub(/`/, "") + "`"} * ", " + ")"
      values_string = values.map{|vals| "(" + vals * ", " + ")"} * ", "
      return "INSERT INTO #{self.table_name} #{fields_string} VALUES #{values_string}"
    end

    def bulk_insert(attribute_array)
      return if attribute_array.empty?
      self.connection.execute(bulk_insert_sql(attribute_array))
    end

    # Simple left join taking advantage of existing Rails & Arel code
    def left_joins(*args)
      inner_joins = self.joins(*args).arel.join_sources
      left_joins = inner_joins.map do |join|
        Arel::Nodes::OuterJoin.new(join.left, join.right)
      end
      self.joins(left_joins)
    end

    def _unscoped_joins(*args)
      arel = self.klass.all.arel
      unscoped_joins = self.joins(*args).arel.join_sources.map do |join|
        join_condition = join.right.expr.children.first
        foreign = join_condition.left.relation
        arel = arel.join(foreign).on(join_condition)
      end
      self.joins(arel.join_sources)
    end

    # Find records of self where no records of given association exist
    def without(assoc_name)
      assoc = self.reflect_on_association(assoc_name)
      self.left_joins(assoc_name).where(assoc.table_name => {assoc.klass.primary_key => nil})
    end

    # perform a count of results even if GROUP BY was issued
    def outer_count
      self.connection.execute("select COUNT(*) from (#{self.all.to_sql}) results").first.first
    end
  end

  module Base
    def activate_secret_extensions
      relation = self.is_a?(Class) ? self.all : self
      relation.singleton_class.include(ActiveRecordExtension::SecretExtension)
      return relation
    end
  end

end


ActiveRecord::Base.extend(ActiveRecordExtension::Base)