From f9bccd98d821c726eae0d28b4a50e15f8f140107 Mon Sep 17 00:00:00 2001 From: Luke Hill Date: Fri, 24 Apr 2026 10:50:00 +0100 Subject: [PATCH 01/11] Initial spike for deduplication --- lib/testing_record/model.rb | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/lib/testing_record/model.rb b/lib/testing_record/model.rb index 22f3122..923358d 100644 --- a/lib/testing_record/model.rb +++ b/lib/testing_record/model.rb @@ -23,6 +23,7 @@ class << self # # @return [TestingRecord::Model] def create(attributes) + ensure_deduplication(attributes) new(attributes.transform_keys(&:to_sym)).tap do |entity| configure_data(entity, attributes) add_helpers(attributes) if entity.class.instance_variable_get(:@include_helpers) @@ -72,6 +73,13 @@ def configure_data(entity, attributes) entity.class.attr_reader attribute_key end end + + def ensure_deduplication(attributes) + return unless exists?(attributes[__primary_key]) + + TestingRecord.logger.debug("#{self} entity already exists with attributes: #{attributes}") + raise 'This exists' + end end def initialize(attributes = {}) From be5f61bebae6fe2e40258ec6f59991373e1a3d6c Mon Sep 17 00:00:00 2001 From: Luke Hill Date: Fri, 24 Apr 2026 11:07:18 +0100 Subject: [PATCH 02/11] Add test and implementation of filter `with_primary_key` --- lib/testing_record/dsl/builder/filters.rb | 8 +++++++ lib/testing_record/model.rb | 9 +------- .../dsl/builder/filters_spec.rb | 22 +++++++++++++++++++ 3 files changed, 31 insertions(+), 8 deletions(-) diff --git a/lib/testing_record/dsl/builder/filters.rb b/lib/testing_record/dsl/builder/filters.rb index e29272c..cfc757e 100644 --- a/lib/testing_record/dsl/builder/filters.rb +++ b/lib/testing_record/dsl/builder/filters.rb @@ -36,6 +36,14 @@ def with_id(id) find_by({ id: })&.first&.tap { |entity| entity.class.current = entity } end + # Finds an entity with the provided primary_key + # If one is found, set it as the current entity + # + # @return [TestingRecord::Model, nil] + def with_primary_key(primary_key) + find_by({ __primary_key => primary_key })&.first&.tap { |entity| entity.class.current = entity } + end + private # Finds all entities that match specified attribute values diff --git a/lib/testing_record/model.rb b/lib/testing_record/model.rb index 923358d..d4f0b00 100644 --- a/lib/testing_record/model.rb +++ b/lib/testing_record/model.rb @@ -23,7 +23,7 @@ class << self # # @return [TestingRecord::Model] def create(attributes) - ensure_deduplication(attributes) + # ensure_deduplication(attributes) new(attributes.transform_keys(&:to_sym)).tap do |entity| configure_data(entity, attributes) add_helpers(attributes) if entity.class.instance_variable_get(:@include_helpers) @@ -73,13 +73,6 @@ def configure_data(entity, attributes) entity.class.attr_reader attribute_key end end - - def ensure_deduplication(attributes) - return unless exists?(attributes[__primary_key]) - - TestingRecord.logger.debug("#{self} entity already exists with attributes: #{attributes}") - raise 'This exists' - end end def initialize(attributes = {}) diff --git a/spec/testing_record/dsl/builder/filters_spec.rb b/spec/testing_record/dsl/builder/filters_spec.rb index 2abf7ca..b87e4d3 100644 --- a/spec/testing_record/dsl/builder/filters_spec.rb +++ b/spec/testing_record/dsl/builder/filters_spec.rb @@ -84,4 +84,26 @@ end end end + + describe '.with_primary_key' do + before { TestingRecord.default_primary_key = :peekay } + after { TestingRecord.default_primary_key = :id } + + context 'when entity does not exist' do + it 'does not find a model' do + expect(model_klazz.with_primary_key(1)).to be_nil + end + end + + context 'when entity exists' do + before do + model_klazz.create({ peekay: 1 }) + model_klazz.create({ peekay: 2 }) + end + + it 'finds the first matching model' do + expect(model_klazz.with_primary_key(1)).to be_a TestingRecord::Model + end + end + end end From 8a52167efec3d035b028c3dd5279ab18f6ebea67 Mon Sep 17 00:00:00 2001 From: Luke Hill Date: Fri, 24 Apr 2026 11:10:48 +0100 Subject: [PATCH 03/11] Add in all remaining logic to ensure checks for primary key are made and done in create class method --- lib/testing_record/dsl/builder/filters.rb | 7 ++++++ lib/testing_record/model.rb | 10 ++++++++- .../dsl/builder/filters_spec.rb | 22 +++++++++++++++++++ 3 files changed, 38 insertions(+), 1 deletion(-) diff --git a/lib/testing_record/dsl/builder/filters.rb b/lib/testing_record/dsl/builder/filters.rb index cfc757e..9f57cfc 100644 --- a/lib/testing_record/dsl/builder/filters.rb +++ b/lib/testing_record/dsl/builder/filters.rb @@ -36,6 +36,13 @@ def with_id(id) find_by({ id: })&.first&.tap { |entity| entity.class.current = entity } end + # Checks to see whether an entity exists with the provided primary_key + # + # @return [Boolean] + def with_primary_key?(primary_key) + !with_primary_key(primary_key).nil? + end + # Finds an entity with the provided primary_key # If one is found, set it as the current entity # diff --git a/lib/testing_record/model.rb b/lib/testing_record/model.rb index d4f0b00..28be0eb 100644 --- a/lib/testing_record/model.rb +++ b/lib/testing_record/model.rb @@ -23,7 +23,7 @@ class << self # # @return [TestingRecord::Model] def create(attributes) - # ensure_deduplication(attributes) + ensure_deduplication(attributes) new(attributes.transform_keys(&:to_sym)).tap do |entity| configure_data(entity, attributes) add_helpers(attributes) if entity.class.instance_variable_get(:@include_helpers) @@ -73,6 +73,14 @@ def configure_data(entity, attributes) entity.class.attr_reader attribute_key end end + + def ensure_deduplication(attributes) + pk_value = attributes[__primary_key] + return unless with_primary_key?(pk_value) + + TestingRecord.logger.debug("#{self} entity already exists with primary key: #{pk_value}") + raise "#{self} entity already exists with primary key: #{pk_value}" + end end def initialize(attributes = {}) diff --git a/spec/testing_record/dsl/builder/filters_spec.rb b/spec/testing_record/dsl/builder/filters_spec.rb index b87e4d3..ea63a70 100644 --- a/spec/testing_record/dsl/builder/filters_spec.rb +++ b/spec/testing_record/dsl/builder/filters_spec.rb @@ -106,4 +106,26 @@ end end end + + describe '.with_primary_key?' do + before { TestingRecord.default_primary_key = :peekay } + after { TestingRecord.default_primary_key = :id } + + context 'when entity does not exist' do + it 'is `false`' do + expect(model_klazz.with_primary_key?(1)).to be false + end + end + + context 'when entity exists' do + before do + model_klazz.create({ peekay: 1 }) + model_klazz.create({ peekay: 2 }) + end + + it 'is `true`' do + expect(model_klazz.with_primary_key?(1)).to be true + end + end + end end From cb5d2cb25e8fffa02de2c671cd311876ce7a2574 Mon Sep 17 00:00:00 2001 From: Luke Hill Date: Fri, 24 Apr 2026 15:14:20 +0100 Subject: [PATCH 04/11] Add stipulation pk must always be provided --- lib/testing_record/error.rb | 1 + lib/testing_record/model.rb | 11 +++++++++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/lib/testing_record/error.rb b/lib/testing_record/error.rb index f9ab861..44eebd1 100644 --- a/lib/testing_record/error.rb +++ b/lib/testing_record/error.rb @@ -2,6 +2,7 @@ module TestingRecord module Error + class AttributeError < StandardError; end class InvalidConfigurationError < StandardError; end end end diff --git a/lib/testing_record/model.rb b/lib/testing_record/model.rb index 28be0eb..2c0ee71 100644 --- a/lib/testing_record/model.rb +++ b/lib/testing_record/model.rb @@ -23,6 +23,7 @@ class << self # # @return [TestingRecord::Model] def create(attributes) + ensure_primary_key_presence(attributes) ensure_deduplication(attributes) new(attributes.transform_keys(&:to_sym)).tap do |entity| configure_data(entity, attributes) @@ -78,8 +79,14 @@ def ensure_deduplication(attributes) pk_value = attributes[__primary_key] return unless with_primary_key?(pk_value) - TestingRecord.logger.debug("#{self} entity already exists with primary key: #{pk_value}") - raise "#{self} entity already exists with primary key: #{pk_value}" + TestingRecord.logger.error("#{name} entity already exists with primary key: #{pk_value}") + raise Error::AttributeError, "#{name} entity already exists with primary key: #{pk_value}" + end + + def ensure_primary_key_presence(attributes) + return if attributes.key?(__primary_key) + + raise Error::AttributeError, "#{name} entity has not been supplied with the primary key: #{__primary_key}" end end From 6062c16fb96f9f2728ce40bc42d3ec621d6d1bb4 Mon Sep 17 00:00:00 2001 From: Luke Hill Date: Fri, 24 Apr 2026 15:19:34 +0100 Subject: [PATCH 05/11] Add missing primary keys into specs --- spec/testing_record/dsl/builder/filters_spec.rb | 15 +++++++++++---- spec/testing_record/dsl/builder/helpers_spec.rb | 1 + 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/spec/testing_record/dsl/builder/filters_spec.rb b/spec/testing_record/dsl/builder/filters_spec.rb index ea63a70..2ae97e8 100644 --- a/spec/testing_record/dsl/builder/filters_spec.rb +++ b/spec/testing_record/dsl/builder/filters_spec.rb @@ -4,6 +4,7 @@ subject(:model_klazz) do Class.new(TestingRecord::Model) do caching :enabled + primary_key :email_address end end @@ -48,6 +49,9 @@ end describe '.with_id' do + before { model_klazz.primary_key :id } + after { model_klazz.primary_key :email_address } + context 'when entity does not exist' do it 'does not find a model' do expect(model_klazz.with_id(1)).to be_nil @@ -67,6 +71,9 @@ end describe '.with_id?' do + before { model_klazz.primary_key :id } + after { model_klazz.primary_key :email_address } + context 'when entity does not exist' do it 'is `false`' do expect(model_klazz.with_id?(1)).to be false @@ -86,8 +93,8 @@ end describe '.with_primary_key' do - before { TestingRecord.default_primary_key = :peekay } - after { TestingRecord.default_primary_key = :id } + before { model_klazz.primary_key :peekay } + after { model_klazz.primary_key :email_address } context 'when entity does not exist' do it 'does not find a model' do @@ -108,8 +115,8 @@ end describe '.with_primary_key?' do - before { TestingRecord.default_primary_key = :peekay } - after { TestingRecord.default_primary_key = :id } + before { model_klazz.primary_key :peekay } + after { model_klazz.primary_key :email_address } context 'when entity does not exist' do it 'is `false`' do diff --git a/spec/testing_record/dsl/builder/helpers_spec.rb b/spec/testing_record/dsl/builder/helpers_spec.rb index 58a9a06..035e353 100644 --- a/spec/testing_record/dsl/builder/helpers_spec.rb +++ b/spec/testing_record/dsl/builder/helpers_spec.rb @@ -4,6 +4,7 @@ subject(:klazz) do Class.new(TestingRecord::Model) do include_helpers + primary_key :foo end end From 5f47316e82f7e08de234451cbfede0236d2442a1 Mon Sep 17 00:00:00 2001 From: Luke Hill Date: Fri, 24 Apr 2026 15:26:14 +0100 Subject: [PATCH 06/11] Only deduplicate entities when caching is set --- lib/testing_record/model.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/testing_record/model.rb b/lib/testing_record/model.rb index 2c0ee71..c616a7c 100644 --- a/lib/testing_record/model.rb +++ b/lib/testing_record/model.rb @@ -76,6 +76,8 @@ def configure_data(entity, attributes) end def ensure_deduplication(attributes) + return unless respond_to?(:all) + pk_value = attributes[__primary_key] return unless with_primary_key?(pk_value) From ea4275c5e3f1b83b3b0b38df591fc5cd4a22d9d0 Mon Sep 17 00:00:00 2001 From: Luke Hill Date: Fri, 24 Apr 2026 15:29:48 +0100 Subject: [PATCH 07/11] Add changelog for the new deduplication logic --- CHANGELOG.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b16ced..91d2b3c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,16 @@ ## [Unreleased] ### Breaking Changes - Set a default primary key of `:id` to all models +- All models require a primary key attribute to be present on each created entity +- All entities are now deduplicated on their primary key value - but only when `caching` is enabled on the model ### Removed ### Added - Add ability to change the default primary key for all models by using `TestingRecord.default_primary_key = :my_id` +- Added ability to filter model on `primary_key` attribute + - `.with_primary_key?` -> An entity exists with the `primary_key` value specified + - `.with_primary_key` -> Returns the entity with the `primary_key` value specified ### Changed - Deletion calls will now also purge `.current` if the deleted entity is the current entity when caching is enabled @@ -40,7 +45,7 @@ add all helpers to all attributes on a model ## [0.6] - 2026-02-09 ### Added - Added ability to filter model on `id` attribute - - `.with_id?` -> An entity exists that with the id specified + - `.with_id?` -> An entity exists with the id specified - `.with_id` -> Returns the entity with the id specified - Added ability to delete models where caching is enabled - When updating models, the cache is also updated to reflect the new values From c00cac3cbc91d7ef8f7bbe8bef53d7c30fcd2ac0 Mon Sep 17 00:00:00 2001 From: Luke Hill Date: Mon, 27 Apr 2026 11:44:49 +0100 Subject: [PATCH 08/11] Improve docs and split create out now its getting complex --- lib/testing_record/model.rb | 36 +++++++++++++++++++++++++++--------- 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/lib/testing_record/model.rb b/lib/testing_record/model.rb index c616a7c..8c71270 100644 --- a/lib/testing_record/model.rb +++ b/lib/testing_record/model.rb @@ -17,18 +17,18 @@ class << self attr_reader :current # Creates an instance of the model - # -> Creating iVar values for each attribute that was provided - # -> Adding it to the cache if caching is enabled - # -> Keeping a track of all originally supplied attributes in symbolized format in the `attributes` iVar + # -> Ensures that the primary key is specified in the attribute payload + # -> Validates that a duplicate entity has not been made (If caching is enabled) + # -> Creates iVar values (Symbol format) and attr_reader's for each attribute that was provided + # -> Adds helper methods (If the model has been configured to include helpers) + # -> Adds it to the cache (If caching is enabled) # # @return [TestingRecord::Model] def create(attributes) - ensure_primary_key_presence(attributes) - ensure_deduplication(attributes) - new(attributes.transform_keys(&:to_sym)).tap do |entity| - configure_data(entity, attributes) - add_helpers(attributes) if entity.class.instance_variable_get(:@include_helpers) - cache_entity(entity) + if respond_to?(:all) + create_with_caching(attributes) + else + create_without_caching(attributes) end end @@ -60,6 +60,24 @@ def delete_by_id(id) private + def create_with_caching(attributes) + ensure_primary_key_presence(attributes) + ensure_deduplication(attributes) + new(attributes.transform_keys(&:to_sym)).tap do |entity| + configure_data(entity, attributes) + add_helpers(attributes) if entity.class.instance_variable_get(:@include_helpers) + cache_entity(entity) + end + end + + def create_without_caching(attributes) + ensure_primary_key_presence(attributes) + new(attributes.transform_keys(&:to_sym)).tap do |entity| + configure_data(entity, attributes) + add_helpers(attributes) if entity.class.instance_variable_get(:@include_helpers) + end + end + def cache_entity(entity) return unless respond_to?(:all) From c9834ba8bb4ec00b71607697d1822b04b75f839d Mon Sep 17 00:00:00 2001 From: Luke Hill Date: Mon, 27 Apr 2026 11:46:25 +0100 Subject: [PATCH 09/11] Add docs for .current= --- lib/testing_record/model.rb | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/lib/testing_record/model.rb b/lib/testing_record/model.rb index 8c71270..3983fda 100644 --- a/lib/testing_record/model.rb +++ b/lib/testing_record/model.rb @@ -32,6 +32,9 @@ def create(attributes) end end + # Sets the current entity instance to the one supplied (Or removes it if supplied `nil`) + # + # @return [TestingRecord::Model, nil] def current=(entity) if entity TestingRecord.logger.info("Switching current user from #{@current} to #{entity}") @@ -79,8 +82,6 @@ def create_without_caching(attributes) end def cache_entity(entity) - return unless respond_to?(:all) - self.current = entity all << entity TestingRecord.logger.debug("Entity: #{entity} added to cache") @@ -94,8 +95,6 @@ def configure_data(entity, attributes) end def ensure_deduplication(attributes) - return unless respond_to?(:all) - pk_value = attributes[__primary_key] return unless with_primary_key?(pk_value) From 39b0e8c2fe69c3372a8204ed54f83bba87cfd677 Mon Sep 17 00:00:00 2001 From: Luke Hill Date: Mon, 27 Apr 2026 13:51:07 +0100 Subject: [PATCH 10/11] Change error response for duplicate entity --- lib/testing_record/error.rb | 1 + lib/testing_record/model.rb | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/testing_record/error.rb b/lib/testing_record/error.rb index 44eebd1..349cdf8 100644 --- a/lib/testing_record/error.rb +++ b/lib/testing_record/error.rb @@ -3,6 +3,7 @@ module TestingRecord module Error class AttributeError < StandardError; end + class EntityError < StandardError; end class InvalidConfigurationError < StandardError; end end end diff --git a/lib/testing_record/model.rb b/lib/testing_record/model.rb index 3983fda..9cff562 100644 --- a/lib/testing_record/model.rb +++ b/lib/testing_record/model.rb @@ -99,7 +99,7 @@ def ensure_deduplication(attributes) return unless with_primary_key?(pk_value) TestingRecord.logger.error("#{name} entity already exists with primary key: #{pk_value}") - raise Error::AttributeError, "#{name} entity already exists with primary key: #{pk_value}" + raise Error::EntityError, "#{name} entity already exists with primary key: #{pk_value}" end def ensure_primary_key_presence(attributes) From d102a9e3039bf8920d5cadef61bdb7cb0e64330c Mon Sep 17 00:00:00 2001 From: Luke Hill Date: Mon, 27 Apr 2026 13:51:17 +0100 Subject: [PATCH 11/11] Add tests for all branches of .create --- spec/testing_record/model_spec.rb | 53 +++++++++++++++++++++++++++++-- 1 file changed, 51 insertions(+), 2 deletions(-) diff --git a/spec/testing_record/model_spec.rb b/spec/testing_record/model_spec.rb index 7bad61c..90737be 100644 --- a/spec/testing_record/model_spec.rb +++ b/spec/testing_record/model_spec.rb @@ -1,8 +1,9 @@ # frozen_string_literal: true RSpec.describe TestingRecord::Model do - let(:primary_model_entity) { FakeModel.create({ id: 1, foo: :foo, bar: :bar }) } - let(:secondary_model_entity) { FakeOtherModel.create({ id: 1, foo: :foo, bar: :bar }) } + let(:primary_model_entity) { FakeModel.create(model_attributes) } + let(:model_attributes) { { id: 1, foo: :foo, bar: :bar } } + let(:secondary_model_entity) { FakeOtherModel.create(model_attributes) } before do silence_logger! @@ -14,10 +15,38 @@ context 'with caching enabled' do before { FakeModel.caching :enabled } + it 'ensures that the primary key attribute was set in the attributes hash' do + expect(FakeModel).to receive(:ensure_primary_key_presence).with(model_attributes) + + primary_model_entity + end + + it 'does not permit an entity to be created without a primary key' do + expect { FakeModel.create({ foo: :no_primary_key }) } + .to raise_error(TestingRecord::Error::AttributeError) + .with_message('FakeModel entity has not been supplied with the primary key: id') + end + + it 'does not permit an entity to be created with the same primary key' do + primary_model_entity + + expect { FakeModel.create({ id: 1, other_key: :whatever }) } + .to raise_error(TestingRecord::Error::EntityError) + .with_message('FakeModel entity already exists with primary key: 1') + end + it 'generates a new instance of the entity' do expect(primary_model_entity).to be_a FakeModel end + it 'creates a model with all the supplied attributes as iVars' do + expect(primary_model_entity.instance_variables).to contain_exactly(:@attributes, :@id, :@foo, :@bar) + end + + it 'creates a model with all the supplied attributes as attr_readers' do + expect(primary_model_entity).to respond_to(:attributes, :id, :foo, :bar) + end + it 'adds the entity to the cache' do expect { primary_model_entity }.to change(FakeModel.all, :length).by(1) end @@ -32,9 +61,29 @@ context 'without caching enabled' do before { FakeModel.caching :disabled } + it 'ensures that the primary key attribute was set in the attributes hash' do + expect(FakeModel).to receive(:ensure_primary_key_presence).with(model_attributes) + + primary_model_entity + end + + it 'does not permit an entity to be created without a primary key' do + expect { FakeModel.create({ foo: :no_primary_key }) } + .to raise_error(TestingRecord::Error::AttributeError) + .with_message('FakeModel entity has not been supplied with the primary key: id') + end + it 'generates a new instance of the entity' do expect(primary_model_entity).to be_a FakeModel end + + it 'creates a model with all the supplied attributes as iVars' do + expect(primary_model_entity.instance_variables).to contain_exactly(:@attributes, :@id, :@foo, :@bar) + end + + it 'creates a model with all the supplied attributes as attr_readers' do + expect(primary_model_entity).to respond_to(:attributes, :id, :foo, :bar) + end end end