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.
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 1e6625292febfa91f3b4c88c2664eab607d2edf3
4
+ data.tar.gz: 23929ddb68d398a41a4dc49fb55f6f8fd5d1504b
5
+ SHA512:
6
+ metadata.gz: 8614e10f04da9baf552148827245a2bd93a1e66974155a844209ca97717381a2e5effa84cf3a2465c828a02357abfae6d392a6a8d9ae5a6ff38f646cc8be0dd3
7
+ data.tar.gz: 3e9b1ef954bb8d7931549eff70b8e81fd3520a40211524b9aea094ce630bbae197ab4079fc03257c8a56234b2cd3f05af8853922cb2331a7ed472c8eef0b8a7b
@@ -0,0 +1,18 @@
1
+ # 0.0.4 - hotfix
2
+
3
+ - Pagination was occationally missing a page due to a non-float
4
+
5
+ # 0.0.3 - hotfix
6
+
7
+ - Added spec due to improper merge on 0.0.2 tag
8
+ - Fixed bug with bad hash merge on :facet_filter parameter
9
+
10
+ # 0.0.2
11
+
12
+ - Allow facets to keep counts regardless of filters applied
13
+ - Updating readme to reflect this capability
14
+
15
+ # 0.0.1
16
+
17
+ - Initial release
18
+ - Contains full test suite
@@ -0,0 +1,216 @@
1
+ # Elasticsearch::FacetedSearch
2
+
3
+ [![Code Climate](https://codeclimate.com/github/spodlecki/elasticsearch-facetedsearch/badges/gpa.svg)](https://codeclimate.com/github/spodlecki/elasticsearch-facetedsearch)
4
+ [![Test Coverage](https://codeclimate.com/github/spodlecki/elasticsearch-facetedsearch/badges/coverage.svg)](https://codeclimate.com/github/spodlecki/elasticsearch-facetedsearch/coverage)
5
+ [![Build Status](https://travis-ci.org/spodlecki/elasticsearch-facetedsearch.svg)](https://travis-ci.org/spodlecki/elasticsearch-facetedsearch)
6
+
7
+ Quickly add faceted searching to your Rails app. This gem is opinionated as to how faceted searching works. **Filters are applied to the counts** so the counts themselves will change while different filters are applied.
8
+
9
+ ## Installation
10
+
11
+ Add this line to your application's Gemfile:
12
+
13
+ gem 'elasticsearch-facetedsearch'
14
+
15
+ And then execute:
16
+
17
+ $ bundle
18
+
19
+ Create `config/initializers/elasticsearch.rb`. We normally namespace our indexed like below.
20
+
21
+ ELASTICSEARCH_INDEX = [
22
+ Rails.env.development? && `whoami`.strip,
23
+ Rails.env,
24
+ Rails.application.class.to_s.split("::").first.downcase
25
+ ].reject(&:blank?).join('_')
26
+
27
+ # Optional concepts to help with indexing / connection
28
+ ELASTICSEARCH_MODELS = []
29
+ ELASTICSEARCH_SERVER = 'http://lalaland.com:9200'
30
+
31
+ Elasticsearch::Model.client = Elasticsearch::Client.new({
32
+ log: false,
33
+ host: ELASTICSEARCH_SERVER,
34
+ retry_on_failure: 5,
35
+ reload_connections: true
36
+ })
37
+
38
+ ## Dependencies
39
+
40
+ - [Elasticsearch::Model](https://github.com/elastic/elasticsearch-rails/tree/master/elasticsearch-model)
41
+ - [Rails > 3.2.8](http://rubyonrails.org/)
42
+ - [Elasticsearch Server > 1.0.1](http://www.elastic.co)
43
+
44
+ ## Usage
45
+
46
+ ### Controller
47
+
48
+ # Good idea to prefilter the params with strong_params
49
+ #
50
+ def search
51
+ @search = FruitFacetsSearch.new(params)
52
+
53
+ # Fetch results
54
+ # @search.results
55
+
56
+ # Fetch facets
57
+ # @search.facets
58
+ end
59
+
60
+ ### Facet Search Class
61
+
62
+ class FruitFacetsSearch
63
+ include Elasticsearch::FacetedSearch::FacetBase
64
+
65
+ # ... include other facet classes (examples below) ...
66
+ include Elasticsearch::FacetedSearch::FacetColor
67
+
68
+ # *required
69
+ # The type to search for (Elasticsearch Type)
70
+ # Can be an Array or String
71
+ def type
72
+ 'fruit'
73
+ end
74
+
75
+ # Use this to add a query search or something
76
+ # Probably best to cache the results
77
+ def query
78
+ @query ||= super
79
+ end
80
+
81
+ # Apply additional pre-filters
82
+ # If overwriting this method, ensure to call super, and ensure to cache the results
83
+ # => require color to be 'blue'
84
+ def filter_query
85
+ @filter_query ||= begin
86
+ fq = super
87
+ fq << { term: { color: 'blue' } }
88
+ fq
89
+ end
90
+ end
91
+
92
+ # Force specific limit or allow changable #s
93
+ #
94
+ def limit
95
+ 33
96
+ end
97
+
98
+ # Whitelisted collection of sortable options
99
+ def sorts
100
+ [
101
+ {
102
+ label: "Relevant",
103
+ value: "relevant",
104
+ search: [
105
+ "_score"
106
+ ],
107
+ default: true
108
+ }
109
+ ]
110
+ end
111
+
112
+ # Want to always keep facet counts the same regardless of filters applied?
113
+ # pass true to keep counts scoped to search, & false to remove filters entirely
114
+ #
115
+ def build_facets
116
+ super(false)
117
+ end
118
+ end
119
+
120
+ ### Facet Creation Class
121
+
122
+ module Elasticsearch
123
+ module FacetedSearch
124
+ module FacetColor
125
+ extend ActiveSupport::Concern
126
+
127
+ included do
128
+ # Adds the facet to the class.facets collection
129
+ #
130
+ # Available types:
131
+ # => facet_multivalue
132
+ # => facet_multivalue_and(:ident, 'elasticsearch_field', 'Human String')
133
+ # - Allows multiple values, but filters with :and execution
134
+ # => facet_multivalue_or(:ident, 'elasticsearch_field', 'Human String')
135
+ # - Allows multiple values, but filters with :or execution
136
+ # => facet_exclusive_or(:ident, 'elasticsearch_field', 'Human String')
137
+ # - Allows single value only
138
+ #
139
+ facet_multivalue_or(:color, 'color_field', 'Skin Color')
140
+ end
141
+
142
+ # *required
143
+ # Should we apply the filter for this facet?
144
+ # __Replace 'color' with the :ident value of your facet key
145
+ def filter_color?
146
+ valid_color?
147
+ end
148
+
149
+ # *required
150
+ # Returns the array of selected values
151
+ # __Replace 'color' with the :ident value of your facet key
152
+ # __You should really take this time to whitelist the values and remove any noise. Elasticsearch can be picky if you're searching a number field and pass it an alpha character
153
+ def color_value
154
+ return unless valid_color?
155
+ search_params[:color].split( operator_for(:color) )
156
+ end
157
+
158
+ # (optional)
159
+ # By default, Elasticsearch only returns terms that are pertaining to the specific search. If a result was filtered out, that term would not show up.
160
+ # Normally this isn't optimal... create this method and return an array of hashes with id and term keys.
161
+ #
162
+ # __Replace 'color' with the :ident value of your facet key
163
+ def color_collection
164
+ @color_collection ||= begin
165
+ Color.all.map{|x| {id: x.id, term: x.name}}
166
+ end
167
+ end
168
+
169
+ # (concept)
170
+ # Use these type of helper methods to validate the information given to you by users
171
+ #
172
+ def valid_color?
173
+ search_params[:color].present? && !!(search_params[:color] =~ /[\|[0-9]+]/)
174
+ end
175
+ end
176
+ end
177
+ end
178
+
179
+ ### Using Sortable
180
+
181
+ The only requirement to change sort options is to apply a `:sort` http param
182
+
183
+ ### Pagniation
184
+
185
+ Pagination is supported, but only tested with Kaminari.
186
+
187
+ ### HTML
188
+
189
+ **Facets**
190
+
191
+ %ul
192
+ -@search.facets.each do |group|
193
+ %li.title=group.title
194
+ -group.items.each do |item|
195
+ - # Assuming you have some dynamic urls, you can use the url_for and merge in the params
196
+ %li=item.link_to("#{item.term} (#{item.count})", url_for(params.merge(item.params_for)))
197
+
198
+ **Results**
199
+
200
+ %ul
201
+ -@search.results.each do |item|
202
+ - # item is now direct reference to the elastic search _source
203
+ - # item also contains item._type that displays the Elasticsearch type field
204
+ %li=item.id
205
+
206
+ ## Contributing
207
+
208
+ 1. Fork it
209
+ 2. Create your feature branch (`git checkout -b my-new-feature`)
210
+ 3. Commit your changes (`git commit -am 'Add some feature'`)
211
+ 4. Push to the branch (`git push origin my-new-feature`)
212
+ 5. Create new Pull Request
213
+
214
+ ## TODO
215
+
216
+ - Setup facet generator
@@ -0,0 +1,8 @@
1
+ require 'rspec/core/rake_task'
2
+
3
+ RSpec::Core::RakeTask.new('spec')
4
+
5
+ # If you want to make this the default task
6
+ task :default => :spec
7
+
8
+ require "bundler/gem_tasks"
@@ -0,0 +1,15 @@
1
+ require 'active_support'
2
+ require 'active_support/core_ext'
3
+ require 'elasticsearch/model'
4
+ require "elasticsearch/faceted_search/version"
5
+ require "elasticsearch/faceted_search/pagination"
6
+ require "elasticsearch/faceted_search/sortable"
7
+ require "elasticsearch/faceted_search/facet_base"
8
+
9
+ require "elasticsearch/faceted_search/facet_group"
10
+ require "elasticsearch/faceted_search/facet_item"
11
+
12
+ module Elasticsearch
13
+ module FacetedSearch
14
+ end
15
+ end
@@ -0,0 +1,184 @@
1
+ require 'ostruct'
2
+ require 'elasticsearch/model'
3
+
4
+ module Elasticsearch
5
+ module FacetedSearch
6
+ module FacetBase
7
+ include Pagination
8
+ include Sortable
9
+
10
+ extend ActiveSupport::Concern
11
+
12
+ included do
13
+ extend ClassMethods
14
+ attr_accessor :params
15
+
16
+ def initialize(p)
17
+ self.params = p
18
+ end
19
+
20
+ def results
21
+ @results ||= search['hits']['hits'].map{|x| Hashie::Mash.new(x['_source'].merge('_type' => x['_type'])) }
22
+ end
23
+
24
+ def facets
25
+ @facets ||= search['facets'].map{|key, values| FacetGroup.new(self, key, values) }
26
+ end
27
+
28
+ def search
29
+ @search ||= Elasticsearch::Model.client.search({
30
+ index: ELASTICSEARCH_INDEX,
31
+ type: type,
32
+ body: query
33
+ })
34
+ end
35
+ end
36
+
37
+ ## self <<
38
+ module ClassMethods
39
+
40
+ def facets
41
+ @facets || []
42
+ end
43
+
44
+ def facet(name, field, type, title)
45
+ @facets ||= {}
46
+ @facets.merge!({
47
+ name.to_sym => {
48
+ field: field,
49
+ type: type,
50
+ title: title
51
+ }
52
+ })
53
+ end
54
+
55
+ def facet_multivalue(name, field, title = nil)
56
+ facet(name, field, 'multivalue', title)
57
+ end
58
+
59
+ def facet_multivalue_and(name, field, title = nil)
60
+ facet(name, field, 'multivalue_and', title)
61
+ end
62
+
63
+ def facet_multivalue_or(name, field, title = nil)
64
+ facet(name, field, 'multivalue_or', title)
65
+ end
66
+
67
+ def facet_exclusive_or(name, field, title = nil)
68
+ facet(name, field, 'exclusive_or', title)
69
+ end
70
+ end
71
+ # / self
72
+
73
+ ##############
74
+ # Instance
75
+ #
76
+ def class_facets
77
+ self.class.facets.dup
78
+ end
79
+
80
+ def query
81
+ q = {
82
+ size: limit,
83
+ from: ([current_page.to_i, 1].max - 1) * limit,
84
+ sort: current_sort_for_search
85
+ }
86
+
87
+ # Filters
88
+ q.merge!({
89
+ :filter => {
90
+ :and => filter_query
91
+ }
92
+ }) unless filter_query.blank?
93
+
94
+ # Facets
95
+ q.merge!({
96
+ :facets => facet_query
97
+ }) unless facet_query.blank?
98
+
99
+ q.reject{|k,v| v.blank? }
100
+ end
101
+
102
+ def facet_query
103
+ @facet_query ||= build_facets
104
+ end
105
+
106
+ def filter_query
107
+ @filter_query ||= build_filters
108
+ end
109
+
110
+ def search_params
111
+ params
112
+ end
113
+
114
+ protected
115
+
116
+ def operator_mappings(i=nil)
117
+ FacetGroup::OPERATOR_MAPPING.fetch(i, nil)
118
+ end
119
+
120
+ def operator_for(key)
121
+ operator_mappings(
122
+ execution_type(
123
+ class_facets[key][:type]
124
+ )
125
+ )
126
+ end
127
+
128
+ def execution_type(type)
129
+ case type
130
+ when 'multivalue'
131
+ # TODO: Based off params
132
+ when 'multivalue_or'
133
+ :or
134
+ when 'multivalue_and'
135
+ :and
136
+ when 'exclusive_or'
137
+ nil
138
+ end
139
+ end
140
+
141
+ def facet_size_allowed
142
+ 70
143
+ end
144
+
145
+ private
146
+
147
+ def build_facets(filter_counts=true)
148
+ h = {}
149
+ filtered_facets = filter_counts && filter_query.present? ? {facet_filter: { :and => filter_query }} : {}
150
+
151
+ class_facets.each do |k,v|
152
+ h.merge!({
153
+ k => {
154
+ terms: {
155
+ field: v[:field],
156
+ size: facet_size_allowed
157
+ }
158
+ }.merge(filtered_facets).reject{|k,v| v.blank?}
159
+ })
160
+ end
161
+
162
+ h
163
+ end
164
+
165
+ # Whitelist and filter
166
+ def build_filters
167
+ class_facets.map do |type, info|
168
+ if respond_to?(:"filter_#{type}?") and public_send("filter_#{type}?")
169
+ {
170
+ terms: {
171
+ info[:field] => values_for(type),
172
+ :execution => execution_type(info[:type])
173
+ }.reject{|k,v| v.blank?}
174
+ }
175
+ end
176
+ end.compact
177
+ end
178
+
179
+ def values_for(facet_type)
180
+ Array(public_send("#{facet_type}_value"))
181
+ end
182
+ end
183
+ end
184
+ end