Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Prevent touch from saving unsaved changes #148

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions lib/activerecord-bitemporal/bitemporal.rb
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,59 @@ def save!(**)
end
end

# @see https://github.com/rails/rails/blob/v7.1.0/activerecord/lib/active_record/attribute_methods/dirty.rb#L200
# @see https://github.com/rails/rails/blob/v7.1.0/activerecord/lib/active_record/persistence.rb#L1200
def _touch_row(attribute_names, time)
_touch_attr_names = Set.new(attribute_names)

time ||= current_time_from_proper_timezone

attribute_names.each do |attr_name|
_write_attribute(attr_name, time)
end

changes = {}
@attributes.keys.each do |attr_name|
next if _touch_attr_names.include?(attr_name)

if attribute_changed?(attr_name)
changes[attr_name] = _read_attribute(attr_name)
_write_attribute(attr_name, attribute_was(attr_name))
clear_attribute_change(attr_name)
end
end

# The ActiveRecord::Bitemporal::Persistence#_update_row is different from the original
# and also save changes other than the passed attribute_names.
# To avoid saving unintended changes, evacuate the unsaved changes to a variable
# before _update_row is called. ActiveRecord::AttributeMethods::Dirty#_touch_row does this **after** _update_row.
affected_rows = _update_row(attribute_names, "touch")

# @_skip_dirty_tracking was introduced to prevent unintentional changes of previous_changes
# due to deferred touches caused by `touch: true`. See https://github.com/rails/rails/pull/36271
# To reproduce similar behavior, return affected_rows without calling changes_applied.
# Note that unlike the original, changes must be evacuated and restored.
# As explained above, this is to avoid implementation differences in _update_row.
#
# FIXME: Implicit touching with `touch: true`` is **completely** broken in the bi-temporal gem.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
# FIXME: Implicit touching with `touch: true`` is **completely** broken in the bi-temporal gem.
# FIXME: Implicit touching with `touch: true` is **completely** broken in the bi-temporal gem.

# ActiveRecord::TouchLater#touch_later sets an instance variable and performs the actual touching
# with before_committed!, but the bi-temporal gem internally creates multiple Active Record instances
# and calls touch_later and before_committed! for each. So, we cannot propagate instance variables properly.
# As a result, there is no case where @_skip_dirty_tracking works effectively in Rails v7.0.
if @_skip_dirty_tracking ||= false
clear_attribute_changes(_touch_attr_names)
changes.each { |attr_name, value| _write_attribute(attr_name, value) }
return affected_rows
end
Copy link
Contributor Author

@wata727 wata727 Oct 10, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably this @_skip_dirty_tracking check is not appropriate. I'll review it later.
See also rails/rails#36271

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, perhaps we need to start with how to define implicit touching by TouchLater in bi-temporal semantics. There's a long way to go.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ActiveRecord::TouchLater seems completely broken in the bi-temporal gem...
Added a comment about the behavior I investigated.


changes_applied
changes.each { |attr_name, value| _write_attribute(attr_name, value) }

affected_rows
ensure
@_skip_dirty_tracking = nil
end

def _update_row(attribute_names, attempted_action = 'update')
current_valid_record, before_instance, after_instance = bitemporal_build_update_records(valid_datetime: self.valid_datetime, force_update: self.force_update?)

Expand Down
80 changes: 77 additions & 3 deletions spec/activerecord-bitemporal/bitemporal_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,13 @@
it {
is_expected.to have_attributes(
bitemporal_id: subject.id,
# changes: be_empty, FIXME: Creating with bitemporal_id produces the unexpected "id" changes. See #144 or #147
previous_changes: include(
"id" => [nil, subject.swapped_id],
"valid_from" => [nil, be_present],
"valid_to" => [nil, "2019/10/01".in_time_zone],
"transaction_from" => [nil, be_present],
"transaction_to" => [nil, ActiveRecord::Bitemporal::DEFAULT_TRANSACTION_TO],
"name" => [nil, "Tom"]
)
)
Expand All @@ -42,10 +45,13 @@
it {
is_expected.to have_attributes(
bitemporal_id: subject.id,
changes: be_empty,
previous_changes: include(
"id" => [nil, subject.id],
"valid_from" => [nil, be_present],
"valid_to" => [nil, ActiveRecord::Bitemporal::DEFAULT_VALID_TO],
"transaction_from" => [nil, be_present],
"transaction_to" => [nil, ActiveRecord::Bitemporal::DEFAULT_TRANSACTION_TO],
"name" => [nil, "Tom"]
),
previously_force_updated?: false
Expand Down Expand Up @@ -654,7 +660,7 @@ def self.bitemporal_id_key
let(:from) { time_current }
let(:to) { from + 10.days }
let(:finish) { Time.utc(9999, 12, 31).in_time_zone }
let!(:employee) { Employee.create!(name: "Jone", valid_from: from, valid_to: to) }
let!(:employee) { Timecop.freeze(from - 1.month) { Employee.create!(name: "Jone", valid_from: from, valid_to: to) } }
let!(:swapped_id) { employee.swapped_id }
let(:count) { -> { Employee.where(bitemporal_id: employee.id).ignore_valid_datetime.count } }
let(:old_jone) { Employee.ignore_valid_datetime.within_deleted.find_by(bitemporal_id: employee.id, name: "Jone") }
Expand Down Expand Up @@ -684,6 +690,13 @@ def self.bitemporal_id_key
let(:now) { from + 5.days }
it { expect { subject }.to change(&count).by(1) }
it { expect { subject }.to change(employee, :name).from("Jone").to("Tom") }
it { expect { subject }.to change(employee, :valid_from).from(from).to(now) }
it { expect { subject }.not_to change(employee, :valid_to) }
it { expect { subject }.to change(employee, :transaction_from).to(now) }
it { expect { subject }.not_to change(employee, :transaction_to) }
it { expect(employee.changes).to be_empty }
it { expect { subject }.not_to change(employee, :changes) }
it { expect { subject }.to change { employee.saved_changes.keys }.to(contain_exactly("name", "valid_from", "transaction_from", "updated_at")) }
it { expect { subject }.to change(employee, :swapped_id).from(swapped_id).to(kind_of(Integer)) }
it { expect { subject }.to change(employee, :swapped_id_previously_was).from(nil).to(swapped_id) }
it_behaves_like "updated Jone" do
Expand All @@ -707,6 +720,13 @@ def self.bitemporal_id_key
let(:now) { from - 5.days }
it { expect { subject }.to change(&count).by(1) }
it { expect { subject }.to change(employee, :name).from("Jone").to("Tom") }
it { expect { subject }.to change(employee, :valid_from).from(from).to(now) }
it { expect { subject }.to change(employee, :valid_to).from(to).to(from) }
it { expect { subject }.to change(employee, :transaction_from).to(now) }
it { expect { subject }.not_to change(employee, :transaction_to) }
it { expect(employee.changes).to be_empty }
it { expect { subject }.not_to change(employee, :changes) }
it { expect { subject }.to change { employee.saved_changes.keys }.to contain_exactly("name", "valid_from", "valid_to", "transaction_from", "updated_at") }
it { expect { subject }.to change(employee, :swapped_id).from(swapped_id).to(kind_of(Integer)) }
it { expect { subject }.to change(employee, :swapped_id_previously_was).from(nil).to(swapped_id) }
it_behaves_like "updated Jone" do
Expand Down Expand Up @@ -1052,6 +1072,8 @@ class EmployeeWithUniquness < Employee
it { expect { subject }.to change { Employee.ignore_valid_datetime.within_deleted.count }.by(1) }
it { expect { subject }.to change(employee, :swapped_id).from(@swapped_id_before_destroy).to(kind_of(Integer)) }
it { expect { subject }.to change(employee, :swapped_id_previously_was).from(kind_of(Integer)).to(@swapped_id_before_destroy) }
xit { expect { subject }.to change(employee, :changes).to(be_empty) } # FIXME: Destroying produces the unexpected "valid_to", "transaction_from", and "transaction_to" changes.
it { expect { subject }.not_to change(employee, :saved_changes) }
it { expect(subject).to eq employee }

it do
Expand Down Expand Up @@ -1223,14 +1245,66 @@ class EmployeeWithUniquness < Employee
end

describe "#touch" do
let!(:employee) { Employee.create(name: "Jane").tap { |it| it.update!(name: "Tom") } }
let!(:employee) { Timecop.freeze(created_time) { Employee.create(name: "Tom") } }
let(:employee_count) { -> { Employee.ignore_valid_datetime.bitemporal_for(employee.id).count } }
subject { employee.touch(:archived_at) }
let(:created_time) { time_current - 10.seconds }
let(:touched_time) { time_current - 5.seconds }
subject { Timecop.freeze(touched_time) { employee.touch(:archived_at) } }

before { @swapped_id_before_touch = employee.swapped_id }

it { expect(employee).to have_attributes(name: "Tom", id: employee.id) }
it { expect(subject).to eq true }
it { expect { subject }.to change(&employee_count).by(1) }
it { expect { subject }.to change { employee.reload.archived_at }.from(nil) }
it { expect { subject }.to change(employee, :valid_from).from(created_time).to(touched_time) }
it { expect { subject }.not_to change(employee, :valid_to) }
it { expect { subject }.to change(employee, :transaction_from).from(created_time).to(touched_time) }
it { expect { subject }.not_to change(employee, :transaction_to) }
it { expect { subject }.to change(employee, :swapped_id).from(@swapped_id_before_touch).to(kind_of(Integer)) }
it { expect { subject }.to change(employee, :swapped_id_previously_was).from(nil).to(@swapped_id_before_touch) }
it { expect(employee.changes).to be_empty }
it { expect { subject }.not_to change(employee, :changes) }
it { expect { subject }.to change { employee.saved_changes.keys }.to(contain_exactly("archived_at", "valid_from", "transaction_from", "updated_at")) }

context "with unsaved changes" do
let(:changed_time) { time_current - 7.seconds } # Neither created_time nor touched_time

before do
employee.name = "Jane"
employee.valid_from = changed_time
employee.archived_at = changed_time
@updated_at_before_touch = employee.updated_at
end

it { expect(employee).to have_attributes(name: "Jane", id: employee.id) }
it { expect(subject).to eq true }
it { expect { subject }.to change(&employee_count).by(1) }
# Do not save temporary changes
it { expect { subject }.not_to change { employee.reload.name } }
it { expect { subject }.to change { employee.reload.valid_from }.from(created_time).to(touched_time) }
it { expect { subject }.to change { employee.reload.archived_at }.from(nil).to(touched_time) }
# Merge temporary changes with changes by bi-temporal operations
it { expect { subject }.not_to change(employee, :valid_from) }
it { expect { subject }.not_to change(employee, :valid_to) }
it { expect { subject }.to change(employee, :transaction_from).from(created_time).to(touched_time) }
it { expect { subject }.not_to change(employee, :transaction_to) }
it { expect(employee.changes).to eq({ "name" => ["Tom", "Jane"], "valid_from" => [created_time, changed_time], "archived_at" => [nil, changed_time] }) }
it { expect { subject }.to change { employee.changes }.to(eq({ "name" => ["Tom", "Jane"], "valid_from" => [touched_time, changed_time] })) } # Discard archived_at and changes the previous value of valid_from
it { expect { subject }.to change { employee.saved_changes }.to(eq({ "archived_at" => [nil, touched_time], "valid_from" => [created_time, touched_time], "transaction_from" => [created_time, touched_time], "updated_at" => [@updated_at_before_touch, touched_time] })) }

context "and #force_update" do
subject { Timecop.freeze(touched_time) { employee.force_update { |e| e.touch(:archived_at) } } }

it { expect(subject).to eq true }
it { expect { subject }.not_to change(&employee_count) }
# With update + force_update, valid_from changes are reflected as is (saved as changed_time), but touch + force_update ignores the changes
it { expect(employee.reload.valid_from).to eq(created_time) }
it { expect { subject }.not_to change { employee.reload.valid_from } }
it { expect(employee.valid_from).to eq(changed_time) }
it { expect { subject }.not_to change(employee, :valid_from) }
end
end
end

describe "validation" do
Expand Down