elasticsearch-persistence 0.0.0 → 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
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