From 31387649f30164d08ab9e2e4d5cc56123279784f Mon Sep 17 00:00:00 2001 From: Reid Morrison Date: Thu, 15 Jan 2015 18:10:49 -0500 Subject: [PATCH] Add support for MongoMapper --- .gitignore | 1 + Gemfile | 4 +- gemfiles/rails_3.2.gemfile | 2 + gemfiles/rails_4.0.gemfile | 2 + gemfiles/rails_4.1.gemfile | 2 + gemfiles/rails_4.2.gemfile | 2 + lib/aasm/persistence.rb | 2 + lib/aasm/persistence/base.rb | 3 + .../persistence/mongo_mapper_persistence.rb | 174 ++++++++++++++++++ .../mongo_mapper/no_scope_mongo_mapper.rb | 10 + .../mongo_mapper/simple_mongo_mapper.rb | 11 ++ .../simple_new_dsl_mongo_mapper.rb | 12 ++ .../mongo_mapper_persistance_spec.rb | 135 ++++++++++++++ 13 files changed, 359 insertions(+), 1 deletion(-) create mode 100644 lib/aasm/persistence/mongo_mapper_persistence.rb create mode 100644 spec/models/mongo_mapper/no_scope_mongo_mapper.rb create mode 100644 spec/models/mongo_mapper/simple_mongo_mapper.rb create mode 100644 spec/models/mongo_mapper/simple_new_dsl_mongo_mapper.rb create mode 100644 spec/unit/persistence/mongo_mapper_persistance_spec.rb diff --git a/.gitignore b/.gitignore index 078ecca..fc0948c 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,4 @@ alto .rspec .bundle tags +/nbproject/ diff --git a/Gemfile b/Gemfile index b1821ca..7c09795 100644 --- a/Gemfile +++ b/Gemfile @@ -6,7 +6,9 @@ gem 'rubysl', :platforms => :rbx gem "jruby-openssl", :platforms => :jruby gem "activerecord-jdbcsqlite3-adapter", :platforms => :jruby gem "rails", "~>4.1" -gem 'mongoid' if Gem::Version.create(RUBY_VERSION.dup) >= Gem::Version.create('1.9.3') +#gem 'mongoid' if Gem::Version.create(RUBY_VERSION.dup) >= Gem::Version.create('1.9.3') +gem 'mongo_mapper' +gem 'bson_ext', :platforms => :ruby gem 'sequel' gemspec diff --git a/gemfiles/rails_3.2.gemfile b/gemfiles/rails_3.2.gemfile index 2b087a1..357160c 100644 --- a/gemfiles/rails_3.2.gemfile +++ b/gemfiles/rails_3.2.gemfile @@ -9,5 +9,7 @@ gem "activerecord-jdbcsqlite3-adapter", :platforms => :jruby gem "rails", "3.2.21" gem 'mongoid' if Gem::Version.create(RUBY_VERSION.dup) >= Gem::Version.create('1.9.3') gem 'sequel' +gem 'mongo_mapper' +gem 'bson_ext', :platforms => :ruby gemspec :path => "../" diff --git a/gemfiles/rails_4.0.gemfile b/gemfiles/rails_4.0.gemfile index d9e4e26..ba53d47 100644 --- a/gemfiles/rails_4.0.gemfile +++ b/gemfiles/rails_4.0.gemfile @@ -10,6 +10,8 @@ gem "rails", "4.0.12" # mongoid is not yet compatible with Rails >= 4 # gem 'mongoid' if Gem::Version.create(RUBY_VERSION.dup) >= Gem::Version.create('1.9.3') +gem 'mongo_mapper' +gem 'bson_ext', :platforms => :ruby gem 'sequel' diff --git a/gemfiles/rails_4.1.gemfile b/gemfiles/rails_4.1.gemfile index 97fcee1..fadad9d 100644 --- a/gemfiles/rails_4.1.gemfile +++ b/gemfiles/rails_4.1.gemfile @@ -10,6 +10,8 @@ gem "rails", "4.1.8" # mongoid is not yet compatible with Rails >= 4 # gem 'mongoid' if Gem::Version.create(RUBY_VERSION.dup) >= Gem::Version.create('1.9.3') +gem 'mongo_mapper' +gem 'bson_ext', :platforms => :ruby gem 'sequel' diff --git a/gemfiles/rails_4.2.gemfile b/gemfiles/rails_4.2.gemfile index 23e2e9b..e4b7d71 100644 --- a/gemfiles/rails_4.2.gemfile +++ b/gemfiles/rails_4.2.gemfile @@ -10,6 +10,8 @@ gem "rails", "4.2.0" # mongoid is not yet compatible with Rails >= 4 # gem 'mongoid' if Gem::Version.create(RUBY_VERSION.dup) >= Gem::Version.create('1.9.3') +gem 'mongo_mapper' +gem 'bson_ext', :platforms => :ruby gem 'sequel' diff --git a/lib/aasm/persistence.rb b/lib/aasm/persistence.rb index aef5ff7..3405f28 100644 --- a/lib/aasm/persistence.rb +++ b/lib/aasm/persistence.rb @@ -10,6 +10,8 @@ module AASM include_persistence base, :active_record elsif hierarchy.include?("Mongoid::Document") include_persistence base, :mongoid + elsif hierarchy.include?("MongoMapper::Document") + include_persistence base, :mongo_mapper elsif hierarchy.include?("Sequel::Model") include_persistence base, :sequel else diff --git a/lib/aasm/persistence/base.rb b/lib/aasm/persistence/base.rb index 30240a9..d019fa4 100644 --- a/lib/aasm/persistence/base.rb +++ b/lib/aasm/persistence/base.rb @@ -71,6 +71,9 @@ module AASM elsif @klass.ancestors.map {|klass| klass.to_s}.include?("Mongoid::Document") scope_options = lambda { @klass.send(:where, {@klass.aasm.attribute_name.to_sym => name.to_s}) } @klass.send(:scope, name, scope_options) + elsif @klass.ancestors.map {|klass| klass.to_s}.include?("MongoMapper::Document") + conditions = { @klass.aasm.attribute_name.to_sym => name.to_s } + @klass.scope(name, lambda { @klass.where(conditions) }) end end end diff --git a/lib/aasm/persistence/mongo_mapper_persistence.rb b/lib/aasm/persistence/mongo_mapper_persistence.rb new file mode 100644 index 0000000..8737aa0 --- /dev/null +++ b/lib/aasm/persistence/mongo_mapper_persistence.rb @@ -0,0 +1,174 @@ +require_relative 'base' + +module AASM + module Persistence + module MongoMapperPersistence + # This method: + # + # * extends the model with ClassMethods + # * includes InstanceMethods + # + # Adds + # + # before_validation :aasm_ensure_initial_state, :on => :create + # + # As a result, it doesn't matter when you define your methods - the following 2 are equivalent + # + # class Foo + # include MongoMapper::Document + # def aasm_write_state(state) + # "bar" + # end + # include AASM + # end + # + # class Foo < ActiveRecord::Base + # include MongoMapper::Document + # include AASM + # def aasm_write_state(state) + # "bar" + # end + # end + # + def self.included(base) + base.send(:include, AASM::Persistence::Base) + base.extend AASM::Persistence::MongoMapperPersistence::ClassMethods + base.send(:include, AASM::Persistence::MongoMapperPersistence::InstanceMethods) + + base.before_create :aasm_ensure_initial_state + + # ensure state is in the list of states + base.validate :aasm_validate_states + end + + module ClassMethods + + def find_in_state(number, state, *args) + with_state_scope(state).find!(number, *args) + end + + def count_in_state(state, *args) + with_state_scope(state).count(*args) + end + + def calculate_in_state(state, *args) + with_state_scope(state).calculate(*args) + end + + protected + def with_state_scope(state) + where(aasm.attribute_name.to_sym => state.to_s) + end + end + + module InstanceMethods + + # Writes state to the state column and persists it to the database + # + # foo = Foo.find(1) + # foo.aasm.current_state # => :opened + # foo.close! + # foo.aasm.current_state # => :closed + # Foo.find(1).aasm.current_state # => :closed + # + # NOTE: intended to be called from an event + def aasm_write_state(state) + old_value = read_attribute(self.class.aasm.attribute_name) + aasm_write_attribute state + + success = if aasm_skipping_validations + value = aasm_raw_attribute_value state + self.class.where(self.class.primary_key => self.id).update_all(self.class.aasm.attribute_name => value) == 1 + else + self.save + end + unless success + write_attribute(self.class.aasm.attribute_name, old_value) + return false + end + + true + end + + # Writes state to the state column, but does not persist it to the database + # + # foo = Foo.find(1) + # foo.aasm.current_state # => :opened + # foo.close + # foo.aasm.current_state # => :closed + # Foo.find(1).aasm.current_state # => :opened + # foo.save + # foo.aasm.current_state # => :closed + # Foo.find(1).aasm.current_state # => :closed + # + # NOTE: intended to be called from an event + def aasm_write_state_without_persistence(state) + aasm_write_attribute state + end + + private + def aasm_enum + case AASM::StateMachine[self.class].config.enum + when false then nil + when true then aasm_guess_enum_method + when nil then aasm_guess_enum_method if aasm_column_looks_like_enum + else AASM::StateMachine[self.class].config.enum + end + end + + def aasm_column_looks_like_enum + self.class.keys[self.class.aasm.attribute_name.to_s].type == Integer + end + + def aasm_guess_enum_method + self.class.aasm.attribute_name.to_s.pluralize.to_sym + end + + def aasm_skipping_validations + AASM::StateMachine[self.class].config.skip_validation_on_save + end + + def aasm_write_attribute(state) + write_attribute self.class.aasm.attribute_name, aasm_raw_attribute_value(state) + end + + def aasm_raw_attribute_value(state) + if aasm_enum + self.class.send(aasm_enum)[state] + else + state.to_s + end + end + + # Ensures that if the aasm_state column is nil and the record is new + # that the initial state gets populated before validation on create + # + # foo = Foo.new + # foo.aasm_state # => nil + # foo.valid? + # foo.aasm_state # => "open" (where :open is the initial state) + # + # + # foo = Foo.find(:first) + # foo.aasm_state # => 1 + # foo.aasm_state = nil + # foo.valid? + # foo.aasm_state # => nil + # + def aasm_ensure_initial_state + return send("#{self.class.aasm.attribute_name}=", aasm.enter_initial_state.to_s) if send(self.class.aasm.attribute_name).blank? + end + + def aasm_validate_states + send("#{self.class.aasm.attribute_name}=", aasm.enter_initial_state.to_s) if send(self.class.aasm.attribute_name).blank? + unless AASM::StateMachine[self.class].config.skip_validation_on_save + if aasm.current_state && !aasm.states.include?(aasm.current_state) + self.errors.add(AASM::StateMachine[self.class].config.column , "is invalid") + end + end + end + end # InstanceMethods + + end + end # Persistence +end # AASM diff --git a/spec/models/mongo_mapper/no_scope_mongo_mapper.rb b/spec/models/mongo_mapper/no_scope_mongo_mapper.rb new file mode 100644 index 0000000..7d7454a --- /dev/null +++ b/spec/models/mongo_mapper/no_scope_mongo_mapper.rb @@ -0,0 +1,10 @@ +class NoScopeMongoMapper + include MongoMapper::Document + include AASM + + key :status, String + + aasm :create_scopes => false, :column => :status do + state :ignored_scope + end +end diff --git a/spec/models/mongo_mapper/simple_mongo_mapper.rb b/spec/models/mongo_mapper/simple_mongo_mapper.rb new file mode 100644 index 0000000..0df4f14 --- /dev/null +++ b/spec/models/mongo_mapper/simple_mongo_mapper.rb @@ -0,0 +1,11 @@ +class SimpleMongoMapper + include MongoMapper::Document + include AASM + + key :status, String + + aasm column: :status do + state :unknown_scope + state :next + end +end diff --git a/spec/models/mongo_mapper/simple_new_dsl_mongo_mapper.rb b/spec/models/mongo_mapper/simple_new_dsl_mongo_mapper.rb new file mode 100644 index 0000000..5d047a2 --- /dev/null +++ b/spec/models/mongo_mapper/simple_new_dsl_mongo_mapper.rb @@ -0,0 +1,12 @@ +class SimpleNewDslMongoMapper + include MongoMapper::Document + include AASM + + key :status, String + + aasm :column => :status + aasm do + state :unknown_scope + state :next + end +end diff --git a/spec/unit/persistence/mongo_mapper_persistance_spec.rb b/spec/unit/persistence/mongo_mapper_persistance_spec.rb new file mode 100644 index 0000000..d7ce651 --- /dev/null +++ b/spec/unit/persistence/mongo_mapper_persistance_spec.rb @@ -0,0 +1,135 @@ +describe 'mongo_mapper' do + begin + require 'mongo_mapper' + require 'logger' + require 'spec_helper' + + before(:all) do + Dir[File.dirname(__FILE__) + "/../../models/mongo_mapper/*.rb"].sort.each { |f| require File.expand_path(f) } + + config = { + 'test' => { + 'database' => "mongo_mapper_#{Process.pid}" + } + } + + MongoMapper.setup(config, 'test') #, :logger => Logger.new(STDERR)) + end + + after do + # Clear Out all non-system Mongo collections between tests + MongoMapper.database.collections.each do |collection| + collection.drop unless collection.capped? || (collection.name =~ /\Asystem/) + end + end + + describe "named scopes with the old DSL" do + + context "Does not already respond_to? the scope name" do + it "should add a scope" do + expect(SimpleMongoMapper).to respond_to(:unknown_scope) + expect(SimpleMongoMapper.unknown_scope.class).to eq(MongoMapper::Plugins::Querying::DecoratedPluckyQuery) + #expect(SimpleMongoMapper.unknown_scope.is_a?(ActiveRecord::Relation)).to be_truthy + end + end + + context "Already respond_to? the scope name" do + it "should not add a scope" do + expect(SimpleMongoMapper).to respond_to(:next) + expect(SimpleMongoMapper.new.class).to eq(SimpleMongoMapper) + end + end + + end + + describe "named scopes with the new DSL" do + + context "Does not already respond_to? the scope name" do + it "should add a scope" do + expect(SimpleNewDslMongoMapper).to respond_to(:unknown_scope) + expect(SimpleNewDslMongoMapper.unknown_scope.class).to eq(MongoMapper::Plugins::Querying::DecoratedPluckyQuery) + end + end + + context "Already respond_to? the scope name" do + it "should not add a scope" do + expect(SimpleNewDslMongoMapper).to respond_to(:next) + expect(SimpleNewDslMongoMapper.new.class).to eq(SimpleNewDslMongoMapper) + end + end + + it "does not create scopes if requested" do + expect(NoScopeMongoMapper).not_to respond_to(:ignored_scope) + end + + end + + describe "#find_in_state" do + + let!(:model) { SimpleNewDslMongoMapper.create!(:status => :unknown_scope) } + let!(:model_id) { model._id } + + it "should respond to method" do + expect(SimpleNewDslMongoMapper).to respond_to(:find_in_state) + end + + it "should find the model when given the correct scope and model id" do + expect(SimpleNewDslMongoMapper.find_in_state(model_id, 'unknown_scope').class).to eq(SimpleNewDslMongoMapper) + expect(SimpleNewDslMongoMapper.find_in_state(model_id, 'unknown_scope')).to eq(model) + end + + it "should raise DocumentNotFound error when given incorrect scope" do + expect {SimpleNewDslMongoMapper.find_in_state(model_id, 'next')}.to raise_error MongoMapper::DocumentNotFound + end + + it "should raise DocumentNotFound error when given incorrect model id" do + expect {SimpleNewDslMongoMapper.find_in_state('bad_id', 'unknown_scope')}.to raise_error MongoMapper::DocumentNotFound + end + + end + + describe "#count_in_state" do + + before do + 3.times { SimpleNewDslMongoMapper.create!(:status => :unknown_scope) } + end + + it "should respond to method" do + expect(SimpleNewDslMongoMapper).to respond_to(:count_in_state) + end + + it "should return n for a scope with n records persisted" do + expect(SimpleNewDslMongoMapper.count_in_state('unknown_scope').class).to eq(Fixnum) + expect(SimpleNewDslMongoMapper.count_in_state('unknown_scope')).to eq(3) + end + + it "should return zero for a scope without records persisted" do + expect(SimpleNewDslMongoMapper.count_in_state('next').class).to eq(Fixnum) + expect(SimpleNewDslMongoMapper.count_in_state('next')).to eq(0) + end + + end + + describe "instance methods" do + + let(:simple) {SimpleNewDslMongoMapper.new} + + it "should call aasm_ensure_initial_state on validation before create" do + expect(SimpleNewDslMongoMapper.aasm.initial_state).to eq(:unknown_scope) + expect(SimpleNewDslMongoMapper.aasm.attribute_name).to eq(:status) + expect(simple.status).to eq(nil) + simple.valid? + expect(simple.status).to eq('unknown_scope') + end + + it "should call aasm_ensure_initial_state before create, even if skipping validations" do + expect(simple.status).to eq(nil) + simple.save(:validate => false) + expect(simple.status).to eq('unknown_scope') + end + end + + rescue LoadError + puts "Not running MongoMapper specs because mongo_mapper gem is not installed!!!" + end +end