Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP
Browse files

Initial commit.

  • Loading branch information...
commit 9c7690bdb28254794ef4d1f0e18d110580df78db 0 parents
@metaskills authored
3  .gitignore
@@ -0,0 +1,3 @@
+debug.log
+Gemfile.lock
+*.gem
6 CHANGELOG
@@ -0,0 +1,6 @@
+
+= 3.2.0
+
+* Initial release. Works with ActiveRecord 3.2.x
+
+
2  Gemfile
@@ -0,0 +1,2 @@
+source :rubygems
+gemspec
5 README.md
@@ -0,0 +1,5 @@
+
+# StoreConfigurable
+
+A zero-configuration recursive hash for storing anything in a serialized ActiveRecord column.
+
13 Rakefile
@@ -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]
7 lib/store_configurable.rb
@@ -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'
38 lib/store_configurable/base.rb
@@ -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 }
74 lib/store_configurable/dirty_options.rb
@@ -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
79 lib/store_configurable/object.rb
@@ -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
+
54 lib/store_configurable/read.rb
@@ -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
18 lib/store_configurable/serialization.rb
@@ -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
6 lib/store_configurable/version.rb
@@ -0,0 +1,6 @@
+module StoreConfigurable
+
+ # We track ActiveRecord's major and minor version and follow semantic versioning.
+ VERSION = '3.2.0'
+
+end
22 store_configurable.gemspec
@@ -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
133 test/cases/base_test.rb
@@ -0,0 +1,133 @@
+require 'helper'
+
+class StoreConfigurable::BaseTest < StoreConfigurable::TestCase
+
+ it 'is never blank' do
+ new_user.config.wont_be_nil
+ end
+
+ it 'can set and get root attributes' do
+ new_user.config.foo = 'foo'
+ new_user.config.foo.must_equal 'foo'
+ end
+
+ it 'can set and get adhoc nested options' do
+ options = {:this => 'that'}
+ new_user.config.foo.bar.options = options
+ new_user.config.foo.bar.options.must_equal options
+ end
+
+ it 'can serialize to yaml' do
+ user_ken.config.foo = 'bar'
+ user_ken.config.to_yaml.must_include '--- !omap'
+ user_ken.config.to_yaml.must_include ':foo: bar'
+ end
+
+ it 'wont mark owner as dirty after initial read from database with no existing config' do
+ user_ken.config
+ user_ken.wont_be :config_changed?
+ end
+
+ it 'must be mark owner as dirty after missing getter since that inits a new namespace' do
+ user_ken.config.bar
+ user_ken.must_be :config_changed?
+ end
+
+ it 'does not support dup, reject, merge' do
+ lambda{ user_ken.config.dup }.must_raise(NotImplementedError)
+ lambda{ user_ken.config.reject{} }.must_raise(NotImplementedError)
+ lambda{ user_ken.config.merge({}) }.must_raise(NotImplementedError)
+ end
+
+ describe 'existing data' do
+
+ let(:color) { '#c1c1c1' }
+ let(:remember) { true }
+ let(:deep_value) { StorableObject.new('test') }
+ let(:plugin_opts) { Hash[:sort,'asc',:on,true] }
+
+ before do
+ user_ken.config.color = color
+ user_ken.config.remember_me = remember
+ user_ken.config.plugin.options = plugin_opts
+ user_ken.config.you.should.never.need.to.do.this = deep_value
+ user_ken.save!
+ @user = User.find(user_ken.id)
+ end
+
+ it 'wont be dirty after loading' do
+ @user.wont_be :config_changed?
+ end
+
+ it 'can reconsitute saved values' do
+ @user.config.color.must_equal color
+ @user.config.remember_me.must_equal remember
+ @user.config.plugin.options.must_equal plugin_opts
+ @user.config.you.should.never.need.to.do.this.must_equal deep_value
+ end
+
+ it 'wont be dirty after reading saved configs' do
+ @user.config.color
+ @user.config.remember_me
+ @user.config.plugin.options
+ @user.config.you.should.never.need.to.do.this
+ @user.wont_be :config_changed?
+ end
+
+ it 'wont be dirty when setting same config values' do
+ @user.config.color = color
+ @user.config.remember_me = remember
+ @user.config.plugin.options = plugin_opts
+ @user.config.you.should.never.need.to.do.this = deep_value
+ @user.wont_be :config_changed?
+ end
+
+ it 'must be marked dirty when values change' do
+ @user.config.color = 'black'
+ @user.must_be :config_changed?
+ @user.save!
+ @user.config.color.must_equal 'black'
+ end
+
+ it 'must be marked dirty when clearing' do
+ @user.config.clear
+ @user.must_be :config_changed?
+ @user.save!
+ @user.config.must_be :blank?
+ end
+
+ it 'must be marked dirty when deleting a key' do
+ @user.config.delete :color
+ @user.must_be :config_changed?
+ @user.save!
+ @user.config.has_key?(:color).must_equal false
+ end
+
+ it 'wont be marked dirty when deleting a non-existent key' do
+ @user.config.delete :doesnotexist
+ @user.wont_be :config_changed?
+ end
+
+ it 'must be marked dirty when using delete_if' do
+ @user.config.delete_if { |k,v| true }
+ @user.must_be :config_changed?
+ @user.config.must_be :blank?
+ end
+
+ it 'wont be marked dirty when using delete_if and nothing happens' do
+ @user.config.delete_if { |k,v| false }
+ @user.wont_be :config_changed?
+ @user.config.you.should.never.need.to.do.this = deep_value
+ end
+
+ it 'must be marked dirty when using reject! on true' do
+ @user.config.reject! { |k,v| true }
+ @user.must_be :config_changed?
+ @user.config.must_be :blank?
+ end
+
+ end
+
+
+end
+
56 test/helper.rb
@@ -0,0 +1,56 @@
+require 'rubygems'
+require 'bundler'
+require "bundler/setup"
+Bundler.require
+require 'store_configurable'
+require 'active_record/base'
+require 'minitest/autorun'
+require 'logger'
+
+
+ActiveRecord::Base.logger = Logger.new(File.join(File.dirname(__FILE__),'debug.log'))
+ActiveRecord::Base.establish_connection :adapter => 'sqlite3', :database => ':memory:'
+
+
+module StoreConfigurable
+ class TestCase < MiniTest::Spec
+
+ before { setup_environment }
+
+ let(:new_user) { User.new }
+ let(:user_ken) { User.find_by_email('ken@metaskills.net') }
+
+ def setup_environment
+ setup_database
+ setup_data
+ end
+
+ protected
+
+ def setup_database
+ ActiveRecord::Base.class_eval do
+ silence do
+ connection.create_table :users, :force => true do |t|
+ t.string :name, :email
+ t.text :_config
+ end
+ end
+ end
+ end
+
+ def setup_data
+ User.create :name => 'Ken Collins', :email => 'ken@metaskills.net'
+ end
+
+ end
+end
+
+class StorableObject
+ attr_accessor :value
+ def initialize(value) ; @value = value ; end
+ def ==(other) ; value == other.value ; end
+end
+
+class User < ActiveRecord::Base
+ store_configurable
+end
Please sign in to comment.
Something went wrong with that request. Please try again.