From 001ae733cf4ea10fa6560f4a2e351128b95ddd8d Mon Sep 17 00:00:00 2001 From: Martin Schaflitzl Date: Wed, 15 Oct 2025 16:06:13 +0200 Subject: [PATCH 1/2] Fix marshalling for virtual attributes on ActiveType::Object and ActiveType::Record --- lib/active_type/marshalling.rb | 62 ++++++++++++++++++++++++ lib/active_type/object.rb | 2 + lib/active_type/record.rb | 2 + spec/active_type/object_spec.rb | 77 ++++++++++++++++++++++++++++++ spec/active_type/record_spec.rb | 84 +++++++++++++++++++++++++++++++++ 5 files changed, 227 insertions(+) create mode 100644 lib/active_type/marshalling.rb diff --git a/lib/active_type/marshalling.rb b/lib/active_type/marshalling.rb new file mode 100644 index 0000000..48d3f83 --- /dev/null +++ b/lib/active_type/marshalling.rb @@ -0,0 +1,62 @@ +module ActiveType + module Marshalling + # With 7.1 rails defines its own marshal_dump and marshal_load methods, + # which selectively only dump and load the record´s attributes and some more stuff, but not our @virtual_attributes. + # Whether these new methods are actually used, depends on ActiveRecord::Marshalling.format_version + # For format_version = 6.1 active record uses the default ruby implementation for dumping and loading. + # For format_version = 7.1 active record uses a custom implementation, which we need to override. + # + # format_version can also be dynamically changed during runtime, on change we need to define or undefine our marshal_dump dynamically, because: + # * We cannot check the format_version at runtime within marshal_dump or marshal_load, + # as we can´t just super to the default for the wrong version, because there is no method to super to. + # (The default implementation is a ruby internal, not a real method.) + # * We cannot override the methods at load time only when format version is 7.1, + # because format version usually gets set after our initialisation and could change at any time. + # + # Two facts about ruby also help us with that (also see https://ruby-doc.org/core-2.6.8/Marshal.html): + # * A custom marshal_load is only used, when marshal_dump is also defined. So we can keep marshal_dump always defined. + # (If either is missing, ruby will use _dump and _load) + # * If a serialized object is dumped using _dump it will be loaded using _load, never marshal_load, so a record + # serialized with format_version = 6.1 using _dump, will always load using _load, ignoring whether marshal_load is defined or not. + # This ensures objects will always be deserialized with the method they were serialized with. We don´t need to worry about that. + + class << self + attr_reader :format_version + + def format_version=(version) + case version + when 6.1 + Methods.remove_method(:marshal_dump) if Methods.method_defined?(:marshal_dump) + when 7.1 + Methods.alias_method(:marshal_dump, :_marshal_dump_7_1) + else + raise ArgumentError, "Unknown marshalling format: #{version.inspect}" + end + @format_version = version + end + end + + module ActiveRecordMarshallingExtension + def format_version=(version) + ActiveType::Marshalling.format_version = version + super(version) + end + end + + module Methods + def _marshal_dump_7_1 + [super, @virtual_attributes] + end + + def marshal_load(state) + super_attributes, @virtual_attributes = state + super(super_attributes) + end + end + + end +end + +ActiveRecord::Marshalling.singleton_class.prepend(ActiveType::Marshalling::ActiveRecordMarshallingExtension) +# Set ActiveType´s format_version to ActiveRecord´s, in case ActiveRecord uses the default value, which is set before we are loaded. +ActiveType::Marshalling.format_version = ActiveRecord::Marshalling.format_version diff --git a/lib/active_type/object.rb b/lib/active_type/object.rb index 5745d6f..c61d961 100644 --- a/lib/active_type/object.rb +++ b/lib/active_type/object.rb @@ -1,6 +1,7 @@ require 'active_type/no_table' require 'active_type/virtual_attributes' require 'active_type/nested_attributes' +require 'active_type/marshalling' if ActiveRecord::VERSION::MAJOR >= 7 && ActiveRecord::VERSION::MINOR >= 1 module ActiveType @@ -9,6 +10,7 @@ class Object < ActiveRecord::Base include NoTable include VirtualAttributes include NestedAttributes + include Marshalling::Methods if ActiveRecord::VERSION::MAJOR >= 7 && ActiveRecord::VERSION::MINOR >= 1 end diff --git a/lib/active_type/record.rb b/lib/active_type/record.rb index 1ef2cc6..5631a2a 100644 --- a/lib/active_type/record.rb +++ b/lib/active_type/record.rb @@ -2,6 +2,7 @@ require 'active_type/record_extension' require 'active_type/nested_attributes' require 'active_type/change_association' +require 'active_type/marshalling' if ActiveRecord::VERSION::MAJOR >= 7 && ActiveRecord::VERSION::MINOR >= 1 module ActiveType @@ -13,6 +14,7 @@ class Record < ActiveRecord::Base include NestedAttributes include RecordExtension include ChangeAssociation + include Marshalling::Methods if ActiveRecord::VERSION::MAJOR >= 7 && ActiveRecord::VERSION::MINOR >= 1 end diff --git a/spec/active_type/object_spec.rb b/spec/active_type/object_spec.rb index 61ff15d..f980a5c 100644 --- a/spec/active_type/object_spec.rb +++ b/spec/active_type/object_spec.rb @@ -531,4 +531,81 @@ class ObjectWithUnsupportedTypes < Object end end + describe "marshalling" do + shared_examples "marshalling attributes" do + it "marshals attributes properly" do + object = ObjectSpec::Object.create( + virtual_string: "foobar", + virtual_integer: 123, + virtual_time: Time.parse("12:00 15.10.2025"), + virtual_date: Date.parse("15.10.2025"), + virtual_boolean: true, + virtual_attribute: { some: "random object" }, + virtual_type_attribute: "ObjectSpec::Object::PlainObject", + ) + + serialized_object = Marshal.dump(object) + deserialized_object = Marshal.load(serialized_object) + + expect(deserialized_object.virtual_string).to eq "foobar" + expect(deserialized_object.virtual_integer).to eq 123 + expect(deserialized_object.virtual_time).to eq Time.parse("12:00 15.10.2025") + expect(deserialized_object.virtual_date).to eq Date.parse("15.10.2025") + expect(deserialized_object.virtual_boolean).to eq true + expect(deserialized_object.virtual_attribute).to eq({ some: "random object" }) + expect(deserialized_object.virtual_type_attribute).to eq "ObjectSpec::Object::PlainObject" + end + end + + if ActiveRecord::VERSION::MAJOR >= 7 && ActiveRecord::VERSION::MINOR >= 1 + context 'for 6.1 marshalling' do + before do + ActiveRecord::Marshalling.format_version = 6.1 + end + + include_examples "marshalling attributes" + end + + context 'for 7.1 marshalling' do + before do + ActiveRecord::Marshalling.format_version = 7.1 + end + + include_examples "marshalling attributes" + end + + describe "loading a object marshalled with format version 6.1, but the current version is 7.1" do + it "marshals attributes properly" do + object = ObjectSpec::Object.create(virtual_attribute: "foobar") + + ActiveRecord::Marshalling.format_version = 6.1 + serialized_object = Marshal.dump(object) + + ActiveRecord::Marshalling.format_version = 7.1 + deserialized_object = Marshal.load(serialized_object) + + expect(deserialized_object.virtual_attribute).to eq "foobar" + end + end + + describe "loading a object marshalled with format version 7.1, but the current version is 6.1" do + it "marshals attributes properly" do + object = ObjectSpec::Object.create(virtual_attribute: "foobar") + + ActiveRecord::Marshalling.format_version = 7.1 + serialized_object = Marshal.dump(object) + + ActiveRecord::Marshalling.format_version = 6.1 + deserialized_object = Marshal.load(serialized_object) + + expect(deserialized_object.virtual_attribute).to eq "foobar" + end + end + + else + include_examples "marshalling attributes" + end + + end + end diff --git a/spec/active_type/record_spec.rb b/spec/active_type/record_spec.rb index 1c53258..77311b0 100644 --- a/spec/active_type/record_spec.rb +++ b/spec/active_type/record_spec.rb @@ -376,4 +376,88 @@ class RecordWithOptionalBelongsToFlippedValidatesForeignKey < Record end end + describe "marshalling" do + shared_examples "marshalling attributes" do + it "marshals attributes properly" do + object = RecordSpec::Record.create!( + virtual_string: "foobar", + virtual_integer: 123, + virtual_time: Time.parse("12:00 15.10.2025"), + virtual_date: Date.parse("15.10.2025"), + virtual_boolean: true, + virtual_attribute: { some: "random object" }, + virtual_type_attribute: "RecordSpec::Record", + persisted_string: "a real active record attribute" + ) + + serialized_object = Marshal.dump(object) + deserialized_object = Marshal.load(serialized_object) + + expect(deserialized_object.virtual_string).to eq "foobar" + expect(deserialized_object.virtual_integer).to eq 123 + expect(deserialized_object.virtual_time).to eq Time.parse("12:00 15.10.2025") + expect(deserialized_object.virtual_date).to eq Date.parse("15.10.2025") + expect(deserialized_object.virtual_boolean).to eq true + expect(deserialized_object.virtual_attribute).to eq({ some: "random object" }) + expect(deserialized_object.virtual_type_attribute).to eq "RecordSpec::Record" + expect(deserialized_object.persisted_string).to eq "a real active record attribute" + end + end + + if ActiveRecord::VERSION::MAJOR >= 7 && ActiveRecord::VERSION::MINOR >= 1 + context 'for 6.1 marshalling' do + before do + ActiveRecord::Marshalling.format_version = 6.1 + end + + include_examples "marshalling attributes" + end + + context 'for 7.1 marshalling' do + before do + ActiveRecord::Marshalling.format_version = 7.1 + end + + include_examples "marshalling attributes" + end + + describe "loading a object marshalled with format version 6.1, but the current version is 7.1" do + it "marshals attributes properly" do + object = RecordSpec::Record.create!( + virtual_string: "foo", + persisted_string: "bar" + ) + + ActiveRecord::Marshalling.format_version = 6.1 + serialized_object = Marshal.dump(object) + + ActiveRecord::Marshalling.format_version = 7.1 + deserialized_object = Marshal.load(serialized_object) + + expect(deserialized_object.virtual_string).to eq "foo" + expect(deserialized_object.persisted_string).to eq "bar" + end + end + + describe "loading a object marshalled with format version 7.1, but the current version is 6.1" do + it "marshals attributes properly" do + object = RecordSpec::Record.create!( + virtual_string: "foo", + persisted_string: "bar" + ) + + ActiveRecord::Marshalling.format_version = 7.1 + serialized_object = Marshal.dump(object) + + ActiveRecord::Marshalling.format_version = 6.1 + deserialized_object = Marshal.load(serialized_object) + + expect(deserialized_object.virtual_string).to eq "foo" + expect(deserialized_object.persisted_string).to eq "bar" + end + end + else + include_examples "marshalling attributes" + end + end end From 4dbcc96923e8aba683480c8c1a12b13846163b6c Mon Sep 17 00:00:00 2001 From: Martin Schaflitzl Date: Thu, 16 Oct 2025 11:17:33 +0200 Subject: [PATCH 2/2] Version 2.6.5 --- CHANGELOG.md | 4 ++++ Gemfile.6.1.pg.lock | 2 +- Gemfile.6.1.sqlite3.lock | 2 +- Gemfile.7.1.pg.lock | 2 +- Gemfile.7.1.sqlite3.lock | 2 +- Gemfile.7.2.mysql2.lock | 2 +- Gemfile.7.2.pg.lock | 2 +- Gemfile.7.2.sqlite3.lock | 2 +- Gemfile.8.0.sqlite3.lock | 2 +- lib/active_type/version.rb | 2 +- 10 files changed, 13 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6735535..c530ea0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ All notable changes to this project will be documented in this file. +## 2.6.5 (2025-10-16) + +* Fixed: ActiveType::Object and ActiveType::Record are now serialized/deserialized correctly using Marshal.dump/Marshal.load + ## 2.6.4 (2025-09-11) * Fixed: When using nests_many, nested updates, with preloaded records, using string ids will no longer cause additional DB queries. diff --git a/Gemfile.6.1.pg.lock b/Gemfile.6.1.pg.lock index 9707f9b..d7e471b 100644 --- a/Gemfile.6.1.pg.lock +++ b/Gemfile.6.1.pg.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - active_type (2.6.4) + active_type (2.6.5) activerecord (>= 6.1) GEM diff --git a/Gemfile.6.1.sqlite3.lock b/Gemfile.6.1.sqlite3.lock index f047449..96aa7a2 100644 --- a/Gemfile.6.1.sqlite3.lock +++ b/Gemfile.6.1.sqlite3.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - active_type (2.6.4) + active_type (2.6.5) activerecord (>= 6.1) GEM diff --git a/Gemfile.7.1.pg.lock b/Gemfile.7.1.pg.lock index 51dfee8..589bb5a 100644 --- a/Gemfile.7.1.pg.lock +++ b/Gemfile.7.1.pg.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - active_type (2.6.4) + active_type (2.6.5) activerecord (>= 6.1) GEM diff --git a/Gemfile.7.1.sqlite3.lock b/Gemfile.7.1.sqlite3.lock index 34686f1..e044365 100644 --- a/Gemfile.7.1.sqlite3.lock +++ b/Gemfile.7.1.sqlite3.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - active_type (2.6.4) + active_type (2.6.5) activerecord (>= 6.1) GEM diff --git a/Gemfile.7.2.mysql2.lock b/Gemfile.7.2.mysql2.lock index b2502eb..063cc19 100644 --- a/Gemfile.7.2.mysql2.lock +++ b/Gemfile.7.2.mysql2.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - active_type (2.6.4) + active_type (2.6.5) activerecord (>= 6.1) GEM diff --git a/Gemfile.7.2.pg.lock b/Gemfile.7.2.pg.lock index 218502a..afe7092 100644 --- a/Gemfile.7.2.pg.lock +++ b/Gemfile.7.2.pg.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - active_type (2.6.4) + active_type (2.6.5) activerecord (>= 6.1) GEM diff --git a/Gemfile.7.2.sqlite3.lock b/Gemfile.7.2.sqlite3.lock index cfe344e..3458c0f 100644 --- a/Gemfile.7.2.sqlite3.lock +++ b/Gemfile.7.2.sqlite3.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - active_type (2.6.4) + active_type (2.6.5) activerecord (>= 6.1) GEM diff --git a/Gemfile.8.0.sqlite3.lock b/Gemfile.8.0.sqlite3.lock index 1448aa9..fadc960 100644 --- a/Gemfile.8.0.sqlite3.lock +++ b/Gemfile.8.0.sqlite3.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - active_type (2.6.4) + active_type (2.6.5) activerecord (>= 6.1) GEM diff --git a/lib/active_type/version.rb b/lib/active_type/version.rb index 4f5969a..7e90795 100644 --- a/lib/active_type/version.rb +++ b/lib/active_type/version.rb @@ -1,3 +1,3 @@ module ActiveType - VERSION = '2.6.4' + VERSION = '2.6.5' end