-
Notifications
You must be signed in to change notification settings - Fork 22k
Description
Steps to reproduce
While writing some tests involving the Rails cache (the details aren't really important), I discovered an issue where an instance of ActiveRecord::AttributeMethods::TimeZoneConversion::TimeZoneConverter
would fail to compare as equal to a duplicate of itself. In my case, this occurs when serializing a value to the cache then deserializing a fresh instance back and trying to make an assertion about equality, which fails.
The problem is that TimeZoneConverter
is actually a DelegateClass
, so it forwards #==
to the underlying __getobj__
: https://github.com/ruby/delegate/blob/1c9f9cb37de9c1baa31dad5f67b00f59b765e22a/lib/delegate.rb#L156-L159
The underlying __getobj__
(in my case, an instance of ActiveRecord::ConnectionAdapters::PostgreSQL::OID::Timestamp
) will then generally use ActiveModel::Type::Value#==
, which compares the classes:
rails/activemodel/lib/active_model/type/value.rb
Lines 121 to 127 in 11f1f35
def ==(other) | |
self.class == other.class && | |
precision == other.precision && | |
scale == other.scale && | |
limit == other.limit | |
end | |
alias eql? == |
However, the self.class
above will just be the underlying __getobj__.class
, while other.class
is still going to be the TimeZoneConverter
wrapper itself. The rest of the values (precision
, scale
, limit
) compare the same via delegation, but the wrapper class gets in the way.
By contrast, #eql?
(aliased to #==
in the Value
class) works because DelegateClass
swaps the order of args around and winds up incidentally "unwrapping" both sides: https://github.com/ruby/delegate/blob/1c9f9cb37de9c1baa31dad5f67b00f59b765e22a/lib/delegate.rb#L172-L175
So then the only reason a given TimeZoneConverter
compares as #==
to itself is because DelegateClass
short-circuits if the incoming argument is #equal?
. Duplicating the instance through any means will result in false ==
comparisons.
# frozen_string_literal: true
require "bundler/inline"
gemfile(true) do
source "https://rubygems.org"
gem "rails", "~> 7.1"
gem "pg"
end
require "active_record"
require "minitest/autorun"
ActiveRecord::Base.establish_connection(adapter: "postgresql", host: "localhost", username: "postgres")
class BugTest < Minitest::Test
def test_equality
subtype = ActiveRecord::ConnectionAdapters::PostgreSQL::OID::Timestamp.new
wrapper = ActiveRecord::AttributeMethods::TimeZoneConversion::TimeZoneConverter.new(subtype)
duped = wrapper.dup
assert_equal wrapper.__getobj__, duped.__getobj__, 'underlying objects should be =='
assert wrapper.eql?(duped), 'wrapper should be hash-equivalent to duplicate'
assert_equal wrapper, duped, 'wrapper should be == to duplicate'
end
def test_more_realistic_situation
subtype = ActiveRecord::ConnectionAdapters::PostgreSQL::OID::Timestamp.new
value = ActiveRecord::AttributeMethods::TimeZoneConversion::TimeZoneConverter.new(subtype)
value_from_cache = Marshal.load(Marshal.dump(value)) # writing then reading from cache makes a Marshal round trip
assert_equal value, value_from_cache
end
end
Expected behavior
I would expect the duplicated value to be ==
to the original, where "duplication" could happen through a simple #dup
, a more complex marshalling back & forth, or any number of other scenarios where the object has an equivalent structure.
Actual behavior
$ ruby /tmp/repro.rb
Fetching gem metadata from https://rubygems.org/...........
Resolving dependencies...
Run options: --seed 59380
# Running:
FF
Finished in 0.085907s, 23.2810 runs/s, 46.5620 assertions/s.
1) Failure:
BugTest#test_more_realistic_situation [/tmp/repro.rb:31]:
No visible difference in the ActiveRecord::AttributeMethods::TimeZoneConversion::TimeZoneConverter#inspect output.
You should look at the implementation of #== on ActiveRecord::AttributeMethods::TimeZoneConversion::TimeZoneConverter or its members.
#<ActiveRecord::ConnectionAdapters::PostgreSQL::OID::Timestamp:0xXXXXXX @precision=nil, @scale=nil, @limit=nil, @timezone=nil>
2) Failure:
BugTest#test_equality [/tmp/repro.rb:24]:
wrapper should be == to duplicate.
No visible difference in the ActiveRecord::AttributeMethods::TimeZoneConversion::TimeZoneConverter#inspect output.
You should look at the implementation of #== on ActiveRecord::AttributeMethods::TimeZoneConversion::TimeZoneConverter or its members.
#<ActiveRecord::ConnectionAdapters::PostgreSQL::OID::Timestamp:0xXXXXXX @precision=nil, @scale=nil, @limit=nil, @timezone=nil>
2 runs, 4 assertions, 2 failures, 0 errors, 0 skips
System configuration
Rails version: 7.1
Ruby version: 3.3.4