lib/mongoid/contextual/mongo.rb
# encoding: utf-8
require "mongoid/contextual/atomic"
require "mongoid/contextual/aggregable/mongo"
require "mongoid/contextual/command"
require "mongoid/contextual/geo_near"
require "mongoid/contextual/map_reduce"
require "mongoid/relations/eager"
module Mongoid
module Contextual
class Mongo
include Enumerable
include Aggregable::Mongo
include Atomic
include Relations::Eager
include Queryable
# Options constant.
#
# @since 5.0.0
OPTIONS = [ :hint, :limit, :skip, :sort, :batch_size, :max_scan, :snapshot, :comment, :read ].freeze
# @attribute [r] view The Mongo collection view.
attr_reader :view
# Is the context cached?
#
# @example Is the context cached?
# context.cached?
#
# @return [ true, false ] If the context is cached.
#
# @since 3.0.0
def cached?
!!@cache
end
# Get the number of documents matching the query.
#
# @example Get the number of matching documents.
# context.count
#
# @example Get the count of documents with the provided options.
# context.count(limit: 1)
#
# @example Get the count for where the provided block is true.
# context.count do |doc|
# doc.likes > 1
# end
#
# @param [ Hash ] options The options, such as skip and limit to be factored
# into the count.
#
# @return [ Integer ] The number of matches.
#
# @since 3.0.0
def count(options = {}, &block)
return super(&block) if block_given?
try_cache(:count) { view.count(options) }
end
# Delete all documents in the database that match the selector.
#
# @example Delete all the documents.
# context.delete
#
# @return [ nil ] Nil.
#
# @since 3.0.0
def delete
self.count.tap do
view.delete_many
end
end
alias :delete_all :delete
# Destroy all documents in the database that match the selector.
#
# @example Destroy all the documents.
# context.destroy
#
# @return [ nil ] Nil.
#
# @since 3.0.0
def destroy
destroyed = self.count
each do |doc|
doc.destroy
end
destroyed
end
alias :destroy_all :destroy
# Get the distinct values in the db for the provided field.
#
# @example Get the distinct values.
# context.distinct(:name)
#
# @param [ String, Symbol ] field The name of the field.
#
# @return [ Array<Object> ] The distinct values for the field.
#
# @since 3.0.0
def distinct(field)
view.distinct(klass.database_field_name(field))
end
# Iterate over the context. If provided a block, yield to a Mongoid
# document for each, otherwise return an enum.
#
# @example Iterate over the context.
# context.each do |doc|
# puts doc.name
# end
#
# @return [ Enumerator ] The enumerator.
#
# @since 3.0.0
def each(&block)
if block_given?
documents_for_iteration.each do |doc|
yield_document(doc, &block)
end
@cache_loaded = true
self
else
to_enum
end
end
# Do any documents exist for the context.
#
# @example Do any documents exist for the context.
# context.exists?
#
# @note We don't use count here since Mongo does not use counted
# b-tree indexes, unless a count is already cached then that is
# used to determine the value.
#
# @return [ true, false ] If the count is more than zero.
#
# @since 3.0.0
def exists?
return !documents.empty? if cached? && cache_loaded?
return @count > 0 if instance_variable_defined?(:@count)
try_cache(:exists) do
!!(view.projection(_id: 1).limit(1).first)
end
end
# Run an explain on the criteria.
#
# @example Explain the criteria.
# Band.where(name: "Depeche Mode").explain
#
# @return [ Hash ] The explain result.
#
# @since 3.0.0
def explain
view.explain
end
# Execute the find and modify command, used for MongoDB's
# $findAndModify.
#
# @example Execute the command.
# context.find_one_and_update({ "$inc" => { likes: 1 }})
#
# @param [ Hash ] update The updates.
# @param [ Hash ] options The command options.
#
# @option options [ :before, :after ] :return_document Return the updated document
# from before or after update.
# @option options [ true, false ] :upsert Create the document if it doesn't exist.
#
# @return [ Document ] The result of the command.
#
# @since 5.0.0
def find_one_and_update(update, options = {})
if doc = view.find_one_and_update(update, options)
Factory.from_db(klass, doc)
end
end
# Execute the find and modify command, used for MongoDB's
# $findAndModify.
#
# @example Execute the command.
# context.find_one_and_update({ likes: 1 })
#
# @param [ Hash ] update The updates.
# @param [ Hash ] options The command options.
#
# @option options [ :before, :after ] :return_document Return the updated document
# from before or after update.
# @option options [ true, false ] :upsert Create the document if it doesn't exist.
#
# @return [ Document ] The result of the command.
#
# @since 5.0.0
def find_one_and_replace(replacement, options = {})
if doc = view.find_one_and_replace(replacement, options)
Factory.from_db(klass, doc)
end
end
# Execute the find and modify command, used for MongoDB's
# $findAndModify. This deletes the found document.
#
# @example Execute the command.
# context.find_one_and_delete
#
# @return [ Document ] The result of the command.
#
# @since 5.0.0
def find_one_and_delete
if doc = view.find_one_and_delete
Factory.from_db(klass, doc)
end
end
# Get the first document in the database for the criteria's selector.
#
# @example Get the first document.
# context.first
#
# @note Mongoid previously added an _id sort when no sort parameters were
# provided explicitly by the user. This caused bad performance issues
# and was not expected, so #first/#last will no longer guarantee order
# if no sorting parameters are provided. For order guarantees - a sort
# must be explicitly provided.
#
# @return [ Document ] The first document.
#
# @since 3.0.0
def first
return documents.first if cached? && cache_loaded?
try_cache(:first) do
with_eager_loading(view.first)
end
end
alias :one :first
# Return the first result without applying sort
#
# @api private
#
# @since 4.0.2
def find_first
return documents.first if cached? && cache_loaded?
with_eager_loading(view.first)
end
# Execute a $geoNear command against the database.
#
# @example Find documents close to 10, 10.
# context.geo_near([ 10, 10 ])
#
# @example Find with spherical distance.
# context.geo_near([ 10, 10 ]).spherical
#
# @example Find with a max distance.
# context.geo_near([ 10, 10 ]).max_distance(0.5)
#
# @example Provide a distance multiplier.
# context.geo_near([ 10, 10 ]).distance_multiplier(1133)
#
# @param [ Array<Float> ] coordinates The coordinates.
#
# @return [ GeoNear ] The GeoNear command.
#
# @since 3.1.0
def geo_near(coordinates)
GeoNear.new(collection, criteria, coordinates)
end
# Invoke the block for each element of Contextual. Create a new array
# containing the values returned by the block.
#
# If the symbol field name is passed instead of the block, additional
# optimizations would be used.
#
# @example Map by some field.
# context.map(:field1)
#
# @exmaple Map with block.
# context.map(&:field1)
#
# @param [ Symbol ] field The field name.
#
# @return [ Array ] The result of mapping.
def map(field = nil, &block)
if block_given?
super(&block)
else
field = field.to_sym
criteria.only(field).map(&field.to_proc)
end
end
# Create the new Mongo context. This delegates operations to the
# underlying driver.
#
# @example Create the new context.
# Mongo.new(criteria)
#
# @param [ Criteria ] criteria The criteria.
#
# @since 3.0.0
def initialize(criteria)
@criteria, @klass, @cache = criteria, criteria.klass, criteria.options[:cache]
@collection = @klass.with(criteria.persistence_options || {}).collection
criteria.send(:merge_type_selection)
@view = collection.find(criteria.selector)
apply_options
end
delegate(:database_field_name, to: :@klass)
# Get the last document in the database for the criteria's selector.
#
# @example Get the last document.
# context.last
#
# @note Mongoid previously added an _id sort when no sort parameters were
# provided explicitly by the user. This caused bad performance issues
# and was not expected, so #first/#last will no longer guarantee order
# if no sorting parameters are provided. For order guarantees - a sort
# must be explicitly provided.
#
# @return [ Document ] The last document.
#
# @since 3.0.0
def last
try_cache(:last) do
with_inverse_sorting do
with_eager_loading(view.first)
end
end
end
# Get's the number of documents matching the query selector.
#
# @example Get the length.
# context.length
#
# @return [ Integer ] The number of documents.
#
# @since 3.0.0
def length
@length ||= self.count
end
alias :size :length
# Limits the number of documents that are returned from the database.
#
# @example Limit the documents.
# context.limit(20)
#
# @param [ Integer ] value The number of documents to return.
#
# @return [ Mongo ] The context.
#
# @since 3.0.0
def limit(value)
@view = view.limit(value) and self
end
# Initiate a map/reduce operation from the context.
#
# @example Initiate a map/reduce.
# context.map_reduce(map, reduce)
#
# @param [ String ] map The map js function.
# @param [ String ] reduce The reduce js function.
#
# @return [ MapReduce ] The map/reduce lazy wrapper.
#
# @since 3.0.0
def map_reduce(map, reduce)
MapReduce.new(collection, criteria, map, reduce)
end
# Pluck the single field values from the database. Will return duplicates
# if they exist and only works for top level fields.
#
# @example Pluck a field.
# context.pluck(:_id)
#
# @note This method will return the raw db values - it performs no custom
# serialization.
#
# @param [ String, Symbol, Array ] field Fields to pluck.
#
# @return [ Array<Object, Array> ] The plucked values.
#
# @since 3.1.0
def pluck(*fields)
normalized_select = fields.inject({}) do |hash, f|
hash[klass.database_field_name(f)] = 1
hash
end
view.projection(normalized_select).map do |doc|
if normalized_select.size == 1
doc[normalized_select.keys.first]
else
normalized_select.keys.map { |n| doc[n] }
end
end
end
# Skips the provided number of documents.
#
# @example Skip the documents.
# context.skip(20)
#
# @param [ Integer ] value The number of documents to skip.
#
# @return [ Mongo ] The context.
#
# @since 3.0.0
def skip(value)
@view = view.skip(value) and self
end
# Sorts the documents by the provided spec.
#
# @example Sort the documents.
# context.sort(name: -1, title: 1)
#
# @param [ Hash ] values The sorting values as field/direction(1/-1)
# pairs.
#
# @return [ Mongo ] The context.
#
# @since 3.0.0
def sort(values = nil, &block)
if block_given?
super(&block)
else
# update the criteria
@criteria = criteria.order_by(values)
apply_option(:sort)
self
end
end
# Update the first matching document atomically.
#
# @example Update the first matching document.
# context.update({ "$set" => { name: "Smiths" }})
#
# @param [ Hash ] attributes The new attributes for the document.
#
# @return [ nil, false ] False if no attributes were provided.
#
# @since 3.0.0
def update(attributes = nil)
update_documents(attributes)
end
# Update all the matching documents atomically.
#
# @example Update all the matching documents.
# context.update_all({ "$set" => { name: "Smiths" }})
#
# @param [ Hash ] attributes The new attributes for each document.
#
# @return [ nil, false ] False if no attributes were provided.
#
# @since 3.0.0
def update_all(attributes = nil)
update_documents(attributes, :update_many)
end
private
# yield the block given or return the cached value
#
# @param [ String, Symbol ] key The instance variable name
#
# @return the result of the block
#
# @since 3.1.4
def try_cache(key, &block)
unless cached?
yield
else
unless ret = instance_variable_get("@#{key}")
instance_variable_set("@#{key}", ret = yield)
end
ret
end
end
# Update the documents for the provided method.
#
# @api private
#
# @example Update the documents.
# context.update_documents(attrs)
#
# @param [ Hash ] attributes The updates.
# @param [ Symbol ] method The method to use.
#
# @return [ true, false ] If the update succeeded.
#
# @since 3.0.4
def update_documents(attributes, method = :update_one)
return false unless attributes
attributes = Hash[attributes.map { |k, v| [klass.database_field_name(k.to_s), v] }]
view.send(method, attributes.__consolidate__(klass))
end
# Apply the field limitations.
#
# @api private
#
# @example Apply the field limitations.
# context.apply_fields
#
# @since 3.0.0
def apply_fields
if spec = criteria.options[:fields]
@view = view.projection(spec)
end
end
# Apply the options.
#
# @api private
#
# @example Apply all options.
# context.apply_options
#
# @since 3.1.0
def apply_options
apply_fields
OPTIONS.each do |name|
apply_option(name)
end
if criteria.options[:timeout] == false
@view = view.no_cursor_timeout
end
end
# Apply an option.
#
# @api private
#
# @example Apply the skip option.
# context.apply_option(:skip)
#
# @since 3.1.0
def apply_option(name)
if spec = criteria.options[name]
@view = view.send(name, spec)
end
end
# Map the inverse sort symbols to the correct MongoDB values.
#
# @api private
#
# @example Apply the inverse sorting params to the given block
# context.with_inverse_sorting
#
# @since 3.0.0
def with_inverse_sorting
begin
if spec = criteria.options[:sort]
@view = view.sort(Hash[spec.map{|k, v| [k, -1*v]}])
end
yield
ensure
apply_option(:sort)
end
end
# Is the cache able to be added to?
#
# @api private
#
# @example Is the context cacheable?
# context.cacheable?
#
# @return [ true, false ] If caching, and the cache isn't loaded.
#
# @since 3.0.0
def cacheable?
cached? && !cache_loaded?
end
# Is the cache fully loaded? Will be true if caching after one full
# iteration.
#
# @api private
#
# @example Is the cache loaded?
# context.cache_loaded?
#
# @return [ true, false ] If the cache is loaded.
#
# @since 3.0.0
def cache_loaded?
!!@cache_loaded
end
# Get the documents for cached queries.
#
# @api private
#
# @example Get the cached documents.
# context.documents
#
# @return [ Array<Document> ] The documents.
#
# @since 3.0.0
def documents
@documents ||= []
end
# Get the documents the context should iterate. This follows 3 rules:
#
# 1. If the query is cached, and we already have documents loaded, use
# them.
# 2. If we are eager loading, then eager load the documents and use
# those.
# 3. Use the query.
#
# @api private
#
# @example Get the documents for iteration.
# context.documents_for_iteration
#
# @return [ Array<Document>, Mongo::Collection::View ] The docs to iterate.
#
# @since 3.0.0
def documents_for_iteration
if cached? && !documents.empty?
documents
elsif eager_loadable?
docs = view.map{ |doc| Factory.from_db(klass, doc, criteria.options[:fields]) }
eager_load(docs)
docs
else
view
end
end
# Yield to the document.
#
# @api private
#
# @example Yield the document.
# context.yield_document(doc) do |doc|
# ...
# end
#
# @param [ Document ] document The document to yield to.
#
# @since 3.0.0
def yield_document(document, &block)
doc = document.respond_to?(:_id) ?
document : Factory.from_db(klass, document, criteria.options[:fields])
yield(doc)
documents.push(doc) if cacheable?
end
end
end
end