elasticsearch-facetedsearch 0.0.4
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 +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
|