Module: Invoicing::FindSubclasses

Defined in:
lib/invoicing/find_subclasses.rb

Overview

Subclass-aware filtering by class methods

Utility module which can be mixed into ActiveRecord::Base 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 Class#inherited 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, Class#inherited 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, ActiveSupport::Dependencies 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 SELECT DISTINCT type FROM table_name), 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 ;-)

Instance Method Summary collapse

Instance Method Details

#expand_hash_conditions_for_aggregates(attrs) ⇒ Object



92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
# File 'lib/invoicing/find_subclasses.rb', line 92

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

#inherited(subclass) ⇒ Object

Ruby callback which is invoked when a subclass is created. We use this to build a list of known subclasses.



133
134
135
136
# File 'lib/invoicing/find_subclasses.rb', line 133

def inherited(subclass)
  remember_subclass subclass
  super
end

#known_subclasses(table = table_name, type_column = inheritance_column) ⇒ Object

Return the list of all known subclasses of this class, if necessary checking the database for classes which have not yet been loaded.



147
148
149
150
# File 'lib/invoicing/find_subclasses.rb', line 147

def known_subclasses(table = table_name, type_column = inheritance_column)
  load_all_subclasses_found_in_database(table, type_column)
  @known_subclasses ||= [self]
end

#remember_subclass(subclass) ⇒ Object

Add subclass to the list of know subclasses of this class.



139
140
141
142
143
# File 'lib/invoicing/find_subclasses.rb', line 139

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

#select_matching_subclasses(method_name, value, table = table_name, type_column = inheritance_column) ⇒ Object

Returns a list of those classes within known_subclasses which match a condition method_name => value. May raise NoMethodError if a class object does not respond to method_name.



120
121
122
123
124
125
126
127
128
129
# File 'lib/invoicing/find_subclasses.rb', line 120

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