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 diff --git a/lib/testing_record/dsl/builder/filters.rb b/lib/testing_record/dsl/builder/filters.rb index e29272c..9f57cfc 100644 --- a/lib/testing_record/dsl/builder/filters.rb +++ b/lib/testing_record/dsl/builder/filters.rb @@ -36,6 +36,21 @@ 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 + # + # @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/error.rb b/lib/testing_record/error.rb index f9ab861..349cdf8 100644 --- a/lib/testing_record/error.rb +++ b/lib/testing_record/error.rb @@ -2,6 +2,8 @@ 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 22f3122..9cff562 100644 --- a/lib/testing_record/model.rb +++ b/lib/testing_record/model.rb @@ -17,19 +17,24 @@ 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) - 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 + # 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}") @@ -58,9 +63,25 @@ def delete_by_id(id) private - def cache_entity(entity) - return unless respond_to?(:all) + 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) self.current = entity all << entity TestingRecord.logger.debug("Entity: #{entity} added to cache") @@ -72,6 +93,20 @@ 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.error("#{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) + return if attributes.key?(__primary_key) + + raise Error::AttributeError, "#{name} entity has not been supplied with the primary key: #{__primary_key}" + 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..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 @@ -84,4 +91,48 @@ end end end + + describe '.with_primary_key' do + 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 + 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 + + describe '.with_primary_key?' do + before { model_klazz.primary_key :peekay } + after { model_klazz.primary_key :email_address } + + 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 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 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