elasticsearch-persistence 0.0.0 → 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. checksums.yaml +15 -0
  2. data/LICENSE.txt +10 -19
  3. data/README.md +432 -14
  4. data/Rakefile +56 -0
  5. data/elasticsearch-persistence.gemspec +45 -17
  6. data/examples/sinatra/.gitignore +7 -0
  7. data/examples/sinatra/Gemfile +28 -0
  8. data/examples/sinatra/README.markdown +36 -0
  9. data/examples/sinatra/application.rb +238 -0
  10. data/examples/sinatra/config.ru +7 -0
  11. data/examples/sinatra/test.rb +118 -0
  12. data/lib/elasticsearch/persistence.rb +88 -2
  13. data/lib/elasticsearch/persistence/client.rb +51 -0
  14. data/lib/elasticsearch/persistence/repository.rb +75 -0
  15. data/lib/elasticsearch/persistence/repository/class.rb +71 -0
  16. data/lib/elasticsearch/persistence/repository/find.rb +73 -0
  17. data/lib/elasticsearch/persistence/repository/naming.rb +115 -0
  18. data/lib/elasticsearch/persistence/repository/response/results.rb +90 -0
  19. data/lib/elasticsearch/persistence/repository/search.rb +60 -0
  20. data/lib/elasticsearch/persistence/repository/serialize.rb +31 -0
  21. data/lib/elasticsearch/persistence/repository/store.rb +95 -0
  22. data/lib/elasticsearch/persistence/version.rb +1 -1
  23. data/test/integration/repository/custom_class_test.rb +85 -0
  24. data/test/integration/repository/customized_class_test.rb +82 -0
  25. data/test/integration/repository/default_class_test.rb +108 -0
  26. data/test/integration/repository/virtus_model_test.rb +114 -0
  27. data/test/test_helper.rb +46 -0
  28. data/test/unit/persistence_test.rb +32 -0
  29. data/test/unit/repository_class_test.rb +51 -0
  30. data/test/unit/repository_client_test.rb +32 -0
  31. data/test/unit/repository_find_test.rb +375 -0
  32. data/test/unit/repository_indexing_test.rb +37 -0
  33. data/test/unit/repository_module_test.rb +144 -0
  34. data/test/unit/repository_naming_test.rb +146 -0
  35. data/test/unit/repository_response_results_test.rb +98 -0
  36. data/test/unit/repository_search_test.rb +97 -0
  37. data/test/unit/repository_serialize_test.rb +57 -0
  38. data/test/unit/repository_store_test.rb +287 -0
  39. metadata +288 -20
@@ -0,0 +1,115 @@
1
+ module Elasticsearch
2
+ module Persistence
3
+ module Repository
4
+
5
+ # Wraps all naming-related features of the repository (index name, the domain object class, etc)
6
+ #
7
+ module Naming
8
+
9
+ # Get or set the class used to initialize domain objects when deserializing them
10
+ #
11
+ def klass name=nil
12
+ @klass = name || @klass
13
+ end
14
+
15
+ # Set the class used to initialize domain objects when deserializing them
16
+ #
17
+ def klass=klass
18
+ @klass = klass
19
+ end
20
+
21
+ # Get or set the index name used when storing and retrieving documents
22
+ #
23
+ def index_name name=nil
24
+ @index_name = name || @index_name || begin
25
+ if respond_to?(:host) && host && host.is_a?(Module)
26
+ self.host.to_s.underscore.gsub(/\//, '-')
27
+ else
28
+ self.class.to_s.underscore.gsub(/\//, '-')
29
+ end
30
+ end
31
+ end; alias :index :index_name
32
+
33
+ # Set the index name used when storing and retrieving documents
34
+ #
35
+ def index_name=(name)
36
+ @index_name = name
37
+ end; alias :index= :index_name=
38
+
39
+ # Get or set the document type used when storing and retrieving documents
40
+ #
41
+ def document_type name=nil
42
+ @document_type = name || @document_type || (klass ? klass.to_s.underscore : nil)
43
+ end; alias :type :document_type
44
+
45
+ # Set the document type used when storing and retrieving documents
46
+ #
47
+ def document_type=(name)
48
+ @document_type = name
49
+ end; alias :type= :document_type=
50
+
51
+ # Get the Ruby class from the Elasticsearch `_type`
52
+ #
53
+ # @example
54
+ # repository.__get_klass_from_type 'note'
55
+ # => Note
56
+ #
57
+ # @return [Class] The class corresponding to the passed type
58
+ # @raise [NameError] if the class cannot be found
59
+ #
60
+ # @api private
61
+ #
62
+ def __get_klass_from_type(type)
63
+ klass = type.classify
64
+ klass.constantize
65
+ rescue NameError => e
66
+ raise NameError, "Attempted to get class '#{klass}' from the '#{type}' type, but no such class can be found."
67
+ end
68
+
69
+ # Get the Elasticsearch `_type` from the Ruby class
70
+ #
71
+ # @example
72
+ # repository.__get_type_from_class Note
73
+ # => "note"
74
+ #
75
+ # @return [String] The type corresponding to the passed class
76
+ #
77
+ # @api private
78
+ #
79
+ def __get_type_from_class(klass)
80
+ klass.to_s.underscore
81
+ end
82
+
83
+ # Get a document ID from the document (assuming Hash or Hash-like object)
84
+ #
85
+ # @example
86
+ # repository.__get_id_from_document title: 'Test', id: 'abc123'
87
+ # => "abc123"
88
+ #
89
+ # @api private
90
+ #
91
+ def __get_id_from_document(document)
92
+ document[:id] || document['id'] || document[:_id] || document['_id']
93
+ end
94
+
95
+ # Extract a document ID from the document (assuming Hash or Hash-like object)
96
+ #
97
+ # @note Calling this method will *remove* the `id` or `_id` key from the passed object.
98
+ #
99
+ # @example
100
+ # options = { title: 'Test', id: 'abc123' }
101
+ # repository.__extract_id_from_document options
102
+ # # => "abc123"
103
+ # options
104
+ # # => { title: 'Test' }
105
+ #
106
+ # @api private
107
+ #
108
+ def __extract_id_from_document(document)
109
+ document.delete(:id) || document.delete('id') || document.delete(:_id) || document.delete('_id')
110
+ end
111
+ end
112
+
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,90 @@
1
+ module Elasticsearch
2
+ module Persistence
3
+ module Repository
4
+ module Response # :nodoc:
5
+
6
+ # Encapsulates the domain objects and documents returned from Elasticsearch when searching
7
+ #
8
+ # Implements `Enumerable` and forwards its methods to the {#results} object.
9
+ #
10
+ class Results
11
+ include Enumerable
12
+
13
+ attr_reader :repository
14
+
15
+ # @param repository [Elasticsearch::Persistence::Repository::Class] The repository instance
16
+ # @param response [Hash] The full response returned from the Elasticsearch client
17
+ # @param options [Hash] Optional parameters
18
+ #
19
+ def initialize(repository, response, options={})
20
+ @repository = repository
21
+ @response = Hashie::Mash.new(response)
22
+ @options = options
23
+ end
24
+
25
+ def method_missing(method_name, *arguments, &block)
26
+ results.respond_to?(method_name) ? results.__send__(method_name, *arguments, &block) : super
27
+ end
28
+
29
+ def respond_to?(method_name, include_private = false)
30
+ results.respond_to?(method_name) || super
31
+ end
32
+
33
+ # The number of total hits for a query
34
+ #
35
+ def total
36
+ response['hits']['total']
37
+ end
38
+
39
+ # The maximum score for a query
40
+ #
41
+ def max_score
42
+ response['hits']['max_score']
43
+ end
44
+
45
+ # Yields [object, hit] pairs to the block
46
+ #
47
+ def each_with_hit(&block)
48
+ results.zip(response['hits']['hits']).each(&block)
49
+ end
50
+
51
+ # Yields [object, hit] pairs and returns the result
52
+ #
53
+ def map_with_hit(&block)
54
+ results.zip(response['hits']['hits']).map(&block)
55
+ end
56
+
57
+ # Return the collection of domain objects
58
+ #
59
+ # @example Iterate over the results
60
+ #
61
+ # results.map { |r| r.attributes[:title] }
62
+ # => ["Fox", "Dog"]
63
+ #
64
+ # @return [Array]
65
+ #
66
+ def results
67
+ @results ||= response['hits']['hits'].map do |document|
68
+ repository.deserialize(document.to_hash)
69
+ end
70
+ end
71
+
72
+ # Access the response returned from Elasticsearch by the client
73
+ #
74
+ # @example Access the aggregations in the response
75
+ #
76
+ # results = repository.search query: { match: { title: 'fox dog' } },
77
+ # aggregations: { titles: { terms: { field: 'title' } } }
78
+ # results.response.aggregations.titles.buckets.map { |term| "#{term['key']}: #{term['doc_count']}" }
79
+ # # => ["brown: 1", "dog: 1", ...]
80
+ #
81
+ # @return [Hashie::Mash]
82
+ #
83
+ def response
84
+ @response
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,60 @@
1
+ module Elasticsearch
2
+ module Persistence
3
+ module Repository
4
+
5
+ # Returns a collection of domain objects by an Elasticsearch query
6
+ #
7
+ module Search
8
+
9
+ # Returns a collection of domain objects by an Elasticsearch query
10
+ #
11
+ # Pass the query either as a string or a Hash-like object
12
+ #
13
+ # @example Return objects matching a simple query
14
+ #
15
+ # repository.search('fox or dog')
16
+ #
17
+ # @example Return objects matching a query in the Elasticsearch DSL
18
+ #
19
+ # repository.search(query: { match: { title: 'fox dog' } })
20
+ #
21
+ # @example Define additional search parameters, such as highlighted excerpts
22
+ #
23
+ # results = repository.search(query: { match: { title: 'fox dog' } }, highlight: { fields: { title: {} } })
24
+ # results.map_with_hit { |d,h| h.highlight.title.join }
25
+ # # => ["quick brown <em>fox</em>", "fast white <em>dog</em>"]
26
+ #
27
+ # @example Perform aggregations as part of the request
28
+ #
29
+ # results = repository.search query: { match: { title: 'fox dog' } },
30
+ # aggregations: { titles: { terms: { field: 'title' } } }
31
+ # results.response.aggregations.titles.buckets.map { |term| "#{term['key']}: #{term['doc_count']}" }
32
+ # # => ["brown: 1", "dog: 1", ... ]
33
+ #
34
+ # @example Pass additional options to the search request, such as `size`
35
+ #
36
+ # repository.search query: { match: { title: 'fox dog' } }, size: 25
37
+ # # GET http://localhost:9200/notes/note/_search
38
+ # # > {"query":{"match":{"title":"fox dog"}},"size":25}
39
+ #
40
+ # @return [Elasticsearch::Persistence::Repository::Response::Results]
41
+ #
42
+ def search(query_or_definition, options={})
43
+ type = document_type || (klass ? __get_type_from_class(klass) : nil )
44
+
45
+ case
46
+ when query_or_definition.respond_to?(:to_hash)
47
+ response = client.search( { index: index_name, type: type, body: query_or_definition.to_hash }.merge(options) )
48
+ when query_or_definition.is_a?(String)
49
+ response = client.search( { index: index_name, type: type, q: query_or_definition }.merge(options) )
50
+ else
51
+ raise ArgumentError, "[!] Pass the search definition as a Hash-like object or pass the query as a String" +
52
+ " -- #{query_or_definition.class} given."
53
+ end
54
+ Response::Results.new(self, response)
55
+ end
56
+ end
57
+
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,31 @@
1
+ module Elasticsearch
2
+ module Persistence
3
+ module Repository
4
+
5
+ # Provide serialization and deserialization between Ruby objects and Elasticsearch documents
6
+ #
7
+ # Override these methods in your repository class to customize the logic.
8
+ #
9
+ module Serialize
10
+
11
+ # Serialize the object for storing it in Elasticsearch
12
+ #
13
+ # In the default implementation, call the `to_hash` method on the passed object.
14
+ #
15
+ def serialize(document)
16
+ document.to_hash
17
+ end
18
+
19
+ # Deserialize the document retrieved from Elasticsearch into a Ruby object
20
+ #
21
+ # Use the `klass` property, if defined, otherwise try to get the class from the document's `_type`.
22
+ #
23
+ def deserialize(document)
24
+ _klass = klass || __get_klass_from_type(document['_type'])
25
+ _klass.new document['_source']
26
+ end
27
+ end
28
+
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,95 @@
1
+ module Elasticsearch
2
+ module Persistence
3
+ module Repository
4
+
5
+ # Save and delete documents in Elasticsearch
6
+ #
7
+ module Store
8
+
9
+ # Store the serialized object in Elasticsearch
10
+ #
11
+ # @example
12
+ # repository.save(myobject)
13
+ # => {"_index"=>"...", "_type"=>"...", "_id"=>"...", "_version"=>1, "created"=>true}
14
+ #
15
+ # @return {Hash} The response from Elasticsearch
16
+ #
17
+ def save(document, options={})
18
+ serialized = serialize(document)
19
+ id = __get_id_from_document(serialized)
20
+ type = document_type || __get_type_from_class(klass || document.class)
21
+ client.index( { index: index_name, type: type, id: id, body: serialized }.merge(options) )
22
+ end
23
+
24
+ # Update the serialized object in Elasticsearch with partial data or script
25
+ #
26
+ # @example Update the document with partial data
27
+ #
28
+ # repository.update id: 1, title: 'UPDATED', tags: []
29
+ # # => {"_index"=>"...", "_type"=>"...", "_id"=>"1", "_version"=>2}
30
+ #
31
+ # @example Update the document with a script
32
+ #
33
+ # repository.update 1, script: 'ctx._source.views += 1'
34
+ # # => {"_index"=>"...", "_type"=>"...", "_id"=>"1", "_version"=>3}
35
+ #
36
+ # @return {Hash} The response from Elasticsearch
37
+ #
38
+ def update(document, options={})
39
+ case
40
+ when document.is_a?(String) || document.is_a?(Integer)
41
+ id = document
42
+ when document.respond_to?(:to_hash)
43
+ serialized = document.to_hash
44
+ id = __extract_id_from_document(serialized)
45
+ else
46
+ raise ArgumentError, "Expected a document ID or a Hash-like object, #{document.class} given"
47
+ end
48
+
49
+ type = options.delete(:type) || \
50
+ (defined?(serialized) && serialized && serialized.delete(:type)) || \
51
+ document_type || \
52
+ __get_type_from_class(klass)
53
+
54
+ if defined?(serialized) && serialized
55
+ body = if serialized[:script]
56
+ serialized.select { |k, v| [:script, :params, :upsert].include? k }
57
+ else
58
+ { doc: serialized }
59
+ end
60
+ else
61
+ body = {}
62
+ body.update( doc: options.delete(:doc)) if options[:doc]
63
+ body.update( script: options.delete(:script)) if options[:script]
64
+ body.update( params: options.delete(:params)) if options[:params]
65
+ body.update( upsert: options.delete(:upsert)) if options[:upsert]
66
+ end
67
+
68
+ client.update( { index: index_name, type: type, id: id, body: body }.merge(options) )
69
+ end
70
+
71
+ # Remove the serialized object or document with specified ID from Elasticsearch
72
+ #
73
+ # @example Remove the document with ID 1
74
+ #
75
+ # repository.delete(1)
76
+ # # => {"_index"=>"...", "_type"=>"...", "_id"=>"1", "_version"=>4}
77
+ #
78
+ # @return {Hash} The response from Elasticsearch
79
+ #
80
+ def delete(document, options={})
81
+ if document.is_a?(String) || document.is_a?(Integer)
82
+ id = document
83
+ type = document_type || __get_type_from_class(klass)
84
+ else
85
+ serialized = serialize(document)
86
+ id = __get_id_from_document(serialized)
87
+ type = document_type || __get_type_from_class(klass || document.class)
88
+ end
89
+ client.delete( { index: index_name, type: type, id: id }.merge(options) )
90
+ end
91
+ end
92
+
93
+ end
94
+ end
95
+ end
@@ -1,5 +1,5 @@
1
1
  module Elasticsearch
2
2
  module Persistence
3
- VERSION = "0.0.0"
3
+ VERSION = "0.0.1"
4
4
  end
5
5
  end
@@ -0,0 +1,85 @@
1
+ require 'test_helper'
2
+
3
+ module Elasticsearch
4
+ module Persistence
5
+ class RepositoryCustomClassIntegrationTest < Elasticsearch::Test::IntegrationTestCase
6
+
7
+ class ::MyNote
8
+ attr_reader :attributes
9
+
10
+ def initialize(attributes={})
11
+ @attributes = Hashie::Mash.new(attributes)
12
+ end
13
+
14
+ def method_missing(method_name, *arguments, &block)
15
+ attributes.respond_to?(method_name) ? attributes.__send__(method_name, *arguments, &block) : super
16
+ end
17
+
18
+ def respond_to?(method_name, include_private=false)
19
+ attributes.respond_to?(method_name) || super
20
+ end
21
+
22
+ def to_hash
23
+ @attributes
24
+ end
25
+ end
26
+
27
+ context "A custom repository class" do
28
+ setup do
29
+ class ::MyNotesRepository
30
+ include Elasticsearch::Persistence::Repository
31
+
32
+ klass MyNote
33
+
34
+ settings number_of_shards: 1 do
35
+ mapping do
36
+ indexes :title, analyzer: 'snowball'
37
+ end
38
+ end
39
+
40
+ create_index!
41
+
42
+ def deserialize(document)
43
+ klass.new document.merge(document['_source'])
44
+ end
45
+ end
46
+
47
+ @repository = MyNotesRepository.new
48
+
49
+ @repository.client.cluster.health wait_for_status: 'yellow'
50
+ end
51
+
52
+ should "save the object under a correct index and type" do
53
+ @repository.save MyNote.new(id: '1', title: 'Test')
54
+ result = @repository.find(1)
55
+
56
+ assert_instance_of MyNote, result
57
+ assert_equal 'Test', result.title
58
+
59
+ assert_not_nil Elasticsearch::Persistence.client.get index: 'my_notes_repository',
60
+ type: 'my_note',
61
+ id: '1'
62
+ end
63
+
64
+ should "delete the object" do
65
+ note = MyNote.new id: 1, title: 'Test'
66
+ @repository.save note
67
+
68
+ assert_not_nil @repository.find(1)
69
+
70
+ @repository.delete(note)
71
+ assert_raise(Elasticsearch::Persistence::Repository::DocumentNotFound) { @repository.find(1) }
72
+ end
73
+
74
+ should "retrieve the object via a search query" do
75
+ note = MyNote.new title: 'Testing'
76
+ @repository.save note, refresh: true
77
+
78
+ results = @repository.search query: { match: { title: 'Test' } }
79
+ assert_equal 'Testing', results.first.title
80
+ end
81
+ end
82
+
83
+ end
84
+ end
85
+ end