├── .rspec ├── UPGRADES ├── lib ├── plucky │ ├── version.rb │ ├── extensions.rb │ ├── pagination.rb │ ├── new_relic.rb │ ├── transformer.rb │ ├── normalizers │ │ ├── hash_key.rb │ │ ├── integer.rb │ │ ├── criteria_hash_key.rb │ │ ├── fields_value.rb │ │ ├── sort_value.rb │ │ ├── criteria_hash_value.rb │ │ └── options_hash_value.rb │ ├── pagination │ │ ├── collection.rb │ │ └── paginator.rb │ ├── extensions │ │ ├── symbol.rb │ │ └── duplicable.rb │ ├── options_hash.rb │ ├── criteria_hash.rb │ └── query.rb └── plucky.rb ├── .gitignore ├── Rakefile ├── spec ├── symbol_spec.rb ├── plucky │ ├── normalizers │ │ ├── hash_key_spec.rb │ │ ├── integer_spec.rb │ │ ├── criteria_hash_key_spec.rb │ │ ├── fields_value_spec.rb │ │ ├── options_hash_value_spec.rb │ │ ├── sort_value_spec.rb │ │ └── criteria_hash_value_spec.rb │ ├── pagination │ │ ├── collection_spec.rb │ │ └── paginator_spec.rb │ ├── options_hash_spec.rb │ ├── criteria_hash_spec.rb │ └── query_spec.rb ├── functional │ └── options_hash_spec.rb ├── helper.rb ├── plucky_spec.rb └── symbol_operator_spec.rb ├── Guardfile ├── script ├── test ├── bootstrap └── release ├── CHANGELOG.md ├── Gemfile ├── plucky.gemspec ├── specs.watchr ├── LICENSE ├── README.md ├── .github └── workflows │ └── ruby.yml └── examples └── query.rb /.rspec: -------------------------------------------------------------------------------- 1 | --color -------------------------------------------------------------------------------- /UPGRADES: -------------------------------------------------------------------------------- 1 | Apr 23, 2011 0.3.8 => 0.4 2 | * Query#update was renamed to #amend. -------------------------------------------------------------------------------- /lib/plucky/version.rb: -------------------------------------------------------------------------------- 1 | # encoding: UTF-8 2 | module Plucky 3 | Version = '0.8.0' 4 | end 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.project 2 | log 3 | *.gem 4 | .byebug_history 5 | .ruby-version 6 | Gemfile.lock 7 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'rspec/core/rake_task' 2 | RSpec::Core::RakeTask.new 3 | 4 | task :default => :spec 5 | -------------------------------------------------------------------------------- /lib/plucky/extensions.rb: -------------------------------------------------------------------------------- 1 | # encoding: UTF-8 2 | require 'plucky/extensions/duplicable' 3 | require 'plucky/extensions/symbol' -------------------------------------------------------------------------------- /lib/plucky/pagination.rb: -------------------------------------------------------------------------------- 1 | require 'plucky/pagination/collection' 2 | require 'plucky/pagination/paginator' 3 | 4 | module Plucky 5 | module Pagination; end 6 | end 7 | -------------------------------------------------------------------------------- /lib/plucky/new_relic.rb: -------------------------------------------------------------------------------- 1 | if defined?(NewRelic) 2 | Plucky::Query.class_eval do 3 | include NewRelic::Agent::MethodTracer 4 | 5 | Plucky::Methods.each do |method_name| 6 | add_method_tracer(method_name.to_sym) 7 | end 8 | end 9 | end -------------------------------------------------------------------------------- /spec/symbol_spec.rb: -------------------------------------------------------------------------------- 1 | require 'helper' 2 | 3 | describe Symbol do 4 | SymbolOperators.each do |operator| 5 | it "responds to #{operator}" do 6 | :foo.send(operator).should be_instance_of(SymbolOperator) 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/plucky/transformer.rb: -------------------------------------------------------------------------------- 1 | module Plucky 2 | class Transformer 3 | def initialize(view, transformer) 4 | @view = view 5 | @transformer = transformer 6 | end 7 | 8 | def each 9 | @view.each do |doc| 10 | yield @transformer.call(doc) 11 | end 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /Guardfile: -------------------------------------------------------------------------------- 1 | # A sample Guardfile 2 | # More info at https://github.com/guard/guard#readme 3 | 4 | guard 'bundler' do 5 | watch('Gemfile') 6 | watch(/^.+\.gemspec/) 7 | end 8 | 9 | guard 'rspec' do 10 | watch(%r{^spec/.+_spec\.rb$}) 11 | watch(%r{^lib/(.+)\.rb$}) { |m| "spec/#{m[1]}_spec.rb" } 12 | watch('spec/helper.rb') { "spec" } 13 | end 14 | -------------------------------------------------------------------------------- /script/test: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | #/ Usage: test 3 | #/ 4 | #/ Bootstrap and run all tests. 5 | #/ 6 | #/ Examples: 7 | #/ 8 | #/ # run all tests 9 | #/ test 10 | #/ 11 | 12 | set -e 13 | cd $(dirname "$0")/.. 14 | 15 | [ "$1" = "--help" -o "$1" = "-h" -o "$1" = "help" ] && { 16 | grep '^#/' <"$0"| cut -c4- 17 | exit 0 18 | } 19 | 20 | script/bootstrap && bundle exec rake 21 | -------------------------------------------------------------------------------- /spec/plucky/normalizers/hash_key_spec.rb: -------------------------------------------------------------------------------- 1 | require 'helper' 2 | 3 | describe Plucky::Normalizers::HashKey do 4 | subject { 5 | described_class.new(:bacon => :sizzle) 6 | } 7 | 8 | it "changes defined fields" do 9 | subject.call(:bacon).should eq(:sizzle) 10 | end 11 | 12 | it "does not change undefined fields" do 13 | subject.call(:sausage).should eq(:sausage) 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/plucky/normalizers/hash_key.rb: -------------------------------------------------------------------------------- 1 | module Plucky 2 | module Normalizers 3 | class HashKey 4 | 5 | def initialize(keys) 6 | @keys = keys 7 | end 8 | 9 | # Public: Normalizes an options hash key 10 | # 11 | # key - The key to normalize 12 | # 13 | # Returns a Symbol. 14 | def call(key) 15 | @keys.fetch key.to_sym, key 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/plucky/normalizers/integer.rb: -------------------------------------------------------------------------------- 1 | module Plucky 2 | module Normalizers 3 | class Integer 4 | 5 | # Public: Returns value coerced to integer or nil 6 | # 7 | # value - The value to normalize to an integer 8 | # 9 | # Returns an Integer or nil 10 | def call(value) 11 | if value.nil? 12 | nil 13 | else 14 | value.to_i 15 | end 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /script/bootstrap: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | #/ Usage: bootstrap [bundle options] 3 | #/ 4 | #/ Bundle install the dependencies. 5 | #/ 6 | #/ Examples: 7 | #/ 8 | #/ bootstrap 9 | #/ bootstrap --local 10 | #/ 11 | 12 | set -e 13 | cd $(dirname "$0")/.. 14 | 15 | [ "$1" = "--help" -o "$1" = "-h" -o "$1" = "help" ] && { 16 | grep '^#/' <"$0"| cut -c4- 17 | exit 0 18 | } 19 | 20 | rm -rf .bundle/{binstubs,config} 21 | bundle install --binstubs .bundle/binstubs --path .bundle --quiet "$@" 22 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 7 | 8 | ## Unreleased 9 | 10 | ### Enhancements: 11 | 12 | - PR-52 Masato Ikeda Add MongoDB 6.0 and 7.0 to CI 13 | - PR-53 Masato Ikeda Add Ruby 3.2 to CI matrix 14 | - PR-54 Masato Ikeda Add Ruby 3.3 to CI matrix 15 | - PR-57 Masato Ikeda Add Ruby 3.4 to CI matrix 16 | - PR-59 Masato Ikeda Add MongoDB 8.0 to CI matrix 17 | -------------------------------------------------------------------------------- /lib/plucky/normalizers/criteria_hash_key.rb: -------------------------------------------------------------------------------- 1 | module Plucky 2 | module Normalizers 3 | class CriteriaHashKey 4 | # Public: Returns key normalized for Mongo 5 | # 6 | # key - The key to normalize 7 | # 8 | # Returns key as Symbol if possible, else key with no changes 9 | def call(key) 10 | key = key.to_sym if key.respond_to?(:to_sym) 11 | return call(key.field) if key.respond_to?(:field) 12 | return :_id if key == :id 13 | key 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | gemspec 3 | 4 | gem 'rake' 5 | 6 | if RUBY_VERSION >= '3.4' 7 | # the activesupport gem depends on the bigdecimal gem, which has been extracted as a bundled gem since Ruby 3.4. 8 | gem 'bigdecimal' 9 | end 10 | 11 | group(:test) do 12 | gem 'rspec' 13 | gem 'log_buddy' 14 | 15 | if RUBY_ENGINE == "ruby" && RUBY_VERSION >= '2.3' 16 | platforms :mri do 17 | gem 'byebug' 18 | end 19 | end 20 | end 21 | 22 | group(:guard) do 23 | gem 'guard' 24 | gem 'guard-rspec' 25 | gem 'guard-bundler' 26 | gem 'rb-fsevent' 27 | end 28 | -------------------------------------------------------------------------------- /spec/plucky/normalizers/integer_spec.rb: -------------------------------------------------------------------------------- 1 | require 'helper' 2 | require 'plucky/normalizers/integer' 3 | 4 | describe Plucky::Normalizers::Integer do 5 | context "with nil" do 6 | it "returns nil" do 7 | subject.call(nil).should be_nil 8 | end 9 | end 10 | 11 | context "with an integer" do 12 | it "returns an integer" do 13 | subject.call(1).should be(1) 14 | subject.call(3232).should be(3232) 15 | end 16 | end 17 | 18 | context "with a string" do 19 | it "returns a string" do 20 | subject.call('1').should be(1) 21 | subject.call('3232').should be(3232) 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/plucky/normalizers/fields_value.rb: -------------------------------------------------------------------------------- 1 | module Plucky 2 | module Normalizers 3 | class FieldsValue 4 | 5 | # Public: Given a value returns it normalized for Mongo's fields option 6 | def call(value) 7 | return nil if value.respond_to?(:empty?) && value.empty? 8 | 9 | case value 10 | when Array 11 | if value.size == 1 && value.first.is_a?(Hash) 12 | value.first 13 | else 14 | value.flatten.inject({}) {|acc, field| acc.merge(field => 1)} 15 | end 16 | when Symbol 17 | {value => 1} 18 | when String 19 | value.split(',').inject({}) { |acc, v| acc.merge(v.strip => 1) } 20 | else 21 | value 22 | end 23 | end 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/plucky/pagination/collection.rb: -------------------------------------------------------------------------------- 1 | require 'forwardable' 2 | module Plucky 3 | module Pagination 4 | class Collection < Array 5 | extend Forwardable 6 | 7 | def_delegators :@paginator, 8 | :total_entries, :total_pages, 9 | :current_page, :per_page, 10 | :previous_page, :next_page, 11 | :skip, :limit, 12 | :offset, :out_of_bounds? 13 | 14 | def initialize(records, paginator) 15 | replace records 16 | @paginator = paginator 17 | end 18 | 19 | def method_missing(method, *args) 20 | @paginator.send method, *args 21 | end 22 | 23 | # Public 24 | def paginator(p=nil) 25 | return @paginator if p.nil? 26 | @paginator = p 27 | self 28 | end 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /plucky.gemspec: -------------------------------------------------------------------------------- 1 | # encoding: UTF-8 2 | require File.expand_path('../lib/plucky/version', __FILE__) 3 | 4 | Gem::Specification.new do |s| 5 | s.name = 'plucky' 6 | s.homepage = 'http://github.com/mongomapper/plucky' 7 | s.summary = 'Thin layer over the ruby driver that allows you to quickly grab hold of your data (pluck it!).' 8 | s.require_path = 'lib' 9 | s.authors = ['John Nunemaker', 'Chris Heald', 'Scott Taylor'] 10 | s.email = ['nunemaker@gmail.com', 'cheald@gmail.com', 'scott@railsnewbie.com'] 11 | s.version = Plucky::Version 12 | s.platform = Gem::Platform::RUBY 13 | 14 | s.files = `git ls-files`.split("\n") 15 | s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n") 16 | s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) } 17 | s.require_paths = ["lib"] 18 | 19 | s.add_dependency 'mongo', '~> 2.0' 20 | end 21 | -------------------------------------------------------------------------------- /spec/plucky/normalizers/criteria_hash_key_spec.rb: -------------------------------------------------------------------------------- 1 | require 'helper' 2 | 3 | describe Plucky::Normalizers::CriteriaHashKey do 4 | subject { 5 | described_class.new 6 | } 7 | 8 | context "with a string" do 9 | it "returns symbol" do 10 | subject.call('foo').should eq(:foo) 11 | end 12 | end 13 | 14 | context "with a symbol" do 15 | it "returns symbol" do 16 | subject.call(:foo).should eq(:foo) 17 | end 18 | end 19 | 20 | context "with :id" do 21 | it "returns :_id" do 22 | subject.call(:id).should eq(:_id) 23 | end 24 | end 25 | 26 | it "returns key if something weird" do 27 | subject.call(['crazytown']).should eq(['crazytown']) 28 | end 29 | 30 | SymbolOperators.each do |operator| 31 | context "with #{operator} symbol operator" do 32 | it "returns field" do 33 | subject.call(:age.send(operator)).should eq(:age) 34 | end 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /script/release: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | #/ Usage: release 3 | #/ 4 | #/ Tag the version in the repo and push the gem. 5 | #/ 6 | 7 | set -e 8 | cd $(dirname "$0")/.. 9 | 10 | [ "$1" = "--help" -o "$1" = "-h" -o "$1" = "help" ] && { 11 | grep '^#/' <"$0"| cut -c4- 12 | exit 0 13 | } 14 | 15 | gem_name=plucky 16 | 17 | # Build a new gem archive. 18 | rm -rf $gem_name-*.gem 19 | gem build -q $gem_name.gemspec 20 | 21 | # Make sure we're on the master branch. 22 | (git branch | grep -q '* master') || { 23 | echo "Only release from the master branch." 24 | exit 1 25 | } 26 | 27 | # Figure out what version we're releasing. 28 | tag=v`ls $gem_name-*.gem | sed "s/^$gem_name-\(.*\)\.gem$/\1/"` 29 | 30 | echo "Releasing $tag" 31 | 32 | # Make sure we haven't released this version before. 33 | git fetch -t origin 34 | 35 | (git tag -l | grep -q "$tag") && { 36 | echo "Whoops, there's already a '${tag}' tag." 37 | exit 1 38 | } 39 | 40 | # Tag it and bag it. 41 | gem push $gem_name-*.gem && git tag "$tag" && 42 | git push origin master && git push origin "$tag" 43 | -------------------------------------------------------------------------------- /specs.watchr: -------------------------------------------------------------------------------- 1 | def run(cmd) 2 | puts(cmd) 3 | output = "" 4 | IO.popen(cmd) do |com| 5 | com.each_char do |c| 6 | print c 7 | output << c 8 | $stdout.flush 9 | end 10 | end 11 | end 12 | 13 | def run_test_file(file) 14 | run %Q(ruby -I"lib:test" -rubygems #{file}) 15 | end 16 | 17 | def run_all_tests 18 | run "rake test" 19 | end 20 | 21 | def related_test_files(path) 22 | Dir['test/**/*.rb'].select { |file| file =~ /test_#{File.basename(path)}/ } 23 | end 24 | 25 | # watch('.*\.rb') { system('clear'); run_all_tests } 26 | watch('test/helper\.rb') { system('clear'); run_all_tests } 27 | watch('test/.*test_.*\.rb') { |m| system('clear'); run_test_file(m[0]) } 28 | watch('lib/.*') { |m| related_test_files(m[0]).each { |file| run_test_file(file) } } 29 | 30 | watch('examples/.*\.rb') { |m| system('clear'); run "bundle exec ruby #{m[0]}" } 31 | 32 | # Ctrl-\ 33 | Signal.trap('QUIT') do 34 | puts " --- Running all tests ---\n\n" 35 | run_all_tests 36 | end 37 | 38 | # Ctrl-C 39 | Signal.trap('INT') { abort("\n") } 40 | 41 | -------------------------------------------------------------------------------- /lib/plucky.rb: -------------------------------------------------------------------------------- 1 | # encoding: UTF-8 2 | require 'set' 3 | require 'mongo' 4 | require 'plucky/extensions' 5 | require 'plucky/criteria_hash' 6 | require 'plucky/options_hash' 7 | require 'plucky/query' 8 | require 'plucky/transformer' 9 | require 'plucky/pagination' 10 | 11 | module Plucky 12 | autoload :Version, 'plucky/version' 13 | 14 | # Array of finder DSL methods to delegate 15 | Methods = Plucky::Query::DSL.instance_methods.sort.map(&:to_sym) 16 | 17 | # Public: Converts value to object id if possible 18 | # 19 | # value - The value to attempt converation of 20 | # 21 | # Returns BSON::ObjectId or value 22 | def self.to_object_id(value) 23 | return value if value.is_a?(BSON::ObjectId) 24 | return nil if value.nil? || (value.respond_to?(:empty?) && value.empty?) 25 | 26 | if BSON::ObjectId.legal?(value.to_s) 27 | BSON::ObjectId.from_string(value.to_s) 28 | else 29 | value 30 | end 31 | end 32 | 33 | # Private 34 | ModifierString = '$' 35 | 36 | # Internal 37 | def self.modifier?(key) 38 | key.to_s[0, 1] == ModifierString 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/plucky/pagination/paginator.rb: -------------------------------------------------------------------------------- 1 | # encoding: UTF-8 2 | module Plucky 3 | module Pagination 4 | class Paginator 5 | attr_reader :total_entries, :current_page, :per_page 6 | 7 | # Public 8 | def initialize(total, page, per_page=nil) 9 | @total_entries = total.to_i 10 | @current_page = [page.to_i, 1].max 11 | @per_page = (per_page || 25).to_i 12 | end 13 | 14 | # Public 15 | def total_pages 16 | (@total_entries / @per_page.to_f).ceil 17 | end 18 | 19 | # Public 20 | def out_of_bounds? 21 | @current_page > total_pages 22 | end 23 | 24 | # Public 25 | def previous_page 26 | @current_page > 1 ? (@current_page - 1) : nil 27 | end 28 | 29 | # Public 30 | def next_page 31 | @current_page < total_pages ? (@current_page + 1) : nil 32 | end 33 | 34 | # Public 35 | def skip 36 | (@current_page - 1) * @per_page 37 | end 38 | 39 | # Public 40 | alias :limit :per_page 41 | 42 | # Public 43 | alias :offset :skip 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2010-2020 MongoMapper: John Nunemaker, Chris Heald, Scott Taylor 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /spec/functional/options_hash_spec.rb: -------------------------------------------------------------------------------- 1 | require 'helper' 2 | 3 | describe Plucky::OptionsHash do 4 | subject { described_class.new } 5 | 6 | describe "#[]=" do 7 | it "changes order to sort" do 8 | subject[:order] = "foo asc" 9 | subject[:sort].should == {"foo" => 1} 10 | subject[:order].should be_nil 11 | end 12 | 13 | it "changes sort(id) to sort(_id)" do 14 | subject[:sort] = "id asc" 15 | subject[:sort].should == {"_id" => 1} 16 | end 17 | 18 | it "changes select to fields" do 19 | subject[:select] = [:foo] 20 | subject[:projection].should == {:foo => 1} 21 | subject[:select].should be_nil 22 | end 23 | 24 | it "changes offset to skip" do 25 | subject[:offset] = 10 26 | subject[:skip].should == 10 27 | subject[:offset].should be_nil 28 | end 29 | 30 | it "changes id to _id" do 31 | subject[:id] = :foo 32 | subject[:_id].should == :foo 33 | subject[:id].should be_nil 34 | end 35 | 36 | it "does not change the sort field" do 37 | subject[:order] = :order.asc 38 | subject[:sort].should == {"order" => 1} 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Plucky 2 | 3 | A thin layer over the ruby driver that allows you to quickly grab hold of your data (pluck it!). 4 | 5 | Used as a query selector inside of MongoMapper. 6 | 7 | ## Install 8 | 9 | ``` 10 | gem install plucky 11 | ``` 12 | 13 | ## Examples 14 | 15 | See `examples/query.rb`. 16 | 17 | ## Notes 18 | 19 | - if you are using Ruby >= 3.4 and Rails <= 7.0, you'll need to add the bigdecimal gem to your Gemfile 20 | 21 | ## Help 22 | 23 | https://groups.google.com/forum/#!forum/mongomapper 24 | 25 | ## Contributing 26 | 27 | * Fork the project. 28 | * Make your feature addition or bug fix. 29 | * Add tests for it. This is important so I don't break it in a future version unintentionally. 30 | * Commit, do not mess with rakefile, version, or history. (if you want to have your own version, that is fine but bump version in a commit by itself I can ignore when I pull) 31 | * Send me a pull request. Bonus points for topic branches. 32 | 33 | ## Copyright 34 | 35 | Copyright (c) 2010-2020 MongoMapper. See LICENSE for details. 36 | 37 | ## Contributors 38 | 39 | MongoMapper/Plucky is: 40 | 41 | * John Nunemaker 42 | * Chris Heald 43 | * Scott Taylor 44 | 45 | With contributions from: 46 | 47 | * Frederick Cheung 48 | -------------------------------------------------------------------------------- /spec/plucky/pagination/collection_spec.rb: -------------------------------------------------------------------------------- 1 | require 'helper' 2 | 3 | describe Plucky::Pagination::Collection do 4 | context "Object decorated with Collection with paginator set" do 5 | before do 6 | @object = [1, 2, 3, 4] 7 | @object_id = @object.object_id 8 | @paginator = Plucky::Pagination::Paginator.new(20, 2, 10) 9 | end 10 | subject { Plucky::Pagination::Collection.new(@object, @paginator) } 11 | 12 | it "knows paginator" do 13 | subject.paginator.should == @paginator 14 | end 15 | 16 | [:total_entries, :current_page, :per_page, :total_pages, :out_of_bounds?, 17 | :previous_page, :next_page, :skip, :limit, :offset].each do |method| 18 | it "delegates #{method} to paginator" do 19 | subject.send(method).should == @paginator.send(method) 20 | end 21 | end 22 | 23 | it "does not interfere with other methods on the object" do 24 | @object.object_id.should == @object_id 25 | @object.should == [1, 2, 3, 4] 26 | @object.size.should == 4 27 | @object.select { |o| o > 2 }.should == [3, 4] 28 | end 29 | 30 | it "delegates missing methods to the paginator" do 31 | @paginator.should_receive(:blather_matter).with('hello', :xyz, 4) 32 | subject.blather_matter('hello', :xyz, 4) 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /spec/plucky/normalizers/fields_value_spec.rb: -------------------------------------------------------------------------------- 1 | require 'helper' 2 | require 'plucky/normalizers/fields_value' 3 | 4 | describe Plucky::Normalizers::FieldsValue do 5 | it "defaults to nil" do 6 | subject.call(nil).should be_nil 7 | end 8 | 9 | it "returns nil if empty string" do 10 | subject.call('').should be_nil 11 | end 12 | 13 | it "returns nil if empty array" do 14 | subject.call([]).should be_nil 15 | end 16 | 17 | it "works with array" do 18 | subject.call(['one', 'two']).should eq({'one' => 1, 'two' => 1}) 19 | end 20 | 21 | # Ruby 1.9.x was sending array [{:age => 20}], instead of hash. 22 | it "works with array that has one hash" do 23 | subject.call([{:age => 20}]).should eq({:age => 20}) 24 | end 25 | 26 | it "flattens multi-dimensional array" do 27 | subject.call([[:one, :two]]).should eq({:one => 1, :two => 1}) 28 | end 29 | 30 | it "works with symbol" do 31 | subject.call(:one).should eq({:one => 1}) 32 | end 33 | 34 | it "works with array of symbols" do 35 | subject.call([:one, :two]).should eq({:one => 1, :two => 1}) 36 | end 37 | 38 | it "works with hash" do 39 | subject.call({:one => 1, :two => -1}).should eq({:one => 1, :two => -1}) 40 | end 41 | 42 | it "converts comma separated list to array" do 43 | subject.call('one, two').should eq({'one' => 1, 'two' => 1}) 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /.github/workflows/ruby.yml: -------------------------------------------------------------------------------- 1 | name: Ruby 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | fail-fast: false 14 | matrix: 15 | ruby-version: 16 | - jruby-9.4 17 | - 2.4 18 | - 2.5 19 | - 2.6 20 | - 2.7 21 | - 3.0 22 | - 3.1 23 | - 3.2 24 | - 3.3 25 | - 3.4 26 | mongo-image: 27 | - mongo:4.4 28 | include: 29 | - { "ruby-version": 3.3, "mongo-image": "mongo:4.2" } 30 | - { "ruby-version": 3.3, "mongo-image": "mongo:5.0" } 31 | - { "ruby-version": 3.3, "mongo-image": "mongo:6.0" } 32 | - { "ruby-version": 3.3, "mongo-image": "mongo:7.0" } 33 | - { "ruby-version": 3.3, "mongo-image": "mongo:8.0" } 34 | services: 35 | mongo: 36 | image: ${{ matrix.mongo-image }} 37 | ports: 38 | - 27017:27017 39 | steps: 40 | - name: Git checkout 41 | uses: actions/checkout@v2 42 | - name: Set up Ruby 43 | uses: ruby/setup-ruby@v1 44 | with: 45 | ruby-version: "${{ matrix.ruby-version }}" 46 | - name: Install dependencies 47 | run: "bundle install --without guard" 48 | - name: Run tests 49 | run: "bundle exec rake" 50 | -------------------------------------------------------------------------------- /spec/helper.rb: -------------------------------------------------------------------------------- 1 | $:.unshift(File.expand_path('../../lib', __FILE__)) 2 | 3 | require 'rubygems' 4 | require 'bundler' 5 | 6 | Bundler.require(:default, :test) 7 | 8 | require 'plucky' 9 | 10 | require 'fileutils' 11 | require 'logger' 12 | require 'pp' 13 | 14 | if RUBY_ENGINE == "ruby" && RUBY_VERSION >= '2.3' 15 | require 'byebug' 16 | end 17 | 18 | log_dir = File.expand_path('../../log', __FILE__) 19 | FileUtils.mkdir_p(log_dir) 20 | Log = Logger.new(File.join(log_dir, 'test.log')) 21 | 22 | LogBuddy.init :logger => Log 23 | 24 | port = ENV.fetch "BOXEN_MONGODB_PORT", 27017 25 | connection = Mongo::Client.new(["127.0.0.1:#{port.to_i}"], :logger => Log) 26 | DB = connection.use('test').database 27 | 28 | RSpec.configure do |config| 29 | config.filter_run :focused => true 30 | config.alias_example_to :fit, :focused => true 31 | config.alias_example_to :xit, :pending => true 32 | config.run_all_when_everything_filtered = true 33 | 34 | config.expect_with(:rspec) { |c| c.syntax = :should } 35 | config.mock_with(:rspec) { |c| c.syntax = :should } 36 | 37 | config.before(:suite) do 38 | DB.collections.reject { |collection| 39 | collection.name =~ /system\./ 40 | }.each { |collection| collection.indexes.drop_all} 41 | end 42 | 43 | config.before(:each) do 44 | DB.collections.reject { |collection| 45 | collection.name =~ /system\./ 46 | }.map(&:drop) 47 | end 48 | end 49 | 50 | operators = %w{gt lt gte lte ne in nin mod all size exists} 51 | operators.delete('size') if RUBY_VERSION >= '1.9.1' 52 | SymbolOperators = operators 53 | -------------------------------------------------------------------------------- /lib/plucky/normalizers/sort_value.rb: -------------------------------------------------------------------------------- 1 | module Plucky 2 | module Normalizers 3 | class SortValue 4 | 5 | # Public: Initializes a Plucky::Normalizers::SortValue 6 | # 7 | # args - The hash of arguments 8 | # :key_normalizer - What to use to normalize keys, must 9 | # respond to call. 10 | # 11 | def initialize(args = {}) 12 | @key_normalizer = args.fetch(:key_normalizer) { 13 | raise ArgumentError, "Missing required key :key_normalizer" 14 | } 15 | end 16 | 17 | # Public: Given a value returns it normalized for Mongo's sort option 18 | def call(value) 19 | case value 20 | when Array 21 | if value.size == 1 && value[0].is_a?(String) 22 | normalized_sort_piece(value[0]) 23 | else 24 | value.compact.inject({}) { |acc, v| acc.merge(normalized_sort_piece(v)) } 25 | end 26 | else 27 | normalized_sort_piece(value) 28 | end 29 | end 30 | 31 | # Private 32 | def normalized_sort_piece(value) 33 | case value 34 | when SymbolOperator 35 | normalized_direction(value.field, value.operator) 36 | when String 37 | value.split(',').inject({}) do |acc, piece| 38 | acc.merge(normalized_direction(*piece.split(' '))) 39 | end 40 | when Symbol 41 | normalized_direction(value) 42 | when Array 43 | Hash[*value] 44 | else 45 | value 46 | end 47 | end 48 | 49 | # Private 50 | def normalized_direction(field, direction=nil) 51 | direction ||= 'ASC' 52 | direction = direction.upcase == 'ASC' ? 1 : -1 53 | {@key_normalizer.call(field).to_s => direction} 54 | end 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /spec/plucky_spec.rb: -------------------------------------------------------------------------------- 1 | require 'helper' 2 | 3 | describe Plucky do 4 | describe ".to_object_id" do 5 | before do 6 | @id = BSON::ObjectId.new 7 | end 8 | 9 | it "converts nil to nil" do 10 | Plucky.to_object_id(nil).should be_nil 11 | end 12 | 13 | it "converts blank to nil" do 14 | Plucky.to_object_id('').should be_nil 15 | end 16 | 17 | it "leaves object id alone" do 18 | Plucky.to_object_id(@id).should equal(@id) 19 | end 20 | 21 | it "converts string to object id" do 22 | Plucky.to_object_id(@id.to_s).should == @id 23 | end 24 | 25 | it "not convert string that is not legal object id" do 26 | Plucky.to_object_id('foo').should == 'foo' 27 | Plucky.to_object_id(1).should == 1 28 | end 29 | end 30 | 31 | describe ".modifier?" do 32 | context "with a string" do 33 | it "returns true if modifier" do 34 | Plucky.modifier?('$in').should == true 35 | end 36 | 37 | it "returns false if not modifier" do 38 | Plucky.modifier?('nope').should == false 39 | end 40 | end 41 | 42 | context "with a symbol" do 43 | it "returns true if modifier" do 44 | Plucky.modifier?(:$in).should == true 45 | end 46 | 47 | it "returns false if not modifier" do 48 | Plucky.modifier?(:nope).should == false 49 | end 50 | end 51 | end 52 | 53 | describe "::Methods" do 54 | it "returns array of methods" do 55 | Plucky::Methods.should == [ 56 | :where, :filter, 57 | :sort, :order, :reverse, 58 | :paginate, :per_page, :limit, :skip, :offset, 59 | :fields, :projection, :ignore, :only, 60 | :each, :find_each, :find_one, :find, 61 | :count, :size, :distinct, 62 | :last, :first, :all, :to_a, 63 | :exists?, :exist?, :empty?, 64 | :remove, 65 | ].sort_by(&:to_s) 66 | end 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /lib/plucky/extensions/symbol.rb: -------------------------------------------------------------------------------- 1 | # encoding: UTF-8 2 | module Plucky 3 | module Extensions 4 | module Symbol 5 | def gt 6 | SymbolOperator.new(self, 'gt') 7 | end 8 | 9 | def lt 10 | SymbolOperator.new(self, 'lt') 11 | end 12 | 13 | def gte 14 | SymbolOperator.new(self, 'gte') 15 | end 16 | 17 | def lte 18 | SymbolOperator.new(self, 'lte') 19 | end 20 | 21 | def ne 22 | SymbolOperator.new(self, 'ne') 23 | end 24 | 25 | def in 26 | SymbolOperator.new(self, 'in') 27 | end 28 | 29 | def nin 30 | SymbolOperator.new(self, 'nin') 31 | end 32 | 33 | def mod 34 | SymbolOperator.new(self, 'mod') 35 | end 36 | 37 | def all 38 | SymbolOperator.new(self, 'all') 39 | end 40 | 41 | def size 42 | SymbolOperator.new(self, 'size') 43 | end unless Symbol.instance_methods.include?(:size) # Ruby 1.9 defines symbol size 44 | 45 | def exists 46 | SymbolOperator.new(self, 'exists') 47 | end 48 | 49 | def asc 50 | SymbolOperator.new(self, 'asc') 51 | end 52 | 53 | def desc 54 | SymbolOperator.new(self, 'desc') 55 | end 56 | end 57 | end 58 | end 59 | 60 | class SymbolOperator 61 | include Comparable 62 | 63 | attr_reader :field, :operator 64 | 65 | def initialize(field, operator, options={}) 66 | @field, @operator = field, operator 67 | end unless method_defined?(:initialize) 68 | 69 | def <=>(other) 70 | if field == other.field 71 | operator <=> other.operator 72 | else 73 | field.to_s <=> other.field.to_s 74 | end 75 | end 76 | 77 | def hash 78 | field.hash + operator.hash 79 | end 80 | 81 | def eql?(other) 82 | self == other 83 | end 84 | 85 | def ==(other) 86 | other.class == self.class && field == other.field && operator == other.operator 87 | end 88 | end 89 | 90 | class Symbol 91 | include Plucky::Extensions::Symbol 92 | end 93 | -------------------------------------------------------------------------------- /spec/plucky/options_hash_spec.rb: -------------------------------------------------------------------------------- 1 | require 'helper' 2 | 3 | describe Plucky::OptionsHash do 4 | describe "#initialize_copy" do 5 | before do 6 | @original = described_class.new(:projection => {:name => true}, :sort => :name, :limit => 10) 7 | @cloned = @original.clone 8 | end 9 | 10 | it "duplicates source hash" do 11 | @cloned.source.should_not equal(@original.source) 12 | end 13 | 14 | it "clones duplicable? values" do 15 | @cloned[:projection].should_not equal(@original[:projection]) 16 | @cloned[:sort].should_not equal(@original[:sort]) 17 | end 18 | end 19 | 20 | describe "#fields?" do 21 | it "returns true if fields have been selected" do 22 | described_class.new(:fields => :name).fields?.should be(true) 23 | end 24 | 25 | it "returns false if no fields have been selected" do 26 | described_class.new.fields?.should be(false) 27 | end 28 | end 29 | 30 | describe "#merge" do 31 | before do 32 | @o1 = described_class.new(:skip => 5, :sort => :name) 33 | @o2 = described_class.new(:limit => 10, :skip => 15) 34 | @merged = @o1.merge(@o2) 35 | end 36 | 37 | it "overrides options in first with options in second" do 38 | @merged.should == described_class.new(:limit => 10, :skip => 15, :sort => :name) 39 | end 40 | 41 | it "returns new instance and not change either of the merged" do 42 | @o1[:skip].should == 5 43 | @o2[:sort].should be_nil 44 | @merged.should_not equal(@o1) 45 | @merged.should_not equal(@o2) 46 | end 47 | end 48 | 49 | describe "#merge!" do 50 | before do 51 | @o1 = described_class.new(:skip => 5, :sort => :name) 52 | @o2 = described_class.new(:limit => 10, :skip => 15) 53 | @merged = @o1.merge!(@o2) 54 | end 55 | 56 | it "overrides options in first with options in second" do 57 | @merged.should == described_class.new(:limit => 10, :skip => 15, :sort => :name) 58 | end 59 | 60 | it "just updates the first" do 61 | @merged.should equal(@o1) 62 | end 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /spec/symbol_operator_spec.rb: -------------------------------------------------------------------------------- 1 | require 'helper' 2 | 3 | describe SymbolOperator do 4 | context "SymbolOperator" do 5 | before { @operator = SymbolOperator.new(:foo, 'in') } 6 | subject { @operator } 7 | 8 | it "has field" do 9 | subject.field.should == :foo 10 | end 11 | 12 | it "has operator" do 13 | subject.operator.should == 'in' 14 | end 15 | 16 | context "==" do 17 | it "returns true if field and operator are equal" do 18 | SymbolOperator.new(:foo, 'in').should == SymbolOperator.new(:foo, 'in') 19 | end 20 | 21 | it "returns false if fields are equal but operators are not" do 22 | SymbolOperator.new(:foo, 'in').should_not == SymbolOperator.new(:foo, 'all') 23 | end 24 | 25 | it "returns false if operators are equal but fields are not" do 26 | SymbolOperator.new(:foo, 'in').should_not == SymbolOperator.new(:bar, 'in') 27 | end 28 | 29 | it "returns false if neither are equal" do 30 | SymbolOperator.new(:foo, 'in').should_not == SymbolOperator.new(:bar, 'all') 31 | end 32 | 33 | it "returns false if other isn't an symbol operator" do 34 | SymbolOperator.new(:foo, 'in').should_not == 'foo.in' 35 | end 36 | end 37 | 38 | context "hash" do 39 | 40 | it 'returns sum of operator and hash field' do 41 | SymbolOperator.new(:foo, 'in').hash.should == :foo.hash + 'in'.hash 42 | end 43 | 44 | end 45 | 46 | context 'eql?' do 47 | 48 | it 'uses #== for equality comparison' do 49 | subject.should_receive(:"==").with("dummy_value") 50 | subject.eql?("dummy_value") 51 | end 52 | 53 | end 54 | 55 | context "<=>" do 56 | it "returns string comparison of operator for same field, different operator" do 57 | (SymbolOperator.new(:foo, 'in') <=> SymbolOperator.new(:foo, 'all')).should == 1 58 | (SymbolOperator.new(:foo, 'all') <=> SymbolOperator.new(:foo, 'in')).should == -1 59 | end 60 | 61 | it "returns 0 for same field same operator" do 62 | (SymbolOperator.new(:foo, 'in') <=> SymbolOperator.new(:foo, 'in')).should == 0 63 | end 64 | 65 | it "returns 1 for different field" do 66 | (SymbolOperator.new(:foo, 'in') <=> SymbolOperator.new(:bar, 'in')).should == 1 67 | end 68 | end 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /lib/plucky/options_hash.rb: -------------------------------------------------------------------------------- 1 | # encoding: UTF-8 2 | 3 | require 'plucky/normalizers/hash_key' 4 | require 'plucky/normalizers/options_hash_value' 5 | 6 | module Plucky 7 | class OptionsHash 8 | 9 | # Private: The Hash that stores the query options 10 | attr_reader :source 11 | 12 | # Private: The Hash that stores instance options 13 | attr_reader :options 14 | 15 | # Public 16 | def initialize(hash={}, options={}) 17 | @source = {} 18 | @options = options 19 | hash.each { |key, value| self[key] = value } 20 | end 21 | 22 | def initialize_copy(original) 23 | super 24 | @source = @source.dup 25 | @source.each do |key, value| 26 | self[key] = value.clone if value.duplicable? 27 | end 28 | end 29 | 30 | # Public 31 | def [](key) 32 | @source[key] 33 | end 34 | 35 | # Public 36 | def []=(key, value) 37 | key = normalized_key(key) 38 | @source[key] = normalized_value(key, value) 39 | end 40 | 41 | # Public 42 | def keys 43 | @source.keys 44 | end 45 | 46 | # Public 47 | def ==(other) 48 | @source == other.source 49 | end 50 | 51 | # Public 52 | def to_hash 53 | @source 54 | end 55 | 56 | # Public 57 | def fields? 58 | !self[:projection].nil? 59 | end 60 | 61 | # Public 62 | def merge(other) 63 | self.class.new(to_hash.merge(other.to_hash)) 64 | end 65 | 66 | # Public 67 | def merge!(other) 68 | other.to_hash.each { |key, value| self[key] = value } 69 | self 70 | end 71 | 72 | # Private 73 | def normalized_key(key) 74 | key_normalizer.call(key) 75 | end 76 | 77 | # Private 78 | def normalized_value(key, value) 79 | value_normalizer.call(key, value) 80 | end 81 | 82 | # Private 83 | def key_normalizer 84 | @key_normalizer ||= @options.fetch(:key_normalizer) { 85 | Normalizers::HashKey.new({ 86 | :order => :sort, 87 | :select => :projection, 88 | :fields => :projection, 89 | :offset => :skip, 90 | :id => :_id, 91 | }) 92 | } 93 | end 94 | 95 | # Private 96 | def value_normalizer 97 | @value_normalizer ||= @options.fetch(:value_normalizer) { 98 | Normalizers::OptionsHashValue.new({ 99 | :key_normalizer => key_normalizer, 100 | }) 101 | } 102 | end 103 | end 104 | end 105 | -------------------------------------------------------------------------------- /spec/plucky/normalizers/options_hash_value_spec.rb: -------------------------------------------------------------------------------- 1 | require 'helper' 2 | 3 | describe Plucky::Normalizers::OptionsHashValue do 4 | let(:key_normalizer) { 5 | lambda { |key| 6 | if key == :id 7 | :_id 8 | else 9 | key.to_sym 10 | end 11 | } 12 | } 13 | 14 | let(:upcasing_normalizer) { 15 | lambda { |value| value.to_s.upcase } 16 | } 17 | 18 | let(:default_arguments) { 19 | { 20 | :key_normalizer => key_normalizer, 21 | } 22 | } 23 | 24 | subject { 25 | described_class.new(default_arguments) 26 | } 27 | 28 | it "raises exception if missing key normalizer" do 29 | lambda { 30 | described_class.new 31 | }.should raise_error(ArgumentError, "Missing required key :key_normalizer") 32 | end 33 | 34 | it "allows injecting a new value normalizer" do 35 | instance = described_class.new(default_arguments.merge({ 36 | :value_normalizers => { 37 | :some_field => upcasing_normalizer, 38 | } 39 | })) 40 | 41 | instance.call(:some_field, 'upcase me').should eq('UPCASE ME') 42 | end 43 | 44 | context "with :fields key" do 45 | subject { 46 | described_class.new(default_arguments.merge({ 47 | :value_normalizers => { 48 | :fields => upcasing_normalizer 49 | }, 50 | })) 51 | } 52 | 53 | it "calls the fields value normalizer" do 54 | subject.call(:fields, :foo).should eq('FOO') 55 | end 56 | end 57 | 58 | context "with :sort key" do 59 | subject { 60 | described_class.new(default_arguments.merge({ 61 | :value_normalizers => { 62 | :sort => upcasing_normalizer 63 | }, 64 | })) 65 | } 66 | 67 | it "calls the sort value normalizer" do 68 | subject.call(:sort, :foo).should eq('FOO') 69 | end 70 | end 71 | 72 | context "with :limit key" do 73 | subject { 74 | described_class.new(default_arguments.merge({ 75 | :value_normalizers => { 76 | :limit => upcasing_normalizer 77 | }, 78 | })) 79 | } 80 | 81 | it "calls the limit value normalizer" do 82 | subject.call(:limit, :foo).should eq('FOO') 83 | end 84 | end 85 | 86 | context "with :skip key" do 87 | subject { 88 | described_class.new(default_arguments.merge({ 89 | :value_normalizers => { 90 | :skip => upcasing_normalizer 91 | }, 92 | })) 93 | } 94 | 95 | it "calls the skip value normalizer" do 96 | subject.call(:skip, :foo).should eq('FOO') 97 | end 98 | end 99 | end 100 | -------------------------------------------------------------------------------- /examples/query.rb: -------------------------------------------------------------------------------- 1 | require 'pp' 2 | require 'pathname' 3 | require 'rubygems' 4 | 5 | root_path = Pathname(__FILE__).dirname.join('..').expand_path 6 | lib_path = root_path.join('lib') 7 | $:.unshift(lib_path) 8 | require 'plucky' 9 | 10 | connection = Mongo::Client.new(["127.0.0.1"], :logger => Logger.new('/dev/null')) 11 | db = connection.use('test').database 12 | collection = db['users'] 13 | 14 | # initialize query with collection 15 | query = Plucky::Query.new(collection) 16 | 17 | query.remove # clear out the collection 18 | 19 | query.insert({'_id' => 'chris', 'age' => 26, 'name' => 'Chris'}) 20 | query.insert({'_id' => 'steve', 'age' => 29, 'name' => 'Steve'}) 21 | query.insert({'_id' => 'john', 'age' => 28, 'name' => 'John'}) 22 | 23 | puts 'Querying' 24 | pp query.where(:name => 'John').first 25 | pp query.first(:name => 'John') 26 | pp query.where(:name => 'John').all 27 | pp query.all(:name => 'John') 28 | 29 | puts 'Find by _id' 30 | pp query.find('chris') 31 | pp query.find('chris', 'steve') 32 | pp query.find(['chris', 'steve']) 33 | 34 | puts 'Sort' 35 | pp query.sort(:age).all 36 | pp query.sort(:age.asc).all # same as above 37 | pp query.sort(:age.desc).all 38 | pp query.sort(:age).last # steve 39 | 40 | puts 'Counting' 41 | pp query.count # 3 42 | pp query.size # 3 43 | pp query.count(:name => 'John') # 1 44 | pp query.where(:name => 'John').count # 1 45 | pp query.where(:name => 'John').size # 1 46 | 47 | puts 'Distinct' 48 | pp query.distinct(:age) # [26, 29, 28] 49 | 50 | puts 'Select only certain fields' 51 | pp query.fields(:age).find('chris') # {"_id"=>"chris", "age"=>26} 52 | pp query.only(:age).find('chris') # {"_id"=>"chris", "age"=>26} 53 | pp query.ignore(:name).find('chris') # {"_id"=>"chris", "age"=>26} 54 | 55 | puts 'Pagination, yeah we got that' 56 | pp query.sort(:age).paginate(:per_page => 1, :page => 2) 57 | pp query.sort(:age).per_page(1).paginate(:page => 2) 58 | 59 | pp query.sort(:age).limit(2).to_a # [chris, john] 60 | pp query.sort(:age).skip(1).limit(2).to_a # [john, steve] 61 | pp query.sort(:age).offset(1).limit(2).to_a # [john, steve] 62 | 63 | puts 'Using a cursor' 64 | cursor = query.find_each(:sort => :age) do |doc| 65 | pp doc 66 | end 67 | pp cursor 68 | 69 | puts 'Symbol Operators' 70 | pp query.where(:age.gt => 28).count # 1 (steve) 71 | pp query.where(:age.lt => 28).count # 1 (chris) 72 | pp query.where(:age.in => [26, 28]).to_a # [chris, john] 73 | pp query.where(:age.nin => [26, 28]).to_a # [steve] 74 | 75 | puts 'Removing' 76 | query.remove(:name => 'John') 77 | pp query.count # 2 78 | query.where(:name => 'Chris').remove 79 | pp query.count # 1 80 | query.remove 81 | pp query.count # 0 82 | -------------------------------------------------------------------------------- /lib/plucky/normalizers/criteria_hash_value.rb: -------------------------------------------------------------------------------- 1 | require 'set' 2 | 3 | module Plucky 4 | module Normalizers 5 | class CriteriaHashValue 6 | 7 | # Internal: Used by normalized_value to determine if we need to run the 8 | # value through another criteria hash to normalize it. 9 | NestingOperators = Set[:$or, :$and, :$nor] 10 | 11 | def initialize(criteria_hash) 12 | @criteria_hash = criteria_hash 13 | end 14 | 15 | # Public: Returns value normalized for Mongo 16 | # 17 | # parent_key - The parent key if nested, otherwise same as key 18 | # key - The key we are currently normalizing 19 | # value - The value that should be normalized 20 | # 21 | # Returns value normalized for Mongo 22 | def call(parent_key, key, value) 23 | case value 24 | when Array, Set 25 | if object_id?(parent_key) 26 | value = value.map { |v| to_object_id(v) } 27 | end 28 | 29 | if nesting_operator?(key) 30 | value.map { |v| criteria_hash_class.new(v, options).to_hash } 31 | elsif parent_key == key && !modifier?(key) && !value.empty? 32 | # we're not nested and not the value for a symbol operator 33 | {:$in => value.to_a} 34 | else 35 | # we are a value for a symbol operator or nested hash 36 | value.to_a 37 | end 38 | when Time 39 | value.utc 40 | when String 41 | if object_id?(key) 42 | return to_object_id(value) 43 | end 44 | value 45 | when Hash 46 | value.each { |k, v| value[k] = call(key, k, v) } 47 | value 48 | when Regexp 49 | Regexp.new(value) 50 | else 51 | value 52 | end 53 | end 54 | 55 | # Private: Ensures value is object id if possible 56 | def to_object_id(value) 57 | Plucky.to_object_id(value) 58 | end 59 | 60 | # Private: Returns class of provided criteria hash 61 | def criteria_hash_class 62 | @criteria_hash.class 63 | end 64 | 65 | # Private: Returns options of provided criteria hash 66 | def options 67 | @criteria_hash.options 68 | end 69 | 70 | # Private: Returns true or false if key should be converted to object id 71 | def object_id?(key) 72 | @criteria_hash.object_id?(key) 73 | end 74 | 75 | # Private: Returns true or false if key is a nesting operator 76 | def nesting_operator?(key) 77 | NestingOperators.include?(key) 78 | end 79 | 80 | def modifier?(key) 81 | Plucky.modifier?(key) 82 | end 83 | end 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /lib/plucky/extensions/duplicable.rb: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2005-2010 David Heinemeier Hansson 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining 4 | # a copy of this software and associated documentation files (the 5 | # "Software"), to deal in the Software without restriction, including 6 | # without limitation the rights to use, copy, modify, merge, publish, 7 | # distribute, sublicense, and/or sell copies of the Software, and to 8 | # permit persons to whom the Software is furnished to do so, subject to 9 | # the following conditions: 10 | # 11 | # The above copyright notice and this permission notice shall be 12 | # included in all copies or substantial portions of the Software. 13 | # 14 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | # 22 | # Most objects are cloneable, but not all. For example you can't dup +nil+: 23 | # 24 | # nil.dup # => TypeError: can't dup NilClass 25 | # 26 | # Classes may signal their instances are not duplicable removing +dup+/+clone+ 27 | # or raising exceptions from them. So, to dup an arbitrary object you normally 28 | # use an optimistic approach and are ready to catch an exception, say: 29 | # 30 | # arbitrary_object.dup rescue object 31 | # 32 | # Rails dups objects in a few critical spots where they are not that arbitrary. 33 | # That rescue is very expensive (like 40 times slower than a predicate), and it 34 | # is often triggered. 35 | # 36 | # That's why we hardcode the following cases and check duplicable? instead of 37 | # using that rescue idiom. 38 | class Object 39 | # Can you safely .dup this object? 40 | # False for nil, false, true, symbols, numbers, class and module objects; true otherwise. 41 | def duplicable? 42 | true 43 | end 44 | end 45 | 46 | class NilClass #:nodoc: 47 | def duplicable? 48 | false 49 | end 50 | end 51 | 52 | class FalseClass #:nodoc: 53 | def duplicable? 54 | false 55 | end 56 | end 57 | 58 | class TrueClass #:nodoc: 59 | def duplicable? 60 | false 61 | end 62 | end 63 | 64 | class Symbol #:nodoc: 65 | def duplicable? 66 | false 67 | end 68 | end 69 | 70 | class Numeric #:nodoc: 71 | def duplicable? 72 | false 73 | end 74 | end 75 | 76 | class Class #:nodoc: 77 | def duplicable? 78 | false 79 | end 80 | end 81 | 82 | class Module #:nodoc: 83 | def duplicable? 84 | false 85 | end 86 | end 87 | -------------------------------------------------------------------------------- /lib/plucky/normalizers/options_hash_value.rb: -------------------------------------------------------------------------------- 1 | require 'plucky/normalizers/integer' 2 | require 'plucky/normalizers/fields_value' 3 | require 'plucky/normalizers/sort_value' 4 | 5 | module Plucky 6 | module Normalizers 7 | class OptionsHashValue 8 | 9 | # Public: Initialize an OptionsHashValue. 10 | # 11 | # args - The hash of arguments (default: {}) 12 | # :key_normalizer - The key normalizer to use, must respond to call 13 | # :value_normalizers - Hash where key is name of options hash key 14 | # to normalize and value is what should be used 15 | # to normalize the value accordingly (must respond 16 | # to call). Allows adding normalizers for new keys 17 | # and overriding existing default normalizers. 18 | # 19 | # 20 | # Examples 21 | # 22 | # Plucky::Normalizers::OptionsHashValue.new({ 23 | # :key_normalizer => lambda { |key| key}, # key normalizer must responds to call 24 | # :value_normalizers => { 25 | # :new_key => lambda { |key| key.to_s.upcase }, # add normalizer for :new_key 26 | # :fields => lambda { |key| key }, # override normalizer for fields to one that does nothing 27 | # } 28 | # }) 29 | # 30 | # Returns the duplicated String. 31 | def initialize(args = {}) 32 | @key_normalizer = args.fetch(:key_normalizer) { 33 | raise ArgumentError, "Missing required key :key_normalizer" 34 | } 35 | 36 | @value_normalizers = { 37 | :projection => default_fields_value_normalizer, 38 | :sort => default_sort_value_normalizer, 39 | :limit => default_limit_value_normalizer, 40 | :skip => default_skip_value_normalizer, 41 | } 42 | 43 | if (value_normalizers = args[:value_normalizers]) 44 | @value_normalizers.update(value_normalizers) 45 | end 46 | end 47 | 48 | # Public: Returns value normalized for Mongo 49 | # 50 | # key - The name of the key whose value is being normalized 51 | # value - The value to normalize 52 | # 53 | # Returns value normalized for Mongo. 54 | def call(key, value) 55 | if (value_normalizer = @value_normalizers[key]) 56 | value_normalizer.call(value) 57 | else 58 | value 59 | end 60 | end 61 | 62 | # Private 63 | def default_fields_value_normalizer 64 | Normalizers::FieldsValue.new 65 | end 66 | 67 | # Private 68 | def default_sort_value_normalizer 69 | Normalizers::SortValue.new(:key_normalizer => Normalizers::HashKey.new({:id => :_id})) 70 | end 71 | 72 | # Private 73 | def default_limit_value_normalizer 74 | Normalizers::Integer.new 75 | end 76 | 77 | # Private 78 | def default_skip_value_normalizer 79 | Normalizers::Integer.new 80 | end 81 | end 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /spec/plucky/normalizers/sort_value_spec.rb: -------------------------------------------------------------------------------- 1 | require 'helper' 2 | require 'plucky/normalizers/sort_value' 3 | 4 | describe Plucky::Normalizers::SortValue do 5 | let(:key_normalizer) { 6 | Plucky::Normalizers::HashKey.new({:id => :_id}) 7 | } 8 | 9 | subject { 10 | described_class.new({ 11 | :key_normalizer => key_normalizer, 12 | }) 13 | } 14 | 15 | it "raises exception if missing key normalizer" do 16 | lambda { 17 | described_class.new 18 | }.should raise_error(ArgumentError, "Missing required key :key_normalizer") 19 | end 20 | 21 | it "defaults to nil" do 22 | subject.call(nil).should eq(nil) 23 | end 24 | 25 | it "works with natural order ascending" do 26 | subject.call('$natural' => 1).should eq('$natural' => 1) 27 | end 28 | 29 | it "works with natural order descending" do 30 | subject.call('$natural' => -1).should eq('$natural' => -1) 31 | end 32 | 33 | it "converts single ascending field (string)" do 34 | subject.call('foo asc').should eq({'foo' => 1}) 35 | subject.call('foo ASC').should eq({'foo' => 1}) 36 | end 37 | 38 | it "converts single descending field (string)" do 39 | subject.call('foo desc').should eq({'foo' => -1}) 40 | subject.call('foo DESC').should eq({'foo' => -1}) 41 | end 42 | 43 | it "converts multiple fields (string)" do 44 | subject.call('foo desc, bar asc').should eq({'foo' => -1, 'bar' => 1}) 45 | end 46 | 47 | it "converts multiple fields and default no direction to ascending (string)" do 48 | subject.call('foo desc, bar, baz').should eq({'foo' => -1, 'bar' => 1, 'baz' => 1}) 49 | end 50 | 51 | it "converts symbol" do 52 | subject.call(:name).should eq({'name' => 1}) 53 | end 54 | 55 | it "converts operator" do 56 | subject.call(:foo.desc).should eq({'foo' => -1}) 57 | end 58 | 59 | it "converts array of operators" do 60 | subject.call([:foo.desc, :bar.asc]).should eq({'foo' => -1, 'bar' => 1}) 61 | end 62 | 63 | it "converts array of symbols" do 64 | subject.call([:first_name, :last_name]).should eq({'first_name' => 1, 'last_name' => 1}) 65 | end 66 | 67 | it "works with array and one string element" do 68 | subject.call(['foo, bar desc']).should eq({'foo' => 1, 'bar' => -1}) 69 | end 70 | 71 | it "works with array of single array" do 72 | subject.call([['foo', -1]]).should eq({'foo' => -1}) 73 | end 74 | 75 | it "works with array of multiple arrays" do 76 | subject.call([['foo', -1], ['bar', 1]]).should eq({'foo' => -1, 'bar' => 1}) 77 | end 78 | 79 | it "compacts nil values in array" do 80 | subject.call([nil, :foo.desc]).should eq({'foo' => -1}) 81 | end 82 | 83 | it "converts array with mix of values" do 84 | subject.call([:foo.desc, 'bar']).should eq({'foo' => -1, 'bar' => 1}) 85 | end 86 | 87 | it "converts keys based on key normalizer" do 88 | subject.call([:id.asc]).should eq({'_id' => 1}) 89 | end 90 | 91 | it "doesn't convert keys like :sort to :order via key normalizer" do 92 | subject.call(:order.asc).should eq({'order' => 1}) 93 | end 94 | 95 | it "converts string with $natural correctly" do 96 | subject.call('$natural desc').should eq({'$natural' => -1}) 97 | end 98 | end 99 | -------------------------------------------------------------------------------- /spec/plucky/pagination/paginator_spec.rb: -------------------------------------------------------------------------------- 1 | require 'helper' 2 | 3 | describe Plucky::Pagination::Paginator do 4 | describe "#initialize" do 5 | context "with total and page" do 6 | before { @paginator = described_class.new(20, 2) } 7 | subject { @paginator } 8 | 9 | it "sets total" do 10 | subject.total_entries.should == 20 11 | end 12 | 13 | it "sets page" do 14 | subject.current_page.should == 2 15 | end 16 | 17 | it "defaults per_page to 25" do 18 | subject.per_page.should == 25 19 | end 20 | end 21 | 22 | context "with total, page and per_page" do 23 | before { @paginator = described_class.new(20, 2, 10) } 24 | subject { @paginator } 25 | 26 | it "sets total" do 27 | subject.total_entries.should == 20 28 | end 29 | 30 | it "sets page" do 31 | subject.current_page.should == 2 32 | end 33 | 34 | it "sets per_page" do 35 | subject.per_page.should == 10 36 | end 37 | end 38 | 39 | context "with string values for total, page and per_page" do 40 | before { @paginator = described_class.new('20', '2', '10') } 41 | subject { @paginator } 42 | 43 | it "sets total" do 44 | subject.total_entries.should == 20 45 | end 46 | 47 | it "sets page" do 48 | subject.current_page.should == 2 49 | end 50 | 51 | it "sets per_page" do 52 | subject.per_page.should == 10 53 | end 54 | end 55 | 56 | context "with page less than 1" do 57 | before { @paginator = described_class.new(20, -2, 10) } 58 | subject { @paginator } 59 | 60 | it "sets page to 1" do 61 | subject.current_page.should == 1 62 | end 63 | end 64 | end 65 | 66 | it "aliases limit to per_page" do 67 | described_class.new(30, 2, 30).limit.should == 30 68 | end 69 | 70 | it "knows total number of pages" do 71 | described_class.new(43, 2, 7).total_pages.should == 7 72 | described_class.new(40, 2, 10).total_pages.should == 4 73 | end 74 | 75 | describe "#out_of_bounds?" do 76 | it "returns true if current_page is greater than total_pages" do 77 | described_class.new(2, 3, 1).should be_out_of_bounds 78 | end 79 | 80 | it "returns false if current page is less than total_pages" do 81 | described_class.new(2, 1, 1).should_not be_out_of_bounds 82 | end 83 | 84 | it "returns false if current page equals total_pages" do 85 | described_class.new(2, 2, 1).should_not be_out_of_bounds 86 | end 87 | end 88 | 89 | describe "#previous_page" do 90 | it "returns nil if there is no page less than current" do 91 | described_class.new(2, 1, 1).previous_page.should be_nil 92 | end 93 | 94 | it "returns number less than current page if there is one" do 95 | described_class.new(2, 2, 1).previous_page.should == 1 96 | end 97 | end 98 | 99 | describe "#next_page" do 100 | it "returns nil if no page greater than current page" do 101 | described_class.new(2, 2, 1).next_page.should be_nil 102 | end 103 | 104 | it "returns number greater than current page if there is one" do 105 | described_class.new(2, 1, 1).next_page.should == 2 106 | end 107 | end 108 | 109 | describe "#skip" do 110 | it "works" do 111 | described_class.new(30, 3, 10).skip.should == 20 112 | end 113 | 114 | it "returns aliased to offset for will paginate" do 115 | described_class.new(30, 3, 10).offset.should == 20 116 | end 117 | end 118 | end 119 | -------------------------------------------------------------------------------- /lib/plucky/criteria_hash.rb: -------------------------------------------------------------------------------- 1 | # encoding: UTF-8 2 | require 'set' 3 | require 'plucky/normalizers/criteria_hash_value' 4 | require 'plucky/normalizers/criteria_hash_key' 5 | 6 | module Plucky 7 | class CriteriaHash 8 | # Private: The Hash that stores query criteria 9 | attr_reader :source 10 | 11 | # Private: The Hash that stores options 12 | attr_reader :options 13 | 14 | # Internal: Used to determine if criteria keys match simple id lookup. 15 | SimpleIdQueryKeys = [:_id].to_set 16 | 17 | # Internal: Used to determine if criteria keys match simple id and type 18 | # lookup (for single collection inheritance). 19 | SimpleIdAndTypeQueryKeys = [:_id, :_type].to_set 20 | 21 | # Internal: Used to quickly check if it is possible that the 22 | # criteria hash is simple. 23 | SimpleQueryMaxSize = [SimpleIdQueryKeys.size, SimpleIdAndTypeQueryKeys.size].max 24 | 25 | # Public 26 | def initialize(hash={}, options={}) 27 | @source, @options = {}, options 28 | hash.each { |key, value| self[key] = value } 29 | end 30 | 31 | def initialize_copy(original) 32 | super 33 | @options = @options.dup 34 | @source = @source.dup 35 | @source.each do |key, value| 36 | self[key] = value.clone if value.duplicable? 37 | end 38 | end 39 | 40 | # Public 41 | def [](key) 42 | @source[key] 43 | end 44 | 45 | # Public 46 | # The contents of this make me sad...need to clean it up 47 | def []=(key, value) 48 | normalized_key = normalized_key(key) 49 | 50 | if key.is_a?(SymbolOperator) 51 | operator = :"$#{key.operator}" 52 | normalized_value = normalized_value(normalized_key, operator, value) 53 | @source[normalized_key] ||= {} 54 | @source[normalized_key][operator] = normalized_value 55 | else 56 | if key == :conditions 57 | value.each { |k, v| self[k] = v } 58 | else 59 | normalized_value = normalized_value(normalized_key, normalized_key, value) 60 | @source[normalized_key] = normalized_value 61 | end 62 | end 63 | end 64 | 65 | # Public 66 | def keys 67 | @source.keys 68 | end 69 | 70 | # Public 71 | def ==(other) 72 | @source == other.source 73 | end 74 | 75 | # Public 76 | def to_hash 77 | @source 78 | end 79 | 80 | # Public 81 | def merge(other) 82 | self.class.new hash_merge(@source, other.source) 83 | end 84 | 85 | # Public 86 | def merge!(other) 87 | merge(other).to_hash.each do |key, value| 88 | self[key] = value 89 | end 90 | self 91 | end 92 | 93 | # Public: The definition of simple is querying by only _id or _id and _type. 94 | # If this is the case, you can use IdentityMap in library to not perform 95 | # query and instead just return from map. 96 | # 97 | # Returns true or false 98 | def simple? 99 | return false if keys.size > SimpleQueryMaxSize 100 | key_set = keys.to_set 101 | key_set == SimpleIdQueryKeys || key_set == SimpleIdAndTypeQueryKeys 102 | end 103 | 104 | def object_id?(key) 105 | object_ids.include?(key.to_sym) 106 | end 107 | 108 | # Private 109 | def object_ids 110 | @options[:object_ids] ||= [] 111 | end 112 | 113 | # Private 114 | def object_ids=(value) 115 | raise ArgumentError unless value.is_a?(Array) 116 | @options[:object_ids] = value.flatten 117 | end 118 | 119 | private 120 | 121 | # Private 122 | def hash_merge(oldhash, newhash) 123 | merge_compound_or_clauses!(oldhash, newhash) 124 | oldhash.merge(newhash) do |key, oldval, newval| 125 | old_is_hash = oldval.instance_of? Hash 126 | new_is_hash = newval.instance_of? Hash 127 | 128 | if oldval == newval 129 | oldval 130 | elsif old_is_hash && new_is_hash 131 | hash_merge(oldval, newval) 132 | elsif old_is_hash 133 | modifier_merge(oldval, newval) 134 | elsif new_is_hash 135 | modifier_merge(newval, oldval) 136 | else 137 | merge_values_into_array(oldval, newval) 138 | end 139 | end 140 | end 141 | 142 | def merge_compound_or_clauses!(oldhash, newhash) 143 | old_or = oldhash[:$or] 144 | new_or = newhash[:$or] 145 | if old_or && new_or 146 | oldhash[:$and] ||= [] 147 | oldhash[:$and] << {:$or => oldhash.delete(:$or)} 148 | oldhash[:$and] << {:$or => newhash.delete(:$or)} 149 | elsif new_or && oldhash[:$and] 150 | if oldhash[:$and].any? {|v| v.key? :$or } 151 | oldhash[:$and] << {:$or => newhash.delete(:$or)} 152 | end 153 | elsif old_or && newhash[:$and] 154 | if newhash[:$and].any? {|v| v.key? :$or } 155 | newhash[:$and] << {:$or => oldhash.delete(:$or)} 156 | end 157 | end 158 | end 159 | 160 | # Private 161 | def modifier_merge(hash, value) 162 | if modifier_key = hash.keys.detect { |k| Plucky.modifier?(k) } 163 | hash[modifier_key].concat( array(value) ).uniq 164 | end 165 | end 166 | 167 | # Private 168 | def merge_values_into_array(value, other_value) 169 | array(value).concat(array(other_value)).uniq 170 | end 171 | 172 | # Private: Array(BSON::ObjectId) returns the byte array or what not instead 173 | # of the object id. This makes sure it is an array of object ids, not the 174 | # guts of the object id. 175 | def array(value) 176 | case value 177 | when nil, BSON::ObjectId 178 | [value] 179 | else 180 | Array(value) 181 | end 182 | end 183 | 184 | # Private 185 | def normalized_key(key) 186 | key_normalizer.call(key) 187 | end 188 | 189 | # Private 190 | def key_normalizer 191 | @key_normalizer ||= @options.fetch(:key_normalizer) { 192 | Normalizers::CriteriaHashKey.new 193 | } 194 | end 195 | 196 | # Private 197 | def normalized_value(parent_key, key, value) 198 | value_normalizer.call(parent_key, key, value) 199 | end 200 | 201 | # Private 202 | def value_normalizer 203 | @value_normalizer ||= @options.fetch(:value_normalizer) { 204 | Normalizers::CriteriaHashValue.new(self) 205 | } 206 | end 207 | end 208 | end 209 | -------------------------------------------------------------------------------- /spec/plucky/normalizers/criteria_hash_value_spec.rb: -------------------------------------------------------------------------------- 1 | require 'helper' 2 | 3 | describe Plucky::Normalizers::CriteriaHashValue do 4 | let(:criteria_hash) { Plucky::CriteriaHash.new } 5 | 6 | subject { 7 | described_class.new(criteria_hash) 8 | } 9 | 10 | context "with a string" do 11 | it "leaves string values for string keys alone" do 12 | subject.call(:foo, :foo, 'bar').should eq('bar') 13 | end 14 | 15 | context "that is actually an object id" do 16 | it "converts string values to object ids for object id keys" do 17 | criteria_hash.object_ids = [:_id] 18 | id = BSON::ObjectId.new 19 | subject.call(:_id, :_id, id.to_s).should eq(id) 20 | end 21 | end 22 | end 23 | 24 | context "with a time" do 25 | it "converts times to utc" do 26 | time = Time.now 27 | actual = time 28 | expected = time.utc 29 | result = subject.call(:foo, :foo, actual) 30 | result.should be_utc 31 | result.should eq(expected) 32 | end 33 | 34 | it "leaves utc times alone" do 35 | time = Time.now 36 | actual = time.utc 37 | expected = time.utc 38 | result = subject.call(:foo, :foo, actual) 39 | result.should be_utc 40 | result.should eq(expected) 41 | end 42 | end 43 | 44 | context "with an array" do 45 | it "defaults to $in" do 46 | actual = [1,2,3] 47 | expected = {:$in => [1,2,3]} 48 | subject.call(:foo, :foo, actual).should eq(expected) 49 | end 50 | 51 | it "does not double up $in" do 52 | actual = [1, 2, 3] 53 | expected = [1, 2, 3] 54 | subject.call(:$in, :$in, actual).should eq(expected) 55 | end 56 | 57 | it "uses existing modifier if present" do 58 | actual = {'$all' => [1,2,3]} 59 | expected = {'$all' => [1,2,3]} 60 | subject.call(:foo, :foo, actual).should eq(expected) 61 | 62 | actual = {'$any' => [1,2,3]} 63 | expected = {'$any' => [1,2,3]} 64 | subject.call(:foo, :foo, actual).should eq(expected) 65 | end 66 | 67 | it "does not turn value to $in with an empty array value" do 68 | actual = [] 69 | expected = [] 70 | subject.call(:foo, :foo, actual).should eq(expected) 71 | end 72 | 73 | it "does not turn value to $in with $or key" do 74 | actual = [{:numbers => 1}, {:numbers => 2}] 75 | expected = [{:numbers => 1}, {:numbers => 2}] 76 | subject.call(:$or, :$or, actual).should eq(expected) 77 | end 78 | 79 | it "does not turn value to $in with $and key" do 80 | actual = [{:numbers => 1}, {:numbers => 2}] 81 | expected = [{:numbers => 1}, {:numbers => 2}] 82 | subject.call(:$and, :$and, actual).should eq(expected) 83 | end 84 | 85 | it "does not turn value to $in with $nor key" do 86 | actual = [{:numbers => 1}, {:numbers => 2}] 87 | expected = [{:numbers => 1}, {:numbers => 2}] 88 | subject.call(:$nor, :$nor, actual).should eq(expected) 89 | end 90 | 91 | it "defaults to $in even with ObjectId keys" do 92 | actual = [1,2,3] 93 | expected = {:$in => [1,2,3]} 94 | criteria_hash.object_ids = [:mistake_id] 95 | subject.call(:mistake_id, :mistake_id, actual).should eq(expected) 96 | end 97 | end 98 | 99 | context "with a set" do 100 | it "defaults to $in and convert to array" do 101 | actual = [1,2,3].to_set 102 | expected = {:$in => [1,2,3]} 103 | subject.call(:numbers, :numbers, actual).should eq(expected) 104 | end 105 | 106 | it "uses existing modifier if present and convert to array" do 107 | actual = {'$all' => [1,2,3].to_set} 108 | expected = {'$all' => [1,2,3]} 109 | subject.call(:foo, :foo, actual).should eq(expected) 110 | 111 | actual = {'$any' => [1,2,3].to_set} 112 | expected = {'$any' => [1,2,3]} 113 | subject.call(:foo, :foo, actual).should eq(expected) 114 | end 115 | end 116 | 117 | context "with string object ids for string keys" do 118 | let(:object_id) { BSON::ObjectId.new } 119 | 120 | it "leaves string ids as strings" do 121 | subject.call(:_id, :_id, object_id.to_s).should eq(object_id.to_s) 122 | subject.call(:room_id, :room_id, object_id.to_s).should eq(object_id.to_s) 123 | end 124 | end 125 | 126 | context "with string object ids for object id keys" do 127 | let(:object_id) { BSON::ObjectId.new } 128 | 129 | before do 130 | criteria_hash.object_ids = [:_id, :room_id] 131 | end 132 | 133 | it "converts strings to object ids" do 134 | subject.call(:_id, :_id, object_id.to_s).should eq(object_id) 135 | subject.call(:room_id, :room_id, object_id.to_s).should eq(object_id) 136 | end 137 | 138 | context "nested with modifier" do 139 | let(:oid1) { BSON::ObjectId.new } 140 | let(:oid2) { BSON::ObjectId.new } 141 | let(:oids) { [oid1.to_s, oid2.to_s] } 142 | 143 | it "converts strings to object ids" do 144 | actual = {:$in => oids} 145 | expected = {:$in => [oid1, oid2]} 146 | subject.call(:_id, :_id, actual).should eq(expected) 147 | end 148 | 149 | it "does not modify original array of string ids" do 150 | subject.call(:_id, :_id, {:$in => oids}) 151 | oids.should == [oid1.to_s, oid2.to_s] 152 | end 153 | end 154 | end 155 | 156 | context "nested clauses" do 157 | it "knows constant array of operators that take nested queries" do 158 | described_class::NestingOperators.should == Set[:$or, :$and, :$nor] 159 | end 160 | 161 | described_class::NestingOperators.each do |operator| 162 | context "with #{operator}" do 163 | it "works with symbol operators" do 164 | nested1 = {:age.gt => 12, :age.lt => 20} 165 | translated1 = {:age => {:$gt => 12, :$lt => 20 }} 166 | nested2 = {:type.nin => ['friend', 'enemy']} 167 | translated2 = {:type => {:$nin => ['friend', 'enemy']}} 168 | value = [nested1, nested2] 169 | expected = [translated1, translated2] 170 | 171 | subject.call(operator, operator, value).should eq(expected) 172 | end 173 | 174 | it "honors criteria hash options" do 175 | nested = [{:post_id => '4f5ead6378fca23a13000001'}] 176 | translated = [{:post_id => BSON::ObjectId.from_string('4f5ead6378fca23a13000001')}] 177 | given = {operator.to_s => [nested]} 178 | 179 | criteria_hash.object_ids = [:post_id] 180 | subject.call(operator, operator, nested).should eq(translated) 181 | end 182 | end 183 | end 184 | 185 | context "doubly nested" do 186 | it "works with symbol operators" do 187 | nested1 = {:age.gt => 12, :age.lt => 20} 188 | translated1 = {:age => {:$gt => 12, :$lt => 20}} 189 | nested2 = {:type.nin => ['friend', 'enemy']} 190 | translated2 = {:type => {:$nin => ['friend', 'enemy']}} 191 | nested3 = {'$and' => [nested2]} 192 | translated3 = {:$and => [translated2]} 193 | expected = [translated1, translated3] 194 | 195 | subject.call(:$or, :$or, [nested1, nested3]).should eq(expected) 196 | end 197 | end 198 | end 199 | end 200 | -------------------------------------------------------------------------------- /lib/plucky/query.rb: -------------------------------------------------------------------------------- 1 | # encoding: UTF-8 2 | require 'set' 3 | require 'forwardable' 4 | 5 | module Plucky 6 | class Query 7 | include Enumerable 8 | extend Forwardable 9 | 10 | # Private 11 | OptionKeys = Set[ 12 | :select, :offset, :order, :transformer, # MM 13 | :projection, :fields, :skip, :limit, :sort, :hint, :snapshot, # Ruby Driver 14 | :batch_size, :timeout, :max_scan, :return_key, # Ruby Driver 15 | :show_disk_loc, :comment, :read, # Ruby Driver 16 | :tag_sets, :acceptable_latency, # Ruby Driver 17 | :max_time_ms, :no_cursor_timeout, :collation, :modifiers 18 | ] 19 | 20 | attr_reader :criteria, :options, :collection 21 | 22 | def_delegator :@criteria, :simple? 23 | def_delegator :@options, :fields? 24 | def_delegators :to_a, :include? 25 | 26 | # Public 27 | def initialize(collection, query_options = {}) 28 | @collection, @options, @criteria = collection, OptionsHash.new, CriteriaHash.new 29 | query_options.each { |key, value| self[key] = value } 30 | end 31 | 32 | def initialize_copy(original) 33 | super 34 | @criteria = @criteria.dup 35 | @options = @options.dup 36 | end 37 | 38 | # Public 39 | def object_ids(*keys) 40 | return @criteria.object_ids if keys.empty? 41 | @criteria.object_ids = *keys 42 | self 43 | end 44 | 45 | # finder DSL methods to delegate to your model if you're building an ODM 46 | # e.g. MyModel.last needs to be equivalent to MyModel.query.last 47 | module DSL 48 | def per_page(limit=nil) 49 | return @per_page || 25 if limit.nil? 50 | @per_page = limit 51 | self 52 | end 53 | 54 | def paginate(opts={}) 55 | page = opts.delete(:page) 56 | limit = opts.delete(:per_page) || per_page 57 | total_entries = opts.delete(:total_entries) 58 | query = clone.amend(opts) 59 | paginator = Pagination::Paginator.new(total_entries || query.count, page, limit) 60 | docs = query.amend({ 61 | :limit => paginator.limit, 62 | :skip => paginator.skip, 63 | }).all 64 | 65 | Pagination::Collection.new(docs, paginator) 66 | end 67 | 68 | def find_each(opts={}) 69 | query = clone.amend(opts) 70 | 71 | if block_given? 72 | enumerator = query.enumerator 73 | enumerator.each do |doc| 74 | yield doc 75 | end 76 | enumerator 77 | else 78 | query.enumerator 79 | end 80 | end 81 | 82 | def find_one(opts={}) 83 | query = clone.amend(opts.merge(limit: -1)) 84 | query.enumerator.first 85 | end 86 | 87 | def find(*ids) 88 | return nil if ids.empty? 89 | 90 | single_id_find = ids.size == 1 && !ids[0].is_a?(Array) 91 | 92 | if single_id_find 93 | first(:_id => ids[0]) 94 | else 95 | all(:_id => ids.flatten) 96 | end 97 | end 98 | 99 | def all(opts={}) 100 | [].tap do |docs| 101 | find_each(opts) {|doc| docs << doc } 102 | end 103 | end 104 | 105 | def last(opts={}) 106 | clone.amend(opts).reverse.find_one 107 | end 108 | 109 | def remove(opts={}, driver_opts={}) 110 | query = clone.amend(opts) 111 | query.collection.find(query.criteria_hash, driver_opts).delete_many 112 | end 113 | 114 | def count(opts={}) 115 | query = clone.amend(opts) 116 | cursor = query.view 117 | cursor.count 118 | end 119 | 120 | def distinct(key, opts = {}) 121 | query = clone.amend(opts) 122 | query.collection.distinct(key, query.criteria_hash) 123 | end 124 | 125 | def projection(*args) 126 | clone.tap { |query| query.options[:projection] = *args } 127 | end 128 | 129 | def ignore(*args) 130 | set_field_inclusion(args, 0) 131 | end 132 | 133 | def only(*args) 134 | set_field_inclusion(args, 1) 135 | end 136 | 137 | def limit(count=nil) 138 | clone.tap { |query| query.options[:limit] = count } 139 | end 140 | 141 | def reverse 142 | clone.tap do |query| 143 | sort = query[:sort] 144 | if sort.nil? 145 | query.options[:sort] = [[:_id, -1]] 146 | else 147 | query.options[:sort] = sort.map { |s| [s[0], -s[1]] } 148 | end 149 | end 150 | end 151 | 152 | def skip(count=nil) 153 | clone.tap { |query| query.options[:skip] = count } 154 | end 155 | 156 | def sort(*args) 157 | clone.tap { |query| query.options[:sort] = *args } 158 | end 159 | 160 | def where(hash={}) 161 | clone.tap { |query| query.criteria.merge!(CriteriaHash.new(hash)) } 162 | end 163 | 164 | def empty? 165 | count == 0 166 | end 167 | 168 | def exists?(query_options={}) 169 | !only(:_id).find_one(query_options).nil? 170 | end 171 | 172 | alias_method :each, :find_each 173 | alias_method :first, :find_one 174 | alias_method :size, :count 175 | alias_method :offset, :skip 176 | alias_method :order, :sort 177 | alias_method :exist?, :exists? 178 | alias_method :filter, :where 179 | alias_method :to_a, :all 180 | alias_method :fields, :projection 181 | end 182 | include DSL 183 | 184 | def update(document, driver_opts={}) 185 | query = clone 186 | if driver_opts[:multi] 187 | query.collection.find(query.criteria_hash).update_many(document, driver_opts) 188 | else 189 | query.collection.find(query.criteria_hash).update_one(document, driver_opts) 190 | end 191 | end 192 | 193 | def insert(document_or_array, driver_opts={}) 194 | query = clone 195 | 196 | if document_or_array.is_a?(Array) 197 | query.collection.insert_many(document_or_array, driver_opts) 198 | else 199 | query.collection.insert_one(document_or_array, driver_opts) 200 | end 201 | end 202 | 203 | def amend(opts={}) 204 | opts.each { |key, value| self[key] = value } 205 | self 206 | end 207 | 208 | def [](key) 209 | key = symbolized_key(key) 210 | source = hash_for_key(key) 211 | source[key] 212 | end 213 | 214 | def []=(key, value) 215 | key = symbolized_key(key) 216 | source = hash_for_key(key) 217 | source[key] = value 218 | end 219 | 220 | def merge(other) 221 | merged_criteria = @criteria.merge(other.criteria).to_hash 222 | merged_options = @options.merge(other.options).to_hash 223 | clone.amend(merged_criteria).amend(merged_options) 224 | end 225 | 226 | def to_hash 227 | criteria_hash.merge(options_hash) 228 | end 229 | 230 | def explain 231 | @collection.find(criteria_hash, options_hash).explain 232 | end 233 | 234 | def inspect 235 | as_nice_string = to_hash.collect do |key, value| 236 | " #{key}: #{value.inspect}" 237 | end.sort.join(",") 238 | "#<#{self.class}#{as_nice_string}>" 239 | end 240 | 241 | def criteria_hash 242 | @criteria.to_hash 243 | end 244 | 245 | def options_hash 246 | @options.to_hash 247 | end 248 | 249 | def view 250 | driver_opts = options_hash.dup 251 | driver_opts.delete :transformer 252 | case driver_opts[:read] 253 | when Hash 254 | driver_opts[:read] = Mongo::ServerSelector.get(driver_opts[:read]) 255 | when Symbol 256 | driver_opts[:read] = Mongo::ServerSelector.get(mode: driver_opts[:read]) 257 | else 258 | raise "Unexpected read options: #{driver_opts[:read]} - expected hash or symbol" 259 | end if driver_opts.has_key?(:read) 260 | 261 | @collection.find(criteria_hash, driver_opts) 262 | end 263 | 264 | def enumerator 265 | if transformer = options_hash[:transformer] 266 | Transformer.new(view, transformer).to_enum 267 | else 268 | view.to_enum 269 | end 270 | end 271 | 272 | private 273 | 274 | # Private 275 | def hash_for_key(key) 276 | options_key?(key) ? @options : @criteria 277 | end 278 | 279 | # Private 280 | def symbolized_key(key) 281 | if key.respond_to?(:to_sym) 282 | key.to_sym 283 | else 284 | key 285 | end 286 | end 287 | 288 | # Private 289 | def options_key?(key) 290 | OptionKeys.include?(key) 291 | end 292 | 293 | # Private 294 | def set_field_inclusion(fields, value) 295 | fields_option = {} 296 | fields.each { |field| fields_option[symbolized_key(field)] = value } 297 | clone.tap { |query| query.options[:projection] = fields_option } 298 | end 299 | end 300 | end 301 | -------------------------------------------------------------------------------- /spec/plucky/criteria_hash_spec.rb: -------------------------------------------------------------------------------- 1 | require 'helper' 2 | 3 | describe Plucky::CriteriaHash do 4 | context "#initialize_copy" do 5 | before do 6 | @original = described_class.new({ 7 | :comments => {:_id => 1}, :tags => ['mongo', 'ruby'], 8 | }, :object_ids => [:_id]) 9 | @cloned = @original.clone 10 | end 11 | 12 | it "duplicates source hash" do 13 | @cloned.source.should_not equal(@original.source) 14 | end 15 | 16 | it "duplicates options hash" do 17 | @cloned.options.should_not equal(@original.options) 18 | end 19 | 20 | it "clones duplicable? values" do 21 | @cloned[:comments].should_not equal(@original[:comments]) 22 | @cloned[:tags].should_not equal(@original[:tags]) 23 | end 24 | end 25 | 26 | context "#object_ids=" do 27 | it "works with array" do 28 | criteria = described_class.new 29 | criteria.object_ids = [:_id] 30 | criteria.object_ids.should == [:_id] 31 | end 32 | 33 | it "flattens multi-dimensional array" do 34 | criteria = described_class.new 35 | criteria.object_ids = [[:_id]] 36 | criteria.object_ids.should == [:_id] 37 | end 38 | 39 | it "raises argument error if not array" do 40 | lambda { described_class.new.object_ids = {} }.should raise_error(ArgumentError) 41 | lambda { described_class.new.object_ids = nil }.should raise_error(ArgumentError) 42 | lambda { described_class.new.object_ids = 'foo' }.should raise_error(ArgumentError) 43 | end 44 | end 45 | 46 | context "#[]=" do 47 | context "with key and value" do 48 | let(:key_normalizer) { lambda { |*args| :normalized_key } } 49 | let(:value_normalizer) { lambda { |*args| 'normalized_value' } } 50 | 51 | it "sets normalized key to normalized value in source" do 52 | criteria = described_class.new({}, :value_normalizer => value_normalizer, :key_normalizer => key_normalizer) 53 | criteria[:foo] = 'bar' 54 | criteria.source[:normalized_key].should eq('normalized_value') 55 | end 56 | end 57 | 58 | context "with conditions" do 59 | it "sets each of conditions keys in source" do 60 | criteria = described_class.new 61 | criteria[:conditions] = {:_id => 'john', :foo => 'bar'} 62 | criteria.source[:_id].should eq('john') 63 | criteria.source[:foo].should eq('bar') 64 | end 65 | end 66 | 67 | context "with symbol operators" do 68 | it "sets nests key with operator and value" do 69 | criteria = described_class.new 70 | criteria[:age.gt] = 20 71 | criteria[:age.lt] = 10 72 | criteria.source[:age].should eq({:$gt => 20, :$lt => 10}) 73 | end 74 | end 75 | end 76 | 77 | context "#merge" do 78 | it "works when no keys match" do 79 | c1 = described_class.new(:foo => 'bar') 80 | c2 = described_class.new(:baz => 'wick') 81 | c1.merge(c2).source.should eq(:foo => 'bar', :baz => 'wick') 82 | end 83 | 84 | it "turns matching keys with simple values into array" do 85 | c1 = described_class.new(:foo => 'bar') 86 | c2 = described_class.new(:foo => 'baz') 87 | c1.merge(c2).source.should eq(:foo => {:$in => %w[bar baz]}) 88 | end 89 | 90 | it "uniques matching key values" do 91 | c1 = described_class.new(:foo => 'bar') 92 | c2 = described_class.new(:foo => 'bar') 93 | c1.merge(c2).source.should eq(:foo => 'bar') 94 | end 95 | 96 | it "correctly merges arrays and non-arrays" do 97 | c1 = described_class.new(:foo => 'bar') 98 | c2 = described_class.new(:foo => %w[bar baz]) 99 | c1.merge(c2).source.should eq(:foo => {:$in => %w[bar baz]}) 100 | c2.merge(c1).source.should eq(:foo => {:$in => %w[bar baz]}) 101 | end 102 | 103 | it "correctly merges two bson object ids" do 104 | id1 = BSON::ObjectId.new 105 | id2 = BSON::ObjectId.new 106 | c1 = described_class.new(:foo => id1) 107 | c2 = described_class.new(:foo => id2) 108 | c1.merge(c2).source.should eq(:foo => {:$in => [id1, id2]}) 109 | end 110 | 111 | it "correctly merges array and an object id" do 112 | id1 = BSON::ObjectId.new 113 | id2 = BSON::ObjectId.new 114 | c1 = described_class.new(:foo => [id1]) 115 | c2 = described_class.new(:foo => id2) 116 | c1.merge(c2).source.should eq(:foo => {:$in => [id1, id2]}) 117 | c2.merge(c1).source.should eq(:foo => {:$in => [id1, id2]}) 118 | end 119 | 120 | it "is able to merge two modifier hashes" do 121 | c1 = described_class.new(:$in => [1, 2]) 122 | c2 = described_class.new(:$in => [2, 3]) 123 | c1.merge(c2).source.should eq(:$in => [1, 2, 3]) 124 | end 125 | 126 | it "is able to merge two modifier hashes with hash values" do 127 | c1 = described_class.new(:arr => {:$elemMatch => {:foo => 'bar'}}) 128 | c2 = described_class.new(:arr => {:$elemMatch => {:omg => 'ponies'}}) 129 | c1.merge(c2).source.should eq(:arr => {:$elemMatch => {:foo => 'bar', :omg => 'ponies'}}) 130 | end 131 | 132 | it "merges matching keys with a single modifier" do 133 | c1 = described_class.new(:foo => {:$in => [1, 2, 3]}) 134 | c2 = described_class.new(:foo => {:$in => [1, 4, 5]}) 135 | c1.merge(c2).source.should eq(:foo => {:$in => [1, 2, 3, 4, 5]}) 136 | end 137 | 138 | it "merges matching keys with multiple modifiers" do 139 | c1 = described_class.new(:foo => {:$in => [1, 2, 3]}) 140 | c2 = described_class.new(:foo => {:$all => [1, 4, 5]}) 141 | c1.merge(c2).source.should eq(:foo => {:$in => [1, 2, 3], :$all => [1, 4, 5]}) 142 | end 143 | 144 | it "does not update mergee" do 145 | c1 = described_class.new(:foo => 'bar') 146 | c2 = described_class.new(:foo => 'baz') 147 | c1.merge(c2).should_not equal(c1) 148 | c1[:foo].should == 'bar' 149 | end 150 | 151 | it "merges two hashes with the same key, but nil values as nil" do 152 | c1 = described_class.new(:foo => nil) 153 | c2 = described_class.new(:foo => nil) 154 | c1.merge(c2).source.should == { :foo => nil } 155 | end 156 | 157 | it "merges two hashes with the same key, but false values as false" do 158 | c1 = described_class.new(:foo => false) 159 | c2 = described_class.new(:foo => false) 160 | c1.merge(c2).source.should == { :foo => false } 161 | end 162 | 163 | it "merges two hashes with the same key, but different values with $in" do 164 | c1 = described_class.new(:foo => false) 165 | c2 = described_class.new(:foo => true) 166 | c1.merge(c2).source.should == { :foo => { :'$in' => [false, true] } } 167 | end 168 | 169 | it "merges two hashes with different keys and different values properly" do 170 | c1 = described_class.new(:foo => 1) 171 | c2 = described_class.new(:bar => 2) 172 | c1.merge(c2).source.should == { :foo => 1, :bar => 2 } 173 | end 174 | 175 | it "merges two sets" do 176 | c1 = described_class.new(:foo => Set.new([1, 2])) 177 | c2 = described_class.new(:foo => Set.new([2, 3])) 178 | c1.merge(c2).source.should == { :foo => { :'$in' => [1,2,3] } } 179 | end 180 | 181 | context "given multiple $or clauses" do 182 | before do 183 | @c1 = described_class.new(:$or => [{:a => 1}, {:b => 2}]) 184 | @c2 = described_class.new(:$or => [{:a => 3}, {:b => 4}]) 185 | @c3 = described_class.new(:$or => [{:a => 4}, {:b => 4}]) 186 | end 187 | 188 | it "merges two $ors into a compound $and" do 189 | merged = @c1.merge(@c2) 190 | merged[:$and].should == [ 191 | {:$or => [{:a => 1}, {:b => 2}]}, 192 | {:$or => [{:a => 3}, {:b => 4}]} 193 | ] 194 | end 195 | 196 | it "merges an $and and a $or into a compound $and" do 197 | merged = @c1.merge(@c2).merge(@c3) 198 | merged[:$and].should == [ 199 | {:$or => [{:a => 1}, {:b => 2}]}, 200 | {:$or => [{:a => 3}, {:b => 4}]}, 201 | {:$or => [{:a => 4}, {:b => 4}]} 202 | ] 203 | end 204 | 205 | it "merges an $or and an $and into a compound $and" do 206 | merged = @c3.merge @c1.merge(@c2) 207 | merged[:$and].should == [ 208 | {:$or => [{:a => 1}, {:b => 2}]}, 209 | {:$or => [{:a => 3}, {:b => 4}]}, 210 | {:$or => [{:a => 4}, {:b => 4}]} 211 | ] 212 | end 213 | end 214 | end 215 | 216 | context "#merge!" do 217 | it "updates mergee" do 218 | c1 = described_class.new(:foo => 'bar') 219 | c2 = described_class.new(:foo => 'baz') 220 | c1.merge!(c2).should equal(c1) 221 | c1[:foo].should == {:$in => ['bar', 'baz']} 222 | end 223 | end 224 | 225 | context "#simple?" do 226 | it "returns true if only filtering by _id" do 227 | described_class.new(:_id => 'id').should be_simple 228 | end 229 | 230 | it "returns true if only filtering by Sci" do 231 | described_class.new(:_id => 'id', :_type => 'Foo').should be_simple 232 | described_class.new(:_type => 'Foo', :_id => 'id').should be_simple # reverse order 233 | end 234 | 235 | it "returns false if querying by more than max number of simple keys" do 236 | described_class.new(:one => 1, :two => 2, :three => 3).should_not be_simple 237 | end 238 | 239 | it "returns false if querying by anthing other than _id/Sci" do 240 | described_class.new(:foo => 'bar').should_not be_simple 241 | end 242 | 243 | it "returns false if querying only by _type" do 244 | described_class.new(:_type => 'Foo').should_not be_simple 245 | end 246 | end 247 | end 248 | -------------------------------------------------------------------------------- /spec/plucky/query_spec.rb: -------------------------------------------------------------------------------- 1 | require 'helper' 2 | 3 | describe Plucky::Query do 4 | before do 5 | @chris = Hash['_id', 'chris', 'age', 26, 'name', 'Chris'] 6 | @steve = Hash['_id', 'steve', 'age', 29, 'name', 'Steve'] 7 | @john = Hash['_id', 'john', 'age', 28, 'name', 'John'] 8 | @collection = DB['users'] 9 | @collection.insert_one(@chris) 10 | @collection.insert_one(@steve) 11 | @collection.insert_one(@john) 12 | end 13 | 14 | context "#initialize" do 15 | before { @query = described_class.new(@collection) } 16 | subject { @query } 17 | 18 | it "defaults options to options hash" do 19 | @query.options.should be_instance_of(Plucky::OptionsHash) 20 | end 21 | 22 | it "defaults criteria to criteria hash" do 23 | @query.criteria.should be_instance_of(Plucky::CriteriaHash) 24 | end 25 | end 26 | 27 | context "#initialize_copy" do 28 | before do 29 | @original = described_class.new(@collection) 30 | @cloned = @original.clone 31 | end 32 | 33 | it "duplicates options" do 34 | @cloned.options.should_not equal(@original.options) 35 | end 36 | 37 | it "duplicates criteria" do 38 | @cloned.criteria.should_not equal(@original.criteria) 39 | end 40 | end 41 | 42 | context "#[]=" do 43 | before { @query = described_class.new(@collection) } 44 | subject { @query } 45 | 46 | it "sets key on options for option" do 47 | subject[:skip] = 1 48 | subject[:skip].should == 1 49 | end 50 | 51 | it "sets key on criteria for criteria" do 52 | subject[:foo] = 'bar' 53 | subject[:foo].should == 'bar' 54 | end 55 | end 56 | 57 | context "#find_each" do 58 | it "returns a cursor" do 59 | cursor = described_class.new(@collection).find_each 60 | cursor.should be_instance_of(Enumerator) 61 | end 62 | 63 | it "works with and normalize criteria" do 64 | cursor = described_class.new(@collection).find_each(:id.in => ['john']) 65 | cursor.to_a.should == [@john] 66 | end 67 | 68 | it "works with and normalize options" do 69 | cursor = described_class.new(@collection).find_each(:order => :name.asc) 70 | cursor.to_a.should == [@chris, @john, @steve] 71 | end 72 | 73 | it "yields elements to a block if given" do 74 | yielded_elements = Set.new 75 | described_class.new(@collection).find_each { |doc| yielded_elements << doc } 76 | yielded_elements.should == [@chris, @john, @steve].to_set 77 | end 78 | 79 | it "is Ruby-like and returns a reset cursor if a block is given" do 80 | cursor = described_class.new(@collection).find_each {} 81 | cursor.should be_instance_of(Enumerator) 82 | cursor.next.should be_kind_of(Hash) 83 | end 84 | end 85 | 86 | context "#find_one" do 87 | it "works with and normalize criteria" do 88 | described_class.new(@collection).find_one(:id.in => ['john']).should == @john 89 | end 90 | 91 | it "works with and normalize options" do 92 | described_class.new(@collection).find_one(:order => :age.desc).should == @steve 93 | end 94 | end 95 | 96 | context "#find" do 97 | before do 98 | @query = described_class.new(@collection) 99 | end 100 | subject { @query } 101 | 102 | it "works with single id" do 103 | @query.find('chris').should == @chris 104 | end 105 | 106 | it "works with multiple ids" do 107 | @query.find('chris', 'john').should == [@chris, @john] 108 | end 109 | 110 | it "works with array of one id" do 111 | @query.find(['chris']).should == [@chris] 112 | end 113 | 114 | it "works with array of ids" do 115 | @query.find(['chris', 'john']).should == [@chris, @john] 116 | end 117 | 118 | it "ignores those not found" do 119 | @query.find('john', 'frank').should == [@john] 120 | end 121 | 122 | it "returns nil for nil" do 123 | @query.find(nil).should be_nil 124 | end 125 | 126 | it "returns nil for *nil" do 127 | @query.find(*nil).should be_nil 128 | end 129 | 130 | it "normalizes if using object id" do 131 | id = @collection.insert_one(:name => 'Frank').inserted_id 132 | @query.object_ids([:_id]) 133 | doc = @query.find(id.to_s) 134 | doc['name'].should == 'Frank' 135 | end 136 | end 137 | 138 | context "#per_page" do 139 | it "defaults to 25" do 140 | described_class.new(@collection).per_page.should == 25 141 | end 142 | 143 | it "is changeable and chainable" do 144 | query = described_class.new(@collection) 145 | query.per_page(10).per_page.should == 10 146 | end 147 | end 148 | 149 | context "#paginate" do 150 | before do 151 | @query = described_class.new(@collection).sort(:age).per_page(1) 152 | end 153 | subject { @query } 154 | 155 | it "defaults to page 1" do 156 | subject.paginate.should == [@chris] 157 | end 158 | 159 | it "works with other pages" do 160 | subject.paginate(:page => 2).should == [@john] 161 | subject.paginate(:page => 3).should == [@steve] 162 | end 163 | 164 | it "works with string page number" do 165 | subject.paginate(:page => '2').should == [@john] 166 | end 167 | 168 | it "allows changing per_page" do 169 | subject.paginate(:per_page => 2).should == [@chris, @john] 170 | end 171 | 172 | it "decorates return value" do 173 | docs = subject.paginate 174 | docs.should respond_to(:paginator) 175 | docs.should respond_to(:total_entries) 176 | end 177 | 178 | it "does not modify the original query" do 179 | subject.paginate(:name => 'John') 180 | subject[:page].should be_nil 181 | subject[:per_page].should be_nil 182 | subject[:name].should be_nil 183 | end 184 | 185 | it "allows total_entries overrides" do 186 | docs = subject.paginate(:total_entries => 1) 187 | docs.total_entries.should == 1 188 | end 189 | 190 | context "with options" do 191 | before do 192 | @result = @query.sort(:age).paginate(:age.gt => 27, :per_page => 10) 193 | end 194 | subject { @result } 195 | 196 | it "only returns matching" do 197 | subject.should == [@john, @steve] 198 | end 199 | 200 | it "correctly counts matching" do 201 | subject.total_entries.should == 2 202 | end 203 | end 204 | end 205 | 206 | context "#view" do 207 | it 'returns a mongo::collection::view' do 208 | described_class.new(@collection).view.should be_a(Mongo::Collection::View) 209 | end 210 | 211 | it 'converts criteria into the view' do 212 | view = described_class.new(@collection).where(:name => "bob").view 213 | view.filter.should == {"name" => "bob"} 214 | end 215 | 216 | it "converts read option symbol to a server selector" do 217 | view = described_class.new(@collection, :read => :secondary_preferred).view 218 | view.read.should be_a(Mongo::ServerSelector::SecondaryPreferred) 219 | end 220 | 221 | it "converts read option hash to a server selector" do 222 | view = described_class.new(@collection, :read => {:mode =>:secondary_preferred}).view 223 | view.read.should be_a(Mongo::ServerSelector::SecondaryPreferred) 224 | end 225 | end 226 | 227 | context "#all" do 228 | it "works with no arguments" do 229 | docs = described_class.new(@collection).all 230 | docs.size.should == 3 231 | docs.should include(@john) 232 | docs.should include(@steve) 233 | docs.should include(@chris) 234 | end 235 | 236 | it "works with and normalize criteria" do 237 | docs = described_class.new(@collection).all(:id.in => ['steve']) 238 | docs.should == [@steve] 239 | end 240 | 241 | it "works with and normalize options" do 242 | docs = described_class.new(@collection).all(:order => :name.asc) 243 | docs.should == [@chris, @john, @steve] 244 | end 245 | 246 | it "does not modify original query object" do 247 | query = described_class.new(@collection) 248 | query.all(:name => 'Steve') 249 | query[:name].should be_nil 250 | end 251 | end 252 | 253 | context "#first" do 254 | it "works with and normalize criteria" do 255 | described_class.new(@collection).first(:age.lt => 29).should == @chris 256 | end 257 | 258 | it "works with and normalize options" do 259 | described_class.new(@collection).first(:age.lte => 29, :order => :name.desc).should == @steve 260 | end 261 | 262 | it "does not modify original query object" do 263 | query = described_class.new(@collection) 264 | query.first(:name => 'Steve') 265 | query[:name].should be_nil 266 | end 267 | end 268 | 269 | context "#last" do 270 | it "works with and normalize criteria" do 271 | described_class.new(@collection).last(:age.lte => 29, :order => :name.asc).should == @steve 272 | end 273 | 274 | it "works with and normalize options" do 275 | described_class.new(@collection).last(:age.lte => 26, :order => :name.desc).should == @chris 276 | end 277 | 278 | it "uses _id if a sort key is not specified" do 279 | described_class.new(@collection).last.should == [@steve, @chris, @john].sort {|a, b| a["_id"] <=> b["_id"] }.last 280 | end 281 | 282 | it "does not modify original query object" do 283 | query = described_class.new(@collection) 284 | query.last(:name => 'Steve') 285 | query[:name].should be_nil 286 | end 287 | end 288 | 289 | context "#count" do 290 | it "works with no arguments" do 291 | described_class.new(@collection).count.should == 3 292 | end 293 | 294 | it "works with and normalize criteria" do 295 | described_class.new(@collection).count(:age.lte => 28).should == 2 296 | end 297 | 298 | it "does not modify original query object" do 299 | query = described_class.new(@collection) 300 | query.count(:name => 'Steve') 301 | query[:name].should be_nil 302 | end 303 | 304 | it 'counts the result set and not the enumerator' do 305 | described_class.new(@collection).limit(1).count.should == 3 306 | end 307 | end 308 | 309 | context "#size" do 310 | it "works just like count without options" do 311 | described_class.new(@collection).size.should == 3 312 | end 313 | end 314 | 315 | context "#distinct" do 316 | before do 317 | # same age as John 318 | @mark = Hash['_id', 'mark', 'age', 28, 'name', 'Mark'] 319 | @collection.insert_one(@mark) 320 | end 321 | 322 | it "works with just a key" do 323 | described_class.new(@collection).distinct(:age).sort.should == [26, 28, 29] 324 | end 325 | 326 | it "works with criteria" do 327 | described_class.new(@collection).distinct(:age, :age.gt => 26).sort.should == [28, 29] 328 | end 329 | 330 | it "does not modify the original query object" do 331 | query = described_class.new(@collection) 332 | query.distinct(:age, :name => 'Mark').should == [28] 333 | query[:name].should be_nil 334 | end 335 | end 336 | 337 | context "#remove" do 338 | it "works with no arguments" do 339 | lambda { described_class.new(@collection).remove }.should change { @collection.count }.by(-3) 340 | end 341 | 342 | it "works with and normalize criteria" do 343 | lambda { described_class.new(@collection).remove(:age.lte => 28) }.should change { @collection.count } 344 | end 345 | 346 | it "works with options" do 347 | lambda { described_class.new(@collection).remove({:age.lte => 28}, :w => 1) }.should change { @collection.count } 348 | end 349 | 350 | it "does not modify original query object" do 351 | query = described_class.new(@collection) 352 | query.remove(:name => 'Steve') 353 | query[:name].should be_nil 354 | end 355 | end 356 | 357 | context "#update" do 358 | before do 359 | @query = described_class.new(@collection).where('_id' => 'john') 360 | end 361 | 362 | it "works with document" do 363 | @query.update('$set' => {'age' => 29}) 364 | doc = @query.first('_id' => 'john') 365 | doc['age'].should be(29) 366 | end 367 | 368 | it "works with document and driver options" do 369 | @query.update({'$set' => {'age' => 30}}, :multi => true) 370 | @query.each do |doc| 371 | doc['age'].should be(30) 372 | end 373 | end 374 | end 375 | 376 | context "#[]" do 377 | it "returns value if key in criteria (symbol)" do 378 | described_class.new(@collection, :count => 1)[:count].should == 1 379 | end 380 | 381 | it "returns value if key in criteria (string)" do 382 | described_class.new(@collection, :count => 1)['count'].should == 1 383 | end 384 | 385 | it "returns nil if key not in criteria" do 386 | described_class.new(@collection)[:count].should be_nil 387 | end 388 | end 389 | 390 | context "#[]=" do 391 | before { @query = described_class.new(@collection) } 392 | 393 | it "sets the value of the given criteria key" do 394 | @query[:count] = 1 395 | @query[:count].should == 1 396 | end 397 | 398 | it "overwrites value if key already exists" do 399 | @query[:count] = 1 400 | @query[:count] = 2 401 | @query[:count].should == 2 402 | end 403 | 404 | it "normalizes value" do 405 | now = Time.now 406 | @query[:published_at] = now 407 | @query[:published_at].should == now.utc 408 | end 409 | end 410 | 411 | context "#fields" do 412 | before { @query = described_class.new(@collection) } 413 | subject { @query } 414 | 415 | it "works" do 416 | subject.fields(:name).first(:id => 'john').keys.should == ['_id', 'name'] 417 | end 418 | 419 | it "returns new instance of query" do 420 | new_query = subject.fields(:name) 421 | new_query.should_not equal(subject) 422 | subject[:fields].should be_nil 423 | end 424 | 425 | it "works with hash" do 426 | subject.fields(:name => 0). 427 | first(:id => 'john').keys.sort. 428 | should == ['_id', 'age'] 429 | end 430 | end 431 | 432 | context "#ignore" do 433 | before { @query = described_class.new(@collection) } 434 | subject { @query } 435 | 436 | it "includes a list of keys to ignore" do 437 | new_query = subject.ignore(:name).first(:id => 'john') 438 | new_query.keys.should == ['_id', 'age'] 439 | end 440 | end 441 | 442 | context "#only" do 443 | before { @query = described_class.new(@collection) } 444 | subject { @query } 445 | 446 | it "includes a list of keys with others excluded" do 447 | new_query = subject.only(:name).first(:id => 'john') 448 | new_query.keys.should == ['_id', 'name'] 449 | end 450 | 451 | end 452 | 453 | context "#skip" do 454 | before { @query = described_class.new(@collection) } 455 | subject { @query } 456 | 457 | it "works" do 458 | subject.skip(2).all(:order => :age).should == [@steve] 459 | end 460 | 461 | it "sets skip option" do 462 | subject.skip(5).options[:skip].should == 5 463 | end 464 | 465 | it "overrides existing skip" do 466 | subject.skip(5).skip(10).options[:skip].should == 10 467 | end 468 | 469 | it "returns nil for nil" do 470 | subject.skip.options[:skip].should be_nil 471 | end 472 | 473 | it "returns new instance of query" do 474 | new_query = subject.skip(2) 475 | new_query.should_not equal(subject) 476 | subject[:skip].should be_nil 477 | end 478 | 479 | it "aliases to offset" do 480 | subject.offset(5).options[:skip].should == 5 481 | end 482 | end 483 | 484 | context "#limit" do 485 | before { @query = described_class.new(@collection) } 486 | subject { @query } 487 | 488 | it "works" do 489 | subject.limit(2).all(:order => :age).should == [@chris, @john] 490 | end 491 | 492 | it "sets limit option" do 493 | subject.limit(5).options[:limit].should == 5 494 | end 495 | 496 | it "overwrites existing limit" do 497 | subject.limit(5).limit(15).options[:limit].should == 15 498 | end 499 | 500 | it "returns new instance of query" do 501 | new_query = subject.limit(2) 502 | new_query.should_not equal(subject) 503 | subject[:limit].should be_nil 504 | end 505 | end 506 | 507 | context "#sort" do 508 | before { @query = described_class.new(@collection) } 509 | subject { @query } 510 | 511 | it "works" do 512 | subject.sort(:age).all.should == [@chris, @john, @steve] 513 | subject.sort(:age.desc).all.should == [@steve, @john, @chris] 514 | end 515 | 516 | it "works with symbol operators" do 517 | subject.sort(:foo.asc, :bar.desc).options[:sort].should == {"foo" => 1, "bar" => -1} 518 | end 519 | 520 | it "works with string" do 521 | subject.sort('foo, bar desc').options[:sort].should == {"foo" => 1, "bar" => -1} 522 | end 523 | 524 | it "works with just a symbol" do 525 | subject.sort(:foo).options[:sort].should == {"foo" => 1} 526 | end 527 | 528 | it "works with symbol descending" do 529 | subject.sort(:foo.desc).options[:sort].should == {"foo" => -1} 530 | end 531 | 532 | it "works with multiple symbols" do 533 | subject.sort(:foo, :bar).options[:sort].should == {"foo" => 1, "bar" => 1} 534 | end 535 | 536 | it "returns new instance of query" do 537 | new_query = subject.sort(:name) 538 | new_query.should_not equal(subject) 539 | subject[:sort].should be_nil 540 | end 541 | 542 | it "is aliased to order" do 543 | subject.order(:foo).options[:sort].should == {"foo" => 1} 544 | subject.order(:foo, :bar).options[:sort].should == {"foo" => 1, "bar" => 1} 545 | end 546 | end 547 | 548 | context "#reverse" do 549 | before { @query = described_class.new(@collection) } 550 | subject { @query } 551 | 552 | it "works" do 553 | subject.sort(:age).reverse.all.should == [@steve, @john, @chris] 554 | end 555 | 556 | it "does not error if no sort provided" do 557 | lambda { 558 | subject.reverse 559 | }.should_not raise_error 560 | end 561 | 562 | it "reverses the sort order" do 563 | subject.sort('foo asc, bar desc'). 564 | reverse.options[:sort].should == {"foo" => -1, "bar" => 1} 565 | end 566 | 567 | it "returns new instance of query" do 568 | sorted_query = subject.sort(:name) 569 | new_query = sorted_query.reverse 570 | new_query.should_not equal(sorted_query) 571 | sorted_query[:sort].should == {'name' => 1} 572 | end 573 | end 574 | 575 | context "#amend" do 576 | it "normalizes and update options" do 577 | described_class.new(@collection).amend(:order => :age.desc).options[:sort].should == {'age' => -1} 578 | end 579 | 580 | it "works with simple stuff" do 581 | described_class.new(@collection). 582 | amend(:foo => 'bar'). 583 | amend(:baz => 'wick'). 584 | criteria.source.should eq(:foo => 'bar', :baz => 'wick') 585 | end 586 | end 587 | 588 | context "#where" do 589 | before { @query = described_class.new(@collection) } 590 | subject { @query } 591 | 592 | it "works" do 593 | subject.where(:age.lt => 29).where(:name => 'Chris').all.should == [@chris] 594 | end 595 | 596 | it "works with literal regexp" do 597 | subject.where(:name => /^c/i).all.should == [@chris] 598 | end 599 | 600 | it "updates criteria" do 601 | subject. 602 | where(:moo => 'cow'). 603 | where(:foo => 'bar'). 604 | criteria.source.should eq(:foo => 'bar', :moo => 'cow') 605 | end 606 | 607 | it "gets normalized" do 608 | subject. 609 | where(:moo => 'cow'). 610 | where(:foo.in => ['bar']). 611 | criteria.source.should eq(:moo => 'cow', :foo => {:$in => ['bar']}) 612 | end 613 | 614 | it "normalizes merged criteria" do 615 | subject. 616 | where(:foo => 'bar'). 617 | where(:foo => 'baz'). 618 | criteria.source.should eq(:foo => {:$in => %w[bar baz]}) 619 | end 620 | 621 | it "returns new instance of query" do 622 | new_query = subject.where(:name => 'John') 623 | new_query.should_not equal(subject) 624 | subject[:name].should be_nil 625 | end 626 | end 627 | 628 | context "#filter" do 629 | before { @query = described_class.new(@collection) } 630 | subject { @query } 631 | 632 | it "works the same as where" do 633 | subject.filter(:age.lt => 29).filter(:name => 'Chris').all.should == [@chris] 634 | end 635 | end 636 | 637 | context "#empty?" do 638 | it "returns true if empty" do 639 | @collection.drop 640 | described_class.new(@collection).should be_empty 641 | end 642 | 643 | it "returns false if not empty" do 644 | described_class.new(@collection).should_not be_empty 645 | end 646 | end 647 | 648 | context "#exists?" do 649 | it "returns true if found" do 650 | described_class.new(@collection).exists?(:name => 'John').should be(true) 651 | end 652 | 653 | it "returns false if not found" do 654 | described_class.new(@collection).exists?(:name => 'Billy Bob').should be(false) 655 | end 656 | end 657 | 658 | context "#exist?" do 659 | it "returns true if found" do 660 | described_class.new(@collection).exist?(:name => 'John').should be(true) 661 | end 662 | 663 | it "returns false if not found" do 664 | described_class.new(@collection).exist?(:name => 'Billy Bob').should be(false) 665 | end 666 | end 667 | 668 | context "#include?" do 669 | it "returns true if included" do 670 | described_class.new(@collection).include?(@john).should be(true) 671 | end 672 | 673 | it "returns false if not included" do 674 | described_class.new(@collection).include?(['_id', 'frankyboy']).should be(false) 675 | end 676 | end 677 | 678 | context "#to_a" do 679 | it "returns all documents the query matches" do 680 | described_class.new(@collection).sort(:name).to_a. 681 | should == [@chris, @john, @steve] 682 | 683 | described_class.new(@collection).where(:name => 'John').sort(:name).to_a. 684 | should == [@john] 685 | end 686 | end 687 | 688 | context "#each" do 689 | it "iterates through matching documents" do 690 | docs = [] 691 | described_class.new(@collection).sort(:name).each do |doc| 692 | docs << doc 693 | end 694 | docs.should == [@chris, @john, @steve] 695 | end 696 | 697 | it "returns a working enumerator" do 698 | query = described_class.new(@collection) 699 | query.each.methods.map(&:to_sym).include?(:group_by).should be(true) 700 | query.each.next.should be_instance_of(BSON::Document) 701 | end 702 | end 703 | 704 | context "enumerables" do 705 | it "works" do 706 | query = described_class.new(@collection).sort(:name) 707 | query.map { |doc| doc['name'] }.should == %w(Chris John Steve) 708 | query.collect { |doc| doc['name'] }.should == %w(Chris John Steve) 709 | query.detect { |doc| doc['name'] == 'John' }.should == @john 710 | query.min { |a, b| a['age'] <=> b['age'] }.should == @chris 711 | end 712 | end 713 | 714 | context "#object_ids" do 715 | before { @query = described_class.new(@collection) } 716 | subject { @query } 717 | 718 | it "sets criteria's object_ids" do 719 | subject.criteria.should_receive(:object_ids=).with([:foo, :bar]) 720 | subject.object_ids(:foo, :bar) 721 | end 722 | 723 | it "returns current object ids if keys argument is empty" do 724 | subject.object_ids(:foo, :bar) 725 | subject.object_ids.should == [:foo, :bar] 726 | end 727 | end 728 | 729 | context "#merge" do 730 | it "overwrites options" do 731 | query1 = described_class.new(@collection, :skip => 5, :limit => 5) 732 | query2 = described_class.new(@collection, :skip => 10, :limit => 10) 733 | new_query = query1.merge(query2) 734 | new_query.options[:skip].should == 10 735 | new_query.options[:limit].should == 10 736 | end 737 | 738 | it "merges criteria" do 739 | query1 = described_class.new(@collection, :foo => 'bar') 740 | query2 = described_class.new(@collection, :foo => 'baz', :fent => 'wick') 741 | new_query = query1.merge(query2) 742 | new_query.criteria[:fent].should == 'wick' 743 | new_query.criteria[:foo].should == {:$in => %w[bar baz]} 744 | end 745 | 746 | it "does not affect either of the merged queries" do 747 | query1 = described_class.new(@collection, :foo => 'bar', :limit => 5) 748 | query2 = described_class.new(@collection, :foo => 'baz', :limit => 10) 749 | new_query = query1.merge(query2) 750 | query1[:foo].should == 'bar' 751 | query1[:limit].should == 5 752 | query2[:foo].should == 'baz' 753 | query2[:limit].should == 10 754 | end 755 | end 756 | 757 | context "Criteria/option auto-detection" do 758 | it "knows :conditions are criteria" do 759 | query = described_class.new(@collection, :conditions => {:foo => 'bar'}) 760 | query.criteria.source.should eq(:foo => 'bar') 761 | query.options.keys.should_not include(:conditions) 762 | end 763 | 764 | { 765 | :projection => {'foo' => 1}, 766 | :sort => {'foo' => 1}, 767 | :hint => '', 768 | :skip => 0, 769 | :limit => 0, 770 | :batch_size => 0, 771 | :timeout => 0, 772 | }.each do |option, value| 773 | it "knows #{option} is an option" do 774 | query = described_class.new(@collection, option => value) 775 | query.options[option].should == value 776 | query.criteria.keys.should_not include(option) 777 | end 778 | end 779 | 780 | it "knows select is an option and remove it from options" do 781 | query = described_class.new(@collection, :select => 'foo') 782 | query.options[:projection].should == {'foo' => 1} 783 | query.criteria.keys.should_not include(:select) 784 | query.options.keys.should_not include(:select) 785 | end 786 | 787 | it "knows order is an option and remove it from options" do 788 | query = described_class.new(@collection, :order => 'foo') 789 | query.options[:sort].should == {'foo' => 1} 790 | query.criteria.keys.should_not include(:order) 791 | query.options.keys.should_not include(:order) 792 | end 793 | 794 | it "knows offset is an option and remove it from options" do 795 | query = described_class.new(@collection, :offset => 0) 796 | query.options[:skip].should == 0 797 | query.criteria.keys.should_not include(:offset) 798 | query.options.keys.should_not include(:offset) 799 | end 800 | 801 | it "works with full range of things" do 802 | query = described_class.new(@collection, { 803 | :foo => 'bar', 804 | :baz => true, 805 | :sort => {"foo" => 1}, 806 | :fields => ['foo', 'baz'], 807 | :limit => 10, 808 | :skip => 10, 809 | }) 810 | query.criteria.source.should eq(:foo => 'bar', :baz => true) 811 | query.options.source.should eq({ 812 | :sort => {"foo" => 1}, 813 | :projection => {'foo' => 1, 'baz' => 1}, 814 | :limit => 10, 815 | :skip => 10, 816 | }) 817 | end 818 | end 819 | 820 | it "inspects pretty" do 821 | inspect = described_class.new(@collection, :baz => 'wick', :foo => 'bar').inspect 822 | inspect.should == '#' 823 | end 824 | 825 | it "delegates simple? to criteria" do 826 | query = described_class.new(@collection) 827 | query.criteria.should_receive(:simple?) 828 | query.simple? 829 | end 830 | 831 | it "delegates fields? to options" do 832 | query = described_class.new(@collection) 833 | query.options.should_receive(:fields?) 834 | query.fields? 835 | end 836 | 837 | context "#explain" do 838 | before { @query = described_class.new(@collection) } 839 | subject { @query } 840 | 841 | it "works" do 842 | explain = subject.where(:age.lt => 28).explain 843 | 844 | if explain['cursor'] 845 | explain['cursor'].should == 'BasicCursor' 846 | explain['nscanned'].should == 3 847 | elsif explain['executionStats'] 848 | explain['executionStats']['executionSuccess'].should == true 849 | explain['executionStats']['totalDocsExamined'].should == 3 850 | end 851 | end 852 | end 853 | 854 | context "Transforming documents" do 855 | before do 856 | transformer = lambda { |doc| @user_class.new(doc['_id'], doc['name'], doc['age']) } 857 | @user_class = Struct.new(:id, :name, :age) 858 | @query = described_class.new(@collection, :transformer => transformer) 859 | end 860 | 861 | it "works with find_one" do 862 | result = @query.find_one('_id' => 'john') 863 | result.should be_instance_of(@user_class) 864 | end 865 | 866 | it "works with find_each" do 867 | results = @query.find_each 868 | results.each do |result| 869 | result.should be_instance_of(@user_class) 870 | end 871 | end 872 | 873 | it "works with block form of find_each" do 874 | results = [] 875 | @query.find_each do |doc| 876 | results << doc 877 | end 878 | results.each do |result| 879 | result.should be_instance_of(@user_class) 880 | end 881 | end 882 | end 883 | 884 | describe "insert" do 885 | before { @query = described_class.new(@collection) } 886 | subject { @query } 887 | 888 | it "should be able to insert one doc" do 889 | subject.insert({ foo: 'bar' }) 890 | subject.count({ foo: 'bar' }).should == 1 891 | end 892 | 893 | it "should be able to insert multiple" do 894 | subject.insert([{ foo: 'bar' }, { baz: 'quxx' }]) 895 | subject.count({ foo: 'bar' }).should == 1 896 | subject.count({ baz: 'quxx' }).should == 1 897 | end 898 | end 899 | end 900 | --------------------------------------------------------------------------------