Skip to content

Commit b26b67a

Browse files
committed
[MODEL] Refactorings and cleanups for the multi-model search
Many thanks to @miguelff for the implementation! * Rephrase documentation in README and code annotations * Move the `Registry` class to the multimodel.rb file * Clean up adapters/multiple.rb * Tweak test descriptions and values Closes elastic#345 Closes elastic#10 Closes elastic#30 Closes elastic#50 Closes elastic#129
1 parent 3c8e052 commit b26b67a

File tree

7 files changed

+150
-166
lines changed

7 files changed

+150
-166
lines changed

elasticsearch-model/README.md

+22-11
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,7 @@ response.results.first._source.title
172172
# => "Quick brown fox"
173173
```
174174

175-
Or if we want to perform the search across different models:
175+
To search across multiple models, use the module method:
176176

177177
```ruby
178178
Elasticsearch::Model.search 'fox dogs', [Article, Comment]
@@ -223,17 +223,8 @@ response.records.to_a
223223

224224
The returned object is the genuine collection of model instances returned by your database,
225225
i.e. `ActiveRecord::Relation` for ActiveRecord, or `Mongoid::Criteria` in case of MongoDB.
226-
When the search is performed across different models, an Array of model instances is returned.
227226

228-
```ruby
229-
Elasticsearch::Model.search('fox', [Article, Comment]).records
230-
# Article Load (0.3ms) SELECT "articles".* FROM "articles" WHERE "articles"."id" IN (1)
231-
# Comment Load (0.2ms) SELECT "comments".* FROM "comments" WHERE "comments"."id" IN (1,5)
232-
# => [#<Article id: 1, title: "Quick brown fox">, #<Comment id: 1, body: "I like foxes">, #<Comment id: 5, body: "Michael J.Fox is my favorite actor">]
233-
```
234-
235-
In the case of searching a single model, this allows you to
236-
chain other methods on top of search results, as you would normally do:
227+
This allows you to chain other methods on top of search results, as you would normally do:
237228

238229
```ruby
239230
response.records.where(title: 'Quick brown fox').to_a
@@ -268,6 +259,26 @@ response.records.each_with_hit { |record, hit| puts "* #{record.title}: #{hit._s
268259
# * Fast black dogs: 0.02250402
269260
```
270261

262+
It is possible to search across multiple models with the module method:
263+
264+
```ruby
265+
Elasticsearch::Model.search('fox', [Article, Comment]).results.to_a.map(&:to_hash)
266+
# => [
267+
# {"_index"=>"articles", "_type"=>"article", "_id"=>"1", "_score"=>0.35136628, "_source"=>...},
268+
# {"_index"=>"comments", "_type"=>"comment", "_id"=>"1", "_score"=>0.35136628, "_source"=>...}
269+
# ]
270+
271+
Elasticsearch::Model.search('fox', [Article, Comment]).records.to_a
272+
# Article Load (0.3ms) SELECT "articles".* FROM "articles" WHERE "articles"."id" IN (1)
273+
# Comment Load (0.2ms) SELECT "comments".* FROM "comments" WHERE "comments"."id" IN (1,5)
274+
# => [#<Article id: 1, title: "Quick brown fox">, #<Comment id: 1, body: "Fox News">, ...]
275+
```
276+
277+
By default, all models which include the `Elasticsearch::Model` module are searched.
278+
279+
NOTE: It is _not_ possible to chain other methods on top of the `records` object, since it
280+
is a heterogenous collection, with models potentially backed by different databases.
281+
271282
#### Pagination
272283

273284
You can implement pagination with the `from` and `size` search parameters. However, search results

elasticsearch-model/lib/elasticsearch/model.rb

+6-48
Original file line numberDiff line numberDiff line change
@@ -67,50 +67,6 @@ module Elasticsearch
6767
module Model
6868
METHODS = [:search, :mapping, :mappings, :settings, :index_name, :document_type, :import]
6969

70-
71-
# Keeps a registry of the classes that include `Elasticsearch::Model`
72-
#
73-
class Registry
74-
75-
# Add the class of a model to the registry
76-
#
77-
def self.add(klass)
78-
__instance.add(klass)
79-
end
80-
81-
# List all the registered models
82-
#
83-
# @return [Class]
84-
#
85-
def self.all
86-
__instance.models
87-
end
88-
89-
# Returns the unique instance of the registry
90-
#
91-
# @api private
92-
#
93-
def self.__instance
94-
@instance ||= new
95-
end
96-
97-
def initialize
98-
@models = []
99-
end
100-
101-
# Adds a model to the registry
102-
#
103-
def add(klass)
104-
@models << klass
105-
end
106-
107-
# Gets a copy of the registered models
108-
#
109-
def models
110-
@models.dup
111-
end
112-
end
113-
11470
# Adds the `Elasticsearch::Model` functionality to the including class.
11571
#
11672
# * Creates the `__elasticsearch__` class and instance methods, pointing to the proxy object
@@ -199,20 +155,22 @@ def client=(client)
199155
@client = client
200156
end
201157

202-
# Search across models which include Elasticsearch::Model
158+
# Search across multiple models
159+
#
160+
# By default, all models which include the `Elasticsearch::Model` module are searched
203161
#
204162
# @param query_or_payload [String,Hash,Object] The search request definition
205163
# (string, JSON, Hash, or object responding to `to_hash`)
206-
# @param models [Array] The list of Model objects to search
164+
# @param models [Array] The Array of Model objects to search
207165
# @param options [Hash] Optional parameters to be passed to the Elasticsearch client
208166
#
209167
# @return [Elasticsearch::Model::Response::Response]
210168
#
211-
# @example Search across specified models
169+
# @example Search across specific models
212170
#
213171
# Elasticsearch::Model.search('foo', [Author, Article])
214172
#
215-
# @example Search across all models
173+
# @example Search across all models which include the `Elasticsearch::Model` module
216174
#
217175
# Elasticsearch::Model.search('foo')
218176
#

elasticsearch-model/lib/elasticsearch/model/adapters/multiple.rb

+48-67
Original file line numberDiff line numberDiff line change
@@ -2,125 +2,106 @@ module Elasticsearch
22
module Model
33
module Adapter
44

5-
# An adapter to be used for deserializing results from multiple models, retrieved through
6-
# Elasticsearch::Model.search
5+
# An adapter to be used for deserializing results from multiple models,
6+
# retrieved through `Elasticsearch::Model.search`
77
#
88
# @see Elasticsearch::Model.search
99
#
1010
module Multiple
11-
1211
Adapter.register self, lambda { |klass| klass.is_a? Multimodel }
1312

1413
module Records
15-
16-
# Returns an Array, which elements are the model instances represented
17-
# by the search results.
18-
#
19-
# This means that if the models queried are a Mixture of ActiveRecord, Mongoid, or
20-
# POROs, the elements contained in this array will also be instances of those models
21-
#
22-
# Ranking of results across multiple indexes is preserved, and queries made to the different
23-
# model's datasources are minimal.
24-
#
25-
# Internally, it gets the results, as ranked by elasticsearch.
26-
# Then results are grouped by _type
27-
# Then the model corresponding to each _type is queried to retrieve the records
28-
# Finally records are rearranged in the same way results were ranked.
14+
# Returns a collection of model instances, possibly of different classes (ActiveRecord, Mongoid, ...)
2915
#
30-
# @return [ElasticSearch::Model]
16+
# @note The order of results in the Elasticsearch response is preserved
3117
#
3218
def records
33-
@_records ||= begin
34-
result = []
35-
by_type = __records_by_type
36-
__hits.each do |hit|
37-
result << by_type[__type(hit)][hit[:_id]]
38-
end
39-
result.compact
19+
records_by_type = __records_by_type
20+
21+
response.response["hits"]["hits"].map do |hit|
22+
records_by_type[ __type_for_hit(hit) ][ hit[:_id] ]
4023
end
4124
end
4225

43-
# Returns the record representation of the results retrieved from Elasticsearch, grouped
44-
# by model type
26+
# Returns the collection of records grouped by class based on `_type`
4527
#
46-
# @example
47-
# {Series =>
48-
# {"1"=> #<Series id: 1, series_name: "The Who S01", created_at: "2015-02-23 17:18:28">},
28+
# Example:
4929
#
50-
# "Title =>
51-
# {"1"=> #<Title id: 1, name: "Who Strikes Back", created_at: "2015-02-23 17:18:28">}}
30+
# {
31+
# Foo => {"1"=> #<Foo id: 1, title: "ABC"}, ...},
32+
# Bar => {"1"=> #<Bar id: 1, name: "XYZ"}, ...}
33+
# }
5234
#
5335
# @api private
5436
#
5537
def __records_by_type
56-
array = __ids_by_type.map do |klass, ids|
57-
records = __type_records(klass, ids)
58-
ids = records.map(&:id).map(&:to_s)
59-
[klass, Hash[ids.zip(records)]]
38+
result = __ids_by_type.map do |klass, ids|
39+
records = __records_for_klass(klass, ids)
40+
ids = records.map(&:id).map(&:to_s)
41+
[ klass, Hash[ids.zip(records)] ]
6042
end
61-
Hash[array]
43+
44+
Hash[result]
6245
end
6346

64-
# Returns the records for a specific type
47+
# Returns the collection of records for a specific type based on passed `klass`
6548
#
6649
# @api private
6750
#
68-
def __type_records(klass, ids)
69-
if (adapter = Adapter.adapters[ActiveRecord]) && adapter.call(klass)
70-
klass.where(klass.primary_key => ids)
71-
elsif (adapter = Adapter.adapters[Mongoid]) && adapter.call(klass)
72-
klass.where(:id.in => ids)
73-
else
74-
klass.find(ids)
51+
def __records_for_klass(klass, ids)
52+
adapter = __adapter_name_for_klass(klass)
53+
54+
case adapter
55+
when Elasticsearch::Model::Adapter::ActiveRecord
56+
klass.where(klass.primary_key => ids)
57+
when Elasticsearch::Model::Adapter::Mongoid
58+
klass.where(:id.in => ids)
59+
else
60+
klass.find(ids)
7561
end
7662
end
7763

78-
79-
# @return A Hash containing for each type, the ids to retrieve
64+
# Returns the record IDs grouped by class based on type `_type`
65+
#
66+
# Example:
8067
#
81-
# @example {Series =>["1"], Title =>["1", "5"]}
68+
# { Foo => ["1"], Bar => ["1", "5"] }
8269
#
8370
# @api private
8471
#
8572
def __ids_by_type
8673
ids_by_type = {}
87-
__hits.each do |hit|
88-
type = __type(hit)
74+
75+
response.response["hits"]["hits"].each do |hit|
76+
type = __type_for_hit(hit)
8977
ids_by_type[type] ||= []
9078
ids_by_type[type] << hit[:_id]
9179
end
9280
ids_by_type
9381
end
9482

95-
# Returns the class of the model associated to a certain hit
96-
#
97-
# A simple class-level memoization over the `_index` and `_type` properties of the hit is applied.
98-
# Hence querying the Model Registry is done the minimal amount of times.
99-
#
100-
# Event though memoization happens at the class level, the side effect of a race condition will only be
101-
# to iterate over models one extra time, so we can consider the method thread-safe, and don't include
102-
# any Mutex.synchronize around the method implementaion
83+
# Returns the class of the model corresponding to a specific `hit` in Elasticsearch results
10384
#
10485
# @see Elasticsearch::Model::Registry
10586
#
106-
# @return Class
107-
#
10887
# @api private
10988
#
110-
def __type(hit)
89+
def __type_for_hit(hit)
11190
@@__types ||= {}
112-
@@__types[[hit[:_index], hit[:_type]].join("::")] ||= begin
113-
Registry.all.detect { |model| model.index_name == hit[:_index] && model.document_type == hit[:_type] }
91+
92+
@@__types[ "#{hit[:_index]}::#{hit[:_type]}" ] ||= begin
93+
Registry.all.detect do |model|
94+
model.index_name == hit[:_index] && model.document_type == hit[:_type]
95+
end
11496
end
11597
end
11698

117-
118-
# Memoizes and returns the hits from the response
99+
# Returns the adapter registered for a particular `klass` or `nil` if not available
119100
#
120101
# @api private
121102
#
122-
def __hits
123-
@__hits ||= response.response["hits"]["hits"]
103+
def __adapter_name_for_klass(klass)
104+
Adapter.adapters.select { |name, checker| checker.call(klass) }.keys.first
124105
end
125106
end
126107
end

elasticsearch-model/lib/elasticsearch/model/multimodel.rb

+47-7
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,71 @@
11
module Elasticsearch
22
module Model
33

4-
# Wraps a series of models to be used when querying multiple indexes via Elasticsearch::Model.search
4+
# Keeps a global registry of classes that include `Elasticsearch::Model`
5+
#
6+
class Registry
7+
def initialize
8+
@models = []
9+
end
10+
11+
# Returns the unique instance of the registry (Singleton)
12+
#
13+
# @api private
14+
#
15+
def self.__instance
16+
@instance ||= new
17+
end
18+
19+
# Adds a model to the registry
20+
#
21+
def self.add(klass)
22+
__instance.add(klass)
23+
end
24+
25+
# Returns an Array of registered models
26+
#
27+
def self.all
28+
__instance.models
29+
end
30+
31+
# Adds a model to the registry
32+
#
33+
def add(klass)
34+
@models << klass
35+
end
36+
37+
# Returns a copy of the registered models
38+
#
39+
def models
40+
@models.dup
41+
end
42+
end
43+
44+
# Wraps a collection of models when querying multiple indices
545
#
646
# @see Elasticsearch::Model.search
747
#
848
class Multimodel
9-
1049
attr_reader :models
1150

12-
# @param models [Class] The list of models across which the search will be performed.
51+
# @param models [Class] The list of models across which the search will be performed
52+
#
1353
def initialize(*models)
1454
@models = models.flatten
1555
@models = Model::Registry.all if @models.empty?
1656
end
1757

18-
# Get the list of index names used for retrieving documents when doing a search across multiple models
58+
# Get an Array of index names used for retrieving documents when doing a search across multiple models
1959
#
20-
# @return [String] the list of index names used for retrieving documents
60+
# @return [Array] the list of index names used for retrieving documents
2161
#
2262
def index_name
2363
models.map { |m| m.index_name }
2464
end
2565

26-
# Get the list of document types used for retrieving documents when doing a search across multiple models
66+
# Get an Array of document types used for retrieving documents when doing a search across multiple models
2767
#
28-
# @return [String] the list of document types used for retrieving documents
68+
# @return [Array] the list of document types used for retrieving documents
2969
#
3070
def document_type
3171
models.map { |m| m.document_type }

0 commit comments

Comments
 (0)