Skip to content

Commit

Permalink
Initial commit.
Browse files Browse the repository at this point in the history
  • Loading branch information
metaskills committed Mar 10, 2012
0 parents commit 9c7690b
Show file tree
Hide file tree
Showing 15 changed files with 516 additions and 0 deletions.
3 changes: 3 additions & 0 deletions .gitignore
@@ -0,0 +1,3 @@
debug.log
Gemfile.lock
*.gem
6 changes: 6 additions & 0 deletions CHANGELOG
@@ -0,0 +1,6 @@

= 3.2.0

* Initial release. Works with ActiveRecord 3.2.x


2 changes: 2 additions & 0 deletions Gemfile
@@ -0,0 +1,2 @@
source :rubygems
gemspec
5 changes: 5 additions & 0 deletions README.md
@@ -0,0 +1,5 @@

# StoreConfigurable

A zero-configuration recursive hash for storing anything in a serialized ActiveRecord column.

13 changes: 13 additions & 0 deletions 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 changes: 7 additions & 0 deletions 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 changes: 38 additions & 0 deletions 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 changes: 74 additions & 0 deletions 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 changes: 79 additions & 0 deletions 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 changes: 54 additions & 0 deletions 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 changes: 18 additions & 0 deletions 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 changes: 6 additions & 0 deletions 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 changes: 22 additions & 0 deletions 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

0 comments on commit 9c7690b

Please sign in to comment.