From 1c832b14e62f0c86212b675a7eab55efe8f7ca0d Mon Sep 17 00:00:00 2001 From: Albert Jankowski Date: Sun, 2 Aug 2026 10:28:40 -0500 Subject: [PATCH] Fix in-place session data mutations being silently dropped When session data is mutated, the change detection in data= compared against the already-mutated @data object, so changed? returned false and the save was skipped. --- lib/active_record/session_store/session.rb | 9 +++++++-- lib/active_record/session_store/sql_bypass.rb | 4 +++- test/session_test.rb | 11 +++++++++++ test/sql_bypass_test.rb | 12 ++++++++++++ 4 files changed, 33 insertions(+), 3 deletions(-) diff --git a/lib/active_record/session_store/session.rb b/lib/active_record/session_store/session.rb index c789762..1b08490 100644 --- a/lib/active_record/session_store/session.rb +++ b/lib/active_record/session_store/session.rb @@ -39,11 +39,16 @@ def initialize(*) # Lazy-deserialize session state. def data - @data ||= self.class.deserialize(read_attribute(@@data_column_name)) || {} + unless @data + @data = self.class.deserialize(read_attribute(@@data_column_name)) || {} + @data_snapshot = Marshal.load(Marshal.dump(@data)) + end + @data end def data=(data) - attribute_will_change!(@@data_column_name) if data != self.data + original = @data_snapshot || self.data + attribute_will_change!(@@data_column_name) if data != original @data = data end diff --git a/lib/active_record/session_store/sql_bypass.rb b/lib/active_record/session_store/sql_bypass.rb index 20313d6..5e7cc62 100644 --- a/lib/active_record/session_store/sql_bypass.rb +++ b/lib/active_record/session_store/sql_bypass.rb @@ -94,6 +94,7 @@ def data unless @data if @serialized_data @data, @serialized_data = self.class.deserialize(@serialized_data) || {}, nil + @data_snapshot = Marshal.load(Marshal.dump(@data)) else @data = {} end @@ -106,7 +107,8 @@ def loaded? end def data=(data) - @data_changed = true if data != self.data + original = @data_snapshot || self.data + @data_changed = true if data != original @data = data end diff --git a/test/session_test.rb b/test/session_test.rb index 9a32cfb..9d2b969 100644 --- a/test/session_test.rb +++ b/test/session_test.rb @@ -111,6 +111,17 @@ def test_loaded? assert !s.loaded?, 'session is not loaded' end + def test_in_place_mutation_is_tracked + Session.create_table! + session_klass.create!(:data => {"key" => ["a"]}, :session_id => '42') + t = session_klass.find_by_session_id('42') + assert !t.changed?, 'freshly loaded session is unchanged' + data = t.data + data["key"] << "b" + t.data = data + assert t.changed?, 'in-place mutation must be detected as a change' + end + def test_data_changes_are_tracked Session.create_table! session_klass.create!(:data => 'world', :session_id => '10') diff --git a/test/sql_bypass_test.rb b/test/sql_bypass_test.rb index 6f917b7..83df5b3 100644 --- a/test/sql_bypass_test.rb +++ b/test/sql_bypass_test.rb @@ -28,6 +28,18 @@ def test_persisted? assert !s.persisted?, 'this is a new record!' end + def test_in_place_mutation_is_tracked + SqlBypass.create_table! unless Session.table_exists? + s = SqlBypass.new :data => {"key" => ["a"]}, :session_id => 50 + s.save + t = SqlBypass.find_by_session_id 50 + assert !t.changed?, 'freshly loaded session is unchanged' + data = t.data + data["key"] << "b" + t.data = data + assert t.changed?, 'in-place mutation must be detected as a change' + end + def test_changed? SqlBypass.create_table! unless Session.table_exists? session_id = 20