diff --git a/sunspot/lib/sunspot/util.rb b/sunspot/lib/sunspot/util.rb index 85ea7d245..e83be0378 100644 --- a/sunspot/lib/sunspot/util.rb +++ b/sunspot/lib/sunspot/util.rb @@ -298,20 +298,43 @@ def respond_to_missing?(method, _) end def __proxy_method__(method, *args, **kwargs, &block) - if kwargs.empty? - @__receiver__.__send__(method.to_sym, *args, &block) - else - @__receiver__.__send__(method.to_sym, *args, **kwargs, &block) + method = method.to_sym + + # Fast path: skip the rescue below when the receiver can't handle the + # method, avoiding a raise on every call into the calling context. + if !__may_handle__?(@__receiver__, method) && + ::Kernel === @__calling_context__ && + @__calling_context__.respond_to?(method, true) + return __dispatch__(@__calling_context__, method, args, kwargs, &block) end - rescue ::NoMethodError => e + begin - if kwargs.empty? - @__calling_context__.__send__(method.to_sym, *args, &block) - else - @__calling_context__.__send__(method.to_sym, *args, **kwargs, &block) + __dispatch__(@__receiver__, method, args, kwargs, &block) + rescue ::NoMethodError => e + begin + __dispatch__(@__calling_context__, method, args, kwargs, &block) + rescue ::NoMethodError + raise(e) end - rescue ::NoMethodError - raise(e) + end + end + + KERNEL_METHOD = ::Kernel.instance_method(:method) + + # respond_to? only settles it when method_missing is the default one; + # receivers like DSL::Function answer everything through method_missing. + # Objects without Kernel (BasicObject proxies) may not have respond_to?. + def __may_handle__?(target, method) + !(::Kernel === target) || + target.respond_to?(method, true) || + KERNEL_METHOD.bind(target).call(:method_missing).owner != ::BasicObject + end + + def __dispatch__(target, method, args, kwargs, &block) + if kwargs.empty? + target.__send__(method, *args, &block) + else + target.__send__(method, *args, **kwargs, &block) end end end diff --git a/sunspot/spec/api/binding_spec.rb b/sunspot/spec/api/binding_spec.rb index 14008252b..5c4467548 100644 --- a/sunspot/spec/api/binding_spec.rb +++ b/sunspot/spec/api/binding_spec.rb @@ -49,6 +49,50 @@ expect(value).to eq({ a: 10, b: 20 }) end + it 'should build function queries whose names the calling context also defines' do + # Like a model class method calling solr_search, where the model responds + # to ActiveRecord's #sum. + searcher = Class.new do + def search(session) + session.search(Post) do + keywords('pizza') do + boost(function { sum(:average_rating, 10) }) + end + end + end + + def sum(*) + 'from context' + end + end.new + + searcher.search(session) + expect(connection).to have_last_search_including(:bf, 'sum(average_rating_ft,10)') + end + + it 'should define typed fields whose names the calling context also defines' do + stub_const('BindingSpecDocument', Class.new) + # The example group responds to rspec-mocks' #double. + Sunspot::Setup.setup(BindingSpecDocument) { double :rating } + expect(Sunspot::Setup.for(BindingSpecDocument).field(:rating).type).to be_a(Sunspot::Type::DoubleType) + end + + it 'should prefer the record\'s method_missing in index-time blocks' do + record_class = Class.new do + def method_missing(name, *args, &block) + name == :name ? 'from record' : super + end + # respond_to_missing? is deliberately not defined. + end + # Inside a searchable block the calling context is the model class, which + # responds to Class#name. + extractor = record_class.instance_eval do + Sunspot::DataExtractor::BlockExtractor.new { name } + end + + expect(extractor.value_for(record_class.new)).to eq('from record') + end + private def test_method @@ -63,3 +107,120 @@ def kwargs_method(a:, b:) { a: a, b: b } end end + +describe Sunspot::Util::ContextBoundDelegate do + # Resolution must not depend on raising; the discarded backtraces are costly. + it 'does not raise NoMethodError when resolving a calling context method' do + raised = 0 + test_thread = Thread.current + tracer = TracePoint.new(:raise) do |tp| + raised += 1 if Thread.current == test_thread && tp.raised_exception.is_a?(NoMethodError) + end + + context = Class.new do + def resolve(receiver) + Sunspot::Util::ContextBoundDelegate + .instance_eval_with_context(receiver) { context_only_method } + end + + def context_only_method + 'from context' + end + end.new + + result = nil + tracer.enable { result = context.resolve(Object.new) } + + expect(result).to eq('from context') + expect(raised).to eq(0) + end + + # respond_to? cannot see method_missing, so this must still resolve via the + # rescue-based fallback. + it 'resolves a calling context method that only exists via method_missing' do + context = Class.new do + def resolve(receiver) + Sunspot::Util::ContextBoundDelegate + .instance_eval_with_context(receiver) { ghost_method } + end + + def method_missing(name, *args, &block) + name == :ghost_method ? 'from method_missing' : super + end + # respond_to_missing? is deliberately not defined. + end.new + + expect(context.resolve(Object.new)).to eq('from method_missing') + end + + it 'raises NoMethodError when neither the receiver nor the context responds' do + context = Class.new do + def resolve(receiver) + Sunspot::Util::ContextBoundDelegate + .instance_eval_with_context(receiver) { totally_unknown_method } + end + end.new + + expect { context.resolve(Object.new) }.to raise_error(NoMethodError) + end + + it 'prefers the receiver when both define the method' do + receiver = Class.new do + def shared_method + 'from receiver' + end + end.new + + context = Class.new do + def resolve(receiver) + Sunspot::Util::ContextBoundDelegate + .instance_eval_with_context(receiver) { shared_method } + end + + def shared_method + 'from context' + end + end.new + + expect(context.resolve(receiver)).to eq('from receiver') + end + + it 'prefers a receiver method_missing over a context method of the same name' do + receiver = Class.new do + def method_missing(name, *args, &block) + name == :shared_method ? 'from receiver' : super + end + # respond_to_missing? is deliberately not defined. + end.new + + context = Class.new do + def resolve(receiver) + Sunspot::Util::ContextBoundDelegate + .instance_eval_with_context(receiver) { shared_method } + end + + def shared_method + 'from context' + end + end.new + + expect(context.resolve(receiver)).to eq('from receiver') + end + + it 'dispatches to a BasicObject receiver that does not implement respond_to?' do + receiver = Class.new(BasicObject) do + def method_missing(name, *args, &block) + name == :proxied_method ? 'from proxy' : super + end + end.new + + context = Class.new do + def resolve(receiver) + Sunspot::Util::ContextBoundDelegate + .instance_eval_with_context(receiver) { proxied_method } + end + end.new + + expect(context.resolve(receiver)).to eq('from proxy') + end +end diff --git a/sunspot_rails/Appraisals b/sunspot_rails/Appraisals index d24067e7a..66f1f291a 100644 --- a/sunspot_rails/Appraisals +++ b/sunspot_rails/Appraisals @@ -11,6 +11,8 @@ if ruby_version < Gem::Version.new('3.0.0') gem 'sqlite3', '~> 1.3.0' gem 'rspec', '~> 3.7' gem 'rspec-rails', '~> 3.7' + # ActiveSupport < 7.2.4 and 8.0.x pass quirks_mode to JSON.generate/parse, which json 3 rejects + gem 'json', '< 3' end end end @@ -21,12 +23,13 @@ if Gem::Version.new('3.0.0') <= ruby_version && ruby_version < Gem::Version.new( gem 'sunspot', path: File.expand_path('sunspot', ENV['SUNSPOT_LIB_HOME']) gem 'sunspot_solr', path: File.expand_path('sunspot_solr', ENV['SUNSPOT_LIB_HOME']) gem 'rails', "~> #{rails_version}" - gem 'json', '< 3' # Under json 3, these Rails versions' to_json raises "unknown keyword: quirks_mode" gem 'sprockets', '~> 3.0' gem 'progress_bar', '~> 1.0.5', require: false gem 'sqlite3', '~> 1.4.0' gem 'rspec', '~> 3' gem 'rspec-rails', '~> 6' + # ActiveSupport < 7.2.4 and 8.0.x pass quirks_mode to JSON.generate/parse, which json 3 rejects + gem 'json', '< 3' end end end