require 'enumerate_by/extensions/associations'
require 'enumerate_by/extensions/base_conditions'
require 'enumerate_by/extensions/serializer'
require 'enumerate_by/extensions/xml_serializer'
# An enumeration defines a finite set of enumerators which (often) have no
# numerical order. This extension provides a general technique for using
# ActiveRecord classes to define enumerations.
module EnumerateBy
# Whether to enable enumeration caching (default is true)
mattr_accessor :perform_caching
self.perform_caching = true
module MacroMethods
def self.extended(base) #:nodoc:
base.class_eval do
# Tracks which associations are backed by an enumeration
# {"foreign key" => "association name"}
class_inheritable_accessor :enumeration_associations
self.enumeration_associations = {}
end
end
# Indicates that this class is an enumeration.
#
# The default attribute used to enumerate the class is +name+. You can
# override this by specifying a custom attribute that will be used to
# *uniquely* reference a record.
#
# *Note* that a presence and uniqueness validation is automatically
# defined for the given attribute since all records must have this value
# in order to be properly enumerated.
#
# Configuration options:
# * :cache - Whether to cache all finder queries for this
# enumeration. Default is true.
#
# == Defining enumerators
#
# The enumerators of the class uniquely identify each record in the
# table. The enumerator value is based on the attribute described above.
# In scenarios where the records are managed in code (like colors,
# countries, states, etc.), records can be automatically synchronized
# via #bootstrap.
#
# == Accessing records
#
# The actual records for an enumeration can be accessed via shortcut
# helpers like so:
#
# Color['red'] # => #
# Color['green'] # => #
#
# When caching is enabled, these lookup queries are cached so that there
# is no performance hit.
#
# == Associations
#
# When using enumerations together with +belongs_to+ associations, the
# enumerator value can be used as a shortcut for assigning the
# association.
#
# In addition, the enumerator value is automatically used during
# serialization (xml and json) of the associated record instead of the
# foreign key for the association.
#
# For more information about how to use enumerations with associations,
# see EnumerateBy::Extensions::Associations and EnumerateBy::Extensions::Serializer.
#
# === Finders
#
# In order to be consistent by always using enumerators to reference
# records, a set of finder extensions are added to allow searching
# for records like so:
#
# class Car < ActiveRecord::Base
# belongs_to :color
# end
#
# Car.find_by_color('red')
# Car.all(:conditions => {:color => 'red'})
#
# For more information about finders, see EnumerateBy::Extensions::BaseConditions.
def enumerate_by(attribute = :name, options = {})
options.reverse_merge!(:cache => true)
options.assert_valid_keys(:cache)
extend EnumerateBy::ClassMethods
extend EnumerateBy::Bootstrapped
include EnumerateBy::InstanceMethods
# The attribute representing a record's enumerator
cattr_accessor :enumerator_attribute
self.enumerator_attribute = attribute
# Whether to perform caching of enumerators within finder queries
cattr_accessor :perform_enumerator_caching
self.perform_enumerator_caching = options[:cache]
# The cache store to use for queries (default is a memory store)
cattr_accessor :enumerator_cache_store
self.enumerator_cache_store = ActiveSupport::Cache::MemoryStore.new
validates_presence_of attribute
validates_uniqueness_of attribute
end
# Does this class define an enumeration? Always false.
def enumeration?
false
end
end
module ClassMethods
# Does this class define an enumeration? Always true.
def enumeration?
true
end
# Finds the record that is associated with the given enumerator. The
# attribute that defines the enumerator is based on what was specified
# when calling +enumerate_by+.
#
# For example,
#
# Color.find_by_enumerator('red') # => #
# Color.find_by_enumerator('invalid') # => nil
def find_by_enumerator(enumerator)
first(:conditions => {enumerator_attribute => enumerator})
end
# Finds the record that is associated with the given enumerator. If no
# record is found, then an ActiveRecord::RecordNotFound exception is
# raised.
#
# For example,
#
# Color['red'] # => #
# Color['invalid'] # => ActiveRecord::RecordNotFound: Couldn't find Color with name "red"
#
# To avoid raising an exception on invalid enumerators, use +find_by_enumerator+.
def find_by_enumerator!(enumerator)
find_by_enumerator(enumerator) || raise(ActiveRecord::RecordNotFound, "Couldn't find #{name} with #{enumerator_attribute} #{enumerator.inspect}")
end
alias_method :[], :find_by_enumerator!
# Finds records with the given enumerators.
#
# For example,
#
# Color.find_all_by_enumerator('red', 'green') # => [#, #]
# Color.find_all_by_enumerator('invalid') # => []
def find_all_by_enumerator(enumerators)
all(:conditions => {enumerator_attribute => enumerators})
end
# Finds records with the given enumerators. If no record is found for a
# particular enumerator, then an ActiveRecord::RecordNotFound exception
# is raised.
#
# For Example,
#
# Color.find_all_by_enumerator!('red', 'green') # => [#, #]
# Color.find_all_by_enumerator!('invalid') # => ActiveRecord::RecordNotFound: Couldn't find Color with name(s) "invalid"
#
# To avoid raising an exception on invalid enumerators, use +find_all_by_enumerator+.
def find_all_by_enumerator!(enumerators)
records = find_all_by_enumerator(enumerators)
missing = [enumerators].flatten - records.map(&:enumerator)
missing.empty? ? records : raise(ActiveRecord::RecordNotFound, "Couldn't find #{name} with #{enumerator_attribute}(s) #{missing.map(&:inspect).to_sentence}")
end
# Adds support for looking up results from the enumeration cache for
# before querying the database.
#
# This allows for enumerations to permanently cache find queries, avoiding
# unnecessary lookups in the database.
[:find_by_sql, :exists?, :calculate].each do |method|
define_method(method) do |*args|
if EnumerateBy.perform_caching && perform_enumerator_caching
enumerator_cache_store.fetch([method] + args) { super }
else
super
end
end
end
# Temporarily disables the enumeration cache (as well as the query cache)
# within the context of the given block if the enumeration is configured
# to allow caching.
def uncached
old = perform_enumerator_caching
self.perform_enumerator_caching = false
super
ensure
self.perform_enumerator_caching = old
end
end
module Bootstrapped
# Synchronizes the given records with existing ones. This ensures that
# only the correct and most up-to-date records exist in the database.
# The sync process is as follows:
# * Any existing record that doesn't match is deleted
# * Existing records with matches are updated based on the given attributes for that record
# * Records that don't exist are created
#
# To create records that can be referenced elsewhere in the database, an
# id should always be specified. Otherwise, records may change id each
# time they are bootstrapped.
#
# == Examples
#
# class Color < ActiveRecord::Base
# enumerate_by :name
#
# bootstrap(
# {:id => 1, :name => 'red'},
# {:id => 2, :name => 'blue'},
# {:id => 3, :name => 'green'}
# )
# end
#
# In the above model, the +colors+ table will be synchronized with the 3
# records passed into the +bootstrap+ helper. Any existing records that
# do not match those 3 are deleted. Otherwise, they are either created or
# updated with the attributes specified.
#
# == Defaults
#
# In addition to *always* synchronizing certain attributes, an additional
# +defaults+ option can be given to indicate that certain attributes
# should only be synchronized if they haven't been modified in the
# database.
#
# For example,
#
# class Color < ActiveRecord::Base
# enumerate_by :name
#
# bootstrap(
# {:id => 1, :name => 'red', :defaults => {:html => '#f00'}},
# {:id => 2, :name => 'blue', :defaults => {:html => '#0f0'}},
# {:id => 3, :name => 'green', :defaults => {:html => '#00f'}}
# )
# end
#
# In the above model, the +name+ attribute will always be updated on
# existing records in the database. However, the +html+ attribute will
# only be synchronized if the attribute is nil in the database.
# Otherwise, any changes to that column remain there.
def bootstrap(*records)
uncached do
# Remove records that are no longer being used
records.flatten!
delete_all(['id NOT IN (?)', records.map {|record| record[:id]}])
existing = all.inject({}) {|existing, record| existing[record.id] = record; existing}
records.map! do |attributes|
attributes.symbolize_keys!
defaults = attributes.delete(:defaults)
# Update with new attributes
record = !existing.include?(attributes[:id]) ? new(attributes) : begin
record = existing[attributes[:id]]
record.attributes = attributes
record
end
record.id = attributes[:id]
# Only update defaults if they aren't already specified
defaults.each {|attribute, value| record[attribute] = value unless record.send("#{attribute}?")} if defaults
# Force failed saves to stop execution
record.save!
record
end
records
end
end
# Quickly synchronizes the given records with the existing ones. This
# disables certain features of ActiveRecord in order to provide a speed
# boost, including:
# * Callbacks
# * Validations
# * Timestamps
# * Dirty attributes
#
# This produces a noticeable performance increase when bootstrapping more
# than several hundred records.
#
# See EnumerateBy::Bootstrapped#bootstrap for information about usage.
def fast_bootstrap(*records)
features = {:callbacks => %w(create create_or_update valid?), :dirty => %w(write_attribute), :validation => %w(save save!)}
features.each do |feature, methods|
methods.each do |method|
method, punctuation = method.sub(/([?!=])$/, ''), $1
alias_method "#{method}_without_bootstrap#{punctuation}", "#{method}#{punctuation}"
alias_method "#{method}#{punctuation}", "#{method}_without_#{feature}#{punctuation}"
end
end
original_record_timestamps = self.record_timestamps
self.record_timestamps = false
bootstrap(*records)
ensure
features.each do |feature, methods|
methods.each do |method|
method, punctuation = method.sub(/([?!=])$/, ''), $1
alias_method "#{method}_without_#{feature}#{punctuation}", "#{method}#{punctuation}"
alias_method "#{method}#{punctuation}", "#{method}_without_bootstrap#{punctuation}"
end
end
self.record_timestamps = original_record_timestamps
end
end
module InstanceMethods
# Whether or not this record is equal to the given value. If the value is
# a String, then it is compared against the enumerator. Otherwise,
# ActiveRecord's default equality comparator is used.
def ==(arg)
arg.is_a?(String) ? self == self.class.find_by_enumerator!(arg) : super
end
# Determines whether this enumeration is in the given list.
#
# For example,
#
# color = Color.find_by_name('red') # => #
# color.in?('green') # => false
# color.in?('red', 'green') # => true
def in?(*list)
list.any? {|item| self === item}
end
# A helper method for getting the current value of the enumerator
# attribute for this record. For example, if this record's model is
# enumerated by the attribute +name+, then this will return the current
# value for +name+.
def enumerator
send(enumerator_attribute)
end
# Stringifies the record typecasted to the enumerator value.
#
# For example,
#
# color = Color.find_by_name('red') # => #
# color.to_s # => "red"
def to_s
to_str
end
# Add support for equality comparison with strings
def to_str
enumerator.to_s
end
end
end
ActiveRecord::Base.class_eval do
extend EnumerateBy::MacroMethods
end