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::Valuewas introduced was to handle a bug in MariaDB where it wouldn’t ignore the date part of a timestamp when comparing against aTIMEcolumn 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_tagschange 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
TIMEcolumns 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.TimeWithZonechanged 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.namebefore callingencode_with. BecauseClass.nametriggers the new deprecation warning the coding / serialization behavior was changed to rely ondump_tagsBut dump tags is sensitive to the actual class being serialized so needs to be aware of any classes that might rely onTimeWithZoneto perform serialization (because the old behavior allowedTimeWithZoneto 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#serializedocs 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::Datafor PostgreSQL adapter. The docs also aren’t true for certain Active Model types either - e.g.Binaryreturns 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
deserializemethod I suggested would handle that - theValueobjects are unwound and passed to the super method so all non-Valueobjects should behave as before.You only get an
ActiveRecord::Type::Time::Valuewhen callingserializein 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::Valuewhenobj_of_ClassToDelegateTois aTimeWithZonehttps://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::Valuedelegates it’s YAML serialization behavior to it’sobj_of_ClassToDelegateToWhen
obj_of_ClassToDelegateTois aTimeWithZonethat means thatYAML#dumpwill eventually sendencode_withtoTime::Valuewhich delegates toobj_of_ClassToDelegateTowhich isTimeWithZoneBefore commit db9ba1865916985212b02a73e24500555532be2b
TimeWithZone#encode_withwould explicitly set thetagof thecoderAnd 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_tagsBut because
dump_tagsis a simple Hash with the key being thetypeit only gets the correct tag when the type is specificallyTimeWithZoneIn order to maintain compatibility with previous behavior any class that delegates YAML serialization behavior to
TimeWithZoneviaencode_withneeds to output the sametagasTimeWithZoneYes 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::Valueand YAML dumping@pixeltrix Maybe we should add
Wdyt?