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.
- checksums.yaml +15 -0
- data/LICENSE.txt +10 -19
- data/README.md +432 -14
- data/Rakefile +56 -0
- data/elasticsearch-persistence.gemspec +45 -17
- data/examples/sinatra/.gitignore +7 -0
- data/examples/sinatra/Gemfile +28 -0
- data/examples/sinatra/README.markdown +36 -0
- data/examples/sinatra/application.rb +238 -0
- data/examples/sinatra/config.ru +7 -0
- data/examples/sinatra/test.rb +118 -0
- data/lib/elasticsearch/persistence.rb +88 -2
- data/lib/elasticsearch/persistence/client.rb +51 -0
- data/lib/elasticsearch/persistence/repository.rb +75 -0
- data/lib/elasticsearch/persistence/repository/class.rb +71 -0
- data/lib/elasticsearch/persistence/repository/find.rb +73 -0
- data/lib/elasticsearch/persistence/repository/naming.rb +115 -0
- data/lib/elasticsearch/persistence/repository/response/results.rb +90 -0
- data/lib/elasticsearch/persistence/repository/search.rb +60 -0
- data/lib/elasticsearch/persistence/repository/serialize.rb +31 -0
- data/lib/elasticsearch/persistence/repository/store.rb +95 -0
- data/lib/elasticsearch/persistence/version.rb +1 -1
- data/test/integration/repository/custom_class_test.rb +85 -0
- data/test/integration/repository/customized_class_test.rb +82 -0
- data/test/integration/repository/default_class_test.rb +108 -0
- data/test/integration/repository/virtus_model_test.rb +114 -0
- data/test/test_helper.rb +46 -0
- data/test/unit/persistence_test.rb +32 -0
- data/test/unit/repository_class_test.rb +51 -0
- data/test/unit/repository_client_test.rb +32 -0
- data/test/unit/repository_find_test.rb +375 -0
- data/test/unit/repository_indexing_test.rb +37 -0
- data/test/unit/repository_module_test.rb +144 -0
- data/test/unit/repository_naming_test.rb +146 -0
- data/test/unit/repository_response_results_test.rb +98 -0
- data/test/unit/repository_search_test.rb +97 -0
- data/test/unit/repository_serialize_test.rb +57 -0
- data/test/unit/repository_store_test.rb +287 -0
- 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
|
@@ -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
|