lib/invoicing/find_subclasses.rb
module Invoicing
# = Subclass-aware filtering by class methods
#
# Utility module which can be mixed into <tt>ActiveRecord::Base</tt> subclasses which use
# single table inheritance. It enables you to query the database for model objects based
# on static class properties without having to instantiate more model objects than necessary.
# Its methods should be used as class methods, so the module should be mixed in using +extend+.
#
# For example:
#
# class Product < ActiveRecord::Base
# extend Invoicing::FindSubclasses
# def self.needs_refrigeration; false; end
# end
#
# class Food < Product; end
# class Bread < Food; end
# class Yoghurt < Food
# def self.needs_refrigeration; true; end
# end
# class GreekYoghurt < Yoghurt; end
#
# class Drink < Product; end
# class SoftDrink < Drink; end
# class Smoothie < Drink
# def self.needs_refrigeration; true; end
# end
#
# So we know that all +Yoghurt+ and all +Smoothie+ objects need refrigeration (including subclasses
# of +Yoghurt+ and +Smoothly+, unless they override +needs_refrigeration+ again), and the others
# don't. This fact is defined through a class method and not stored in the database. It needn't
# necessarily be constant -- you could make +needs_refrigeration+ return +true+ or +false+
# depending on the current temperature, for example.
#
# Now assume that in your application you need to query all objects which need refrigeration
# (and maybe also satisfy some other conditions). Since the database knows nothing about
# +needs_refrigeration+, what you would have to do traditionally is to instantiate all objects
# and then to filter them yourself, i.e.
#
# Product.find(:all).select{|p| p.class.needs_refrigeration}
#
# However, if only a small proportion of your products needs refrigeration, this requires you to
# load many more objects than necessary, putting unnecessary load on your application. With the
# +FindSubclasses+ module you can let the database do the filtering instead:
#
# Product.find(:all, :conditions => {:needs_refrigeration => true})
#
# You could even define a named scope to do the same thing:
#
# class Product
# named_scope :refrigerated_products, :conditions => {:needs_refrigeration => true})
# end
#
# Much nicer! The condition looks precisely like a condition on a database table column, even
# though it actually refers to a class method. Under the hood, this query translates into:
#
# Product.find(:all, :conditions => {:type => ['Yoghurt', 'GreekYoghurt', 'Smoothie']})
#
# And of course you can combine it with normal conditions on database table columns. If there
# is a table column and a class method with the same name, +FindSublasses+ remains polite and lets
# the table column take precedence.
#
# == How it works
#
# +FindSubclasses+ relies on having a list of all subclasses of your single-table-inheritance
# base class; then, if you specify a condition with a key which has no corresponding database
# table column, +FindSubclasses+ will check all subclasses for the return value of a class
# method with that name, and search for the names of classes which match the condition.
#
# Purists of object-oriented programming will most likely find this appalling, and it's important
# to know the limitations. In Ruby, a class can be notified if it subclassed, by defining the
# <tt>Class#inherited</tt> method; we use this to gather together a list of subclasses. Of course,
# we won't necessarily know about every class in the world which may subclass our class; in
# particular, <tt>Class#inherited</tt> won't be called until that subclass is loaded.
#
# If you're including the Ruby files with the subclass definitions using +require+, we will learn
# about subclasses as soon as they are defined. However, if class loading is delayed until a
# class is first used (for example, <tt>ActiveSupport::Dependencies</tt> does this with model
# objects in Rails projects), we could run into a situation where we don't yet know about all
# subclasses used in a project at the point where we need to process a class method condition.
# This would cause us to omit some objects we should have found.
#
# To prevent this from happening, this module searches for all types of object currently stored
# in the table (along the lines of <tt>SELECT DISTINCT type FROM table_name</tt>), and makes sure
# all class names mentioned there are loaded before evaluating a class method condition. Note that
# this doesn't necessarily load all subclasses, but at least it loads those which currently have
# instances stored in the database, so we won't omit any objects when selecting from the table.
# There is still room for race conditions to occur, but otherwise it should be fine. If you want
# to be on the safe side you can ensure all subclasses are loaded when your application
# initialises -- but that's not completely DRY ;-)
module FindSubclasses
def expand_hash_conditions_for_aggregates(attrs)
new_attrs = {}
attrs.each_pair do |attr, value|
attr = attr_base = attr.to_s
attr_table_name = table_name
# Extract table name from qualified attribute names
attr_table_name, attr_base = attr.split('.', 2) if attr.include?('.')
if columns_hash.include?(attr_base) || ![self.table_name, quoted_table_name].include?(attr_table_name)
new_attrs[attr] = value # Condition on a table column, or another table -- pass through unmodified
else
begin
matching_classes = select_matching_subclasses(attr_base, value)
new_attrs["#{self.table_name}.#{inheritance_column}"] = matching_classes.map{|cls| cls.name.to_s}
rescue NoMethodError
new_attrs[attr] = value # If the class method doesn't exist, fall back to passing condition through unmodified
end
end
end
super(new_attrs)
end
# Returns a list of those classes within +known_subclasses+ which match a condition
# <tt>method_name => value</tt>. May raise +NoMethodError+ if a class object does not
# respond to +method_name+.
def select_matching_subclasses(method_name, value, table = table_name, type_column = inheritance_column)
known_subclasses(table, type_column).select do |cls|
returned = cls.send(method_name)
(returned == value) or case value
when true then !!returned
when false then !returned
when Array, Range then value.include?(returned)
end
end
end
# Ruby callback which is invoked when a subclass is created. We use this to build a list of known
# subclasses.
def inherited(subclass)
remember_subclass subclass
super
end
# Add +subclass+ to the list of know subclasses of this class.
def remember_subclass(subclass)
@known_subclasses ||= [self]
@known_subclasses << subclass unless @known_subclasses.include? subclass
self.superclass.remember_subclass(subclass) if self.superclass.respond_to? :remember_subclass
end
# Return the list of all known subclasses of this class, if necessary checking the database for
# classes which have not yet been loaded.
def known_subclasses(table = table_name, type_column = inheritance_column)
load_all_subclasses_found_in_database(table, type_column)
@known_subclasses ||= [self]
end
private
# Query the database for all qualified class names found in the +type_column+ column
# (called +type+ by default), and check that classes of that name have been loaded by the Ruby
# interpreter. If a type name is encountered which cannot be loaded,
# <tt>ActiveRecord::SubclassNotFound</tt> is raised.
#
# TODO: Cache this somehow, to avoid querying for class names more often than necessary. It's not
# obvious though how to do this best -- a different Ruby instance may insert a row into the
# database with a type which is not yet loaded in this interpreter. Maybe reloading the list
# of types from the database every 30-60 seconds or so would be a compromise?
def load_all_subclasses_found_in_database(table = table_name, type_column = inheritance_column)
quoted_table_name = connection.quote_table_name(table)
quoted_inheritance_column = connection.quote_column_name(type_column)
query = "SELECT DISTINCT #{quoted_inheritance_column} FROM #{quoted_table_name}"
for subclass_name in connection.select_all(query).map{|record| record[type_column]}
unless subclass_name.blank? # empty string or nil means base class
begin
compute_type(subclass_name)
rescue NameError
raise ActiveRecord::SubclassNotFound, # Error message borrowed from ActiveRecord::Base
"The single-table inheritance mechanism failed to locate the subclass: '#{subclass_name}'. " +
"This error is raised because the column '#{type_column}' is reserved for storing the class in case of inheritance. " +
"Please rename this column if you didn't intend it to be used for storing the inheritance class " +
"or overwrite #{self.to_s}.inheritance_column to use another column for that information."
end
end
end
end
end
end