ActiveSupport::IncludeWithRange gotcha
Lets see how ruby implements ===
for ranges.
As documentation say “Returns true if obj is an element of the range, false otherwise”. Let’s try it out.
2.5.1 :001 > (1..10) === 5
=> true
Looks fine… how about if we compare it to another range?
2.5.1 :001 > (1..10) === (5..15)
=> false
Seems to work properly again. How about if one range is a sub range of the other one.
2.5.1 :004 > (1..10) === (5..6)
=> false
As expected. Those ranges are not equal after all. Or at least (5..6)
is not an element that (1..10)
holds.
What is surprising, is what happens if we run the same thing in rails console (5.2.0 at the time of writing). Suddenly
[1] pry(main)> (1..10) === (5..6)
=> true
WAT? It now checks if range is included in the original range! Rails do not override ===
itself though. After looking at what rails adds to range…
[2] pry(main)> (1..10).class.ancestors
=> [ActiveSupport::EachTimeWithZone,
ActiveSupport::IncludeTimeWithZone,
ActiveSupport::IncludeWithRange,
ActiveSupport::RangeWithFormat,
Range,
Enumerable,
ActiveSupport::ToJsonWithActiveSupportEncoder,
Object,
RequireAll,
PP::ObjectMixin,
Nori::CoreExt::Object,
ActiveSupport::Dependencies::Loadable,
JSON::Ext::Generator::GeneratorMethods::Object,
ActiveSupport::Tryable,
Kernel,
BasicObject]
…we have identified this suspicious module ActiveSupport::IncludeWithRange
. Its documentation explains everything.
# Extends the default Range#include? to support range comparisons.
# (1..5).include?(1..5) # => true
# (1..5).include?(2..3) # => true
# (1..5).include?(2..6) # => false
Now guess what ruby’s Range#===
uses behind the scenes
static VALUE
range_eqq(VALUE range, VALUE val)
{
return rb_funcall(range, rb_intern("include?"), 1, val);
}
Yes… include?
. The consequences are… there are consequences ;) The most annoying one is related to rspec.
expect(1..10).to match(5..6) # => true
expect([1..10]).to include(5..6) # => true
expect([1..10]).to match_array([5..6]) # => true
It is not possible to easily compare array of ranges matching on exact begin
and end
values yet disregarding actual order. Also the match
behaviour is really misleading in my opinion. The only matcher we can use safely here is eq
, as expect(1..10).to eq(5..6)
will fail properly.