# frozen_string_literal: true # encoding: utf-8 require "mongoid/criteria/findable" require "mongoid/criteria/includable" require "mongoid/criteria/inspectable" require "mongoid/criteria/marshalable" require "mongoid/criteria/modifiable" require "mongoid/criteria/queryable" require "mongoid/criteria/scopable" require "mongoid/criteria/options" module Mongoid # The +Criteria+ class is the core object needed in Mongoid to retrieve # objects from the database. It is a DSL that essentially sets up the # selector and options arguments that get passed on to a Mongo::Collection # in the Ruby driver. Each method on the +Criteria+ returns self to they # can be chained in order to create a readable criterion to be executed # against the database. class Criteria include Enumerable # @api private alias :_enumerable_find :find include Contextual include Queryable include Findable # @api private alias :_findable_find :find include Inspectable include Includable include Marshalable include Modifiable include Scopable include Clients::Options include Clients::Sessions include Options # Static array used to check with method missing - we only need to ever # instantiate once. # # @since 4.0.0 CHECK = [] attr_accessor :embedded, :klass, :parent_document, :association # Returns true if the supplied +Enumerable+ or +Criteria+ is equal to the results # of this +Criteria+ or the criteria itself. # # @note This will force a database load when called if an enumerable is passed. # # @param [ Object ] other The other +Enumerable+ or +Criteria+ to compare to. # # @return [ true, false ] If the objects are equal. # # @since 1.0.0 def ==(other) return super if other.respond_to?(:selector) entries == other end # Finds one or many documents given the provided _id values, or filters # the documents in the current scope in the application process space # after loading them if needed. # # If this method is not given a block, it delegates to +Findable#find+ # and finds one or many documents for the provided _id values. # # If this method is given a block, it delegates to +Enumerable#find+ and # returns the first document of those found by the current Crieria object # for which the block returns a truthy value. # # Note that the "default proc" argument of Enumerable is not specially # treated by Mongoid - the decision between delegating to +Findable+ vs # +Enumerable+ is made solely based on whether +find+ is passed a block. # # @example Finds a document by its _id, invokes Findable#find. # critera.find("1234") # # @example Finds the first matching document using a block, invokes Enumerable#find. # criteria.find { |item| item.name == "Depeche Mode" } # # @example Finds the first matching document using a block using the default Proc, invokes Enumerable#find. # criteria.find(-> { "Default Band" }) { |item| item.name == "Milwaukee Mode" } # # @example Tries to find a document whose _id is the stringification of the provided Proc, typically failing. # enumerator = criteria.find(-> { "Default Band" }) # # @return [ Document | Array | nil ] A document or matching documents. # # @raise Errors::DocumentNotFound If the parameters were _id values and # not all documents are found and the +raise_not_found_error+ # Mongoid configuration option is truthy. # # @see https://ruby-doc.org/core/Enumerable.html#method-i-find def find(*args, &block) if block_given? _enumerable_find(*args, &block) else _findable_find(*args) end end # Needed to properly get a criteria back as json # # @example Get the criteria as json. # Person.where(:title => "Sir").as_json # # @param [ Hash ] options Options to pass through to the serializer. # # @return [ String ] The JSON string. def as_json(options = nil) entries.as_json(options) end # Tells the criteria that the cursor that gets returned needs to be # cached. This is so multiple iterations don't hit the database multiple # times, however this is not advisable when working with large data sets # as the entire results will get stored in memory. # # @example Flag the criteria as cached. # criteria.cache # # @return [ Criteria ] The cloned criteria. def cache crit = clone crit.options.merge!(cache: true) crit end # Will return true if the cache option has been set. # # @example Is the criteria cached? # criteria.cached? # # @return [ true, false ] If the criteria is flagged as cached. def cached? options[:cache] == true end # Get the documents from the embedded criteria. # # @example Get the documents. # criteria.documents # # @return [ Array ] The documents. # # @since 3.0.0 def documents @documents ||= [] end # Set the embedded documents on the criteria. # # @example Set the documents. # # @param [ Array ] docs The embedded documents. # # @return [ Array ] The embedded documents. # # @since 3.0.0 def documents=(docs) @documents = docs end # Is the criteria for embedded documents? # # @example Is the criteria for embedded documents? # criteria.embedded? # # @return [ true, false ] If the criteria is embedded. # # @since 3.0.0 def embedded? !!@embedded end # Extract a single id from the provided criteria. Could be in an $and # query or a straight _id query. # # @example Extract the id. # criteria.extract_id # # @return [ Object ] The id. # # @since 2.3.0 def extract_id selector.extract_id end # Adds a criterion to the +Criteria+ that specifies additional options # to be passed to the Ruby driver, in the exact format for the driver. # # @example Add extra params to the criteria. # criteria.extras(:limit => 20, :skip => 40) # # @param [ Hash ] extras The extra driver options. # # @return [ Criteria ] The cloned criteria. # # @since 2.0.0 def extras(extras) crit = clone crit.options.merge!(extras) crit end # Get the list of included fields. # # @example Get the field list. # criteria.field_list # # @return [ Array ] The fields. # # @since 2.0.0 def field_list if options[:fields] options[:fields].keys.reject{ |key| key == klass.discriminator_key } else [] end end # When freezing a criteria we need to initialize the context first # otherwise the setting of the context on attempted iteration will raise a # runtime error. # # @example Freeze the criteria. # criteria.freeze # # @return [ Criteria ] The frozen criteria. # # @since 2.0.0 def freeze context and inclusions and super end # Initialize the new criteria. # # @example Init the new criteria. # Criteria.new(Band) # # @param [ Class ] klass The model class. # # @since 1.0.0 def initialize(klass) @klass = klass @embedded = nil @none = nil klass ? super(klass.aliased_fields, klass.fields) : super({}, {}) end # Merges another object with this +Criteria+ and returns a new criteria. # The other object may be a +Criteria+ or a +Hash+. This is used to # combine multiple scopes together, where a chained scope situation # may be desired. # # @example Merge the criteria with another criteria. # criteri.merge(other_criteria) # # @example Merge the criteria with a hash. The hash must contain a klass # key and the key/value pairs correspond to method names/args. # # criteria.merge({ # klass: Band, # where: { name: "Depeche Mode" }, # order_by: { name: 1 } # }) # # @param [ Criteria ] other The other criterion to merge with. # # @return [ Criteria ] A cloned self. def merge(other) crit = clone crit.merge!(other) crit end # Merge the other criteria into this one. # # @example Merge another criteria into this criteria. # criteria.merge(Person.where(name: "bob")) # # @param [ Criteria ] other The criteria to merge in. # # @return [ Criteria ] The merged criteria. # # @since 3.0.0 def merge!(other) criteria = other.to_criteria selector.merge!(criteria.selector) options.merge!(criteria.options) self.documents = criteria.documents.dup unless criteria.documents.empty? self.scoping_options = criteria.scoping_options self.inclusions = (inclusions + criteria.inclusions).uniq self end # Returns a criteria that will always contain zero results and never hits # the database. # # @example Return a none criteria. # criteria.none # # @return [ Criteria ] The none criteria. # # @since 4.0.0 def none @none = true and self end # Is the criteria an empty but chainable criteria? # # @example Is the criteria a none criteria? # criteria.empty_and_chainable? # # @return [ true, false ] If the criteria is a none. # # @since 4.0.0 def empty_and_chainable? !!@none end # Overriden to include _type in the fields. # # @example Limit the fields returned from the database. # Band.only(:name) # # @param [ Array ] args The names of the fields. # # @return [ Criteria ] The cloned criteria. # # @since 1.0.0 def only(*args) args = args.flatten return clone if args.empty? if (args & Fields::IDS).empty? args.unshift(:_id) end if klass.hereditary? args.push(klass.discriminator_key.to_sym) end super(*args) end # Set the read preference for the criteria. # # @example Set the read preference. # criteria.read(mode: :primary_preferred) # # @param [ Hash ] value The mode preference. # # @return [ Criteria ] The cloned criteria. # # @since 5.0.0 def read(value = nil) clone.tap do |criteria| criteria.options.merge!(read: value) end end # Overriden to exclude _id from the fields. # # @example Exclude fields returned from the database. # Band.without(:name) # # @param [ Array ] args The names of the fields. # # @return [ Criteria ] The cloned criteria. # # @since 4.0.3 def without(*args) args -= id_fields super(*args) end # Returns true if criteria responds to the given method. # # @example Does the criteria respond to the method? # crtiteria.respond_to?(:each) # # @param [ Symbol ] name The name of the class method on the +Document+. # @param [ true, false ] include_private Whether to include privates. # # @return [ true, false ] If the criteria responds to the method. def respond_to?(name, include_private = false) super || klass.respond_to?(name) || CHECK.respond_to?(name, include_private) end alias :to_ary :to_a # Convenience for objects that want to be merged into a criteria. # # @example Convert to a criteria. # criteria.to_criteria # # @return [ Criteria ] self. # # @since 3.0.0 def to_criteria self end # Convert the criteria to a proc. # # @example Convert the criteria to a proc. # criteria.to_proc # # @return [ Proc ] The wrapped criteria. # # @since 3.0.0 def to_proc ->{ self } end # Adds a criterion to the +Criteria+ that specifies a type or an Array of # types that must be matched. # # @example Match only specific models. # criteria.type('Browser') # criteria.type(['Firefox', 'Browser']) # # @param [ Array ] types The types to match against. # # @return [ Criteria ] The cloned criteria. def type(types) any_in(self.discriminator_key.to_sym => Array(types)) end # This is the general entry point for most MongoDB queries. This either # creates a standard field: value selection, and expanded selection with # the use of hash methods, or a $where selection if a string is provided. # # @example Add a standard selection. # criteria.where(name: "syd") # # @example Add a javascript selection. # criteria.where("this.name == 'syd'") # # @param [ String, Hash ] expression The javascript or standard selection. # # @raise [ UnsupportedJavascript ] If provided a string and the criteria # is embedded. # # @return [ Criteria ] The cloned selectable. # # @since 1.0.0 def where(*args) # Historically this method required exactly one argument. # As of https://jira.mongodb.org/browse/MONGOID-4804 it also accepts # zero arguments. # The underlying where implemetation that super invokes supports # any number of arguments, but we don't presently allow mutiple # arguments through this method. This API can be reconsidered in the # future. if args.length > 1 raise ArgumentError, "Criteria#where requires zero or one arguments (given #{args.length})" end if args.length == 1 expression = args.first if expression.is_a?(::String) && embedded? raise Errors::UnsupportedJavascript.new(klass, expression) end end super end # Get a version of this criteria without the options. # # @example Get the criteria without options. # criteria.without_options # # @return [ Criteria ] The cloned criteria. # # @since 3.0.4 def without_options crit = clone crit.options.clear crit end # Find documents by the provided javascript and scope. Uses a $where but is # different from +Criteria#where+ in that it will pass a code object to the # query instead of a pure string. Safe against Javascript injection # attacks. # # @example Find by javascript. # Band.for_js("this.name = param", param: "Tool") # # @param [ String ] javascript The javascript to execute in the $where. # @param [ Hash ] scope The scope for the code. # # @return [ Criteria ] The criteria. # # @since 3.1.0 def for_js(javascript, scope = {}) code = if scope.empty? # CodeWithScope is not supported for $where as of MongoDB 4.4 BSON::Code.new(javascript) else BSON::CodeWithScope.new(javascript, scope) end js_query(code) end private # Are documents in the query missing, and are we configured to raise an # error? # # @api private # # @example Check for missing documents. # criteria.check_for_missing_documents!([], [ 1 ]) # # @param [ Array ] result The result. # @param [ Array ] ids The ids. # # @raise [ Errors::DocumentNotFound ] If none are found and raising an # error. # # @since 3.0.0 def check_for_missing_documents!(result, ids) if (result.size < ids.size) && Mongoid.raise_not_found_error raise Errors::DocumentNotFound.new(klass, ids, ids - result.map(&:_id)) end end # Clone or dup the current +Criteria+. This will return a new criteria with # the selector, options, klass, embedded options, etc intact. # # @api private # # @example Clone a criteria. # criteria.clone # # @example Dup a criteria. # criteria.dup # # @param [ Criteria ] other The criteria getting cloned. # # @return [ nil ] nil. # # @since 1.0.0 def initialize_copy(other) @inclusions = other.inclusions.dup @scoping_options = other.scoping_options @documents = other.documents.dup @context = nil super end # Used for chaining +Criteria+ scopes together in the for of class methods # on the +Document+ the criteria is for. # # @example Handle method missing. # criteria.method_missing(:name) # # @param [ Symbol ] name The method name. # @param [ Array ] args The arguments. # # @return [ Object ] The result of the method call. # # @since 1.0.0 ruby2_keywords def method_missing(name, *args, &block) if klass.respond_to?(name) klass.send(:with_scope, self) do klass.send(name, *args, &block) end elsif CHECK.respond_to?(name) return entries.send(name, *args, &block) else super end end # For models where inheritance is at play we need to add the type # selection. # # @example Add the type selection. # criteria.merge_type_selection # # @return [ true, false ] If type selection was added. # # @since 3.0.3 def merge_type_selection selector.merge!(type_selection) if type_selectable? end # Is the criteria type selectable? # # @api private # # @example If the criteria type selectable? # criteria.type_selectable? # # @return [ true, false ] If type selection should be added. # # @since 3.0.3 def type_selectable? klass.hereditary? && !selector.keys.include?(self.discriminator_key) && !selector.keys.include?(self.discriminator_key.to_sym) end # Get the selector for type selection. # # @api private # # @example Get a type selection hash. # criteria.type_selection # # @return [ Hash ] The type selection. # # @since 3.0.3 def type_selection klasses = klass._types if klasses.size > 1 { klass.discriminator_key.to_sym => { "$in" => klass._types }} else { klass.discriminator_key.to_sym => klass._types[0] } end end # Get a new selector with type selection in it. # # @api private # # @example Get a selector with type selection. # criteria.selector_with_type_selection # # @return [ Hash ] The selector. # # @since 3.0.3 def selector_with_type_selection type_selectable? ? selector.merge(type_selection) : selector end end end