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
checksums.yaml
ADDED
@@ -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
|
data/CHANGELOG.md
ADDED
@@ -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
|
data/README.md
ADDED
@@ -0,0 +1,216 @@
|
|
1
|
+
# Elasticsearch::FacetedSearch
|
2
|
+
|
3
|
+
[](https://codeclimate.com/github/spodlecki/elasticsearch-facetedsearch)
|
4
|
+
[](https://codeclimate.com/github/spodlecki/elasticsearch-facetedsearch/coverage)
|
5
|
+
[](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
|
data/Rakefile
ADDED
@@ -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
|