Created
August 31, 2010 09:00
-
-
Save mikezter/558760 to your computer and use it in GitHub Desktop.
Allow an ActiveRecord to behave like a schemaless document
This file contains hidden or 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
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 | |
This file contains hidden or 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
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