Skip to content

Commit

Permalink
Use model timestamps when available (#18)
Browse files Browse the repository at this point in the history
  • Loading branch information
andrepiske authored Jan 29, 2025
1 parent 8ce1a88 commit ed20667
Show file tree
Hide file tree
Showing 7 changed files with 139 additions and 6 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Changed

- Use model timestamps (created_at, updated_at) for irontrail_changes.created_at column

## 0.0.6 - 2025-01-14

### Changed
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,10 @@ Enabling/disabling IronTrail in specs works by replacing the trigger function in
with a dummy no-op function or with the real function and it won't add or drop triggers from
any tables.

The `created_at` column of the `irontrail_changes` table is determined from the model timestamp attributes
(`created_at` for inserts and `updated_at` for updates) if they're present.
There is a caveat for delete operations: the value will always be the current timestamp from the database. This makes it impossible to mock for that scenario.

## Rake tasks

IronTrail comes with a few handy rake tasks you can use in your dev, test and
Expand Down
26 changes: 20 additions & 6 deletions lib/iron_trail/irontrail_log_row_function.sql
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,12 @@ DECLARE
new_obj JSONB;
actor_type TEXT;
actor_id TEXT;
created_at TIMESTAMP;

err_text TEXT; err_detail TEXT; err_hint TEXT; err_ctx TEXT;
BEGIN
SELECT split_part(split_part(current_query(), '/*IronTrail ', 2), ' IronTrail*/', 1) INTO it_meta;

IF (it_meta <> '') THEN
it_meta_obj = it_meta::JSONB;

Expand All @@ -28,17 +30,30 @@ BEGIN
END IF;
END IF;

old_obj = row_to_json(OLD);
new_obj = row_to_json(NEW);

IF (TG_OP = 'INSERT' AND new_obj ? 'created_at') THEN
created_at = NEW.created_at;
ELSIF (TG_OP = 'UPDATE' AND new_obj ? 'updated_at') THEN
created_at = NEW.updated_at;
END IF;

IF (created_at IS NULL) THEN
created_at = NOW();
ELSE
it_meta_obj = jsonb_set(COALESCE(it_meta_obj, '{}'::jsonb), array['_db_created_at'], TO_JSONB(NOW()));
END IF;

IF (TG_OP = 'INSERT') THEN
INSERT INTO "irontrail_changes" ("actor_id", "actor_type",
"rec_table", "operation", "rec_id", "rec_new", "metadata", "created_at")
VALUES (actor_id, actor_type,
TG_TABLE_NAME, 'i', NEW.id, row_to_json(NEW), it_meta_obj, NOW());
TG_TABLE_NAME, 'i', NEW.id, new_obj, it_meta_obj, created_at);

ELSIF (TG_OP = 'UPDATE') THEN
IF (OLD <> NEW) THEN
u_changes = jsonb_build_object();
old_obj = row_to_json(OLD);
new_obj = row_to_json(NEW);

FOR key IN (SELECT jsonb_object_keys(old_obj) UNION SELECT jsonb_object_keys(new_obj))
LOOP
Expand All @@ -51,14 +66,13 @@ BEGIN

INSERT INTO "irontrail_changes" ("actor_id", "actor_type", "rec_table", "operation",
"rec_id", "rec_old", "rec_new", "rec_delta", "metadata", "created_at")
VALUES (actor_id, actor_type, TG_TABLE_NAME, 'u', NEW.id, row_to_json(OLD), row_to_json(NEW),
u_changes, it_meta_obj, NOW());
VALUES (actor_id, actor_type, TG_TABLE_NAME, 'u', NEW.id, old_obj, new_obj, u_changes, it_meta_obj, created_at);

END IF;
ELSIF (TG_OP = 'DELETE') THEN
INSERT INTO "irontrail_changes" ("actor_id", "actor_type", "rec_table", "operation",
"rec_id", "rec_old", "metadata", "created_at")
VALUES (actor_id, actor_type, TG_TABLE_NAME, 'd', OLD.id, row_to_json(OLD), it_meta_obj, NOW());
VALUES (actor_id, actor_type, TG_TABLE_NAME, 'd', OLD.id, old_obj, it_meta_obj, created_at);

END IF;
RETURN NULL;
Expand Down
2 changes: 2 additions & 0 deletions spec/dummy_app/app/models/guitar_part.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# frozen_string_literal: true

class GuitarPart < ApplicationRecord
include IronTrail::Model

belongs_to :guitar
end
2 changes: 2 additions & 0 deletions spec/dummy_app/db/migrate/20241112090542_setup_test_db.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ def up
create_table :guitar_parts, id: :bigserial, force: true do |t|
t.uuid :guitar_id
t.string :name

t.timestamps
end

create_table :matrix_pills, id: :bigserial, force: true do |t|
Expand Down
106 changes: 106 additions & 0 deletions spec/models/guitar_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,112 @@
end
end

describe 'model created_at and updated_at attributes' do
let(:guitar) { Guitar.create!(description: 'the guitar', person:) }
let(:fake_update_time) { '2022-02-03T18:58:01.498334Z' }
let!(:some_part) { guitar.guitar_parts.create!(name: 'strings') }
let(:trails) { some_part.iron_trails.order(id: :asc).to_a }

before do
travel_to(fake_update_time) do
some_part.update!(name: 'Strings of the Guitar')
end

some_part.update!(name: 'Strings')
end

it 'uses the model created_at for the trail created_at on inserts and updates' do
expect(trails).to have_attributes(count: 3)
expect(trails[0].created_at).to be_within(1.second).of(some_part.created_at)
expect(trails[1].created_at).to be_within(1.second).of(Time.parse(fake_update_time))
expect(trails[2].created_at).to be_within(1.second).of(some_part.updated_at)
end

it 'will logically have the oldest trail be the first update' do
oldest_trail = some_part.iron_trails.order(created_at: :asc).first
expect(oldest_trail.id).to eq(trails[1].id)
end

describe 'metadata _db_created_at injection' do
it 'injects original db time into metadata' do
current_time = Time.now

expect(trails[0].metadata).not_to be_nil
expect(trails[1].metadata).not_to be_nil
expect(trails[2].metadata).not_to be_nil

expect(trails[0].metadata).to include('_db_created_at')
expect(Time.parse(trails[0].metadata['_db_created_at'])).to be_within(1.second).of(current_time)
expect(trails[1].metadata).to include('_db_created_at')
expect(Time.parse(trails[1].metadata['_db_created_at'])).to be_within(1.second).of(current_time)
expect(trails[2].metadata).to include('_db_created_at')
expect(Time.parse(trails[2].metadata['_db_created_at'])).to be_within(1.second).of(current_time)
end

context 'when there is previous metadata present' do
let(:fake_update_time_with_metadata) { '2022-01-02T20:00:30.778899Z' }
let(:expected_metadata) { { 'foo_bar' => { 'whatever' => 'does it work?' } } }

before do
travel_to(fake_update_time_with_metadata) do
IronTrail.store_metadata(:foo_bar, { whatever: 'does it work?' })

some_part.update!(name: 'the last straw')
end

some_part.destroy!
end

it 'preserves original metadata' do
last_trail = trails[3]
expect(last_trail.metadata).not_to be_nil
expect(last_trail.metadata).to include('foo_bar', '_db_created_at')
expect(last_trail.metadata).to include(expected_metadata)
end

it 'keeps the original metadata untouched when db original timestamp is not stored' do
expect(trails[4].metadata).to eq(expected_metadata)
end
end

context 'when it is a delete operation' do
it 'does not inject original db time into metadata' do
some_part.destroy!
trail = some_part.iron_trails.find_by!(operation: 'd')
expect(trail.metadata).to be_nil
end
end
end

describe 'record insertion' do
let(:fake_insert_time) { '2021-12-14T12:34:56.010102Z' }

it 'uses the model creation time for the insert operation' do
part = nil

travel_to(fake_insert_time) do
part = guitar.guitar_parts.create!(name: 'neck')
end

trail = part.iron_trails.first
expect(trail.created_at).to be_within(1.second).of(Time.parse(fake_insert_time))
end
end

describe 'record deletion' do
let(:fake_delete_time) { '2023-01-22T23:24:25.262728Z' }

before do
travel_to(fake_delete_time) { some_part.destroy! }
end

it 'uses the current time for the delete operation' do
trail = some_part.iron_trails.find_by!(operation: 'd')
expect(trail.created_at).to be_within(1.second).of(Time.now)
end
end
end

describe 'iron_trails.travel_to' do
let(:guitar) { Guitar.create!(description: 'the guitar', person:) }

Expand Down
1 change: 1 addition & 0 deletions spec/spec_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -43,4 +43,5 @@

RSpec.configure do |config|
config.use_transactional_fixtures = true
config.include ActiveSupport::Testing::TimeHelpers
end

0 comments on commit ed20667

Please sign in to comment.