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 => typecast_enumerator(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} #{typecast_enumerator(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 => typecast_enumerator(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)
enumerators = [enumerators].flatten
records = find_all_by_enumerator(enumerators)
missing = enumerators - 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(*args) }
else
super(*args)
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
private
# Typecasts the given enumerator to its actual value stored in the
# database. This will only convert symbols to strings. All other values
# will remain in the same type.
def typecast_enumerator(enumerator)
if enumerator.is_a?(Array)
enumerator.flatten!
enumerator.map! {|value| typecast_enumerator(value)}
enumerator
else
enumerator.is_a?(Symbol) ? enumerator.to_s : enumerator
end
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!
ids = records.map {|record| record[:id]}.compact
delete_all(ids.any? ? ['id NOT IN (?)', ids] : nil)
# Find remaining existing records (to be updated)
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 =
if record = existing[attributes[:id]]
attributes.merge!(defaults.delete_if {|attribute, value| record.send("#{attribute}?")}) if defaults
record.attributes = attributes
record
else
attributes.merge!(defaults) if defaults
new(attributes)
end
record.id = attributes[:id]
# Force failed saves to stop execution
record.save!
record
end
records
end
end
# Quickly synchronizes the given records with the existing ones. This
# skips ActiveRecord altogether, interacting directly with the connection
# instead. As a result, certain features are not available when being
# bootstrapped, including:
# * Callbacks
# * Validations
# * Transactions
# * Timestamps
# * Dirty attributes
#
# Also note that records are created directly without creating instances
# of the model. As a result, all of the attributes for the record must be
# specified.
#
# This produces a significant performance increase when bootstrapping more
# than several hundred records.
#
# See EnumerateBy::Bootstrapped#bootstrap for information about usage.
def fast_bootstrap(*records)
# Remove records that are no longer being used
records.flatten!
ids = records.map {|record| record[:id]}.compact
delete_all(ids.any? ? ['id NOT IN (?)', ids] : nil)
# Find remaining existing records (to be updated)
quoted_table_name = self.quoted_table_name
existing = connection.select_all("SELECT * FROM #{quoted_table_name}").inject({}) {|existing, record| existing[record['id'].to_i] = record; existing}
records.each do |attributes|
attributes.stringify_keys!
if defaults = attributes.delete('defaults')
defaults.stringify_keys!
end
id = attributes['id']
if existing_attributes = existing[id]
# Record exists: Update attributes
attributes.delete('id')
attributes.merge!(defaults.delete_if {|attribute, value| !existing_attributes[attribute].nil?}) if defaults
update_all(attributes, :id => id)
else
# Record doesn't exist: create new one
attributes.merge!(defaults) if defaults
column_names = []
values = []
attributes.each do |column_name, value|
column_names << connection.quote_column_name(column_name)
values << connection.quote(value, columns_hash[column_name])
end
connection.insert(
"INSERT INTO #{quoted_table_name} (#{column_names * ', '}) VALUES(#{values * ', '})",
"#{name} Create", primary_key, id, sequence_name
)
end
end
true
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.nil? || arg.is_a?(self.class) ? super : self == self.class.find_by_enumerator!(arg)
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