elasticsearch-facetedsearch 0.0.4

Sign up to get free protection for your applications and to get access to all the features.
@@ -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,5 @@
1
+ module Elasticsearch
2
+ module FacetedSearch
3
+ VERSION = "0.0.4"
4
+ end
5
+ 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