Skip to content

ActiveRecord::AttributeMethods::TimeZoneConversion::TimeZoneConverter equality is buggy #52698

@ajvondrak

Description

@ajvondrak

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:

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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions