secret
Modify a core class ... secretly
Add features to a core class (ActiveRecord) without really adding them
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 dup
ing 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 methodactivate_secret_extensions
- Calling
activate_secret_extensions
adds any methods defined inActiveRecordExtension::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 callactivate_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)