rails: ActiveRecord::Type::Time::Value crashes when loaded from YAML on rails 7.0
Steps to reproduce
# Rails 6.1
(byebug) YAML.load(YAML.dump(ActiveRecord::Type::Time::Value.new(Time.current))) + 1.day
Thu, 23 Dec 2021 10:24:42.873895000 UTC +00:00
# Rails 7
(byebug) YAML.load(YAML.dump(ActiveRecord::Type::Time::Value.new(Time.current))) + 1.day
*** ArgumentError Exception: not delegated
Expected behavior
Should return an ActiveRecord::Type::Time::Value
object
Actual behavior
Raises ArgumentError Exception: not delegated
System configuration
Rails version: 7.0
Ruby version: 3.0.3 and 2.7.3
Details
We are trying to make the paper_trail gem compatible with rails 7 and encountered this issue. It looks like ActiveRecord::Type::Time::Value
is not properly deserializable from YAML.
In Rails 6.1, it used to be dumped as ActiveSupport::TimeWithZone
which avoided the issue.
Looks like this line is the one raising the exception :
About this issue
- Original URL
- State: closed
- Created 3 years ago
- Reactions: 8
- Comments: 29 (10 by maintainers)
Ugh, this is a bit of a mess - unintended consequences spread across six years 😢
So the reason that
ActiveRecord::Type::Time::Value
was introduced was to handle a bug in MariaDB where it wouldn’t ignore the date part of a timestamp when comparing against aTIME
column unlike other databases. This is a class that isn’t documented and shouldn’t be relied upon to exist - therefore we should definitely not dump it out as YAML.I think the fix to restore previous behaviour should be to add the following:
We can put these in Active Record so that we don’t tie Active Support to it. The
load_tags
change will handle any YAML that’s been dumped since the change in db9ba18 was made and convert them to AS::TWZ instances which is what would happen with Rails 6.1.There’s a wider issue of what
TIME
columns should be dumped as - YAML does support them, and they get loaded as seconds since midnight, e.g.However that’s outside the scope of this issue.
I’m going to have a closer look at the papertrail failure - I’d like to know how these instances are being exposed.
I agree with @pedrocarmona but slightly amended. Add to the bottom of activerecord/lib/active_record/type/time.rb
It looks like the intent has always been that AR Time::Value delegates yaml serialization to
TimeWithZone
.TimeWithZone
changed how it implements yaml serialization because of the way Psych works:https://github.com/ruby/psych/blob/e8d262fa108a1605443d7426c185452b538775be/lib/psych/visitors/yaml_tree.rb#L498
Psych calls
Class.name
before callingencode_with
. BecauseClass.name
triggers the new deprecation warning the coding / serialization behavior was changed to rely ondump_tags
But dump tags is sensitive to the actual class being serialized so needs to be aware of any classes that might rely onTimeWithZone
to perform serialization (because the old behavior allowedTimeWithZone
to set the YAML tagI did a grep for any other classes that used
DelegateClass(::Time)
and didn’t find any, so I think this is the only file that needs to change.@pixeltrix can you weigh in here?
So the
ActiveModel::Type::Value#serialize
docs says this:However this isn’t true for Active Record types - there’s a whole host of internal, non-documented classes that can get returned for non-standard column types for different database, e.g.
OID::Array::Data
for PostgreSQL adapter. The docs also aren’t true for certain Active Model types either - e.g.Binary
returns a custom class as well (not sure why it just doesn’t return a binary-encoded string).Most of these types get serialised in PaperTrail using the default mechanism, e.g. this is an int array in PostgreSQL:
That’s because they’re mostly PORO classes, however the time one is special which makes me think that the problem lies in that class. I’m going to investigate what impacts changing that to a PORO class has on the rest of the tests. This would all be so much easier if Ruby had a time of day class 😅
Hopefully the
deserialize
method I suggested would handle that - theValue
objects are unwound and passed to the super method so all non-Value
objects should behave as before.You only get an
ActiveRecord::Type::Time::Value
when callingserialize
in preparation for using in a database query so apps are very unlikely to be affected, therefore the main source of issues is likely to be gems like papertrail that hook into that layer somehow. I can look into some other gems to see if there might be any issues - any suggestions like alternative connection adaptors? After MySQL, PostgreSQL, SQLite, Oracle and SQL Server are there any other adaptors worth looking at?The changes specifically apply to the behavior of
Time::Value
whenobj_of_ClassToDelegateTo
is aTimeWithZone
https://apidock.com/ruby/Object/DelegateClass/instance
I ran a git bisect (shakes fist mimemagic and curses) using this script:
It is definitely commit db9ba1865916985212b02a73e24500555532be2b that introduces the breaking change
Because
Time::Value
delegates it’s YAML serialization behavior to it’sobj_of_ClassToDelegateTo
When
obj_of_ClassToDelegateTo
is aTimeWithZone
that means thatYAML#dump
will eventually sendencode_with
toTime::Value
which delegates toobj_of_ClassToDelegateTo
which isTimeWithZone
Before commit db9ba1865916985212b02a73e24500555532be2b
TimeWithZone#encode_with
would explicitly set thetag
of thecoder
And it would do this no matter the underlying type of the class being serialized.After commit db9ba1865916985212b02a73e24500555532be2b the tag is set via the built-in behavior of Psych by looking in
dump_tags
But because
dump_tags
is a simple Hash with the key being thetype
it only gets the correct tag when the type is specificallyTimeWithZone
In order to maintain compatibility with previous behavior any class that delegates YAML serialization behavior to
TimeWithZone
viaencode_with
needs to output the sametag
asTimeWithZone
Yes but this is way below my pay grade unfortunately, I don’t have the context about the changes that were done around
ActiveRecord::Type::Time::Value
and YAML dumping@pixeltrix Maybe we should add
Wdyt?