Skip to content

Instantly share code, notes, and snippets.

@samsondav
Last active August 29, 2015 14:23
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save samsondav/05267e26076d93c7b4df to your computer and use it in GitHub Desktop.
Save samsondav/05267e26076d93c7b4df to your computer and use it in GitHub Desktop.
Rails configuration model with JSON column
# A helper module to be included in any class that requires a configuration
# hash.
#
# The rationale is that after a while, adding configuration options as columns
# on a model becomes unwieldy, especially if there are very many configuration
# options but only a few used by each record, this results in lots of nil columns
# and is not very flexible. Also it requires migration the database for each new
# option.
#
# This module comes with a method called configuration_accessors that defines virtual attributes
# on the host model and may be used as so:
#
# class Company
# include Configurable
# configuration_accessors :foo, :bar, baz: 'rainbows!'
# end
#
# Specify a hash to define a custom default value for an attribute, otherwise the default will be nil
#
# For all intents and purposes, :foo and :bar can now be treated as attributes on the company
# model (except the actual data is stored on the company's Configuration model).
#
### Including this module ensures that the host model always has a configuration
### associated to it
# c = Company.new
# c.configuration.present? =? true
### accessor methods are auto-delegated to the configuration model:
#
# c.foo => nil
# c.foo? => nil
# c.foo = true
# c.foo? => true
# c.foo = 'hello'
# c.foo => 'hello'
# c.foo? => TypeError, 'foo is not a boolean'
### default values are returned if the option is not set
#
# c.bar => nil # bar has no default defined so accessing it returns nil
# c.baz => 'rainbows!' # we defined the default value for baz in configuration_accessors in our example Company class above
# c.baz = 'trolls!'
# c.baz => 'trolls!'
#
### the included module only defines accessors as defined in configuration_accessors
### by default it does nothing:
# c.ogglyboggly => NoMethodError
### options are automatically saved on parent save:
# c.bar = 'ponies!'
# c.reload
# c.bar => nil
# c.bar = 'ponies!'
# c.save
# c.reload
# c.bar => 'ponies!'
#
# ---
# Ruby is quite nice but this would be so much easier and more elegant in LISP
# with macros. Just sayin'.
module Configurable
extend ActiveSupport::Concern
included do
has_one :configuration, as: :configurable, autosave: true, dependent: :destroy
after_initialize ->(record) { record.build_configuration unless record.configuration }
end
module ClassMethods
def configuration_accessors(*args)
option_syms_with_defaults = flatten_args(args)
option_syms_with_defaults.each do |option_sym, option_default|
option_name = option_sym.to_s
reader = option_name
pred = option_name + '?'
writer = option_name + '='
unless option_default.nil?
set_defaults = <<DEFAULTS
configuration.defaults[#{option_sym.inspect}] = #{option_default.inspect}
DEFAULTS
after_initialize(set_defaults)
end
[reader, pred].each do |reader|
reader_method = <<READER
def #{reader}
fail(ActiveRecord::RecordNotFound, 'no Configuration association found for this model') unless configuration
configuration.#{Configuration::ACCESSOR_PREFIX + reader}
end
READER
class_eval(reader_method)
end
writer_method = <<WRITER
def #{writer}(value)
fail(ActiveRecord::RecordNotFound, 'no Configuration association found for this model') unless configuration
configuration.#{Configuration::ACCESSOR_PREFIX + writer}(value)
end
WRITER
class_eval(writer_method)
end
end
private
def flatten_args(args)
args.flat_map do |arg|
if arg.is_a? Hash
arg.map do |option_sym, default|
[ option_sym, default ]
end
else
[[ arg, nil ]] # assume a nil default when none specified
end
end
end
end
end
# == Schema Information
#
# Table name: configurations
#
# id :integer not null, primary key
# options :jsonb default({})
# configurable_type :string
# configurable_id :integer
# created_at :datetime not null
# updated_at :datetime not null
#
# Indexes
#
# index_configurations_on_configurable_id_and_configurable_type (configurable_id,configurable_type) UNIQUE
#
# A generic class that can be referenced by any model and allows storage of configuration
# options in an abitrary hash structure.
#
# Provides direct access to the options hash via the options attribute and also
# dynamically defines convenience accessor methods, used as such:
#
# config = Config.new(options: { foo: 'bar', pred: false })
# config.options[:foo] => 'bar'
#
# Options hash allows indifferent access (keys or strings)
# config.options['foo'] => 'bar'
#
# Dynamically defined methods create virtual accessors
#
# config.options_foo => 'bar'
# config.options_foo = 'baz' => 'baz'
# config.options_pred? => false
# config.options_foo? => TypeError
# config.options_ogglyboggly? => nil
#
# Also includes a transient instance variable called @defaults that allows
# you to specify default values for when a key is not found.
#
# config = Config.new(options: { foo: 'bar', pred: false })
# config.defaults[:ogglyboggly] = true
# config.options_ogglyboggly? => true
class Configuration < ActiveRecord::Base
class OptionSerializer
def self.dump(hash)
hash.to_json
end
def self.load(hash)
(hash || {}).with_indifferent_access
end
end
ACCESSOR_PREFIX = 'option_'
serialize :options, OptionSerializer
belongs_to :configurable, polymorphic: true
attr_accessor :defaults
after_initialize "@defaults ||= {}"
# Override method missing to allow dynamic lookups for _any_ method call
# starting with the prefix Configuration::ACCESSOR_PREFIX
#
# Also allows dynamic allocation of attributes if appended with '='
#
# Method calls that end with a '?' are treated specially in that they always
# return either true or false
def method_missing(method_sym, *arguments, &block)
method_s = method_sym.to_s
super unless method_s.start_with?(ACCESSOR_PREFIX)
option_name = method_s.gsub(/\A#{Regexp.escape(ACCESSOR_PREFIX)}/, '')
.gsub(/[\?=]\z/, '')
.to_sym
case method_s.last
when '='
# Setter
if arguments.count != 1
fail ArgumentError, "wrong number of arguments (#{arguments.count} for 1)"
else
set_option(option_name, arguments.first)
end
when '?'
# Predicate (presence)
if arguments.count != 0
fail ArgumentError, "wrong number of arguments (#{arguments.count} for 0)"
else
get_boolean_option(option_name)
end
else
# Getter
if arguments.count != 0
fail ArgumentError, "wrong number of arguments (#{arguments.count} for 0)"
else
get_option(option_name)
end
end
end
def set_option(option_name, value)
options[option_name] = value
options_will_change! # not 100% sure if this is necessary in latest AR and postgres but it can't possibly hurt
end
def set_option!(option_name, value)
set_option(option_name, value)
save!
end
# Returns true or false if those are set.
# Returns nil if this option is not defined.
# If the option is defined as anything else, fail with an exception.
def get_boolean_option(option_name)
value = get_option(option_name)
fail(TypeError, "option is present but not a boolean type") unless value.nil? || value.is_a?(TrueClass) || value.is_a?(FalseClass)
value
end
def get_option(option_name)
value = options[option_name]
value.nil? ? @defaults[option_name] : options[option_name]
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment