Skip to content

Commit

Permalink
Proof of concept for improving ContainExactly matcher speed when elem…
Browse files Browse the repository at this point in the history
…ents obey transitivity

This is a proof of concept approach for addressing issue rspec#1161.

The current implementation for ContainExactly runs in O(n!).  In practice,
it runs in O(n log n) when the elements are comparable and sorting result
in a match.

The crux of the problem is that some elements don't obey transitivity.  As
a result, knowing that sorting actual and expected doesn't result in a match
*doesn't* guarantee that expected and actual don't match.

This proof of concept provides a way for the user to indicate that the elements
in a particular example's expected and actual obey transitivity. That looks like this:

expect(a).to contain_exactly(*b).transitive

And runs in O(n log n) time.  More practically, this means that common
use cases for contains_exactly will enjoy a massive speedup.  Previously,
users have examples where comparing arrays of 30 integers "never finishes."
Using `.transitive` here with arrays of 10,000 integers runs in < 0.1s
on my machine.
  • Loading branch information
bclayman-sq committed Oct 6, 2021
1 parent dba6798 commit bb5688e
Show file tree
Hide file tree
Showing 2 changed files with 42 additions and 1 deletion.
13 changes: 12 additions & 1 deletion lib/rspec/matchers/built_in/contain_exactly.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,15 @@ module BuiltIn
# Provides the implementation for `contain_exactly` and `match_array`.
# Not intended to be instantiated directly.
class ContainExactly < BaseMatcher
def initialize(expected = nil)
super
@transitive = false
end

def transitive
@transitive = true
self
end
# @api private
# @return [String]
def failure_message
Expand Down Expand Up @@ -72,7 +81,9 @@ def message_line(prefix, collection, surface_descriptions=false)

def match(_expected, _actual)
return false unless convert_actual_to_an_array
match_when_sorted? || (extra_items.empty? && missing_items.empty?)
return true if match_when_sorted?
return match_when_sorted? if @transitive
(extra_items.empty? && missing_items.empty?)
end

# This cannot always work (e.g. when dealing with unsortable items,
Expand Down
30 changes: 30 additions & 0 deletions spec/rspec/matchers/built_in/contain_exactly_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,36 @@ def array.send; :sent; end
end

RSpec.describe "using contain_exactly with expect" do
# users have reported using contains_exactly with 50 elements
# never finishing!
context "speeding up matching for elements that obey transitivity" do
require 'benchmark'
let(:a) { Array.new(10000) { rand(1...9) } }
let(:b) { a.shuffle }

context "when expected and actual match" do
it "matches" do
expect(a).to contain_exactly(*b).transitive
end

it "runs very fast" do
time = Benchmark.realtime do
expect(a).to contain_exactly(*b).transitive
end
# this is in seconds
expect(time).to be < 0.1
end
end

context "when expected and actual do not match" do
let(:b) { Array.new(10000) { rand(1...9) } }

it "does not match" do
expect(a).not_to contain_exactly(*b).transitive
end
end
end

it "passes a valid positive expectation" do
expect([1, 2]).to contain_exactly(2, 1)
end
Expand Down

0 comments on commit bb5688e

Please sign in to comment.