diff --git a/CHANGELOG.md b/CHANGELOG.md index 00191b2..1e09eb5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Now able to travel back in time with `model.iron_trails.travel_to(some_timestamp)` +- Add ability to "reify" a trail, that is, to restore the object to what it was in a given trail +- Added helper methods to `IronTrail::ChangeModelConcern`: `insert_operation?`, `update_operation?`, `delete_operation?` +- Added helpers to filter/scope trails: `model.iron_trails.inserts` (also '.deletes' and `.updates`) +- Full STI (Single Table Inheritance) support now added with proper tests + ## 0.0.2 - 2024-12-09 ### Added diff --git a/lib/iron_trail.rb b/lib/iron_trail.rb index c084d23..00ee4bd 100644 --- a/lib/iron_trail.rb +++ b/lib/iron_trail.rb @@ -17,6 +17,8 @@ require 'iron_trail/reflection' require 'iron_trail/model' require 'iron_trail/change_model_concern' +require 'iron_trail/collection_proxy_mixin' +require 'iron_trail/reifier' require 'iron_trail/railtie' diff --git a/lib/iron_trail/association.rb b/lib/iron_trail/association.rb index 3b17c65..7e117ad 100644 --- a/lib/iron_trail/association.rb +++ b/lib/iron_trail/association.rb @@ -19,6 +19,13 @@ def association_scope scope end + def reader + res = super + res.extend(CollectionProxyMixin) + + res + end + def find_target scope.to_a end diff --git a/lib/iron_trail/change_model_concern.rb b/lib/iron_trail/change_model_concern.rb index a4ac543..266de2c 100644 --- a/lib/iron_trail/change_model_concern.rb +++ b/lib/iron_trail/change_model_concern.rb @@ -4,6 +4,14 @@ module IronTrail module ChangeModelConcern extend ::ActiveSupport::Concern + def reify + Reifier.reify(self) + end + + def insert_operation? = (operation == 'i') + def update_operation? = (operation == 'u') + def delete_operation? = (operation == 'd') + module ClassMethods def where_object_changes_to(args = {}) _where_object_changes(1, args) diff --git a/lib/iron_trail/collection_proxy_mixin.rb b/lib/iron_trail/collection_proxy_mixin.rb new file mode 100644 index 0000000..ebc5915 --- /dev/null +++ b/lib/iron_trail/collection_proxy_mixin.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module IronTrail + module CollectionProxyMixin + def travel_to(ts) + arel_table = arel.ast.cores.first.source.left + + change_record = scope + .order(arel_table[:created_at] => :desc) + .where(arel_table[:created_at].lteq(ts)) + .first + + change_record.reify + end + end +end diff --git a/lib/iron_trail/model.rb b/lib/iron_trail/model.rb index abee1c3..5d1c2ed 100644 --- a/lib/iron_trail/model.rb +++ b/lib/iron_trail/model.rb @@ -9,6 +9,7 @@ module IronTrail module Model def self.included(mod) mod.include ClassMethods + mod.attr_reader :irontrail_reified_ghost_attributes ::ActiveRecord::Reflection.add_reflection( mod, diff --git a/lib/iron_trail/reifier.rb b/lib/iron_trail/reifier.rb new file mode 100644 index 0000000..e8d7b8c --- /dev/null +++ b/lib/iron_trail/reifier.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +module IronTrail + module Reifier + def self.reify(trail) + source_attributes = (trail.delete_operation? ? trail.rec_old : trail.rec_new) + klass = model_from_table_name(trail.rec_table, source_attributes['type']) + + record = klass.where(id: trail.rec_id).first || klass.new + + source_attributes.each do |name, value| + if record.has_attribute?(name) + record[name.to_sym] = value + elsif record.respond_to?("#{name}=") + record.send("#{name}=", value) + else + ghost = record.instance_variable_get(:@irontrail_reified_ghost_attributes) + unless ghost + ghost = HashWithIndifferentAccess.new + record.instance_variable_set(:@irontrail_reified_ghost_attributes, ghost) + end + ghost[name] = value + end + end + + record + end + + def self.model_from_table_name(table_name, sti_type=nil) + index = ActiveRecord::Base.descendants.reject(&:abstract_class).chunk(&:table_name).to_h do |key, val| + v = \ + if val.length == 1 + val[0] + else + val.to_h { |k| [k.to_s, k] } + end + + [key, v] + end + + klass = index[table_name] + raise "Cannot infer model from table named '#{table_name}'" unless klass + + return klass unless klass.is_a?(Hash) + klass = klass[sti_type] + + return klass if klass + + raise "Cannot infer STI model for table #{table_name} and type '#{sti_type}'" + end + end +end diff --git a/spec/dummy_app/app/models/irontrail_change.rb b/spec/dummy_app/app/models/irontrail_change.rb index 9be0bd2..2e093f2 100644 --- a/spec/dummy_app/app/models/irontrail_change.rb +++ b/spec/dummy_app/app/models/irontrail_change.rb @@ -5,4 +5,8 @@ class IrontrailChange < ApplicationRecord include IronTrail::ChangeModelConcern range_partition_by { :created_at } + + scope :inserts, -> { where(operation: 'i') } + scope :updates, -> { where(operation: 'u') } + scope :deletes, -> { where(operation: 'd') } end diff --git a/spec/dummy_app/app/models/matrix_pill.rb b/spec/dummy_app/app/models/matrix_pill.rb index 4d90899..585d011 100644 --- a/spec/dummy_app/app/models/matrix_pill.rb +++ b/spec/dummy_app/app/models/matrix_pill.rb @@ -2,4 +2,5 @@ # An STI model class MatrixPill < ApplicationRecord + include IronTrail::Model end diff --git a/spec/dummy_app/app/services/morpheus.rb b/spec/dummy_app/app/services/morpheus.rb index 1feb7c5..197f889 100644 --- a/spec/dummy_app/app/services/morpheus.rb +++ b/spec/dummy_app/app/services/morpheus.rb @@ -4,12 +4,8 @@ class Morpheus def just_like_in_the_movie { - red: RedPill.create!, - blue: PillBlue.create! + red: RedPill.create!(pill_size: 10), + blue: PillBlue.create!(pill_size: 11) } end - - def guitar_hero - end - end diff --git a/spec/models/guitar_spec.rb b/spec/models/guitar_spec.rb index cfc4df3..c7e77b8 100644 --- a/spec/models/guitar_spec.rb +++ b/spec/models/guitar_spec.rb @@ -3,6 +3,149 @@ RSpec.describe Guitar do let(:person) { Person.create!(first_name: 'Arthur', last_name: 'Schopenhauer') } + describe 'IronTrail::ChangeModelConcern' do + describe 'helper methods' do + before do + person.update!(first_name: 'Joe') + person.update!(first_name: 'Joey') + person.destroy! + end + + it 'correctly classifies operations' do + expect(person.iron_trails.length).to eq(4) + expect(person.iron_trails.inserts.length).to eq(1) + expect(person.iron_trails.updates.length).to eq(2) + expect(person.iron_trails.deletes.length).to eq(1) + + expect(person.iron_trails.inserts[0].insert_operation?).to be(true) + expect(person.iron_trails.inserts[0].update_operation?).to be(false) + expect(person.iron_trails.inserts[0].delete_operation?).to be(false) + + expect(person.iron_trails.updates[0].insert_operation?).to be(false) + expect(person.iron_trails.updates[0].update_operation?).to be(true) + expect(person.iron_trails.updates[0].delete_operation?).to be(false) + expect(person.iron_trails.updates[1].insert_operation?).to be(false) + expect(person.iron_trails.updates[1].update_operation?).to be(true) + expect(person.iron_trails.updates[1].delete_operation?).to be(false) + + expect(person.iron_trails.deletes[0].insert_operation?).to be(false) + expect(person.iron_trails.deletes[0].update_operation?).to be(false) + expect(person.iron_trails.deletes[0].delete_operation?).to be(true) + end + end + end + + describe 'iron_trails.travel_to' do + let(:guitar) { Guitar.create!(description: 'the guitar', person:) } + + before do + fake_timestamps = [ + Time.parse('2005-04-15T13:44:59Z'), + Time.parse('2005-05-08T14:00:03Z'), + Time.parse('2005-05-08T14:00:15Z'), + Time.parse('2005-05-08T14:02:00Z') + ] + + guitar.update!(description: 'guitar 2') + guitar.update!(description: 'guitar 3') + guitar.update!(description: 'guitar 4') + @trail_ids = guitar.iron_trails.order(id: :asc).pluck(:id) + + expect(@trail_ids.length).to eq(4) + + @trail_ids.zip(fake_timestamps).each do |trail_id, fake_ts| + query = "UPDATE irontrail_changes SET created_at='#{fake_ts}' WHERE id=#{trail_id}" + result = ActiveRecord::Base.connection.execute(query) + expect(result.cmd_tuples).to eq(1) + end + guitar.reload + end + + it 'recovers the correct record' do + git = guitar.iron_trails.travel_to('2005-05-08T14:00:03Z') + + expect(git).to be_a(Guitar) + expect(git.id).to eq(guitar.id) + expect(git.description).to eq('guitar 2') + end + + it 'has no ghost reified attributes' do + git = guitar.iron_trails.travel_to('2005-05-08T14:00:03Z') + expect(git.irontrail_reified_ghost_attributes).to be_nil + end + + context 'when a version has attributes that dont exist anymore' do + before do + trail_id = @trail_ids[2] + trail = IrontrailChange.find_by!(id: trail_id) + + rec_old = trail.rec_old.merge('foo' => 'perfectly fine') + rec_new = trail.rec_new.merge('foo' => 'ghosted!') + + query = <<~SQL + UPDATE irontrail_changes SET + rec_old=#{ActiveRecord::Base.connection.quote(JSON.dump(rec_old))}::jsonb, + rec_new=#{ActiveRecord::Base.connection.quote(JSON.dump(rec_new))}::jsonb + WHERE id=#{trail_id} + SQL + + result = ActiveRecord::Base.connection.execute(query) + expect(result.cmd_tuples).to eq(1) + end + + describe 'on time' do + let(:git) { guitar.iron_trails.travel_to('2005-05-08T14:00:15Z') } + + it 'contains ghost reified attributes' do + expect(git).to be_a(Guitar) + expect(git.id).to eq(guitar.id) + expect(git.description).to eq('guitar 3') + expect(git.irontrail_reified_ghost_attributes).to eq({ foo: 'ghosted!' }.with_indifferent_access) + end + end + + describe 'a little late' do + let(:git) { guitar.iron_trails.travel_to('2005-05-08T14:00:17Z') } + + it 'contains ghost reified attributes' do + expect(git).to be_a(Guitar) + expect(git.description).to eq('guitar 3') + expect(git.irontrail_reified_ghost_attributes).to eq({ foo: 'ghosted!' }.with_indifferent_access) + end + end + end + + context 'when the object has been destroyed' do + let(:destroy_time) { '2006-10-21T06:00:00Z' } + before do + guitar.destroy! + query = "UPDATE irontrail_changes SET created_at='#{destroy_time}' WHERE operation='d' AND rec_id='#{guitar.id}'" + result = ActiveRecord::Base.connection.execute(query) + expect(result.cmd_tuples).to eq(1) + end + + describe 'on time' do + let(:git) { guitar.iron_trails.travel_to(destroy_time) } + + it 'recovers the correct record' do + expect(git).to be_a(Guitar) + expect(git.id).to eq(guitar.id) + expect(git.description).to eq('guitar 4') + end + end + + describe 'a little late' do + let(:git) { guitar.iron_trails.travel_to(Time.parse(destroy_time) + 5) } + + it 'recovers the correct record' do + expect(git).to be_a(Guitar) + expect(git.id).to eq(guitar.id) + expect(git.description).to eq('guitar 4') + end + end + end + end + describe 'has_many trails' do it 'has the trails' do classics = PeopleManager::CLASSIC_GUITARS diff --git a/spec/services/morpheus_spec.rb b/spec/services/morpheus_spec.rb index 433e61c..1f7b6af 100644 --- a/spec/services/morpheus_spec.rb +++ b/spec/services/morpheus_spec.rb @@ -2,12 +2,37 @@ RSpec.describe Morpheus do subject(:instance) { described_class.new } + subject(:pills) { instance.just_like_in_the_movie } - describe '#just_like_in_the_movie' do - xit 'offers two pills' do - 3.times { described_class.new.just_like_in_the_movie } + it 'has the type attribute serialized' do + red = pills[:red] - pills = described_class.new.just_like_in_the_movie + red.update!(pill_size: 44) + trails = red.reload.iron_trails.order(id: :asc) + + expect(trails.length).to eq(2) + expect(trails[0].rec_new['type']).to eq('RedPill') + expect(trails[1].rec_new['type']).to eq('RedPill') + end + + describe 'object morphing' do + it 'morphs colors' do + blue = pills[:blue] + pills[:red].destroy! + + blue.update!(pill_size: 15) + blue.update!(pill_size: 25) + + trail = blue.iron_trails.where_object_changes_to(pill_size: 15).first + trail.rec_new['type'] = 'RedPill' # likely invalid case in the real world, but good for testing. + trail.save! + blue.reload + + red = blue.iron_trails.where_object_changes_to(pill_size: 15).first.reify + expect(red).to be_a(RedPill) + + blue_again = blue.iron_trails.where_object_changes_to(pill_size: 25).first.reify + expect(blue_again).to be_a(PillBlue) end end end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 2a3ed70..0d2a16f 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -43,13 +43,13 @@ ::IronTrailSpecMigrator.new.migrate Time.now.tap do |date| - partition_name = "irontrail_chgn_#{date.strftime('%Y%m')}" + partition_name = "irontrail_chgn_infinite" next if ActiveRecord::Base.connection.table_exists?(partition_name) IrontrailChange.create_partition( name: partition_name, - start_range: date, - end_range: date.next_month + start_range: Time.parse('2000-01-01T00:00:00Z'), + end_range: Time.parse('2100-01-01T00:00:00Z') ) end