From 84d3ed2e8cc84a7399d7e9daaf63cb484bc1234f Mon Sep 17 00:00:00 2001 From: Rob Smith Date: Mon, 27 Nov 2023 16:56:12 -0500 Subject: [PATCH] Refactor legacy contracts. --- .rubocop.yml | 14 +- DEVELOPMENT.md | 4 +- .../collections/rspec/collection_contract.rb | 423 ------- lib/cuprum/collections/rspec/contracts.rb | 8 + .../rspec/contracts/collection_contracts.rb | 429 +++++++ .../rspec/contracts/query_contracts.rb | 1082 +++++++++++++++++ .../rspec/contracts/repository_contracts.rb | 605 +++++++++ .../rspec/find_matching_command_contract.rb | 15 +- .../rspec/query_builder_contract.rb | 92 -- .../collections/rspec/query_contract.rb | 650 ---------- .../collections/rspec/querying_contract.rb | 298 ----- .../collections/rspec/repository_contract.rb | 598 --------- .../collections/basic/collection_spec.rb | 6 +- .../collections/basic/query_builder_spec.rb | 6 +- spec/cuprum/collections/basic/query_spec.rb | 6 +- .../collections/basic/repository_spec.rb | 6 +- spec/cuprum/collections/collection_spec.rb | 8 +- spec/cuprum/collections/query_builder_spec.rb | 6 +- spec/cuprum/collections/repository_spec.rb | 7 +- 19 files changed, 2166 insertions(+), 2097 deletions(-) delete mode 100644 lib/cuprum/collections/rspec/collection_contract.rb create mode 100644 lib/cuprum/collections/rspec/contracts/collection_contracts.rb create mode 100644 lib/cuprum/collections/rspec/contracts/query_contracts.rb create mode 100644 lib/cuprum/collections/rspec/contracts/repository_contracts.rb delete mode 100644 lib/cuprum/collections/rspec/query_builder_contract.rb delete mode 100644 lib/cuprum/collections/rspec/query_contract.rb delete mode 100644 lib/cuprum/collections/rspec/querying_contract.rb delete mode 100644 lib/cuprum/collections/rspec/repository_contract.rb diff --git a/.rubocop.yml b/.rubocop.yml index 10b9205..e46184a 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,6 +1,13 @@ require: - rubocop-rspec +AllCops: + TargetRubyVersion: 2.7 + NewCops: enable + Exclude: + - tmp/**/* + - vendor/**/* + RSpec: Language: ExampleGroups: @@ -28,13 +35,6 @@ RSpec: - xinclude_examples - xwrap_examples -AllCops: - TargetRubyVersion: 2.7 - NewCops: enable - Exclude: - - tmp/**/* - - vendor/**/* - Layout/ArgumentAlignment: EnforcedStyle: with_fixed_indentation diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index da41cb4..2133858 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -8,8 +8,8 @@ Steps to add an operator: - Update Queries::Operators. - Update Queries::ParseBlock::Builder. -- Define context(s) in RSpec::QUERYING_CONTEXTS. -- Add test cases in RSpec::QUERYING_CONTRACT. +- Define context(s) in QueryContracts::WithQueryContexts. +- Add test cases in QueryContracts::ShouldPerformQueries. - Add implementations to Basic::QueryBuilder. ### ParseCriteria diff --git a/lib/cuprum/collections/rspec/collection_contract.rb b/lib/cuprum/collections/rspec/collection_contract.rb deleted file mode 100644 index a158055..0000000 --- a/lib/cuprum/collections/rspec/collection_contract.rb +++ /dev/null @@ -1,423 +0,0 @@ -# frozen_string_literal: true - -require 'rspec/sleeping_king_studios/contract' - -require 'cuprum/collections/rspec' -require 'cuprum/collections/rspec/contracts/relation_contracts' - -module Cuprum::Collections::RSpec - # Contract validating the behavior of a Collection. - module CollectionContract - extend RSpec::SleepingKingStudios::Contract - - # @!method apply(example_group) - # Adds the contract to the example group. - # - # @param example_group [RSpec::Core::ExampleGroup] The example group to - # which the contract is applied. - # @param options [Hash] additional options for the contract. - # - # @option options abstract [Boolean] if true, the collection is an - # abstract base class and does not define a query or commands. - # @option options default_entity_class [Class] the default entity class - # for the collection, if any. - - contract do |**options| - include Cuprum::Collections::RSpec::Contracts::RelationContracts - - shared_examples 'should define the command' \ - do |command_name, command_class_name = nil| - next if options[:abstract] - - tools = SleepingKingStudios::Tools::Toolbelt.instance - class_name = tools.str.camelize(command_name) - command_options = %i[ - collection_name - member_name - primary_key_name - primary_key_type - ] + options.fetch(:command_options, []).map(&:intern) - - describe "::#{class_name}" do - let(:constructor_options) { defined?(super()) ? super() : {} } - let(:command_class) do - command_class_name || - "#{options[:commands_namespace]}::#{class_name}" - .then { |str| Object.const_get(str) } - end - let(:command) do - collection.const_get(class_name).new(**constructor_options) - end - let(:expected_options) do - Hash - .new { |_, key| collection.send(key) } - .merge( - collection_name: collection.name, - member_name: collection.singular_name - ) - end - - it { expect(collection).to define_constant(class_name) } - - it { expect(collection.const_get(class_name)).to be_a Class } - - it { expect(collection.const_get(class_name)).to be < command_class } - - it { expect(command.options).to be >= {} } - - command_options.each do |option_name| - it "should set the ##{option_name}" do - expect(command.send(option_name)) - .to be == expected_options[option_name] - end - end - - describe 'with options' do - let(:constructor_options) do - super().merge( - custom_option: 'value', - singular_name: 'tome' - ) - end - - it { expect(command.options).to be >= { custom_option: 'value' } } - - command_options.each do |option_name| - it "should set the ##{option_name}" do - expect(command.send(option_name)).to( - be == expected_options[option_name] - ) - end - end - end - end - - describe "##{command_name}" do - let(:constructor_options) { defined?(super()) ? super() : {} } - let(:command) do - collection.send(command_name, **constructor_options) - end - let(:expected_options) do - Hash - .new { |_, key| collection.send(key) } - .merge( - collection_name: collection.name, - member_name: collection.singular_name - ) - end - - it 'should define the command' do - expect(collection) - .to respond_to(command_name) - .with(0).arguments - .and_any_keywords - end - - it { expect(command).to be_a collection.const_get(class_name) } - - command_options.each do |option_name| - it "should set the ##{option_name}" do - expect(command.send(option_name)) - .to be == expected_options[option_name] - end - end - - describe 'with options' do - let(:constructor_options) do - super().merge( - custom_option: 'value', - singular_name: 'tome' - ) - end - - it { expect(command.options).to be >= { custom_option: 'value' } } - - command_options.each do |option_name| - it "should set the ##{option_name}" do - expect(command.send(option_name)).to( - be == expected_options[option_name] - ) - end - end - end - end - end - - include_contract 'should be a relation', - constructor: false, - default_entity_class: options[:default_entity_class] - - include_contract 'should disambiguate parameter', - :name, - as: :collection_name - - include_contract 'should disambiguate parameter', - :singular_name, - as: :member_name - - include_contract 'should define primary keys' - - include_examples 'should define the command', :assign_one - - include_examples 'should define the command', :build_one - - include_examples 'should define the command', :destroy_one - - include_examples 'should define the command', :find_many - - include_examples 'should define the command', :find_matching - - include_examples 'should define the command', :find_one - - include_examples 'should define the command', :insert_one - - include_examples 'should define the command', :update_one - - include_examples 'should define the command', :validate_one - - describe '#==' do - let(:other_options) { { name: name } } - let(:other_collection) { described_class.new(**other_options) } - - describe 'with nil' do - it { expect(collection == nil).to be false } # rubocop:disable Style/NilComparison - end - - describe 'with an object' do - it { expect(collection == Object.new.freeze).to be false } - end - - describe 'with a collection with non-matching properties' do - let(:other_options) { super().merge(custom_option: 'value') } - - it { expect(collection == other_collection).to be false } - end - - describe 'with a collection with matching properties' do - it { expect(collection == other_collection).to be true } - end - - describe 'with another type of collection' do - let(:other_collection) do - Spec::OtherCollection.new(**other_options) - end - - example_class 'Spec::OtherCollection', Cuprum::Collections::Collection - - it { expect(collection == other_collection).to be false } - end - - context 'when initialized with options' do - let(:constructor_options) do - super().merge( - qualified_name: 'spec/scoped_books', - singular_name: 'grimoire' - ) - end - - describe 'with a collection with non-matching properties' do - it { expect(collection == other_collection).to be false } - end - - describe 'with a collection with matching properties' do - let(:other_options) do - super().merge( - qualified_name: 'spec/scoped_books', - singular_name: 'grimoire' - ) - end - - it { expect(collection == other_collection).to be true } - end - end - end - - describe '#count' do - it { expect(collection).to respond_to(:count).with(0).arguments } - - it { expect(collection).to have_aliased_method(:count).as(:size) } - - next if options[:abstract] - - it { expect(collection.count).to be 0 } - - wrap_context 'when the collection has many items' do - it { expect(collection.count).to be items.count } - end - end - - describe '#matches?' do - def tools - SleepingKingStudios::Tools::Toolbelt.instance - end - - it 'should define the method' do - expect(collection) - .to respond_to(:matches?) - .with(0).arguments - .and_any_keywords - end - - describe 'with no options' do - it { expect(collection.matches?).to be true } - end - - describe 'with non-matching entity class as a Class' do - let(:other_options) { { entity_class: Grimoire } } - - it { expect(collection.matches?(**other_options)).to be false } - end - - describe 'with non-matching entity class as a String' do - let(:other_options) { { entity_class: 'Grimoire' } } - - it { expect(collection.matches?(**other_options)).to be false } - end - - describe 'with non-matching name' do - it { expect(collection.matches?(name: 'grimoires')).to be false } - end - - describe 'with non-matching primary key name' do - let(:other_options) { { primary_key_name: 'uuid' } } - - it { expect(collection.matches?(**other_options)).to be false } - end - - describe 'with non-matching primary key type' do - let(:other_options) { { primary_key_type: String } } - - it { expect(collection.matches?(**other_options)).to be false } - end - - describe 'with non-matching qualified name' do - let(:other_options) { { qualified_name: 'spec/scoped_books' } } - - it { expect(collection.matches?(**other_options)).to be false } - end - - describe 'with non-matching singular name' do - let(:other_options) { { singular_name: 'grimoire' } } - - it { expect(collection.matches?(**other_options)).to be false } - end - - describe 'with non-matching custom options' do - let(:other_options) { { custom_option: 'custom value' } } - - it { expect(collection.matches?(**other_options)).to be false } - end - - describe 'with partially-matching options' do - let(:other_options) do - { - name: name, - singular_name: 'grimoire' - } - end - - it { expect(collection.matches?(**other_options)).to be false } - end - - describe 'with matching entity class as a Class' do - let(:configured_entity_class) do - options.fetch(:default_entity_class, Book) - end - let(:other_options) { { entity_class: configured_entity_class } } - - it { expect(collection.matches?(**other_options)).to be true } - end - - describe 'with matching entity class as a String' do - let(:configured_entity_class) do - options.fetch(:default_entity_class, Book) - end - let(:other_options) { { entity_class: configured_entity_class.to_s } } - - it { expect(collection.matches?(**other_options)).to be true } - end - - describe 'with matching name' do - let(:other_options) { { collection_name: name } } - - it { expect(collection.matches?(**other_options)).to be true } - end - - describe 'with matching primary key name' do - let(:other_options) { { primary_key_name: 'id' } } - - it { expect(collection.matches?(**other_options)).to be true } - end - - describe 'with matching primary key type' do - let(:other_options) { { primary_key_type: Integer } } - - it { expect(collection.matches?(**other_options)).to be true } - end - - describe 'with matching qualified name' do - let(:other_options) { { qualified_name: name } } - - it { expect(collection.matches?(**other_options)).to be true } - end - - describe 'with matching singular name' do - let(:other_options) do - { singular_name: tools.str.singularize(name) } - end - - it { expect(collection.matches?(**other_options)).to be true } - end - - describe 'with multiple matching options' do - let(:other_options) do - { - collection_name: name, - primary_key_name: 'id', - qualified_name: name - } - end - - it { expect(collection.matches?(**other_options)).to be true } - end - end - - describe '#query' do - let(:error_message) do - "#{described_class.name} is an abstract class. Define a repository " \ - 'subclass and implement the #query method.' - end - let(:default_order) { defined?(super()) ? super() : {} } - let(:query) { collection.query } - - it { expect(collection).to respond_to(:query).with(0).arguments } - - if options[:abstract] - it 'should raise an exception' do - expect { collection.query } - .to raise_error( - described_class::AbstractCollectionError, - error_message - ) - end - else - it { expect(collection.query).to be_a query_class } - - it 'should set the query options' do - query_options.each do |option, value| - expect(collection.query.send option).to be == value - end - end - - it { expect(query.criteria).to be == [] } - - it { expect(query.limit).to be nil } - - it { expect(query.offset).to be nil } - - it { expect(query.order).to be == default_order } - end - end - end - end -end diff --git a/lib/cuprum/collections/rspec/contracts.rb b/lib/cuprum/collections/rspec/contracts.rb index ee7265b..cbdf5ac 100644 --- a/lib/cuprum/collections/rspec/contracts.rb +++ b/lib/cuprum/collections/rspec/contracts.rb @@ -5,7 +5,15 @@ module Cuprum::Collections::RSpec # Namespace for RSpec contract objects. module Contracts + autoload :AssociationContracts, + 'cuprum/collections/rspec/contracts/association_contracts' + autoload :CollectionContracts, + 'cuprum/collections/rspec/contracts/collection_contracts' + autoload :QueryContracts, + 'cuprum/collections/rspec/contracts/query_contracts' autoload :RelationContracts, 'cuprum/collections/rspec/contracts/relation_contracts' + autoload :RepositoryContracts, + 'cuprum/collections/rspec/contracts/repository_contracts' end end diff --git a/lib/cuprum/collections/rspec/contracts/collection_contracts.rb b/lib/cuprum/collections/rspec/contracts/collection_contracts.rb new file mode 100644 index 0000000..3cf9dc6 --- /dev/null +++ b/lib/cuprum/collections/rspec/contracts/collection_contracts.rb @@ -0,0 +1,429 @@ +# frozen_string_literal: true + +require 'cuprum/collections/rspec/contracts' +require 'cuprum/collections/rspec/contracts/relation_contracts' + +module Cuprum::Collections::RSpec::Contracts + # Contracts for asserting on Collection objects. + module CollectionContracts + include Cuprum::Collections::RSpec::Contracts::RelationContracts + + # Contract validating the behavior of a Collection. + module ShouldBeACollectionContract + extend RSpec::SleepingKingStudios::Contract + + # @!method apply(example_group) + # Adds the contract to the example group. + # + # @param example_group [RSpec::Core::ExampleGroup] The example group to + # which the contract is applied. + # @param options [Hash] additional options for the contract. + # + # @option options abstract [Boolean] if true, the collection is an + # abstract base class and does not define a query or commands. + # @option options default_entity_class [Class] the default entity class + # for the collection, if any. + + contract do |**options| + shared_examples 'should define the command' \ + do |command_name, command_class_name = nil| + next if options[:abstract] + + tools = SleepingKingStudios::Tools::Toolbelt.instance + class_name = tools.str.camelize(command_name) + command_options = %i[ + collection_name + member_name + primary_key_name + primary_key_type + ] + options.fetch(:command_options, []).map(&:intern) + + describe "::#{class_name}" do + let(:constructor_options) { defined?(super()) ? super() : {} } + let(:command_class) do + command_class_name || + "#{options[:commands_namespace]}::#{class_name}" + .then { |str| Object.const_get(str) } + end + let(:command) do + collection.const_get(class_name).new(**constructor_options) + end + let(:expected_options) do + Hash + .new { |_, key| collection.send(key) } + .merge( + collection_name: collection.name, + member_name: collection.singular_name + ) + end + + it { expect(collection).to define_constant(class_name) } + + it { expect(collection.const_get(class_name)).to be_a Class } + + it 'should be an instance of the command class' do + expect(collection.const_get(class_name)).to be < command_class + end + + it { expect(command.options).to be >= {} } + + command_options.each do |option_name| + it "should set the ##{option_name}" do + expect(command.send(option_name)) + .to be == expected_options[option_name] + end + end + + describe 'with options' do + let(:constructor_options) do + super().merge( + custom_option: 'value', + singular_name: 'tome' + ) + end + + it { expect(command.options).to be >= { custom_option: 'value' } } + + command_options.each do |option_name| + it "should set the ##{option_name}" do + expect(command.send(option_name)).to( + be == expected_options[option_name] + ) + end + end + end + end + + describe "##{command_name}" do + let(:constructor_options) { defined?(super()) ? super() : {} } + let(:command) do + collection.send(command_name, **constructor_options) + end + let(:expected_options) do + Hash + .new { |_, key| collection.send(key) } + .merge( + collection_name: collection.name, + member_name: collection.singular_name + ) + end + + it 'should define the command' do + expect(collection) + .to respond_to(command_name) + .with(0).arguments + .and_any_keywords + end + + it { expect(command).to be_a collection.const_get(class_name) } + + command_options.each do |option_name| + it "should set the ##{option_name}" do + expect(command.send(option_name)) + .to be == expected_options[option_name] + end + end + + describe 'with options' do + let(:constructor_options) do + super().merge( + custom_option: 'value', + singular_name: 'tome' + ) + end + + it { expect(command.options).to be >= { custom_option: 'value' } } + + command_options.each do |option_name| + it "should set the ##{option_name}" do + expect(command.send(option_name)).to( + be == expected_options[option_name] + ) + end + end + end + end + end + + include_contract 'should be a relation', + constructor: false, + default_entity_class: options[:default_entity_class] + + include_contract 'should disambiguate parameter', + :name, + as: :collection_name + + include_contract 'should disambiguate parameter', + :singular_name, + as: :member_name + + include_contract 'should define primary keys' + + include_examples 'should define the command', :assign_one + + include_examples 'should define the command', :build_one + + include_examples 'should define the command', :destroy_one + + include_examples 'should define the command', :find_many + + include_examples 'should define the command', :find_matching + + include_examples 'should define the command', :find_one + + include_examples 'should define the command', :insert_one + + include_examples 'should define the command', :update_one + + include_examples 'should define the command', :validate_one + + describe '#==' do + let(:other_options) { { name: name } } + let(:other_collection) { described_class.new(**other_options) } + + describe 'with nil' do + it { expect(collection == nil).to be false } # rubocop:disable Style/NilComparison + end + + describe 'with an object' do + it { expect(collection == Object.new.freeze).to be false } + end + + describe 'with a collection with non-matching properties' do + let(:other_options) { super().merge(custom_option: 'value') } + + it { expect(collection == other_collection).to be false } + end + + describe 'with a collection with matching properties' do + it { expect(collection == other_collection).to be true } + end + + describe 'with another type of collection' do + let(:other_collection) do + Spec::OtherCollection.new(**other_options) + end + + example_class 'Spec::OtherCollection', + Cuprum::Collections::Collection + + it { expect(collection == other_collection).to be false } + end + + context 'when initialized with options' do + let(:constructor_options) do + super().merge( + qualified_name: 'spec/scoped_books', + singular_name: 'grimoire' + ) + end + + describe 'with a collection with non-matching properties' do + it { expect(collection == other_collection).to be false } + end + + describe 'with a collection with matching properties' do + let(:other_options) do + super().merge( + qualified_name: 'spec/scoped_books', + singular_name: 'grimoire' + ) + end + + it { expect(collection == other_collection).to be true } + end + end + end + + describe '#count' do + it { expect(collection).to respond_to(:count).with(0).arguments } + + it { expect(collection).to have_aliased_method(:count).as(:size) } + + next if options[:abstract] + + it { expect(collection.count).to be 0 } + + wrap_context 'when the collection has many items' do + it { expect(collection.count).to be items.count } + end + end + + describe '#matches?' do + def tools + SleepingKingStudios::Tools::Toolbelt.instance + end + + it 'should define the method' do + expect(collection) + .to respond_to(:matches?) + .with(0).arguments + .and_any_keywords + end + + describe 'with no options' do + it { expect(collection.matches?).to be true } + end + + describe 'with non-matching entity class as a Class' do + let(:other_options) { { entity_class: Grimoire } } + + it { expect(collection.matches?(**other_options)).to be false } + end + + describe 'with non-matching entity class as a String' do + let(:other_options) { { entity_class: 'Grimoire' } } + + it { expect(collection.matches?(**other_options)).to be false } + end + + describe 'with non-matching name' do + it { expect(collection.matches?(name: 'grimoires')).to be false } + end + + describe 'with non-matching primary key name' do + let(:other_options) { { primary_key_name: 'uuid' } } + + it { expect(collection.matches?(**other_options)).to be false } + end + + describe 'with non-matching primary key type' do + let(:other_options) { { primary_key_type: String } } + + it { expect(collection.matches?(**other_options)).to be false } + end + + describe 'with non-matching qualified name' do + let(:other_options) { { qualified_name: 'spec/scoped_books' } } + + it { expect(collection.matches?(**other_options)).to be false } + end + + describe 'with non-matching singular name' do + let(:other_options) { { singular_name: 'grimoire' } } + + it { expect(collection.matches?(**other_options)).to be false } + end + + describe 'with non-matching custom options' do + let(:other_options) { { custom_option: 'custom value' } } + + it { expect(collection.matches?(**other_options)).to be false } + end + + describe 'with partially-matching options' do + let(:other_options) do + { + name: name, + singular_name: 'grimoire' + } + end + + it { expect(collection.matches?(**other_options)).to be false } + end + + describe 'with matching entity class as a Class' do + let(:configured_entity_class) do + options.fetch(:default_entity_class, Book) + end + let(:other_options) { { entity_class: configured_entity_class } } + + it { expect(collection.matches?(**other_options)).to be true } + end + + describe 'with matching entity class as a String' do + let(:configured_entity_class) do + options.fetch(:default_entity_class, Book) + end + let(:other_options) do + { entity_class: configured_entity_class.to_s } + end + + it { expect(collection.matches?(**other_options)).to be true } + end + + describe 'with matching name' do + let(:other_options) { { collection_name: name } } + + it { expect(collection.matches?(**other_options)).to be true } + end + + describe 'with matching primary key name' do + let(:other_options) { { primary_key_name: 'id' } } + + it { expect(collection.matches?(**other_options)).to be true } + end + + describe 'with matching primary key type' do + let(:other_options) { { primary_key_type: Integer } } + + it { expect(collection.matches?(**other_options)).to be true } + end + + describe 'with matching qualified name' do + let(:other_options) { { qualified_name: name } } + + it { expect(collection.matches?(**other_options)).to be true } + end + + describe 'with matching singular name' do + let(:other_options) do + { singular_name: tools.str.singularize(name) } + end + + it { expect(collection.matches?(**other_options)).to be true } + end + + describe 'with multiple matching options' do + let(:other_options) do + { + collection_name: name, + primary_key_name: 'id', + qualified_name: name + } + end + + it { expect(collection.matches?(**other_options)).to be true } + end + end + + describe '#query' do + let(:error_message) do + "#{described_class.name} is an abstract class. Define a " \ + 'repository subclass and implement the #query method.' + end + let(:default_order) { defined?(super()) ? super() : {} } + let(:query) { collection.query } + + it { expect(collection).to respond_to(:query).with(0).arguments } + + if options[:abstract] + it 'should raise an exception' do + expect { collection.query } + .to raise_error( + described_class::AbstractCollectionError, + error_message + ) + end + else + it { expect(collection.query).to be_a query_class } + + it 'should set the query options' do + query_options.each do |option, value| + expect(collection.query.send(option)).to be == value + end + end + + it { expect(query.criteria).to be == [] } + + it { expect(query.limit).to be nil } + + it { expect(query.offset).to be nil } + + it { expect(query.order).to be == default_order } + end + end + end + end + end +end diff --git a/lib/cuprum/collections/rspec/contracts/query_contracts.rb b/lib/cuprum/collections/rspec/contracts/query_contracts.rb new file mode 100644 index 0000000..758cefe --- /dev/null +++ b/lib/cuprum/collections/rspec/contracts/query_contracts.rb @@ -0,0 +1,1082 @@ +# frozen_string_literal: true + +require 'cuprum/collections/rspec/contracts' +require 'cuprum/collections/rspec/fixtures' + +module Cuprum::Collections::RSpec::Contracts + # Contracts for asserting on Query objects. + module QueryContracts + # Contract validating the behavior of a Query implementation. + module ShouldBeAQuery + extend RSpec::SleepingKingStudios::Contract + + BOOKS_FIXTURES = Cuprum::Collections::RSpec::BOOKS_FIXTURES + private_constant :BOOKS_FIXTURES + + OPERATORS = Cuprum::Collections::Queries::Operators + private_constant :OPERATORS + + # @!method apply(example_group) + # Adds the contract to the example group. + # + # @param operators [Array] the expected operators. + contract do |operators: OPERATORS.values| + include Cuprum::Collections::RSpec::Contracts::QueryContracts + + operators = Set.new(operators.map(&:to_sym)) + + include_contract 'with query contexts' + + shared_context 'when the query has composed filters' do + let(:scoped_query) do + super() + .where { { author: 'Ursula K. LeGuin' } } + .where { { series: not_equal('Earthsea') } } + end + let(:matching_data) do + super() + .select { |item| item['author'] == 'Ursula K. LeGuin' } + .reject { |item| item['series'] == 'Earthsea' } + end + end + + let(:scoped_query) do + # :nocov: + scoped = + if filter.is_a?(Proc) + query.where(&filter) + elsif !filter.nil? + query.where(filter) + else + query + end + # :nocov: + scoped = scoped.limit(limit) if limit + scoped = scoped.offset(offset) if offset + scoped = scoped.order(order) if order + + scoped + end + + it 'should be enumerable' do + expect(described_class).to be < Enumerable + end + + describe '#count' do + let(:data) { [] } + let(:matching_data) { data } + let(:expected_data) do + defined?(super()) ? super() : matching_data + end + + it { expect(query).to respond_to(:count).with(0).arguments } + + it { expect(query.count).to be == expected_data.count } + + wrap_context 'when the query has composed filters' do + it { expect(scoped_query.count).to be == expected_data.count } + end + + context 'when the collection data changes' do + let(:item) { BOOKS_FIXTURES.first } + + before(:example) do + query.count # Cache query results. + + add_item_to_collection(item) + end + + it { expect(query.count).to be == expected_data.count } + end + + context 'when the collection has many items' do + let(:data) { BOOKS_FIXTURES } + + it { expect(query.count).to be == expected_data.count } + + wrap_context 'when the query has composed filters' do + it { expect(scoped_query.count).to be == expected_data.count } + end + + context 'when the collection data changes' do + let(:data) { BOOKS_FIXTURES[0...-1] } + let(:item) { BOOKS_FIXTURES.last } + + before(:example) do + query.count # Cache query results. + + add_item_to_collection(item) + end + + it { expect(query.count).to be == expected_data.count } + end + end + end + + describe '#criteria' do + include_examples 'should have reader', :criteria, [] + + wrap_context 'when the query has where: a simple block filter' do + let(:expected) { [['author', :equal, 'Ursula K. LeGuin']] } + + it { expect(scoped_query.criteria).to be == expected } + end + + wrap_context 'when the query has where: a complex block filter' do + let(:expected) do + [ + ['author', :equal, 'Ursula K. LeGuin'], + ['series', :not_equal, 'Earthsea'] + ] + end + + if operators.include?(OPERATORS::EQUAL) && + operators.include?(OPERATORS::NOT_EQUAL) + it { expect(scoped_query.criteria).to be == expected } + else + # :nocov: + pending + # :nocov: + end + end + + wrap_context 'when the query has composed filters' do + let(:expected) do + [ + ['author', :equal, 'Ursula K. LeGuin'], + ['series', :not_equal, 'Earthsea'] + ] + end + + it { expect(scoped_query.criteria).to be == expected } + end + + wrap_context 'when the query has where: an equal block filter' do + let(:expected) { [['author', :equal, 'Ursula K. LeGuin']] } + + if operators.include?(OPERATORS::EQUAL) + it { expect(scoped_query.criteria).to be == expected } + else + # :nocov: + pending + # :nocov: + end + end + + wrap_context 'when the query has where: a not_equal block filter' do + let(:expected) { [['author', :not_equal, 'Ursula K. LeGuin']] } + + if operators.include?(OPERATORS::NOT_EQUAL) + it { expect(scoped_query.criteria).to be == expected } + else + # :nocov: + pending + # :nocov: + end + end + end + + describe '#each' do + shared_examples 'should enumerate the matching data' do + describe 'with no arguments' do + it { expect(scoped_query.each).to be_a Enumerator } + + it { expect(scoped_query.each.count).to be == matching_data.size } + + it { expect(scoped_query.each.to_a).to deep_match expected_data } + end + + describe 'with a block' do + it 'should yield each matching item' do + expect { |block| scoped_query.each(&block) } + .to yield_successive_args(*expected_data) + end + end + end + + let(:data) { [] } + let(:matching_data) { data } + let(:expected_data) do + defined?(super()) ? super() : matching_data + end + + it { expect(query).to respond_to(:each).with(0).arguments } + + include_examples 'should enumerate the matching data' + + include_contract 'should perform queries', + block: lambda { + include_examples 'should enumerate the matching data' + }, + operators: operators + + wrap_context 'when the query has composed filters' do + include_examples 'should enumerate the matching data' + end + + context 'when the collection data changes' do + let(:item) { BOOKS_FIXTURES.first } + + before(:example) do + query.each {} # Cache query results. + + add_item_to_collection(item) + end + + include_examples 'should enumerate the matching data' + end + + context 'when the collection has many items' do + let(:data) { BOOKS_FIXTURES } + + include_examples 'should enumerate the matching data' + + include_contract 'should perform queries', + block: lambda { + include_examples 'should enumerate the matching data' + }, + operators: operators + + wrap_context 'when the query has composed filters' do + include_examples 'should enumerate the matching data' + end + + context 'when the collection data changes' do + let(:data) { BOOKS_FIXTURES[0...-1] } + let(:item) { BOOKS_FIXTURES.last } + + before(:example) do + query.each {} # Cache query results. + + add_item_to_collection(item) + end + + include_examples 'should enumerate the matching data' + end + end + end + + describe '#exists?' do + shared_examples 'should check the existence of matching data' do + it { expect(query.exists?).to be == !matching_data.empty? } + end + + let(:data) { [] } + let(:matching_data) { data } + + include_examples 'should define predicate', :exists? + + include_examples 'should check the existence of matching data' + + include_contract 'should perform queries', + block: lambda { + include_examples 'should check the existence of matching data' + }, + operators: operators + + wrap_context 'when the query has composed filters' do + include_examples 'should check the existence of matching data' + end + + context 'when the collection has many items' do + let(:data) { BOOKS_FIXTURES } + + include_examples 'should check the existence of matching data' + + include_contract 'should perform queries', + block: lambda { + include_examples 'should check the existence of matching data' + }, + operators: operators + + wrap_context 'when the query has composed filters' do + include_examples 'should check the existence of matching data' + end + end + end + + describe '#limit' do + it { expect(query).to respond_to(:limit).with(0..1).arguments } + + describe 'with no arguments' do + it { expect(query.limit).to be nil } + end + + describe 'with nil' do + let(:error_message) { 'limit must be a non-negative integer' } + + it 'should raise an exception' do + expect { query.limit nil } + .to raise_error ArgumentError, error_message + end + end + + describe 'with an object' do + let(:error_message) { 'limit must be a non-negative integer' } + + it 'should raise an exception' do + expect { query.limit Object.new.freeze } + .to raise_error ArgumentError, error_message + end + end + + describe 'with a negative integer' do + let(:error_message) { 'limit must be a non-negative integer' } + + it 'should raise an exception' do + expect { query.limit(-1) } + .to raise_error ArgumentError, error_message + end + end + + describe 'with zero' do + it { expect(query.limit(0)).to be_a described_class } + + it { expect(query.limit(0)).not_to be query } + + it { expect(query.limit(0).limit).to be 0 } + end + + describe 'with a positive integer' do + it { expect(query.limit(3)).to be_a described_class } + + it { expect(query.limit(3)).not_to be query } + + it { expect(query.limit(3).limit).to be 3 } + end + end + + describe '#offset' do + it { expect(query).to respond_to(:offset).with(0..1).argument } + + describe 'with no arguments' do + it { expect(query.offset).to be nil } + end + + describe 'with nil' do + let(:error_message) { 'offset must be a non-negative integer' } + + it 'should raise an exception' do + expect { query.offset nil } + .to raise_error ArgumentError, error_message + end + end + + describe 'with an object' do + let(:error_message) { 'offset must be a non-negative integer' } + + it 'should raise an exception' do + expect { query.offset Object.new.freeze } + .to raise_error ArgumentError, error_message + end + end + + describe 'with a negative integer' do + let(:error_message) { 'offset must be a non-negative integer' } + + it 'should raise an exception' do + expect { query.offset(-1) } + .to raise_error ArgumentError, error_message + end + end + + describe 'with zero' do + it { expect(query.offset(0)).to be_a described_class } + + it { expect(query.offset(0)).not_to be query } + + it { expect(query.offset(0).offset).to be 0 } + end + + describe 'with a positive integer' do + it { expect(query.offset(3)).to be_a described_class } + + it { expect(query.offset(3)).not_to be query } + + it { expect(query.offset(3).offset).to be 3 } + end + end + + describe '#order' do + let(:default_order) { defined?(super()) ? super() : {} } + let(:error_message) do + 'order must be a list of attribute names and/or a hash of ' \ + 'attribute names with values :asc or :desc' + end + + it 'should define the method' do + expect(query) + .to respond_to(:order) + .with(0).arguments + .and_unlimited_arguments + end + + it { expect(query).to have_aliased_method(:order).as(:order_by) } + + describe 'with no arguments' do + it { expect(query.order).to be == default_order } + end + + describe 'with a hash with invalid keys' do + it 'should raise an exception' do + expect { query.order({ nil => :asc }) } + .to raise_error ArgumentError, error_message + end + end + + describe 'with a hash with empty string keys' do + it 'should raise an exception' do + expect { query.order({ '' => :asc }) } + .to raise_error ArgumentError, error_message + end + end + + describe 'with a hash with empty symbol keys' do + it 'should raise an exception' do + expect { query.order({ '': :asc }) } + .to raise_error ArgumentError, error_message + end + end + + describe 'with a hash with nil value' do + it 'should raise an exception' do + expect { query.order({ title: nil }) } + .to raise_error ArgumentError, error_message + end + end + + describe 'with a hash with object value' do + it 'should raise an exception' do + expect { query.order({ title: Object.new.freeze }) } + .to raise_error ArgumentError, error_message + end + end + + describe 'with a hash with empty value' do + it 'should raise an exception' do + expect { query.order({ title: '' }) } + .to raise_error ArgumentError, error_message + end + end + + describe 'with a hash with invalid value' do + it 'should raise an exception' do + expect { query.order({ title: 'wibbly' }) } + .to raise_error ArgumentError, error_message + end + end + + describe 'with a valid ordering' do + let(:expected) do + { title: :asc } + end + + it { expect(query.order(:title)).to be_a described_class } + + it { expect(query.order(:title)).not_to be query } + + it { expect(query.order(:title).order).to be == expected } + end + end + + describe '#reset' do + let(:data) { [] } + let(:matching_data) { data } + let(:expected_data) do + defined?(super()) ? super() : matching_data + end + + it { expect(query).to respond_to(:reset).with(0).arguments } + + it { expect(query.reset).to be_a query.class } + + it { expect(query.reset).not_to be query } + + it { expect(query.reset.to_a).to be == query.to_a } + + context 'when the collection data changes' do + let(:item) { BOOKS_FIXTURES.first } + let(:matching_data) { [item] } + + before(:example) do + query.to_a # Cache query results. + + add_item_to_collection(item) + end + + it { expect(query.reset.count).to be expected_data.size } + + it { expect(query.reset.to_a).to deep_match expected_data } + end + + context 'when the collection has many items' do + let(:data) { BOOKS_FIXTURES } + + it { expect(query.reset).to be_a query.class } + + it { expect(query.reset).not_to be query } + + it { expect(query.reset.to_a).to be == query.to_a } + + context 'when the collection data changes' do + let(:data) { BOOKS_FIXTURES[0...-1] } + let(:item) { BOOKS_FIXTURES.last } + let(:matching_data) { [*data, item] } + + before(:example) do + query.to_a # Cache query results. + + add_item_to_collection(item) + end + + it { expect(query.reset.count).to be expected_data.size } + + it { expect(query.reset.to_a).to deep_match expected_data } + end + end + end + + describe '#to_a' do + let(:data) { [] } + let(:matching_data) { data } + let(:expected_data) do + defined?(super()) ? super() : matching_data + end + + it { expect(query).to respond_to(:to_a).with(0).arguments } + + it { expect(query.to_a).to deep_match expected_data } + + include_contract 'should perform queries', + block: lambda { + it { expect(scoped_query.to_a).to deep_match expected_data } + }, + operators: operators + + wrap_context 'when the query has composed filters' do + it { expect(scoped_query.to_a).to deep_match expected_data } + end + + context 'when the collection data changes' do + let(:item) { BOOKS_FIXTURES.first } + + before(:example) do + query.to_a # Cache query results. + + add_item_to_collection(item) + end + + it { expect(query.to_a).to deep_match expected_data } + end + + context 'when the collection has many items' do + let(:data) { BOOKS_FIXTURES } + + it { expect(query.to_a).to deep_match expected_data } + + include_contract 'should perform queries', + block: lambda { + it { expect(scoped_query.to_a).to deep_match expected_data } + }, + operators: operators + + wrap_context 'when the query has composed filters' do + it { expect(scoped_query.to_a).to deep_match expected_data } + end + + context 'when the collection data changes' do + let(:data) { BOOKS_FIXTURES[0...-1] } + let(:item) { BOOKS_FIXTURES.last } + + before(:example) do + query.to_a # Cache query results. + + add_item_to_collection(item) + end + + it { expect(query.to_a).to deep_match expected_data } + end + end + end + + describe '#where' do + let(:block) { -> { { title: 'The Caves of Steel' } } } + + it 'should define the method' do + expect(query) + .to respond_to(:where) + .with(0..1).arguments + .and_keywords(:strategy) + .and_a_block + end + + describe 'with no arguments' do + it { expect(query.where).to be_a described_class } + + it { expect(query.where).not_to be query } + end + + describe 'with a block' do + it { expect(query.where(&block)).to be_a described_class } + + it { expect(query.where(&block)).not_to be query } + end + + describe 'with a valid strategy' do + it 'should return a query instance' do + expect(query.where(strategy: :block, &block)) + .to be_a described_class + end + + it { expect(query.where(strategy: :block, &block)).not_to be query } + end + + describe 'with parameters that do not match a strategy' do + let(:error_class) do + Cuprum::Collections::QueryBuilder::ParseError + end + let(:error_message) { 'unable to parse query with strategy nil' } + + it 'should raise an exception' do + expect { query.where(%w[ichi ni san]) } + .to raise_error error_class, error_message + end + end + + describe 'with an invalid strategy' do + let(:error_class) do + Cuprum::Collections::QueryBuilder::ParseError + end + let(:error_message) do + 'unable to parse query with strategy :random' + end + + it 'should raise an exception' do + expect { query.where(strategy: :random) } + .to raise_error error_class, error_message + end + end + + describe 'with invalid parameters for a strategy' do + let(:error_class) do + Cuprum::Collections::QueryBuilder::ParseError + end + let(:error_message) { 'unable to parse query with strategy :block' } + + it 'should raise an exception' do + expect { query.where(strategy: :block) } + .to raise_error error_class, error_message + end + end + end + end + end + + # Contract validating the behavior of a QueryBuilder implementation. + module ShouldBeAQueryBuilderContract + extend RSpec::SleepingKingStudios::Contract + + # @!method apply(example_group) + # Adds the contract to the example group. + contract do + describe '#base_query' do + include_examples 'should define reader', + :base_query, + -> { base_query } + end + + describe '#call' do + let(:criteria) { [['title', :equal, 'The Naked Sun']] } + let(:expected) { criteria } + let(:filter) { { title: 'The Naked Sun' } } + let(:strategy) { :custom } + let(:parser) do + instance_double( + Cuprum::Collections::Queries::Parse, + call: Cuprum::Result.new(value: criteria) + ) + end + let(:query) do + builder.call(strategy: strategy, where: filter) + end + + before(:example) do + allow(Cuprum::Collections::Queries::Parse) + .to receive(:new) + .and_return(parser) + end + + it 'should define the method' do + expect(builder).to respond_to(:call) + .with(0).arguments + .and_keywords(:strategy, :where) + end + + it 'should parse the criteria' do + builder.call(strategy: strategy, where: filter) + + expect(parser) + .to have_received(:call) + .with(strategy: strategy, where: filter) + end + + it { expect(query).to be_a base_query.class } + + it { expect(query).not_to be base_query } + + it { expect(query.criteria).to be == expected } + + describe 'with strategy: :unsafe' do + let(:strategy) { :unsafe } + let(:filter) { criteria } + + it 'should not parse the criteria' do + builder.call(strategy: strategy, where: filter) + + expect(parser).not_to have_received(:call) + end + + it { expect(query.criteria).to be == expected } + end + + context 'when the query has existing criteria' do + let(:old_criteria) { [['genre', :eq, 'Science Fiction']] } + let(:expected) { old_criteria + criteria } + let(:base_query) { super().send(:with_criteria, old_criteria) } + + it { expect(query.criteria).to be == expected } + end + + context 'when the parser is unable to parse the query' do + let(:error) { Cuprum::Error.new(message: 'Something went wrong.') } + let(:result) { Cuprum::Result.new(error: error) } + + before(:example) do + allow(parser).to receive(:call).and_return(result) + end + + it 'should raise an exception' do + expect do + builder.call(strategy: strategy, where: filter) + end + .to raise_error Cuprum::Collections::QueryBuilder::ParseError, + error.message + end + end + end + end + end + + # Contract validating the behavior when performing queries. + module ShouldPerformQueriesContract + extend RSpec::SleepingKingStudios::Contract + + OPERATORS = Cuprum::Collections::Queries::Operators + private_constant :OPERATORS + + # @!method apply(example_group) + # Adds the contract to the example group. + # + # @param block [Proc] the expectations for each query context. + # @param operators [Array] the expected operators. + contract do |block:, operators: OPERATORS.values| + operators = Set.new(operators.map(&:to_sym)) + + wrap_context 'when the query has limit: value' do + instance_exec(&block) + end + + wrap_context 'when the query has offset: value' do + instance_exec(&block) + end + + wrap_context 'when the query has order: a simple ordering' do + instance_exec(&block) + end + + wrap_context 'when the query has order: a complex ordering' do + instance_exec(&block) + end + + context 'when the query has where: a block filter' do + context 'with a simple filter' do + include_context 'when the query has where: a simple block filter' + + instance_exec(&block) + end + + context 'with a complex filter' do + include_context 'when the query has where: a complex block filter' + + if operators.include?(OPERATORS::EQUAL) && + operators.include?(OPERATORS::NOT_EQUAL) + instance_exec(&block) + else + # :nocov: + pending + # :nocov: + end + end + + context 'with an equals filter' do + include_context 'when the query has where: an equal block filter' + + if operators.include?(OPERATORS::EQUAL) + instance_exec(&block) + else + # :nocov: + pending + # :nocov: + end + end + + context 'with a greater_than filter' do + include_context 'when the query has where: a greater_than filter' + + if operators.include?(OPERATORS::GREATER_THAN) + instance_exec(&block) + else + # :nocov: + pending + # :nocov: + end + end + + context 'with a greater_than_or_equal_to filter' do + include_context \ + 'when the query has where: a greater_than_or_equal_to filter' + + if operators.include?(OPERATORS::GREATER_THAN_OR_EQUAL_TO) + instance_exec(&block) + else + # :nocov: + pending + # :nocov: + end + end + + context 'with a less_than filter' do + include_context 'when the query has where: a less_than filter' + + if operators.include?(OPERATORS::LESS_THAN) + instance_exec(&block) + else + # :nocov: + pending + # :nocov: + end + end + + context 'with a less_than_or_equal_to filter' do + include_context \ + 'when the query has where: a less_than_or_equal_to filter' + + if operators.include?(OPERATORS::LESS_THAN_OR_EQUAL_TO) + instance_exec(&block) + else + # :nocov: + pending + # :nocov: + end + end + + context 'with a not_equal filter' do + include_context 'when the query has where: a not_equal block filter' + + if operators.include?(OPERATORS::NOT_EQUAL) + instance_exec(&block) + else + # :nocov: + pending + # :nocov: + end + end + + context 'with a not_one_of filter' do + include_context \ + 'when the query has where: a not_one_of block filter' + + if operators.include?(OPERATORS::NOT_ONE_OF) + instance_exec(&block) + else + # :nocov: + pending + # :nocov: + end + end + + context 'with a one_of filter' do + include_context 'when the query has where: a one_of block filter' + + if operators.include?(OPERATORS::ONE_OF) + instance_exec(&block) + else + # :nocov: + pending + # :nocov: + end + end + end + + wrap_context 'when the query has multiple query options' do + instance_exec(&block) + end + end + end + + # Contract defining contexts for validating query behavior. + module WithQueryContextsContract + extend RSpec::SleepingKingStudios::Contract + + # @!method apply(example_group) + # Adds the contract to the example group. + contract do + let(:filter) { nil } + let(:strategy) { nil } + let(:limit) { nil } + let(:offset) { nil } + let(:order) { nil } + + shared_context 'when the query has limit: value' do + let(:limit) { 3 } + let(:matching_data) { super()[0...limit] } + end + + shared_context 'when the query has offset: value' do + let(:offset) { 2 } + let(:matching_data) { super()[offset..] || [] } + end + + shared_context 'when the query has order: a simple ordering' do + let(:order) { :title } + let(:matching_data) { super().sort_by { |item| item['title'] } } + end + + shared_context 'when the query has order: a complex ordering' do + let(:order) do + { + author: :asc, + title: :desc + } + end + let(:matching_data) do + super().sort do |u, v| + cmp = u['author'] <=> v['author'] + + cmp.zero? ? (v['title'] <=> u['title']) : cmp + end + end + end + + shared_context 'when the query has where: a simple block filter' do + let(:filter) { -> { { author: 'Ursula K. LeGuin' } } } + let(:matching_data) do + super().select { |item| item['author'] == 'Ursula K. LeGuin' } + end + end + + shared_context 'when the query has where: a complex block filter' do + let(:filter) do + lambda do + { + author: equals('Ursula K. LeGuin'), + series: not_equal('Earthsea') + } + end + end + let(:matching_data) do + super() + .select { |item| item['author'] == 'Ursula K. LeGuin' } + .reject { |item| item['series'] == 'Earthsea' } + end + end + + shared_context 'when the query has where: a greater_than filter' do + let(:filter) { -> { { published_at: greater_than('1970-12-01') } } } + let(:matching_data) do + super().select { |item| item['published_at'] > '1970-12-01' } + end + end + + shared_context 'when the query has where: a greater_than_or_equal_to ' \ + 'filter' \ + do + let(:filter) do + -> { { published_at: greater_than_or_equal_to('1970-12-01') } } + end + let(:matching_data) do + super().select { |item| item['published_at'] >= '1970-12-01' } + end + end + + shared_context 'when the query has where: a less_than filter' do + let(:filter) { -> { { published_at: less_than('1970-12-01') } } } + let(:matching_data) do + super().select { |item| item['published_at'] < '1970-12-01' } + end + end + + shared_context 'when the query has where: a ' \ + 'less_than_or_equal_to filter' \ + do + let(:filter) do + -> { { published_at: less_than_or_equal_to('1970-12-01') } } + end + let(:matching_data) do + super().select { |item| item['published_at'] <= '1970-12-01' } + end + end + + shared_context 'when the query has where: an equal block filter' do + let(:filter) { -> { { author: equals('Ursula K. LeGuin') } } } + let(:matching_data) do + super().select { |item| item['author'] == 'Ursula K. LeGuin' } + end + end + + shared_context 'when the query has where: a not_equal block filter' do + let(:filter) { -> { { author: not_equal('Ursula K. LeGuin') } } } + let(:matching_data) do + super().reject { |item| item['author'] == 'Ursula K. LeGuin' } + end + end + + shared_context 'when the query has where: a not_one_of block filter' do + let(:filter) do + -> { { series: not_one_of(['Earthsea', 'The Lord of the Rings']) } } + end + let(:matching_data) do + super().reject do |item| + ['Earthsea', 'The Lord of the Rings'].include?(item['series']) + end + end + end + + shared_context 'when the query has where: a one_of block filter' do + let(:filter) do + -> { { series: one_of(['Earthsea', 'The Lord of the Rings']) } } + end + let(:matching_data) do + super().select do |item| + ['Earthsea', 'The Lord of the Rings'].include?(item['series']) + end + end + end + + shared_context 'when the query has multiple query options' do + let(:filter) { -> { { author: 'Ursula K. LeGuin' } } } + let(:strategy) { nil } + let(:order) { { title: :desc } } + let(:limit) { 2 } + let(:offset) { 1 } + let(:matching_data) do + super() + .select { |item| item['author'] == 'Ursula K. LeGuin' } + .sort { |u, v| v['title'] <=> u['title'] } + .slice(1, 2) || [] + end + end + end + end + end +end diff --git a/lib/cuprum/collections/rspec/contracts/repository_contracts.rb b/lib/cuprum/collections/rspec/contracts/repository_contracts.rb new file mode 100644 index 0000000..3be704d --- /dev/null +++ b/lib/cuprum/collections/rspec/contracts/repository_contracts.rb @@ -0,0 +1,605 @@ +# frozen_string_literal: true + +require 'cuprum/collections/rspec/contracts' + +module Cuprum::Collections::RSpec::Contracts + # Contracts for asserting on Repository objects. + module RepositoryContracts + # Contract validating the behavior of a Repository. + module ShouldBeARepositoryContract + extend RSpec::SleepingKingStudios::Contract + + # @!method apply(example_group) + # Adds the contract to the example group. + # + # @param abstract [Boolean] if true, the repository is abstract and does + # not define certain methods. Defaults to false. + # @param example_group [RSpec::Core::ExampleGroup] the example group to + # which the contract is applied. + # @param options [Hash] additional options for the contract. + # + # @option options collection_class [Class, String] the expected class + # for created collections. + # @option options entity_class [Class, String] the expected entity + # class. + contract do |abstract: false, **options| + shared_examples 'should create the collection' do + let(:configured_collection_class) do + return super() if defined?(super()) + + configured = options[:collection_class] + + # :nocov: + if configured.is_a?(String) + configured = Object.const_get(configured) + end + # :nocov: + + configured + end + let(:configured_entity_class) do + return super() if defined?(super()) + + # :nocov: + expected = + if collection_options.key?(:entity_class) + collection_options[:entity_class] + elsif options.key?(:entity_class) + options[:entity_class] + else + qualified_name + .split('/') + .then { |ary| [*ary[0...-1], tools.str.singularize(ary[-1])] } + .map { |str| tools.str.camelize(str) } + .join('::') + end + # :nocov: + expected = Object.const_get(expected) if expected.is_a?(String) + + expected + end + let(:configured_member_name) do + return super() if defined?(super()) + + tools.str.singularize(collection_name.to_s.split('/').last) + end + + def tools + SleepingKingStudios::Tools::Toolbelt.instance + end + + it 'should create the collection' do + create_collection(safe: false) + + expect(repository.key?(qualified_name)).to be true + end + + it 'should return the collection' do + collection = create_collection(safe: false) + + expect(collection).to be repository[qualified_name] + end + + it { expect(collection).to be_a configured_collection_class } + + it 'should set the entity class' do + expect(collection.entity_class).to be == configured_entity_class + end + + it 'should set the collection name' do + expect(collection.name).to be == collection_name.to_s + end + + it 'should set the member name' do + expect(collection.singular_name).to be == configured_member_name + end + + it 'should set the qualified name' do + expect(collection.qualified_name).to be == qualified_name + end + + it 'should set the collection options' do + expect(collection).to have_attributes( + primary_key_name: primary_key_name, + primary_key_type: primary_key_type + ) + end + end + + describe '#[]' do + let(:error_class) do + described_class::UndefinedCollectionError + end + let(:error_message) do + "repository does not define collection #{collection_name.inspect}" + end + + it { expect(repository).to respond_to(:[]).with(1).argument } + + describe 'with nil' do + let(:collection_name) { nil } + + it 'should raise an exception' do + expect { repository[collection_name] } + .to raise_error(error_class, error_message) + end + end + + describe 'with an object' do + let(:collection_name) { Object.new.freeze } + + it 'should raise an exception' do + expect { repository[collection_name] } + .to raise_error(error_class, error_message) + end + end + + describe 'with an invalid string' do + let(:collection_name) { 'invalid_name' } + + it 'should raise an exception' do + expect { repository[collection_name] } + .to raise_error(error_class, error_message) + end + end + + describe 'with an invalid symbol' do + let(:collection_name) { :invalid_name } + + it 'should raise an exception' do + expect { repository[collection_name] } + .to raise_error(error_class, error_message) + end + end + + wrap_context 'when the repository has many collections' do + describe 'with an invalid string' do + let(:collection_name) { 'invalid_name' } + + it 'should raise an exception' do + expect { repository[collection_name] } + .to raise_error(error_class, error_message) + end + end + + describe 'with an invalid symbol' do + let(:collection_name) { :invalid_name } + + it 'should raise an exception' do + expect { repository[collection_name] } + .to raise_error(error_class, error_message) + end + end + + describe 'with a valid string' do + let(:collection) { collections.values.first } + let(:collection_name) { collections.keys.first } + + it { expect(repository[collection_name]).to be collection } + end + + describe 'with a valid symbol' do + let(:collection) { collections.values.first } + let(:collection_name) { collections.keys.first.intern } + + it { expect(repository[collection_name]).to be collection } + end + end + end + + describe '#add' do + let(:error_class) do + described_class::InvalidCollectionError + end + let(:error_message) do + "#{collection.inspect} is not a valid collection" + end + + it 'should define the method' do + expect(repository) + .to respond_to(:add) + .with(1).argument + .and_keywords(:force) + end + + it 'should alias #add as #<<' do + expect(repository.method(:<<)).to be == repository.method(:add) + end + + describe 'with nil' do + let(:collection) { nil } + + it 'should raise an exception' do + expect { repository.add(collection) } + .to raise_error(error_class, error_message) + end + end + + describe 'with an object' do + let(:collection) { Object.new.freeze } + + it 'should raise an exception' do + expect { repository.add(collection) } + .to raise_error(error_class, error_message) + end + end + + describe 'with a collection' do + it { expect(repository.add(example_collection)).to be repository } + + it 'should add the collection to the repository' do + repository.add(example_collection) + + expect(repository[example_collection.qualified_name]) + .to be example_collection + end + + describe 'with force: true' do + it 'should add the collection to the repository' do + repository.add(example_collection, force: true) + + expect(repository[example_collection.qualified_name]) + .to be example_collection + end + end + + context 'when the collection already exists' do + let(:error_message) do + "collection #{example_collection.qualified_name} already exists" + end + + before(:example) do + allow(repository) + .to receive(:key?) + .with(example_collection.qualified_name) + .and_return(true) + end + + it 'should raise an exception' do + expect { repository.add(example_collection) } + .to raise_error( + described_class::DuplicateCollectionError, + error_message + ) + end + + it 'should not update the repository' do + begin + repository.add(example_collection) + rescue described_class::DuplicateCollectionError + # Do nothing. + end + + expect { repository[example_collection.qualified_name] } + .to raise_error( + described_class::UndefinedCollectionError, + 'repository does not define collection ' \ + "#{example_collection.qualified_name.inspect}" + ) + end + + describe 'with force: true' do + it 'should add the collection to the repository' do + repository.add(example_collection, force: true) + + expect(repository[example_collection.qualified_name]) + .to be example_collection + end + end + end + end + end + + describe '#create' do + let(:collection_name) { 'books' } + let(:qualified_name) { collection_name.to_s } + let(:primary_key_name) { 'id' } + let(:primary_key_type) { Integer } + let(:collection_options) { {} } + let(:collection) do + create_collection + + repository[qualified_name] + end + let(:error_message) do + "#{described_class.name} is an abstract class. Define a " \ + 'repository subclass and implement the #build_collection method.' + end + + def create_collection(force: false, safe: true, **options) + if safe + begin + repository.create(force: force, **collection_options, **options) + rescue StandardError + # Do nothing. + end + else + repository.create(force: force, **collection_options, **options) + end + end + + it 'should define the method' do + expect(repository) + .to respond_to(:create) + .with(0).arguments + .and_keywords(:collection_name, :entity_class, :force) + .and_any_keywords + end + + if abstract + it 'should raise an exception' do + expect { create_collection(safe: false) } + .to raise_error( + described_class::AbstractRepositoryError, + error_message + ) + end + + next + end + + describe 'with entity_class: a Class' do + let(:entity_class) { Book } + let(:collection_options) do + super().merge(entity_class: entity_class) + end + + include_examples 'should create the collection' + end + + describe 'with entity_class: a String' do + let(:entity_class) { 'Book' } + let(:collection_options) do + super().merge(entity_class: entity_class) + end + + include_examples 'should create the collection' + end + + describe 'with name: a String' do + let(:collection_name) { 'books' } + let(:collection_options) do + super().merge(name: collection_name) + end + + include_examples 'should create the collection' + end + + describe 'with name: a Symbol' do + let(:collection_name) { :books } + let(:collection_options) do + super().merge(name: collection_name) + end + + include_examples 'should create the collection' + end + + describe 'with collection options' do + let(:primary_key_name) { 'uuid' } + let(:primary_key_type) { String } + let(:collection_options) do + super().merge( + name: collection_name, + primary_key_name: primary_key_name, + primary_key_type: primary_key_type + ) + end + + include_examples 'should create the collection' + end + + context 'when the collection already exists' do + let(:collection_name) { 'books' } + let(:collection_options) do + super().merge(name: collection_name) + end + let(:error_message) do + "collection #{qualified_name} already exists" + end + + before { create_collection(old: true) } + + it 'should raise an exception' do + expect { create_collection(safe: false) } + .to raise_error( + described_class::DuplicateCollectionError, + error_message + ) + end + + it 'should not update the repository' do + create_collection(old: false) + + collection = repository[qualified_name] + + expect(collection.options[:old]).to be true + end + + describe 'with force: true' do + it 'should update the repository' do + create_collection(force: true, old: false) + + collection = repository[qualified_name] + + expect(collection.options[:old]).to be false + end + end + end + end + + describe '#find_or_create' do + let(:collection_name) { 'books' } + let(:qualified_name) { collection_name.to_s } + let(:primary_key_name) { 'id' } + let(:primary_key_type) { Integer } + let(:collection_options) { {} } + let(:collection) do + create_collection + + repository[qualified_name] + end + let(:error_message) do + "#{described_class.name} is an abstract class. Define a " \ + 'repository subclass and implement the #build_collection method.' + end + + def create_collection(safe: true, **options) + if safe + begin + repository.find_or_create(**collection_options, **options) + rescue StandardError + # Do nothing. + end + else + repository.find_or_create(**collection_options, **options) + end + end + + it 'should define the method' do + expect(repository) + .to respond_to(:find_or_create) + .with(0).arguments + .and_keywords(:entity_class) + .and_any_keywords + end + + if abstract + let(:collection_options) { { name: collection_name } } + + it 'should raise an exception' do + expect { create_collection(safe: false) } + .to raise_error( + described_class::AbstractRepositoryError, + error_message + ) + end + + next + end + + describe 'with entity_class: a Class' do + let(:entity_class) { Book } + let(:collection_options) do + super().merge(entity_class: entity_class) + end + + include_examples 'should create the collection' + end + + describe 'with entity_class: a String' do + let(:entity_class) { Book } + let(:collection_options) do + super().merge(entity_class: entity_class) + end + + include_examples 'should create the collection' + end + + describe 'with name: a String' do + let(:collection_name) { 'books' } + let(:collection_options) do + super().merge(name: collection_name) + end + + include_examples 'should create the collection' + end + + describe 'with name: a Symbol' do + let(:collection_name) { :books } + let(:collection_options) do + super().merge(name: collection_name) + end + + include_examples 'should create the collection' + end + + describe 'with collection options' do + let(:primary_key_name) { 'uuid' } + let(:primary_key_type) { String } + let(:qualified_name) { 'spec/scoped_books' } + let(:collection_options) do + super().merge( + name: collection_name, + primary_key_name: primary_key_name, + primary_key_type: primary_key_type, + qualified_name: qualified_name + ) + end + + include_examples 'should create the collection' + end + + context 'when the collection already exists' do + let(:collection_name) { 'books' } + let(:collection_options) do + super().merge(name: collection_name) + end + let(:error_message) do + "collection #{qualified_name} already exists" + end + + before { create_collection(old: true) } + + describe 'with non-matching options' do + it 'should raise an exception' do + expect { create_collection(old: false, safe: false) } + .to raise_error( + described_class::DuplicateCollectionError, + error_message + ) + end + + it 'should not update the repository' do + create_collection(old: false) + + collection = repository[qualified_name] + + expect(collection.options[:old]).to be true + end + end + + describe 'with matching options' do + it 'should return the collection' do + collection = create_collection(old: true) + + expect(collection.options[:old]).to be true + end + end + end + end + + describe '#key?' do + it { expect(repository).to respond_to(:key?).with(1).argument } + + it { expect(repository.key?(nil)).to be false } + + it { expect(repository.key?(Object.new.freeze)).to be false } + + it { expect(repository.key?('invalid_name')).to be false } + + it { expect(repository.key?(:invalid_name)).to be false } + + wrap_context 'when the repository has many collections' do + it { expect(repository.key?('invalid_name')).to be false } + + it { expect(repository.key?(:invalid_name)).to be false } + + it { expect(repository.key?(collections.keys.first)).to be true } + + it 'should include the key' do + expect(repository.key?(collections.keys.first.intern)).to be true + end + end + end + + describe '#keys' do + include_examples 'should define reader', :keys, [] + + wrap_context 'when the repository has many collections' do + it { expect(repository.keys).to be == collections.keys } + end + end + end + end + end +end diff --git a/lib/cuprum/collections/rspec/find_matching_command_contract.rb b/lib/cuprum/collections/rspec/find_matching_command_contract.rb index e62ade6..43acfd9 100644 --- a/lib/cuprum/collections/rspec/find_matching_command_contract.rb +++ b/lib/cuprum/collections/rspec/find_matching_command_contract.rb @@ -4,14 +4,17 @@ require 'cuprum/collections/constraints/ordering' require 'cuprum/collections/rspec' -require 'cuprum/collections/rspec/querying_contract' +require 'cuprum/collections/rspec/contracts/query_contracts' module Cuprum::Collections::RSpec # Contract validating the behavior of a FindMatching command implementation. FIND_MATCHING_COMMAND_CONTRACT = lambda do include Stannum::RSpec::Matchers + include Cuprum::Collections::RSpec::Contracts::QueryContracts describe '#call' do + include_contract 'with query contexts' + shared_examples 'should return the matching items' do it { expect(result).to be_a_passing_result } @@ -30,8 +33,6 @@ module Cuprum::Collections::RSpec it { expect(result.value[collection_name]).to be == expected_data } end - include_contract Cuprum::Collections::RSpec::QUERYING_CONTEXTS - let(:options) do opts = {} @@ -94,7 +95,7 @@ module Cuprum::Collections::RSpec include_examples 'should return the matching items' - include_contract Cuprum::Collections::RSpec::QUERYING_CONTRACT, + include_contract 'should perform queries', block: lambda { include_examples 'should return the matching items' } @@ -115,7 +116,7 @@ module Cuprum::Collections::RSpec include_examples 'should return the wrapped items' - include_contract Cuprum::Collections::RSpec::QUERYING_CONTRACT, + include_contract 'should perform queries', block: lambda { include_examples 'should return the wrapped items' } @@ -126,7 +127,7 @@ module Cuprum::Collections::RSpec include_examples 'should return the matching items' - include_contract Cuprum::Collections::RSpec::QUERYING_CONTRACT, + include_contract 'should perform queries', block: lambda { include_examples 'should return the matching items' } @@ -136,7 +137,7 @@ module Cuprum::Collections::RSpec include_examples 'should return the wrapped items' - include_contract Cuprum::Collections::RSpec::QUERYING_CONTRACT, + include_contract 'should perform queries', block: lambda { include_examples 'should return the wrapped items' } diff --git a/lib/cuprum/collections/rspec/query_builder_contract.rb b/lib/cuprum/collections/rspec/query_builder_contract.rb deleted file mode 100644 index 37040d5..0000000 --- a/lib/cuprum/collections/rspec/query_builder_contract.rb +++ /dev/null @@ -1,92 +0,0 @@ -# frozen_string_literal: true - -require 'cuprum/collections/rspec' - -module Cuprum::Collections::RSpec - # Contract validating the behavior of a QueryBuilder implementation. - QUERY_BUILDER_CONTRACT = lambda do - describe '#base_query' do - include_examples 'should define reader', :base_query, -> { base_query } - end - - describe '#call' do - let(:criteria) { [['title', :equal, 'The Naked Sun']] } - let(:expected) { criteria } - let(:filter) { { title: 'The Naked Sun' } } - let(:strategy) { :custom } - let(:parser) do - instance_double( - Cuprum::Collections::Queries::Parse, - call: Cuprum::Result.new(value: criteria) - ) - end - let(:query) do - builder.call(strategy: strategy, where: filter) - end - - before(:example) do - allow(Cuprum::Collections::Queries::Parse) - .to receive(:new) - .and_return(parser) - end - - it 'should define the method' do - expect(builder).to respond_to(:call) - .with(0).arguments - .and_keywords(:strategy, :where) - end - - it 'should parse the criteria' do - builder.call(strategy: strategy, where: filter) - - expect(parser) - .to have_received(:call) - .with(strategy: strategy, where: filter) - end - - it { expect(query).to be_a base_query.class } - - it { expect(query).not_to be base_query } - - it { expect(query.criteria).to be == expected } - - describe 'with strategy: :unsafe' do - let(:strategy) { :unsafe } - let(:filter) { criteria } - - it 'should not parse the criteria' do - builder.call(strategy: strategy, where: filter) - - expect(parser).not_to have_received(:call) - end - - it { expect(query.criteria).to be == expected } - end - - context 'when the query has existing criteria' do - let(:old_criteria) { [['genre', :eq, 'Science Fiction']] } - let(:expected) { old_criteria + criteria } - let(:base_query) { super().send(:with_criteria, old_criteria) } - - it { expect(query.criteria).to be == expected } - end - - context 'when the parser is unable to parse the query' do - let(:error) { Cuprum::Error.new(message: 'Something went wrong.') } - let(:result) { Cuprum::Result.new(error: error) } - - before(:example) do - allow(parser).to receive(:call).and_return(result) - end - - it 'should raise an exception' do - expect do - builder.call(strategy: strategy, where: filter) - end - .to raise_error Cuprum::Collections::QueryBuilder::ParseError, - error.message - end - end - end - end -end diff --git a/lib/cuprum/collections/rspec/query_contract.rb b/lib/cuprum/collections/rspec/query_contract.rb deleted file mode 100644 index f6fa0bc..0000000 --- a/lib/cuprum/collections/rspec/query_contract.rb +++ /dev/null @@ -1,650 +0,0 @@ -# frozen_string_literal: true - -require 'cuprum/collections/rspec' -require 'cuprum/collections/rspec/fixtures' -require 'cuprum/collections/rspec/querying_contract' - -module Cuprum::Collections::RSpec # rubocop:disable Style/Documentation - default_operators = Cuprum::Collections::Queries::Operators.values - - # Contract validating the behavior of a Query implementation. - QUERY_CONTRACT = lambda do |operators: default_operators.freeze| - operators = Set.new(operators.map(&:to_sym)) - - include_contract Cuprum::Collections::RSpec::QUERYING_CONTEXTS - - shared_context 'when the query has composed filters' do - let(:scoped_query) do - super() - .where { { author: 'Ursula K. LeGuin' } } - .where { { series: not_equal('Earthsea') } } - end - let(:matching_data) do - super() - .select { |item| item['author'] == 'Ursula K. LeGuin' } - .reject { |item| item['series'] == 'Earthsea' } - end - end - - let(:scoped_query) do - # :nocov: - scoped = - if filter.is_a?(Proc) - query.where(&filter) - elsif !filter.nil? - query.where(filter) - else - query - end - # :nocov: - scoped = scoped.limit(limit) if limit - scoped = scoped.offset(offset) if offset - scoped = scoped.order(order) if order - - scoped - end - - it 'should be enumerable' do - expect(described_class).to be < Enumerable - end - - describe '#count' do - let(:data) { [] } - let(:matching_data) { data } - let(:expected_data) do - defined?(super()) ? super() : matching_data - end - - it { expect(query).to respond_to(:count).with(0).arguments } - - it { expect(query.count).to be == expected_data.count } - - wrap_context 'when the query has composed filters' do - it { expect(scoped_query.count).to be == expected_data.count } - end - - context 'when the collection data changes' do - let(:item) { BOOKS_FIXTURES.first } - - before(:example) do - query.count # Cache query results. - - add_item_to_collection(item) - end - - it { expect(query.count).to be == expected_data.count } - end - - context 'when the collection has many items' do - let(:data) { BOOKS_FIXTURES } - - it { expect(query.count).to be == expected_data.count } - - wrap_context 'when the query has composed filters' do - it { expect(scoped_query.count).to be == expected_data.count } - end - - context 'when the collection data changes' do - let(:data) { BOOKS_FIXTURES[0...-1] } - let(:item) { BOOKS_FIXTURES.last } - - before(:example) do - query.count # Cache query results. - - add_item_to_collection(item) - end - - it { expect(query.count).to be == expected_data.count } - end - end - end - - describe '#criteria' do - include_examples 'should have reader', :criteria, [] - - wrap_context 'when the query has where: a simple block filter' do - let(:expected) { [['author', :equal, 'Ursula K. LeGuin']] } - - it { expect(scoped_query.criteria).to be == expected } - end - - wrap_context 'when the query has where: a complex block filter' do - let(:expected) do - [ - ['author', :equal, 'Ursula K. LeGuin'], - ['series', :not_equal, 'Earthsea'] - ] - end - - if operators.include?(OPERATORS::EQUAL) && - operators.include?(OPERATORS::NOT_EQUAL) - it { expect(scoped_query.criteria).to be == expected } - else - # :nocov: - pending - # :nocov: - end - end - - wrap_context 'when the query has composed filters' do - let(:expected) do - [ - ['author', :equal, 'Ursula K. LeGuin'], - ['series', :not_equal, 'Earthsea'] - ] - end - - it { expect(scoped_query.criteria).to be == expected } - end - - wrap_context 'when the query has where: an equal block filter' do - let(:expected) { [['author', :equal, 'Ursula K. LeGuin']] } - - if operators.include?(OPERATORS::EQUAL) - it { expect(scoped_query.criteria).to be == expected } - else - # :nocov: - pending - # :nocov: - end - end - - wrap_context 'when the query has where: a not_equal block filter' do - let(:expected) { [['author', :not_equal, 'Ursula K. LeGuin']] } - - if operators.include?(OPERATORS::NOT_EQUAL) - it { expect(scoped_query.criteria).to be == expected } - else - # :nocov: - pending - # :nocov: - end - end - end - - describe '#each' do - shared_examples 'should enumerate the matching data' do - describe 'with no arguments' do - it { expect(scoped_query.each).to be_a Enumerator } - - it { expect(scoped_query.each.count).to be == matching_data.size } - - it { expect(scoped_query.each.to_a).to deep_match expected_data } - end - - describe 'with a block' do - it 'should yield each matching item' do - expect { |block| scoped_query.each(&block) } - .to yield_successive_args(*expected_data) - end - end - end - - let(:data) { [] } - let(:matching_data) { data } - let(:expected_data) do - defined?(super()) ? super() : matching_data - end - - it { expect(query).to respond_to(:each).with(0).arguments } - - include_examples 'should enumerate the matching data' - - include_contract Cuprum::Collections::RSpec::QUERYING_CONTRACT, - block: lambda { - include_examples 'should enumerate the matching data' - }, - operators: operators - - wrap_context 'when the query has composed filters' do - include_examples 'should enumerate the matching data' - end - - context 'when the collection data changes' do - let(:item) { BOOKS_FIXTURES.first } - - before(:example) do - query.each {} # Cache query results. - - add_item_to_collection(item) - end - - include_examples 'should enumerate the matching data' - end - - context 'when the collection has many items' do - let(:data) { BOOKS_FIXTURES } - - include_examples 'should enumerate the matching data' - - include_contract Cuprum::Collections::RSpec::QUERYING_CONTRACT, - block: lambda { - include_examples 'should enumerate the matching data' - }, - operators: operators - - wrap_context 'when the query has composed filters' do - include_examples 'should enumerate the matching data' - end - - context 'when the collection data changes' do - let(:data) { BOOKS_FIXTURES[0...-1] } - let(:item) { BOOKS_FIXTURES.last } - - before(:example) do - query.each {} # Cache query results. - - add_item_to_collection(item) - end - - include_examples 'should enumerate the matching data' - end - end - end - - describe '#exists?' do - shared_examples 'should check the existence of matching data' do - it { expect(query.exists?).to be == !matching_data.empty? } - end - - let(:data) { [] } - let(:matching_data) { data } - - include_examples 'should define predicate', :exists? - - include_examples 'should check the existence of matching data' - - include_contract Cuprum::Collections::RSpec::QUERYING_CONTRACT, - block: lambda { - include_examples 'should check the existence of matching data' - }, - operators: operators - - wrap_context 'when the query has composed filters' do - include_examples 'should check the existence of matching data' - end - - context 'when the collection has many items' do - let(:data) { BOOKS_FIXTURES } - - include_examples 'should check the existence of matching data' - - include_contract Cuprum::Collections::RSpec::QUERYING_CONTRACT, - block: lambda { - include_examples 'should check the existence of matching data' - }, - operators: operators - - wrap_context 'when the query has composed filters' do - include_examples 'should check the existence of matching data' - end - end - end - - describe '#limit' do - it { expect(query).to respond_to(:limit).with(0..1).arguments } - - describe 'with no arguments' do - it { expect(query.limit).to be nil } - end - - describe 'with nil' do - let(:error_message) { 'limit must be a non-negative integer' } - - it 'should raise an exception' do - expect { query.limit nil } - .to raise_error ArgumentError, error_message - end - end - - describe 'with an object' do - let(:error_message) { 'limit must be a non-negative integer' } - - it 'should raise an exception' do - expect { query.limit Object.new.freeze } - .to raise_error ArgumentError, error_message - end - end - - describe 'with a negative integer' do - let(:error_message) { 'limit must be a non-negative integer' } - - it 'should raise an exception' do - expect { query.limit(-1) } - .to raise_error ArgumentError, error_message - end - end - - describe 'with zero' do - it { expect(query.limit 0).to be_a described_class } - - it { expect(query.limit 0).not_to be query } - - it { expect(query.limit(0).limit).to be 0 } - end - - describe 'with a positive integer' do - it { expect(query.limit 3).to be_a described_class } - - it { expect(query.limit 3).not_to be query } - - it { expect(query.limit(3).limit).to be 3 } - end - end - - describe '#offset' do - it { expect(query).to respond_to(:offset).with(0..1).argument } - - describe 'with no arguments' do - it { expect(query.offset).to be nil } - end - - describe 'with nil' do - let(:error_message) { 'offset must be a non-negative integer' } - - it 'should raise an exception' do - expect { query.offset nil } - .to raise_error ArgumentError, error_message - end - end - - describe 'with an object' do - let(:error_message) { 'offset must be a non-negative integer' } - - it 'should raise an exception' do - expect { query.offset Object.new.freeze } - .to raise_error ArgumentError, error_message - end - end - - describe 'with a negative integer' do - let(:error_message) { 'offset must be a non-negative integer' } - - it 'should raise an exception' do - expect { query.offset(-1) } - .to raise_error ArgumentError, error_message - end - end - - describe 'with zero' do - it { expect(query.offset 0).to be_a described_class } - - it { expect(query.offset 0).not_to be query } - - it { expect(query.offset(0).offset).to be 0 } - end - - describe 'with a positive integer' do - it { expect(query.offset 3).to be_a described_class } - - it { expect(query.offset 3).not_to be query } - - it { expect(query.offset(3).offset).to be 3 } - end - end - - describe '#order' do - let(:default_order) { defined?(super()) ? super() : {} } - let(:error_message) do - 'order must be a list of attribute names and/or a hash of attribute ' \ - 'names with values :asc or :desc' - end - - it 'should define the method' do - expect(query) - .to respond_to(:order) - .with(0).arguments - .and_unlimited_arguments - end - - it { expect(query).to have_aliased_method(:order).as(:order_by) } - - describe 'with no arguments' do - it { expect(query.order).to be == default_order } - end - - describe 'with a hash with invalid keys' do - it 'should raise an exception' do - expect { query.order({ nil => :asc }) } - .to raise_error ArgumentError, error_message - end - end - - describe 'with a hash with empty string keys' do - it 'should raise an exception' do - expect { query.order({ '' => :asc }) } - .to raise_error ArgumentError, error_message - end - end - - describe 'with a hash with empty symbol keys' do - it 'should raise an exception' do - expect { query.order({ '': :asc }) } - .to raise_error ArgumentError, error_message - end - end - - describe 'with a hash with nil value' do - it 'should raise an exception' do - expect { query.order({ title: nil }) } - .to raise_error ArgumentError, error_message - end - end - - describe 'with a hash with object value' do - it 'should raise an exception' do - expect { query.order({ title: Object.new.freeze }) } - .to raise_error ArgumentError, error_message - end - end - - describe 'with a hash with empty value' do - it 'should raise an exception' do - expect { query.order({ title: '' }) } - .to raise_error ArgumentError, error_message - end - end - - describe 'with a hash with invalid value' do - it 'should raise an exception' do - expect { query.order({ title: 'wibbly' }) } - .to raise_error ArgumentError, error_message - end - end - - describe 'with a valid ordering' do - let(:expected) do - { title: :asc } - end - - it { expect(query.order :title).to be_a described_class } - - it { expect(query.order :title).not_to be query } - - it { expect(query.order(:title).order).to be == expected } - end - end - - describe '#reset' do - let(:data) { [] } - let(:matching_data) { data } - let(:expected_data) do - defined?(super()) ? super() : matching_data - end - - it { expect(query).to respond_to(:reset).with(0).arguments } - - it { expect(query.reset).to be_a query.class } - - it { expect(query.reset).not_to be query } - - it { expect(query.reset.to_a).to be == query.to_a } - - context 'when the collection data changes' do - let(:item) { BOOKS_FIXTURES.first } - let(:matching_data) { [item] } - - before(:example) do - query.to_a # Cache query results. - - add_item_to_collection(item) - end - - it { expect(query.reset.count).to be expected_data.size } - - it { expect(query.reset.to_a).to deep_match expected_data } - end - - context 'when the collection has many items' do - let(:data) { BOOKS_FIXTURES } - - it { expect(query.reset).to be_a query.class } - - it { expect(query.reset).not_to be query } - - it { expect(query.reset.to_a).to be == query.to_a } - - context 'when the collection data changes' do - let(:data) { BOOKS_FIXTURES[0...-1] } - let(:item) { BOOKS_FIXTURES.last } - let(:matching_data) { [*data, item] } - - before(:example) do - query.to_a # Cache query results. - - add_item_to_collection(item) - end - - it { expect(query.reset.count).to be expected_data.size } - - it { expect(query.reset.to_a).to deep_match expected_data } - end - end - end - - describe '#to_a' do - let(:data) { [] } - let(:matching_data) { data } - let(:expected_data) do - defined?(super()) ? super() : matching_data - end - - it { expect(query).to respond_to(:to_a).with(0).arguments } - - it { expect(query.to_a).to deep_match expected_data } - - include_contract Cuprum::Collections::RSpec::QUERYING_CONTRACT, - block: lambda { - it { expect(scoped_query.to_a).to deep_match expected_data } - }, - operators: operators - - wrap_context 'when the query has composed filters' do - it { expect(scoped_query.to_a).to deep_match expected_data } - end - - context 'when the collection data changes' do - let(:item) { BOOKS_FIXTURES.first } - - before(:example) do - query.to_a # Cache query results. - - add_item_to_collection(item) - end - - it { expect(query.to_a).to deep_match expected_data } - end - - context 'when the collection has many items' do - let(:data) { BOOKS_FIXTURES } - - it { expect(query.to_a).to deep_match expected_data } - - include_contract Cuprum::Collections::RSpec::QUERYING_CONTRACT, - block: lambda { - it { expect(scoped_query.to_a).to deep_match expected_data } - }, - operators: operators - - wrap_context 'when the query has composed filters' do - it { expect(scoped_query.to_a).to deep_match expected_data } - end - - context 'when the collection data changes' do - let(:data) { BOOKS_FIXTURES[0...-1] } - let(:item) { BOOKS_FIXTURES.last } - - before(:example) do - query.to_a # Cache query results. - - add_item_to_collection(item) - end - - it { expect(query.to_a).to deep_match expected_data } - end - end - end - - describe '#where' do - let(:block) { -> { { title: 'The Caves of Steel' } } } - - it 'should define the method' do - expect(query) - .to respond_to(:where) - .with(0..1).arguments - .and_keywords(:strategy) - .and_a_block - end - - describe 'with no arguments' do - it { expect(query.where).to be_a described_class } - - it { expect(query.where).not_to be query } - end - - describe 'with a block' do - it { expect(query.where(&block)).to be_a described_class } - - it { expect(query.where(&block)).not_to be query } - end - - describe 'with a valid strategy' do - it 'should return a query instance' do - expect(query.where(strategy: :block, &block)).to be_a described_class - end - - it { expect(query.where(strategy: :block, &block)).not_to be query } - end - - describe 'with parameters that do not match a strategy' do - let(:error_class) { Cuprum::Collections::QueryBuilder::ParseError } - let(:error_message) { 'unable to parse query with strategy nil' } - - it 'should raise an exception' do - expect { query.where(%w[ichi ni san]) } - .to raise_error error_class, error_message - end - end - - describe 'with an invalid strategy' do - let(:error_class) { Cuprum::Collections::QueryBuilder::ParseError } - let(:error_message) { 'unable to parse query with strategy :random' } - - it 'should raise an exception' do - expect { query.where(strategy: :random) } - .to raise_error error_class, error_message - end - end - - describe 'with invalid parameters for a strategy' do - let(:error_class) { Cuprum::Collections::QueryBuilder::ParseError } - let(:error_message) { 'unable to parse query with strategy :block' } - - it 'should raise an exception' do - expect { query.where(strategy: :block) } - .to raise_error error_class, error_message - end - end - end - end -end diff --git a/lib/cuprum/collections/rspec/querying_contract.rb b/lib/cuprum/collections/rspec/querying_contract.rb deleted file mode 100644 index 05bfc17..0000000 --- a/lib/cuprum/collections/rspec/querying_contract.rb +++ /dev/null @@ -1,298 +0,0 @@ -# frozen_string_literal: true - -require 'cuprum/collections/queries' -require 'cuprum/collections/rspec' - -module Cuprum::Collections::RSpec - OPERATORS = Cuprum::Collections::Queries::Operators - private_constant :OPERATORS - - # Shared contexts for specs that define querying behavior. - QUERYING_CONTEXTS = lambda do - let(:filter) { nil } - let(:strategy) { nil } - let(:limit) { nil } - let(:offset) { nil } - let(:order) { nil } - - shared_context 'when the query has limit: value' do - let(:limit) { 3 } - let(:matching_data) { super()[0...limit] } - end - - shared_context 'when the query has offset: value' do - let(:offset) { 2 } - let(:matching_data) { super()[offset..] || [] } - end - - shared_context 'when the query has order: a simple ordering' do - let(:order) { :title } - let(:matching_data) { super().sort_by { |item| item['title'] } } - end - - shared_context 'when the query has order: a complex ordering' do - let(:order) do - { - author: :asc, - title: :desc - } - end - let(:matching_data) do - super().sort do |u, v| - cmp = u['author'] <=> v['author'] - - cmp.zero? ? (v['title'] <=> u['title']) : cmp - end - end - end - - shared_context 'when the query has where: a simple block filter' do - let(:filter) { -> { { author: 'Ursula K. LeGuin' } } } - let(:matching_data) do - super().select { |item| item['author'] == 'Ursula K. LeGuin' } - end - end - - shared_context 'when the query has where: a complex block filter' do - let(:filter) do - lambda do - { - author: equals('Ursula K. LeGuin'), - series: not_equal('Earthsea') - } - end - end - let(:matching_data) do - super() - .select { |item| item['author'] == 'Ursula K. LeGuin' } - .reject { |item| item['series'] == 'Earthsea' } - end - end - - shared_context 'when the query has where: a greater_than filter' do - let(:filter) { -> { { published_at: greater_than('1970-12-01') } } } - let(:matching_data) do - super().select { |item| item['published_at'] > '1970-12-01' } - end - end - - shared_context 'when the query has where: a greater_than_or_equal_to ' \ - 'filter' \ - do - let(:filter) do - -> { { published_at: greater_than_or_equal_to('1970-12-01') } } - end - let(:matching_data) do - super().select { |item| item['published_at'] >= '1970-12-01' } - end - end - - shared_context 'when the query has where: a less_than filter' do - let(:filter) { -> { { published_at: less_than('1970-12-01') } } } - let(:matching_data) do - super().select { |item| item['published_at'] < '1970-12-01' } - end - end - - shared_context 'when the query has where: a less_than_or_equal_to filter' do - let(:filter) do - -> { { published_at: less_than_or_equal_to('1970-12-01') } } - end - let(:matching_data) do - super().select { |item| item['published_at'] <= '1970-12-01' } - end - end - - shared_context 'when the query has where: an equal block filter' do - let(:filter) { -> { { author: equals('Ursula K. LeGuin') } } } - let(:matching_data) do - super().select { |item| item['author'] == 'Ursula K. LeGuin' } - end - end - - shared_context 'when the query has where: a not_equal block filter' do - let(:filter) { -> { { author: not_equal('Ursula K. LeGuin') } } } - let(:matching_data) do - super().reject { |item| item['author'] == 'Ursula K. LeGuin' } - end - end - - shared_context 'when the query has where: a not_one_of block filter' do - let(:filter) do - -> { { series: not_one_of(['Earthsea', 'The Lord of the Rings']) } } - end - let(:matching_data) do - super().reject do |item| - ['Earthsea', 'The Lord of the Rings'].include?(item['series']) - end - end - end - - shared_context 'when the query has where: a one_of block filter' do - let(:filter) do - -> { { series: one_of(['Earthsea', 'The Lord of the Rings']) } } - end - let(:matching_data) do - super().select do |item| - ['Earthsea', 'The Lord of the Rings'].include?(item['series']) - end - end - end - - shared_context 'when the query has multiple query options' do - let(:filter) { -> { { author: 'Ursula K. LeGuin' } } } - let(:strategy) { nil } - let(:order) { { title: :desc } } - let(:limit) { 2 } - let(:offset) { 1 } - let(:matching_data) do - super() - .select { |item| item['author'] == 'Ursula K. LeGuin' } - .sort { |u, v| v['title'] <=> u['title'] } - .slice(1, 2) || [] - end - end - end - - # Contract validating the behavior objects that perform queries. - QUERYING_CONTRACT = lambda do |block:, operators: OPERATORS.values| - wrap_context 'when the query has limit: value' do - instance_exec(&block) - end - - wrap_context 'when the query has offset: value' do - instance_exec(&block) - end - - wrap_context 'when the query has order: a simple ordering' do - instance_exec(&block) - end - - wrap_context 'when the query has order: a complex ordering' do - instance_exec(&block) - end - - context 'when the query has where: a block filter' do - context 'with a simple filter' do - include_context 'when the query has where: a simple block filter' - - instance_exec(&block) - end - - context 'with a complex filter' do - include_context 'when the query has where: a complex block filter' - - if operators.include?(OPERATORS::EQUAL) && - operators.include?(OPERATORS::NOT_EQUAL) - instance_exec(&block) - else - # :nocov: - pending - # :nocov: - end - end - - context 'with an equals filter' do - include_context 'when the query has where: an equal block filter' - - if operators.include?(OPERATORS::EQUAL) - instance_exec(&block) - else - # :nocov: - pending - # :nocov: - end - end - - context 'with a greater_than filter' do - include_context 'when the query has where: a greater_than filter' - - if operators.include?(OPERATORS::GREATER_THAN) - instance_exec(&block) - else - # :nocov: - pending - # :nocov: - end - end - - context 'with a greater_than_or_equal_to filter' do - include_context \ - 'when the query has where: a greater_than_or_equal_to filter' - - if operators.include?(OPERATORS::GREATER_THAN_OR_EQUAL_TO) - instance_exec(&block) - else - # :nocov: - pending - # :nocov: - end - end - - context 'with a less_than filter' do - include_context 'when the query has where: a less_than filter' - - if operators.include?(OPERATORS::LESS_THAN) - instance_exec(&block) - else - # :nocov: - pending - # :nocov: - end - end - - context 'with a less_than_or_equal_to filter' do - include_context \ - 'when the query has where: a less_than_or_equal_to filter' - - if operators.include?(OPERATORS::LESS_THAN_OR_EQUAL_TO) - instance_exec(&block) - else - # :nocov: - pending - # :nocov: - end - end - - context 'with a not_equal filter' do - include_context 'when the query has where: a not_equal block filter' - - if operators.include?(OPERATORS::NOT_EQUAL) - instance_exec(&block) - else - # :nocov: - pending - # :nocov: - end - end - - context 'with a not_one_of filter' do - include_context 'when the query has where: a not_one_of block filter' - - if operators.include?(OPERATORS::NOT_ONE_OF) - instance_exec(&block) - else - # :nocov: - pending - # :nocov: - end - end - - context 'with a one_of filter' do - include_context 'when the query has where: a one_of block filter' - - if operators.include?(OPERATORS::ONE_OF) - instance_exec(&block) - else - # :nocov: - pending - # :nocov: - end - end - end - - wrap_context 'when the query has multiple query options' do - instance_exec(&block) - end - end -end diff --git a/lib/cuprum/collections/rspec/repository_contract.rb b/lib/cuprum/collections/rspec/repository_contract.rb deleted file mode 100644 index 9e32ea4..0000000 --- a/lib/cuprum/collections/rspec/repository_contract.rb +++ /dev/null @@ -1,598 +0,0 @@ -# frozen_string_literal: true - -require 'rspec/sleeping_king_studios/contract' - -require 'cuprum/collections/rspec' - -module Cuprum::Collections::RSpec - # Contract validating the behavior of a Repository. - module RepositoryContract - extend RSpec::SleepingKingStudios::Contract - - # @!method apply(example_group) - # Adds the contract to the example group. - # - # @param abstract [Boolean] if true, the repository is abstract and does - # not define certain methods. Defaults to false. - # @param example_group [RSpec::Core::ExampleGroup] the example group to - # which the contract is applied. - # @param options [Hash] additional options for the contract. - # - # @option options collection_class [Class, String] the expected class for - # created collections. - # @option options entity_class [Class, String] the expected entity class. - contract do |abstract: false, **options| - shared_examples 'should create the collection' do - let(:configured_collection_class) do - return super() if defined?(super()) - - configured = options[:collection_class] - configured = Object.const_get(configured) if configured.is_a?(String) - - configured - end - let(:configured_entity_class) do - return super() if defined?(super()) - - # :nocov: - expected = - if collection_options.key?(:entity_class) - collection_options[:entity_class] - elsif options.key?(:entity_class) - options[:entity_class] - else - qualified_name - .split('/') - .then { |ary| [*ary[0...-1], tools.str.singularize(ary[-1])] } - .map { |str| tools.str.camelize(str) } - .join('::') - end - # :nocov: - expected = Object.const_get(expected) if expected.is_a?(String) - - expected - end - let(:configured_member_name) do - return super() if defined?(super()) - - tools.str.singularize(collection_name.to_s.split('/').last) - end - - def tools - SleepingKingStudios::Tools::Toolbelt.instance - end - - it 'should create the collection' do - create_collection(safe: false) - - expect(repository.key?(qualified_name)).to be true - end - - it 'should return the collection' do - collection = create_collection(safe: false) - - expect(collection).to be repository[qualified_name] - end - - it { expect(collection).to be_a configured_collection_class } - - it 'should set the entity class' do - expect(collection.entity_class).to be == configured_entity_class - end - - it 'should set the collection name' do - expect(collection.name).to be == collection_name.to_s - end - - it 'should set the member name' do - expect(collection.singular_name).to be == configured_member_name - end - - it 'should set the qualified name' do - expect(collection.qualified_name).to be == qualified_name - end - - it 'should set the collection options' do - expect(collection).to have_attributes( - primary_key_name: primary_key_name, - primary_key_type: primary_key_type - ) - end - end - - describe '#[]' do - let(:error_class) do - described_class::UndefinedCollectionError - end - let(:error_message) do - "repository does not define collection #{collection_name.inspect}" - end - - it { expect(repository).to respond_to(:[]).with(1).argument } - - describe 'with nil' do - let(:collection_name) { nil } - - it 'should raise an exception' do - expect { repository[collection_name] } - .to raise_error(error_class, error_message) - end - end - - describe 'with an object' do - let(:collection_name) { Object.new.freeze } - - it 'should raise an exception' do - expect { repository[collection_name] } - .to raise_error(error_class, error_message) - end - end - - describe 'with an invalid string' do - let(:collection_name) { 'invalid_name' } - - it 'should raise an exception' do - expect { repository[collection_name] } - .to raise_error(error_class, error_message) - end - end - - describe 'with an invalid symbol' do - let(:collection_name) { :invalid_name } - - it 'should raise an exception' do - expect { repository[collection_name] } - .to raise_error(error_class, error_message) - end - end - - wrap_context 'when the repository has many collections' do - describe 'with an invalid string' do - let(:collection_name) { 'invalid_name' } - - it 'should raise an exception' do - expect { repository[collection_name] } - .to raise_error(error_class, error_message) - end - end - - describe 'with an invalid symbol' do - let(:collection_name) { :invalid_name } - - it 'should raise an exception' do - expect { repository[collection_name] } - .to raise_error(error_class, error_message) - end - end - - describe 'with a valid string' do - let(:collection) { collections.values.first } - let(:collection_name) { collections.keys.first } - - it { expect(repository[collection_name]).to be collection } - end - - describe 'with a valid symbol' do - let(:collection) { collections.values.first } - let(:collection_name) { collections.keys.first.intern } - - it { expect(repository[collection_name]).to be collection } - end - end - end - - describe '#add' do - let(:error_class) do - described_class::InvalidCollectionError - end - let(:error_message) do - "#{collection.inspect} is not a valid collection" - end - - it 'should define the method' do - expect(repository) - .to respond_to(:add) - .with(1).argument - .and_keywords(:force) - end - - it 'should alias #add as #<<' do - expect(repository.method(:<<)).to be == repository.method(:add) - end - - describe 'with nil' do - let(:collection) { nil } - - it 'should raise an exception' do - expect { repository.add(collection) } - .to raise_error(error_class, error_message) - end - end - - describe 'with an object' do - let(:collection) { Object.new.freeze } - - it 'should raise an exception' do - expect { repository.add(collection) } - .to raise_error(error_class, error_message) - end - end - - describe 'with a collection' do - it { expect(repository.add(example_collection)).to be repository } - - it 'should add the collection to the repository' do - repository.add(example_collection) - - expect(repository[example_collection.qualified_name]) - .to be example_collection - end - - describe 'with force: true' do - it 'should add the collection to the repository' do - repository.add(example_collection, force: true) - - expect(repository[example_collection.qualified_name]) - .to be example_collection - end - end - - context 'when the collection already exists' do - let(:error_message) do - "collection #{example_collection.qualified_name} already exists" - end - - before(:example) do - allow(repository) - .to receive(:key?) - .with(example_collection.qualified_name) - .and_return(true) - end - - it 'should raise an exception' do - expect { repository.add(example_collection) } - .to raise_error( - described_class::DuplicateCollectionError, - error_message - ) - end - - it 'should not update the repository' do - begin - repository.add(example_collection) - rescue described_class::DuplicateCollectionError - # Do nothing. - end - - expect { repository[example_collection.qualified_name] } - .to raise_error( - described_class::UndefinedCollectionError, - 'repository does not define collection ' \ - "#{example_collection.qualified_name.inspect}" - ) - end - - describe 'with force: true' do - it 'should add the collection to the repository' do - repository.add(example_collection, force: true) - - expect(repository[example_collection.qualified_name]) - .to be example_collection - end - end - end - end - end - - describe '#create' do - let(:collection_name) { 'books' } - let(:qualified_name) { collection_name.to_s } - let(:primary_key_name) { 'id' } - let(:primary_key_type) { Integer } - let(:collection_options) { {} } - let(:collection) do - create_collection - - repository[qualified_name] - end - let(:error_message) do - "#{described_class.name} is an abstract class. Define a repository " \ - 'subclass and implement the #build_collection method.' - end - - def create_collection(force: false, safe: true, **options) - if safe - begin - repository.create(force: force, **collection_options, **options) - rescue StandardError - # Do nothing. - end - else - repository.create(force: force, **collection_options, **options) - end - end - - it 'should define the method' do - expect(repository) - .to respond_to(:create) - .with(0).arguments - .and_keywords(:collection_name, :entity_class, :force) - .and_any_keywords - end - - if abstract - it 'should raise an exception' do - expect { create_collection(safe: false) } - .to raise_error( - described_class::AbstractRepositoryError, - error_message - ) - end - - next - end - - describe 'with entity_class: a Class' do - let(:entity_class) { Book } - let(:collection_options) do - super().merge(entity_class: entity_class) - end - - include_examples 'should create the collection' - end - - describe 'with entity_class: a String' do - let(:entity_class) { 'Book' } - let(:collection_options) do - super().merge(entity_class: entity_class) - end - - include_examples 'should create the collection' - end - - describe 'with name: a String' do - let(:collection_name) { 'books' } - let(:collection_options) do - super().merge(name: collection_name) - end - - include_examples 'should create the collection' - end - - describe 'with name: a Symbol' do - let(:collection_name) { :books } - let(:collection_options) do - super().merge(name: collection_name) - end - - include_examples 'should create the collection' - end - - describe 'with collection options' do - let(:primary_key_name) { 'uuid' } - let(:primary_key_type) { String } - let(:collection_options) do - super().merge( - name: collection_name, - primary_key_name: primary_key_name, - primary_key_type: primary_key_type - ) - end - - include_examples 'should create the collection' - end - - context 'when the collection already exists' do - let(:collection_name) { 'books' } - let(:collection_options) do - super().merge(name: collection_name) - end - let(:error_message) do - "collection #{qualified_name} already exists" - end - - before { create_collection(old: true) } - - it 'should raise an exception' do - expect { create_collection(safe: false) } - .to raise_error( - described_class::DuplicateCollectionError, - error_message - ) - end - - it 'should not update the repository' do - create_collection(old: false) - - collection = repository[qualified_name] - - expect(collection.options[:old]).to be true - end - - describe 'with force: true' do - it 'should update the repository' do - create_collection(force: true, old: false) - - collection = repository[qualified_name] - - expect(collection.options[:old]).to be false - end - end - end - end - - describe '#find_or_create' do - let(:collection_name) { 'books' } - let(:qualified_name) { collection_name.to_s } - let(:primary_key_name) { 'id' } - let(:primary_key_type) { Integer } - let(:collection_options) { {} } - let(:collection) do - create_collection - - repository[qualified_name] - end - let(:error_message) do - "#{described_class.name} is an abstract class. Define a repository " \ - 'subclass and implement the #build_collection method.' - end - - def create_collection(safe: true, **options) - if safe - begin - repository.find_or_create(**collection_options, **options) - rescue StandardError - # Do nothing. - end - else - repository.find_or_create(**collection_options, **options) - end - end - - it 'should define the method' do - expect(repository) - .to respond_to(:find_or_create) - .with(0).arguments - .and_keywords(:entity_class) - .and_any_keywords - end - - if abstract - let(:collection_options) { { name: collection_name } } - - it 'should raise an exception' do - expect { create_collection(safe: false) } - .to raise_error( - described_class::AbstractRepositoryError, - error_message - ) - end - - next - end - - describe 'with entity_class: a Class' do - let(:entity_class) { Book } - let(:collection_options) do - super().merge(entity_class: entity_class) - end - - include_examples 'should create the collection' - end - - describe 'with entity_class: a String' do - let(:entity_class) { Book } - let(:collection_options) do - super().merge(entity_class: entity_class) - end - - include_examples 'should create the collection' - end - - describe 'with name: a String' do - let(:collection_name) { 'books' } - let(:collection_options) do - super().merge(name: collection_name) - end - - include_examples 'should create the collection' - end - - describe 'with name: a Symbol' do - let(:collection_name) { :books } - let(:collection_options) do - super().merge(name: collection_name) - end - - include_examples 'should create the collection' - end - - describe 'with collection options' do - let(:primary_key_name) { 'uuid' } - let(:primary_key_type) { String } - let(:qualified_name) { 'spec/scoped_books' } - let(:collection_options) do - super().merge( - name: collection_name, - primary_key_name: primary_key_name, - primary_key_type: primary_key_type, - qualified_name: qualified_name - ) - end - - include_examples 'should create the collection' - end - - context 'when the collection already exists' do - let(:collection_name) { 'books' } - let(:collection_options) do - super().merge(name: collection_name) - end - let(:error_message) do - "collection #{qualified_name} already exists" - end - - before { create_collection(old: true) } - - describe 'with non-matching options' do - it 'should raise an exception' do - expect { create_collection(old: false, safe: false) } - .to raise_error( - described_class::DuplicateCollectionError, - error_message - ) - end - - it 'should not update the repository' do - create_collection(old: false) - - collection = repository[qualified_name] - - expect(collection.options[:old]).to be true - end - end - - describe 'with matching options' do - it 'should return the collection' do - collection = create_collection(old: true) - - expect(collection.options[:old]).to be true - end - end - end - end - - describe '#key?' do - it { expect(repository).to respond_to(:key?).with(1).argument } - - it { expect(repository.key? nil).to be false } - - it { expect(repository.key? Object.new.freeze).to be false } - - it { expect(repository.key? 'invalid_name').to be false } - - it { expect(repository.key? :invalid_name).to be false } - - wrap_context 'when the repository has many collections' do - it { expect(repository.key? 'invalid_name').to be false } - - it { expect(repository.key? :invalid_name).to be false } - - it { expect(repository.key? collections.keys.first).to be true } - - it 'should include the key' do - expect(repository.key? collections.keys.first.intern).to be true - end - end - end - - describe '#keys' do - include_examples 'should define reader', :keys, [] - - wrap_context 'when the repository has many collections' do - it { expect(repository.keys).to be == collections.keys } - end - end - end - end -end diff --git a/spec/cuprum/collections/basic/collection_spec.rb b/spec/cuprum/collections/basic/collection_spec.rb index e1d4aaf..78b1054 100644 --- a/spec/cuprum/collections/basic/collection_spec.rb +++ b/spec/cuprum/collections/basic/collection_spec.rb @@ -2,7 +2,7 @@ require 'cuprum/collections/basic/collection' require 'cuprum/collections/basic/commands' -require 'cuprum/collections/rspec/collection_contract' +require 'cuprum/collections/rspec/contracts/collection_contracts' require 'cuprum/collections/rspec/fixtures' require 'support/book' @@ -10,6 +10,8 @@ require 'support/scoped_book' RSpec.describe Cuprum::Collections::Basic::Collection do + include Cuprum::Collections::RSpec::Contracts::CollectionContracts + subject(:collection) do described_class.new( data: data, @@ -38,7 +40,7 @@ end end - include_contract Cuprum::Collections::RSpec::CollectionContract, + include_contract 'should be a collection', command_options: %i[data default_contract], commands_namespace: 'Cuprum::Collections::Basic::Commands', default_entity_class: Hash diff --git a/spec/cuprum/collections/basic/query_builder_spec.rb b/spec/cuprum/collections/basic/query_builder_spec.rb index a68d0f5..2fcaed7 100644 --- a/spec/cuprum/collections/basic/query_builder_spec.rb +++ b/spec/cuprum/collections/basic/query_builder_spec.rb @@ -1,9 +1,11 @@ # frozen_string_literal: true require 'cuprum/collections/basic/query_builder' -require 'cuprum/collections/rspec/query_builder_contract' +require 'cuprum/collections/rspec/contracts/query_contracts' RSpec.describe Cuprum::Collections::Basic::QueryBuilder do + include Cuprum::Collections::RSpec::Contracts::QueryContracts + shared_context 'when the query has criteria' do let(:base_query) do super().where do @@ -24,7 +26,7 @@ it { expect(described_class).to respond_to(:new).with(1).argument } end - include_contract Cuprum::Collections::RSpec::QUERY_BUILDER_CONTRACT + include_contract 'should be a query builder' describe '#call' do def match_item(expected) diff --git a/spec/cuprum/collections/basic/query_spec.rb b/spec/cuprum/collections/basic/query_spec.rb index b9a63bf..c871fb7 100644 --- a/spec/cuprum/collections/basic/query_spec.rb +++ b/spec/cuprum/collections/basic/query_spec.rb @@ -3,9 +3,11 @@ require 'sleeping_king_studios/tools/string_tools' require 'cuprum/collections/basic/query' -require 'cuprum/collections/rspec/query_contract' +require 'cuprum/collections/rspec/contracts/query_contracts' RSpec.describe Cuprum::Collections::Basic::Query do + include Cuprum::Collections::RSpec::Contracts::QueryContracts + subject(:query) { described_class.new(stringify_data(data)) } let(:data) { [] } @@ -27,5 +29,5 @@ def stringify_data(data) it { expect(described_class).to respond_to(:new).with(1).argument } end - include_contract Cuprum::Collections::RSpec::QUERY_CONTRACT + include_contract 'should be a query' end diff --git a/spec/cuprum/collections/basic/repository_spec.rb b/spec/cuprum/collections/basic/repository_spec.rb index 2267430..120d567 100644 --- a/spec/cuprum/collections/basic/repository_spec.rb +++ b/spec/cuprum/collections/basic/repository_spec.rb @@ -3,14 +3,14 @@ require 'cuprum/collections/basic/collection' require 'cuprum/collections/basic/repository' require 'cuprum/collections/rspec/fixtures' -require 'cuprum/collections/rspec/repository_contract' +require 'cuprum/collections/rspec/contracts/repository_contracts' require 'support/book' require 'support/grimoire' require 'support/scoped_book' RSpec.describe Cuprum::Collections::Basic::Repository do - include Cuprum::Collections::RSpec + include Cuprum::Collections::RSpec::Contracts::RepositoryContracts subject(:repository) { described_class.new(**constructor_options) } @@ -66,7 +66,7 @@ end end - include_contract Cuprum::Collections::RSpec::RepositoryContract, + include_contract 'should be a repository', collection_class: Cuprum::Collections::Basic::Collection, entity_class: Hash diff --git a/spec/cuprum/collections/collection_spec.rb b/spec/cuprum/collections/collection_spec.rb index 2f63e23..def3b6f 100644 --- a/spec/cuprum/collections/collection_spec.rb +++ b/spec/cuprum/collections/collection_spec.rb @@ -1,15 +1,14 @@ # frozen_string_literal: true require 'cuprum/collections/collection' -require 'cuprum/collections/rspec/collection_contract' -require 'cuprum/collections/rspec/contracts/relation_contracts' +require 'cuprum/collections/rspec/contracts/collection_contracts' require 'support/book' require 'support/grimoire' require 'support/scoped_book' RSpec.describe Cuprum::Collections::Collection do - include Cuprum::Collections::RSpec::Contracts::RelationContracts + include Cuprum::Collections::RSpec::Contracts::CollectionContracts subject(:collection) do described_class.new(**constructor_options) @@ -55,6 +54,5 @@ def call_method(**parameters) include_contract 'should validate the parameters' end - include_contract Cuprum::Collections::RSpec::CollectionContract, - abstract: true + include_contract 'should be a collection', abstract: true end diff --git a/spec/cuprum/collections/query_builder_spec.rb b/spec/cuprum/collections/query_builder_spec.rb index 4293c0d..48251bb 100644 --- a/spec/cuprum/collections/query_builder_spec.rb +++ b/spec/cuprum/collections/query_builder_spec.rb @@ -2,9 +2,11 @@ require 'cuprum/collections/query' require 'cuprum/collections/query_builder' -require 'cuprum/collections/rspec/query_builder_contract' +require 'cuprum/collections/rspec/contracts/query_contracts' RSpec.describe Cuprum::Collections::QueryBuilder do + include Cuprum::Collections::RSpec::Contracts::QueryContracts + subject(:builder) { described_class.new(base_query) } let(:base_query) { Cuprum::Collections::Query.new } @@ -19,5 +21,5 @@ it { expect(described_class).to respond_to(:new).with(1).argument } end - include_contract Cuprum::Collections::RSpec::QUERY_BUILDER_CONTRACT + include_contract 'should be a query builder' end diff --git a/spec/cuprum/collections/repository_spec.rb b/spec/cuprum/collections/repository_spec.rb index 96c2302..7c8c66b 100644 --- a/spec/cuprum/collections/repository_spec.rb +++ b/spec/cuprum/collections/repository_spec.rb @@ -2,12 +2,12 @@ require 'cuprum/collections/basic/collection' require 'cuprum/collections/repository' -require 'cuprum/collections/rspec/repository_contract' +require 'cuprum/collections/rspec/contracts/repository_contracts' require 'support/book' RSpec.describe Cuprum::Collections::Repository do - include Cuprum::Collections::RSpec + include Cuprum::Collections::RSpec::Contracts::RepositoryContracts subject(:repository) { described_class.new } @@ -101,6 +101,5 @@ it { expect(described_class).to respond_to(:new).with(0).arguments } end - include_contract Cuprum::Collections::RSpec::RepositoryContract, - abstract: true + include_contract 'should be a repository', abstract: true end