Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 34 additions & 11 deletions sunspot/lib/sunspot/util.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
161 changes: 161 additions & 0 deletions sunspot/spec/api/binding_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
5 changes: 4 additions & 1 deletion sunspot_rails/Appraisals
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Loading