Last active
August 29, 2015 14:23
-
-
Save samsondav/05267e26076d93c7b4df to your computer and use it in GitHub Desktop.
Rails configuration model with JSON column
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# == 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