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

Use model timestamps when available #18

Merged
merged 6 commits into from
Jan 29, 2025
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
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