diff --git a/.release-version b/.release-version index a6c2798a..53cc1a6f 100644 --- a/.release-version +++ b/.release-version @@ -1 +1 @@ -1.23.0 +1.24.0 diff --git a/Gemfile.lock b/Gemfile.lock index 53325a82..524f5315 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -7,25 +7,25 @@ GIT GEM remote: https://rubygems.org/ specs: - actionpack (7.0.8.5) - actionview (= 7.0.8.5) - activesupport (= 7.0.8.5) + actionpack (7.0.8.7) + actionview (= 7.0.8.7) + activesupport (= 7.0.8.7) rack (~> 2.0, >= 2.2.4) rack-test (>= 0.6.3) rails-dom-testing (~> 2.0) rails-html-sanitizer (~> 1.0, >= 1.2.0) - actionview (7.0.8.5) - activesupport (= 7.0.8.5) + actionview (7.0.8.7) + activesupport (= 7.0.8.7) builder (~> 3.1) erubi (~> 1.4) rails-dom-testing (~> 2.0) rails-html-sanitizer (~> 1.1, >= 1.2.0) - activemodel (7.0.8.5) - activesupport (= 7.0.8.5) - activerecord (7.0.8.5) - activemodel (= 7.0.8.5) - activesupport (= 7.0.8.5) - activesupport (7.0.8.5) + activemodel (7.0.8.7) + activesupport (= 7.0.8.7) + activerecord (7.0.8.7) + activemodel (= 7.0.8.7) + activesupport (= 7.0.8.7) + activesupport (7.0.8.7) concurrent-ruby (~> 1.0, >= 1.0.2) i18n (>= 1.6, < 2) minitest (>= 5.1) @@ -44,7 +44,7 @@ GEM crass (1.0.6) diff-lcs (1.5.1) docile (1.4.0) - erubi (1.13.0) + erubi (1.13.1) factory_bot (6.5.0) activesupport (>= 5.0.0) factory_bot_rails (6.4.4) @@ -73,23 +73,23 @@ GEM hashie (5.0.0) i18n (1.14.6) concurrent-ruby (~> 1.0) - json (2.8.2) + json (2.9.1) language_server-protocol (3.17.0.3) listen (3.9.0) rb-fsevent (~> 0.10, >= 0.10.3) rb-inotify (~> 0.9, >= 0.9.10) - loofah (2.23.1) + loofah (2.24.0) crass (~> 1.0.2) nokogiri (>= 1.12.0) lumberjack (1.2.10) method_source (1.1.0) mini_portile2 (2.8.8) - minitest (5.25.2) + minitest (5.25.4) msgpack (1.7.2) multi_json (1.15.0) mysql2 (0.5.6) nenv (0.3.0) - nokogiri (1.16.7) + nokogiri (1.18.1) mini_portile2 (~> 2.8.2) racc (~> 1.4) notiffany (0.1.3) @@ -99,23 +99,23 @@ GEM parser (3.3.6.0) ast (~> 2.4.1) racc - pry (0.15.0) + pry (0.15.2) coderay (~> 1.1) method_source (~> 1.0) racc (1.8.1) rack (2.2.10) - rack-test (2.1.0) + rack-test (2.2.0) rack (>= 1.3) rails-dom-testing (2.2.0) activesupport (>= 5.0.0) minitest nokogiri (>= 1.6) - rails-html-sanitizer (1.6.0) + rails-html-sanitizer (1.6.2) loofah (~> 2.21) - nokogiri (~> 1.14) - railties (7.0.8.5) - actionpack (= 7.0.8.5) - activesupport (= 7.0.8.5) + nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) + railties (7.0.8.7) + actionpack (= 7.0.8.7) + activesupport (= 7.0.8.7) method_source rake (>= 12.2) thor (~> 1.0) @@ -126,7 +126,7 @@ GEM rb-inotify (0.11.1) ffi (~> 1.0) rbtree (0.4.6) - regexp_parser (2.9.2) + regexp_parser (2.10.0) rspec (3.13.0) rspec-core (~> 3.13.0) rspec-expectations (~> 3.13.0) @@ -148,22 +148,22 @@ GEM rspec-mocks (~> 3.13) rspec-support (~> 3.13) rspec-support (3.13.1) - rubocop (1.68.0) + rubocop (1.69.2) json (~> 2.3) language_server-protocol (>= 3.17.0) parallel (~> 1.10) parser (>= 3.3.0.2) rainbow (>= 2.2.2, < 4.0) - regexp_parser (>= 2.4, < 3.0) - rubocop-ast (>= 1.32.2, < 2.0) + regexp_parser (>= 2.9.3, < 3.0) + rubocop-ast (>= 1.36.2, < 2.0) ruby-progressbar (~> 1.7) - unicode-display_width (>= 2.4.0, < 3.0) - rubocop-ast (1.36.1) + unicode-display_width (>= 2.4.0, < 4.0) + rubocop-ast (1.37.0) parser (>= 3.3.1.0) - rubocop-performance (1.23.0) + rubocop-performance (1.23.1) rubocop (>= 1.48.1, < 2.0) rubocop-ast (>= 1.31.1, < 2.0) - rubocop-rails (2.27.0) + rubocop-rails (2.28.0) activesupport (>= 4.2.0) rack (>= 1.1) rubocop (>= 1.52.0, < 2.0) @@ -190,7 +190,9 @@ GEM thor (1.3.2) tzinfo (2.0.6) concurrent-ruby (~> 1.0) - unicode-display_width (2.6.0) + unicode-display_width (3.1.3) + unicode-emoji (~> 4.0, >= 4.0.4) + unicode-emoji (4.0.4) zeitwerk (2.7.1) PLATFORMS diff --git a/app/models/aliquot.rb b/app/models/aliquot.rb index 290cc821..da48649c 100644 --- a/app/models/aliquot.rb +++ b/app/models/aliquot.rb @@ -4,7 +4,7 @@ # This model is used to store aliquot data class Aliquot < ApplicationRecord include ResourceTools - include SingularResourceTools + include SingularResourceVersionedTools def self.base_resource_key 'aliquot_uuid' diff --git a/db/migrate/20241211144506_add_hu_m_fre_code_to_sample.rb b/db/migrate/20241211144506_add_hu_m_fre_code_to_sample.rb new file mode 100644 index 00000000..923e6e8a --- /dev/null +++ b/db/migrate/20241211144506_add_hu_m_fre_code_to_sample.rb @@ -0,0 +1,5 @@ +class AddHuMFreCodeToSample < ActiveRecord::Migration[7.0] + def change + add_column :sample, :huMFre_code, :string, limit: 16 + end +end diff --git a/db/schema.rb b/db/schema.rb index 1aaedb85..a099a6ca 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.0].define(version: 2024_12_03_134455) do +ActiveRecord::Schema[7.0].define(version: 2024_12_11_144506) do create_table "aliquot", charset: "utf8mb3", collation: "utf8mb3_unicode_ci", force: :cascade do |t| t.string "id_lims", null: false, comment: "The LIMS system that the aliquot was created in" t.string "aliquot_uuid", null: false, comment: "The UUID of the aliquot in the LIMS system" @@ -366,6 +366,7 @@ t.string "gc_content" t.string "dna_source" t.string "priority_level", comment: "Priority level eg Medium, High etc" + t.string "huMFre_code", limit: 16 t.index ["accession_number"], name: "sample_accession_number_index" t.index ["id_lims", "id_sample_lims"], name: "index_sample_on_id_lims_and_id_sample_lims", unique: true t.index ["id_lims"], name: "index_sample_on_id_lims" diff --git a/lib/singular_resource_versioned_tools.rb b/lib/singular_resource_versioned_tools.rb new file mode 100644 index 00000000..666f5ee7 --- /dev/null +++ b/lib/singular_resource_versioned_tools.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +# This module is used to store the latest version of a record and manage versioned records. +# It provides methods to check if a record is the latest and if any attributes have changed, +# and to create or update records accordingly. This ensures an audit trail of updates by +# creating new records when necessary. +module SingularResourceVersionedTools + extend ActiveSupport::Concern + + EXCLUDED_ATTRIBUTES = %w[id created_at updated_at last_updated recorded_at].freeze + + # Checks if the attributes of the given record have changed and if the given record is more recent. + # If both conditions are met, returns true. Otherwise, returns false. + # + # @param other [ActiveRecord::Base] The record to compare with. + # @return [Boolean] Returns true if the given record is the latest and has changed, false otherwise. + # + # Note: This method is used to ensure that only the most recent and changed records are processed, + # maintaining an audit trail of updates. + def latest?(other) + attributes_changed?(other) && (other.last_updated > last_updated) + end + + # Compares the attributes of the current record with the given record, excluding 'id', 'created_at', and 'updated_at'. + # + # @param other [ActiveRecord::Base] The record to compare with. + # @return [Boolean] Returns true if the attributes have changed, false otherwise. + def attributes_changed?(other) + attributes.except(*EXCLUDED_ATTRIBUTES) != other.attributes.except(*EXCLUDED_ATTRIBUTES) + end + + # This module is used to create or update a record + module ClassMethods + # Creates a record based on the given attributes. + # If an existing record with the same base resource key is found, it checks if the new record is the latest + # and if any attributes have changed. If both conditions are met, it creates a new record to maintain an audit trail. + # + # @param attributes [Hash] The attributes of the record to create or update which is an instance of Aliquot::JsonHandler. + # @return [ActiveRecord::Base] Returns the created record. + def create_or_update(attributes) + new_record = new(attributes.to_hash) + + existing_record = for_lims(attributes.id_lims).with_id(attributes[base_resource_key]).order(last_updated: :desc) + .first + return unless existing_record.nil? || existing_record.latest?(new_record) + + create!(attributes.to_hash) + end + private :create_or_update + end +end diff --git a/spec/models/aliquot_spec.rb b/spec/models/aliquot_spec.rb index a424392d..f7edc883 100644 --- a/spec/models/aliquot_spec.rb +++ b/spec/models/aliquot_spec.rb @@ -3,26 +3,30 @@ require 'spec_helper' describe Aliquot do - context 'aliquot' do - let(:example_lims) { 'example' } + let(:example_lims) { 'example' } + let(:attribute_to_change) { 'volume' } + let(:new_value) { 10.0 } - let(:json) do - { 'id_lims' => 'example', - 'aliquot_type' => 'DNA', - 'volume' => 5.43, - 'concentration' => 2.34, - 'insert_size' => 100, - 'aliquot_uuid' => '000000-0000-0000-0000-0000000002', - 'source_type' => 'library', - 'source_barcode' => 'PR-rna-00000001_H12', - 'sample_name' => 'aliquot-sample', - 'used_by_type' => 'pool', - 'used_by_barcode' => 'pool-barcode', - 'last_updated' => '2012-03-11 10:20:08', - 'recorded_at' => '2012-03-11 10:20:08', - 'created_at' => '2012-03-11 10:20:08' } - end + let(:json) do + { 'id_lims' => 'example', + 'aliquot_type' => 'DNA', + 'volume' => 5.43, + 'concentration' => 2.34, + 'insert_size' => 100, + 'aliquot_uuid' => '000000-0000-0000-0000-0000000002', + 'source_type' => 'library', + 'source_barcode' => 'PR-rna-00000001_H12', + 'sample_name' => 'aliquot-sample', + 'used_by_type' => 'pool', + 'used_by_barcode' => 'pool-barcode', + 'last_updated' => '2012-03-11 10:20:08', + 'recorded_at' => '2012-03-11 10:20:08', + 'created_at' => '2012-03-11 10:20:08' } + end + it_behaves_like 'a singular resource versioned' + + context 'aliquot' do it 'saves the correct resource' do expect(described_class.create_or_update_from_json(json, example_lims)).to be_truthy end diff --git a/spec/support/it_behaves_like_a_singular_resource_versioned.rb b/spec/support/it_behaves_like_a_singular_resource_versioned.rb new file mode 100644 index 00000000..cb4e3355 --- /dev/null +++ b/spec/support/it_behaves_like_a_singular_resource_versioned.rb @@ -0,0 +1,127 @@ +# frozen_string_literal: true + +shared_examples_for 'a singular resource versioned' do + let(:originally_created_at) { Time.zone.parse('2012-Mar-16 12:06') } + let(:timestamped_json) { json.merge('created_at' => originally_created_at, 'last_updated' => originally_created_at) } + let(:modified_at) { originally_created_at + 1.day } + let(:checked_time_now) { Time.parse('2012-Mar-26 13:20').utc } + let(:checked_time_then) { Time.parse('2012-Mar-25 13:20').utc } + let(:example_lims) { 'example' } + + let(:attributes) { described_class.send(:json).new(timestamped_json) } + + def current_records + ActiveRecord::Base.connection.select_all("SELECT * FROM #{described_class.table_name}") + end + + context '.create_or_update_from_json' do + context 'from different lims' do + let(:second_lims) { 'example_2' } + + before(:each) do + described_class.create_or_update_from_json(timestamped_json.merge('last_updated' => modified_at), example_lims) + described_class.create_or_update_from_json(timestamped_json.merge('last_updated' => modified_at, 'uuid' => 'other'), second_lims) + end + + it 'creates multiple records' do + expect(current_records.count).to eq(2) + end + end + + context 'without existing records' do + let(:recorded_time) { checked_time_now } + + context 'when the record is new' do + let(:most_recent_time) { originally_created_at } + before(:each) do + allow(described_class).to receive(:checked_time_now).and_return(checked_time_now) + described_class.create_or_update_from_json(timestamped_json, example_lims) + end + + it_behaves_like 'has only one row' + end + end + + context 'with an existing record' do + before(:each) do + allow(described_class).to receive(:checked_time_now).and_return(checked_time_then) + described_class.create_or_update_from_json(timestamped_json.merge('last_updated' => modified_at), example_lims) + end + + context 'when the new record is not current' do + before(:each) do + described_class.send(:create_or_update, attributes.merge('last_updated' => modified_at - 2.hours, :id_lims => example_lims)) + end + + it 'only has the current row in the view' do + expect(current_records.count).to eq(1) + expect(current_records.first['last_updated']).to eq(modified_at) + end + end + + context 'when the fields are unchanged' do + let(:most_recent_time) { modified_at } + let(:recorded_time) { checked_time_then } + + before(:each) do + allow(described_class).to receive(:checked_time_now).and_return(checked_time_now) + described_class.create_or_update_from_json(timestamped_json.merge('last_updated' => modified_at), example_lims) + end + + it_behaves_like 'has only one row' + end + + context 'when the fields change in latest update' do + let(:most_recent_time) { modified_at } + let(:recorded_time) { checked_time_then } + + before(:each) do + allow(described_class).to receive(:checked_time_now).and_return(checked_time_now) + described_class.create_or_update_from_json(timestamped_json.merge('last_updated' => modified_at + 1.hour, attribute_to_change => new_value), example_lims) + end + + it 'creates a new record with the updated fields' do + expect(current_records.count).to eq(2) + expect(current_records.last[attribute_to_change]).to eq(new_value) + expect(current_records.last['last_updated']).to eq(modified_at + 1.hour) + end + end + + context 'when the fields are not changed in latest update' do + let(:most_recent_time) { modified_at } + let(:recorded_time) { checked_time_then } + + before(:each) do + allow(described_class).to receive(:checked_time_now).and_return(checked_time_now) + described_class.create_or_update_from_json(timestamped_json.merge('last_updated' => modified_at + 1.hour), example_lims) + end + + it 'does not create a new record' do + expect(current_records.count).to eq(1) + expect(current_records.last['last_updated']).to eq(modified_at) + end + end + + context 'when ignored fields change' do + SingularResourceVersionedTools::EXCLUDED_ATTRIBUTES.each do |attribute| + next if attribute.to_s == 'dont_use_id' # Protected by mass-assignment! + + let(:most_recent_time) { modified_at } + let(:recorded_time) { checked_time_then } + + context "when #{attribute.to_sym.inspect} changes" do + before(:each) do + # We have to account for attribute translation, so process through the JSON handler + # and then update the attribute. + allow(described_class).to receive(:checked_time_now).and_return(checked_time_now) + attributes[attribute] = 'changed' + described_class.send(:create_or_update, attributes.merge('last_updated' => modified_at, :id_lims => example_lims)) + end + + it_behaves_like 'has only one row' + end + end + end + end + end +end