diff --git a/elasticsearch-persistence/.rspec b/elasticsearch-persistence/.rspec new file mode 100644 index 000000000..77d185827 --- /dev/null +++ b/elasticsearch-persistence/.rspec @@ -0,0 +1,2 @@ +--tty +--colour diff --git a/elasticsearch-persistence/Gemfile b/elasticsearch-persistence/Gemfile index a60150cf4..2f8e97b69 100644 --- a/elasticsearch-persistence/Gemfile +++ b/elasticsearch-persistence/Gemfile @@ -2,3 +2,8 @@ source 'https://rubygems.org' # Specify your gem's dependencies in elasticsearch-persistence.gemspec gemspec + +group :development, :testing do + gem 'rspec' + gem 'pry-nav' +end diff --git a/elasticsearch-persistence/Rakefile b/elasticsearch-persistence/Rakefile index 61038d08c..72084c44d 100644 --- a/elasticsearch-persistence/Rakefile +++ b/elasticsearch-persistence/Rakefile @@ -7,19 +7,22 @@ task :test => 'test:unit' # ----- Test tasks ------------------------------------------------------------ require 'rake/testtask' +require 'rspec/core/rake_task' + namespace :test do + Rake::TestTask.new(:unit) do |test| test.libs << 'lib' << 'test' - test.test_files = FileList["test/unit/**/*_test.rb"] + #test.test_files = FileList["test/unit/**/*_test.rb"] test.verbose = false test.warning = false end + RSpec::Core::RakeTask.new(:spec) Rake::TestTask.new(:integration) do |test| - test.libs << 'lib' << 'test' - test.test_files = FileList["test/integration/**/*_test.rb"] test.verbose = false test.warning = false + test.deps = [ :spec ] end Rake::TestTask.new(:all) do |test| diff --git a/elasticsearch-persistence/elasticsearch-persistence.gemspec b/elasticsearch-persistence/elasticsearch-persistence.gemspec index e90f2afb1..887c47194 100644 --- a/elasticsearch-persistence/elasticsearch-persistence.gemspec +++ b/elasticsearch-persistence/elasticsearch-persistence.gemspec @@ -23,8 +23,8 @@ Gem::Specification.new do |s| s.required_ruby_version = ">= 1.9.3" - s.add_dependency "elasticsearch", '~> 5' - s.add_dependency "elasticsearch-model", '~> 5' + s.add_dependency "elasticsearch", '>= 5' + s.add_dependency "elasticsearch-model", '>= 5' s.add_dependency "activesupport", '> 4' s.add_dependency "activemodel", '> 4' s.add_dependency "hashie" diff --git a/elasticsearch-persistence/lib/elasticsearch/persistence.rb b/elasticsearch-persistence/lib/elasticsearch/persistence.rb index 986ef1bc4..ce0d25b60 100644 --- a/elasticsearch-persistence/lib/elasticsearch/persistence.rb +++ b/elasticsearch-persistence/lib/elasticsearch/persistence.rb @@ -1,116 +1,8 @@ require 'hashie/mash' require 'elasticsearch' - -require 'elasticsearch/model/hash_wrapper' -require 'elasticsearch/model/indexing' -require 'elasticsearch/model/searching' - -require 'active_support/inflector' +require 'elasticsearch/model' require 'elasticsearch/persistence/version' - -require 'elasticsearch/persistence/client' -require 'elasticsearch/persistence/repository/response/results' -require 'elasticsearch/persistence/repository/naming' -require 'elasticsearch/persistence/repository/serialize' -require 'elasticsearch/persistence/repository/store' -require 'elasticsearch/persistence/repository/find' -require 'elasticsearch/persistence/repository/search' -require 'elasticsearch/persistence/repository/class' require 'elasticsearch/persistence/repository' - -module Elasticsearch - - # Persistence for Ruby domain objects and models in Elasticsearch - # =============================================================== - # - # `Elasticsearch::Persistence` contains modules for storing and retrieving Ruby domain objects and models - # in Elasticsearch. - # - # == Repository - # - # The repository patterns allows to store and retrieve Ruby objects in Elasticsearch. - # - # require 'elasticsearch/persistence' - # - # class Note - # def to_hash; {foo: 'bar'}; end - # end - # - # repository = Elasticsearch::Persistence::Repository.new - # - # repository.save Note.new - # # => {"_index"=>"repository", "_type"=>"note", "_id"=>"mY108X9mSHajxIy2rzH2CA", ...} - # - # Customize your repository by including the main module in a Ruby class - # class MyRepository - # include Elasticsearch::Persistence::Repository - # - # index 'my_notes' - # klass Note - # - # client Elasticsearch::Client.new log: true - # end - # - # repository = MyRepository.new - # - # repository.save Note.new - # # 2014-04-04 22:15:25 +0200: POST http://localhost:9200/my_notes/note [status:201, request:0.009s, query:n/a] - # # 2014-04-04 22:15:25 +0200: > {"foo":"bar"} - # # 2014-04-04 22:15:25 +0200: < {"_index":"my_notes","_type":"note","_id":"-d28yXLFSlusnTxb13WIZQ", ...} - # - # == Model - # - # The active record pattern allows to use the interface familiar from ActiveRecord models: - # - # require 'elasticsearch/persistence' - # - # class Article - # attribute :title, String, mapping: { analyzer: 'snowball' } - # end - # - # article = Article.new id: 1, title: 'Test' - # article.save - # - # Article.find(1) - # - # article.update_attributes title: 'Update' - # - # article.destroy - # - module Persistence - - # :nodoc: - module ClassMethods - - # Get or set the default client for all repositories and models - # - # @example Set and configure the default client - # - # Elasticsearch::Persistence.client Elasticsearch::Client.new host: 'http://localhost:9200', tracer: true - # - # @example Perform an API request through the client - # - # Elasticsearch::Persistence.client.cluster.health - # # => { "cluster_name" => "elasticsearch" ... } - # - def client client=nil - @client = client || @client || Elasticsearch::Client.new - end - - # Set the default client for all repositories and models - # - # @example Set and configure the default client - # - # Elasticsearch::Persistence.client = Elasticsearch::Client.new host: 'http://localhost:9200', tracer: true - # => # - # - def client=(client) - @client = client - end - end - - extend ClassMethods - end -end +require 'elasticsearch/persistence/repository/response/results' diff --git a/elasticsearch-persistence/lib/elasticsearch/persistence/client.rb b/elasticsearch-persistence/lib/elasticsearch/persistence/client.rb deleted file mode 100644 index 3c9d618d4..000000000 --- a/elasticsearch-persistence/lib/elasticsearch/persistence/client.rb +++ /dev/null @@ -1,51 +0,0 @@ -module Elasticsearch - module Persistence - module Repository - - # Wraps the Elasticsearch Ruby - # [client](https://github.com/elasticsearch/elasticsearch-ruby/tree/master/elasticsearch#usage) - # - module Client - - # Get or set the default client for this repository - # - # @example Set and configure the client for the repository class - # - # class MyRepository - # include Elasticsearch::Persistence::Repository - # client Elasticsearch::Client.new host: 'http://localhost:9200', log: true - # end - # - # @example Set and configure the client for this repository instance - # - # repository.client Elasticsearch::Client.new host: 'http://localhost:9200', tracer: true - # - # @example Perform an API request through the client - # - # MyRepository.client.cluster.health - # repository.client.cluster.health - # # => { "cluster_name" => "elasticsearch" ... } - # - def client client=nil - @client = client || @client || Elasticsearch::Persistence.client - end - - # Set the default client for this repository - # - # @example Set and configure the client for the repository class - # - # MyRepository.client = Elasticsearch::Client.new host: 'http://localhost:9200', log: true - # - # @example Set and configure the client for this repository instance - # - # repository.client = Elasticsearch::Client.new host: 'http://localhost:9200', tracer: true - # - def client=(client) - @client = client - @client - end - end - - end - end -end diff --git a/elasticsearch-persistence/lib/elasticsearch/persistence/repository.rb b/elasticsearch-persistence/lib/elasticsearch/persistence/repository.rb index f6a202029..c42dc56d9 100644 --- a/elasticsearch-persistence/lib/elasticsearch/persistence/repository.rb +++ b/elasticsearch-persistence/lib/elasticsearch/persistence/repository.rb @@ -1,77 +1 @@ -module Elasticsearch - module Persistence - - # Delegate methods to the repository (acting as a gateway) - # - module GatewayDelegation - def method_missing(method_name, *arguments, &block) - gateway.respond_to?(method_name) ? gateway.__send__(method_name, *arguments, &block) : super - end - - def respond_to?(method_name, include_private=false) - gateway.respond_to?(method_name) || super - end - - def respond_to_missing?(method_name, *) - gateway.respond_to?(method_name) || super - end - end - - # When included, creates an instance of the {Repository::Class Repository} class as a "gateway" - # - # @example Include the repository in a custom class - # - # require 'elasticsearch/persistence' - # - # class MyRepository - # include Elasticsearch::Persistence::Repository - # end - # - module Repository - def self.included(base) - gateway = Elasticsearch::Persistence::Repository::Class.new host: base - - # Define the instance level gateway - # - base.class_eval do - define_method :gateway do - @gateway ||= gateway - end - - include GatewayDelegation - end - - # Define the class level gateway - # - (class << base; self; end).class_eval do - define_method :gateway do |&block| - @gateway ||= gateway - @gateway.instance_eval(&block) if block - @gateway - end - - include GatewayDelegation - end - - # Catch repository methods (such as `serialize` and others) defined in the receiving class, - # and overload the default definition in the gateway - # - def base.method_added(name) - if :gateway != name && respond_to?(:gateway) && (gateway.public_methods - Object.public_methods).include?(name) - gateway.define_singleton_method(name, self.new.method(name).to_proc) - end - end - end - - # Shortcut method to allow concise repository initialization - # - # @example Create a new default repository - # - # repository = Elasticsearch::Persistence::Repository.new - # - def new(options={}, &block) - Elasticsearch::Persistence::Repository::Class.new( {index: 'repository'}.merge(options), &block ) - end; module_function :new - end - end -end +require 'elasticsearch/persistence/repository/base' diff --git a/elasticsearch-persistence/lib/elasticsearch/persistence/repository/base.rb b/elasticsearch-persistence/lib/elasticsearch/persistence/repository/base.rb new file mode 100644 index 000000000..aba9797a5 --- /dev/null +++ b/elasticsearch-persistence/lib/elasticsearch/persistence/repository/base.rb @@ -0,0 +1,274 @@ +require 'singleton' +require 'elasticsearch/persistence/repository/find' +require 'elasticsearch/persistence/repository/store' +require 'elasticsearch/persistence/repository/serialize' +require 'elasticsearch/persistence/repository/search' + +module Elasticsearch + module Persistence + module Repository + + # The base repository class. It can be used as it is or other classes can inherit from it + # and define their own settings and custom methods. + # + # @since 6.0.0 + class Base + include Singleton + include Repository::Find + include Repository::Store + include Repository::Serialize + include Repository::Search + extend Elasticsearch::Model::Indexing::ClassMethods + + class << self + + # Base methods for the class and single instance. + # + # @return [ Array ] The base methods. + # + # @since 6.0.0 + BASE_METHODS = [ :client, + :client=, + :index_name, + :index_name=, + :document_type, + :document_type=, + :klass, + :klass= ].freeze + + # Define each base method explicitly so that #method_missing does not have to be used + # each time the method is called. + # + BASE_METHODS.each do |_method| + class_eval("def #{_method}(*args); instance.send(__method__, *args); end", __FILE__, __LINE__) + end + + # Does the class or the single instance respond to the method. + # + # @return [ true, false ] If the class or the instance respond to the method. + # + # @since 6.0.0 + def respond_to_missing?(method, include_private = false) + super || instance.respond_to?(method) + end + + # Special behavior when a method is called that is not defined on the class. + # + # @since 6.0.0 + def method_missing(method, *args) + instance.send(method, *args) + end + end + + # The default index name. + # + # @return [ String ] The default repository name. + # + # @since 6.0.0 + DEFAULT_INDEX_NAME = 'repository'.freeze + + # The default document type. + # + # @return [ String ] The default document type. + # + # @note the document type will no longer be configurable in future versions + # of Elasticsearch. + # + # @since 6.0.0 + DEFAULT_DOC_TYPE = '_doc'.freeze + + # Set or get the client for the repository to use. + # If the class inherits from another Repository class, the closest ancestor's client + # will be returned. + # + # @example + # repository.client + # + # @example + # client = Elasticsearch::Client.new + # repository.client(client) + # + # @param [ Elasticsearch::Client ] _client The client to be used by this repository. + # + # @return [ Elasticsearch::Client ] The repository's client. + # + # @since 6.0.0 + def client(_client = nil) + return @client = _client if _client + return @client if @client + + if self.class.superclass.respond_to?(:client) + @client = self.class.superclass.send(:client) + else + @client ||= Elasticsearch::Client.new + end + end + + # Set the client for the repository to use. + # + # @example + # repository.client = Elasticsearch::Client.new + # + # @example + # repository.client = nil + # + # @param [ Elasticsearch::Client, nil ] _client The client to be used by this repository. + # Set it to nil if the repository should fallback to an ancestor's client. + # + # @return [ Elasticsearch::Client ] The repository's client. + # + # @since 6.0.0 + def client=(_client) + @client = _client + end + + # Set or get the index name for the repository. + # If the class inherits from another Repository class, the closest ancestor's index name + # will be returned. + # + # @example + # repository.index_name + # + # @example + # repository.index_name('my_repository') + # + # @param [ String ] name The name of the index. + # + # @return [ String ] The index name. + # + # @since 6.0.0 + def index_name(name = nil) + return @index_name = name if name + return @index_name if @index_name + + if self.class.superclass.respond_to?(:index_name) + @index_name = self.class.superclass.send(:index_name) + else + @index_name = DEFAULT_INDEX_NAME + end + end + + # Set the index name for the repository. + # + # @example + # repository.index_name = 'my_repository' + # + # @example + # repository.index_name = nil + # + # @param [ String, nil ] name The name of the index. Set to nil if the repository should + # fallback to an ancestor's index name. + # + # @return [ String ] The index name. + # + # @since 6.0.0 + def index_name=(name) + @index_name = name + end + + # Set or get the document type for the repository. + # If the class inherits from another Repository class, the closest ancestor's + # document type will be returned. + # + # @example + # repository.document_type + # + # @example + # repository.document_type('my_document_type') + # + # @note the document type will no longer be configurable in future versions + # of Elasticsearch and only one type can be used with a single index with + # Elasticsearch versions >= 6.0. + # + # @param [ String ] type The document type to use. + # + # @return [ String ] The document type. + # + # @since 6.0.0 + def document_type(type = nil) + return @document_type = type if type + return @document_type if @document_type + + if self.class.superclass.respond_to?(:document_type) + @document_type = self.class.superclass.send(:document_type) + else + @document_type = DEFAULT_DOC_TYPE + end + end + + # Set the document type for the repository. + # + # @example + # repository.document_type = 'my_document_type' + # + # @note the document type will no longer be configurable in future versions + # of Elasticsearch and only one type can be used with a single index with + # Elasticsearch versions >= 6.0. + # + # @param [ String, nil ] type The document type to use. Set to nil if the repository should + # fallback to an ancestor's document type. + # + # @return [ String ] The document type. + # + # @since 6.0.0 + def document_type=(type) + @document_type = type + end + + # Set or get the class to be used when deserializing documents. + # If the class inherits from another Repository class, the closest ancestor's + # class will be returned. The default is nil. + # + # @example + # repository.klass + # + # @example + # repository.klass(Note) + # + # @param [ class ] _class The class to use when deserializing documents from Elasticsearch. + # + # @return [ class ] The class. + # + # @since 6.0.0 + def klass(_class = nil) + return @klass = _class if _class + return @klass if @klass + + if self.class.superclass.respond_to?(:klass) + @klass = self.class.superclass.send(:klass) + end + end + + # Set the class to be used when deserializing documents. + # + # @example + # repository.klass = Note + # + # @param [ class ] _class The class to use when deserializing documents from Elasticsearch. + # + # @return [ class ] The class. + # + # @since 6.0.0 + def klass=(_class) + @klass = _class + end + + # Does the class or the single instance respond to the method. + # + # @return [ true, false ] If the class or the instance respond to the method. + # + # @since 6.0.0 + def respond_to_missing?(method, include_private = false) + super || self.class.respond_to?(method) + end + + # Special behavior when a method is called that is not defined on the single instance. + # + # @since 6.0.0 + def method_missing(method, *args) + self.class.send(method, *args) + end + end + end + end +end diff --git a/elasticsearch-persistence/lib/elasticsearch/persistence/repository/class.rb b/elasticsearch-persistence/lib/elasticsearch/persistence/repository/class.rb deleted file mode 100644 index 0dcd2d576..000000000 --- a/elasticsearch-persistence/lib/elasticsearch/persistence/repository/class.rb +++ /dev/null @@ -1,71 +0,0 @@ -module Elasticsearch - module Persistence - module Repository - - # The default repository class, to be used either directly, or as a gateway in a custom repository class - # - # @example Standalone use - # - # repository = Elasticsearch::Persistence::Repository::Class.new - # # => # - # repository.save(my_object) - # # => {"_index"=> ... } - # - # @example Shortcut use - # - # repository = Elasticsearch::Persistence::Repository.new - # # => # - # - # @example Configuration via a block - # - # repository = Elasticsearch::Persistence::Repository.new do - # index 'my_notes' - # end - # - # # => # - # # > repository.save(my_object) - # # => {"_index"=> ... } - # - # @example Accessing the gateway in a custom class - # - # class MyRepository - # include Elasticsearch::Persistence::Repository - # end - # - # repository = MyRepository.new - # - # repository.gateway.client.info - # # => {"status"=>200, "name"=>"Venom", ... } - # - class Class - include Elasticsearch::Persistence::Repository::Client - include Elasticsearch::Persistence::Repository::Naming - include Elasticsearch::Persistence::Repository::Serialize - include Elasticsearch::Persistence::Repository::Store - include Elasticsearch::Persistence::Repository::Find - include Elasticsearch::Persistence::Repository::Search - - include Elasticsearch::Model::Indexing::ClassMethods - - attr_reader :options - - def initialize(options={}, &block) - @options = options - index_name options.delete(:index) - block.arity < 1 ? instance_eval(&block) : block.call(self) if block_given? - end - - # Return the "host" class, if this repository is a gateway hosted in another class - # - # @return [nil, Class] - # - # @api private - # - def host - options[:host] - end - end - - end - end -end diff --git a/elasticsearch-persistence/lib/elasticsearch/persistence/repository/find.rb b/elasticsearch-persistence/lib/elasticsearch/persistence/repository/find.rb index 1ea0233fd..644fa2486 100644 --- a/elasticsearch-persistence/lib/elasticsearch/persistence/repository/find.rb +++ b/elasticsearch-persistence/lib/elasticsearch/persistence/repository/find.rb @@ -7,15 +7,23 @@ class DocumentNotFound < StandardError; end # module Find - # The key for accessing the document found and returned from an - # Elasticsearch _mget query. + # Base methods for the class and single repository instance. # - DOCS = 'docs'.freeze - - # The key for the boolean value indicating whether a particular id - # has been successfully found in an Elasticsearch _mget query. + # @return [ Array ] The base methods. # - FOUND = 'found'.freeze + # @since 6.0.0 + BASE_METHODS = [ :find, + :exists? ].freeze + + def self.included(base) + + # Define each base method explicitly so that #method_missing does not have to be used + # each time the method is called. + # + BASE_METHODS.each do |_method| + base.class_eval("def self.#{_method}(*args); instance.send(__method__, *args); end", __FILE__, __LINE__) + end + end # Retrieve a single object or multiple objects from Elasticsearch by ID or IDs # @@ -58,6 +66,18 @@ def exists?(id, options={}) client.exists(request.merge(options)) end + private + + # The key for accessing the document found and returned from an + # Elasticsearch _mget query. + # + DOCS = 'docs'.freeze + + # The key for the boolean value indicating whether a particular id + # has been successfully found in an Elasticsearch _mget query. + # + FOUND = 'found'.freeze + # @api private # def __find_one(id, options={}) @@ -75,7 +95,9 @@ def __find_many(ids, options={}) request = { index: index_name, body: { ids: ids } } request[:type] = document_type if document_type documents = client.mget(request.merge(options)) - documents[DOCS].map { |document| document[FOUND] ? deserialize(document) : nil } + documents[DOCS].map do |document| + deserialize(document) if document[FOUND] + end end end end diff --git a/elasticsearch-persistence/lib/elasticsearch/persistence/repository/naming.rb b/elasticsearch-persistence/lib/elasticsearch/persistence/repository/naming.rb deleted file mode 100644 index d559d8d51..000000000 --- a/elasticsearch-persistence/lib/elasticsearch/persistence/repository/naming.rb +++ /dev/null @@ -1,99 +0,0 @@ -module Elasticsearch - module Persistence - module Repository - - # Wraps all naming-related features of the repository (index name, the domain object class, etc) - # - module Naming - - # The possible keys for a document id. - # - IDS = [:id, 'id', :_id, '_id'].freeze - - DEFAULT_DOC_TYPE = '_doc'.freeze - - # Get or set the class used to initialize domain objects when deserializing them - # - def klass(name=nil) - if name - @klass = name - else - @klass - end - end - - # Set the class used to initialize domain objects when deserializing them - # - def klass=klass - @klass = klass - end - - # Get or set the index name used when storing and retrieving documents - # - def index_name name=nil - @index_name = name || @index_name || begin - if respond_to?(:host) && host && host.is_a?(Module) - self.host.to_s.underscore.gsub(/\//, '-') - else - self.class.to_s.underscore.gsub(/\//, '-') - end - end - end; alias :index :index_name - - # Set the index name used when storing and retrieving documents - # - def index_name=(name) - @index_name = name - end; alias :index= :index_name= - - # Get or set the document type used when storing and retrieving documents - # - def document_type name=nil - @document_type = name || @document_type || DEFAULT_DOC_TYPE - end; alias :type :document_type - - # Set the document type used when storing and retrieving documents - # - def document_type=(name) - @document_type = name - end; alias :type= :document_type= - - # Get a document ID from the document (assuming Hash or Hash-like object) - # - # @example - # repository.__get_id_from_document title: 'Test', id: 'abc123' - # => "abc123" - # - # @api private - # - def __get_id_from_document(document) - document[IDS.find { |id| document[id] }] - end - - # Extract a document ID from the document (assuming Hash or Hash-like object) - # - # @note Calling this method will *remove* the `id` or `_id` key from the passed object. - # - # @example - # options = { title: 'Test', id: 'abc123' } - # repository.__extract_id_from_document options - # # => "abc123" - # options - # # => { title: 'Test' } - # - # @api private - # - def __extract_id_from_document(document) - IDS.inject(nil) do |deleted, id| - if document[id] - document.delete(id) - else - deleted - end - end - end - end - - end - end -end diff --git a/elasticsearch-persistence/lib/elasticsearch/persistence/repository/search.rb b/elasticsearch-persistence/lib/elasticsearch/persistence/repository/search.rb index 07c0e4d3d..955a5317c 100644 --- a/elasticsearch-persistence/lib/elasticsearch/persistence/repository/search.rb +++ b/elasticsearch-persistence/lib/elasticsearch/persistence/repository/search.rb @@ -6,9 +6,23 @@ module Repository # module Search - # The key for accessing the count in a Elasticsearch query response. + # Base methods for the class and single repository instance. # - COUNT = 'count'.freeze + # @return [ Array ] The base methods. + # + # @since 6.0.0 + BASE_METHODS = [ :search, + :count ].freeze + + def self.included(base) + + # Define each base method explicitly so that #method_missing does not have to be used + # each time the method is called. + # + BASE_METHODS.each do |_method| + base.class_eval("def self.#{_method}(*args); instance.send(__method__, *args); end", __FILE__, __LINE__) + end + end # Returns a collection of domain objects by an Elasticsearch query # @@ -92,8 +106,13 @@ def count(query_or_definition=nil, options={}) response[COUNT] end - end + private + + # The key for accessing the count in a Elasticsearch query response. + # + COUNT = 'count'.freeze + end end end end diff --git a/elasticsearch-persistence/lib/elasticsearch/persistence/repository/serialize.rb b/elasticsearch-persistence/lib/elasticsearch/persistence/repository/serialize.rb index e9a8d875e..a740878b1 100644 --- a/elasticsearch-persistence/lib/elasticsearch/persistence/repository/serialize.rb +++ b/elasticsearch-persistence/lib/elasticsearch/persistence/repository/serialize.rb @@ -8,13 +8,45 @@ module Repository # module Serialize - # Error message raised when documents are attempted to be deserialized and no klass is defined for - # the Repository. + # Base methods for the class and single repository instance. + # + # @return [ Array ] The base methods. # # @since 6.0.0 - NO_CLASS_ERROR_MESSAGE = "No class is defined for deserializing documents. " + - "Please define a 'klass' for the Repository or define a custom " + - "deserialize method.".freeze + BASE_METHODS = [ :serialize, + :deserialize ].freeze + + def self.included(base) + + # Define each base method explicitly so that #method_missing does not have to be used + # each time the method is called. + # + BASE_METHODS.each do |_method| + base.class_eval("def self.#{_method}(*args); instance.send(__method__, *args); end", __FILE__, __LINE__) + end + end + + # Serialize the object for storing it in Elasticsearch + # + # In the default implementation, call the `to_hash` method on the passed object. + # + def serialize(document) + document.to_hash + end + + # Deserialize the document retrieved from Elasticsearch into a Ruby object + # + # Use the `klass` property, if defined, otherwise try to get the class from the document's `_type`. + # + # def deserialize(document) + # raise NameError.new(NO_CLASS_ERROR_MESSAGE) unless klass + # klass.new document[SOURCE] + # end + def deserialize(document) + klass ? klass.new(document[SOURCE]) : document[SOURCE] + end + + private # The key for document fields in an Elasticsearch query response. # @@ -26,21 +58,41 @@ module Serialize # TYPE = '_type'.freeze - # Serialize the object for storing it in Elasticsearch + IDS = [:id, 'id', :_id, '_id'].freeze + + # Get a document ID from the document (assuming Hash or Hash-like object) # - # In the default implementation, call the `to_hash` method on the passed object. + # @example + # repository.__get_id_from_document title: 'Test', id: 'abc123' + # => "abc123" # - def serialize(document) - document.to_hash + # @api private + # + def __get_id_from_document(document) + document[IDS.find { |id| document[id] }] end - # Deserialize the document retrieved from Elasticsearch into a Ruby object + # Extract a document ID from the document (assuming Hash or Hash-like object) # - # Use the `klass` property, if defined, otherwise try to get the class from the document's `_type`. + # @note Calling this method will *remove* the `id` or `_id` key from the passed object. # - def deserialize(document) - raise NameError.new(NO_CLASS_ERROR_MESSAGE) unless klass - klass.new document[SOURCE] + # @example + # options = { title: 'Test', id: 'abc123' } + # repository.__extract_id_from_document options + # # => "abc123" + # options + # # => { title: 'Test' } + # + # @api private + # + def __extract_id_from_document(document) + IDS.inject(nil) do |deleted, id| + if document[id] + document.delete(id) + else + deleted + end + end end end end diff --git a/elasticsearch-persistence/lib/elasticsearch/persistence/repository/store.rb b/elasticsearch-persistence/lib/elasticsearch/persistence/repository/store.rb index 2327d23a6..c49132b42 100644 --- a/elasticsearch-persistence/lib/elasticsearch/persistence/repository/store.rb +++ b/elasticsearch-persistence/lib/elasticsearch/persistence/repository/store.rb @@ -6,6 +6,25 @@ module Repository # module Store + # Base methods for the class and single repository instance. + # + # @return [ Array ] The base methods. + # + # @since 6.0.0 + BASE_METHODS = [ :save, + :update, + :delete ].freeze + + def self.included(base) + + # Define each base method explicitly so that #method_missing does not have to be used + # each time the method is called. + # + BASE_METHODS.each do |_method| + base.class_eval("def self.#{_method}(*args); instance.send(__method__, *args); end", __FILE__, __LINE__) + end + end + # Store the serialized object in Elasticsearch # # @example @@ -16,9 +35,12 @@ module Store # def save(document, options={}) serialized = serialize(document) - id = __get_id_from_document(serialized) - type = document_type - client.index( { index: index_name, type: type, id: id, body: serialized }.merge(options) ) + id = __get_id_from_document(serialized) + request = { index: index_name, + id: id, + body: serialized } + request[:type] = document_type if document_type + client.index(request.merge(options)) end # Update the serialized object in Elasticsearch with partial data or script @@ -35,36 +57,22 @@ def save(document, options={}) # # @return {Hash} The response from Elasticsearch # - def update(document, options={}) - case - when document.is_a?(String) || document.is_a?(Integer) - id = document - when document.respond_to?(:to_hash) - serialized = document.to_hash - id = __extract_id_from_document(serialized) - else - raise ArgumentError, "Expected a document ID or a Hash-like object, #{document.class} given" - end - - type = options.delete(:type) || \ - (defined?(serialized) && serialized && serialized.delete(:type)) || \ - document_type - - if defined?(serialized) && serialized - body = if serialized[:script] - serialized.select { |k, v| [:script, :params, :upsert].include? k } - else - { doc: serialized } - end + def update(document_or_id, options={}) + if document_or_id.is_a?(String) || document_or_id.is_a?(Integer) + id = document_or_id + body = options + type = document_type else - body = {} - body.update( doc: options.delete(:doc)) if options[:doc] - body.update( script: options.delete(:script)) if options[:script] - body.update( params: options.delete(:params)) if options[:params] - body.update( upsert: options.delete(:upsert)) if options[:upsert] + document = serialize(document_or_id) + id = __extract_id_from_document(document) + if options[:script] + body = options + else + body = { doc: document }.merge(options) + end + type = document.delete(:type) || document_type end - - client.update( { index: index_name, type: type, id: id, body: body }.merge(options) ) + client.update( { index: index_name, id: id, type: type, body: body } ) end # Remove the serialized object or document with specified ID from Elasticsearch diff --git a/elasticsearch-persistence/lib/elasticsearch/persistence/version.rb b/elasticsearch-persistence/lib/elasticsearch/persistence/version.rb index 862c815a6..fea31b99c 100644 --- a/elasticsearch-persistence/lib/elasticsearch/persistence/version.rb +++ b/elasticsearch-persistence/lib/elasticsearch/persistence/version.rb @@ -1,5 +1,5 @@ module Elasticsearch module Persistence - VERSION = "6.0.0" + VERSION = '6.0.0' end end diff --git a/elasticsearch-persistence/spec/repository/base_spec.rb b/elasticsearch-persistence/spec/repository/base_spec.rb new file mode 100644 index 000000000..2977da7d6 --- /dev/null +++ b/elasticsearch-persistence/spec/repository/base_spec.rb @@ -0,0 +1,638 @@ +require 'spec_helper' + +describe Elasticsearch::Persistence::Repository::Base do + + before do + class MyRepository < Elasticsearch::Persistence::Repository::Base; end + end + + after do + begin; Elasticsearch::Persistence::Repository::Base.delete_index!; rescue; end + Elasticsearch::Persistence::Repository::Base.client = DEFAULT_CLIENT + begin; MyRepository.delete_index!; rescue; end + Object.send(:remove_const, MyRepository.name) if defined?(MyRepository) + end + + shared_examples 'a base repository' do + + describe '#client' do + + context 'when client is not passed as an argument' do + + it 'returns the default client' do + expect(repository.client).to be_a(Elasticsearch::Transport::Client) + end + + context 'when the method is called more than once' do + + it 'returns the same client' do + expect(repository.client).to be(repository.client) + end + end + end + + context 'when a client is passed as an argument' do + + let(:new_client) do + Elasticsearch::Transport::Client.new + end + + before do + repository.client(new_client) + end + + it 'sets the client' do + expect(repository.client).to be(new_client) + end + + context 'when the method is called more than once' do + + it 'returns the same client' do + repository.client + expect(repository.client).to be(new_client) + end + end + + context 'when the client is nil' do + + before do + repository.client(nil) + end + + it 'does not set the client to nil' do + expect(repository.client).to be_a(Elasticsearch::Transport::Client) + end + end + end + end + + describe '#client=' do + + let(:new_client) do + Elasticsearch::Transport::Client.new + end + + before do + repository.client = new_client + end + + it 'sets the new client' do + expect(repository.client).to be(new_client) + end + + context 'when the client is set to nil' do + + before do + repository.client = nil + end + + it 'falls back to a default client' do + expect(repository.client).to be_a(Elasticsearch::Transport::Client) + end + end + end + + describe '#index_name' do + + context 'when name is not passed as an argument' do + + it 'returns the default index name' do + expect(repository.index_name).to eq('repository') + end + + context 'when the method is called more than once' do + + it 'returns the same index name' do + expect(repository.index_name).to be(repository.index_name) + end + end + end + + context 'when a name is passed as an argument' do + + let(:new_name) do + 'my_other_repository' + end + + before do + repository.index_name(new_name) + end + + it 'sets the index name' do + expect(repository.index_name).to eq(new_name) + end + + context 'when the method is called more than once' do + + it 'returns the same name' do + repository.index_name + expect(repository.index_name).to eq(new_name) + end + end + + context 'when the name is nil' do + + before do + repository.index_name(nil) + end + + it 'does not set the name to nil' do + expect(repository.index_name).to eq(new_name) + end + end + end + end + + describe '#index_name=' do + + let(:new_name) do + 'my_other_repository' + end + + before do + repository.index_name = new_name + end + + it 'sets the index name' do + expect(repository.index_name).to eq(new_name) + end + + context 'when the name is set to nil' do + + before do + repository.index_name = nil + end + + it 'falls back to the default repository name' do + expect(repository.index_name).to eq('repository') + end + end + end + + describe '#document_type' do + + context 'when type is not passed as an argument' do + + it 'returns the default document type' do + expect(repository.document_type).to eq('_doc') + end + + context 'when the method is called more than once' do + + it 'returns the same type' do + expect(repository.document_type).to be(repository.document_type) + end + end + end + + context 'when a type is passed as an argument' do + + let(:new_type) do + 'other_document_type' + end + + before do + repository.document_type(new_type) + end + + it 'sets the type' do + expect(repository.document_type).to be(new_type) + end + + context 'when the method is called more than once' do + + it 'returns the same type' do + repository.document_type + expect(repository.document_type).to be(new_type) + end + end + + context 'when the type is nil' do + + before do + repository.document_type(nil) + end + + it 'does not set the document_type to nil' do + expect(repository.document_type).to eq(new_type) + end + end + end + end + + describe '#document_type=' do + + let(:new_type) do + 'other_document_type' + end + + before do + repository.document_type = new_type + end + + it 'sets the new type' do + expect(repository.document_type).to be(new_type) + end + + context 'when the document type is set to nil' do + + before do + repository.document_type = nil + end + + it 'falls back to a default document type' do + expect(repository.document_type).to eq('_doc') + end + end + end + + describe '#klass' do + + context 'when class is not passed as an argument' do + + it 'returns nil' do + expect(repository.klass).to be_nil + end + end + + context 'when a class is passed as an argument' do + + let(:new_class) do + Hash + end + + before do + repository.klass(new_class) + end + + it 'sets the class' do + expect(repository.klass).to be(new_class) + end + + context 'when the method is called more than once' do + + it 'returns the same type' do + repository.klass + expect(repository.klass).to be(new_class) + end + end + + context 'when the class is nil' do + + before do + repository.klass(nil) + end + + it 'does not set the class to nil' do + expect(repository.klass).to eq(new_class) + end + end + end + end + + describe '#klass=' do + + let(:new_class) do + Hash + end + + before do + repository.klass = new_class + end + + it 'sets the new class' do + expect(repository.klass).to be(new_class) + end + + context 'when the class is set to nil' do + + before do + repository.klass = nil + end + + it 'sets the class to nil' do + expect(repository.klass).to be_nil + end + end + end + end + + shared_examples 'a singleton' do + + describe '#client' do + + context 'when the client is changed on the class' do + + let(:new_client) do + Elasticsearch::Transport::Client.new + end + + before do + repository.client(new_client) + end + + it 'applies to the singleton instance as well' do + expect(repository.instance.client).to be(new_client) + end + end + end + + describe '#index_name' do + + context 'when the index name is changed on the class' do + + let!(:new_name) do + 'my_other_repository' + end + + before do + repository.index_name(new_name) + end + + it 'applies to the singleton instance as well' do + expect(repository.instance.index_name).to be(new_name) + end + end + end + + describe '#document_type' do + + context 'when the document type is changed on the class' do + + let!(:new_type) do + 'my_other_document_type' + end + + before do + repository.document_type(new_type) + end + + it 'applies to the singleton instance as well' do + expect(repository.instance.document_type).to be(new_type) + end + end + end + + describe '#klass' do + + context 'when the klass is changed on the class' do + + let(:new_class) do + Hash + end + + before do + repository.klass = new_class + end + + it 'applies to the singleton instance as well' do + expect(repository.instance.klass).to be(new_class) + end + end + end + end + + describe 'inheritance' do + + context 'when the client is changed on the base repository' do + + let(:new_client) do + Elasticsearch::Transport::Client.new + end + + before do + Elasticsearch::Persistence::Repository::Base.client = new_client + end + + it 'it changes the client on a descendant repository' do + expect(Elasticsearch::Persistence::Repository::Base.client).to be(new_client) + expect(MyRepository.client).to be(new_client) + end + end + + context 'when the client is changed on a descendant repository' do + + let(:new_client) do + Elasticsearch::Transport::Client.new + end + + before do + MyRepository.client = new_client + end + + it 'it does not change the client on the base repository' do + expect(MyRepository.client).to be(new_client) + expect(Elasticsearch::Persistence::Repository::Base.client).not_to be(new_client) + end + end + + context 'when the base repository has a custom index name' do + + before do + Elasticsearch::Persistence::Repository::Base.index_name = 'other_index' + end + + after do + Elasticsearch::Persistence::Repository::Base.index_name = nil + end + + it 'it changes the index name on a descendant repository' do + expect(Elasticsearch::Persistence::Repository::Base.index_name).to eq('other_index') + expect(MyRepository.index_name).to eq('other_index') + end + + context 'when the descendant has a custom index name' do + + before do + MyRepository.index_name = 'my_other_index' + end + + it 'it applies the custom index name only on a descendant repository' do + expect(Elasticsearch::Persistence::Repository::Base.index_name).to eq('other_index') + expect(MyRepository.index_name).to eq('my_other_index') + end + + context 'when the index_name is reset' do + + before do + MyRepository.index_name = nil + end + + it 'falls back to the default index name' do + expect(MyRepository.index_name).to eq('other_index') + end + end + end + end + + context 'when there are multiple levels of inheritance' do + + context 'when the descendant does not have custom settings' do + + before do + MyRepository.index_name = 'my_repository' + class MyDescendantRepository < MyRepository; end + end + + after do + Object.send(:remove_const, MyDescendantRepository.name) if defined?(MyDescendantRepository) + end + + describe '#index_name' do + + it 'uses the index name of its immediate parent' do + expect(MyDescendantRepository.index_name).to eq('my_repository') + end + end + + describe '#client' do + + it 'uses the client of its immediate parent' do + expect(MyDescendantRepository.client).to be(MyRepository.client) + end + end + end + + context 'when the descendant has custom settings' do + + before do + MyRepository.index_name = 'my_repository' + class MyDescendantRepository < MyRepository + client Elasticsearch::Transport::Client.new + index_name 'my_descendant_repository' + end + end + + after do + Object.send(:remove_const, MyDescendantRepository.name) if defined?(MyDescendantRepository) + end + + describe '#index_name' do + + it 'uses its custom index_name' do + expect(MyDescendantRepository.index_name).to eq('my_descendant_repository') + end + end + + describe '#client' do + + it 'uses its custom client' do + expect(MyDescendantRepository.client).not_to be(MyRepository.client) + end + end + end + end + + context 'when the descendant defines its own methods' do + + context 'when the method is an instance method' do + + before do + class MyDescendantRepository < MyRepository + client Elasticsearch::Transport::Client.new + index_name 'my_descendant_repository' + + def custom_method + 'custom_value' + end + end + end + + after do + Object.send(:remove_const, MyDescendantRepository.name) if defined?(MyDescendantRepository) + end + + it 'allows the methods to be called on the class' do + expect(MyDescendantRepository.custom_method).to eq('custom_value') + end + + it 'allows the methods to be called on the singleton instance' do + expect(MyDescendantRepository.instance.custom_method).to eq('custom_value') + end + end + + context 'when the method is a class method' do + + before do + class MyDescendantRepository < MyRepository + client Elasticsearch::Transport::Client.new + index_name 'my_descendant_repository' + + def self.custom_method + 'custom_value' + end + end + end + + after do + Object.send(:remove_const, MyDescendantRepository.name) if defined?(MyDescendantRepository) + end + + it 'allows the methods to be called on the class' do + expect(MyDescendantRepository.custom_method).to eq('custom_value') + end + + it 'allows the methods to be called on the singleton instance' do + expect(MyDescendantRepository.instance.custom_method).to eq('custom_value') + end + end + end + + context 'when an anonymous class is defined' do + + let(:repository) do + Class.new(Elasticsearch::Persistence::Repository::Base) + end + + it_behaves_like 'a base repository' + it_behaves_like 'a singleton' + + context 'when a block is passed to the class definition' do + + let(:repository) do + Class.new(Elasticsearch::Persistence::Repository::Base) do + document_type 'other_type' + index_name 'other_name' + client 'client' + klass Hash + end + end + + it 'allows the document type to be set in the block' do + expect(repository.document_type).to eq('other_type') + end + + it 'allows the index name to be set in the block' do + expect(repository.index_name).to eq('other_name') + end + + it 'allows the client to be set in the block' do + expect(repository.client).to eq('client') + end + + it 'allows the class to be set in the block' do + expect(repository.klass).to eq(Hash) + end + end + end + end + + context 'when methods are called on the class' do + + let(:repository) do + MyRepository + end + + it_behaves_like 'a base repository' + it_behaves_like 'a singleton' + end + + context 'when methods are called on the class instance' do + + let(:repository) do + MyRepository.instance + end + + it_behaves_like 'a base repository' + end +end diff --git a/elasticsearch-persistence/spec/repository/find_spec.rb b/elasticsearch-persistence/spec/repository/find_spec.rb new file mode 100644 index 000000000..9c2becf6c --- /dev/null +++ b/elasticsearch-persistence/spec/repository/find_spec.rb @@ -0,0 +1,175 @@ +require 'spec_helper' + +describe Elasticsearch::Persistence::Repository::Find do + + let(:repository) do + Elasticsearch::Persistence::Repository::Base + end + + after do + begin; Elasticsearch::Persistence::Repository::Base.delete_index!; rescue; end + begin; MyRepository.delete_index!; rescue; end + Object.send(:remove_const, MyRepository.name) if defined?(MyRepository) + end + + describe '#exists?' do + + context 'when the document exists' do + + let(:id) do + repository.save(a: 1)['_id'] + end + + it 'returns true' do + expect(repository.exists?(id)).to be(true) + end + end + + context 'when the document does not exist' do + + it 'returns false' do + expect(repository.exists?('testing')).to be(false) + end + end + + context 'when options are provided' do + + let(:id) do + repository.save(a: 1)['_id'] + end + + it 'applies the options' do + expect(repository.exists?(id, type: 'other_type')).to be(false) + end + end + end + + describe '#find' do + + context 'when options are not provided' do + + context 'when a single id is passed' do + + let!(:id) do + repository.save(a: 1)['_id'] + end + + it 'retrieves the document' do + expect(repository.find(id)).to eq('a' => 1) + end + end + + context 'when an array of ids is passed' do + + let!(:ids) do + 3.times.collect do |i| + repository.save(a: i)['_id'] + end + end + + it 'retrieves the documents' do + expect(repository.find(ids)).to eq([{ 'a' =>0 }, + { 'a' => 1 }, + { 'a' => 2 }]) + end + + context 'when some documents are found and some are not' do + + before do + ids[1] = 22 + ids + end + + it 'nil is returned in the result list for the documents not found' do + expect(repository.find(ids)).to eq([{ 'a' =>0 }, + nil, + { 'a' => 2 }]) + end + end + end + + context 'when multiple ids is passed' do + + let!(:ids) do + 3.times.collect do |i| + repository.save(a: i)['_id'] + end + end + + it 'retrieves the documents' do + expect(repository.find(*ids)).to eq([{ 'a' =>0 }, + { 'a' => 1 }, + { 'a' => 2 }]) + end + + + end + + context 'when the document cannot be found' do + + before do + begin; repository.create_index!; rescue; end + end + + it 'raises a DocumentNotFound exception' do + expect { + repository.find(1) + }.to raise_exception(Elasticsearch::Persistence::Repository::DocumentNotFound) + end + end + end + + context 'when options are provided' do + + context 'when a single id is passed' do + + let!(:id) do + repository.save(a: 1)['_id'] + end + + it 'applies the options' do + expect { + repository.find(id, type: 'none') + }.to raise_exception(Elasticsearch::Persistence::Repository::DocumentNotFound) + end + end + + context 'when an array of ids is passed' do + + let!(:ids) do + 3.times.collect do |i| + repository.save(a: i)['_id'] + end + end + + it 'applies the options' do + expect(repository.find(ids, type: 'none')).to eq([nil, nil, nil]) + end + end + end + + context 'when a document_type is defined on the class' do + + let(:repository) do + class MyRepository < Elasticsearch::Persistence::Repository::Base + document_type 'other_type' + client Elasticsearch::Persistence::Repository::Base.client + end + MyRepository.create_index!(force: true) + MyRepository + end + + let!(:ids) do + 3.times.collect do |i| + repository.save(a: i)['_id'] + end + end + + it 'uses the document type in the query' do + expect(repository.find(ids)).to eq([{ 'a' =>0 }, + { 'a' => 1 }, + { 'a' => 2 }]) + end + end + end +end \ No newline at end of file diff --git a/elasticsearch-persistence/spec/repository/search_spec.rb b/elasticsearch-persistence/spec/repository/search_spec.rb new file mode 100644 index 000000000..b84cb12f6 --- /dev/null +++ b/elasticsearch-persistence/spec/repository/search_spec.rb @@ -0,0 +1,160 @@ +require 'spec_helper' + +describe Elasticsearch::Persistence::Repository::Search do + + let(:repository) do + Elasticsearch::Persistence::Repository::Base + end + + after do + begin; Elasticsearch::Persistence::Repository::Base.delete_index!; rescue; end + begin; MyRepository.delete_index!; rescue; end + Object.send(:remove_const, MyRepository.name) if defined?(MyRepository) + end + + describe '#search' do + + context 'when the repository does not have a type set' do + + before do + repository.save({ name: 'emily' }, refresh: true) + end + + context 'when a query definition is provided as a hash' do + + it 'uses the default document type' do + expect(repository.search({ query: { match: { name: 'emily' } } }).first).to eq('name' => 'emily') + end + end + + context 'when a query definition is provided as a string' do + + it 'uses the default document type' do + expect(repository.search('emily').first).to eq('name' => 'emily') + end + end + + context 'when options are provided' do + + context 'when a query definition is provided as a hash' do + + it 'uses the default document type' do + expect(repository.search({ query: { match: { name: 'emily' } } }, type: 'other').first).to be_nil + end + end + + context 'when a query definition is provided as a string' do + + it 'uses the default document type' do + expect(repository.search('emily', type: 'other').first).to be_nil + end + end + end + end + + context 'when the repository does have a type set' do + + before do + class MyRepository < Elasticsearch::Persistence::Repository::Base + document_type 'other_type' + client Elasticsearch::Persistence::Repository::Base.client + end + MyRepository.save({ name: 'emily' }, refresh: true) + end + + let(:repository) do + Elasticsearch::Persistence::Repository::Base + end + + context 'when options are provided' do + + context 'when a query definition is provided as a hash' do + + it 'uses the default document type' do + expect(repository.search({ query: { match: { name: 'emily' } } }, type: 'other_type').first).to eq('name' => 'emily') + end + end + + context 'when a query definition is provided as a string' do + + it 'uses the default document type' do + expect(repository.search('emily', type: 'other_type').first).to eq('name' => 'emily') + end + end + end + end + end + + describe '#count' do + + context 'when the repository does not have a type set' do + + before do + repository.save({ name: 'emily' }, refresh: true) + end + + context 'when a query definition is provided as a hash' do + + it 'uses the default document type' do + expect(repository.count({ query: { match: { name: 'emily' } } })).to eq(1) + end + end + + context 'when a query definition is provided as a string' do + + it 'uses the default document type' do + expect(repository.count('emily')).to eq(1) + end + end + + context 'when options are provided' do + + context 'when a query definition is provided as a hash' do + + it 'uses the default document type' do + expect(repository.count({ query: { match: { name: 'emily' } } }, type: 'other')).to eq(0) + end + end + + context 'when a query definition is provided as a string' do + + it 'uses the default document type' do + expect(repository.count('emily', type: 'other')).to eq(0) + end + end + end + end + + context 'when the repository does have a type set' do + + before do + class MyRepository < Elasticsearch::Persistence::Repository::Base + document_type 'other_type' + client Elasticsearch::Persistence::Repository::Base.client + end + MyRepository.save({ name: 'emily' }, refresh: true) + end + + let(:repository) do + Elasticsearch::Persistence::Repository::Base + end + + context 'when options are provided' do + + context 'when a query definition is provided as a hash' do + + it 'uses the default document type' do + expect(repository.count({ query: { match: { name: 'emily' } } }, type: 'other_type')).to eq(1) + end + end + + context 'when a query definition is provided as a string' do + + it 'uses the default document type' do + expect(repository.count('emily', type: 'other_type')).to eq(1) + end + end + end + end + end +end diff --git a/elasticsearch-persistence/spec/repository/serialize_spec.rb b/elasticsearch-persistence/spec/repository/serialize_spec.rb new file mode 100644 index 000000000..d4ccddad6 --- /dev/null +++ b/elasticsearch-persistence/spec/repository/serialize_spec.rb @@ -0,0 +1,65 @@ +require 'spec_helper' + +describe Elasticsearch::Persistence::Repository::Serialize do + + let(:repository) do + Elasticsearch::Persistence::Repository::Base + end + + after do + begin; Elasticsearch::Persistence::Repository::Base.delete_index!; rescue; end + begin; MyRepository.delete_index!; rescue; end + Object.send(:remove_const, MyRepository.name) if defined?(MyRepository) + end + + describe '#serialize' do + + before do + class MyDocument + def to_hash + { a: 1 } + end + end + end + + it 'calls #to_hash on the object' do + expect(repository.serialize(MyDocument.new)).to eq(a: 1) + end + end + + describe '#deserialize' do + + context 'when klass is defined on the Repository' do + + before do + require 'set' + class MyRepository < Elasticsearch::Persistence::Repository::Base + klass Set + end + end + + it 'instantiates an object of the klass' do + expect(MyRepository.deserialize('_source' => { a: 1 })).to be_a(Set) + end + + it 'uses the source field to instantiate the object' do + expect(MyRepository.deserialize('_source' => { a: 1 })).to eq(Set.new({ a: 1})) + end + end + + context 'when klass is not defined on the Repository' do + + before do + class MyRepository < Elasticsearch::Persistence::Repository::Base; end + end + + it 'returns the raw Hash' do + expect(MyRepository.deserialize('_source' => { a: 1 })).to be_a(Hash) + end + + it 'uses the source field to instantiate the object' do + expect(MyRepository.deserialize('_source' => { a: 1 })).to eq(a: 1) + end + end + end +end diff --git a/elasticsearch-persistence/spec/repository/store_spec.rb b/elasticsearch-persistence/spec/repository/store_spec.rb new file mode 100644 index 000000000..f6b112184 --- /dev/null +++ b/elasticsearch-persistence/spec/repository/store_spec.rb @@ -0,0 +1,321 @@ +require 'spec_helper' + +describe Elasticsearch::Persistence::Repository::Search do + + let(:repository) do + Elasticsearch::Persistence::Repository::Base + end + + after do + begin; Elasticsearch::Persistence::Repository::Base.delete_index!; rescue; end + if defined?(MyRepository) + begin; MyRepository.delete_index!; rescue; end + Object.send(:remove_const, MyRepository.name) + end + end + + describe '#save' do + + let(:document) do + { a: 1 } + end + + let(:response) do + repository.save(document) + end + + it 'saves the document' do + expect(repository.find(response['_id'])).to eq('a' => 1) + end + + context 'when the repository defines a custom serialize method' do + + before do + class MyRepository < Elasticsearch::Persistence::Repository::Base + client Elasticsearch::Persistence::Repository::Base.client + def serialize(document) + { b: 1 } + end + end + end + + let(:response) do + MyRepository.save(document) + end + + it 'saves the document' do + expect(repository.find(response['_id'])).to eq('b' => 1) + end + end + + context 'when options are provided' do + + let(:response) do + repository.save(document, type: 'other_type') + end + + it 'saves the document using the options' do + expect { + repository.find(response['_id']) + }.to raise_exception(Elasticsearch::Persistence::Repository::DocumentNotFound) + expect(repository.find(response['_id'], type: 'other_type')).to eq('a' => 1) + end + end + end + + describe '#update' do + + before do + class Note + def to_hash + { text: 'testing', views: 0 } + end + end + end + + after do + if defined?(Note) + Object.send(:remove_const, :Note) + end + end + + context 'when the document exists' do + + let!(:id) do + repository.save(Note.new)['_id'] + end + + context 'when an id is provided' do + + context 'when a doc is specified in the options' do + + before do + repository.update(id, doc: { text: 'testing_2' }) + end + + it 'updates using the doc parameter' do + expect(repository.find(id)).to eq('text' => 'testing_2', 'views' => 0) + end + end + + context 'when a script is specified in the options' do + + before do + repository.update(id, script: { inline: 'ctx._source.views += 1' }) + end + + it 'updates using the script parameter' do + expect(repository.find(id)).to eq('text' => 'testing', 'views' => 1) + end + end + + context 'when params are specified in the options' do + + before do + repository.update(id, script: { inline: 'ctx._source.views += params.count', + params: { count: 2 } }) + end + + it 'updates using the script parameter' do + expect(repository.find(id)).to eq('text' => 'testing', 'views' => 2) + end + end + + context 'when upsert is specified in the options' do + + before do + repository.update(id, script: { inline: 'ctx._source.views += 1' }, + upsert: { text: 'testing_2' }) + end + + it 'executes the script' do + expect(repository.find(id)).to eq('text' => 'testing', 'views' => 1) + end + end + + context 'when doc_as_upsert is specified in the options' do + + before do + repository.update(id, doc: { text: 'testing_2' }, + doc_as_upsert: true) + end + + it 'applies the update' do + expect(repository.find(id)).to eq('text' => 'testing_2', 'views' => 0) + end + end + end + + context 'when a document is provided' do + + context 'when no options are provided' do + + before do + repository.update(id: id, text: 'testing_2') + end + + it 'updates using the id and the document as the doc parameter' do + expect(repository.find(id)).to eq('text' => 'testing_2', 'views' => 0) + end + end + + context 'when options are provided' do + + context 'when a doc is specified in the options' do + + before do + repository.update({ id: id, text: 'testing' }, doc: { text: 'testing_2' }) + end + + it 'updates using the id and the doc in the options' do + expect(repository.find(id)).to eq('text' => 'testing_2', 'views' => 0) + end + end + + context 'when a script is specified in the options' do + + before do + repository.update({ id: id, text: 'testing' }, + script: { inline: 'ctx._source.views += 1' }) + end + + it 'updates using the id and script from the options' do + expect(repository.find(id)).to eq('text' => 'testing', 'views' => 1) + end + end + + context 'when params are specified in the options' do + + before do + repository.update({ id: id, text: 'testing' }, + script: { inline: 'ctx._source.views += params.count', + params: { count: 2 } }) + end + + it 'updates using the id and script and params from the options' do + expect(repository.find(id)).to eq('text' => 'testing', 'views' => 2) + end + end + + context 'when upsert is specified in the options' do + + before do + repository.update({ id: id, text: 'testing_2' }, + doc_as_upsert: true) + end + + it 'updates using the id and script and params from the options' do + expect(repository.find(id)).to eq('text' => 'testing_2', 'views' => 0) + end + end + end + end + end + + context 'when the document does not exist' do + + context 'when an id is provided 'do + + it 'raises an exception' do + expect { + repository.update(1, doc: { text: 'testing_2' }) + }.to raise_exception(Elasticsearch::Transport::Transport::Errors::NotFound) + end + + context 'when upsert is provided' do + + before do + repository.update(1, doc: { text: 'testing' }, doc_as_upsert: true) + end + + it 'inserts the document' do + expect(repository.find(1)).to eq('text' => 'testing') + end + end + end + + context 'when a document is provided' do + + it 'raises an exception' do + expect { + repository.update(id: 1, text: 'testing_2') + }.to raise_exception(Elasticsearch::Transport::Transport::Errors::NotFound) + end + + context 'when upsert is provided' do + + before do + repository.update({ id: 1, text: 'testing' }, doc_as_upsert: true) + end + + it 'inserts the document' do + expect(repository.find(1)).to eq('text' => 'testing') + end + end + end + end + end + + describe '#delete' do + + before do + class Note + def to_hash + { text: 'testing', views: 0 } + end + end + end + + after do + if defined?(Note) + Object.send(:remove_const, :Note) + end + end + + context 'when the document exists' do + + let!(:id) do + repository.save(Note.new)['_id'] + end + + context 'an id is provided' do + + before do + repository.delete(id) + end + + it 'deletes the document using the id' do + expect { + repository.find(id) + }.to raise_exception(Elasticsearch::Persistence::Repository::DocumentNotFound) + end + end + + context 'when a document is provided' do + + before do + repository.delete(id: id, text: 'testing') + end + + it 'deletes the document using the id' do + expect { + repository.find(id) + }.to raise_exception(Elasticsearch::Persistence::Repository::DocumentNotFound) + end + end + end + + context 'when the document does not exist' do + + before do + repository.create_index! + end + + it 'deletes the document using the id' do + expect { + repository.delete(1) + }.to raise_exception(Elasticsearch::Transport::Transport::Errors::NotFound) + end + end + end +end diff --git a/elasticsearch-persistence/spec/spec_helper.rb b/elasticsearch-persistence/spec/spec_helper.rb new file mode 100644 index 000000000..05686c1ed --- /dev/null +++ b/elasticsearch-persistence/spec/spec_helper.rb @@ -0,0 +1,16 @@ +require 'elasticsearch/persistence' + +# The default client to be used by the repositories. +# +# @since 6.0.0 +DEFAULT_CLIENT = Elasticsearch::Client.new(host: "localhost:#{(ENV['TEST_CLUSTER_PORT'] || 9250)}", + tracer: (ENV['QUIET'] ? nil : ::Logger.new(STDERR))) + +RSpec.configure do |config| + config.formatter = 'documentation' + config.color = true + + config.before(:suite) do + Elasticsearch::Persistence::Repository::Base.client = DEFAULT_CLIENT + end +end