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
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
|
+
[![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
|
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
|