From 57ccbff43c58d6a2f1dd5c7d27d2e42802754a6a Mon Sep 17 00:00:00 2001 From: Benjamin Clayman Date: Wed, 6 Oct 2021 18:02:22 -0400 Subject: [PATCH] Proof of concept for improving ContainExactly matcher speed when elements obey transitivity This is a proof of concept approach for addressing issue #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. --- .../matchers/built_in/contain_exactly.rb | 18 ++++++++- .../matchers/built_in/contain_exactly_spec.rb | 40 +++++++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/lib/rspec/matchers/built_in/contain_exactly.rb b/lib/rspec/matchers/built_in/contain_exactly.rb index 1bf7f9831..f8d50d28b 100644 --- a/lib/rspec/matchers/built_in/contain_exactly.rb +++ b/lib/rspec/matchers/built_in/contain_exactly.rb @@ -6,6 +6,19 @@ 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 + + # @api public + # Specifies that elements contained in actual and expected + # obey transitivity. This lets match run much faster. + def transitive + @transitive = true + self + end + # @api private # @return [String] def failure_message @@ -72,7 +85,10 @@ 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?) + matched_when_sorted = match_when_sorted? + return true if matched_when_sorted + return matched_when_sorted if @transitive + (extra_items.empty? && missing_items.empty?) end # This cannot always work (e.g. when dealing with unsortable items, diff --git a/spec/rspec/matchers/built_in/contain_exactly_spec.rb b/spec/rspec/matchers/built_in/contain_exactly_spec.rb index ac1996ae8..2723d1259 100644 --- a/spec/rspec/matchers/built_in/contain_exactly_spec.rb +++ b/spec/rspec/matchers/built_in/contain_exactly_spec.rb @@ -95,6 +95,46 @@ 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' + shared_examples "runs very fast" do + it do + time = Benchmark.realtime do + subject + end + # this is in seconds + expect(time).to be < 0.3 + end + end + + let(:a) { Array.new(10_000) { rand(10) } } + let(:b) { a.shuffle } + + context "when expected and actual match" do + subject { expect(a).to contain_exactly(*b).transitive } + + it "matches" do + subject + end + + include_examples "runs very fast" + end + + context "when expected and actual do not match" do + subject { expect(a).not_to contain_exactly(*b).transitive } + + let(:b) { Array.new(10_000) { rand(10) } } + + it "does not match" do + subject + end + + include_examples "runs very fast" + end + end + it "passes a valid positive expectation" do expect([1, 2]).to contain_exactly(2, 1) end