require 'preferences/preference_definition'
# Adds support for defining preferences on ActiveRecord models.
#
# == Saving preferences
#
# Preferences are not automatically saved when they are set. You must save
# the record that the preferences were set on.
#
# For example,
#
# class User < ActiveRecord::Base
# preference :notifications
# end
#
# u = User.new(:login => 'admin', :prefers_notifications => false)
# u.save!
#
# u = User.find_by_login('admin')
# u.attributes = {:prefers_notifications => true}
# u.save!
#
# == Validations
#
# Since the generated accessors for a preference allow the preference to be
# treated just like regular ActiveRecord attributes, they can also be
# validated against in the same way. For example,
#
# class User < ActiveRecord::Base
# preference :color, :string
#
# validates_presence_of :preferred_color
# validates_inclusion_of :preferred_color, :in => %w(red green blue)
# end
#
# u = User.new
# u.valid? # => false
# u.errors.on(:preferred_color) # => "can't be blank"
#
# u.preferred_color = 'white'
# u.valid? # => false
# u.errors.on(:preferred_color) # => "is not included in the list"
#
# u.preferred_color = 'red'
# u.valid? # => true
module Preferences
module MacroMethods
# Defines a new preference for all records in the model. By default,
# preferences are assumed to have a boolean data type, so all values will
# be typecasted to true/false based on ActiveRecord rules.
#
# Configuration options:
# * :default - The default value for the preference. Default is nil.
# * :group_defaults - Defines the default values to use for various
# groups. This should map group_name -> defaults. For ActiveRecord groups,
# use the class name.
#
# == Examples
#
# The example below shows the various ways to define a preference for a
# particular model.
#
# class User < ActiveRecord::Base
# preference :notifications, :default => false
# preference :color, :string, :default => 'red', :group_defaults => {:car => 'black'}
# preference :favorite_number, :integer
# preference :data, :any # Allows any data type to be stored
# end
#
# All preferences are also inherited by subclasses.
#
# == Associations
#
# After the first preference is defined, the following associations are
# created for the model:
# * +stored_preferences+ - A collection of all the custom preferences
# specified for a record. This will not include default preferences
# unless they have been explicitly set.
#
# == Named scopes
#
# In addition to the above associations, the following named scopes get
# generated for the model:
# * +with_preferences+ - Finds all records with a given set of preferences
# * +without_preferences+ - Finds all records without a given set of preferences
#
# In addition to utilizing preferences stored in the database, each of the
# above scopes also take into account the defaults that have been defined
# for each preference.
#
# Example:
#
# User.with_preferences(:notifications => true)
# User.with_preferences(:notifications => true, :color => 'blue')
#
# # Searching with group preferences
# car = Car.find(:first)
# User.with_preferences(car => {:color => 'blue'})
# User.with_preferences(:notifications => true, car => {:color => 'blue'})
#
# == Generated accessors
#
# In addition to calling prefers? and +preferred+ on a record,
# you can also use the shortcut accessor methods that are generated when a
# preference is defined. For example,
#
# class User < ActiveRecord::Base
# preference :notifications
# end
#
# ...generates the following methods:
# * prefers_notifications? - Whether a value has been specified, i.e. record.prefers?(:notifications)
# * prefers_notifications - The actual value stored, i.e. record.prefers(:notifications)
# * prefers_notifications=(value) - Sets a new value, i.e. record.write_preference(:notifications, value)
# * prefers_notifications_changed? - Whether the preference has unsaved changes
# * prefers_notifications_was - The last saved value for the preference
# * prefers_notifications_change - A list of [original_value, new_value] if the preference has changed
# * prefers_notifications_will_change! - Forces the preference to get updated
# * reset_prefers_notifications! - Reverts any unsaved changes to the preference
#
# ...and the equivalent +preferred+ methods:
# * preferred_notifications?
# * preferred_notifications
# * preferred_notifications=(value)
# * preferred_notifications_changed?
# * preferred_notifications_was
# * preferred_notifications_change
# * preferred_notifications_will_change!
# * reset_preferred_notifications!
#
# Notice that there are two tenses used depending on the context of the
# preference. Conventionally, prefers_notifications? is better
# for accessing boolean preferences, while +preferred_color+ is better for
# accessing non-boolean preferences.
#
# Example:
#
# user = User.find(:first)
# user.prefers_notifications? # => false
# user.prefers_notifications # => false
# user.preferred_color? # => true
# user.preferred_color # => 'red'
# user.preferred_color = 'blue' # => 'blue'
#
# user.prefers_notifications = true
#
# car = Car.find(:first)
# user.preferred_color = 'red', car # => 'red'
# user.preferred_color(car) # => 'red'
# user.preferred_color?(car) # => true
#
# user.save! # => true
def preference(name, *args)
unless included_modules.include?(InstanceMethods)
class_inheritable_hash :preference_definitions
self.preference_definitions = {}
has_many :stored_preferences, :as => :owner, :class_name => 'Preference'
after_save :update_preferences
# Named scopes
named_scope :with_preferences, lambda {|preferences| build_preference_scope(preferences)}
named_scope :without_preferences, lambda {|preferences| build_preference_scope(preferences, true)}
extend Preferences::ClassMethods
include Preferences::InstanceMethods
end
# Create the definition
name = name.to_s
definition = PreferenceDefinition.new(name, *args)
self.preference_definitions[name] = definition
# Create short-hand accessor methods, making sure that the name
# is method-safe in terms of what characters are allowed
name = name.gsub(/[^A-Za-z0-9_-]/, '').underscore
# Query lookup
define_method("preferred_#{name}?") do |*group|
preferred?(name, group.first)
end
alias_method "prefers_#{name}?", "preferred_#{name}?"
# Reader
define_method("preferred_#{name}") do |*group|
preferred(name, group.first)
end
alias_method "prefers_#{name}", "preferred_#{name}"
# Writer
define_method("preferred_#{name}=") do |*args|
write_preference(*args.flatten.unshift(name))
end
alias_method "prefers_#{name}=", "preferred_#{name}="
# Changes
define_method("preferred_#{name}_changed?") do |*group|
preference_changed?(name, group.first)
end
alias_method "prefers_#{name}_changed?", "preferred_#{name}_changed?"
define_method("preferred_#{name}_was") do |*group|
preference_was(name, group.first)
end
alias_method "prefers_#{name}_was", "preferred_#{name}_was"
define_method("preferred_#{name}_change") do |*group|
preference_change(name, group.first)
end
alias_method "prefers_#{name}_change", "preferred_#{name}_change"
define_method("preferred_#{name}_will_change!") do |*group|
preference_will_change!(name, group.first)
end
alias_method "prefers_#{name}_will_change!", "preferred_#{name}_will_change!"
define_method("reset_preferred_#{name}!") do |*group|
reset_preference!(name, group.first)
end
alias_method "reset_prefers_#{name}!", "reset_preferred_#{name}!"
definition
end
end
module ClassMethods #:nodoc:
# Generates the scope for looking under records with a specific set of
# preferences associated with them.
#
# Note thate this is a bit more complicated than usual since the preference
# definitions aren't in the database for joins, defaults need to be accounted
# for, and querying for the the presence of multiple preferences requires
# multiple joins.
def build_preference_scope(preferences, inverse = false)
joins = []
statements = []
values = []
# Flatten the preferences for easier processing
preferences = preferences.inject({}) do |result, (group, value)|
if value.is_a?(Hash)
value.each {|preference, value| result[[group, preference]] = value}
else
result[[nil, group]] = value
end
result
end
preferences.each do |(group, preference), value|
group_id, group_type = Preference.split_group(group)
preference = preference.to_s
definition = preference_definitions[preference.to_s]
value = definition.type_cast(value)
is_default = definition.default_value(group_type) == value
table = "preferences_#{group_id}_#{group_type}_#{preference}"
# Since each preference is a different record, they need their own
# join so that the proper conditions can be set
joins << "LEFT JOIN preferences AS #{table} ON #{table}.owner_id = #{table_name}.#{primary_key} AND " + sanitize_sql(
"#{table}.owner_type" => base_class.name.to_s,
"#{table}.group_id" => group_id,
"#{table}.group_type" => group_type,
"#{table}.name" => preference
)
if inverse
statements << "#{table}.id IS NOT NULL AND #{table}.value " + (value.nil? ? ' IS NOT NULL' : ' != ?') + (!is_default ? " OR #{table}.id IS NULL" : '')
else
statements << "#{table}.id IS NOT NULL AND #{table}.value " + (value.nil? ? ' IS NULL' : ' = ?') + (is_default ? " OR #{table}.id IS NULL" : '')
end
values << value unless value.nil?
end
sql = statements.map! {|statement| "(#{statement})"} * ' AND '
{:joins => joins, :conditions => values.unshift(sql)}
end
end
module InstanceMethods
def self.included(base) #:nodoc:
base.class_eval do
alias_method :prefs, :preferences
end
end
# Finds all preferences, including defaults, for the current record. If
# looking up custom group preferences, then this will include all default
# preferences within that particular group as well.
#
# == Examples
#
# A user with no stored values:
#
# user = User.find(:first)
# user.preferences
# => {"language"=>"English", "color"=>nil}
#
# A user with stored values for a particular group:
#
# user.preferred_color = 'red', :cars
# user.preferences(:cars)
# => {"language=>"English", "color"=>"red"}
def preferences(group = nil)
preferences = preferences_group(group)
unless preferences_group_loaded?(group)
group_id, group_type = Preference.split_group(group)
find_preferences(:group_id => group_id, :group_type => group_type).each do |preference|
preferences[preference.name] = preference.value unless preferences.include?(preference.name)
end
# Add defaults
preference_definitions.each do |name, definition|
preferences[name] = definition.default_value(group_type) unless preferences.include?(name)
end
end
preferences.inject({}) do |typed_preferences, (name, value)|
typed_preferences[name] = value.nil? ? value : preference_definitions[name].type_cast(value)
typed_preferences
end
end
# Queries whether or not a value is present for the given preference.
# This is dependent on how the value is type-casted.
#
# == Examples
#
# class User < ActiveRecord::Base
# preference :color, :string, :default => 'red'
# end
#
# user = User.create
# user.preferred(:color) # => "red"
# user.preferred?(:color) # => true
# user.preferred?(:color, 'cars') # => true
# user.preferred?(:color, Car.first) # => true
#
# user.write_preference(:color, nil)
# user.preferred(:color) # => nil
# user.preferred?(:color) # => false
def preferred?(name, group = nil)
name = name.to_s
assert_valid_preference(name)
value = preferred(name, group)
preference_definitions[name].query(value)
end
alias_method :prefers?, :preferred?
# Gets the actual value stored for the given preference, or the default
# value if nothing is present.
#
# == Examples
#
# class User < ActiveRecord::Base
# preference :color, :string, :default => 'red'
# end
#
# user = User.create
# user.preferred(:color) # => "red"
# user.preferred(:color, 'cars') # => "red"
# user.preferred(:color, Car.first) # => "red"
#
# user.write_preference(:color, 'blue')
# user.preferred(:color) # => "blue"
def preferred(name, group = nil)
name = name.to_s
assert_valid_preference(name)
if preferences_group(group).include?(name)
# Value for this group/name has been written, but not saved yet:
# grab from the pending values
value = preferences_group(group)[name]
else
# Grab the first preference; if it doesn't exist, use the default value
group_id, group_type = Preference.split_group(group)
preference = find_preferences(:name => name, :group_id => group_id, :group_type => group_type).first unless preferences_group_loaded?(group)
value = preference ? preference.value : preference_definitions[name].default_value(group_type)
preferences_group(group)[name] = value
end
definition = preference_definitions[name]
value = definition.type_cast(value) unless value.nil?
value
end
alias_method :prefers, :preferred
# Sets a new value for the given preference. The actual Preference record
# is *not* created until this record is saved. In this way, preferences
# act *exactly* the same as attributes. They can be written to and
# validated against, but won't actually be written to the database until
# the record is saved.
#
# == Examples
#
# user = User.find(:first)
# user.write_preference(:color, 'red') # => "red"
# user.save!
#
# user.write_preference(:color, 'blue', Car.first) # => "blue"
# user.save!
def write_preference(name, value, group = nil)
name = name.to_s
assert_valid_preference(name)
preferences_changed = preferences_changed_group(group)
if preferences_changed.include?(name)
old = preferences_changed[name]
preferences_changed.delete(name) unless preference_value_changed?(name, old, value)
else
old = clone_preference_value(name, group)
preferences_changed[name] = old if preference_value_changed?(name, old, value)
end
value = convert_number_column_value(value) if preference_definitions[name].number?
preferences_group(group)[name] = value
value
end
# Whether any attributes have unsaved changes.
#
# == Examples
#
# user = User.find(:first)
# user.preferences_changed? # => false
# user.write_preference(:color, 'red')
# user.preferences_changed? # => true
# user.save
# user.preferences_changed? # => false
#
# # Groups
# user.preferences_changed?(:car) # => false
# user.write_preference(:color, 'red', :car)
# user.preferences_changed(:car) # => true
def preferences_changed?(group = nil)
!preferences_changed_group(group).empty?
end
# A list of the preferences that have unsaved changes.
#
# == Examples
#
# user = User.find(:first)
# user.preferences_changed # => []
# user.write_preference(:color, 'red')
# user.preferences_changed # => ["color"]
# user.save
# user.preferences_changed # => []
#
# # Groups
# user.preferences_changed(:car) # => []
# user.write_preference(:color, 'red', :car)
# user.preferences_changed(:car) # => ["color"]
def preferences_changed(group = nil)
preferences_changed_group(group).keys
end
# A map of the preferences that have changed in the current object.
#
# == Examples
#
# user = User.find(:first)
# user.preferred(:color) # => nil
# user.preference_changes # => {}
#
# user.write_preference(:color, 'red')
# user.preference_changes # => {"color" => [nil, "red"]}
# user.save
# user.preference_changes # => {}
#
# # Groups
# user.preferred(:color, :car) # => nil
# user.preference_changes(:car) # => {}
# user.write_preference(:color, 'red', :car)
# user.preference_changes(:car) # => {"color" => [nil, "red"]}
def preference_changes(group = nil)
preferences_changed(group).inject({}) do |changes, preference|
changes[preference] = preference_change(preference, group)
changes
end
end
# Reloads the pereferences of this object as well as its attributes
def reload(*args) #:nodoc:
result = super
@preferences.clear if @preferences
@preferences_changed.clear if @preferences_changed
result
end
private
# Asserts that the given name is a valid preference in this model. If it
# is not, then an ArgumentError exception is raised.
def assert_valid_preference(name)
raise(ArgumentError, "Unknown preference: #{name}") unless preference_definitions.include?(name)
end
# Gets the set of preferences identified by the given group
def preferences_group(group)
@preferences ||= {}
@preferences[group.is_a?(Symbol) ? group.to_s : group] ||= {}
end
# Determines whether the given group of preferences has already been
# loaded from the database
def preferences_group_loaded?(group)
preference_definitions.length == preferences_group(group).length
end
# Generates a clone of the current value stored for the preference with
# the given name / group
def clone_preference_value(name, group)
value = preferred(name, group)
value.duplicable? ? value.clone : value
rescue TypeError, NoMethodError
value
end
# Keeps track of all preferences that have been changed so that they can
# be properly updated in the database. Maps group -> preference -> value.
def preferences_changed_group(group)
@preferences_changed ||= {}
@preferences_changed[group.is_a?(Symbol) ? group.to_s : group] ||= {}
end
# Determines whether a preference changed in the given group
def preference_changed?(name, group)
preferences_changed_group(group).include?(name)
end
# Builds an array of [original_value, new_value] for the given preference.
# If the perference did not change, this will return nil.
def preference_change(name, group)
[preferences_changed_group(group)[name], preferred(name, group)] if preference_changed?(name, group)
end
# Gets the last saved value for the given preference
def preference_was(name, group)
preference_changed?(name, group) ? preferences_changed_group(group)[name] : preferred(name, group)
end
# Forces the given preference to be saved regardless of whether the value
# is actually diferent
def preference_will_change!(name, group)
preferences_changed_group(group)[name] = clone_preference_value(name, group)
end
# Reverts any unsaved changes to the given preference
def reset_preference!(name, group)
write_preference(name, preferences_changed_group(group)[name], group) if preference_changed?(name, group)
end
# Determines whether the old value is different from the new value for the
# given preference. This will use the typecasted value to determine
# equality.
def preference_value_changed?(name, old, value)
definition = preference_definitions[name]
if definition.type == :integer && (old.nil? || old == 0)
# For nullable numeric columns, NULL gets stored in database for blank (i.e. '') values.
# Hence we don't record it as a change if the value changes from nil to ''.
# If an old value of 0 is set to '' we want this to get changed to nil as otherwise it'll
# be typecast back to 0 (''.to_i => 0)
value = nil if value.blank?
else
value = definition.type_cast(value)
end
old != value
end
# Updates any preferences that have been changed/added since the record
# was last saved
def update_preferences
if @preferences_changed
@preferences_changed.each do |group, preferences|
group_id, group_type = Preference.split_group(group)
preferences.keys.each do |name|
# Find an existing preference or build a new one
attributes = {:name => name, :group_id => group_id, :group_type => group_type}
preference = find_preferences(attributes).first || stored_preferences.build(attributes)
preference.value = preferred(name, group)
preference.save!
end
end
@preferences_changed.clear
end
end
# Finds all stored preferences with the given attributes. This will do a
# smart lookup by looking at the in-memory collection if it was eager-
# loaded.
def find_preferences(attributes)
if stored_preferences.loaded?
stored_preferences.select do |preference|
attributes.all? {|attribute, value| preference[attribute] == value}
end
else
stored_preferences.find(:all, :conditions => attributes)
end
end
end
end
ActiveRecord::Base.class_eval do
extend Preferences::MacroMethods
end