diff --git a/README.md b/README.md index e04e89c..0faebdc 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ serialization. require "attribute_helpers" class Vehicle < ActiveRecord::Base - extend AttributeHelpers + prepend AttributeHelpers attr_class :manufacturer attr_symbol :status @@ -51,7 +51,8 @@ car.status # :parked (the symbol) rather than "parked" (the string) Note: while this gem was written to help with ActiveRecord objects, it has **no dependencies** and works great with any database -backend (or none!). **You can extend it in pure Ruby classes just fine!** +backend (or none!). **You can prepend it into pure Ruby classes just +fine!** ## Contributing diff --git a/attribute_helpers.gemspec b/attribute_helpers.gemspec index 340cfa6..2e45994 100644 --- a/attribute_helpers.gemspec +++ b/attribute_helpers.gemspec @@ -24,4 +24,5 @@ Gem::Specification.new do |spec| spec.add_development_dependency "codeclimate-test-reporter", "~> 0.4" spec.add_development_dependency "rake", "~> 10.0" spec.add_development_dependency "rspec", "~> 3.1" + spec.add_development_dependency "temping", "~> 3.2" end diff --git a/lib/attribute_helpers.rb b/lib/attribute_helpers.rb index 031590b..53b9f8b 100644 --- a/lib/attribute_helpers.rb +++ b/lib/attribute_helpers.rb @@ -6,48 +6,59 @@ # the database and your application code. module AttributeHelpers - # Marks attributes as storing symbol values, providing setters and getters for - # the attributes that will allow the application to use them as a symbols but - # store them internally as strings. - # @param attrs [*Symbol] a list of the names of attributes that store symbols - def attr_symbol(*attrs) - attrs.each do |attr| - # Store the original methods for use in the overwritten ones. - original_getter = instance_method(attr) - original_setter = instance_method("#{attr}=") + # This module needs to be prepended to work in ActiveRecord classes. This is + # because ActiveRecord doesn't have accessors/mutators defined until an + # instance is created, which means we need to use the prepend + super() + # pattern because attempts to use instance_method + binding will fail since + # instance_method will not find the method in the class context as it will not + # exist until it is dynamically created when an instance is created. Prepend + # works for us because it inserts the behavior *below* the class in the + # inheritance hierarchy, so we can access the default ActiveRecord accessors/ + # mutators through the use of super(). + # More information here: http://stackoverflow.com/a/4471202/1103543 + def self.prepended(klass) + # We need to store the module in a variable for use when we're in the class + # context. + me = self - # Overwrite the accessor. - define_method attr do - val = original_getter.bind(self).call - val && val.to_sym - end + # Marks attributes as storing symbol values, providing setters and getters + # for the attributes that will allow the application to use them as symbols + # but store them internally as strings. + # @param attrs [*Symbol] a list of the attributes that store symbols + klass.define_singleton_method :attr_symbol do |*attrs| + # Overwrite each attribute's methods. + attrs.each do |attr| + # Overwrite the accessor. + me.send(:define_method, attr) do + val = super() + val && val.to_sym + end - # Overwrite the mutator. - define_method "#{attr}=" do |val| - original_setter.bind(self).call(val && val.to_s) + # Overwrite the mutator. + me.send(:define_method, "#{attr}=") do |val| + super(val && val.to_s) + end end end - end - # Marks attributes as storing class values, providing setters and getters for - # the attributes that will allow the application to use them as a classes but - # store them internally as strings. - # @param attrs [*Symbol] a list of the names of attributes that store classes - def attr_class(*attrs) - attrs.each do |attr| - # Store the original methods for use in the overwritten ones. - original_getter = instance_method(attr) - original_setter = instance_method("#{attr}=") - - # Overwrite the accessor. - define_method attr do - val = original_getter.bind(self).call - val && Kernel.const_get(val) - end + # Marks attributes as storing class values, providing setters and getters + # for the attributes that will allow the application to use them as classes + # but store them internally as strings. + # @param attrs [*Symbol] a list of the attributes that store classes + klass.define_singleton_method :attr_class do |*attrs| + # Overwrite each attribute's methods. + attrs.each do |attr| + # Overwrite the accessor. + # @raise [NameError] if the string can't be constantized + me.send(:define_method, attr) do + val = super() + val && Kernel.const_get(val) + end - # Overwrite the mutator. - define_method "#{attr}=" do |val| - original_setter.bind(self).call(val && val.to_s) + # Overwrite the mutator. + me.send(:define_method, "#{attr}=") do |val| + super(val && val.to_s) + end end end end diff --git a/lib/attribute_helpers/version.rb b/lib/attribute_helpers/version.rb index 9eed5f1..e9f6f52 100644 --- a/lib/attribute_helpers/version.rb +++ b/lib/attribute_helpers/version.rb @@ -1,3 +1,3 @@ module AttributeHelpers - VERSION = "0.1.0" + VERSION = "0.1.1" end diff --git a/spec/active_record_class_spec.rb b/spec/active_record_class_spec.rb new file mode 100644 index 0000000..557b72f --- /dev/null +++ b/spec/active_record_class_spec.rb @@ -0,0 +1,98 @@ +require "spec_helper" + +RSpec.context "in an ActiveRecord class" do + Temping.create :active_record_test_class do + with_columns do |t| + t.string :symbol_attr + t.string :class_attr + end + + prepend AttributeHelpers + + attr_symbol :symbol_attr + attr_class :class_attr + end + + let(:test_obj) { ActiveRecordTestClass.new } + + describe ".attr_symbol" do + describe "getter" do + it "translates string to symbol" do + # Give it an intial value to be read. + test_obj[:symbol_attr] = "example" + + expect(test_obj.symbol_attr).to eq :example + end + + it "reads nil correctly" do + # Explicitly set the attribute to nil, though it's already nil by + # default. + test_obj[:symbol_attr] = nil + + expect(test_obj.symbol_attr).to be_nil + end + end + + describe "setter" do + it "translates symbol to string" do + test_obj.symbol_attr = :example + expect(test_obj[:symbol_attr]).to eq "example" + end + + it "stores nil when nil is passed" do + # Give it an intial value, to make sure the nil set isn't just relying + # on default object behavior. + test_obj.symbol_attr = :example + + test_obj.symbol_attr = nil + expect(test_obj[:symbol_attr]).to be_nil + end + end + end + + describe ".attr_class" do + describe "getter" do + context "when string represents a real class" do + it "translates string to class" do + # Give it an intial value to be read. + test_obj[:class_attr] = "Object" + + expect(test_obj.class_attr).to eq Object + end + end + + context "when string does not represent a real class" do + it "raises an error" do + # Give it an intial value to be read. + test_obj[:class_attr] = "BogusClass" + + expect { test_obj.class_attr }.to raise_error(NameError) + end + end + + it "reads nil correctly" do + # Explicitly set the instance variable to nil, though it's already nil + # by default. + test_obj[:class_attr] = nil + + expect(test_obj.class_attr).to be_nil + end + end + + describe "setter" do + it "translates class to string" do + test_obj.class_attr = Object + expect(test_obj[:class_attr]).to eq "Object" + end + + it "stores nil when nil is passed" do + # Give it an intial value, to make sure the nil set isn't just relying + # on default object behavior. + test_obj.class_attr = Object + + test_obj.class_attr = nil + expect(test_obj[:class_attr]).to be_nil + end + end + end +end diff --git a/spec/attribute_helpers_spec.rb b/spec/ruby_class_spec.rb similarity index 70% rename from spec/attribute_helpers_spec.rb rename to spec/ruby_class_spec.rb index ff23f04..23df69b 100644 --- a/spec/attribute_helpers_spec.rb +++ b/spec/ruby_class_spec.rb @@ -1,17 +1,19 @@ require "spec_helper" -RSpec.describe AttributeHelpers do - describe ".attr_symbol" do - class AttributeHelperTestClass - extend AttributeHelpers +RSpec.context "in a pure Ruby class" do + class RubyTestClass + prepend AttributeHelpers - attr_accessor :symbol_attr + attr_accessor :symbol_attr + attr_accessor :class_attr - attr_symbol :symbol_attr - end + attr_symbol :symbol_attr + attr_class :class_attr + end - let(:test_obj) { AttributeHelperTestClass.new } + let(:test_obj) { RubyTestClass.new } + describe ".attr_symbol" do describe "getter" do it "translates string to symbol" do # Give it an intial value to be read. @@ -47,22 +49,23 @@ class AttributeHelperTestClass end describe ".attr_class" do - class AttributeHelperTestClass - extend AttributeHelpers - - attr_accessor :class_attr - - attr_class :class_attr - end + describe "getter" do + context "when string represents a real class" do + it "translates string to class" do + # Give it an intial value to be read. + test_obj.instance_variable_set(:@class_attr, "Object") - let(:test_obj) { AttributeHelperTestClass.new } + expect(test_obj.class_attr).to eq Object + end + end - describe "getter" do - it "translates string to class" do - # Give it an intial value to be read. - test_obj.instance_variable_set(:@class_attr, "Object") + context "when string does not represent a real class" do + it "raises an error" do + # Give it an intial value to be read. + test_obj.instance_variable_set(:@class_attr, "BogusClass") - expect(test_obj.class_attr).to eq Object + expect { test_obj.class_attr }.to raise_error(NameError) + end end it "reads nil correctly" do diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 6cb4127..d922b6a 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,6 +1,11 @@ require "codeclimate-test-reporter" CodeClimate::TestReporter.start +# Connect to an in-memory database for ActiveRecord tests. +require "temping" +ActiveRecord::Base. + establish_connection(adapter: "sqlite3", database: ":memory:") + require "attribute_helpers" RSpec.configure do |config|