Permalink
Browse files

Initial commit.

  • Loading branch information...
metaskills committed Mar 10, 2012
0 parents commit 9c7690bdb28254794ef4d1f0e18d110580df78db
@@ -0,0 +1,3 @@
+debug.log
+Gemfile.lock
+*.gem
@@ -0,0 +1,6 @@
+
+= 3.2.0
+
+* Initial release. Works with ActiveRecord 3.2.x
+
+
@@ -0,0 +1,2 @@
+source :rubygems
+gemspec
@@ -0,0 +1,5 @@
+
+# StoreConfigurable
+
+A zero-configuration recursive hash for storing anything in a serialized ActiveRecord column.
+
@@ -0,0 +1,13 @@
+require 'bundler'
+require 'rake/testtask'
+
+Bundler::GemHelper.install_tasks
+
+desc 'Test the StoreConfigurable gem.'
+Rake::TestTask.new do |t|
+ t.libs = ['lib','test']
+ t.test_files = Dir.glob("test/**/*_test.rb").sort
+ t.verbose = true
+end
+
+task :default => [:test]
@@ -0,0 +1,7 @@
+require 'active_record'
+require 'store_configurable/version'
+require 'store_configurable/dirty_options'
+require 'store_configurable/object'
+require 'store_configurable/serialization'
+require 'store_configurable/read'
+require 'store_configurable/base'
@@ -0,0 +1,38 @@
+require 'active_support/concern'
+
+module StoreConfigurable
+ module Base
+
+ extend ActiveSupport::Concern
+
+ module ClassMethods
+
+ # To use StoreConfigurable, you must create create a +_config+ colun in the mdoel's
+ # table. Make sure that you declare this column as a text, so there's plenty of room.
+ #
+ # class AddStoreConfigurableField < ActiveRecord::Migration
+ # def up
+ # add_column :users, :_config, :text
+ # end
+ # def down
+ # remove_column :users, :_config
+ # end
+ # end
+ #
+ # Next declare that your model uses StoreConfigurable like so `store_configurable`.
+ # Please read the +config+ documentation for usage examples.
+ #
+ # class User < ActiveRecord::Base
+ # store_configurable
+ # end
+ def store_configurable
+ serialize '_config', StoreConfigurable::Object
+ include Read
+ end
+
+ end
+
+ end
+end
+
+ActiveSupport.on_load(:active_record) { include StoreConfigurable::Base }
@@ -0,0 +1,74 @@
+require 'active_support/ordered_options'
+
+module StoreConfigurable
+
+ # The heart of StoreConfigurable's data store is this subclass of ActiveSupport's OrderedOptions.
+ # They are the heart of Rails' configurations and allow you to dynamically set and get hash keys
+ # and values using dot property notation vs the +[]+ hash accessors.
+ #
+ # However, instances of DirtyTrackingOrderedOptions use a recursive lambda via Hash's block
+ # initialization syntax so that you get a dynamic and endless scope on config data. Instances of
+ # DirtyTrackingOrderedOptions also make sure that every sub instance of it self also has a handle
+ # back to your store's owner. In this way when config attributes are added or values change,
+ # we can mark your ActiveRecord object as dirty/changed.
+ class DirtyTrackingOrderedOptions < ::ActiveSupport::OrderedOptions
+
+ Recursive = lambda { |h,k| h[k] = h.class.new(h.__store_configurable_owner__) }
+
+ attr_accessor :__store_configurable_owner__
+
+ def initialize(owner)
+ @__store_configurable_owner__ = owner
+ super(&Recursive)
+ end
+
+ def []=(key, value)
+ _config_may_change!(key, value)
+ super
+ end
+
+ def delete(key)
+ name = key.to_sym
+ _config_will_change! if has_key?(name)
+ super
+ end
+
+ def delete_if
+ _with_config_keys_may_change! { super }
+ end
+
+ def dup
+ raise NotImplementedError, 'the StoreConfigurable::Object does not support making a copy'
+ end
+
+ def reject!
+ _with_config_keys_may_change! { super }
+ end
+
+ def clear
+ _config_will_change!
+ super
+ end
+
+
+ protected
+
+ def _with_config_keys_may_change!
+ starting_keys = keys.dup
+ yield
+ _config_will_change! if starting_keys != keys
+ self
+ end
+
+ def _config_may_change!(key, value)
+ name = key.to_sym
+ _config_will_change! unless has_key?(name) && self[name] == value
+ end
+
+ def _config_will_change!
+ __store_configurable_owner__._config_will_change!
+ end
+
+ end
+
+end
@@ -0,0 +1,79 @@
+require 'active_support/basic_object'
+
+module StoreConfigurable
+
+ # The is the object returned by the +config+ method. It does nothing more than delegate
+ # all calls to a tree of +DirtyTrackingOrderedOptions+ objects which are basically hashes.
+ class Object < ::ActiveSupport::BasicObject
+
+ # Class methods so the +StoreConfigurable::Object+ responds to +dump+ and +load+ which
+ # allows it to conform to ActiveRecord's coder requirement via its serialize method
+ # that we use.
+ #
+ # The +dump+ method serializes the raw data behind the +StoreConfigurable::Object+ proxy
+ # object. This means that we only storing pure ruby primitives in the datbase, not our
+ # object's proxy.
+ #
+ # The +load+ method mimics +ActiveRecord::Coders::YAMLColumn+ internals by retuning a
+ # new object when needed as well as making sure that the yaml we are process if of the
+ # same type. When reconstituting a +StoreConfigurable::Object+ we must set the store's
+ # owner as this does. That way as our recursive lambda loader regenerates the tree of
+ # config data, we always have a handle for each +DirtyTrackingOrderedOptions+ object to
+ # report state changes back to the owner. Finally, after each load we make sure to clear
+ # out changes so reloaded objects are not marked as dirty.
+ module Coding
+
+ def dump(value)
+ YAML.dump value.__config__
+ end
+
+ def load(yaml, owner)
+ return StoreConfigurable::Object.new if yaml.blank?
+ return yaml unless yaml.is_a?(String) && yaml =~ /^---/
+ stored_data = YAML.load(yaml)
+ unless stored_data.is_a?(Hash)
+ raise ActiveRecord::SerializationTypeMismatch,
+ "Attribute was supposed to be a Hash, but was a #{stored_data.class}"
+ end
+ config = StoreConfigurable::Object.new
+ config.__store_configurable_owner__ = owner
+ loader = lambda do |options, key, value|
+ value.is_a?(Hash) ? value.each { |k,v| loader.call(options.send(key), k, v) } :
+ options.send("#{key}=", value)
+ end
+ stored_data.each { |k,v| loader.call(config, k, v) }
+ owner.changed_attributes.delete('_config')
+ config
+ end
+
+ end
+
+ # Instance methods for +StoreConfigurable::Object+ defined and included in a module so
+ # that if you ever wanted to, you could redefine these methods and +super+ up.
+ module Behavior
+
+ attr_accessor :__store_configurable_owner__
+
+ def __config__
+ @__config__ ||= DirtyTrackingOrderedOptions.new(@__store_configurable_owner__)
+ end
+
+ def inspect
+ "#<StoreConfigurable::Object:#{object_id}>"
+ end
+
+ private
+
+ def method_missing(method, *args, &block)
+ __config__.__send__ method, *args, &block
+ end
+
+ end
+
+ extend Coding
+ include Behavior
+
+ end
+
+end
+
@@ -0,0 +1,54 @@
+module StoreConfigurable
+ module Read
+
+ # Our main syntatic interface to the underlying +_config+ store. This method ensures that
+ # +self+, the store's owner, will allways be set in the config object. Hence allowing all
+ # other recursive options to get a handle back to the owner.
+ #
+ # The config object can be treated as a Hash but in actuality is an enhanced subclass of
+ # +ActiveSupport::OrderedOptions+ that does two important things. First, it allows you to
+ # dynamically define any nested namespace property. Second, it hooks back into your parent
+ # object to notify it of change via ActiveRecord's dirty support.
+ #
+ # Example:
+ #
+ # class User < ActiveRecord::Base
+ # store_configurable
+ # end
+ #
+ # user = User.find(42)
+ # user.config.remember_me = true
+ # user.config.sortable_tables.products.sort_on = 'created_at'
+ # user.config.sortable_tables.products.direction = 'asc'
+ # user.changed? # => true
+ # user.config_changed? # => true
+ def config
+ _config.__store_configurable_owner__ = self
+ _config
+ end
+
+ # Simple delegation to the underlying data attribute's changed query method.
+ def config_changed?
+ _config_changed?
+ end
+
+ # Simple delegation to the underlying data attribute's change array.
+ def config_change
+ _config_change
+ end
+
+ # An override to ActiveRecord's accessor for the sole purpoes of injecting +Serialization+
+ # behavior so that we can set the context of this owner and ensure we pass that down to
+ # the YAML coder. Doing this on a per instance basis keeps us from trumping all other
+ # +ActiveRecord::AttributeMethods::Serialization::Attribute+ objects.
+ def _config
+ attrib = @attributes['_config']
+ unless attrib.respond_to?(:__store_configurable_owner__)
+ attrib.extend Serialization
+ attrib.__store_configurable_owner__ = self
+ end
+ super
+ end
+
+ end
+end
@@ -0,0 +1,18 @@
+module StoreConfigurable
+
+ # This module's behavior is injected into +ActiveRecord::AttributeMethods::Serialization::Attribute+
+ # class which is a mini state machine for serialized objects. It allows us to both set the store's
+ # owner as well as overwrite the +unserialize+ method to give the coder both the YAML and owner
+ # context. This is done via the +_config+ attribute reader override.
+ module Serialization
+
+ attr_accessor :__store_configurable_owner__
+
+ def unserialize
+ self.state = :unserialized
+ self.value = coder.load(value, __store_configurable_owner__)
+ end
+
+ end
+
+end
@@ -0,0 +1,6 @@
+module StoreConfigurable
+
+ # We track ActiveRecord's major and minor version and follow semantic versioning.
+ VERSION = '3.2.0'
+
+end
@@ -0,0 +1,22 @@
+$:.push File.expand_path("../lib", __FILE__)
+require "store_configurable/version"
+
+Gem::Specification.new do |s|
+ s.name = 'store_configurable'
+ s.version = StoreConfigurable::VERSION
+ s.platform = Gem::Platform::RUBY
+ s.authors = ['Ken Collins']
+ s.email = ['ken@metaskills.net']
+ s.homepage = 'http://github.com/Decisiv/store_configurable/'
+ s.summary = 'A small engine around ActiveRecord::Store.'
+ s.description = 'Includes a #config method and syntatic sugar hooks for our classes.'
+ s.files = `git ls-files`.split("\n") - ["store_configurable.gemspec"]
+ s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
+ s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
+ s.require_paths = ['lib']
+ s.rdoc_options = ['--charset=UTF-8']
+ s.add_runtime_dependency 'activerecord', '~> 3.2.0'
+ s.add_development_dependency 'sqlite3', '~> 1.3'
+ s.add_development_dependency 'rake', '~> 0.9.2'
+ s.add_development_dependency 'minitest', '~> 2.8.1'
+end
Oops, something went wrong.

0 comments on commit 9c7690b

Please sign in to comment.