# Copyright (c) 2005 Trevor Squires # Copyright (c) 2012 Arthur Shagall # Released under the MIT License. See the LICENSE file for more details. module PowerEnum # Implementation of acts_as_enumerated module Enumerated extend ActiveSupport::Concern # Class level methods injected into ActiveRecord. module ClassMethods # Returns false for ActiveRecord models that do not act as enumerated. def acts_as_enumerated? false end # Declares the model as enumerated. See the README for detailed usage instructions. # # === Supported options # [:conditions] # SQL search conditions # [:order] # SQL load order clause # [:on_lookup_failure] # Specifies the name of a class method to invoke when the +[]+ method is unable to locate a BookingStatus # record for arg. The default is the built-in :enforce_none which returns nil. There are also built-ins for # :enforce_strict (raise and exception regardless of the type for arg), :enforce_strict_literals (raises an # exception if the arg is a Integer or Symbol), :enforce_strict_ids (raises and exception if the arg is a # Integer) and :enforce_strict_symbols (raises an exception if the arg is a Symbol). The purpose of the # :on_lookup_failure option is that a) under some circumstances a lookup failure is a Bad Thing and action # should be taken, therefore b) a fallback action should be easily configurable. You can also give it a # lambda that takes in a single argument (The arg that was passed to +[]+). # [:name_column] # Override for the 'name' column. By default, assumed to be 'name'. # [:alias_name] # By default, if a name column is not 'name', will create an alias of 'name' to the name_column attribute. Set # this to +false+ if you don't want this behavior. # [:freeze_members] # Specifies whether individual enum instances should be frozen on database load. By default, true in production. # Can be either a lambda or a boolean. # # === Examples # # ====Example 1 # class BookingStatus < ActiveRecord::Base # acts_as_enumerated # end # # ====Example 2 # class BookingStatus < ActiveRecord::Base # acts_as_enumerated :on_lookup_failure => :enforce_strict # end # # ====Example 3 # class BookingStatus < ActiveRecord::Base # acts_as_enumerated :conditions => [:exclude => false], # :order => 'created_at DESC', # :on_lookup_failure => :lookup_failed, # :name_column => :status_code # # def self.lookup_failed(arg) # logger.error("Invalid status code lookup #{arg.inspect}") # nil # end # end # # ====Example 4 # class BookingStatus < ActiveRecord::Base # acts_as_enumerated :conditions => [:exclude => false], # :order => 'created_at DESC', # :on_lookup_failure => lambda { |arg| raise CustomError, "BookingStatus lookup failed; #{arg}" }, # :name_column => :status_code, # :freeze_members => true # end def acts_as_enumerated(options = {}) valid_keys = [:conditions, :order, :on_lookup_failure, :name_column, :alias_name, :freeze_members] options.assert_valid_keys(*valid_keys) valid_keys.each do |key| class_attribute "acts_enumerated_#{key.to_s}" if options.has_key?( key ) self.send "acts_enumerated_#{key.to_s}=", options[key] end end self.acts_enumerated_name_column = get_name_column(options) unless self.is_a? PowerEnum::Enumerated::EnumClassMethods preserve_query_aliases extend_enum_class_methods( options ) end end # Rails 4 delegates all the finder methods to 'all'. PowerEnum overrides 'all'. Hence, # the need to re-alias the query methods. def preserve_query_aliases class << self # I have to do the interesting hack below instead of using alias_method # because there's some sort of weirdness going on with how __all binds # to all in Ruby 2.0. __all = self.instance_method(:all) define_method(:__all) do __all.bind(self).call end # From ActiveRecord::Querying delegate :find, :take, :take!, :first, :first!, :last, :last!, :exists?, :any?, :many?, :to => :__all delegate :first_or_create, :first_or_create!, :first_or_initialize, :to => :__all delegate :find_or_create_by, :find_or_create_by!, :find_or_initialize_by, :to => :__all delegate :find_by, :find_by!, :to => :__all delegate :destroy, :destroy_all, :delete, :delete_all, :update, :update_all, :to => :__all delegate :find_each, :find_in_batches, :to => :__all delegate :select, :group, :order, :except, :reorder, :limit, :offset, :joins, :where, :preload, :eager_load, :includes, :from, :lock, :readonly, :having, :create_with, :uniq, :distinct, :references, :none, :unscope, :to => :__all delegate :count, :average, :minimum, :maximum, :sum, :calculate, :pluck, :ids, :to => :__all end end # Injects the class methods into model def extend_enum_class_methods(options) #:nodoc: extend PowerEnum::Enumerated::EnumClassMethods class_eval do include PowerEnum::Enumerated::EnumInstanceMethods before_save :enumeration_model_update before_destroy :enumeration_model_update validates acts_enumerated_name_column, :presence => true, :uniqueness => true validate :validate_enumeration_model_updates_permitted define_method :__enum_name__ do read_attribute(acts_enumerated_name_column).to_s end if should_alias_name?(options) && acts_enumerated_name_column != :name alias_method :name, :__enum_name__ end end # class_eval end # Determines if the name column should be explicitly aliased def should_alias_name?(options) #:nodoc: if options.has_key?(:alias_name) then options[:alias_name] else true end end # Extracts the name column from options or gives the default def get_name_column(options) #:nodoc: if options.has_key?(:name_column) && !options[:name_column].blank? then options[:name_column].to_s.to_sym else :name end end end # These are class level methods which are patched into classes that act as # enumerated module EnumClassMethods attr_accessor :enumeration_model_updates_permitted # Returns true for ActiveRecord models that act as enumerated. def acts_as_enumerated? true end # Returns all the enum values. Caches results after the first time this method is run. def all return @all if @all freeze_handler = if (handler = self.acts_enumerated_freeze_members).nil? -> { Rails.env.production? } else case handler when Proc handler else -> { handler } end end @all = load_all.collect{ |val| !!freeze_handler.call ? val.freeze : val }.freeze end # Returns all the active enum values. See the 'active?' instance method. def active return @all_active if @all_active @all_active = all.find_all{ |enum| enum.active? }.freeze end # Returns all the inactive enum values. See the 'inactive?' instance method. def inactive return @all_inactive if @all_inactive @all_inactive = all.find_all{ |enum| !enum.active? }.freeze end # Returns the names of all the enum values as an array of symbols. def names all.map { |item| item.name_sym } end # Returns all except for the given list def all_except(*excluded) all.find_all { |item| !(item === excluded) } end # Enum lookup by Symbol, String, or id. Returns arg if arg is # an enum instance. Passing in a list of arguments returns a list of # enums. When called with no arguments, returns nil. def [](*args) case args.size when 0 nil when 1 arg = args.first lookup_enum_by_type(arg) || handle_lookup_failure(arg) else args.map{ |item| self[item] }.uniq end end # Returns true if the given Symbol, String or id has a member # instance in the enumeration, false otherwise. Returns true # if the argument is an enum instance, returns false if the argument # is nil or any other value. def contains?(arg) case arg when Symbol !!lookup_name(arg.id2name) when String !!lookup_name(arg) when Integer !!lookup_id(arg) when self true else false end end # Enum lookup by id def lookup_id(arg) all_by_id[arg] end # Enum lookup by String def lookup_name(arg) all_by_name[arg] end # Returns true if the enum lookup by the given Symbol, String or id would have returned a value, false otherwise. def include?(arg) case arg when Symbol !lookup_name(arg.id2name).nil? when String !lookup_name(arg).nil? when Integer !lookup_id(arg).nil? when self possible_match = lookup_id(arg.id) !possible_match.nil? && possible_match == arg else false end end # NOTE: purging the cache is sort of pointless because # of the per-process rails model. # By default this blows up noisily just in case you try to be more # clever than rails allows. # For those times (like in Migrations) when you really do want to # alter the records you can silence the carping by setting # enumeration_model_updates_permitted to true. def purge_enumerations_cache unless self.enumeration_model_updates_permitted raise "#{self.name}: cache purging disabled for your protection" end @all = @all_by_name = @all_by_id = @all_active = nil end # The preferred method to update an enumerations model. The same # warnings as 'purge_enumerations_cache' and # 'enumerations_model_update_permitted' apply. Pass a block to this # method where you perform your updates. Cache will be # flushed automatically. If your block takes an argument, will pass in # the model class. The argument is optional. def update_enumerations_model(&block) if block_given? begin self.enumeration_model_updates_permitted = true purge_enumerations_cache @all = load_all @enumerations_model_updating = true case block.arity when 0 yield else yield self end ensure purge_enumerations_cache @enumerations_model_updating = false self.enumeration_model_updates_permitted = false end end end # Returns true if the enumerations model is in the middle of an # update_enumerations_model block, false otherwise. def enumerations_model_updating? !!@enumerations_model_updating end # Returns the name of the column this enum uses as the basic underlying value. def name_column @name_column ||= self.acts_enumerated_name_column end # ---Private methods--- private def load_all conditions = self.acts_enumerated_conditions order = self.acts_enumerated_order unscoped.where(conditions).order(order) end # Looks up the enum based on the type of the argument. private def lookup_enum_by_type(arg) case arg when Symbol lookup_name(arg.id2name) when String lookup_name(arg) when Integer lookup_id(arg) when self arg when nil nil else raise TypeError, "#{self.name}[]: argument should"\ " be a String, Symbol or Integer but got a: #{arg.class.name}" end end # Deals with a lookup failure for the given argument. private def handle_lookup_failure(arg) if (lookup_failure_handler = self.acts_enumerated_on_lookup_failure) case lookup_failure_handler when Proc lookup_failure_handler.call(arg) else self.send(lookup_failure_handler, arg) end else self.send(:enforce_none, arg) end end # Returns a hash of all enumeration members keyed by their ids. private def all_by_id @all_by_id ||= all_by_attribute( primary_key ) end # Returns a hash of all the enumeration members keyed by their names. private def all_by_name begin @all_by_name ||= all_by_attribute( :__enum_name__ ) rescue NoMethodError => err if err.name == name_column raise TypeError, "#{self.name}: you need to define a '#{name_column}' column in the table '#{table_name}'" end raise end end private def all_by_attribute(attr) # :nodoc: aba = all.inject({}) { |memo, item| memo[item.send(attr)] = item memo } aba.freeze unless enumerations_model_updating? aba end private def enforce_none(arg) # :nodoc: nil end private def enforce_strict(arg) # :nodoc: raise_record_not_found(arg) end private def enforce_strict_literals(arg) # :nodoc: raise_record_not_found(arg) if (Integer === arg) || (Symbol === arg) nil end private def enforce_strict_ids(arg) # :nodoc: raise_record_not_found(arg) if Integer === arg nil end private def enforce_strict_symbols(arg) # :nodoc: raise_record_not_found(arg) if Symbol === arg nil end # raise the {ActiveRecord::RecordNotFound} error. # @private private def raise_record_not_found(arg) raise ActiveRecord::RecordNotFound, "Couldn't find a #{self.name} identified by (#{arg.inspect})" end end # These are instance methods for objects which are enums. module EnumInstanceMethods # Behavior depends on the type of +arg+. # # * If +arg+ is +nil+, returns +false+. # * If +arg+ is an instance of +Symbol+, +Integer+ or +String+, returns the result of +BookingStatus[:foo] == BookingStatus[arg]+. # * If +arg+ is an +Array+, returns +true+ if any member of the array returns +true+ for +===(arg)+, +false+ otherwise. # * In all other cases, delegates to +===(arg)+ of the superclass. # # Examples: # # BookingStatus[:foo] === :foo #Returns true # BookingStatus[:foo] === 'foo' #Returns true # BookingStatus[:foo] === :bar #Returns false # BookingStatus[:foo] === [:foo, :bar, :baz] #Returns true # BookingStatus[:foo] === nil #Returns false # # You should note that defining an +:on_lookup_failure+ method that raises an exception will cause +===+ to # also raise an exception for any lookup failure of +BookingStatus[arg]+. def ===(arg) case arg when nil false when Symbol, String, Integer return self == self.class[arg] when Array return self.in?(*arg) else super end end alias_method :like?, :=== # Returns true if any element in the list returns true for ===(arg), false otherwise. def in?(*list) for item in list self === item and return true end false end # Returns the symbol representation of the name of the enum. BookingStatus[:foo].name_sym returns :foo. def name_sym self.__enum_name__.to_sym end alias_method :to_sym, :name_sym # By default enumeration #to_s should return stringified name of the enum. BookingStatus[:foo].to_s returns "foo" def to_s self.__enum_name__ end # Returns true if the instance is active, false otherwise. If it has an attribute 'active', # returns the attribute cast to a boolean, otherwise returns true. This method is used by the 'active' # class method to select active enums. def active? @_active_status ||= ( attributes.include?('active') ? !!self.active : true ) end # Returns true if the instance is inactive, false otherwise. Default implementations returns !active? # This method is used by the 'inactive' class method to select inactive enums. def inactive? !active? end # NOTE: updating the models that back an acts_as_enumerated is # rather dangerous because of rails' per-process model. # The cached values could get out of synch between processes # and rather than completely disallow changes I make you jump # through an extra hoop just in case you're defining your enumeration # values in Migrations. I.e. set enumeration_model_updates_permitted = true private def enumeration_model_update if self.class.enumeration_model_updates_permitted self.class.purge_enumerations_cache true else # Ugh. This just seems hack-ish. I wonder if there's a better way. if Rails.version =~ /^4\.2\.*/ false else throw(:abort) end end end # Validates that model updates are enabled. private def validate_enumeration_model_updates_permitted unless self.class.enumeration_model_updates_permitted self.errors.add(self.class.name_column, "changes to acts_as_enumeration model instances are not permitted") end end end # module EnumInstanceMethods end # module Enumerated end # module PowerEnum