From 133873304a93bc493562cf3135ba7f95edc127a4 Mon Sep 17 00:00:00 2001 From: Jamis Buck Date: Thu, 23 Apr 2026 15:04:49 -0600 Subject: [PATCH] MONGOID-5030 Short-circuit trivial queries where `$in` is given an empty array --- lib/mongoid/config.rb | 5 + lib/mongoid/contextual.rb | 12 ++ spec/mongoid/contextual/short_circuit_spec.rb | 139 ++++++++++++++++++ 3 files changed, 156 insertions(+) create mode 100644 spec/mongoid/contextual/short_circuit_spec.rb diff --git a/lib/mongoid/config.rb b/lib/mongoid/config.rb index c55d0d5fae..98ae824d1b 100644 --- a/lib/mongoid/config.rb +++ b/lib/mongoid/config.rb @@ -145,6 +145,11 @@ module Config # as if this option were set to false. option :allow_reparenting_via_nested_attributes, default: false + # When this flag is true, Mongoid will skip sending queries to the database + # for criteria that are guaranteed to return no results, such as those with + # an empty $in array (e.g. Band.in(name: [])). The default is false. + option :allow_short_circuit_queries, default: false + # When this flag is true, any documents in associations with `autosave: true` # will be saved even if they have not been changed. When this flag is false, # only autosaved documents that have been changed will be saved. The default diff --git a/lib/mongoid/contextual.rb b/lib/mongoid/contextual.rb index 52a46d3419..5ef3533562 100644 --- a/lib/mongoid/contextual.rb +++ b/lib/mongoid/contextual.rb @@ -62,8 +62,20 @@ def load_async # @return [ Mongo | Memory ] The context. def create_context return None.new(self) if empty_and_chainable? + return None.new(self) if short_circuit_query? embedded ? Memory.new(self) : Mongo.new(self) end + + def short_circuit_query? + return false unless Mongoid.allow_short_circuit_queries? + + selector.any? do |_field, condition| + next unless condition.is_a?(Hash) + + v = condition['$in'] || condition[:$in] + v.is_a?(Array) && v.empty? + end + end end end diff --git a/spec/mongoid/contextual/short_circuit_spec.rb b/spec/mongoid/contextual/short_circuit_spec.rb new file mode 100644 index 0000000000..b5f67b75fc --- /dev/null +++ b/spec/mongoid/contextual/short_circuit_spec.rb @@ -0,0 +1,139 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe 'Short-circuit query optimization (MONGOID-5030)' do + before { Band.create!(name: 'Depeche Mode') } + + describe 'allow_short_circuit_queries config option' do + it 'defaults to false' do + expect(Mongoid.allow_short_circuit_queries).to be false + end + end + + context 'when allow_short_circuit_queries is false (default)' do + config_override :allow_short_circuit_queries, false + + describe 'a criteria with $in: []' do + let(:criteria) { Band.in(name: []) } + + it 'uses the Mongo context (no short-circuit)' do + expect(criteria.context).to be_a(Mongoid::Contextual::Mongo) + end + + it 'issues a query to the database' do + expect_query(1) { criteria.to_a } + end + end + end + + context 'when allow_short_circuit_queries is true' do + config_override :allow_short_circuit_queries, true + + describe 'a criteria with $in: []' do + let(:criteria) { Band.in(name: []) } + + it 'uses the None context (short-circuits)' do + expect(criteria.context).to be_a(Mongoid::Contextual::None) + end + + it 'returns an empty array without querying the database' do + expect_no_queries { expect(criteria.to_a).to eq([]) } + end + + it 'returns zero for count without querying the database' do + expect_no_queries { expect(criteria.count).to eq(0) } + end + + it 'returns nil for first without querying the database' do + expect_no_queries { expect(criteria.first).to be_nil } + end + + it 'returns nil for last without querying the database' do + expect_no_queries { expect(criteria.last).to be_nil } + end + end + + describe 'a criteria built with where($in: [])' do + let(:criteria) { Band.where(name: { '$in' => [] }) } + + it 'uses the None context (short-circuits)' do + expect(criteria.context).to be_a(Mongoid::Contextual::None) + end + + it 'issues no database query' do + expect_no_queries { criteria.to_a } + end + end + + describe 'a criteria with multiple conditions where one has $in: []' do + let(:criteria) { Band.where(active: true).in(name: []) } + + it 'uses the None context (short-circuits)' do + expect(criteria.context).to be_a(Mongoid::Contextual::None) + end + + it 'issues no database query' do + expect_no_queries { criteria.to_a } + end + end + + describe 'chained .in calls on the same field' do + # Mongoid puts the second .in inside $and rather than intersecting at the + # top level, so this does NOT short-circuit (treated as nested condition). + let(:criteria) { Band.in(name: %w[A B]).in(name: %w[C D]) } + + it 'uses the Mongo context (no short-circuit for nested conditions)' do + expect(criteria.context).to be_a(Mongoid::Contextual::Mongo) + end + + it 'issues a query to the database' do + expect_query(1) { criteria.to_a } + end + end + + describe 'a criteria with a non-empty $in' do + let(:criteria) { Band.in(name: [ 'Depeche Mode' ]) } + + it 'uses the Mongo context (no short-circuit)' do + expect(criteria.context).to be_a(Mongoid::Contextual::Mongo) + end + + it 'issues a query and returns matching results' do + expect_query(1) { expect(criteria.map(&:name)).to eq([ 'Depeche Mode' ]) } + end + end + + describe 'a criteria with no $in condition' do + let(:criteria) { Band.where(name: 'Depeche Mode') } + + it 'uses the Mongo context (no short-circuit)' do + expect(criteria.context).to be_a(Mongoid::Contextual::Mongo) + end + + it 'issues a query to the database' do + expect_query(1) { criteria.to_a } + end + end + + describe 'a nested $in: [] inside $and (out of scope, not short-circuited)' do + let(:criteria) { Band.where('$and' => [ { 'name' => { '$in' => [] } } ]) } + + it 'uses the Mongo context (no short-circuit for nested conditions)' do + expect(criteria.context).to be_a(Mongoid::Contextual::Mongo) + end + + it 'issues a query to the database' do + expect_query(1) { criteria.to_a } + end + end + + describe 'chainability after short-circuit' do + it 'can chain further conditions on the short-circuited criteria and issues no query' do + result = Band.in(name: []).where(active: true) + expect(result).to be_a(Mongoid::Criteria) + expect_no_queries { expect(result.to_a).to eq([]) } + end + end + end +end