Skip to content

Instantly share code, notes, and snippets.

@mikezter
Created August 31, 2010 09:00
Show Gist options
  • Save mikezter/558760 to your computer and use it in GitHub Desktop.
Save mikezter/558760 to your computer and use it in GitHub Desktop.
Allow an ActiveRecord to behave like a schemaless document
module DynamicAttributes
class DynamicAttributesError < StandardError; end;
def self.included(base)
base.send(:include, InstanceMethods)
base.send(:extend, ClassMethods)
end
module ClassMethods
def migrated_attributes
@migrated_attributes ||= []
end
def migrated_attributes=(value)
@migrated_attributes = value
end
def attribute_migrated?(name)
migrated_attributes.include?(name)
end
def create_table
connection.execute %Q(
CREATE TABLE IF NOT EXISTS #{quoted_table_name}
(`id` INT(8) UNSIGNED NOT NULL PRIMARY KEY AUTO_INCREMENT)
COLLATE `utf8_unicode_ci`
ENGINE `InnoDB`
)
end
end
module InstanceMethods
def initialize(attributes = {})
self.class.create_table unless self.class.table_exists?
super()
clear_dynamic_attributes
attributes.each { |name, value| write_dynamic_or_ar_attribute(name, value) }
end
def write_dynamic_or_ar_attribute(name, value)
if has_attribute?(name)
write_attribute(name, value)
else
write_dynamic_attribute(name, value)
end
end
def save(*args)
migrate_columns_and_move_dynamic_to_ar_attributes!
super
end
# Returns the all dynamic columns with values for this instance
#
# always access the hash through this getter
# so you always get an empty Hash at least
#
def dynamic_columns_hash
@dynamic_columns_hash ||= Hash.new(:_undefined_dynamic_column)
end
# Returns the names of all dynamic columns of this instance as an array
#
def dynamic_columns
dynamic_columns_hash.keys
end
alias :dynamic_attributes :dynamic_columns
def write_dynamic_attribute(name, value)
dynamic_columns_hash[name] = value
end
def read_dynamic_attribute(name)
dynamic_columns_hash[name]
end
def has_dynamic_attribute?(name)
dynamic_columns.include?(name)
end
def has_dynamic_attributes?
dynamic_columns.any?
end
def clear_dynamic_attribute(name)
dynamic_columns_hash.delete(name)
end
def clear_dynamic_attributes
@dynamic_columns_hash = nil
end
def method_missing(method, *args)
if method =~ /=\Z/
name = method.to_s.gsub!('=', '').to_sym
return write_dynamic_attribute(name, args.first)
elsif has_dynamic_attribute?(method)
return read_dynamic_attribute(method)
else
super
end
end
private
def move_dynamic_to_ar_attribute(name)
write_attribute(name, read_dynamic_attribute(name))
clear_dynamic_attribute(name)
end
def migrate_columns_and_move_dynamic_to_ar_attributes!
migrate_new_columns!
self.class.reset_column_information
dynamic_columns.each { |name| move_dynamic_to_ar_attribute(name) }
end
def migrate_new_columns!
return true unless has_dynamic_attributes?
connection.execute migration_sql
self.class.migrated_attributes += columns_for_migration
return true
end
def columns_for_migration
dynamic_columns_hash.keys.reject { |name| self.class.attribute_migrated?(name) }
end
def migration_sql
return '' unless has_dynamic_attributes?
column_definitions = columns_for_migration.collect do |new_column|
column_definition_for(new_column)
end
sql = "ALTER TABLE #{self.class.quoted_table_name} #{column_definitions.join(', ')}"
end
def column_definition_for(name)
raise DynamicAttributesError unless has_dynamic_attribute?(name)
"ADD COLUMN `#{name.to_s}` #{mysql_type(name)}"
end
def mysql_type(name)
raise DynamicAttributesError unless has_dynamic_attribute?(name)
case dynamic_columns_hash[name]
when String, Symbol then return 'VARCHAR(255) CHARACTER SET `utf8` COLLATE `utf8_unicode_ci`'
when Fixnum, Float then return 'FLOAT'
else raise DynamicAttributesError.new("Unknown column type: #{name}")
end
end
end
end
LOG = false #true
load 'dynamic_attributes.rb'
require 'test/unit'
require 'rubygems'
require 'active_record'
ActiveRecord::Base.establish_connection(
:adapter => "mysql",
:host => "localhost",
:username => "dynattr",
:password => "dynattr",
:database => "dynattr"
)
if LOG
require 'logger'
ActiveRecord::Base.logger = Logger.new(STDOUT)
end
class DynamicAttributesTest < Test::Unit::TestCase
class Foo < ActiveRecord::Base
include DynamicAttributes
end
class Bar < ActiveRecord::Base
include DynamicAttributes
end
def reset_test_tables
ActiveRecord::Base.connection.execute "DROP TABLE IF EXISTS `foos`"
ActiveRecord::Base.connection.execute "DROP TABLE IF EXISTS `bars`"
ActiveRecord::Base.connection.execute %Q(
CREATE TABLE `foos`
(`id` INT(8) UNSIGNED NOT NULL PRIMARY KEY AUTO_INCREMENT)
COLLATE `utf8_unicode_ci`
ENGINE `InnoDB`
)
end
def setup
reset_test_tables
Foo.reset_column_information
Foo.migrated_attributes = []
@f = Foo.new
end
def test_initialize_dynamic_attributes_hash
assert_equal({}, @f.dynamic_columns_hash)
end
def test_dynamic_attributes_are_undefinded
assert_equal(:_undefined_dynamic_column, @f.dynamic_columns_hash[:random_attribute])
end
def test_method_missing_assigns_dynamic_attributes
assert_equal 'a string', (@f.bar = 'a string')
end
def test_method_missing_returns_dynamic_attribute_value
@f.bar = 'a string'
assert_equal 'a string', @f.bar
end
def test_method_missing_works_as_expected_on_undefined_methods
assert_raise(NoMethodError) { @f.bar }
end
def test_has_dynamic_attributes
assert not(@f.has_dynamic_attributes?)
@f.bar = 'a string'
assert @f.has_dynamic_attributes?
end
def test_attribute_mysql_type
@f.bar = 'a string'
assert_equal 'VARCHAR(255) CHARACTER SET `utf8` COLLATE `utf8_unicode_ci`', @f.send(:mysql_type, :bar)
@f.baz = 23
assert_equal 'FLOAT', @f.send(:mysql_type, :baz)
@f.foo = 5.9
assert_equal 'FLOAT', @f.send(:mysql_type ,:foo)
end
def test_migration_sql
@f.foo = 'Mike'
@f.bar = 23
@f.baz = 2.9
assert_equal "ALTER TABLE `foos` ADD COLUMN `foo` VARCHAR(255) CHARACTER SET `utf8` COLLATE `utf8_unicode_ci`, ADD COLUMN `bar` FLOAT, ADD COLUMN `baz` FLOAT", @f.send(:migration_sql)
end
def test_migrate_new_columns
@f.foo = 'Mike'
@f.bar = 23
@f.baz = 2.9
@f.send(:migrate_new_columns!)
Foo.reset_column_information
@g = Foo.new(:foo => 'Mok')
assert_equal 'Mok', @g.foo
end
def test_ar_creates_attribute_methods_after_migrate
@f.foo = 'Mike'
@f.bar = 23
@f.baz = 2.9
@f.send(:migrate_new_columns!)
Foo.reset_column_information;
assert Foo.new.has_attribute?(:bar)
end
def test_clear_dynamic_attributes
@f.foo = 'Mike'
@f.bar = 23
@f.baz = 2.9
@f.clear_dynamic_attributes
assert not(@f.has_dynamic_attributes?)
assert not(@f.has_dynamic_attribute?(:bar))
assert_equal({}, @f.instance_variable_get(:@dynamic_columns_hash))
assert_raise(NoMethodError) { @f.foo }
end
def test_migrate_columns_and_move_dynamic_to_ar_attributes
@f.foo = 'Mike'
@f.bar = 23
@f.baz = 2.9
@f.send(:migrate_columns_and_move_dynamic_to_ar_attributes!)
assert not(@f.has_dynamic_attributes?)
assert_equal 'Mike', @f.foo
assert_equal 23, @f.bar
end
def test_clear_dynamic_attribute
@f.foo = 'Mike'
assert @f.has_dynamic_attribute?(:foo)
@f.clear_dynamic_attribute(:foo)
assert not(@f.has_dynamic_attribute?(:foo))
assert_raise(NoMethodError) { @f.foo }
end
def test_save
@f.foo = 'Mike'
@f.bar = 23
@f.baz = 2.9
@f.save
assert @f.has_attribute?(:foo)
@g = Foo.find :last
assert_equal 'Mike', @g.foo
end
def test_dynamic_attributes_through_initializer
@ia = Foo.new(:dyninit => 'test')
assert_equal 'test', @ia.dyninit
@ia.save
assert_equal 'test', Foo.last.dyninit
end
def test_columns_for_migration
@f.foo = 'Mike'
@g = Foo.new(:foo => 'Mok', :bar => 25)
assert @f.send(:columns_for_migration).include?(:foo)
assert @g.send(:columns_for_migration).include?(:foo) && @g.send(:columns_for_migration).include?(:bar)
@f.save
assert not(@g.send(:columns_for_migration).include?(:foo))
end
def test_dont_duplicate_columns_but_save_anyway
@g = Foo.new
@f.foo = 'erstes'
@g.foo = 'zweites'
@f.save
assert_nothing_thrown { @g.save }
assert_equal 'zweites', Foo.last.foo
end
def test_existing_records_can_get_new_dynamic_attributes
@f.foo = 'Mike'
@f.save
@g = Foo.last
assert_nothing_thrown { @g.bar = 25 }
assert @g.save
end
def test_creates_new_tables_automatically
bar = Bar.new
assert_nothing_thrown { bar.save }
assert_equal bar, Bar.last
bar.foo = 'Mike'
assert bar.save
assert_equal 'Mike', Bar.last.foo
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment