Skip to content

Commit 6dd4972

Browse files
miguelffkarmi
authored andcommitted
[MODEL] Implement multi-model search
Related: elastic#10, elastic#30, elastic#50, elastic#129, elastic#346
1 parent d6cce61 commit 6dd4972

File tree

6 files changed

+544
-0
lines changed

6 files changed

+544
-0
lines changed

elasticsearch-model/lib/elasticsearch/model.rb

+72
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,13 @@
88

99
require 'elasticsearch/model/client'
1010

11+
require 'elasticsearch/model/multimodel'
12+
1113
require 'elasticsearch/model/adapter'
1214
require 'elasticsearch/model/adapters/default'
1315
require 'elasticsearch/model/adapters/active_record'
1416
require 'elasticsearch/model/adapters/mongoid'
17+
require 'elasticsearch/model/adapters/multiple'
1518

1619
require 'elasticsearch/model/importing'
1720
require 'elasticsearch/model/indexing'
@@ -64,6 +67,50 @@ module Elasticsearch
6467
module Model
6568
METHODS = [:search, :mapping, :mappings, :settings, :index_name, :document_type, :import]
6669

70+
71+
# Keeps a registry of the classes that include `Elasticsearch::Model`
72+
#
73+
class Registry < Array
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+
67114
# Adds the `Elasticsearch::Model` functionality to the including class.
68115
#
69116
# * Creates the `__elasticsearch__` class and instance methods, pointing to the proxy object
@@ -119,6 +166,9 @@ class << self
119166
include Elasticsearch::Model::Importing::ClassMethods
120167
include Adapter.from_class(base).importing_mixin
121168
end
169+
170+
# Add to the registry if it's a class (and not in intermediate module)
171+
Registry.add(base) if base.is_a?(Class)
122172
end
123173
end
124174

@@ -149,6 +199,28 @@ def client=(client)
149199
@client = client
150200
end
151201

202+
# Search across models which include Elasticsearch::Model
203+
#
204+
# @param query_or_payload [String,Hash,Object] The search request definition
205+
# (string, JSON, Hash, or object responding to `to_hash`)
206+
# @param models [Array] The list of Model objects to search
207+
# @param options [Hash] Optional parameters to be passed to the Elasticsearch client
208+
#
209+
# @return [Elasticsearch::Model::Response::Response]
210+
#
211+
# @example Search across specified models
212+
#
213+
# Elasticsearch::Model.search('foo', [Author, Article])
214+
#
215+
# @example Search across all models
216+
#
217+
# Elasticsearch::Model.search('foo')
218+
#
219+
def search(query_or_payload, models=[], options={})
220+
models = Multimodel.new(models)
221+
request = Searching::SearchRequest.new(models, query_or_payload, options)
222+
Response::Response.new(models, request)
223+
end
152224
end
153225
extend ClassMethods
154226

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
module Elasticsearch
2+
module Model
3+
module Adapter
4+
5+
# An adapter to be used for deserializing results from multiple models, retrieved through
6+
# Elasticsearch::Model.search
7+
#
8+
# @see Elasticsearch::Model.search
9+
#
10+
module Multiple
11+
12+
Adapter.register self, lambda { |klass| klass.is_a? Multimodel }
13+
14+
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.
29+
#
30+
# @return [ElasticSearch::Model]
31+
#
32+
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
40+
end
41+
end
42+
43+
# Returns the record representation of the results retrieved from Elasticsearch, grouped
44+
# by model type
45+
#
46+
# @example
47+
# {Series =>
48+
# {"1"=> #<Series id: 1, series_name: "The Who S01", created_at: "2015-02-23 17:18:28">},
49+
#
50+
# "Title =>
51+
# {"1"=> #<Title id: 1, name: "Who Strikes Back", created_at: "2015-02-23 17:18:28">}}
52+
#
53+
# @api private
54+
#
55+
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)]]
60+
end
61+
Hash[array]
62+
end
63+
64+
# Returns the records for a specific type
65+
#
66+
# @api private
67+
#
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)
75+
end
76+
end
77+
78+
79+
# @return A Hash containing for each type, the ids to retrieve
80+
#
81+
# @example {Series =>["1"], Title =>["1", "5"]}
82+
#
83+
# @api private
84+
#
85+
def __ids_by_type
86+
ids_by_type = {}
87+
__hits.each do |hit|
88+
type = __type(hit)
89+
ids_by_type[type] ||= []
90+
ids_by_type[type] << hit[:_id]
91+
end
92+
ids_by_type
93+
end
94+
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+
# @see Elasticsearch::Model::Registry
101+
#
102+
# @return Class
103+
#
104+
# @api private
105+
#
106+
def __type(hit)
107+
@@__types ||= {}
108+
@@__types[[hit[:_index], hit[:_type]].join("::")] ||= begin
109+
Registry.all.detect { |model| model.index_name == hit[:_index] && model.document_type == hit[:_type] }
110+
end
111+
end
112+
113+
114+
# Memoizes and returns the hits from the response
115+
#
116+
# @api private
117+
#
118+
def __hits
119+
@__hits ||= response.response["hits"]["hits"]
120+
end
121+
end
122+
end
123+
end
124+
end
125+
end
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
module Elasticsearch
2+
module Model
3+
4+
# Wraps a series of models to be used when querying multiple indexes via Elasticsearch::Model.search
5+
#
6+
# @see Elasticsearch::Model.search
7+
#
8+
class Multimodel
9+
10+
attr_reader :models
11+
12+
# @param models [Class] The list of models across which the search will be performed.
13+
def initialize(*models)
14+
@models = models.flatten
15+
@models = Model::Registry.all if @models.empty?
16+
end
17+
18+
# Get the list of index names used for retrieving documents when doing a search across multiple models
19+
#
20+
# @return [String] the list of index names used for retrieving documents
21+
#
22+
def index_name
23+
models.map { |m| m.index_name }
24+
end
25+
26+
# Get the list of document types used for retrieving documents when doing a search across multiple models
27+
#
28+
# @return [String] the list of document types used for retrieving documents
29+
#
30+
def document_type
31+
models.map { |m| m.document_type }
32+
end
33+
34+
# Get the client common for all models
35+
#
36+
# @return Elasticsearch::Transport::Client
37+
#
38+
def client
39+
Elasticsearch::Model.client
40+
end
41+
end
42+
end
43+
end

0 commit comments

Comments
 (0)