elasticsearch-facetedsearch 0.0.4
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/CHANGELOG.md +18 -0
- data/README.md +216 -0
- data/Rakefile +8 -0
- data/lib/elasticsearch/faceted_search.rb +15 -0
- data/lib/elasticsearch/faceted_search/facet_base.rb +184 -0
- data/lib/elasticsearch/faceted_search/facet_group.rb +101 -0
- data/lib/elasticsearch/faceted_search/facet_item.rb +105 -0
- data/lib/elasticsearch/faceted_search/pagination.rb +27 -0
- data/lib/elasticsearch/faceted_search/sortable.rb +62 -0
- data/lib/elasticsearch/faceted_search/version.rb +5 -0
- data/spec/lib/elasticsearch/faceted_search/facet_base_spec.rb +256 -0
- data/spec/lib/elasticsearch/faceted_search/facet_group_spec.rb +208 -0
- data/spec/lib/elasticsearch/faceted_search/facet_item_spec.rb +201 -0
- data/spec/lib/elasticsearch/faceted_search/pagination_spec.rb +52 -0
- data/spec/lib/elasticsearch/faceted_search/sortable_spec.rb +128 -0
- data/spec/spec_helper.rb +15 -0
- metadata +150 -0
@@ -0,0 +1,101 @@
|
|
1
|
+
require 'forwardable'
|
2
|
+
|
3
|
+
module Elasticsearch
|
4
|
+
module FacetedSearch
|
5
|
+
class FacetGroup
|
6
|
+
extend Forwardable
|
7
|
+
|
8
|
+
OPERATOR_MAPPING = {
|
9
|
+
:and => ',',
|
10
|
+
:or => '|'
|
11
|
+
}
|
12
|
+
|
13
|
+
attr_accessor :search, :objects, :key
|
14
|
+
|
15
|
+
def_delegators :search, :class_facets, :execution_type, :search_params
|
16
|
+
# delegate :class_facets, :execution_type, :search_params, to: :search
|
17
|
+
|
18
|
+
def initialize(search, key, objects)
|
19
|
+
self.search = search
|
20
|
+
self.key = key.to_sym
|
21
|
+
self.objects = hit_count_mapping(objects['terms'])
|
22
|
+
end
|
23
|
+
|
24
|
+
def items
|
25
|
+
@items ||= build_items
|
26
|
+
end
|
27
|
+
|
28
|
+
def title
|
29
|
+
class_facets[key][:title]
|
30
|
+
rescue
|
31
|
+
key.to_s.humanize
|
32
|
+
end
|
33
|
+
|
34
|
+
def selected_values
|
35
|
+
v = search_params.fetch(key, '')
|
36
|
+
operator ? v.split(operator).map(&:downcase) : v.downcase
|
37
|
+
end
|
38
|
+
|
39
|
+
def group_params
|
40
|
+
group_params_string.split(operator).dup
|
41
|
+
end
|
42
|
+
|
43
|
+
def group_params_string
|
44
|
+
search_params.fetch(key, '')
|
45
|
+
end
|
46
|
+
|
47
|
+
def type
|
48
|
+
class_facets[key][:type]
|
49
|
+
rescue
|
50
|
+
nil
|
51
|
+
end
|
52
|
+
|
53
|
+
# Returns the string value ',' or '|'
|
54
|
+
#
|
55
|
+
def operator
|
56
|
+
@operator ||= operator_mappings(operator_for)
|
57
|
+
end
|
58
|
+
|
59
|
+
def operator_mappings(i=nil)
|
60
|
+
OPERATOR_MAPPING.fetch(i, nil)
|
61
|
+
end
|
62
|
+
|
63
|
+
private
|
64
|
+
|
65
|
+
def operator_for
|
66
|
+
execution_type(
|
67
|
+
class_facets[key][:type]
|
68
|
+
)
|
69
|
+
end
|
70
|
+
|
71
|
+
def build_items
|
72
|
+
terms_collection.map do |x|
|
73
|
+
FacetItem.new(self, x.merge(count: count(x[:id])))
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
def terms_collection
|
78
|
+
return mapped_objects unless search.respond_to?(:"#{key}_collection")
|
79
|
+
@terms_collection ||= search.public_send(:"#{key}_collection")
|
80
|
+
end
|
81
|
+
|
82
|
+
# Occationally ElasticSearch will return boolean facets with mix cased characters (T/t & F/f)
|
83
|
+
# This method cleans this result and combines the counts to be correct & valid
|
84
|
+
#
|
85
|
+
def hit_count_mapping(o)
|
86
|
+
@hit_count_mapping ||= o.each_with_object(Hash.new(0)) do |result, hash|
|
87
|
+
term = result['term'].to_s.downcase
|
88
|
+
hash[term] += result['count']
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|
92
|
+
def count(id)
|
93
|
+
objects.fetch(id.to_s.downcase, 0)
|
94
|
+
end
|
95
|
+
|
96
|
+
def mapped_objects
|
97
|
+
objects.map{|k,v| {id: k, term: k, count: v} }
|
98
|
+
end
|
99
|
+
end
|
100
|
+
end
|
101
|
+
end
|
@@ -0,0 +1,105 @@
|
|
1
|
+
require 'forwardable'
|
2
|
+
|
3
|
+
module Elasticsearch
|
4
|
+
module FacetedSearch
|
5
|
+
class FacetItem
|
6
|
+
extend Forwardable
|
7
|
+
|
8
|
+
attr_accessor :group, :object
|
9
|
+
|
10
|
+
def_delegators :group, :operator_mappings, :operator, :key, :selected_values, :group_params, :type
|
11
|
+
def_delegators :object, :term, :count
|
12
|
+
# delegate :operator_mappings, :operator, :key, :selected_values, :group_params, :type, to: :group
|
13
|
+
# delegate :term, :count, to: :object
|
14
|
+
|
15
|
+
def id
|
16
|
+
object.id.to_s
|
17
|
+
end
|
18
|
+
|
19
|
+
def initialize(group, object)
|
20
|
+
self.group = group
|
21
|
+
self.object = OpenStruct.new(object)
|
22
|
+
end
|
23
|
+
|
24
|
+
def selected?
|
25
|
+
@selected ||= Array(group_params).include?(id)
|
26
|
+
end
|
27
|
+
|
28
|
+
def params_for
|
29
|
+
prefix = selected? ? :remove : :add
|
30
|
+
|
31
|
+
case type
|
32
|
+
when 'multivalue' then send(:"#{prefix}_multivalue")
|
33
|
+
when 'multivalue_and' then send(:"#{prefix}_multivalue", :and)
|
34
|
+
when 'multivalue_or' then send(:"#{prefix}_multivalue", :or)
|
35
|
+
when 'exclusive_or' then send(:"#{prefix}_singlevalue")
|
36
|
+
# else raise UnknownSelectableType.new "Unknown selectable type #{selectable_type} for #{@type}"
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
private
|
41
|
+
def params
|
42
|
+
@params ||= group_params.dup
|
43
|
+
end
|
44
|
+
|
45
|
+
# Removing a value from the parameters
|
46
|
+
# Example:
|
47
|
+
# group_params = ['1','3','4']
|
48
|
+
# id = 3
|
49
|
+
# => {key => '1|4'}
|
50
|
+
def remove_multivalue(op=nil)
|
51
|
+
op = operator_mappings(op) || operator
|
52
|
+
p = params.reject{|v| matches?(v) }
|
53
|
+
|
54
|
+
if p.blank?
|
55
|
+
remove_singlevalue
|
56
|
+
else
|
57
|
+
{
|
58
|
+
key => p.reject(&:blank?)
|
59
|
+
.join( op )
|
60
|
+
}
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
# Remove a single value from params
|
65
|
+
#
|
66
|
+
def remove_singlevalue
|
67
|
+
{
|
68
|
+
key => nil
|
69
|
+
}
|
70
|
+
end
|
71
|
+
|
72
|
+
# Adding a value to the parameters
|
73
|
+
# Example:
|
74
|
+
# group_params = ['1','3','4']
|
75
|
+
# id = 5
|
76
|
+
# => {key => '1|3|4|5'}
|
77
|
+
def add_multivalue(op=nil)
|
78
|
+
op = operator_mappings(op) || operator
|
79
|
+
|
80
|
+
if params.blank?
|
81
|
+
add_singlevalue
|
82
|
+
else
|
83
|
+
{
|
84
|
+
key => (Array(params) + Array(id.to_s)).uniq
|
85
|
+
.join( op )
|
86
|
+
}
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
# Adding a single value to the parameters
|
91
|
+
# Example:
|
92
|
+
# group_params = ['7']
|
93
|
+
# id = 5
|
94
|
+
# => {key => '5'}
|
95
|
+
def add_singlevalue
|
96
|
+
{ key => id.to_s }
|
97
|
+
end
|
98
|
+
|
99
|
+
def matches?(v)
|
100
|
+
v.to_s == id.to_s
|
101
|
+
end
|
102
|
+
|
103
|
+
end
|
104
|
+
end
|
105
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
module Elasticsearch
|
2
|
+
module FacetedSearch
|
3
|
+
module Pagination
|
4
|
+
def total_count
|
5
|
+
search['hits']['total'].to_i
|
6
|
+
rescue
|
7
|
+
0
|
8
|
+
end
|
9
|
+
|
10
|
+
def total_pages
|
11
|
+
(total_count.to_f / limit_value.to_f).ceil
|
12
|
+
end
|
13
|
+
|
14
|
+
def limit_value
|
15
|
+
limit
|
16
|
+
end
|
17
|
+
|
18
|
+
def limit
|
19
|
+
(search_params[:limit] ||= 32).to_i
|
20
|
+
end
|
21
|
+
|
22
|
+
def current_page
|
23
|
+
(search_params[:page] ||= 1).to_i
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
@@ -0,0 +1,62 @@
|
|
1
|
+
module Elasticsearch
|
2
|
+
module FacetedSearch
|
3
|
+
module Sortable
|
4
|
+
|
5
|
+
# Setup by the parent class
|
6
|
+
# https://www.elastic.co/guide/en/elasticsearch/reference/current/search-request-sort.html
|
7
|
+
#
|
8
|
+
# => returns Array or Hash
|
9
|
+
# {
|
10
|
+
# label: "Relevant",
|
11
|
+
# value: "relevant",
|
12
|
+
# search: {...sort value(s) for elasticsearch...},
|
13
|
+
# default: false
|
14
|
+
# }
|
15
|
+
#
|
16
|
+
def sorts
|
17
|
+
[]
|
18
|
+
end
|
19
|
+
|
20
|
+
# Returns current sort hash to use for elasticsearch query
|
21
|
+
#
|
22
|
+
def current_sort_for_search
|
23
|
+
return unless current_sort.present?
|
24
|
+
current_sort[:search]
|
25
|
+
end
|
26
|
+
|
27
|
+
# Returns entire sort hash (Label, value, search....)
|
28
|
+
#
|
29
|
+
def current_sort
|
30
|
+
sorts.select{|x| x.fetch(:value) == selected_sort_value }.first || default_sort
|
31
|
+
end
|
32
|
+
|
33
|
+
private
|
34
|
+
|
35
|
+
# Selected sort value (params || default)
|
36
|
+
# => returns String
|
37
|
+
def selected_sort_value
|
38
|
+
sort_param.present? ? sort_param : default_sort_value
|
39
|
+
end
|
40
|
+
|
41
|
+
# Returns string for sort param even if invalid
|
42
|
+
# rescue required if search params is not a hash
|
43
|
+
#
|
44
|
+
def sort_param
|
45
|
+
search_params[:sort]
|
46
|
+
rescue
|
47
|
+
nil
|
48
|
+
end
|
49
|
+
|
50
|
+
# Returns string value of the default sort signified by
|
51
|
+
# => default: true
|
52
|
+
def default_sort_value
|
53
|
+
default_sort.fetch(:value, nil)
|
54
|
+
end
|
55
|
+
|
56
|
+
# Returns entire hash for sort for the default
|
57
|
+
def default_sort
|
58
|
+
sorts.select{|x| x.fetch(:default, false) }.first
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
@@ -0,0 +1,256 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
class DummyFacets
|
4
|
+
include Elasticsearch::FacetedSearch::FacetBase
|
5
|
+
facet_exclusive_or(:hd, 'media_hd', 'HD Media')
|
6
|
+
|
7
|
+
#mock
|
8
|
+
def type
|
9
|
+
['type_here']
|
10
|
+
end
|
11
|
+
|
12
|
+
#mock
|
13
|
+
def filter_hd?
|
14
|
+
end
|
15
|
+
|
16
|
+
#mock
|
17
|
+
def hd_value
|
18
|
+
true
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
module Elasticsearch
|
23
|
+
module FacetedSearch
|
24
|
+
describe FacetBase do
|
25
|
+
let(:model) { DummyFacets.new({}) }
|
26
|
+
|
27
|
+
describe "#class_facets" do
|
28
|
+
it "returns class facets" do
|
29
|
+
expect(DummyFacets).to receive(:facets) { {facet: true} }
|
30
|
+
expect(model.class_facets).to eq({facet: true})
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
describe "#query" do
|
35
|
+
|
36
|
+
it "removes an empty items" do
|
37
|
+
expect(model.query).to eq({:size=>32, :from=>0, :facets=>{:hd=>{:terms=>{:field=>"media_hd", :size=>70}}}})
|
38
|
+
end
|
39
|
+
|
40
|
+
describe "with facets and filters" do
|
41
|
+
it "merges filters when filter_query has value" do
|
42
|
+
expect(model).to receive(:filter_query).at_least(:once) { 'hi' }
|
43
|
+
expect(model).to receive(:facet_query) { nil }
|
44
|
+
expect(model.query).to eq({:size=>32, :from=>0, filter: {and: 'hi'}})
|
45
|
+
end
|
46
|
+
|
47
|
+
it "merges facets when facets has value" do
|
48
|
+
expect(model).to receive(:filter_query) { nil }
|
49
|
+
expect(model).to receive(:facet_query).at_least(:once) { 'hi' }
|
50
|
+
expect(model.query).to eq({:size=>32, :from=>0, facets: 'hi'})
|
51
|
+
end
|
52
|
+
|
53
|
+
it "can merge both hashes" do
|
54
|
+
expect(model).to receive(:filter_query).at_least(:once) { 'hi' }
|
55
|
+
expect(model).to receive(:facet_query).at_least(:once) { 'hi' }
|
56
|
+
expect(model.query).to eq({:size=>32, :from=>0, facets: 'hi', filter: {and: 'hi'}})
|
57
|
+
end
|
58
|
+
end
|
59
|
+
describe "with limits" do
|
60
|
+
before(:each) do
|
61
|
+
expect(model).to receive(:filter_query).at_least(:once) { 'hi' }
|
62
|
+
expect(model).to receive(:facet_query).at_least(:once) { 'hi' }
|
63
|
+
end
|
64
|
+
|
65
|
+
it "returns from params" do
|
66
|
+
expect(model).to receive(:search_params).at_least(:once) { {limit: '10'}}
|
67
|
+
expect(model.query).to eq({:size=>10, :from=>0, :filter=>{:and=>"hi"}, :facets=>"hi"})
|
68
|
+
end
|
69
|
+
|
70
|
+
it "defaults to 32" do
|
71
|
+
expect(model.query).to eq({:size=>32, :from=>0, :filter=>{:and=>"hi"}, :facets=>"hi"})
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
describe "with page" do
|
76
|
+
before(:each) do
|
77
|
+
expect(model).to receive(:filter_query).at_least(:once) { 'hi' }
|
78
|
+
expect(model).to receive(:facet_query).at_least(:once) { 'hi' }
|
79
|
+
expect(model).to receive(:search_params).at_least(:once) { {page: 10, limit: 3} }
|
80
|
+
end
|
81
|
+
|
82
|
+
it "returns from params" do
|
83
|
+
expect(model.query).to eq({:size=>3, :from=>27, :filter=>{:and=>"hi"}, :facets=>"hi"})
|
84
|
+
end
|
85
|
+
end
|
86
|
+
|
87
|
+
describe "with sort" do
|
88
|
+
before(:each) do
|
89
|
+
expect(model).to receive(:sorts).at_least(:once) {
|
90
|
+
[
|
91
|
+
{
|
92
|
+
label: "Relevant",
|
93
|
+
value: "relevant",
|
94
|
+
search: ["_score"],
|
95
|
+
default: false
|
96
|
+
},
|
97
|
+
{
|
98
|
+
label: "updated",
|
99
|
+
value: "updated",
|
100
|
+
search: ["updated"],
|
101
|
+
default: true
|
102
|
+
}
|
103
|
+
]
|
104
|
+
}
|
105
|
+
end
|
106
|
+
it "has a default sort" do
|
107
|
+
expect(model.query).to eq({:size=>32, :from=>0, :sort=>["updated"], :facets=>{:hd=>{:terms=>{:field=>"media_hd", :size=>70}}}})
|
108
|
+
end
|
109
|
+
|
110
|
+
it "can have a selected sort" do
|
111
|
+
expect(model).to receive(:search_params).at_least(:once) { {sort: 'relevant'} }
|
112
|
+
expect(model.query).to eq({:size=>32, :from=>0, :sort=>["_score"], :facets=>{:hd=>{:terms=>{:field=>"media_hd", :size=>70}}}})
|
113
|
+
end
|
114
|
+
end
|
115
|
+
end
|
116
|
+
|
117
|
+
describe "#facet_query" do
|
118
|
+
it "caches the request" do
|
119
|
+
expect(model).to receive(:build_facets).once {'blah'}
|
120
|
+
2.times { model.facet_query }
|
121
|
+
end
|
122
|
+
end
|
123
|
+
|
124
|
+
describe "#filter_query" do
|
125
|
+
it "caches the request" do
|
126
|
+
expect(model).to receive(:build_filters).once {'blah'}
|
127
|
+
2.times { model.filter_query }
|
128
|
+
end
|
129
|
+
end
|
130
|
+
|
131
|
+
describe "#search_params" do
|
132
|
+
it "returns the params" do
|
133
|
+
expect(model).to receive(:params) { 'boom' }
|
134
|
+
expect(model.search_params).to eq('boom')
|
135
|
+
end
|
136
|
+
end
|
137
|
+
|
138
|
+
describe "#build_facets" do
|
139
|
+
it "is private" do
|
140
|
+
expect {
|
141
|
+
model.build_facets
|
142
|
+
}.to raise_error(NoMethodError)
|
143
|
+
end
|
144
|
+
describe "with filters" do
|
145
|
+
it "equals a specific format" do
|
146
|
+
expect(model).to receive(:filter_query).at_least(:once) { [{terms: {:hello => ['world']}}] }
|
147
|
+
expect(model.send(:build_facets)).to eq(
|
148
|
+
{
|
149
|
+
:hd=>{
|
150
|
+
:terms=>{
|
151
|
+
:field=>"media_hd", :size=>70
|
152
|
+
},
|
153
|
+
:facet_filter=>{
|
154
|
+
:and=>[
|
155
|
+
{:terms=>{:hello=>["world"]}}
|
156
|
+
]
|
157
|
+
}
|
158
|
+
}
|
159
|
+
}
|
160
|
+
)
|
161
|
+
end
|
162
|
+
|
163
|
+
# Added spec due to improper merge on 0.0.2 release
|
164
|
+
# => if this spec is failing, elastic search is going to complain
|
165
|
+
it "has 2 keys" do
|
166
|
+
expect(model).to receive(:filter_query).at_least(:once) { [{terms: {:hello => ['world']}}] }
|
167
|
+
expect(model.send(:build_facets)[:hd].keys).to eq([:terms, :facet_filter])
|
168
|
+
end
|
169
|
+
|
170
|
+
it "can bypass the facet_filter declaration" do
|
171
|
+
expect(model.send(:build_facets, false)).to eq(
|
172
|
+
{
|
173
|
+
:hd=>{
|
174
|
+
:terms=>{
|
175
|
+
:field=>"media_hd", :size=>70
|
176
|
+
}
|
177
|
+
}
|
178
|
+
}
|
179
|
+
)
|
180
|
+
end
|
181
|
+
end
|
182
|
+
describe "without filters" do
|
183
|
+
before(:each) do
|
184
|
+
expect(model).to receive(:filter_query) { [] }
|
185
|
+
end
|
186
|
+
it "equals a specific format" do
|
187
|
+
expect(model.send(:build_facets)).to eq({
|
188
|
+
:hd=>{
|
189
|
+
:terms=>{:field=>"media_hd", :size=>70}
|
190
|
+
}
|
191
|
+
})
|
192
|
+
end
|
193
|
+
end
|
194
|
+
end
|
195
|
+
|
196
|
+
describe "#build_filters" do
|
197
|
+
it "is private" do
|
198
|
+
expect {
|
199
|
+
model.build_filters
|
200
|
+
}.to raise_error(NoMethodError)
|
201
|
+
end
|
202
|
+
describe "with #filter_XX? = true" do
|
203
|
+
before(:each) do
|
204
|
+
expect(model).to receive(:filter_hd?) { true }
|
205
|
+
end
|
206
|
+
|
207
|
+
it "returns filter hash for Elasticsearch" do
|
208
|
+
expect(model.send(:build_filters)).to eq([{:terms=>{"media_hd"=>[true]}}])
|
209
|
+
end
|
210
|
+
end
|
211
|
+
|
212
|
+
describe "with #filter_XX? = false" do
|
213
|
+
before(:each) do
|
214
|
+
expect(model).to receive(:filter_hd?) { false }
|
215
|
+
end
|
216
|
+
|
217
|
+
it "returns an empty array" do
|
218
|
+
expect(model.send(:build_filters)).to be_blank
|
219
|
+
end
|
220
|
+
end
|
221
|
+
end
|
222
|
+
|
223
|
+
describe "#values_for" do
|
224
|
+
it "is private" do
|
225
|
+
expect {
|
226
|
+
model.values_for(:hd)
|
227
|
+
}.to raise_error(NoMethodError)
|
228
|
+
end
|
229
|
+
|
230
|
+
it "always returns an array" do
|
231
|
+
expect(model).to receive(:temp_value) { 'hello' }
|
232
|
+
expect(model.send(:values_for, :temp)).to eq(['hello'])
|
233
|
+
end
|
234
|
+
end
|
235
|
+
|
236
|
+
describe "#execution_type" do
|
237
|
+
it "is private" do
|
238
|
+
expect {
|
239
|
+
model.execution_type('multivalue_or')
|
240
|
+
}.to raise_error(NoMethodError)
|
241
|
+
end
|
242
|
+
it "returns :or when 'multivalue_or'" do
|
243
|
+
expect(model.send(:execution_type, 'multivalue_or')).to eq(:or)
|
244
|
+
end
|
245
|
+
|
246
|
+
it "returns :and when 'multivalue_and'" do
|
247
|
+
expect(model.send(:execution_type, 'multivalue_and')).to eq(:and)
|
248
|
+
end
|
249
|
+
|
250
|
+
it "returns nil when 'exclusive_or'" do
|
251
|
+
expect(model.send(:execution_type, 'exclusive_or')).to be_nil
|
252
|
+
end
|
253
|
+
end
|
254
|
+
end
|
255
|
+
end
|
256
|
+
end
|