├── .rspec ├── .ruby-version ├── spec ├── support │ ├── i18n.rb │ ├── time_zone.rb │ ├── database.sample.yml │ ├── error_on.rb │ ├── protected_params.rb │ └── database.rb ├── isolated │ ├── use_util_methods_spec.rb │ ├── load_without_active_record_use_spec.rb │ ├── load_without_active_record_use_within_rails_spec.rb │ └── require_does_not_trigger_ar_load_spec.rb ├── shared_examples │ ├── belongs_to.rb │ ├── accessors.rb │ ├── mass_assignment.rb │ ├── dupable.rb │ ├── constructor.rb │ ├── dirty_tracking.rb │ ├── defaults.rb │ └── coercible_columns.rb ├── isolated_spec_helper.rb ├── spec_helper.rb ├── active_type │ ├── extended_record │ │ └── single_table_inheritance_spec.rb │ ├── util_spec.rb │ ├── extended_record_spec.rb │ ├── record_spec.rb │ ├── object_spec.rb │ └── nested_attributes_spec.rb └── integration │ ├── sign_up_spec.rb │ ├── sign_in_spec.rb │ ├── shape_spec.rb │ └── holidays_spec.rb ├── lib ├── active_type │ ├── version.rb │ ├── object.rb │ ├── record.rb │ ├── extended_record.rb │ ├── nested_attributes.rb │ ├── nested_attributes │ │ ├── nests_one_association.rb │ │ ├── builder.rb │ │ ├── nests_many_association.rb │ │ └── association.rb │ ├── util.rb │ ├── extended_record │ │ └── inheritance.rb │ ├── no_table.rb │ ├── type_caster.rb │ └── virtual_attributes.rb └── active_type.rb ├── .gitignore ├── gemfiles ├── Gemfile.4.2.1.pg ├── Gemfile.5.0.0.pg ├── Gemfile.4.0.sqlite3 ├── Gemfile.4.1.sqlite3 ├── Gemfile.4.2.1.sqlite3 ├── Gemfile.5.0.0.sqlite3 ├── Gemfile.4.2.1.mysql2 ├── Gemfile.5.0.0.mysql2 ├── Gemfile.3.2.sqlite3 ├── Gemfile.3.2.mysql2 ├── Gemfile.3.2.sqlite3.lock ├── Gemfile.3.2.mysql2.lock ├── Gemfile.5.0.0.pg.lock ├── Gemfile.5.0.0.sqlite3.lock ├── Gemfile.5.0.0.mysql2.lock ├── Gemfile.4.2.1.pg.lock ├── Gemfile.4.2.1.sqlite3.lock ├── Gemfile.4.2.1.mysql2.lock ├── Gemfile.4.1.sqlite3.lock └── Gemfile.4.0.sqlite3.lock ├── active_type.gemspec ├── LICENSE ├── Rakefile ├── .travis.yml ├── CHANGELOG.md └── README.md /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | 2.2.4 2 | -------------------------------------------------------------------------------- /spec/support/i18n.rb: -------------------------------------------------------------------------------- 1 | I18n.enforce_available_locales = false 2 | -------------------------------------------------------------------------------- /spec/support/time_zone.rb: -------------------------------------------------------------------------------- 1 | require 'active_support/time' 2 | -------------------------------------------------------------------------------- /lib/active_type/version.rb: -------------------------------------------------------------------------------- 1 | module ActiveType 2 | VERSION = '0.7.0' 3 | end 4 | -------------------------------------------------------------------------------- /spec/support/database.sample.yml: -------------------------------------------------------------------------------- 1 | host: localhost 2 | username: joe 3 | password: secret 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | doc 2 | pkg 3 | tags 4 | *.gem 5 | .idea 6 | tmp 7 | spec/support/database.yml 8 | .bundle 9 | -------------------------------------------------------------------------------- /gemfiles/Gemfile.4.2.1.pg: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gem 'activerecord', '~>4.2.1' 4 | gem 'rspec', '~>3.4' 5 | gem 'pg' 6 | gem 'rake' 7 | 8 | gem 'active_type', :path => '..' 9 | -------------------------------------------------------------------------------- /gemfiles/Gemfile.5.0.0.pg: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gem 'activerecord', '~>5.0.0' 4 | gem 'rspec', '~>3.4' 5 | gem 'pg' 6 | gem 'rake' 7 | 8 | gem 'active_type', :path => '..' 9 | -------------------------------------------------------------------------------- /gemfiles/Gemfile.4.0.sqlite3: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gem 'activerecord', '~>4.0.0' 4 | gem 'rspec', '~>3.4' 5 | gem 'sqlite3' 6 | gem 'rake' 7 | 8 | gem 'active_type', :path => '..' 9 | -------------------------------------------------------------------------------- /gemfiles/Gemfile.4.1.sqlite3: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gem 'activerecord', '~>4.1.0' 4 | gem 'rspec', '~>3.4' 5 | gem 'sqlite3' 6 | gem 'rake' 7 | 8 | gem 'active_type', :path => '..' 9 | -------------------------------------------------------------------------------- /gemfiles/Gemfile.4.2.1.sqlite3: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gem 'activerecord', '~>4.2.1' 4 | gem 'rspec', '~> 3.4' 5 | gem 'sqlite3' 6 | gem 'rake' 7 | 8 | gem 'active_type', :path => '..' 9 | -------------------------------------------------------------------------------- /gemfiles/Gemfile.5.0.0.sqlite3: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gem 'activerecord', '~>5.0.0' 4 | gem 'rspec', '~> 3.4' 5 | gem 'sqlite3' 6 | gem 'rake' 7 | 8 | gem 'active_type', :path => '..' 9 | -------------------------------------------------------------------------------- /gemfiles/Gemfile.4.2.1.mysql2: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gem 'activerecord', '~>4.2.1' 4 | gem 'rspec', '~>3.4' 5 | gem 'mysql2', '~> 0.3.17' 6 | gem 'rake' 7 | 8 | gem 'active_type', :path => '..' 9 | -------------------------------------------------------------------------------- /gemfiles/Gemfile.5.0.0.mysql2: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gem 'activerecord', '~>5.0.0' 4 | gem 'rspec', '~>3.4' 5 | gem 'mysql2', '~> 0.3.17' 6 | gem 'rake' 7 | 8 | gem 'active_type', :path => '..' 9 | -------------------------------------------------------------------------------- /gemfiles/Gemfile.3.2.sqlite3: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gem 'activerecord', '=3.2.22' 4 | gem 'rspec', '~>3.4' 5 | gem 'sqlite3' 6 | gem 'rake' 7 | 8 | gem 'active_type', :path => '..' 9 | gem 'i18n', '=0.6.11' # 0.7 no longer builds for Ruby 1.8.7 10 | -------------------------------------------------------------------------------- /gemfiles/Gemfile.3.2.mysql2: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gem 'activerecord', '=3.2.22' 4 | gem 'rspec', '~> 3.4' 5 | gem 'mysql2', '= 0.3.17' 6 | gem 'rake' 7 | 8 | gem 'active_type', :path => '..' 9 | gem 'i18n', '=0.6.11' # 0.7 no longer builds for Ruby 1.8.7 10 | -------------------------------------------------------------------------------- /spec/support/error_on.rb: -------------------------------------------------------------------------------- 1 | module ActiveModel::Validations 2 | 3 | def errors_on(attribute, options = {}) 4 | valid_args = [options[:context]].compact 5 | self.valid?(*valid_args) 6 | 7 | [self.errors[attribute]].flatten.compact 8 | end 9 | 10 | alias :error_on :errors_on 11 | 12 | end 13 | -------------------------------------------------------------------------------- /spec/isolated/use_util_methods_spec.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | require 'isolated_spec_helper' 4 | 5 | RSpec.describe 'ActiveType', type: :isolated do 6 | 7 | it 'can be used without explicitly using ActiveRecord::Base first' do 8 | require 'active_type' 9 | expect(ActiveType).to respond_to(:cast) 10 | end 11 | 12 | end 13 | -------------------------------------------------------------------------------- /lib/active_type/object.rb: -------------------------------------------------------------------------------- 1 | require 'active_type/no_table' 2 | require 'active_type/virtual_attributes' 3 | require 'active_type/nested_attributes' 4 | 5 | module ActiveType 6 | 7 | class Object < ActiveRecord::Base 8 | 9 | include NoTable 10 | include VirtualAttributes 11 | include NestedAttributes 12 | 13 | end 14 | 15 | end 16 | -------------------------------------------------------------------------------- /spec/isolated/load_without_active_record_use_spec.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | require 'isolated_spec_helper' 4 | 5 | RSpec.describe 'ActiveType', type: :isolated do 6 | 7 | it 'can be used without explicitly using ActiveRecord::Base first' do 8 | expect { 9 | require 'active_type' 10 | ActiveType::Object 11 | }.not_to raise_error 12 | end 13 | 14 | end 15 | -------------------------------------------------------------------------------- /lib/active_type/record.rb: -------------------------------------------------------------------------------- 1 | require 'active_type/virtual_attributes' 2 | require 'active_type/extended_record' 3 | require 'active_type/nested_attributes' 4 | 5 | module ActiveType 6 | 7 | class Record < ActiveRecord::Base 8 | 9 | @abstract_class = true 10 | 11 | include VirtualAttributes 12 | include NestedAttributes 13 | include ExtendedRecord 14 | 15 | end 16 | 17 | end 18 | -------------------------------------------------------------------------------- /spec/isolated/load_without_active_record_use_within_rails_spec.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | require 'isolated_spec_helper' 4 | 5 | RSpec.describe 'ActiveType', type: :isolated do 6 | 7 | it 'can be used in a Rails app without explicitly using ActiveRecord::Base first (see issue #75)' do 8 | expect { 9 | fake_rails 10 | require 'active_type' 11 | ActiveType::Object 12 | }.not_to raise_error 13 | end 14 | 15 | end 16 | -------------------------------------------------------------------------------- /spec/support/protected_params.rb: -------------------------------------------------------------------------------- 1 | class ProtectedParams < ActiveSupport::HashWithIndifferentAccess 2 | attr_accessor :permitted 3 | alias :permitted? :permitted 4 | 5 | def initialize(attributes) 6 | super(attributes) 7 | @permitted = false 8 | end 9 | 10 | def permit! 11 | @permitted = true 12 | self 13 | end 14 | 15 | def dup 16 | super.tap do |duplicate| 17 | duplicate.instance_variable_set :@permitted, @permitted 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /spec/shared_examples/belongs_to.rb: -------------------------------------------------------------------------------- 1 | shared_examples_for 'a belongs_to association' do |association, klass| 2 | 3 | let(:record) { klass.create } 4 | 5 | it 'sets the id when assigning a record' do 6 | subject.send("#{association}=", record) 7 | 8 | expect(subject.send("#{association}_id")).to eq(record.id) 9 | end 10 | 11 | it 'sets the record when assigning an id' do 12 | subject.send("#{association}_id=", record.id) 13 | 14 | expect(subject.send("#{association}")).to eq(record) 15 | end 16 | 17 | end 18 | -------------------------------------------------------------------------------- /spec/isolated/require_does_not_trigger_ar_load_spec.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | require 'isolated_spec_helper' 4 | 5 | RSpec.describe 'ActiveType', type: :isolated do 6 | 7 | it 'does not trigger active_record load-hook on require, since this messes up AR configuration via Rails initializers (see issue #72)' do 8 | fake_rails 9 | loaded = false 10 | ActiveSupport.on_load(:active_record) do 11 | loaded = true 12 | end 13 | require 'active_type' 14 | expect(loaded).to eq false 15 | end 16 | 17 | end 18 | -------------------------------------------------------------------------------- /lib/active_type/extended_record.rb: -------------------------------------------------------------------------------- 1 | require 'active_type/virtual_attributes' 2 | require 'active_type/extended_record/inheritance' 3 | 4 | module ActiveType 5 | 6 | module ExtendedRecord 7 | 8 | extend ActiveSupport::Concern 9 | 10 | module ClassMethods 11 | 12 | def [](base) 13 | Class.new(base) do 14 | 15 | include VirtualAttributes 16 | include NestedAttributes 17 | include Inheritance 18 | 19 | self.extended_record_base_class = base 20 | end 21 | end 22 | 23 | end 24 | 25 | end 26 | 27 | end 28 | -------------------------------------------------------------------------------- /lib/active_type.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | require 'active_type/version' 4 | 5 | require 'active_record' 6 | 7 | if ActiveRecord::VERSION::STRING == '4.2.0' 8 | raise(<<-MESSAGE.strip_heredoc) 9 | ActiveType is not compatible with ActiveRecord 4.2.0. Please upgrade to 4.2.1 10 | For details see https://github.com/makandra/active_type/issues/31 11 | MESSAGE 12 | end 13 | 14 | module ActiveType 15 | extend ActiveSupport::Autoload 16 | 17 | autoload :Object 18 | autoload :Record 19 | autoload :Util 20 | 21 | # Make Util methods available under the `ActiveType` namespace 22 | # like `ActiveType.cast(...)` 23 | extend Util 24 | end 25 | -------------------------------------------------------------------------------- /spec/shared_examples/accessors.rb: -------------------------------------------------------------------------------- 1 | shared_examples_for "ActiveRecord-like accessors" do |attributes| 2 | 3 | it 'allows to read and write' do 4 | attributes.each do |key, value| 5 | subject.send("#{key}=", value) 6 | expect(subject.send(key)).to eq(value) 7 | end 8 | end 9 | 10 | it 'allows to read via []' do 11 | attributes.each do |key, value| 12 | subject.send("#{key}=", value) 13 | expect(subject[key]).to eq(value) 14 | end 15 | end 16 | 17 | it 'allows to write via []=' do 18 | attributes.each do |key, value| 19 | subject[key] = value 20 | expect(subject.send(key)).to eq(value) 21 | end 22 | end 23 | 24 | end 25 | -------------------------------------------------------------------------------- /spec/isolated_spec_helper.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | $: << File.join(File.dirname(__FILE__), "/../../lib" ) 4 | 5 | module FakeRailsHelper 6 | def fake_rails 7 | require 'active_support' 8 | require 'active_record' 9 | eval <<-RUBY 10 | module ::Rails 11 | def self.env 12 | 'test' 13 | end 14 | end 15 | RUBY 16 | end 17 | end 18 | 19 | RSpec.configure do |config| 20 | config.include FakeRailsHelper 21 | 22 | config.around(:example, type: :isolated) do |example| 23 | if defined?(ActiveType::Object) || defined?(ActiveType::Record) 24 | skip('can only run isolated') 25 | else 26 | example.run 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /spec/shared_examples/mass_assignment.rb: -------------------------------------------------------------------------------- 1 | shared_examples_for 'ActiveRecord-like mass assignment' do |attributes| 2 | 3 | it 'assigns all given attributes' do 4 | subject.attributes = attributes 5 | 6 | attributes.each do |key, value| 7 | expect(subject.send(key)).to eq(value) 8 | end 9 | end 10 | 11 | if ActiveRecord::VERSION::MAJOR >= 4 12 | 13 | it 'raises on unpermitted parameters' do 14 | params = ProtectedParams.new(attributes) 15 | expect { subject.attributes = params }.to raise_error(ActiveModel::ForbiddenAttributesError) 16 | end 17 | 18 | it 'accepts permitted parameters' do 19 | params = ProtectedParams.new(attributes) 20 | params.permit! 21 | expect { subject.attributes = params }.to_not raise_error 22 | end 23 | 24 | end 25 | 26 | end 27 | -------------------------------------------------------------------------------- /spec/shared_examples/dupable.rb: -------------------------------------------------------------------------------- 1 | shared_examples_for "a class supporting dup for attributes" do |klass| 2 | 3 | subject do 4 | Class.new(klass) do 5 | attribute :attribute 6 | end.new 7 | end 8 | 9 | describe '#dup' do 10 | 11 | it 'returns an object with independent attributes' do 12 | subject.attribute = "foo" 13 | duped = subject.dup 14 | duped.attribute = "bar" 15 | 16 | expect(subject.attribute).to eq("foo") 17 | expect(duped.attribute).to eq("bar") 18 | end 19 | 20 | it 'does a deep copy' do 21 | subject.attribute = { :foo => "bar" } 22 | duped = subject.dup 23 | duped.attribute.merge!(:foo => "baz") 24 | 25 | expect(subject.attribute).to eq({ :foo => "bar" }) 26 | expect(duped.attribute).to eq({ :foo => "baz" }) 27 | end 28 | 29 | end 30 | 31 | end 32 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | $: << File.join(File.dirname(__FILE__), "/../../lib" ) 4 | 5 | require 'active_type' 6 | 7 | ActiveRecord::Base.default_timezone = :local 8 | ActiveRecord::Base.raise_in_transactional_callbacks = true if ActiveRecord::Base.respond_to?(:raise_in_transactional_callbacks) && ActiveRecord::VERSION::MAJOR < 5 9 | 10 | Dir["#{File.dirname(__FILE__)}/support/*.rb"].each {|f| require f} 11 | Dir["#{File.dirname(__FILE__)}/shared_examples/*.rb"].each {|f| require f} 12 | 13 | 14 | RSpec.configure do |config| 15 | config.around do |example| 16 | if example.metadata.fetch(:rollback, true) 17 | ActiveRecord::Base.transaction do 18 | begin 19 | example.run 20 | ensure 21 | raise ActiveRecord::Rollback 22 | end 23 | end 24 | else 25 | example.run 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /spec/shared_examples/constructor.rb: -------------------------------------------------------------------------------- 1 | shared_examples_for 'ActiveRecord-like constructors' do |attributes| 2 | 3 | it 'return a new record' do 4 | expect(subject.new).to be_new_record 5 | end 6 | 7 | it 'assigns given attributes' do 8 | record = subject.new(attributes) 9 | 10 | attributes.each do |key, value| 11 | expect(record.send(key)).to eq(value) 12 | end 13 | end 14 | 15 | if ActiveRecord::VERSION::MAJOR >= 4 16 | 17 | it 'raises on unpermitted parameters' do 18 | params = ProtectedParams.new(attributes) 19 | expect { subject.new(params) }.to raise_error(ActiveModel::ForbiddenAttributesError) 20 | end 21 | 22 | it 'accepts permitted parameters' do 23 | params = ProtectedParams.new(attributes) 24 | params.permit! 25 | expect { subject.new(params) }.to_not raise_error 26 | end 27 | 28 | end 29 | 30 | end 31 | -------------------------------------------------------------------------------- /active_type.gemspec: -------------------------------------------------------------------------------- 1 | $:.push File.expand_path("../lib", __FILE__) 2 | require "active_type/version" 3 | 4 | Gem::Specification.new do |s| 5 | s.name = 'active_type' 6 | s.version = ActiveType::VERSION 7 | s.authors = ["Tobias Kraze", "Henning Koch"] 8 | s.email = 'tobias.kraze@makandra.de' 9 | s.homepage = 'https://github.com/makandra/active_type' 10 | s.summary = 'Make any Ruby object quack like ActiveRecord' 11 | s.description = s.summary 12 | s.license = 'MIT' 13 | 14 | s.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } 15 | s.executables = s.files.grep(%r{^bin/}) { |f| File.basename(f) } 16 | s.test_files = s.files.grep(%r{^(test|spec|features)/}) 17 | s.require_paths = ["lib"] 18 | 19 | s.add_development_dependency "bundler", "~> 1.5" 20 | s.add_development_dependency "rake" 21 | 22 | s.add_runtime_dependency('activerecord', '>= 3.2') 23 | 24 | s.required_ruby_version = '>= 1.9.3' 25 | end 26 | -------------------------------------------------------------------------------- /lib/active_type/nested_attributes.rb: -------------------------------------------------------------------------------- 1 | require 'active_type/nested_attributes/builder' 2 | 3 | module ActiveType 4 | 5 | module NestedAttributes 6 | 7 | extend ActiveSupport::Concern 8 | 9 | included do 10 | attr_accessor :_nested_attribute_scopes 11 | end 12 | 13 | module ClassMethods 14 | 15 | def nests_one(association_name, options = {}) 16 | Builder.new(self, generated_nested_attribute_methods).build(association_name, :one, options) 17 | end 18 | 19 | def nests_many(association_name, options = {}) 20 | Builder.new(self, generated_nested_attribute_methods).build(association_name, :many, options) 21 | end 22 | 23 | 24 | private 25 | 26 | def generated_nested_attribute_methods 27 | @generated_nested_attribute_methods ||= begin 28 | mod = Module.new 29 | include mod 30 | mod 31 | end 32 | end 33 | 34 | end 35 | 36 | end 37 | 38 | end 39 | 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014 Tobias Kraze 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /spec/shared_examples/dirty_tracking.rb: -------------------------------------------------------------------------------- 1 | shared_examples_for "a class supporting dirty tracking for virtual attributes" do |klass| 2 | 3 | subject do 4 | Class.new(klass) do 5 | attribute :virtual_attribute 6 | end.new 7 | end 8 | 9 | describe '#virtual_attribute_was' do 10 | 11 | it 'always returns nil, since there can be no previously saved value' do 12 | expect(subject.virtual_attribute_was).to be_nil 13 | end 14 | 15 | end 16 | 17 | describe '#virtual_attribute_changed?' do 18 | 19 | it 'returns true if the attribute is not nil' do 20 | subject.virtual_attribute = 'foo' 21 | expect(subject.virtual_attribute_changed?).to eq(true) 22 | end 23 | 24 | it 'returns false if the attribute is nil' do 25 | subject.virtual_attribute = nil 26 | expect(subject.virtual_attribute_changed?).to be_falsey 27 | end 28 | 29 | end 30 | 31 | describe '#virtual_attribute_will_change!' do 32 | 33 | it 'is implemented for compatibility with ActiveModel::Dirty, but does nothing' do 34 | expect(subject).to respond_to(:virtual_attribute_will_change!) 35 | expect { subject.virtual_attribute_will_change! }.to_not raise_error 36 | end 37 | 38 | end 39 | 40 | end 41 | -------------------------------------------------------------------------------- /gemfiles/Gemfile.3.2.sqlite3.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: .. 3 | specs: 4 | active_type (0.6.4) 5 | activerecord (>= 3.2) 6 | 7 | GEM 8 | remote: https://rubygems.org/ 9 | specs: 10 | activemodel (3.2.22) 11 | activesupport (= 3.2.22) 12 | builder (~> 3.0.0) 13 | activerecord (3.2.22) 14 | activemodel (= 3.2.22) 15 | activesupport (= 3.2.22) 16 | arel (~> 3.0.2) 17 | tzinfo (~> 0.3.29) 18 | activesupport (3.2.22) 19 | i18n (~> 0.6, >= 0.6.4) 20 | multi_json (~> 1.0) 21 | arel (3.0.3) 22 | builder (3.0.4) 23 | diff-lcs (1.2.5) 24 | i18n (0.6.11) 25 | multi_json (1.11.2) 26 | rake (10.4.2) 27 | rspec (3.4.0) 28 | rspec-core (~> 3.4.0) 29 | rspec-expectations (~> 3.4.0) 30 | rspec-mocks (~> 3.4.0) 31 | rspec-core (3.4.1) 32 | rspec-support (~> 3.4.0) 33 | rspec-expectations (3.4.0) 34 | diff-lcs (>= 1.2.0, < 2.0) 35 | rspec-support (~> 3.4.0) 36 | rspec-mocks (3.4.1) 37 | diff-lcs (>= 1.2.0, < 2.0) 38 | rspec-support (~> 3.4.0) 39 | rspec-support (3.4.1) 40 | sqlite3 (1.3.11) 41 | tzinfo (0.3.46) 42 | 43 | PLATFORMS 44 | ruby 45 | 46 | DEPENDENCIES 47 | active_type! 48 | activerecord (= 3.2.22) 49 | i18n (= 0.6.11) 50 | rake 51 | rspec (~> 3.4) 52 | sqlite3 53 | 54 | BUNDLED WITH 55 | 1.12.1 56 | -------------------------------------------------------------------------------- /gemfiles/Gemfile.3.2.mysql2.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: .. 3 | specs: 4 | active_type (0.6.4) 5 | activerecord (>= 3.2) 6 | 7 | GEM 8 | remote: https://rubygems.org/ 9 | specs: 10 | activemodel (3.2.22) 11 | activesupport (= 3.2.22) 12 | builder (~> 3.0.0) 13 | activerecord (3.2.22) 14 | activemodel (= 3.2.22) 15 | activesupport (= 3.2.22) 16 | arel (~> 3.0.2) 17 | tzinfo (~> 0.3.29) 18 | activesupport (3.2.22) 19 | i18n (~> 0.6, >= 0.6.4) 20 | multi_json (~> 1.0) 21 | arel (3.0.3) 22 | builder (3.0.4) 23 | diff-lcs (1.2.5) 24 | i18n (0.6.11) 25 | multi_json (1.11.2) 26 | mysql2 (0.3.17) 27 | rake (10.4.2) 28 | rspec (3.4.0) 29 | rspec-core (~> 3.4.0) 30 | rspec-expectations (~> 3.4.0) 31 | rspec-mocks (~> 3.4.0) 32 | rspec-core (3.4.1) 33 | rspec-support (~> 3.4.0) 34 | rspec-expectations (3.4.0) 35 | diff-lcs (>= 1.2.0, < 2.0) 36 | rspec-support (~> 3.4.0) 37 | rspec-mocks (3.4.1) 38 | diff-lcs (>= 1.2.0, < 2.0) 39 | rspec-support (~> 3.4.0) 40 | rspec-support (3.4.1) 41 | tzinfo (0.3.46) 42 | 43 | PLATFORMS 44 | ruby 45 | 46 | DEPENDENCIES 47 | active_type! 48 | activerecord (= 3.2.22) 49 | i18n (= 0.6.11) 50 | mysql2 (= 0.3.17) 51 | rake 52 | rspec (~> 3.4) 53 | 54 | BUNDLED WITH 55 | 1.12.1 56 | -------------------------------------------------------------------------------- /gemfiles/Gemfile.5.0.0.pg.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: .. 3 | specs: 4 | active_type (0.6.4) 5 | activerecord (>= 3.2) 6 | 7 | GEM 8 | remote: https://rubygems.org/ 9 | specs: 10 | activemodel (5.0.0) 11 | activesupport (= 5.0.0) 12 | activerecord (5.0.0) 13 | activemodel (= 5.0.0) 14 | activesupport (= 5.0.0) 15 | arel (~> 7.0) 16 | activesupport (5.0.0) 17 | concurrent-ruby (~> 1.0, >= 1.0.2) 18 | i18n (~> 0.7) 19 | minitest (~> 5.1) 20 | tzinfo (~> 1.1) 21 | arel (7.0.0) 22 | concurrent-ruby (1.0.2) 23 | diff-lcs (1.2.5) 24 | i18n (0.7.0) 25 | minitest (5.9.0) 26 | pg (0.18.4) 27 | rake (10.4.2) 28 | rspec (3.5.0) 29 | rspec-core (~> 3.5.0) 30 | rspec-expectations (~> 3.5.0) 31 | rspec-mocks (~> 3.5.0) 32 | rspec-core (3.5.0) 33 | rspec-support (~> 3.5.0) 34 | rspec-expectations (3.5.0) 35 | diff-lcs (>= 1.2.0, < 2.0) 36 | rspec-support (~> 3.5.0) 37 | rspec-mocks (3.5.0) 38 | diff-lcs (>= 1.2.0, < 2.0) 39 | rspec-support (~> 3.5.0) 40 | rspec-support (3.5.0) 41 | thread_safe (0.3.5) 42 | tzinfo (1.2.2) 43 | thread_safe (~> 0.1) 44 | 45 | PLATFORMS 46 | ruby 47 | 48 | DEPENDENCIES 49 | active_type! 50 | activerecord (~> 5.0.0) 51 | pg 52 | rake 53 | rspec (~> 3.4) 54 | 55 | BUNDLED WITH 56 | 1.12.1 57 | -------------------------------------------------------------------------------- /gemfiles/Gemfile.5.0.0.sqlite3.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: .. 3 | specs: 4 | active_type (0.6.4) 5 | activerecord (>= 3.2) 6 | 7 | GEM 8 | remote: https://rubygems.org/ 9 | specs: 10 | activemodel (5.0.0) 11 | activesupport (= 5.0.0) 12 | activerecord (5.0.0) 13 | activemodel (= 5.0.0) 14 | activesupport (= 5.0.0) 15 | arel (~> 7.0) 16 | activesupport (5.0.0) 17 | concurrent-ruby (~> 1.0, >= 1.0.2) 18 | i18n (~> 0.7) 19 | minitest (~> 5.1) 20 | tzinfo (~> 1.1) 21 | arel (7.0.0) 22 | concurrent-ruby (1.0.2) 23 | diff-lcs (1.2.5) 24 | i18n (0.7.0) 25 | minitest (5.9.0) 26 | rake (10.4.2) 27 | rspec (3.5.0) 28 | rspec-core (~> 3.5.0) 29 | rspec-expectations (~> 3.5.0) 30 | rspec-mocks (~> 3.5.0) 31 | rspec-core (3.5.0) 32 | rspec-support (~> 3.5.0) 33 | rspec-expectations (3.5.0) 34 | diff-lcs (>= 1.2.0, < 2.0) 35 | rspec-support (~> 3.5.0) 36 | rspec-mocks (3.5.0) 37 | diff-lcs (>= 1.2.0, < 2.0) 38 | rspec-support (~> 3.5.0) 39 | rspec-support (3.5.0) 40 | sqlite3 (1.3.11) 41 | thread_safe (0.3.5) 42 | tzinfo (1.2.2) 43 | thread_safe (~> 0.1) 44 | 45 | PLATFORMS 46 | ruby 47 | 48 | DEPENDENCIES 49 | active_type! 50 | activerecord (~> 5.0.0) 51 | rake 52 | rspec (~> 3.4) 53 | sqlite3 54 | 55 | BUNDLED WITH 56 | 1.12.1 57 | -------------------------------------------------------------------------------- /gemfiles/Gemfile.5.0.0.mysql2.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: .. 3 | specs: 4 | active_type (0.6.4) 5 | activerecord (>= 3.2) 6 | 7 | GEM 8 | remote: https://rubygems.org/ 9 | specs: 10 | activemodel (5.0.0) 11 | activesupport (= 5.0.0) 12 | activerecord (5.0.0) 13 | activemodel (= 5.0.0) 14 | activesupport (= 5.0.0) 15 | arel (~> 7.0) 16 | activesupport (5.0.0) 17 | concurrent-ruby (~> 1.0, >= 1.0.2) 18 | i18n (~> 0.7) 19 | minitest (~> 5.1) 20 | tzinfo (~> 1.1) 21 | arel (7.0.0) 22 | concurrent-ruby (1.0.2) 23 | diff-lcs (1.2.5) 24 | i18n (0.7.0) 25 | minitest (5.9.0) 26 | mysql2 (0.3.21) 27 | rake (10.4.2) 28 | rspec (3.5.0) 29 | rspec-core (~> 3.5.0) 30 | rspec-expectations (~> 3.5.0) 31 | rspec-mocks (~> 3.5.0) 32 | rspec-core (3.5.0) 33 | rspec-support (~> 3.5.0) 34 | rspec-expectations (3.5.0) 35 | diff-lcs (>= 1.2.0, < 2.0) 36 | rspec-support (~> 3.5.0) 37 | rspec-mocks (3.5.0) 38 | diff-lcs (>= 1.2.0, < 2.0) 39 | rspec-support (~> 3.5.0) 40 | rspec-support (3.5.0) 41 | thread_safe (0.3.5) 42 | tzinfo (1.2.2) 43 | thread_safe (~> 0.1) 44 | 45 | PLATFORMS 46 | ruby 47 | 48 | DEPENDENCIES 49 | active_type! 50 | activerecord (~> 5.0.0) 51 | mysql2 (~> 0.3.17) 52 | rake 53 | rspec (~> 3.4) 54 | 55 | BUNDLED WITH 56 | 1.12.1 57 | -------------------------------------------------------------------------------- /gemfiles/Gemfile.4.2.1.pg.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: .. 3 | specs: 4 | active_type (0.6.4) 5 | activerecord (>= 3.2) 6 | 7 | GEM 8 | remote: https://rubygems.org/ 9 | specs: 10 | activemodel (4.2.5) 11 | activesupport (= 4.2.5) 12 | builder (~> 3.1) 13 | activerecord (4.2.5) 14 | activemodel (= 4.2.5) 15 | activesupport (= 4.2.5) 16 | arel (~> 6.0) 17 | activesupport (4.2.5) 18 | i18n (~> 0.7) 19 | json (~> 1.7, >= 1.7.7) 20 | minitest (~> 5.1) 21 | thread_safe (~> 0.3, >= 0.3.4) 22 | tzinfo (~> 1.1) 23 | arel (6.0.3) 24 | builder (3.2.2) 25 | diff-lcs (1.2.5) 26 | i18n (0.7.0) 27 | json (1.8.3) 28 | minitest (5.8.3) 29 | pg (0.18.4) 30 | rake (10.4.2) 31 | rspec (3.4.0) 32 | rspec-core (~> 3.4.0) 33 | rspec-expectations (~> 3.4.0) 34 | rspec-mocks (~> 3.4.0) 35 | rspec-core (3.4.1) 36 | rspec-support (~> 3.4.0) 37 | rspec-expectations (3.4.0) 38 | diff-lcs (>= 1.2.0, < 2.0) 39 | rspec-support (~> 3.4.0) 40 | rspec-mocks (3.4.1) 41 | diff-lcs (>= 1.2.0, < 2.0) 42 | rspec-support (~> 3.4.0) 43 | rspec-support (3.4.1) 44 | thread_safe (0.3.5) 45 | tzinfo (1.2.2) 46 | thread_safe (~> 0.1) 47 | 48 | PLATFORMS 49 | ruby 50 | 51 | DEPENDENCIES 52 | active_type! 53 | activerecord (~> 4.2.1) 54 | pg 55 | rake 56 | rspec (~> 3.4) 57 | 58 | BUNDLED WITH 59 | 1.12.1 60 | -------------------------------------------------------------------------------- /gemfiles/Gemfile.4.2.1.sqlite3.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: .. 3 | specs: 4 | active_type (0.6.4) 5 | activerecord (>= 3.2) 6 | 7 | GEM 8 | remote: https://rubygems.org/ 9 | specs: 10 | activemodel (4.2.5) 11 | activesupport (= 4.2.5) 12 | builder (~> 3.1) 13 | activerecord (4.2.5) 14 | activemodel (= 4.2.5) 15 | activesupport (= 4.2.5) 16 | arel (~> 6.0) 17 | activesupport (4.2.5) 18 | i18n (~> 0.7) 19 | json (~> 1.7, >= 1.7.7) 20 | minitest (~> 5.1) 21 | thread_safe (~> 0.3, >= 0.3.4) 22 | tzinfo (~> 1.1) 23 | arel (6.0.3) 24 | builder (3.2.2) 25 | diff-lcs (1.2.5) 26 | i18n (0.7.0) 27 | json (1.8.3) 28 | minitest (5.8.3) 29 | rake (10.4.2) 30 | rspec (3.4.0) 31 | rspec-core (~> 3.4.0) 32 | rspec-expectations (~> 3.4.0) 33 | rspec-mocks (~> 3.4.0) 34 | rspec-core (3.4.1) 35 | rspec-support (~> 3.4.0) 36 | rspec-expectations (3.4.0) 37 | diff-lcs (>= 1.2.0, < 2.0) 38 | rspec-support (~> 3.4.0) 39 | rspec-mocks (3.4.1) 40 | diff-lcs (>= 1.2.0, < 2.0) 41 | rspec-support (~> 3.4.0) 42 | rspec-support (3.4.1) 43 | sqlite3 (1.3.11) 44 | thread_safe (0.3.5) 45 | tzinfo (1.2.2) 46 | thread_safe (~> 0.1) 47 | 48 | PLATFORMS 49 | ruby 50 | 51 | DEPENDENCIES 52 | active_type! 53 | activerecord (~> 4.2.1) 54 | rake 55 | rspec (~> 3.4) 56 | sqlite3 57 | 58 | BUNDLED WITH 59 | 1.12.1 60 | -------------------------------------------------------------------------------- /gemfiles/Gemfile.4.2.1.mysql2.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: .. 3 | specs: 4 | active_type (0.6.4) 5 | activerecord (>= 3.2) 6 | 7 | GEM 8 | remote: https://rubygems.org/ 9 | specs: 10 | activemodel (4.2.5) 11 | activesupport (= 4.2.5) 12 | builder (~> 3.1) 13 | activerecord (4.2.5) 14 | activemodel (= 4.2.5) 15 | activesupport (= 4.2.5) 16 | arel (~> 6.0) 17 | activesupport (4.2.5) 18 | i18n (~> 0.7) 19 | json (~> 1.7, >= 1.7.7) 20 | minitest (~> 5.1) 21 | thread_safe (~> 0.3, >= 0.3.4) 22 | tzinfo (~> 1.1) 23 | arel (6.0.3) 24 | builder (3.2.2) 25 | diff-lcs (1.2.5) 26 | i18n (0.7.0) 27 | json (1.8.3) 28 | minitest (5.8.3) 29 | mysql2 (0.3.20) 30 | rake (10.4.2) 31 | rspec (3.4.0) 32 | rspec-core (~> 3.4.0) 33 | rspec-expectations (~> 3.4.0) 34 | rspec-mocks (~> 3.4.0) 35 | rspec-core (3.4.1) 36 | rspec-support (~> 3.4.0) 37 | rspec-expectations (3.4.0) 38 | diff-lcs (>= 1.2.0, < 2.0) 39 | rspec-support (~> 3.4.0) 40 | rspec-mocks (3.4.1) 41 | diff-lcs (>= 1.2.0, < 2.0) 42 | rspec-support (~> 3.4.0) 43 | rspec-support (3.4.1) 44 | thread_safe (0.3.5) 45 | tzinfo (1.2.2) 46 | thread_safe (~> 0.1) 47 | 48 | PLATFORMS 49 | ruby 50 | 51 | DEPENDENCIES 52 | active_type! 53 | activerecord (~> 4.2.1) 54 | mysql2 (~> 0.3.17) 55 | rake 56 | rspec (~> 3.4) 57 | 58 | BUNDLED WITH 59 | 1.12.1 60 | -------------------------------------------------------------------------------- /gemfiles/Gemfile.4.1.sqlite3.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: .. 3 | specs: 4 | active_type (0.6.4) 5 | activerecord (>= 3.2) 6 | 7 | GEM 8 | remote: https://rubygems.org/ 9 | specs: 10 | activemodel (4.1.14) 11 | activesupport (= 4.1.14) 12 | builder (~> 3.1) 13 | activerecord (4.1.14) 14 | activemodel (= 4.1.14) 15 | activesupport (= 4.1.14) 16 | arel (~> 5.0.0) 17 | activesupport (4.1.14) 18 | i18n (~> 0.6, >= 0.6.9) 19 | json (~> 1.7, >= 1.7.7) 20 | minitest (~> 5.1) 21 | thread_safe (~> 0.1) 22 | tzinfo (~> 1.1) 23 | arel (5.0.1.20140414130214) 24 | builder (3.2.2) 25 | diff-lcs (1.2.5) 26 | i18n (0.7.0) 27 | json (1.8.3) 28 | minitest (5.8.3) 29 | rake (10.4.2) 30 | rspec (3.4.0) 31 | rspec-core (~> 3.4.0) 32 | rspec-expectations (~> 3.4.0) 33 | rspec-mocks (~> 3.4.0) 34 | rspec-core (3.4.1) 35 | rspec-support (~> 3.4.0) 36 | rspec-expectations (3.4.0) 37 | diff-lcs (>= 1.2.0, < 2.0) 38 | rspec-support (~> 3.4.0) 39 | rspec-mocks (3.4.1) 40 | diff-lcs (>= 1.2.0, < 2.0) 41 | rspec-support (~> 3.4.0) 42 | rspec-support (3.4.1) 43 | sqlite3 (1.3.11) 44 | thread_safe (0.3.5) 45 | tzinfo (1.2.2) 46 | thread_safe (~> 0.1) 47 | 48 | PLATFORMS 49 | ruby 50 | 51 | DEPENDENCIES 52 | active_type! 53 | activerecord (~> 4.1.0) 54 | rake 55 | rspec (~> 3.4) 56 | sqlite3 57 | 58 | BUNDLED WITH 59 | 1.12.1 60 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'rake' 2 | require 'bundler/gem_tasks' 3 | 4 | desc 'Default: Run all specs.' 5 | task :default => 'all:spec' 6 | 7 | 8 | desc "Run specs and isolated specs" 9 | task :spec do 10 | success = run_specs 11 | fail "Tests failed" unless success 12 | end 13 | 14 | namespace :all do 15 | 16 | desc "Run specs on all versions" 17 | task :spec do 18 | success = true 19 | for_each_gemfile do 20 | success &= run_specs 21 | end 22 | fail "Tests failed" unless success 23 | end 24 | 25 | desc "Bundle all versions" 26 | task :install do 27 | for_each_gemfile do 28 | system('bundle install') 29 | end 30 | end 31 | 32 | desc "Update all versions" 33 | task :update do 34 | for_each_gemfile do 35 | system('bundle update') 36 | end 37 | end 38 | 39 | end 40 | 41 | def for_each_gemfile 42 | version = ENV['VERSION'] || '*' 43 | Dir["gemfiles/Gemfile.#{version}"].sort.each do |gemfile| 44 | next if gemfile =~ /.lock/ 45 | puts '', "\033[44m#{gemfile}\033[0m", '' 46 | ENV['BUNDLE_GEMFILE'] = gemfile 47 | yield 48 | end 49 | end 50 | 51 | def for_each_isolated_spec 52 | Dir["spec/isolated/**/*_spec.rb"].sort.each do |isolated_spec| 53 | yield(isolated_spec) 54 | end 55 | end 56 | 57 | def run_specs 58 | success = system("bundle exec rspec spec --exclude-pattern '**/isolated/**'") 59 | for_each_isolated_spec do |isolated_spec| 60 | success &= system("bundle exec rspec #{isolated_spec}") 61 | end 62 | success 63 | end 64 | -------------------------------------------------------------------------------- /gemfiles/Gemfile.4.0.sqlite3.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: .. 3 | specs: 4 | active_type (0.6.4) 5 | activerecord (>= 3.2) 6 | 7 | GEM 8 | remote: https://rubygems.org/ 9 | specs: 10 | activemodel (4.0.13) 11 | activesupport (= 4.0.13) 12 | builder (~> 3.1.0) 13 | activerecord (4.0.13) 14 | activemodel (= 4.0.13) 15 | activerecord-deprecated_finders (~> 1.0.2) 16 | activesupport (= 4.0.13) 17 | arel (~> 4.0.0) 18 | activerecord-deprecated_finders (1.0.4) 19 | activesupport (4.0.13) 20 | i18n (~> 0.6, >= 0.6.9) 21 | minitest (~> 4.2) 22 | multi_json (~> 1.3) 23 | thread_safe (~> 0.1) 24 | tzinfo (~> 0.3.37) 25 | arel (4.0.2) 26 | builder (3.1.4) 27 | diff-lcs (1.2.5) 28 | i18n (0.7.0) 29 | minitest (4.7.5) 30 | multi_json (1.11.2) 31 | rake (10.4.2) 32 | rspec (3.4.0) 33 | rspec-core (~> 3.4.0) 34 | rspec-expectations (~> 3.4.0) 35 | rspec-mocks (~> 3.4.0) 36 | rspec-core (3.4.1) 37 | rspec-support (~> 3.4.0) 38 | rspec-expectations (3.4.0) 39 | diff-lcs (>= 1.2.0, < 2.0) 40 | rspec-support (~> 3.4.0) 41 | rspec-mocks (3.4.1) 42 | diff-lcs (>= 1.2.0, < 2.0) 43 | rspec-support (~> 3.4.0) 44 | rspec-support (3.4.1) 45 | sqlite3 (1.3.11) 46 | thread_safe (0.3.5) 47 | tzinfo (0.3.46) 48 | 49 | PLATFORMS 50 | ruby 51 | 52 | DEPENDENCIES 53 | active_type! 54 | activerecord (~> 4.0.0) 55 | rake 56 | rspec (~> 3.4) 57 | sqlite3 58 | 59 | BUNDLED WITH 60 | 1.12.1 61 | -------------------------------------------------------------------------------- /spec/support/database.rb: -------------------------------------------------------------------------------- 1 | require 'yaml' 2 | 3 | # pg? 4 | case ENV['BUNDLE_GEMFILE'] 5 | when /pg/ 6 | if ENV['TRAVIS'] 7 | ActiveRecord::Base.establish_connection(:adapter => 'postgresql', :database => 'active_type_test', :username => 'postgres') 8 | else 9 | ActiveRecord::Base.establish_connection(:adapter => 'postgresql', :database => 'active_type_test') 10 | end 11 | # mysql2? 12 | when /mysql2/ 13 | config = { :adapter => 'mysql2', :encoding => 'utf8', :database => 'active_type_test' } 14 | custom_config_path = File.join(File.dirname(__FILE__), 'database.yml') 15 | if File.exists?(custom_config_path) 16 | custom_config = YAML.load_file(custom_config_path) 17 | config.merge!(custom_config) 18 | end 19 | ActiveRecord::Base.establish_connection(config) 20 | else 21 | ActiveRecord::Base.establish_connection(:adapter => 'sqlite3', :database => ':memory:') 22 | end 23 | 24 | 25 | connection = ::ActiveRecord::Base.connection 26 | tables = connection.respond_to?(:data_sources) ? connection.data_sources : connection.tables 27 | tables.each do |table| 28 | connection.drop_table table 29 | end 30 | 31 | ActiveRecord::Migration.class_eval do 32 | 33 | create_table :records do |t| 34 | t.string :persisted_string 35 | t.integer :persisted_integer 36 | t.datetime :persisted_time 37 | t.date :persisted_date 38 | t.boolean :persisted_boolean 39 | end 40 | 41 | create_table :children do |t| 42 | t.integer :record_id 43 | end 44 | 45 | create_table :sti_records do |t| 46 | t.string :persisted_string 47 | t.string :type 48 | end 49 | 50 | create_table :other_records do |t| 51 | t.string :other_string 52 | end 53 | 54 | end 55 | -------------------------------------------------------------------------------- /lib/active_type/nested_attributes/nests_one_association.rb: -------------------------------------------------------------------------------- 1 | require 'active_type/nested_attributes/association' 2 | 3 | module ActiveType 4 | 5 | module NestedAttributes 6 | 7 | class AssignmentError < StandardError; end 8 | 9 | class NestsOneAssociation < Association 10 | 11 | def assign_attributes(parent, attributes) 12 | return if attributes.nil? 13 | attributes = attributes.with_indifferent_access 14 | return if reject?(parent, attributes) 15 | 16 | assigned_child = assigned_children(parent).first 17 | destroy = truthy?(attributes.delete(:_destroy)) && @allow_destroy 18 | 19 | if id = attributes.delete(:id) 20 | assigned_child ||= fetch_child(parent, id.to_i) 21 | if assigned_child 22 | if assigned_child.id == id.to_i 23 | assigned_child.attributes = attributes 24 | else 25 | raise AssignmentError, "child record '#{@target_name}' did not match id '#{id}'" 26 | end 27 | if destroy 28 | assigned_child.mark_for_destruction 29 | end 30 | end 31 | elsif !destroy 32 | assigned_child ||= add_child(parent, build_child(parent, {})) 33 | assigned_child.attributes = attributes 34 | end 35 | end 36 | 37 | 38 | private 39 | 40 | def add_child(parent, child) 41 | parent[@target_name] = child 42 | end 43 | 44 | def assign_children(parent, children) 45 | parent[@target_name] = children.first 46 | end 47 | 48 | def derive_class_name 49 | @target_name.to_s.camelize 50 | end 51 | 52 | end 53 | 54 | end 55 | 56 | end 57 | -------------------------------------------------------------------------------- /spec/shared_examples/defaults.rb: -------------------------------------------------------------------------------- 1 | shared_examples_for "a class accepting attribute defaults" do |klass| 2 | 3 | subject do 4 | Class.new(klass) do 5 | attribute :static_string, :string, :default => "static string" 6 | attribute :dynamic_string, :string, :default => proc { "dynamic string" } 7 | attribute :referential_string, :string, :default => proc { value } 8 | attribute :number, :integer, :default => "10" 9 | attribute :computed, :default => proc { compute } 10 | 11 | def value 12 | "value" 13 | end 14 | 15 | end.new 16 | end 17 | 18 | it 'can have static defaults' do 19 | expect(subject.static_string).to eq("static string") 20 | end 21 | 22 | it 'can have dynamic defaults' do 23 | expect(subject.dynamic_string).to eq("dynamic string") 24 | end 25 | 26 | it 'can have defaults refering to instance methods' do 27 | expect(subject.referential_string).to eq("value") 28 | end 29 | 30 | it 'typecasts defaults' do 31 | expect(subject.number).to eq(10) 32 | end 33 | 34 | it 'computes defaults lazily' do 35 | expect(subject).to receive(:compute).and_return("computed") 36 | expect(subject.computed).to eq("computed") 37 | end 38 | 39 | it 'does not compute defaults more than once' do 40 | expect(subject).to receive(:compute).exactly(:once).and_return(nil) 41 | subject.computed 42 | subject.computed 43 | end 44 | 45 | it 'does not compute defaults when overriden' do 46 | subject.computed = 'not computed' 47 | expect(subject.computed).to eq('not computed') 48 | end 49 | 50 | it 'does not use defaults when overriden' do 51 | subject.static_string = "my string" 52 | expect(subject.static_string).to eq("my string") 53 | end 54 | 55 | it 'does not use defaults when overriden with nil' do 56 | subject.static_string = nil 57 | expect(subject.static_string).to eq(nil) 58 | end 59 | 60 | end 61 | -------------------------------------------------------------------------------- /lib/active_type/util.rb: -------------------------------------------------------------------------------- 1 | module ActiveType 2 | module Util 3 | 4 | def cast(object, klass) 5 | if object.is_a?(ActiveRecord::Relation) 6 | cast_relation(object, klass) 7 | elsif object.is_a?(ActiveRecord::Base) 8 | cast_record(object, klass) 9 | else 10 | raise ArgumentError, "Don't know how to cast #{object.inspect}" 11 | end 12 | end 13 | 14 | def scoped(klass_or_relation) 15 | klass_or_relation.where(nil) 16 | end 17 | 18 | private 19 | 20 | def cast_record(record, klass) 21 | # record.becomes(klass).dup 22 | klass.new do |casted| 23 | # Rails 3.2, 4.2 24 | casted.instance_variable_set(:@attributes, record.instance_variable_get(:@attributes)) 25 | # Rails 3.2 26 | casted.instance_variable_set(:@attributes_cache, record.instance_variable_get(:@attributes_cache)) 27 | # Rails 4.2 28 | casted.instance_variable_set(:@changed_attributes, record.instance_variable_get(:@changed_attributes)) 29 | # Rails 5.0 30 | casted.instance_variable_set(:@mutation_tracker, record.instance_variable_get(:@mutation_tracker)) 31 | # Rails 3.2, 4.2 32 | casted.instance_variable_set(:@new_record, record.new_record?) 33 | # Rails 3.2, 4.2 34 | casted.instance_variable_set(:@destroyed, record.destroyed?) 35 | # Rails 3.2, 4.2 36 | errors = record.errors 37 | if errors.kind_of? ActiveModel::Errors 38 | errors = errors.dup 39 | # otherwise attributes defined in ActiveType::Record 40 | # won't be visible to `errors.add` 41 | errors.instance_variable_set(:@base, casted) 42 | end 43 | casted.instance_variable_set(:@errors, errors) 44 | end 45 | end 46 | 47 | def cast_relation(relation, klass) 48 | scoped(klass).merge(scoped(relation)) 49 | end 50 | 51 | extend self 52 | 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /spec/active_type/extended_record/single_table_inheritance_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | module STISpec 4 | 5 | class Parent < ActiveRecord::Base 6 | self.table_name = 'sti_records' 7 | end 8 | 9 | class Child < Parent 10 | end 11 | 12 | class ExtendedChild < ActiveType::Record[Child] 13 | end 14 | 15 | class ExtendedExtendedChild < ActiveType::Record[ExtendedChild] 16 | end 17 | 18 | end 19 | 20 | 21 | describe 'ActiveType::Record[STIModel]' do 22 | 23 | describe 'persistence' do 24 | 25 | def should_save_and_load(save_as, load_as) 26 | record = save_as.new(:persisted_string => "string") 27 | expect(record.save).to eq(true) 28 | 29 | reloaded_child = load_as.find(record.id) 30 | expect(reloaded_child.persisted_string).to eq("string") 31 | expect(reloaded_child).to be_a(load_as) 32 | end 33 | 34 | it 'can save and load the active type record' do 35 | 36 | should_save_and_load(STISpec::ExtendedChild, STISpec::ExtendedChild) 37 | end 38 | 39 | it 'can save as base and load as active type record' do 40 | should_save_and_load(STISpec::Child, STISpec::ExtendedChild) 41 | end 42 | 43 | it 'can save as active type and load as base record' do 44 | should_save_and_load(STISpec::ExtendedChild, STISpec::Child) 45 | end 46 | 47 | it 'can load via the base class and convert to active type record' do 48 | record = STISpec::ExtendedChild.new(:persisted_string => "string") 49 | expect(record.save).to eq(true) 50 | 51 | reloaded_child = STISpec::Child.find(record.id).becomes(STISpec::ExtendedChild) 52 | expect(reloaded_child.persisted_string).to eq("string") 53 | expect(reloaded_child).to be_a(STISpec::ExtendedChild) 54 | end 55 | 56 | it 'can save classes further down the inheritance tree' do 57 | should_save_and_load(STISpec::ExtendedExtendedChild, STISpec::ExtendedExtendedChild) 58 | end 59 | 60 | end 61 | 62 | end 63 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | rvm: 3 | - "2.1.7" 4 | - "2.2.4" 5 | - "2.3.1" 6 | gemfile: 7 | - gemfiles/Gemfile.3.2.mysql2 8 | - gemfiles/Gemfile.3.2.sqlite3 9 | - gemfiles/Gemfile.4.0.sqlite3 10 | - gemfiles/Gemfile.4.1.sqlite3 11 | - gemfiles/Gemfile.4.2.1.mysql2 12 | - gemfiles/Gemfile.4.2.1.pg 13 | - gemfiles/Gemfile.4.2.1.sqlite3 14 | - gemfiles/Gemfile.5.0.0.mysql2 15 | - gemfiles/Gemfile.5.0.0.pg 16 | - gemfiles/Gemfile.5.0.0.sqlite3 17 | before_script: 18 | - psql -c 'create database active_type_test;' -U postgres 19 | - mysql -e 'create database IF NOT EXISTS active_type_test;' 20 | script: bundle exec rake spec 21 | sudo: false 22 | cache: bundler 23 | notifications: 24 | email: 25 | - fail@makandra.de 26 | branches: 27 | only: 28 | - master 29 | matrix: 30 | exclude: 31 | - rvm: "2.1.7" 32 | gemfile: gemfiles/Gemfile.4.0.sqlite3 33 | - rvm: "2.1.7" 34 | gemfile: gemfiles/Gemfile.4.2.1.mysql2 35 | - rvm: "2.1.7" 36 | gemfile: gemfiles/Gemfile.4.2.1.pg 37 | - rvm: "2.1.7" 38 | gemfile: gemfiles/Gemfile.3.2.mysql2 39 | - rvm: "2.1.7" 40 | gemfile: gemfiles/Gemfile.4.1.sqlite3 41 | - rvm: "2.1.7" 42 | gemfile: gemfiles/Gemfile.5.0.0.sqlite3 43 | - rvm: "2.1.7" 44 | gemfile: gemfiles/Gemfile.5.0.0.mysql2 45 | - rvm: "2.1.7" 46 | gemfile: gemfiles/Gemfile.5.0.0.pg 47 | - rvm: "2.2.4" 48 | gemfile: gemfiles/Gemfile.4.0.sqlite3 49 | - rvm: "2.2.4" 50 | gemfile: gemfiles/Gemfile.4.2.1.mysql2 51 | - rvm: "2.2.4" 52 | gemfile: gemfiles/Gemfile.4.2.1.pg 53 | - rvm: "2.2.4" 54 | gemfile: gemfiles/Gemfile.3.2.mysql2 55 | - rvm: "2.2.4" 56 | gemfile: gemfiles/Gemfile.4.1.sqlite3 57 | install: 58 | # Replace default Travis CI bundler script with a version that doesn't 59 | # explode when lockfile doesn't match recently bumped version 60 | - gem uninstall bundler -x -I 61 | - gem install bundler --version='=1.12.5' 62 | - bundle install --jobs=3 --retry=3 --path=${BUNDLE_PATH:-vendor/bundle} 63 | -------------------------------------------------------------------------------- /lib/active_type/nested_attributes/builder.rb: -------------------------------------------------------------------------------- 1 | require 'active_type/nested_attributes/nests_one_association' 2 | require 'active_type/nested_attributes/nests_many_association' 3 | 4 | module ActiveType 5 | 6 | module NestedAttributes 7 | 8 | class Builder 9 | 10 | def initialize(owner, mod) 11 | @owner = owner 12 | @module = mod 13 | end 14 | 15 | def build(name, one_or_many, options) 16 | add_attribute(name, options.slice(:default)) 17 | association = build_association(name, one_or_many == :one, options.except(:default)) 18 | add_writer_method(name, association) 19 | add_autosave(name, association) 20 | add_validation(name, association) 21 | end 22 | 23 | 24 | private 25 | 26 | def build_association(name, singular, options) 27 | (singular ? NestsOneAssociation : NestsManyAssociation).new(@owner, name, options) 28 | end 29 | 30 | def add_attribute(name, options) 31 | @owner.attribute(name, :object, options) 32 | end 33 | 34 | def add_writer_method(name, association) 35 | write_method = "#{name}_attributes=" 36 | @module.module_eval do 37 | define_method write_method do |attributes| 38 | association.assign_attributes(self, attributes) 39 | end 40 | end 41 | end 42 | 43 | def add_autosave(name, association) 44 | save_method = :"save_associated_records_for_#{name}" 45 | @module.module_eval do 46 | define_method save_method do 47 | association.save(self) 48 | end 49 | end 50 | @owner.after_save save_method 51 | end 52 | 53 | def add_validation(name, association) 54 | validate_method = :"validate_associated_records_for_#{name}" 55 | @module.module_eval do 56 | define_method validate_method do 57 | association.validate(self) 58 | end 59 | end 60 | @owner.validate validate_method 61 | end 62 | 63 | end 64 | 65 | end 66 | 67 | end 68 | -------------------------------------------------------------------------------- /lib/active_type/nested_attributes/nests_many_association.rb: -------------------------------------------------------------------------------- 1 | require 'active_type/nested_attributes/association' 2 | 3 | module ActiveType 4 | 5 | module NestedAttributes 6 | 7 | class NestsManyAssociation < Association 8 | 9 | def assign_attributes(parent, attributes_collection) 10 | return if attributes_collection.nil? 11 | 12 | unless attributes_collection.is_a?(Hash) || attributes_collection.is_a?(Array) 13 | raise ArgumentError, "Hash or Array expected, got #{attributes_collection.class.name} (#{attributes_collection.inspect})" 14 | end 15 | 16 | new_records = [] 17 | 18 | if attributes_collection.is_a?(Hash) 19 | keys = attributes_collection.keys 20 | attributes_collection = if keys.include?('id') || keys.include?(:id) 21 | Array.wrap(attributes_collection) 22 | else 23 | attributes_collection.sort_by { |i, _| i.to_i }.map { |_, attributes| attributes } 24 | end 25 | end 26 | 27 | attributes_collection.each do |attributes| 28 | attributes = attributes.with_indifferent_access 29 | next if reject?(parent, attributes) 30 | 31 | destroy = truthy?(attributes.delete(:_destroy)) && @allow_destroy 32 | 33 | if id = attributes.delete(:id) 34 | child = fetch_child(parent, id.to_i) 35 | if destroy 36 | child.mark_for_destruction 37 | else 38 | child.attributes = attributes 39 | end 40 | elsif !destroy 41 | new_records << build_child(parent, attributes) 42 | end 43 | end 44 | 45 | add_children(parent, new_records) 46 | end 47 | 48 | 49 | private 50 | 51 | def add_child(parent, child) 52 | add_children(parent, [child]) 53 | end 54 | 55 | def add_children(parent, children) 56 | parent[@target_name] = assigned_children(parent) + children 57 | end 58 | 59 | def assign_children(parent, children) 60 | parent[@target_name] = children 61 | end 62 | 63 | def derive_class_name 64 | @target_name.to_s.classify 65 | end 66 | 67 | def valid_options 68 | super + [:index_errors] 69 | end 70 | 71 | end 72 | 73 | end 74 | 75 | end 76 | -------------------------------------------------------------------------------- /spec/integration/sign_up_spec.rb: -------------------------------------------------------------------------------- 1 | # Usecase: implement a sign up form 2 | # The sign up is tied to a user model 3 | 4 | 5 | require 'spec_helper' 6 | 7 | ActiveRecord::Migration.class_eval do 8 | create_table :users do |t| 9 | t.string :email 10 | t.string :password 11 | end 12 | end 13 | 14 | 15 | module SignUpSpec 16 | 17 | class User < ActiveType::Record 18 | validates :email, :presence => true 19 | validates :password, :presence => true 20 | end 21 | 22 | 23 | class SignUp < ActiveType::Record[User] 24 | attribute :terms, :boolean 25 | 26 | validates :terms, :acceptance => {:allow_nil => false, :accept => true} 27 | 28 | after_create :send_welcome_email 29 | 30 | def send_welcome_email 31 | end 32 | end 33 | 34 | end 35 | 36 | 37 | describe SignUpSpec::User do 38 | 39 | it 'is valid without a password confirmation' do 40 | subject.email = "email" 41 | subject.password = "password" 42 | 43 | expect(subject).to be_valid 44 | end 45 | 46 | end 47 | 48 | 49 | describe SignUpSpec::SignUp do 50 | 51 | it 'is invalid without an email' do 52 | subject.password = "password" 53 | subject.terms = true 54 | 55 | expect(subject).not_to be_valid 56 | expect(subject.errors['email']).to eq(["can't be blank"]) 57 | end 58 | 59 | it 'is invalid without accepted terms' do 60 | subject.email = "email" 61 | subject.password = "password" 62 | 63 | expect(subject).not_to be_valid 64 | expect(subject.errors['terms']).to eq(["must be accepted"]) 65 | end 66 | 67 | context 'with invalid data' do 68 | 69 | it 'does not save' do 70 | expect(subject.save).to be_falsey 71 | end 72 | 73 | it 'does not send an email' do 74 | expect(subject).not_to receive :send_welcome_email 75 | subject.save 76 | end 77 | 78 | end 79 | 80 | context 'with valid data' do 81 | 82 | before do 83 | subject.email = "email" 84 | subject.password = "password" 85 | subject.terms = "1" 86 | end 87 | 88 | it 'does save' do 89 | subject.valid? 90 | expect(subject.save).to eq(true) 91 | end 92 | 93 | it 'sends the email' do 94 | expect(subject).to receive :send_welcome_email 95 | 96 | subject.save 97 | end 98 | 99 | end 100 | 101 | 102 | end 103 | -------------------------------------------------------------------------------- /spec/integration/sign_in_spec.rb: -------------------------------------------------------------------------------- 1 | # Usecase: implement a sign in form 2 | # The sign in is not tied to a database record 3 | 4 | require 'spec_helper' 5 | 6 | module SignInSpec 7 | 8 | class SignIn < ActiveType::Object 9 | attribute :email, :string 10 | attribute :password, :string 11 | 12 | validates :email, :presence => true 13 | validates :password, :presence => true 14 | 15 | validate :if => :password do |sign_in| 16 | errors.add(:password, 'is not correct') unless sign_in.password == "correct password" 17 | end 18 | 19 | after_save :set_session 20 | 21 | def set_session 22 | end 23 | end 24 | 25 | end 26 | 27 | describe SignInSpec::SignIn do 28 | 29 | describe 'with missing credentials' do 30 | 31 | it 'is invalid' do 32 | expect(subject).not_to be_valid 33 | end 34 | 35 | it 'has errors' do 36 | subject.valid? 37 | expect(subject.errors[:email]).to eq(["can't be blank"]) 38 | expect(subject.errors[:password]).to eq(["can't be blank"]) 39 | end 40 | 41 | it 'does not save' do 42 | expect(subject.save).to be_falsey 43 | end 44 | 45 | it 'does not set the session' do 46 | expect(subject).not_to receive :set_session 47 | subject.save 48 | end 49 | 50 | end 51 | 52 | describe 'with invalid credentials' do 53 | 54 | before do 55 | subject.email = "email" 56 | subject.password = "incorrect password" 57 | end 58 | 59 | it 'is invalid' do 60 | expect(subject).not_to be_valid 61 | end 62 | 63 | it 'has errors' do 64 | subject.valid? 65 | expect(subject.errors[:password]).to eq(["is not correct"]) 66 | end 67 | 68 | it 'does not save' do 69 | expect(subject.save).to be_falsey 70 | end 71 | 72 | it 'does not set the session' do 73 | expect(subject).not_to receive :set_session 74 | subject.save 75 | end 76 | 77 | end 78 | 79 | describe 'with valid credentials' do 80 | 81 | before do 82 | subject.email = "email" 83 | subject.password = "correct password" 84 | end 85 | 86 | it 'is invalid' do 87 | expect(subject).to be_valid 88 | end 89 | 90 | it 'does save' do 91 | expect(subject.save).to eq(true) 92 | end 93 | 94 | it 'sets the session' do 95 | expect(subject).to receive :set_session 96 | subject.save 97 | end 98 | 99 | end 100 | 101 | end 102 | -------------------------------------------------------------------------------- /lib/active_type/extended_record/inheritance.rb: -------------------------------------------------------------------------------- 1 | module ActiveType 2 | 3 | module ExtendedRecord 4 | 5 | module Inheritance 6 | 7 | extend ActiveSupport::Concern 8 | 9 | included do 10 | class_attribute :extended_record_base_class 11 | end 12 | 13 | module ClassMethods 14 | 15 | def model_name 16 | extended_record_base_class.model_name 17 | end 18 | 19 | def sti_name 20 | extended_record_base_class.sti_name 21 | end 22 | 23 | 24 | private 25 | 26 | if ActiveRecord::VERSION::MAJOR < 5 27 | 28 | def find_sti_class(type_name) 29 | sti_class = super 30 | if self <= sti_class 31 | self 32 | else 33 | sti_class 34 | end 35 | end 36 | 37 | else 38 | 39 | # Rails 5 find_sti_class does a sanity check for proper inheritance that fails for 40 | # our usecase 41 | # copied from activerecord/lib/active_record/inheritance.rb 42 | def find_sti_class(type_name) 43 | type_name = base_class.type_for_attribute(inheritance_column).cast(type_name) 44 | subclass = begin 45 | if store_full_sti_class 46 | ActiveSupport::Dependencies.constantize(type_name) 47 | else 48 | compute_type(type_name) 49 | end 50 | rescue NameError 51 | raise ActiveRecord::SubclassNotFound, 52 | "The single-table inheritance mechanism failed to locate the subclass: '#{type_name}'. " \ 53 | "This error is raised because the column '#{inheritance_column}' is reserved for storing the class in case of inheritance. " \ 54 | "Please rename this column if you didn't intend it to be used for storing the inheritance class " \ 55 | "or overwrite #{name}.inheritance_column to use another column for that information." 56 | end 57 | #### our code starts here 58 | if self <= subclass 59 | subclass = self 60 | end 61 | #### our code ends here 62 | unless subclass == self || descendants.include?(subclass) 63 | raise ActiveRecord::SubclassNotFound, "Invalid single-table inheritance type: #{subclass.name} is not a subclass of #{name}" 64 | end 65 | subclass 66 | end 67 | 68 | end 69 | 70 | end 71 | 72 | end 73 | 74 | end 75 | 76 | end 77 | -------------------------------------------------------------------------------- /spec/integration/shape_spec.rb: -------------------------------------------------------------------------------- 1 | # Usecase: Create a STI record, form model decides which type 2 | 3 | require 'spec_helper' 4 | 5 | ActiveRecord::Migration.class_eval do 6 | create_table :shapes do |t| 7 | t.string :type 8 | t.integer :radius 9 | t.integer :length 10 | t.integer :width 11 | end 12 | end 13 | 14 | module ShapeSpec 15 | 16 | class Shape < ActiveType::Record 17 | end 18 | 19 | class Circle < Shape 20 | validates :radius, :presence => true 21 | end 22 | 23 | class Rectangle < Shape 24 | validates :length, :width, :presence => true 25 | end 26 | 27 | class ShapeForm < ActiveType::Object 28 | nests_one :child 29 | 30 | def child_type=(type) 31 | case type 32 | when 'circle' 33 | if child 34 | self.child = self.child.becomes(Circle) 35 | else 36 | self.child = Circle.new 37 | end 38 | when 'rectangle' 39 | if child 40 | self.child = self.child.becomes(Rectangle) 41 | else 42 | self.child = Rectangle.new 43 | end 44 | end 45 | end 46 | end 47 | 48 | end 49 | 50 | 51 | describe ShapeSpec::ShapeForm do 52 | 53 | let(:form) { ShapeSpec::ShapeForm.new } 54 | 55 | def update(params) 56 | form.child_type = params[:type] 57 | form.child_attributes = params.except(:type) 58 | form.save 59 | end 60 | 61 | it 'can create a circle' do 62 | params = { 63 | 'type' => 'circle', 64 | 'radius' => '20' 65 | }.with_indifferent_access 66 | 67 | expect(update(params)).to eq(true) 68 | 69 | expect(ShapeSpec::Circle.all.collect(&:radius)).to eq([20]) 70 | expect(ShapeSpec::Rectangle.count).to eq(0) 71 | end 72 | 73 | it 'can create a rectangle' do 74 | params = { 75 | 'type' => 'rectangle', 76 | 'length' => '100', 77 | 'width' => '30' 78 | }.with_indifferent_access 79 | 80 | expect(update(params)).to eq(true) 81 | 82 | expect(ShapeSpec::Circle.count).to eq(0) 83 | expect(ShapeSpec::Rectangle.all.collect(&:length)).to eq([100]) 84 | expect(ShapeSpec::Rectangle.all.collect(&:width)).to eq([30]) 85 | end 86 | 87 | it 'can update' do 88 | params = { 89 | 'type' => 'circle', 90 | 'radius' => '20' 91 | }.with_indifferent_access 92 | update(params) 93 | 94 | params['radius'] = '30' 95 | expect(update(params)).to eq(true) 96 | 97 | expect(ShapeSpec::Circle.all.collect(&:radius)).to eq([30]) 98 | end 99 | 100 | it 'has validations' do 101 | params = { 102 | 'type' => 'circle' 103 | }.with_indifferent_access 104 | 105 | expect(update(params)).to be_falsey 106 | 107 | expect(form.child.errors['radius']).to eq(["can't be blank"]) 108 | end 109 | 110 | end 111 | -------------------------------------------------------------------------------- /spec/integration/holidays_spec.rb: -------------------------------------------------------------------------------- 1 | # Usecase: CRUD a number of records 2 | 3 | require 'spec_helper' 4 | 5 | ActiveRecord::Migration.class_eval do 6 | create_table :holidays do |t| 7 | t.string :name 8 | t.date :date 9 | end 10 | end 11 | 12 | module HolidaySpec 13 | 14 | class Holiday < ActiveRecord::Base 15 | validates :name, :date, :presence => true 16 | end 17 | 18 | class HolidayForm < ActiveType::Object 19 | nests_many :holidays, :scope => Holiday, :default => proc { Holiday.all }, :reject_if => :all_blank, :allow_destroy => true 20 | end 21 | 22 | end 23 | 24 | 25 | describe HolidaySpec::HolidayForm do 26 | 27 | let(:params) do 28 | { 29 | '1' => { 30 | 'name' => 'New Year', 31 | 'date' => '2014-01-01', 32 | }, 33 | '2' => { 34 | 'name' => 'Epiphany', 35 | 'date' => '2014-01-06', 36 | }, 37 | } 38 | end 39 | 40 | def update(params) 41 | form = HolidaySpec::HolidayForm.new(:holidays_attributes => params) 42 | if form.save 43 | ids = form.holidays.collect(&:id) 44 | params.each_with_index do |(key, attributes), index| 45 | attributes['id'] = ids[index] 46 | end 47 | true 48 | end 49 | end 50 | 51 | it 'will return holidays including updated ones' do 52 | HolidaySpec::Holiday.create!(:name => 'New Year', :date => '2014-01-01') 53 | form = HolidaySpec::HolidayForm.new(:holidays_attributes => params.slice('2')) 54 | expect(form.holidays.collect(&:name)).to eq(["New Year", "Epiphany"]) 55 | end 56 | 57 | it 'can create a list of holidays' do 58 | expect(update(params)).to eq(true) 59 | 60 | holidays = HolidaySpec::Holiday.order(:date) 61 | expect(holidays.collect(&:name)).to eq(["New Year", "Epiphany"]) 62 | expect(holidays.collect(&:date)).to eq([Date.civil(2014, 1, 1), Date.civil(2014, 1, 6)]) 63 | end 64 | 65 | it 'can update holidays' do 66 | update(params) 67 | 68 | params['1']['name'] += ' 2014' 69 | params['2']['name'] += ' 2014' 70 | expect(update(params)).to eq(true) 71 | 72 | holidays = HolidaySpec::Holiday.order(:date) 73 | expect(holidays.collect(&:name)).to eq(["New Year 2014", "Epiphany 2014"]) 74 | expect(holidays.collect(&:date)).to eq([Date.civil(2014, 1, 1), Date.civil(2014, 1, 6)]) 75 | end 76 | 77 | it 'can destroy holidays' do 78 | update(params) 79 | 80 | params['1']['_destroy'] = '1' 81 | expect(update(params)).to eq(true) 82 | 83 | holidays = HolidaySpec::Holiday.order(:date) 84 | expect(holidays.collect(&:name)).to eq(["Epiphany"]) 85 | expect(holidays.collect(&:date)).to eq([Date.civil(2014, 1, 6)]) 86 | end 87 | 88 | it 'will not save if some fields are invalid' do 89 | update(params) 90 | 91 | params['1']['name'] = '-' 92 | params['1']['_destroy'] = '1' 93 | params['2']['name'] = '' # invalid 94 | expect(update(params)).to be_falsey 95 | 96 | holidays = HolidaySpec::Holiday.order(:date) 97 | expect(holidays.collect(&:name)).to eq(["New Year", "Epiphany"]) 98 | expect(holidays.collect(&:date)).to eq([Date.civil(2014, 1, 1), Date.civil(2014, 1, 6)]) 99 | end 100 | 101 | 102 | end 103 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # ActiveType Change Log 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | ActiveType is in a pre-1.0 state. This means that its APIs and behavior are subject to breaking changes without deprecation notices. Until 1.0, version numbers will follow a [Semver][]-ish `0.y.z` format, where `y` is incremented when new features or breaking changes are introduced, and `z` is incremented for lesser changes or bug fixes. 6 | 7 | ## [0.6.4][] (2017-02-27) 8 | 9 | * Fix an issue when using `ActiveType.cast` "too early". 10 | 11 | ## [0.6.3][] (2017-01-30) 12 | 13 | * Fix a load error when using `ActiveType::Object` before using `ActiveRecord::Base` within a Rails app. 14 | 15 | ## [0.6.2][] (2017-01-30) 16 | 17 | * When used with Rails, defer loading to not interfere with `ActiveRecord` configuration in initializers. 18 | 19 | ## [0.6.1][] (2016-12-05) 20 | 21 | * Remove spec folder from packaged gem. 22 | 23 | ## [0.6.0][] (2016-07-05) 24 | 25 | * Drop support for 1.8.7. 26 | * Rails 5 compatibility. 27 | 28 | ## [0.5.1][] (2016-05-09) 29 | 30 | * Fix an issue with incorrectly copied errors on Util.cast. 31 | 32 | ## [0.5.0][] (2016-04-08) 33 | 34 | * Nicer `#inspect` method. 35 | 36 | ## [0.4.5][] (2016-02-01) 37 | 38 | * Fixed issue `#dup`ing `ActiveType::Object` 39 | 40 | ## [0.4.4][] (2016-01-18) 41 | 42 | * Call `#after_commit` for `ActiveType::Object` 43 | 44 | ## [0.4.3][] (2015-11-11) 45 | 46 | * Fix issue with Booleans on mysql. 47 | 48 | ## [0.4.2][] (2015-09-24) 49 | 50 | * Add `attribute_will_change!` for virtual attributes. 51 | 52 | ## [0.4.1][] (2015-09-24) 53 | 54 | * Add `attribute_was` for virtual attributes. 55 | 56 | ## [0.4.0][] (2015-06-12) 57 | 58 | * Add ActiveType.cast to cast ActiveRecord instances and relations to extended models 59 | 60 | ## [0.3.5][] (2015-06-11) 61 | 62 | * Make gem crash during loading with ActiveRecord 4.2.0 because [#31](https://github.com/makandra/active_type/issues/31) 63 | 64 | ## [0.3.4][] (2015-03-14) 65 | 66 | * Support belongs_to associations for ActiveRecord 4.2.1 67 | * Ensure that ActiveType::Object correctly validates boolean attributes (issue [#34](https://github.com/makandra/active_type/issues/34)) 68 | 69 | ## [0.3.3][] (2015-01-23) 70 | 71 | * Don't crash for database types without casting rules (fixes [#25](https://github.com/makandra/active_type/issues/25)) 72 | 73 | ## [0.3.2][] (2015-01-22) 74 | 75 | * Making the gem to work with Rails version 4.0.0 76 | * Use native database type for type casting in pg 77 | 78 | ## [0.3.1][] (2014-11-19) 79 | 80 | * Support nested attributes in extended records (fixes [#17](https://github.com/makandra/active_type/issues/17)) 81 | 82 | ## [0.3.0][] (2014-09-23) 83 | 84 | * Add support for Rails 4.2beta 85 | 86 | [Semver]: http://semver.org 87 | [Unreleased]: https://github.com/makandra/active_type/compare/v0.4.0...HEAD 88 | [0.4.0]: https://github.com/makandra/active_type/compare/v0.3.5...v0.4.0 89 | [0.3.5]: https://github.com/makandra/active_type/compare/v0.3.4...v0.3.5 90 | [0.3.4]: https://github.com/makandra/active_type/compare/v0.3.3...v0.3.4 91 | [0.3.3]: https://github.com/makandra/active_type/compare/v0.3.2...v0.3.3 92 | [0.3.2]: https://github.com/makandra/active_type/compare/v0.3.1...v0.3.2 93 | [0.3.1]: https://github.com/makandra/active_type/compare/v0.3.0...v0.3.1 94 | [0.3.0]: https://github.com/makandra/active_type/compare/v0.2.1...v0.3.0 95 | -------------------------------------------------------------------------------- /lib/active_type/no_table.rb: -------------------------------------------------------------------------------- 1 | module ActiveType 2 | 3 | if ActiveRecord::VERSION::MAJOR < 5 4 | 5 | module NoTable 6 | 7 | extend ActiveSupport::Concern 8 | 9 | module ClassMethods 10 | 11 | def column_types 12 | {} 13 | end 14 | 15 | def columns 16 | [] 17 | end 18 | 19 | def primary_key 20 | nil 21 | end 22 | 23 | def destroy(*) 24 | new 25 | end 26 | 27 | def destroy_all(*) 28 | [] 29 | end 30 | 31 | def find_by_sql(*) 32 | [] 33 | end 34 | 35 | end 36 | 37 | def id 38 | nil 39 | end 40 | 41 | def attribute_names 42 | [] 43 | end 44 | 45 | def transaction(&block) 46 | @_current_transaction_records ||= [] 47 | yield 48 | end 49 | 50 | def destroy 51 | @destroyed = true 52 | freeze 53 | end 54 | 55 | def reload 56 | self 57 | end 58 | 59 | 60 | private 61 | 62 | def create(*) 63 | true 64 | end 65 | 66 | def update(*) 67 | true 68 | end 69 | 70 | if ActiveRecord::Base.private_method_defined?(:create_record) 71 | def create_record(*) 72 | true 73 | end 74 | 75 | def update_record(*) 76 | true 77 | end 78 | else 79 | def _create_record(*) 80 | @new_record = false 81 | true 82 | end 83 | 84 | def _update_record(*) 85 | true 86 | end 87 | end 88 | 89 | end 90 | 91 | else 92 | 93 | # Rails 5+ 94 | 95 | module NoTable 96 | 97 | extend ActiveSupport::Concern 98 | 99 | class DummySchemaCache 100 | 101 | def columns_hash(table_name) 102 | {} 103 | end 104 | 105 | def data_source_exists?(table_name) 106 | false 107 | end 108 | 109 | def clear_data_source_cache!(table_name) 110 | end 111 | 112 | end 113 | 114 | class DummyConnection < ActiveRecord::ConnectionAdapters::AbstractAdapter 115 | 116 | attr_reader :schema_cache 117 | 118 | def initialize(*) 119 | super 120 | @schema_cache = DummySchemaCache.new 121 | end 122 | 123 | end 124 | 125 | module ClassMethods 126 | 127 | def connection 128 | @connection ||= DummyConnection.new(nil) 129 | end 130 | 131 | def destroy(*) 132 | new 133 | end 134 | 135 | def destroy_all(*) 136 | [] 137 | end 138 | 139 | def find_by_sql(*) 140 | [] 141 | end 142 | 143 | end 144 | 145 | def destroy 146 | @destroyed = true 147 | freeze 148 | end 149 | 150 | def reload 151 | self 152 | end 153 | 154 | 155 | private 156 | 157 | def create(*) 158 | true 159 | end 160 | 161 | def update(*) 162 | true 163 | end 164 | 165 | def _create_record(*) 166 | @new_record = false 167 | true 168 | end 169 | 170 | def _update_record(*) 171 | true 172 | end 173 | 174 | end 175 | 176 | end 177 | 178 | end 179 | -------------------------------------------------------------------------------- /lib/active_type/type_caster.rb: -------------------------------------------------------------------------------- 1 | module ActiveType 2 | class TypeCaster 3 | 4 | def self.get(type, connection) 5 | native_caster = if ActiveRecord::VERSION::STRING < '4.2' 6 | NativeCasters::DelegateToColumn.new(type) 7 | elsif ActiveRecord::VERSION::STRING < '5' 8 | NativeCasters::DelegateToRails4Type.new(type, connection) 9 | else 10 | NativeCasters::DelegateToRails5Type.new(type) 11 | end 12 | new(type, native_caster) 13 | end 14 | 15 | def initialize(type, native_caster) 16 | @type = type 17 | @native_caster = native_caster 18 | end 19 | 20 | def type_cast_from_user(value) 21 | # For some reason, Rails defines additional type casting logic 22 | # outside the classes that have that responsibility. 23 | case @type 24 | when :integer 25 | if value == '' 26 | nil 27 | else 28 | native_type_cast_from_user(value) 29 | end 30 | when :timestamp, :datetime 31 | time = native_type_cast_from_user(value) 32 | if time && ActiveRecord::Base.time_zone_aware_attributes 33 | time = ActiveSupport::TimeWithZone.new(nil, Time.zone, time) 34 | end 35 | time 36 | else 37 | native_type_cast_from_user(value) 38 | end 39 | end 40 | 41 | def native_type_cast_from_user(value) 42 | @native_caster.type_cast_from_user(value) 43 | end 44 | 45 | module NativeCasters 46 | 47 | # Adapter for Rails 3.0 - 4.1. 48 | # In these versions, casting logic lives in ActiveRecord::ConnectionAdapters::Column 49 | class DelegateToColumn 50 | 51 | def initialize(type) 52 | # the Column initializer expects type as returned from the database, and 53 | # resolves them to our types 54 | # fortunately, for all types wie support, type.to_s is a valid sql_type 55 | sql_type = type.to_s 56 | @column = ActiveRecord::ConnectionAdapters::Column.new('foo', nil, sql_type) 57 | end 58 | 59 | def type_cast_from_user(value) 60 | @column.type_cast(value) 61 | end 62 | 63 | end 64 | 65 | # Adapter for Rails 4.2+. 66 | # In these versions, casting logic lives in subclasses of ActiveRecord::Type::Value 67 | class DelegateToRails4Type 68 | 69 | def initialize(type, connection) 70 | # The specified type (e.g. "string") may not necessary match the 71 | # native type ("varchar") expected by the connection adapter. 72 | # PostgreSQL is one of these. Perform a translation if the adapter 73 | # supports it (but don't turn a mysql boolean into a tinyint). 74 | if !type.nil? && !(type == :boolean) && connection.respond_to?(:native_database_types) 75 | native_type = connection.native_database_types[type.to_sym] 76 | if native_type && native_type[:name] 77 | type = native_type[:name] 78 | else 79 | # unknown type, we just dont cast 80 | type = nil 81 | end 82 | end 83 | @active_record_type = connection.lookup_cast_type(type) 84 | end 85 | 86 | def type_cast_from_user(value) 87 | @active_record_type.type_cast_from_user(value) 88 | end 89 | 90 | end 91 | 92 | # Adapter for Rails 5+. 93 | # In these versions, casting logic lives in subclasses of ActiveRecord::Type::Value 94 | class DelegateToRails5Type 95 | 96 | def initialize(type) 97 | @active_record_type = lookup(type) 98 | end 99 | 100 | def type_cast_from_user(value) 101 | @active_record_type.cast(value) 102 | end 103 | 104 | private 105 | 106 | def lookup(type) 107 | if type.respond_to?(:cast) 108 | type 109 | else 110 | ActiveRecord::Base.connection_pool.with_connection{ 111 | ActiveRecord::Type.lookup(type) 112 | } 113 | end 114 | rescue ::ArgumentError => e 115 | ActiveRecord::Type::Value.new 116 | end 117 | 118 | end 119 | 120 | end 121 | 122 | end 123 | 124 | end 125 | -------------------------------------------------------------------------------- /lib/active_type/nested_attributes/association.rb: -------------------------------------------------------------------------------- 1 | require 'active_record/errors' 2 | 3 | module ActiveType 4 | 5 | module NestedAttributes 6 | 7 | class RecordNotFound < ActiveRecord::RecordNotFound; end 8 | 9 | class Association 10 | 11 | def initialize(owner, target_name, options = {}) 12 | options.assert_valid_keys(*valid_options) 13 | 14 | @owner = owner 15 | @target_name = target_name.to_sym 16 | @allow_destroy = options.fetch(:allow_destroy, false) 17 | @reject_if = options.delete(:reject_if) 18 | @options = options.dup 19 | @index_errors = if ActiveRecord::VERSION::MAJOR < 5 20 | @options[:index_errors] 21 | else 22 | @options[:index_errors] || ActiveRecord::Base.index_nested_attribute_errors 23 | end 24 | end 25 | 26 | def assign_attributes(parent, attributes) 27 | raise NotImplementedError 28 | end 29 | 30 | def save(parent) 31 | keep = assigned_children(parent) 32 | changed_children(parent).each do |child| 33 | if child.marked_for_destruction? 34 | child.destroy if child.persisted? 35 | keep.delete(child) 36 | else 37 | child.save(:validate => false) or raise ActiveRecord::Rollback 38 | end 39 | end 40 | assign_children(parent, keep) 41 | end 42 | 43 | def validate(parent) 44 | changed_children(parent).each_with_index do |child, index| 45 | unless child.valid? 46 | child.errors.each do |attribute, message| 47 | attribute = @index_errors ? "#{@target_name}[#{index}].#{attribute}" : "#{@target_name}.#{attribute}" 48 | parent.errors[attribute] << message 49 | parent.errors[attribute].uniq! 50 | end 51 | end 52 | end 53 | end 54 | 55 | private 56 | 57 | def add_child(parent, child_or_children) 58 | raise NotImplementedError 59 | end 60 | 61 | def assigned_children(parent) 62 | Array.wrap(parent[@target_name]) 63 | end 64 | 65 | def assign_children(parent, children) 66 | raise NotImplementedError 67 | end 68 | 69 | def changed_children(parent) 70 | assigned_children(parent).select(&:changed_for_autosave?) 71 | end 72 | 73 | def build_child(parent, attributes) 74 | build_scope(parent).new(attributes) 75 | end 76 | 77 | def scope(parent) 78 | scope_for(parent, :scope) || derive_class_name.constantize 79 | end 80 | 81 | def build_scope(parent) 82 | scope_for(parent, :build_scope) || scope(parent) 83 | end 84 | 85 | def find_scope(parent) 86 | scope_for(parent, :find_scope) || scope(parent) 87 | end 88 | 89 | def scope_for(parent, key) 90 | parent._nested_attribute_scopes ||= {} 91 | parent._nested_attribute_scopes[[self, key]] ||= begin 92 | scope = @options[key] 93 | scope.respond_to?(:call) ? parent.instance_eval(&scope) : scope 94 | end 95 | end 96 | 97 | def derive_class_name 98 | raise NotImplementedError 99 | end 100 | 101 | def fetch_child(parent, id) 102 | assigned = assigned_children(parent).detect { |r| r.id == id } 103 | return assigned if assigned 104 | 105 | if child = find_scope(parent).find_by_id(id) 106 | add_child(parent, child) 107 | child 108 | else 109 | raise RecordNotFound, "could not find a child record with id '#{id}' for '#{@target_name}'" 110 | end 111 | end 112 | 113 | def truthy?(value) 114 | @boolean_type_caster ||= TypeCaster.get(:boolean, @owner.connection) 115 | @boolean_type_caster.type_cast_from_user(value) 116 | end 117 | 118 | def reject?(parent, attributes) 119 | result = case @reject_if 120 | when :all_blank 121 | attributes.all? { |key, value| key == '_destroy' || value.blank? } 122 | when Proc 123 | @reject_if.call(attributes) 124 | when Symbol 125 | parent.method(@reject_if).arity == 0 ? parent.send(@reject_if) : parent.send(@reject_if, attributes) 126 | end 127 | result 128 | end 129 | 130 | def valid_options 131 | [:build_scope, :find_scope, :scope, :allow_destroy, :reject_if] 132 | end 133 | 134 | end 135 | 136 | end 137 | 138 | end 139 | -------------------------------------------------------------------------------- /spec/active_type/util_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | module UtilSpec 4 | 5 | class BaseRecord < ActiveRecord::Base 6 | self.table_name = 'records' 7 | end 8 | 9 | class ExtendedRecord < ActiveType::Record[BaseRecord] 10 | 11 | attribute :virtual_string 12 | attribute :virtual_string_for_validation 13 | after_initialize :set_virtual_string 14 | attr_reader :after_initialize_called 15 | 16 | def set_virtual_string 17 | @after_initialize_called = true 18 | self.virtual_string = "persisted_string is #{persisted_string}" 19 | end 20 | 21 | end 22 | 23 | class Parent < ActiveRecord::Base 24 | self.table_name = 'sti_records' 25 | end 26 | 27 | class Child < Parent 28 | end 29 | 30 | class ChildSibling < Parent 31 | end 32 | 33 | class ExtendedChild < ActiveType::Record[Child] 34 | end 35 | 36 | end 37 | 38 | describe ActiveType::Util do 39 | 40 | describe '.cast' do 41 | 42 | describe 'for a relation' do 43 | 44 | it 'casts a scope to a scope of another class' do 45 | record = UtilSpec::BaseRecord.create!(:persisted_string => 'foo') 46 | base_scope = UtilSpec::BaseRecord.where(:persisted_string => 'foo') 47 | casted_scope = ActiveType::Util.cast(base_scope, UtilSpec::ExtendedRecord) 48 | expect(casted_scope.build).to be_a(UtilSpec::ExtendedRecord) 49 | found_record = casted_scope.find(record.id) 50 | expect(found_record.persisted_string).to eq('foo') 51 | expect(found_record).to be_a(UtilSpec::ExtendedRecord) 52 | end 53 | 54 | it 'preserves existing scope conditions' do 55 | match = UtilSpec::BaseRecord.create!(:persisted_string => 'foo') 56 | no_match = UtilSpec::BaseRecord.create!(:persisted_string => 'bar') 57 | base_scope = UtilSpec::BaseRecord.where(:persisted_string => 'foo') 58 | casted_scope = ActiveType::Util.cast(base_scope, UtilSpec::ExtendedRecord) 59 | casted_match = UtilSpec::ExtendedRecord.find(match.id) 60 | expect(casted_scope.to_a).to eq([casted_match]) 61 | end 62 | 63 | end 64 | 65 | describe 'for a record type' do 66 | 67 | it 'casts a base record to an extended record' do 68 | base_record = UtilSpec::BaseRecord.create!(:persisted_string => 'foo') 69 | extended_record = ActiveType::Util.cast(base_record, UtilSpec::ExtendedRecord) 70 | expect(extended_record).to be_a(UtilSpec::ExtendedRecord) 71 | expect(extended_record).to be_persisted 72 | expect(extended_record.id).to be_present 73 | expect(extended_record.id).to eq(base_record.id) 74 | expect(extended_record.persisted_string).to eq('foo') 75 | end 76 | 77 | it 'casts an extended record to a base record' do 78 | extended_record = UtilSpec::ExtendedRecord.create!(:persisted_string => 'foo') 79 | base_record = ActiveType::Util.cast(extended_record, UtilSpec::BaseRecord) 80 | expect(base_record).to be_a(UtilSpec::BaseRecord) 81 | expect(base_record).to be_persisted 82 | expect(base_record.id).to be_present 83 | expect(base_record.id).to eq(extended_record.id) 84 | expect(base_record.persisted_string).to eq('foo') 85 | end 86 | 87 | it 'calls after_initialize callbacks of the cast target' do 88 | base_record = UtilSpec::BaseRecord.create!(:persisted_string => 'foo') 89 | extended_record = ActiveType::Util.cast(base_record, UtilSpec::ExtendedRecord) 90 | expect(extended_record.after_initialize_called).to eq true 91 | end 92 | 93 | it 'lets after_initialize callbacks access attributes (bug in ActiveRecord#becomes)' do 94 | base_record = UtilSpec::BaseRecord.create!(:persisted_string => 'foo') 95 | extended_record = ActiveType::Util.cast(base_record, UtilSpec::ExtendedRecord) 96 | expect(extended_record.virtual_string).to eq('persisted_string is foo') 97 | end 98 | 99 | it 'preserves the #type of an STI record that is casted to an ExtendedRecord' do 100 | child_record = UtilSpec::Child.create!(:persisted_string => 'foo') 101 | extended_child_record = ActiveType::Util.cast(child_record, UtilSpec::ExtendedChild) 102 | expect(extended_child_record).to be_a(UtilSpec::ExtendedChild) 103 | expect(extended_child_record.type).to eq('UtilSpec::Child') 104 | end 105 | 106 | it 'changes the #type of an STI record when casted to another type in the hierarchy' do 107 | child_record = UtilSpec::Child.create!(:persisted_string => 'foo') 108 | child_sibling_record = ActiveType::Util.cast(child_record, UtilSpec::ChildSibling) 109 | expect(child_sibling_record).to be_a(UtilSpec::ChildSibling) 110 | expect(child_sibling_record.type).to eq('UtilSpec::Child') 111 | end 112 | 113 | it 'preserves dirty tracking flags' do 114 | base_record = UtilSpec::BaseRecord.create!(:persisted_string => 'foo') 115 | expect(base_record.changes).to eq({}) 116 | base_record.persisted_string = 'bar' 117 | expect(base_record.changes).to eq({ 'persisted_string' => ['foo', 'bar'] }) 118 | extended_record = ActiveType::Util.cast(base_record, UtilSpec::ExtendedRecord) 119 | expect(extended_record).to be_a(UtilSpec::ExtendedRecord) 120 | expect(extended_record.changes).to eq({ 'persisted_string' => ['foo', 'bar'] }) 121 | end 122 | 123 | it 'associates the error object correctly with the new type (BUGFIX)' do 124 | base_record = UtilSpec::BaseRecord.create!(:persisted_string => 'foo') 125 | extended_record = ActiveType::Util.cast(base_record, UtilSpec::ExtendedRecord) 126 | expect { 127 | value = extended_record.virtual_string_for_validation 128 | extended_record.errors.add(:virtual_string_for_validation, :empty) if value.nil? || value.empty? 129 | }.not_to raise_error 130 | expect(extended_record.errors.size).to eq 1 131 | expect(base_record.errors.size).to eq 0 132 | end 133 | 134 | end 135 | 136 | end 137 | 138 | it "exposes all methods through ActiveType's root namespace" do 139 | expect(ActiveType).to respond_to(:cast) 140 | end 141 | 142 | end 143 | -------------------------------------------------------------------------------- /spec/shared_examples/coercible_columns.rb: -------------------------------------------------------------------------------- 1 | module TimeConversionSpec 2 | class Record < ActiveRecord::Base 3 | end 4 | end 5 | 6 | shared_examples_for 'a coercible string column' do |column| 7 | 8 | it 'is nil by default' do 9 | expect(subject.send(column)).to be_nil 10 | end 11 | 12 | it 'leaves strings alone' do 13 | subject.send(:"#{column}=", "string") 14 | 15 | expect(subject.send(column)).to eq("string") 16 | end 17 | 18 | it 'does not convert blank' do 19 | subject.send(:"#{column}=", "") 20 | 21 | expect(subject.send(column)).to eq("") 22 | end 23 | 24 | end 25 | 26 | 27 | shared_examples_for 'a coercible integer column' do |column| 28 | 29 | it 'is nil by default' do 30 | expect(subject.send(column)).to be_nil 31 | end 32 | 33 | it 'leaves integers alone' do 34 | subject.send(:"#{column}=", 10) 35 | 36 | expect(subject.send(column)).to eq(10) 37 | end 38 | 39 | it 'converts strings to integers' do 40 | subject.send(:"#{column}=", "10") 41 | 42 | expect(subject.send(column)).to eq(10) 43 | end 44 | 45 | it 'converts blank to nil' do 46 | subject.send(:"#{column}=", "") 47 | 48 | expect(subject.send(column)).to be_nil 49 | end 50 | 51 | end 52 | 53 | 54 | shared_examples_for 'a coercible date column' do |column| 55 | 56 | it 'is nil by default' do 57 | expect(subject.send(column)).to be_nil 58 | end 59 | 60 | it 'leaves dates alone' do 61 | date = Date.today 62 | subject.send(:"#{column}=", date) 63 | 64 | expect(subject.send(column)).to eq(date) 65 | end 66 | 67 | it 'converts strings to dates' do 68 | subject.send(:"#{column}=", "2010-10-01") 69 | 70 | expect(subject.send(column)).to eq(Date.new(2010, 10, 1)) 71 | end 72 | 73 | it 'converts blank to nil' do 74 | subject.send(:"#{column}=", "") 75 | 76 | expect(subject.send(column)).to be_nil 77 | end 78 | 79 | end 80 | 81 | 82 | shared_examples_for 'a coercible time column' do |column| 83 | 84 | around do |example| 85 | begin 86 | old_time_zone = Time.zone 87 | old_time_zone_aware_attributes = ActiveRecord::Base.time_zone_aware_attributes 88 | old_default_timezone = ActiveRecord::Base.default_timezone 89 | example.run 90 | ensure 91 | Time.zone = old_time_zone 92 | ActiveRecord::Base.time_zone_aware_attributes = old_time_zone_aware_attributes 93 | ActiveRecord::Base.default_timezone = old_default_timezone 94 | subject.class.reset_column_information 95 | end 96 | end 97 | 98 | def it_should_convert_like_active_record(column) 99 | time = "2010-10-01 12:15" 100 | TimeConversionSpec::Record.reset_column_information 101 | subject.class.reset_column_information 102 | 103 | comparison = TimeConversionSpec::Record.new 104 | subject.send(:"#{column}=", time) 105 | comparison.persisted_time = time 106 | 107 | result = subject.send(column) 108 | expect(result).to eq(comparison.persisted_time) 109 | expect(result.zone).to eq(comparison.persisted_time.zone) 110 | end 111 | 112 | 113 | it 'is nil by default' do 114 | expect(subject.send(column)).to be_nil 115 | end 116 | 117 | it 'leaves times alone' do 118 | time = Time.at(Time.now.to_i) 119 | subject.send(:"#{column}=", time) 120 | 121 | expect(subject.send(column)).to eq(time) 122 | end 123 | 124 | it 'converts strings to times' do 125 | subject.send(:"#{column}=", "2010-10-01 12:15") 126 | 127 | expect(subject.send(column)).to eq(Time.local(2010, 10, 1, 12, 15)) 128 | end 129 | 130 | it 'behaves consistently with ActiveRecord' do 131 | Time.zone = 'Hawaii' 132 | 133 | it_should_convert_like_active_record(column) 134 | end 135 | 136 | it 'behaves consistently with ActiveRecord if time_zone_aware_attributes is set' do 137 | Time.zone = 'Hawaii' 138 | ActiveRecord::Base.time_zone_aware_attributes = true 139 | 140 | it_should_convert_like_active_record(column) 141 | end 142 | 143 | it 'behaves consistently with ActiveRecord if default_timezone is :utc' do 144 | Time.zone = 'Hawaii' 145 | ActiveRecord::Base.default_timezone = :utc 146 | 147 | it_should_convert_like_active_record(column) 148 | end 149 | 150 | it 'behaves consistently with ActiveRecord if time_zone_aware_attributes is set, default_timezone is :utc' do 151 | Time.zone = 'Hawaii' 152 | ActiveRecord::Base.default_timezone = :utc 153 | ActiveRecord::Base.time_zone_aware_attributes = true 154 | 155 | it_should_convert_like_active_record(column) 156 | end 157 | 158 | end 159 | 160 | 161 | shared_examples_for 'a coercible boolean column' do |column| 162 | 163 | it 'is nil by default' do 164 | expect(subject.send(column)).to be_nil 165 | end 166 | 167 | it 'leaves booleans alone' do 168 | subject.send(:"#{column}=", true) 169 | 170 | expect(subject.send(column)).to eq(true) 171 | end 172 | 173 | it 'converts 1 to true' do 174 | subject.send(:"#{column}=", "1") 175 | 176 | expect(subject.send(column)).to eq(true) 177 | end 178 | 179 | it 'converts 0 to false' do 180 | subject.send(:"#{column}=", "0") 181 | 182 | expect(subject.send(column)).to eq(false) 183 | end 184 | 185 | it 'converts "" to nil' do 186 | subject.send(:"#{column}=", "") 187 | 188 | expect(subject.send(column)).to be_nil 189 | end 190 | 191 | it 'converts "true" to true' do 192 | subject.send(:"#{column}=", "true") 193 | 194 | expect(subject.send(column)).to eq(true) 195 | end 196 | 197 | it 'converts "false" to false' do 198 | subject.send(:"#{column}=", "false") 199 | 200 | expect(subject.send(column)).to eq(false) 201 | end 202 | 203 | end 204 | 205 | shared_examples_for 'an untyped column' do |column| 206 | it 'is nil by default' do 207 | expect(subject.send(column)).to be_nil 208 | end 209 | 210 | it 'leaves strings alone' do 211 | subject.send(:"#{column}=", "string") 212 | 213 | expect(subject.send(column)).to eq("string") 214 | end 215 | 216 | it 'leaves integers alone' do 217 | subject.send(:"#{column}=", 17) 218 | 219 | expect(subject.send(column)).to eq(17) 220 | end 221 | 222 | it 'leaves objects alone' do 223 | object = Object.new 224 | subject.send(:"#{column}=", object) 225 | 226 | expect(subject.send(column)).to eq(object) 227 | end 228 | end 229 | 230 | 231 | shared_examples_for 'a coercible type column' do |column, type| 232 | 233 | if type 234 | 235 | it 'is nil by default' do 236 | expect(subject.send(column)).to be_nil 237 | end 238 | 239 | it 'leaves strings alone' do 240 | expect(type).to receive(:cast).with('input').and_return('output') 241 | subject.send(:"#{column}=", 'input') 242 | 243 | expect(subject.send(column)).to eq('output') 244 | end 245 | 246 | end 247 | 248 | end 249 | -------------------------------------------------------------------------------- /lib/active_type/virtual_attributes.rb: -------------------------------------------------------------------------------- 1 | require 'active_type/type_caster' 2 | 3 | module ActiveType 4 | 5 | class InvalidAttributeNameError < ::StandardError; end 6 | class MissingAttributeError < ::StandardError; end 7 | class ArgumentError < ::ArgumentError; end 8 | 9 | module VirtualAttributes 10 | 11 | class VirtualColumn 12 | 13 | def initialize(name, type_caster, options) 14 | @name = name 15 | @type_caster = type_caster 16 | @options = options 17 | end 18 | 19 | def type_cast(value) 20 | @type_caster.type_cast_from_user(value) 21 | end 22 | 23 | def default_value(object) 24 | default = @options[:default] 25 | default.respond_to?(:call) ? object.instance_eval(&default) : default 26 | end 27 | 28 | end 29 | 30 | class Builder 31 | 32 | def initialize(owner, mod) 33 | @owner = owner 34 | @module = mod 35 | end 36 | 37 | def build(name, type, options) 38 | validate_attribute_name!(name) 39 | options.assert_valid_keys(:default) 40 | add_virtual_column(name, type, options) 41 | build_reader(name) 42 | build_writer(name) 43 | build_dirty_tracking_methods(name) 44 | end 45 | 46 | private 47 | 48 | def add_virtual_column(name, type, options) 49 | type_caster = TypeCaster.get(type, @owner.connection) 50 | column = VirtualColumn.new(name, type_caster, options.slice(:default)) 51 | @owner.virtual_columns_hash = @owner.virtual_columns_hash.merge(name.to_s => column) 52 | end 53 | 54 | def build_reader(name) 55 | @module.module_eval <<-BODY, __FILE__, __LINE__ + 1 56 | def #{name} 57 | read_virtual_attribute('#{name}') 58 | end 59 | 60 | def #{name}? 61 | read_virtual_attribute('#{name}').present? 62 | end 63 | BODY 64 | end 65 | 66 | def build_writer(name) 67 | @module.module_eval <<-BODY, __FILE__, __LINE__ + 1 68 | def #{name}=(value) 69 | write_virtual_attribute('#{name}', value) 70 | end 71 | BODY 72 | end 73 | 74 | # Methods for compatibility with gems expecting the ActiveModel::Dirty API. 75 | def build_dirty_tracking_methods(name) 76 | @module.module_eval <<-BODY, __FILE__, __LINE__ + 1 77 | def #{name}_was 78 | nil 79 | end 80 | BODY 81 | 82 | @module.module_eval <<-BODY, __FILE__, __LINE__ + 1 83 | def #{name}_changed? 84 | not #{name}.nil? 85 | end 86 | BODY 87 | 88 | @module.module_eval <<-BODY, __FILE__, __LINE__ + 1 89 | def #{name}_will_change! 90 | # no-op 91 | end 92 | BODY 93 | end 94 | 95 | def validate_attribute_name!(name) 96 | unless name.to_s =~ /\A[A-z0-9_]*\z/ 97 | raise InvalidAttributeNameError.new("'#{name}' is not a valid name for a virtual attribute") 98 | end 99 | end 100 | 101 | end 102 | 103 | def self.deep_dup(hash) 104 | result = hash.dup 105 | result.each do |key, value| 106 | result[key] = value.dup if value.duplicable? 107 | end 108 | result 109 | end 110 | 111 | 112 | extend ActiveSupport::Concern 113 | 114 | included do 115 | class_attribute :virtual_columns_hash 116 | self.virtual_columns_hash = {} 117 | end 118 | 119 | def initialize_dup(other) 120 | @virtual_attributes_cache = {} 121 | @virtual_attributes = VirtualAttributes.deep_dup(virtual_attributes) 122 | 123 | super 124 | end 125 | 126 | def virtual_attributes 127 | @virtual_attributes ||= {} 128 | end 129 | 130 | def virtual_attributes_cache 131 | @virtual_attributes_cache ||= {} 132 | end 133 | 134 | def [](name) 135 | if self.singleton_class._has_virtual_column?(name) 136 | read_virtual_attribute(name) 137 | else 138 | super 139 | end 140 | end 141 | 142 | # ActiveRecord 4.2.1 143 | def _read_attribute(name) 144 | if self.singleton_class._has_virtual_column?(name) 145 | read_virtual_attribute(name) 146 | else 147 | super 148 | end 149 | end 150 | 151 | def []=(name, value) 152 | if self.singleton_class._has_virtual_column?(name) 153 | write_virtual_attribute(name, value) 154 | else 155 | super 156 | end 157 | end 158 | 159 | def attributes 160 | self.class._virtual_column_names.each_with_object(super) do |name, attrs| 161 | attrs[name] = read_virtual_attribute(name) 162 | end 163 | end 164 | 165 | def read_virtual_attribute(name) 166 | name = name.to_s 167 | if virtual_attributes_cache.has_key?(name) 168 | virtual_attributes_cache[name] 169 | else 170 | virtual_attributes_cache[name] = begin 171 | virtual_column = self.singleton_class._virtual_column(name) 172 | raw_value = virtual_attributes.fetch(name) { virtual_column.default_value(self) } 173 | virtual_column.type_cast(raw_value) 174 | end 175 | end 176 | end 177 | 178 | def write_virtual_attribute(name, value) 179 | name = name.to_s 180 | virtual_attributes_cache.delete(name) 181 | virtual_attributes[name] = value 182 | end 183 | 184 | # Returns the contents of the record as a nicely formatted string. 185 | def inspect 186 | inspection = attributes.collect do |name, value| 187 | "#{name}: #{VirtualAttributes.attribute_for_inspect(value)}" 188 | end.sort.compact.join(", ") 189 | "#<#{self.class} #{inspection}>" 190 | end 191 | 192 | def self.attribute_for_inspect(value) 193 | if value.is_a?(String) && value.length > 50 194 | "#{value[0, 50]}...".inspect 195 | elsif value.is_a?(Date) || value.is_a?(Time) 196 | %("#{value.to_s(:db)}") 197 | elsif value.is_a?(Array) && value.size > 10 198 | inspected = value.first(10).inspect 199 | %(#{inspected[0...-1]}, ...]) 200 | else 201 | value.inspect 202 | end 203 | end 204 | 205 | 206 | module ClassMethods 207 | 208 | def _virtual_column(name) 209 | virtual_columns_hash[name.to_s] || begin 210 | if defined?(super) 211 | super 212 | else 213 | raise MissingAttributeError.new("Undefined attribute '#{name}'") 214 | end 215 | end 216 | end 217 | 218 | def _virtual_column_names 219 | @virtual_column_names ||= begin 220 | names = virtual_columns_hash.keys 221 | if defined?(super) 222 | names += super 223 | end 224 | names 225 | end 226 | end 227 | 228 | def _has_virtual_column?(name) 229 | virtual_columns_hash.has_key?(name.to_s) || begin 230 | if defined?(super) 231 | super 232 | else 233 | false 234 | end 235 | end 236 | end 237 | 238 | def generated_virtual_attribute_methods 239 | @generated_virtual_attribute_methods ||= begin 240 | mod = Module.new 241 | include mod 242 | mod 243 | end 244 | end 245 | 246 | def attribute(name, *args) 247 | options = args.extract_options! 248 | type = args.first 249 | 250 | Builder.new(self, generated_virtual_attribute_methods).build(name, type, options) 251 | end 252 | 253 | end 254 | 255 | end 256 | 257 | end 258 | -------------------------------------------------------------------------------- /spec/active_type/extended_record_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | module ExtendedRecordSpec 4 | 5 | class BaseRecord < ActiveRecord::Base 6 | self.table_name = 'records' 7 | end 8 | 9 | class BaseActiveTypeRecord < ActiveType::Record 10 | self.table_name = 'records' 11 | 12 | attribute :virtual_string, :string 13 | end 14 | 15 | class ExtendedRecord < ActiveType::Record[BaseRecord] 16 | attribute :another_virtual_string, :string 17 | end 18 | 19 | class ExtendedActiveTypeRecord < ActiveType::Record[BaseActiveTypeRecord] 20 | attribute :another_virtual_string, :string 21 | end 22 | 23 | class InheritingFromExtendedRecord < ExtendedRecord 24 | attribute :yet_another_virtual_string, :string 25 | end 26 | 27 | class ExtendedRecordWithValidations < ExtendedActiveTypeRecord 28 | validates :persisted_string, :presence => true 29 | validates :virtual_string, :presence => true 30 | validates :another_virtual_string, :presence => true 31 | end 32 | 33 | end 34 | 35 | 36 | describe "ActiveType::Record[ActiveRecord::Base]" do 37 | 38 | subject { ExtendedRecordSpec::ExtendedRecord.new } 39 | 40 | it 'is inherits from the base type' do 41 | expect(subject).to be_a(ExtendedRecordSpec::BaseRecord) 42 | end 43 | 44 | it 'has the same model name as the base class' do 45 | expect(subject.class.model_name.singular).to eq(ExtendedRecordSpec::BaseRecord.model_name.singular) 46 | end 47 | 48 | describe 'constructors' do 49 | subject { ExtendedRecordSpec::ExtendedRecord } 50 | 51 | it_should_behave_like 'ActiveRecord-like constructors', { :persisted_string => "persisted string", :another_virtual_string => "another virtual string" } 52 | end 53 | 54 | describe '#attributes' do 55 | 56 | it 'returns a hash of virtual and persisted attributes' do 57 | subject.persisted_string = "string" 58 | subject.another_virtual_string = "string" 59 | 60 | expect(subject.attributes).to eq({ 61 | "another_virtual_string" => "string", 62 | "id" => nil, 63 | "persisted_string" => "string", 64 | "persisted_integer" => nil, 65 | "persisted_time" => nil, 66 | "persisted_date" => nil, 67 | "persisted_boolean" => nil 68 | }) 69 | end 70 | 71 | end 72 | 73 | describe 'accessors' do 74 | it_should_behave_like 'ActiveRecord-like accessors', { :persisted_string => "persisted string", :another_virtual_string => "another virtual string" } 75 | end 76 | 77 | describe 'persistence' do 78 | it 'persists to the database' do 79 | subject.persisted_string = "persisted string" 80 | expect(subject.save).to eq(true) 81 | 82 | expect(subject.class.find(subject.id).persisted_string).to eq("persisted string") 83 | end 84 | end 85 | 86 | describe '.find' do 87 | it 'returns an instance of the extended model' do 88 | subject.save 89 | 90 | expect(subject.class.find(subject.id)).to be_a(subject.class) 91 | end 92 | end 93 | 94 | describe '.base_class' do 95 | it 'is the base class inherited from' do 96 | expect(subject.class.base_class).to eq(ExtendedRecordSpec::BaseRecord) 97 | end 98 | end 99 | 100 | end 101 | 102 | describe "class ... < ActiveType::Record[ActiveRecord::Base]" do 103 | 104 | subject { ExtendedRecordSpec::InheritingFromExtendedRecord.new } 105 | 106 | it 'is inherits from the base type' do 107 | expect(subject).to be_a(ExtendedRecordSpec::ExtendedRecord) 108 | end 109 | 110 | it 'has the same model name as the base class' do 111 | expect(subject.class.model_name.singular).to eq(ExtendedRecordSpec::BaseRecord.model_name.singular) 112 | end 113 | 114 | describe '#attributes' do 115 | 116 | it 'returns a hash of virtual and persisted attributes' do 117 | subject.persisted_string = "string" 118 | subject.another_virtual_string = "string" 119 | subject.yet_another_virtual_string = "string" 120 | 121 | expect(subject.attributes).to eq({ 122 | "another_virtual_string" => "string", 123 | "yet_another_virtual_string" => "string", 124 | "id" => nil, 125 | "persisted_string" => "string", 126 | "persisted_integer" => nil, 127 | "persisted_time" => nil, 128 | "persisted_date" => nil, 129 | "persisted_boolean" => nil 130 | }) 131 | end 132 | 133 | end 134 | 135 | describe 'persistence' do 136 | it 'persists to the database' do 137 | subject.persisted_string = "persisted string" 138 | expect(subject.save).to eq(true) 139 | 140 | expect(subject.class.find(subject.id).persisted_string).to eq("persisted string") 141 | end 142 | end 143 | 144 | describe '.find' do 145 | it 'returns an instance of the inheriting model' do 146 | subject.save 147 | 148 | expect(subject.class.find(subject.id)).to be_a(subject.class) 149 | end 150 | end 151 | 152 | end 153 | 154 | describe "ActiveType::Record[ActiveType::Record]" do 155 | 156 | subject { ExtendedRecordSpec::ExtendedActiveTypeRecord.new } 157 | 158 | it 'is inherits from the base type' do 159 | expect(subject).to be_a(ExtendedRecordSpec::BaseActiveTypeRecord) 160 | end 161 | 162 | it 'has the same model name as the base class' do 163 | expect(subject.class.model_name.singular).to eq(ExtendedRecordSpec::BaseActiveTypeRecord.model_name.singular) 164 | end 165 | 166 | describe 'constructors' do 167 | subject { ExtendedRecordSpec::ExtendedActiveTypeRecord } 168 | 169 | it_should_behave_like 'ActiveRecord-like constructors', { :persisted_string => "persisted string", :virtual_string => "virtual string", :another_virtual_string => "another virtual string" } 170 | end 171 | 172 | describe '#attributes' do 173 | 174 | it 'returns a hash of virtual and persisted attributes' do 175 | subject.persisted_string = "string" 176 | subject.virtual_string = "string" 177 | 178 | expect(subject.attributes).to eq({ 179 | "virtual_string" => "string", 180 | "another_virtual_string" => nil, 181 | "id" => nil, 182 | "persisted_string" => "string", 183 | "persisted_integer" => nil, 184 | "persisted_time" => nil, 185 | "persisted_date" => nil, 186 | "persisted_boolean" => nil 187 | }) 188 | end 189 | 190 | end 191 | 192 | describe 'accessors' do 193 | it_should_behave_like 'ActiveRecord-like accessors', { :persisted_string => "persisted string", :virtual_string => "virtual string", :another_virtual_string => "another virtual string" } 194 | end 195 | 196 | describe 'validations' do 197 | subject { ExtendedRecordSpec::ExtendedRecordWithValidations.new } 198 | 199 | it 'has 1 error_on' do 200 | expect(subject.error_on(:persisted_string).size).to eq(1) 201 | end 202 | it 'has 1 error_on' do 203 | expect(subject.error_on(:virtual_string).size).to eq(1) 204 | end 205 | it 'has 1 error_on' do 206 | expect(subject.error_on(:another_virtual_string).size).to eq(1) 207 | end 208 | end 209 | 210 | describe 'persistence' do 211 | it 'persists to the database' do 212 | subject.persisted_string = "persisted string" 213 | expect(subject.save).to eq(true) 214 | 215 | expect(subject.class.find(subject.id).persisted_string).to eq("persisted string") 216 | end 217 | end 218 | 219 | describe '.find' do 220 | it 'returns an instance of the extended model' do 221 | subject.save 222 | 223 | expect(subject.class.find(subject.id)).to be_a(subject.class) 224 | end 225 | end 226 | 227 | describe '.base_class' do 228 | it 'is the base class inherited from' do 229 | expect(subject.class.base_class).to eq(ExtendedRecordSpec::BaseActiveTypeRecord) 230 | end 231 | end 232 | 233 | end 234 | -------------------------------------------------------------------------------- /spec/active_type/record_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'ostruct' 3 | 4 | module RecordSpec 5 | 6 | def self.type 7 | if ActiveRecord::VERSION::MAJOR >= 5 8 | @type ||= ActiveModel::Type::Value.new 9 | end 10 | end 11 | 12 | class Record < ActiveType::Record 13 | 14 | attribute :virtual_string, :string 15 | attribute :virtual_integer, :integer 16 | attribute :virtual_time, :datetime 17 | attribute :virtual_date, :date 18 | attribute :virtual_boolean, :boolean 19 | attribute :virtual_attribute 20 | attribute :virtual_type_attribute, RecordSpec.type 21 | 22 | end 23 | 24 | class RecordWithValidations < Record 25 | 26 | validates :persisted_string, :presence => true 27 | validates :virtual_string, :presence => true 28 | 29 | end 30 | 31 | 32 | class RecordWithOverrides < Record 33 | 34 | attribute :overridable_test, :string 35 | 36 | def overridable_test 37 | super + super 38 | end 39 | 40 | end 41 | 42 | class RecordCopy < ActiveType::Record 43 | self.table_name = 'records' 44 | 45 | attribute :virtual_string, :string 46 | 47 | end 48 | 49 | class OtherRecord < ActiveType::Record 50 | end 51 | 52 | class Child < ActiveRecord::Base 53 | end 54 | 55 | class RecordWithBelongsTo < Record 56 | 57 | attribute :child_id, :integer 58 | 59 | belongs_to :child 60 | 61 | end 62 | end 63 | 64 | 65 | describe ActiveType::Record do 66 | 67 | subject { RecordSpec::Record.new } 68 | t = Time.new(2016, 2, 1, 12) 69 | 70 | it 'is a ActiveRecord::Base' do 71 | expect(subject).to be_a(ActiveRecord::Base) 72 | end 73 | 74 | it 'is an abstract class' do 75 | expect(ActiveType::Record).to be_abstract_class 76 | end 77 | 78 | describe 'constructors' do 79 | subject { RecordSpec::Record } 80 | 81 | it_should_behave_like 'ActiveRecord-like constructors', { :persisted_string => "string", :persisted_integer => 100, :persisted_time => t, :persisted_date => Date.today, :persisted_boolean => true } 82 | 83 | it_should_behave_like 'ActiveRecord-like constructors', { :virtual_string => "string", :virtual_integer => 100, :virtual_time => t, :virtual_date => Date.today, :virtual_boolean => true } 84 | 85 | end 86 | 87 | describe 'mass assignment' do 88 | it_should_behave_like 'ActiveRecord-like mass assignment', { :persisted_string => "string", :persisted_integer => 100, :persisted_time => t, :persisted_date => Date.today, :persisted_boolean => true } 89 | 90 | it_should_behave_like 'ActiveRecord-like mass assignment', { :virtual_string => "string", :virtual_integer => 100, :virtual_time => t, :virtual_date => Date.today, :virtual_boolean => true } 91 | end 92 | 93 | describe 'accessors' do 94 | it_should_behave_like 'ActiveRecord-like accessors', { :persisted_string => "string", :persisted_integer => 100, :persisted_time => t, :persisted_date => Date.today, :persisted_boolean => true } 95 | 96 | it_should_behave_like 'ActiveRecord-like accessors', { :virtual_string => "string", :virtual_integer => 100, :virtual_time => t, :virtual_date => Date.today, :virtual_boolean => true } 97 | end 98 | 99 | describe 'overridable attributes' do 100 | 101 | subject { RecordSpec::RecordWithOverrides.new } 102 | 103 | it 'is possible to override attributes with super' do 104 | subject.overridable_test = "test" 105 | 106 | expect(subject.overridable_test).to eq("testtest") 107 | end 108 | end 109 | 110 | describe 'attribute name validation' do 111 | it 'crashes when trying to define an invalid attribute name' do 112 | klass = Class.new(ActiveType::Record) 113 | expect { 114 | klass.class_eval do 115 | attribute :"", :string 116 | end 117 | }.to raise_error(ActiveType::InvalidAttributeNameError) 118 | end 119 | end 120 | 121 | describe '.reset_column_information' do 122 | it 'does not affect virtual attributes' do 123 | RecordSpec::RecordCopy.new.persisted_string = "string" 124 | RecordSpec::RecordCopy.reset_column_information 125 | 126 | expect do 127 | RecordSpec::RecordCopy.new.virtual_string = "string" 128 | end.to_not raise_error 129 | end 130 | end 131 | 132 | context 'coercible' do 133 | describe 'string columns' do 134 | it_should_behave_like 'a coercible string column', :persisted_string 135 | it_should_behave_like 'a coercible string column', :virtual_string 136 | end 137 | 138 | describe 'integer columns' do 139 | it_should_behave_like 'a coercible integer column', :persisted_integer 140 | it_should_behave_like 'a coercible integer column', :virtual_integer 141 | end 142 | 143 | describe 'date columns' do 144 | it_should_behave_like 'a coercible date column', :persisted_date 145 | it_should_behave_like 'a coercible date column', :virtual_date 146 | end 147 | 148 | describe 'time columns' do 149 | it_should_behave_like 'a coercible time column', :persisted_time 150 | it_should_behave_like 'a coercible time column', :virtual_time 151 | end 152 | 153 | describe 'boolean columns' do 154 | it_should_behave_like 'a coercible boolean column', :persisted_boolean 155 | it_should_behave_like 'a coercible boolean column', :virtual_boolean 156 | end 157 | 158 | describe 'untyped columns' do 159 | it_should_behave_like 'an untyped column', :virtual_attribute 160 | end 161 | 162 | describe 'type columns' do 163 | it_should_behave_like 'a coercible type column', :virtual_type_attribute, RecordSpec.type 164 | end 165 | end 166 | 167 | describe '#inspect' do 168 | 169 | it 'returns the contents of the object as a nicely formatted string' do 170 | t = Time.now 171 | subject.persisted_string = "persisted string" 172 | subject.virtual_string = "string" 173 | subject.persisted_integer = 20 174 | subject.virtual_integer = 17 175 | subject.virtual_time = t 176 | subject.virtual_date = Date.today 177 | subject.virtual_boolean = true 178 | subject.virtual_attribute = OpenStruct.new({:test => "openstruct"}) 179 | 180 | expect(subject.inspect).to eq("#, virtual_boolean: true, virtual_date: \"#{Date.today}\", virtual_integer: 17, virtual_string: \"string\", virtual_time: \"#{t.to_s(:db)}\", virtual_type_attribute: nil>") 181 | end 182 | 183 | end 184 | 185 | describe '#attributes' do 186 | 187 | it 'returns a hash of virtual and persisted attributes' do 188 | subject.persisted_string = "string" 189 | subject.virtual_string = "string" 190 | subject.virtual_integer = "17" 191 | 192 | expect(subject.attributes).to eq({ 193 | "virtual_string" => "string", 194 | "virtual_integer" => 17, 195 | "virtual_time" => nil, 196 | "virtual_date" => nil, 197 | "virtual_boolean" => nil, 198 | "virtual_attribute" => nil, 199 | "virtual_type_attribute" => nil, 200 | "id" => nil, 201 | "persisted_string" => "string", 202 | "persisted_integer" => nil, 203 | "persisted_time" => nil, 204 | "persisted_date" => nil, 205 | "persisted_boolean" => nil, 206 | }) 207 | end 208 | 209 | end 210 | 211 | describe 'validations' do 212 | subject { RecordSpec::RecordWithValidations.new } 213 | 214 | it 'has 1 error_on' do 215 | expect(subject.error_on(:persisted_string).size).to eq(1) 216 | end 217 | it 'has 1 error_on' do 218 | expect(subject.error_on(:virtual_string).size).to eq(1) 219 | end 220 | end 221 | 222 | describe 'undefined columns' do 223 | it 'raises an error when trying to access an undefined virtual attribute' do 224 | expect do 225 | subject.read_virtual_attribute('foo') 226 | end.to raise_error(ActiveType::MissingAttributeError) 227 | end 228 | end 229 | 230 | describe 'defaults' do 231 | it_should_behave_like "a class accepting attribute defaults", RecordSpec::Record 232 | end 233 | 234 | describe 'duping' do 235 | it_should_behave_like "a class supporting dup for attributes", RecordSpec::Record 236 | end 237 | 238 | describe 'dirty tracking' do 239 | it_should_behave_like 'a class supporting dirty tracking for virtual attributes', RecordSpec::Record 240 | end 241 | 242 | describe 'persistence' do 243 | 244 | it 'persists to the database' do 245 | subject.persisted_string = "persisted string" 246 | expect(subject.save).to eq(true) 247 | 248 | expect(subject.class.find(subject.id).persisted_string).to eq("persisted string") 249 | end 250 | end 251 | 252 | describe 'isolation' do 253 | it 'does not let column information bleed into different models' do 254 | record = RecordSpec::Record.new 255 | other_record = RecordSpec::OtherRecord.new 256 | 257 | expect(record).not_to respond_to(:other_string) 258 | expect(other_record).not_to respond_to(:persisted_string) 259 | end 260 | end 261 | 262 | describe '#belongs_to' do 263 | subject { RecordSpec::RecordWithBelongsTo.new } 264 | 265 | it_should_behave_like 'a belongs_to association', :child, RecordSpec::Child 266 | end 267 | 268 | it 'can access virtual attributes after .find' do 269 | subject.save! 270 | expect(subject.class.find(subject.id).virtual_string).to eq(nil) 271 | expect(subject.class.find(subject.id).virtual_string).to eq(nil) 272 | end 273 | 274 | end 275 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ActiveType [![Build Status](https://travis-ci.org/makandra/active_type.svg?branch=master)](https://travis-ci.org/makandra/active_type) 2 | ========== 3 | 4 | Make any Ruby object quack like ActiveRecord 5 | -------------------------------------------- 6 | 7 | ActiveType is our take on "presenter models" (or "form models") in Rails. We want to have controllers (and forms) talk to models that are either not backed by a database table, or have additional functionality that should not be shared to the rest of the application. 8 | 9 | However, we do not want to lose ActiveRecord's amenities, like validations, callbacks, etc. 10 | 11 | Examples for use cases are models to support sign in: 12 | 13 | ```ruby 14 | class SignIn < ActiveType::Object 15 | 16 | # this is not backed by a db table 17 | 18 | attribute :username, :string 19 | attribute :password, :string 20 | 21 | validates :username, presence: true 22 | validates :password, presence: true 23 | 24 | # ... 25 | 26 | end 27 | ``` 28 | 29 | Or models to support sign up: 30 | 31 | ```ruby 32 | class User < ActiveRecord::Base 33 | # ... 34 | end 35 | 36 | class SignUp < ActiveType::Record[User] 37 | 38 | # this inherits from User 39 | 40 | validates :password, confirmation: true 41 | 42 | after_create :send_confirmation_email 43 | 44 | def send_confirmation_email 45 | # this should happen on sign-up, but not when creating a user in tests etc. 46 | end 47 | 48 | # ... 49 | 50 | end 51 | ``` 52 | 53 | 54 | ### A note on Rails 5 55 | 56 | Rails 5 comes with its own implementation of `.attribute`. This implementation is functionally very 57 | similar, but not identical to ActiveType's. 58 | 59 | We have decided to continue to use our own implementation. This means that if you use ActiveType, `ActiveRecord::Base.attribute` will be overriden. 60 | 61 | The following behaviours are different than in vanilla Rails 5: 62 | 63 | - Defaults `proc`s are evaluated in instance context, not class context. 64 | - Defaults are evaluated lazily. 65 | - You can override attributes with custom methods and use `super`. 66 | - Attributes will work on records retrieved via `.find`. 67 | - Attributes will be duped if you dup the record. 68 | 69 | 70 | ### ActiveType::Object 71 | 72 | 73 | Inherit from `ActiveType::Object` if you want an `ActiveRecord`-kind class that is not backed by a database table. 74 | 75 | You can define "columns" by saying `attribute`: 76 | 77 | ```ruby 78 | class SignIn < ActiveType::Object 79 | 80 | attribute :email, :string 81 | attribute :date_of_birth, :date 82 | attribute :accepted_terms, :boolean 83 | attribute :account_type 84 | 85 | end 86 | ``` 87 | 88 | These attributes can be assigned via constructor, mass-assignment, and are automatically typecast: 89 | 90 | ```ruby 91 | sign_in = SignIn.new(date_of_birth: "1980-01-01", accepted_terms: "1", account_type: AccountType::Trial.new) 92 | sign_in.date_of_birth.class # Date 93 | sign_in.accepted_terms? # true 94 | ``` 95 | 96 | ActiveType knows all the types that are allowed in migrations (i.e. `:string`, `:integer`, `:float`, `:decimal`, `:datetime`, `:time`, `:date`, `:boolean`). You can also skip the type to have a virtual attribute without typecasting. 97 | 98 | **`ActiveType::Object` actually inherits from `ActiveRecord::Base`, but simply skips all database access, inspired by [ActiveRecord Tableless](https://github.com/softace/activerecord-tableless).** 99 | 100 | This means your object has all usual `ActiveRecord::Base` methods. Some of those might not work properly, however. What does work: 101 | 102 | - validations 103 | - callbacks (use `before_save`, `after_save`, not `before_create`, or `before_update`) 104 | - "saving" (returning `true` or `false`, without actually persisting) 105 | - belongs_to (after saying `attribute :child_id, :integer`) 106 | 107 | 108 | ### ActiveType::Record 109 | 110 | If you have a database backed record (that inherits from `ActiveRecord::Base`), but also want to declare virtual attributes, simply inherit from `ActiveType::Record`. 111 | 112 | Virtual attributes will not be persisted. 113 | 114 | 115 | ### ActiveType::Record[BaseClass] 116 | 117 | `ActiveType::Record[BaseClass]` is used to extend a given `BaseClass` (that itself has to be an `ActiveRecord` model) with additional functionality, that is not meant to be shared to the rest of the application. 118 | 119 | You class will inherit from `BaseClass`. You can add additional methods, validations, callbacks, as well as use (virtual) attributes like an `ActiveType::Object`: 120 | 121 | ```ruby 122 | class SignUp < ActiveType::Record[User] 123 | # ... 124 | end 125 | ``` 126 | 127 | ### Inheriting from ActiveType:: objects 128 | 129 | If you want to inherit from an ActiveType class, simply do 130 | 131 | ```ruby 132 | class SignUp < ActiveType::Record[User] 133 | # ... 134 | end 135 | 136 | class SpecialSignUp < SignUp 137 | # ... 138 | end 139 | ``` 140 | 141 | ### Defaults #### 142 | 143 | Attributes can have defaults. Those are lazily evaluated on the first read, if no value has been set. 144 | 145 | ```ruby 146 | class SignIn < ActiveType::Object 147 | 148 | attribute :created_at, :datetime, default: proc { Time.now } 149 | 150 | end 151 | ``` 152 | 153 | The proc is evaluated in the context of the object, so you can do 154 | 155 | ```ruby 156 | class SignIn < ActiveType::Object 157 | 158 | attribute :email, :string 159 | attribute :nickname, :string, default: proc { email.split('@').first } 160 | 161 | end 162 | 163 | SignIn.new(email: "tobias@example.org").nickname # "tobias" 164 | SignIn.new(email: "tobias@example.org", :nickname => "kratob").nickname # "kratob" 165 | ``` 166 | 167 | ### Overriding accessors 168 | 169 | You can override attribute getters and setters using `super`: 170 | 171 | ``` 172 | class SignIn < ActiveType::Object 173 | 174 | attribute :email, :string 175 | attribute :nickname, :string 176 | 177 | def email 178 | super.downcase 179 | end 180 | 181 | def nickname=(value) 182 | super(value.titeleize) 183 | end 184 | 185 | end 186 | ``` 187 | 188 | ### Nested attributes 189 | 190 | ActiveType supports its own variant of nested attributes via the `nests_one` / 191 | `nests_many` macros. The intention is to be mostly compatible with 192 | `ActiveRecord`'s `accepts_nested_attributes` functionality. 193 | 194 | Assume you have a list of records, say representing holidays, and you want to support bulk 195 | editing. Then you could do something like: 196 | 197 | ```ruby 198 | class Holiday < ActiveRecord::Base 199 | validates :date, presence: true 200 | end 201 | 202 | class HolidaysForm < ActiveType::Object 203 | nests_many :holidays, reject_if: :all_blank, default: proc { Holiday.all } 204 | end 205 | 206 | class HolidaysController < ApplicationController 207 | def edit 208 | @holidays_form = HolidaysForm.new 209 | end 210 | 211 | def update 212 | @holidays_form = HolidaysForm.new(params[:holidays_form]) 213 | if @holidays_form.save 214 | redirect_to root_url, notice: "Success!" 215 | else 216 | render :edit 217 | end 218 | end 219 | 220 | end 221 | 222 | # and in the view 223 | <%= form_for @holidays_form, url: '/holidays', method: :put do |form| %> 224 | 229 | <% end %> 230 | ``` 231 | 232 | - You have to say `nests_many :records` 233 | - `records` will be validated and saved automatically 234 | - The generated `.records_attributes =` expects parameters like `ActiveRecord`'s nested attributes, and works together with the `fields_for` helper: 235 | 236 | - either as a hash (where the keys are meaningless) 237 | 238 | ```ruby 239 | { 240 | '1' => { date: "new record's date" }, 241 | '2' => { id: '3', date: "existing record's date" } 242 | } 243 | ``` 244 | 245 | - or as an array 246 | 247 | ```ruby 248 | [ 249 | { date: "new record's date" }, 250 | { id: '3', date: "existing record's date" } 251 | ] 252 | ``` 253 | 254 | To use it with single records, use `nests_one`. It works like `accept_nested_attributes` does for `has_one`. Use `.record_attributes =` to build the child record. 255 | 256 | Supported options for `nests_many` / `nests_one` are: 257 | - `build_scope` 258 | 259 | Used to build new records, for example: 260 | 261 | ```ruby 262 | nests_many :documents, build_scope: proc { Document.where(:state => "fresh") } 263 | ``` 264 | 265 | - `find_scope` 266 | 267 | Used to find existing records (in order to update them). 268 | 269 | - `scope` 270 | 271 | Sets `find_scope` and `build_scope` together. 272 | 273 | If you don't supply a scope, `ActiveType` will guess from the association name, i.e. saying 274 | 275 | ```ruby 276 | nests_many :documents 277 | ``` 278 | 279 | is the same as saying 280 | 281 | ```ruby 282 | nests_many :documents, scope: proc { Document } 283 | ``` 284 | 285 | which is identical to 286 | 287 | ```ruby 288 | nests_many :documents, build_scope: proc { Document }, find_scope: proc { Document } 289 | ``` 290 | 291 | All `...scope` options are evaled in the context of the record on first use, and cached. 292 | 293 | - `allow_destroy` 294 | 295 | Allow to destroy records if the attributes contain `_destroy => '1'` 296 | 297 | - `reject_if` 298 | 299 | Pass either a proc of the form `proc { |attributes| ... }`, or a symbol indicating a method, or `:all_blank`. 300 | 301 | Will reject attributes for which the proc or the method returns true, or with only blank values (for `:all_blank`). 302 | 303 | - `default` 304 | 305 | Initializes the association on first access with the given proc: 306 | 307 | ```ruby 308 | nests_many :documents, default: proc { Documents.all } 309 | ``` 310 | 311 | Options supported exclusively by `nests_many` are: 312 | 313 | - `index_errors` 314 | 315 | Use a boolean to get indexed errors on related records. In Rails 5 you can make it global with 316 | `config.active_record.index_nested_attribute_errors = true`. 317 | 318 | 319 | Casting records or relations 320 | ---------------------------- 321 | 322 | When working with ActiveType you will often find it useful to cast an ActiveRecord instance to its extended `ActiveType::Record` variant. 323 | 324 | Use `ActiveType.cast` for this: 325 | 326 | ``` 327 | class User < ActiveRecord::Base 328 | ... 329 | end 330 | 331 | class SignUp < ActiveType::Record[User] 332 | ... 333 | end 334 | 335 | user = User.find(1) 336 | sign_up = ActiveType.cast(user, SignUp) 337 | sign_up.is_a?(SignUp) # => true 338 | ``` 339 | 340 | This is basically like [`ActiveRecord#becomes`](http://apidock.com/rails/v4.2.1/ActiveRecord/Persistence/becomes), but with less bugs and more consistent behavior. 341 | 342 | You can also cast an entire relation (scope) to a relation of an `ActiveType::Record`: 343 | 344 | ``` 345 | adult_users = User.where('age >= 18') 346 | adult_sign_ups = ActiveType.cast(adult_users, SignUp) 347 | sign_up = adult_sign_ups.find(1) 348 | sign_up.is_a?(SignUp) # => true 349 | ``` 350 | 351 | 352 | Supported Rails versions 353 | ------------------------ 354 | 355 | ActiveType is tested against ActiveRecord 3.2, 4.0, 4.1, 4.2 and 5.0. 356 | 357 | Later versions might work, earlier will not. 358 | 359 | Supported Ruby versions 360 | ------------------------ 361 | 362 | ActiveType is tested against 1.9.3, 2.0.0, 2.1.2 (for 4.x only), 2.2.4 and 2.3.1. 363 | 364 | 365 | Installation 366 | ------------ 367 | 368 | In your `Gemfile` say: 369 | 370 | gem 'active_type' 371 | 372 | Now run `bundle install` and restart your server. 373 | 374 | 375 | Development 376 | ----------- 377 | 378 | - We run tests against several ActiveRecord versions. 379 | - You can bundle all versions with `rake all:install`. 380 | - You can run specs against all versions with `rake all:spec`. 381 | - You can run specs against a single version with `VERSION=4.0 rake`. 382 | 383 | If you are getting testing failures due to Mysql trying to connect as `root` user, you can put your Mysql credentials into `spec/support/database.yml`. 384 | See `spec/support/database.sample.yml` for an example. 385 | 386 | If you would like to contribute: 387 | 388 | - Fork the repository. 389 | - Push your changes **with passing specs**. 390 | - Send us a pull request. 391 | 392 | I'm very eager to keep this gem leightweight and on topic. If you're unsure whether a change would make it into the gem, [talk to me beforehand](mailto:henning.koch@makandra.de). 393 | 394 | 395 | Credits 396 | ------- 397 | 398 | Tobias Kraze from [makandra](http://makandra.com/) 399 | 400 | Henning Koch from [makandra](http://makandra.com/) 401 | 402 | 403 | -------------------------------------------------------------------------------- /spec/active_type/object_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'ostruct' 3 | 4 | module ObjectSpec 5 | 6 | def self.type 7 | if ActiveRecord::VERSION::MAJOR >= 5 8 | @type ||= ActiveModel::Type::Value.new 9 | end 10 | end 11 | 12 | class Object < ActiveType::Object 13 | 14 | attribute :virtual_string, :string 15 | attribute :virtual_integer, :integer 16 | attribute :virtual_time, :datetime 17 | attribute :virtual_date, :date 18 | attribute :virtual_boolean, :boolean 19 | attribute :virtual_attribute 20 | attribute :virtual_type_attribute, ObjectSpec.type 21 | 22 | end 23 | 24 | class PlainObject < ActiveType::Object 25 | end 26 | 27 | 28 | class ObjectWithValidations < Object 29 | 30 | validates :virtual_string, :presence => true 31 | validates :virtual_boolean, :presence => true 32 | 33 | end 34 | 35 | 36 | class ObjectWithOverrides < Object 37 | 38 | attribute :overridable_test, :string 39 | 40 | def overridable_test 41 | super + super 42 | end 43 | 44 | end 45 | 46 | 47 | class InheritingObject < Object 48 | attribute :another_virtual_string, :string 49 | end 50 | 51 | 52 | class IncludingObject < Object 53 | 54 | module Module 55 | extend ActiveSupport::Concern 56 | 57 | included do 58 | attribute :another_virtual_string, :string 59 | end 60 | end 61 | 62 | include Module 63 | end 64 | 65 | 66 | class ObjectWithCallbacks < Object 67 | 68 | before_save :before_save_callback 69 | before_validation :before_validation_callback 70 | after_save :after_save_callback 71 | after_commit :after_commit_callback 72 | after_rollback :after_rollback_callback 73 | 74 | def before_save_callback 75 | end 76 | 77 | def before_validation_callback 78 | end 79 | 80 | def after_save_callback 81 | end 82 | 83 | def after_commit_callback 84 | end 85 | 86 | def after_rollback_callback 87 | end 88 | 89 | end 90 | 91 | class Child < ActiveRecord::Base 92 | end 93 | 94 | class ObjectWithBelongsTo < Object 95 | 96 | attribute :child_id, :integer 97 | 98 | belongs_to :child 99 | 100 | end 101 | 102 | class ObjectWithUnsupportedTypes < Object 103 | attribute :virtual_array, :array 104 | attribute :virtual_hash, :hash 105 | end 106 | 107 | end 108 | 109 | 110 | describe ActiveType::Object do 111 | 112 | subject { ObjectSpec::Object.new } 113 | 114 | describe 'constructors' do 115 | subject { ObjectSpec::Object } 116 | 117 | it_should_behave_like 'ActiveRecord-like constructors', { :virtual_string => "string", :virtual_integer => 100, :virtual_time => Time.now, :virtual_date => Date.today, :virtual_boolean => true } 118 | 119 | end 120 | 121 | describe 'mass assignment' do 122 | it_should_behave_like 'ActiveRecord-like mass assignment', { :virtual_string => "string", :virtual_integer => 100, :virtual_time => Time.now, :virtual_date => Date.today, :virtual_boolean => true } 123 | end 124 | 125 | describe 'accessors' do 126 | it_should_behave_like 'ActiveRecord-like accessors', { :virtual_string => "string", :virtual_integer => 100, :virtual_time => Time.now, :virtual_date => Date.today, :virtual_boolean => true } 127 | end 128 | 129 | describe 'unsupported types' do 130 | subject { ObjectSpec::ObjectWithUnsupportedTypes.new } 131 | 132 | it_should_behave_like 'ActiveRecord-like mass assignment', { :virtual_hash => {'foo' => 'bar'}, :virtual_array => ['foo', 'bar'] } 133 | it_should_behave_like 'ActiveRecord-like accessors', { :virtual_hash => {'foo' => 'bar'}, :virtual_array => ['foo', 'bar'] } 134 | end 135 | 136 | describe 'overridable attributes' do 137 | subject { ObjectSpec::ObjectWithOverrides.new } 138 | 139 | it 'is possible to override attributes with super' do 140 | subject.overridable_test = "test" 141 | 142 | expect(subject.overridable_test).to eq("testtest") 143 | end 144 | end 145 | 146 | describe 'attribute name validation' do 147 | it 'crashes when trying to define an invalid attribute name' do 148 | klass = Class.new(ActiveType::Object) 149 | expect { 150 | klass.class_eval do 151 | attribute :"", :string 152 | end 153 | }.to raise_error(ActiveType::InvalidAttributeNameError) 154 | end 155 | end 156 | 157 | context 'coercible' do 158 | describe 'string columns' do 159 | it_should_behave_like 'a coercible string column', :virtual_string 160 | end 161 | 162 | describe 'integer columns' do 163 | it_should_behave_like 'a coercible integer column', :virtual_integer 164 | end 165 | 166 | describe 'date columns' do 167 | it_should_behave_like 'a coercible date column', :virtual_date 168 | end 169 | 170 | describe 'time columns' do 171 | it_should_behave_like 'a coercible time column', :virtual_time 172 | end 173 | 174 | describe 'boolean columns' do 175 | it_should_behave_like 'a coercible boolean column', :virtual_boolean 176 | end 177 | 178 | describe 'untyped columns' do 179 | it_should_behave_like 'an untyped column', :virtual_attribute 180 | end 181 | 182 | describe 'type columns' do 183 | it_should_behave_like 'a coercible type column', :virtual_type_attribute, ObjectSpec.type 184 | end 185 | end 186 | 187 | describe 'query methods' do 188 | 189 | it 'returns true for true' do 190 | subject.virtual_attribute = true 191 | 192 | expect(subject.virtual_attribute?).to eq(true) 193 | end 194 | 195 | it 'returns false for false' do 196 | subject.virtual_attribute = false 197 | 198 | expect(subject.virtual_attribute?).to eq(false) 199 | end 200 | 201 | it 'returns false for nil' do 202 | subject.virtual_attribute = nil 203 | 204 | expect(subject.virtual_attribute?).to eq(false) 205 | end 206 | 207 | it 'returns true for 1' do 208 | subject.virtual_attribute = 1 209 | 210 | expect(subject.virtual_attribute?).to eq(true) 211 | end 212 | 213 | it 'returns true for an object' do 214 | subject.virtual_attribute = Object.new 215 | 216 | expect(subject.virtual_attribute?).to eq(true) 217 | end 218 | 219 | end 220 | 221 | describe '#inspect' do 222 | 223 | it 'returns the contents of the object as a nicely formatted string' do 224 | t = Time.now 225 | subject.virtual_string = "string" 226 | subject.virtual_integer = 17 227 | subject.virtual_time = t 228 | subject.virtual_date = Date.today 229 | subject.virtual_boolean = true 230 | subject.virtual_attribute = OpenStruct.new({:test => "openstruct"}) 231 | 232 | expect(subject.inspect).to eq("#, virtual_boolean: true, virtual_date: \"#{Date.today}\", virtual_integer: 17, virtual_string: \"string\", virtual_time: \"#{t.to_s(:db)}\", virtual_type_attribute: nil>") 233 | end 234 | 235 | end 236 | 237 | describe '#attributes' do 238 | 239 | it 'returns a hash of virtual attributes' do 240 | subject.virtual_string = "string" 241 | subject.virtual_integer = "17" 242 | 243 | expect(subject.attributes).to eq({ 244 | "virtual_string" => "string", 245 | "virtual_integer" => 17, 246 | "virtual_time" => nil, 247 | "virtual_date" => nil, 248 | "virtual_boolean" => nil, 249 | "virtual_attribute" => nil, 250 | "virtual_type_attribute" => nil, 251 | }) 252 | end 253 | 254 | it 'also includes inherited attributes' do 255 | object = ObjectSpec::InheritingObject.new 256 | object.virtual_string = "string" 257 | object.virtual_integer = "17" 258 | 259 | expect(object.attributes).to eq({ 260 | "virtual_string" => "string", 261 | "virtual_integer" => 17, 262 | "virtual_time" => nil, 263 | "virtual_date" => nil, 264 | "virtual_boolean" => nil, 265 | "virtual_attribute" => nil, 266 | "another_virtual_string" => nil, 267 | "virtual_type_attribute" => nil, 268 | }) 269 | end 270 | 271 | it 'also includes included attributes' do 272 | object = ObjectSpec::IncludingObject.new 273 | object.virtual_string = "string" 274 | object.virtual_integer = "17" 275 | 276 | expect(object.attributes).to eq({ 277 | "virtual_string" => "string", 278 | "virtual_integer" => 17, 279 | "virtual_time" => nil, 280 | "virtual_date" => nil, 281 | "virtual_boolean" => nil, 282 | "virtual_attribute" => nil, 283 | "another_virtual_string" => nil, 284 | "virtual_type_attribute" => nil, 285 | }) 286 | end 287 | 288 | end 289 | 290 | describe 'inherited classes' do 291 | 292 | it 'sees attributes of both classes' do 293 | object = ObjectSpec::InheritingObject.new 294 | object.virtual_string = "string" 295 | object.another_virtual_string = "another string" 296 | 297 | expect(object.virtual_string).to eq("string") 298 | expect(object.another_virtual_string).to eq("another string") 299 | end 300 | 301 | it 'does not define the attribute on the parent class' do 302 | object = ObjectSpec::Object.new 303 | expect(object).not_to respond_to(:another_virtual_string) 304 | end 305 | 306 | end 307 | 308 | describe 'included modules' do 309 | it 'sees attributes of the included module' do 310 | object = ObjectSpec::IncludingObject.new 311 | object.virtual_string = "string" 312 | object.another_virtual_string = "another string" 313 | 314 | expect(object.virtual_string).to eq("string") 315 | expect(object.another_virtual_string).to eq("another string") 316 | end 317 | 318 | it 'does not define the attribute on the parent class' do 319 | object = ObjectSpec::Object.new 320 | expect(object).not_to respond_to(:another_virtual_string) 321 | end 322 | end 323 | 324 | describe 'validations' do 325 | subject { ObjectSpec::ObjectWithValidations.new } 326 | 327 | it 'has 1 error_on' do 328 | expect(subject.error_on(:virtual_string).size).to eq(1) 329 | end 330 | 331 | it 'validates the presence of boolean values' do 332 | subject.virtual_boolean = false 333 | expect(subject.error_on(:virtual_boolean).size).to eq(1) 334 | subject.virtual_boolean = '0' 335 | expect(subject.error_on(:virtual_boolean).size).to eq(1) 336 | subject.virtual_boolean = 0 337 | expect(subject.error_on(:virtual_boolean).size).to eq(1) 338 | subject.virtual_boolean = true 339 | expect(subject.errors_on(:virtual_boolean).size).to eq(0) 340 | end 341 | 342 | it 'has no errors if validations pass' do 343 | subject.virtual_string = "foo" 344 | subject.virtual_boolean = true 345 | expect(subject).to be_valid 346 | expect(subject.errors_on(:virtual_string).size).to eq(0) 347 | end 348 | 349 | it 'causes #save to return false' do 350 | expect(subject.save).to be_falsey 351 | end 352 | end 353 | 354 | describe 'defaults' do 355 | it_should_behave_like "a class accepting attribute defaults", ActiveType::Object 356 | end 357 | 358 | describe 'duping' do 359 | it_should_behave_like "a class supporting dup for attributes", ActiveType::Object 360 | 361 | it 'can dup without attributes' do 362 | expect { 363 | ObjectSpec::PlainObject.new.dup 364 | }.not_to raise_error 365 | end 366 | end 367 | 368 | describe 'dirty tracking' do 369 | it_should_behave_like 'a class supporting dirty tracking for virtual attributes', ActiveType::Object 370 | end 371 | 372 | describe '#belongs_to' do 373 | subject { ObjectSpec::ObjectWithBelongsTo.new } 374 | 375 | it_should_behave_like 'a belongs_to association', :child, ObjectSpec::Child 376 | end 377 | 378 | describe '#save' do 379 | subject { ObjectSpec::ObjectWithCallbacks.new } 380 | 381 | it "returns true" do 382 | subject.save 383 | end 384 | 385 | %w[before_validation before_save after_save after_commit].each do |callback| 386 | 387 | it "calls #{callback}", :rollback => false do 388 | expect(subject).to receive("#{callback}_callback") 389 | 390 | expect(subject.save).to eq(true) 391 | end 392 | 393 | end 394 | 395 | %w[before_validation before_save].each do |callback| 396 | 397 | it "aborts the chain when #{callback} returns false" do 398 | if ActiveRecord::VERSION::MAJOR >= 5 399 | allow(subject).to receive("#{callback}_callback") do 400 | throw(:abort) 401 | end 402 | else 403 | allow(subject).to receive_messages("#{callback}_callback" => false) 404 | end 405 | 406 | expect(subject.save).to be_falsey 407 | end 408 | 409 | end 410 | 411 | it 'runs after_rollback callbacks if an after_save callback raises an error', :rollback => false do 412 | expect(subject).to receive(:after_save_callback).ordered.and_raise(ActiveRecord::Rollback) 413 | expect(subject).to receive(:after_rollback_callback).ordered 414 | 415 | expect(subject.save).to be_falsey 416 | end 417 | 418 | it 'does not run after_rollback callbacks if after_save does not raise an error', :rollback => false do 419 | expect(subject).to_not receive(:after_rollback_callback) 420 | 421 | expect(subject.save).to be_truthy 422 | 423 | end 424 | 425 | end 426 | 427 | describe '#id' do 428 | 429 | it 'is nil' do 430 | expect(subject.id).to eq nil 431 | end 432 | 433 | end 434 | 435 | describe '.find' do 436 | it 'raises an error' do 437 | error = if ActiveRecord::VERSION::MAJOR == 4 && ActiveRecord::VERSION::MINOR >= 1 438 | ActiveRecord::UnknownPrimaryKey 439 | else 440 | ActiveRecord::RecordNotFound 441 | end 442 | expect do 443 | ObjectSpec::Object.find(1) 444 | end.to raise_error(error) 445 | end 446 | end 447 | 448 | describe '.all' do 449 | it 'returns []' do 450 | expect(ObjectSpec::Object.all).to eq([]) 451 | end 452 | end 453 | 454 | describe '.create' do 455 | it 'returns an object' do 456 | object = ObjectSpec::Object.create(:virtual_string => "string") 457 | 458 | expect(object).to be_a(ObjectSpec::Object) 459 | expect(object.virtual_string).to eq("string") 460 | end 461 | end 462 | 463 | end 464 | -------------------------------------------------------------------------------- /spec/active_type/nested_attributes_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | module NestedAttributesSpec 4 | 5 | class Record < ActiveRecord::Base 6 | attr_accessor :fail_on_save, :error 7 | 8 | before_save :check_fail 9 | 10 | validate :check_error 11 | 12 | private 13 | 14 | def check_fail 15 | if fail_on_save == true 16 | if ActiveRecord::VERSION::MAJOR >= 5 17 | throw :abort 18 | else 19 | false 20 | end 21 | end 22 | end 23 | 24 | def check_error 25 | if error.present? 26 | errors.add(:base, error) 27 | end 28 | end 29 | end 30 | 31 | end 32 | 33 | class GlobalRecord < ActiveRecord::Base 34 | self.table_name = 'records' 35 | end 36 | 37 | 38 | describe "ActiveType::Object" do 39 | 40 | context '.nests_many' do 41 | 42 | let(:extra_options) { {} } 43 | 44 | subject do 45 | extra = extra_options 46 | Class.new(ActiveType::Object) do 47 | nests_many :records, extra.merge(:scope => NestedAttributesSpec::Record) 48 | 49 | def bad(attributes) 50 | attributes[:persisted_string] =~ /bad/ 51 | end 52 | 53 | def reject_all 54 | true 55 | end 56 | 57 | end.new 58 | end 59 | 60 | def should_assign_and_persist(assign, persist = assign) 61 | expect(subject.records.map(&:persisted_string)).to eq(assign) 62 | expect(subject.save).to eq(true) 63 | expect(NestedAttributesSpec::Record.all.map(&:persisted_string)).to match_array(persist) 64 | end 65 | 66 | 67 | context 'with no records assigned' do 68 | 69 | it 'can save' do 70 | expect(subject.save).to eq(true) 71 | end 72 | 73 | end 74 | 75 | context 'assigning nil' do 76 | 77 | it 'will do nothing' do 78 | subject.records_attributes = nil 79 | expect(subject.records).to be_nil 80 | end 81 | 82 | end 83 | 84 | context 'when assigning records without ids' do 85 | 86 | it 'builds single nested records' do 87 | subject.records_attributes = { 1 => {:persisted_string => "string"} } 88 | 89 | should_assign_and_persist(["string"]) 90 | end 91 | 92 | it 'builds multiple nested records when given a hash of attributes, ordered by key' do 93 | subject.records_attributes = { 94 | 3 => {:persisted_string => "string 3"}, 95 | 1 => {:persisted_string => "string 1"}, 96 | 2 => {:persisted_string => "string 2"}, 97 | } 98 | 99 | should_assign_and_persist(["string 1", "string 2", "string 3"]) 100 | end 101 | 102 | it 'builds multiple nested records when given an array of attributes' do 103 | subject.records_attributes = [ 104 | {:persisted_string => "string 1"}, 105 | {:persisted_string => "string 2"}, 106 | {:persisted_string => "string 3"}, 107 | ] 108 | 109 | should_assign_and_persist(["string 1", "string 2", "string 3"]) 110 | end 111 | 112 | it 'does not build records that match a :reject_if proc' do 113 | extra_options.merge!(:reject_if => proc { |attributes| attributes['persisted_string'] =~ /bad/ }) 114 | subject.records_attributes = { 115 | 1 => {:persisted_string => "good value"}, 116 | 2 => {:persisted_string => "bad value"}, 117 | } 118 | 119 | should_assign_and_persist(["good value"]) 120 | end 121 | 122 | it 'does not build records that match a :reject_if method taking attributes' do 123 | extra_options.merge!(:reject_if => :bad) 124 | subject.records_attributes = { 125 | 1 => {:persisted_string => "good value"}, 126 | 2 => {:persisted_string => "bad value"}, 127 | } 128 | 129 | should_assign_and_persist(["good value"]) 130 | end 131 | 132 | it 'does not build records that match a :reject_if method taking attributes' do 133 | extra_options.merge!(:reject_if => :bad) 134 | subject.records_attributes = { 135 | 1 => {:persisted_string => "good value"}, 136 | 2 => {:persisted_string => "bad value"}, 137 | } 138 | 139 | should_assign_and_persist(["good value"]) 140 | end 141 | 142 | it 'does not build records that match a :reject_if method taking no attributes' do 143 | extra_options.merge!(:reject_if => :reject_all) 144 | subject.records_attributes = { 145 | 1 => {:persisted_string => "good value"}, 146 | 2 => {:persisted_string => "bad value"}, 147 | } 148 | 149 | should_assign_and_persist([]) 150 | end 151 | 152 | it 'does not build records that match a :reject_if all_blank' do 153 | extra_options.merge!(:reject_if => :all_blank) 154 | subject.records_attributes = { 155 | 1 => {:persisted_string => "good value"}, 156 | 2 => {}, 157 | } 158 | 159 | should_assign_and_persist(["good value"]) 160 | end 161 | 162 | it 'appends to existing records' do 163 | subject.records = [NestedAttributesSpec::Record.create!(:persisted_string => "existing string")] 164 | subject.records_attributes = { 1 => {:persisted_string => "new string"} } 165 | 166 | should_assign_and_persist(["existing string", "new string"]) 167 | end 168 | 169 | it 'leaves unassigned records alone' do 170 | NestedAttributesSpec::Record.create!(:persisted_string => "unassigned") 171 | subject.records_attributes = { 1 => {:persisted_string => "string"} } 172 | 173 | should_assign_and_persist(["string"], ["unassigned", "string"]) 174 | end 175 | 176 | it 'does not destroy records on _destroy => trueish by default' do 177 | existing = NestedAttributesSpec::Record.create!(:persisted_string => 'do not delete this') 178 | 179 | subject.records_attributes = [ 180 | { :id => existing.id, :_destroy => "true" }, 181 | ] 182 | should_assign_and_persist(["do not delete this"], ["do not delete this"]) 183 | expect(subject.records.size).to eq(1) 184 | end 185 | 186 | it 'destroys records on _destroy => trueish if allowed' do 187 | extra_options.merge!(:allow_destroy => true) 188 | existing = [ 189 | NestedAttributesSpec::Record.create!(:persisted_string => 'delete this'), 190 | NestedAttributesSpec::Record.create!(:persisted_string => 'delete this'), 191 | NestedAttributesSpec::Record.create!(:persisted_string => 'delete this'), 192 | NestedAttributesSpec::Record.create!(:persisted_string => 'keep this'), 193 | ] 194 | 195 | subject.records = existing.first(2) # assign some 196 | 197 | subject.records_attributes = [ 198 | { :id => existing[0].id, :_destroy => "true" }, 199 | { :id => existing[1].id, :_destroy => 1 }, 200 | { :id => existing[2].id, :_destroy => "1" }, 201 | { :id => existing[3].id, :_destroy => "0" }, 202 | ] 203 | should_assign_and_persist(["delete this", "delete this", "delete this", "keep this"], ["keep this"]) 204 | expect(subject.records.size).to eq(1) 205 | end 206 | 207 | end 208 | 209 | context 'when assigning records with ids' do 210 | 211 | it 'updates the record with the id if already assigned' do 212 | subject.records = [ 213 | NestedAttributesSpec::Record.new(:persisted_string => "existing 1"), 214 | NestedAttributesSpec::Record.new(:persisted_string => "existing 2"), 215 | NestedAttributesSpec::Record.new(:persisted_string => "existing 3"), 216 | ] 217 | subject.records[0].id = 100 218 | subject.records[1].id = 101 219 | subject.records[2].id = 102 220 | 221 | subject.records_attributes = { 1 => {:id => 101, :persisted_string => "updated"} } 222 | 223 | should_assign_and_persist(["existing 1", "updated", "existing 3"]) 224 | end 225 | 226 | it 'does not update records matching a reject_if proc' do 227 | extra_options.merge!(:reject_if => :bad) 228 | subject.records = [ 229 | NestedAttributesSpec::Record.new(:persisted_string => "existing 1"), 230 | NestedAttributesSpec::Record.new(:persisted_string => "existing 2"), 231 | ] 232 | subject.records[0].id = 100 233 | subject.records[1].id = 101 234 | 235 | subject.records_attributes = [ 236 | {:id => 100, :persisted_string => "good"}, 237 | {:id => 101, :persisted_string => "bad"} 238 | ] 239 | 240 | should_assign_and_persist(["good", "existing 2"]) 241 | end 242 | 243 | it 'fetches the record with the id if not already assigned' do 244 | record = NestedAttributesSpec::Record.create!(:persisted_string => "existing string 1") 245 | subject.records = [ 246 | NestedAttributesSpec::Record.new(:persisted_string => "existing string 2"), 247 | ] 248 | subject.records[0].id = record.id + 1 249 | 250 | subject.records_attributes = { 1 => {:id => record.id, :persisted_string => "updated string"} } 251 | 252 | should_assign_and_persist(["existing string 2", "updated string"]) 253 | end 254 | 255 | it 'raises an error if the child record does not exist' do 256 | expect do 257 | subject.records_attributes = { 1 => {:id => 1, :persisted_string => "updated string"} } 258 | end.to raise_error(ActiveType::NestedAttributes::RecordNotFound, "could not find a child record with id '1' for 'records'") 259 | end 260 | 261 | end 262 | 263 | context 'save failure' do 264 | 265 | it 'returns false on #save and does not save the child' do 266 | # it should also cause a rollback, but that will not work with sqlite3 267 | subject.records = [ 268 | NestedAttributesSpec::Record.new(:fail_on_save => true), 269 | ] 270 | 271 | expect(subject.save).to be_falsey 272 | expect(NestedAttributesSpec::Record.count).to eq(0) 273 | 274 | # note that other children would be saved and not be rolled back 275 | # this is also true for regular nested attributes 276 | end 277 | 278 | end 279 | 280 | context 'validations' do 281 | 282 | describe '#valid?' do 283 | 284 | it 'is true if there are no records assigned' do 285 | expect(subject.valid?).to eq(true) 286 | end 287 | 288 | it 'is true if all records are valid' do 289 | subject.records = [ 290 | NestedAttributesSpec::Record.new, 291 | NestedAttributesSpec::Record.new, 292 | ] 293 | 294 | expect(subject.valid?).to eq(true) 295 | end 296 | 297 | it 'is false if one child has an error' do 298 | subject.records = [ 299 | NestedAttributesSpec::Record.new, 300 | NestedAttributesSpec::Record.new(:error => 'some error'), 301 | ] 302 | 303 | expect(subject.valid?).to be_falsey 304 | end 305 | 306 | it 'is copies the error to the record' do 307 | subject.records = [ 308 | NestedAttributesSpec::Record.new, 309 | NestedAttributesSpec::Record.new(:error => 'some error'), 310 | ] 311 | 312 | subject.valid? 313 | expect(subject.errors["records.base"]).to eq(['some error']) 314 | end 315 | 316 | it 'index errors when index_errors option is used' do 317 | extra_options.merge!(:index_errors => true) 318 | subject.records = [ 319 | NestedAttributesSpec::Record.new, 320 | NestedAttributesSpec::Record.new(:error => 'some error') 321 | ] 322 | 323 | subject.valid? 324 | expect(subject.errors["records.base"]).not_to eq(['some error']) 325 | expect(subject.errors["records[1].base"]).to eq(['some error']) 326 | end 327 | 328 | it 'index errors when global flag is set' do 329 | old_attribute_config = ActiveRecord::Base.index_nested_attribute_errors 330 | 331 | ActiveRecord::Base.index_nested_attribute_errors = true 332 | subject.records = [ 333 | NestedAttributesSpec::Record.new, 334 | NestedAttributesSpec::Record.new(:error => 'some error') 335 | ] 336 | 337 | subject.valid? 338 | expect(subject.errors["records.base"]).not_to eq(['some error']) 339 | expect(subject.errors["records[1].base"]).to eq(['some error']) 340 | 341 | ActiveRecord::Base.index_nested_attribute_errors = old_attribute_config 342 | end if ActiveRecord::Base.respond_to?(:index_nested_attribute_errors) 343 | 344 | end 345 | 346 | end 347 | 348 | end 349 | 350 | 351 | context '.nests_one' do 352 | 353 | let(:extra_options) { {} } 354 | 355 | subject do 356 | extra = extra_options 357 | Class.new(ActiveType::Object) do 358 | nests_one :record, extra.merge(:scope => NestedAttributesSpec::Record) 359 | 360 | def bad(attributes) 361 | attributes[:persisted_string] =~ /bad/ 362 | end 363 | end.new 364 | end 365 | 366 | def should_assign_and_persist(assign, persist = assign) 367 | if assign 368 | expect(subject.record).to be_present 369 | expect(subject.record.persisted_string).to eq(assign) 370 | else 371 | expect(subject.record).to be_nil 372 | end 373 | expect(subject.save).to eq(true) 374 | expect(NestedAttributesSpec::Record.all.map(&:persisted_string)).to eq(persist ? [persist] : []) 375 | end 376 | 377 | 378 | context 'with no record assigned' do 379 | 380 | it 'can save' do 381 | expect(subject.save).to eq(true) 382 | end 383 | 384 | end 385 | 386 | context 'assigning nil' do 387 | 388 | it 'will do nothing' do 389 | subject.record_attributes = nil 390 | expect(subject.record).to be_nil 391 | end 392 | 393 | end 394 | 395 | context 'when assigning a records without an id' do 396 | 397 | it 'builds a nested records' do 398 | subject.record_attributes = { :persisted_string => "string" } 399 | 400 | should_assign_and_persist("string") 401 | end 402 | 403 | it 'does not build a record that matchs a :reject_if proc' do 404 | extra_options.merge!(:reject_if => proc { |attributes| attributes['persisted_string'] =~ /bad/ }) 405 | subject.record_attributes = { :persisted_string => "bad" } 406 | 407 | should_assign_and_persist(nil) 408 | end 409 | 410 | 411 | it 'updates an assigned record' do 412 | subject.record = NestedAttributesSpec::Record.create!(:persisted_string => "existing string") 413 | subject.record_attributes = { :persisted_string => "new string" } 414 | 415 | should_assign_and_persist("new string") 416 | end 417 | 418 | it 'does not update a record that matchs a :reject_if proc' do 419 | extra_options.merge!(:reject_if => proc { |attributes| attributes['persisted_string'] =~ /bad/ }) 420 | subject.record = NestedAttributesSpec::Record.create!(:persisted_string => "existing string") 421 | subject.record_attributes = { :persisted_string => "bad" } 422 | 423 | should_assign_and_persist("existing string") 424 | end 425 | 426 | 427 | end 428 | 429 | context 'when assigning a records with an id' do 430 | 431 | let(:record) { record = NestedAttributesSpec::Record.create!(:persisted_string => "existing string") } 432 | 433 | it 'updates the record if already assigned' do 434 | subject.record = record 435 | 436 | subject.record_attributes = { :id => record.id, :persisted_string => "updated string"} 437 | 438 | should_assign_and_persist("updated string") 439 | end 440 | 441 | it 'fetches the record with the id if not already assigned' do 442 | subject.record_attributes = { :id => record.id, :persisted_string => "updated string" } 443 | 444 | should_assign_and_persist("updated string") 445 | end 446 | 447 | it 'does not destroy records on _destroy => true by default' do 448 | subject.record_attributes = { :id => record.id, :_destroy => true } 449 | 450 | should_assign_and_persist("existing string", "existing string") 451 | end 452 | 453 | it 'destroys records on _destroy => true if allowed' do 454 | extra_options.merge!(:allow_destroy => true) 455 | subject.record_attributes = { :id => record.id, :_destroy => true } 456 | 457 | should_assign_and_persist("existing string", nil) 458 | expect(subject.record).to eq(nil) 459 | end 460 | 461 | it 'raises an error if the assigned record does not match the id' do 462 | expect do 463 | subject.record = NestedAttributesSpec::Record.create! 464 | subject.record_attributes = { :id => record.id, :persisted_string => "updated string" } 465 | end.to raise_error(ActiveType::NestedAttributes::AssignmentError, "child record 'record' did not match id '#{record.id}'") 466 | end 467 | 468 | it 'raises an error if a record with the id cannot be found' do 469 | expect do 470 | subject.record_attributes = { :id => 1, :persisted_string => "updated string" } 471 | end.to raise_error(ActiveType::NestedAttributes::RecordNotFound, "could not find a child record with id '1' for 'record'") 472 | end 473 | 474 | end 475 | 476 | context 'validations' do 477 | 478 | describe '#valid?' do 479 | 480 | it 'is true if there is no record assigned' do 481 | expect(subject.valid?).to eq(true) 482 | end 483 | 484 | it 'is true if the assigned record is valid' do 485 | subject.record = NestedAttributesSpec::Record.new 486 | 487 | expect(subject.valid?).to eq(true) 488 | end 489 | 490 | it 'is false the assigned record has an error' do 491 | subject.record = NestedAttributesSpec::Record.new(:error => 'some error') 492 | 493 | expect(subject.valid?).to be_falsey 494 | end 495 | 496 | it 'is copies the error to the record' do 497 | subject.record = NestedAttributesSpec::Record.new(:error => 'some error') 498 | 499 | subject.valid? 500 | expect(subject.errors["record.base"]).to eq(['some error']) 501 | end 502 | 503 | end 504 | 505 | end 506 | 507 | end 508 | 509 | context '.nests_one/nests_many' do 510 | 511 | context 'inheritance' do 512 | 513 | let(:base_class) do 514 | Class.new(ActiveType::Object) do 515 | nests_one :record, :scope => NestedAttributesSpec::Record 516 | end 517 | end 518 | 519 | it 'works across inheritance hierarchy' do 520 | subject = Class.new(base_class) do 521 | nests_one :another_record, :scope => NestedAttributesSpec::Record 522 | end.new 523 | 524 | subject.record_attributes = { :persisted_string => "string" } 525 | subject.another_record_attributes = {:persisted_string => "another string"} 526 | 527 | expect(subject.record.persisted_string).to eq("string") 528 | expect(subject.another_record.persisted_string).to eq("another string") 529 | expect(subject.save).to eq(true) 530 | expect(NestedAttributesSpec::Record.all.map(&:persisted_string)).to match_array(["string", "another string"]) 531 | end 532 | 533 | it 'allows overriding of the accessor' do 534 | subject = Class.new(base_class) do 535 | def record_attributes=(attributes) 536 | reached 537 | super 538 | end 539 | 540 | def reached 541 | end 542 | end.new 543 | 544 | expect(subject).to receive(:reached) 545 | subject.record_attributes = { :persisted_string => "string" } 546 | 547 | expect(subject.record.persisted_string).to eq("string") 548 | expect(subject.save).to eq(true) 549 | expect(NestedAttributesSpec::Record.all.map(&:persisted_string)).to match_array(["string"]) 550 | end 551 | 552 | end 553 | 554 | context 'when not giving a scope' do 555 | 556 | subject do 557 | Class.new(ActiveType::Object) do 558 | nests_many :global_records 559 | nests_one :global_record 560 | end.new 561 | end 562 | 563 | it 'infers the scope from the association name' do 564 | subject.global_records_attributes = { 1 => { :persisted_string => "string" } } 565 | subject.global_record_attributes = { :persisted_string => "string" } 566 | 567 | expect(subject.global_records.first).to be_a(GlobalRecord) 568 | expect(subject.global_record).to be_a(GlobalRecord) 569 | end 570 | 571 | end 572 | 573 | context 'when giving a scope via a proc' do 574 | 575 | subject do 576 | Class.new(ActiveType::Object) do 577 | nests_many :records, :scope => proc { NestedAttributesSpec::Record.where("persisted_string <> 'invisible'") } 578 | nests_one :record, :scope => proc { NestedAttributesSpec::Record } 579 | 580 | attribute :default_value, :string 581 | nests_many :default_records, :scope => proc { NestedAttributesSpec::Record.where(:persisted_string => default_value) } 582 | end.new 583 | end 584 | 585 | it 'uses the scope' do 586 | subject.records_attributes = { 1 => { :persisted_string => "string" } } 587 | subject.record_attributes = { :persisted_string => "string" } 588 | 589 | expect(subject.records.first).to be_a(NestedAttributesSpec::Record) 590 | expect(subject.record).to be_a(NestedAttributesSpec::Record) 591 | end 592 | 593 | it 'evals the scope lazily in the instance' do 594 | subject.default_value = "default value" 595 | subject.default_records_attributes = [{}] 596 | 597 | expect(subject.default_records.map(&:persisted_string)).to eq(["default value"]) 598 | end 599 | 600 | it 'caches the scope' do 601 | subject.default_value = "default value" 602 | subject.default_records_attributes = [{}] 603 | subject.default_value = "another default value" 604 | subject.default_records_attributes = [{}] 605 | 606 | expect(subject.default_records.map(&:persisted_string)).to eq(["default value", "default value"]) 607 | end 608 | 609 | it 'caches the scope per instance' do 610 | subject.default_value = "default value" 611 | subject.default_records_attributes = [{}] 612 | 613 | another_subject = subject.class.new 614 | another_subject.default_value = "another default value" 615 | another_subject.default_records_attributes = [{}] 616 | 617 | expect(another_subject.default_records.map(&:persisted_string)).to eq(["another default value"]) 618 | end 619 | 620 | it 'raises an error if the child record is not found via the scope' do 621 | record = NestedAttributesSpec::Record.create!(:persisted_string => 'invisible') 622 | 623 | expect do 624 | subject.records_attributes = { 1 => { :id => record.id, :persisted_string => "updated string" } } 625 | end.to raise_error(ActiveType::NestedAttributes::RecordNotFound, "could not find a child record with id '#{record.id}' for 'records'") 626 | end 627 | end 628 | 629 | context 'separate scopes for build and find' do 630 | 631 | subject do 632 | find_scope = proc { NestedAttributesSpec::Record.where(:persisted_string => 'findable') } 633 | build_scope = proc { NestedAttributesSpec::Record.where(:persisted_string => 'buildable') } 634 | Class.new(ActiveType::Object) do 635 | nests_many :records, :build_scope => build_scope, :find_scope => find_scope 636 | nests_one :record, :build_scope => build_scope, :find_scope => find_scope 637 | end.new 638 | end 639 | 640 | it 'nests_many uses the find_scope to find records' do 641 | record = NestedAttributesSpec::Record.create!(:persisted_string => 'findable') 642 | hidden_record = NestedAttributesSpec::Record.create!(:persisted_string => 'hidden') 643 | 644 | expect do 645 | subject.records_attributes = [{ :id => record.id, :persisted_string => 'updated' }] 646 | end.to_not raise_error 647 | 648 | expect do 649 | subject.records_attributes = [{ :id => hidden_record.id, :persisted_string => 'updated' }] 650 | end.to raise_error(ActiveType::NestedAttributes::RecordNotFound) 651 | end 652 | 653 | it 'nests_many uses the build_scope to find records' do 654 | subject.records_attributes = [{}] 655 | expect(subject.records.first.persisted_string).to eq('buildable') 656 | end 657 | 658 | it 'nests_one uses the find_scope to find records' do 659 | record = NestedAttributesSpec::Record.create!(:persisted_string => 'findable') 660 | hidden_record = NestedAttributesSpec::Record.create!(:persisted_string => 'hidden') 661 | 662 | expect do 663 | subject.record_attributes = { :id => record.id, :persisted_string => 'updated' } 664 | end.to_not raise_error 665 | 666 | subject.record = nil 667 | expect do 668 | subject.record_attributes = { :id => hidden_record.id, :persisted_string => 'updated' } 669 | end.to raise_error(ActiveType::NestedAttributes::RecordNotFound) 670 | end 671 | 672 | it 'nests_one uses the build_scope to find records' do 673 | subject.record_attributes = {} 674 | expect(subject.record.persisted_string).to eq('buildable') 675 | end 676 | 677 | end 678 | 679 | context 'defaults' do 680 | 681 | subject do 682 | Class.new(ActiveType::Object) do 683 | nests_many :records, :default => proc { [default_record] } 684 | nests_one :record, :default => proc { default_record } 685 | 686 | nests_many :global_records 687 | 688 | nests_many :other_records, :scope => proc { NestedAttributesSpec::Record } 689 | nests_one :other_record, :scope => proc { NestedAttributesSpec::Record } 690 | 691 | nests_many :records_without_default, :default => nil 692 | 693 | def default_record 694 | NestedAttributesSpec::Record.new(:persisted_string => "default") 695 | end 696 | end.new 697 | end 698 | 699 | it 'accepts a :default value' do 700 | expect(subject.records.map(&:persisted_string)).to eq(["default"]) 701 | expect(subject.record.persisted_string).to eq("default") 702 | end 703 | 704 | it 'computes the value lazily' do 705 | allow(subject).to receive_messages :default_record => NestedAttributesSpec::Record.new(:persisted_string => "other default") 706 | expect(subject.records.map(&:persisted_string)).to eq(["other default"]) 707 | expect(subject.record.persisted_string).to eq("other default") 708 | end 709 | 710 | end 711 | 712 | end 713 | 714 | end 715 | 716 | describe "ActiveType::Record" do 717 | 718 | it 'supports nested attributes' do 719 | expect(ActiveType::Record).to respond_to(:nests_one) 720 | expect(ActiveType::Record).to respond_to(:nests_many) 721 | end 722 | 723 | end 724 | 725 | describe "ActiveType::Record" do 726 | 727 | it 'supports nested attributes' do 728 | expect(ActiveType::Record[NestedAttributesSpec::Record]).to respond_to(:nests_one) 729 | expect(ActiveType::Record[NestedAttributesSpec::Record]).to respond_to(:nests_many) 730 | end 731 | 732 | end 733 | --------------------------------------------------------------------------------