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

Add #travel_to and a few more features #11

Merged
merged 7 commits into from
Dec 19, 2024
Merged
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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions lib/iron_trail.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down
7 changes: 7 additions & 0 deletions lib/iron_trail/association.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,13 @@ def association_scope
scope
end

def reader
res = super
res.extend(CollectionProxyMixin)
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I wonder what YJIT thinks of this. Anyway, not a concern :)


res
end

def find_target
scope.to_a
end
Expand Down
8 changes: 8 additions & 0 deletions lib/iron_trail/change_model_concern.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
16 changes: 16 additions & 0 deletions lib/iron_trail/collection_proxy_mixin.rb
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions lib/iron_trail/model.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
52 changes: 52 additions & 0 deletions lib/iron_trail/reifier.rb
Original file line number Diff line number Diff line change
@@ -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] }
Copy link
Member

Choose a reason for hiding this comment

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

Double-checking if this logic is correct, seems wrong to me considering the rest of the method. Also, I am unsure if you are and how hard it is to cover this path.

Suggested change
val.to_h { |k| [k.to_s, k] }
val.to_h { |k| [k.to_s, k] }

Copy link
Collaborator Author

@andrepiske andrepiske Dec 18, 2024

Choose a reason for hiding this comment

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

This is correct. I have to yet add simplecov to this project, won't do in this PR tho. This code is covered by the spec in spec/services/morpheus_spec.rb :)

This basically deals with tables that are mapped by multiple models, that is, activerecord Single Table Inheritance. So the mapping here becomes a hash mapping the model name to the model itself. The index variable will look something like:

index = {
  'some_sti_table' => {
    'ModelA' => ModelA,
    'ModelB' => ModelB
  },
  'some_other_table' => SomeOtherTable
}

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
4 changes: 4 additions & 0 deletions spec/dummy_app/app/models/irontrail_change.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions spec/dummy_app/app/models/matrix_pill.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@

# An STI model
class MatrixPill < ApplicationRecord
include IronTrail::Model
end
8 changes: 2 additions & 6 deletions spec/dummy_app/app/services/morpheus.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
143 changes: 143 additions & 0 deletions spec/models/guitar_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
33 changes: 29 additions & 4 deletions spec/services/morpheus_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
6 changes: 3 additions & 3 deletions spec/spec_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

the goal isn't to test pg_party itself or partitioning. So since I'm now also using dates far in the past in some tests, I'll just create one big partition to accomodate them all.

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

Expand Down
Loading