mini-smart-box 0.0.1
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/elasticsearch-rails-8.0.1/CHANGELOG.md +44 -0
- data/elasticsearch-rails-8.0.1/Gemfile +38 -0
- data/elasticsearch-rails-8.0.1/LICENSE.txt +202 -0
- data/elasticsearch-rails-8.0.1/README.md +149 -0
- data/elasticsearch-rails-8.0.1/Rakefile +67 -0
- data/elasticsearch-rails-8.0.1/elasticsearch-rails.gemspec +67 -0
- data/elasticsearch-rails-8.0.1/lib/elasticsearch/rails/instrumentation/controller_runtime.rb +58 -0
- data/elasticsearch-rails-8.0.1/lib/elasticsearch/rails/instrumentation/log_subscriber.rb +67 -0
- data/elasticsearch-rails-8.0.1/lib/elasticsearch/rails/instrumentation/publishers.rb +53 -0
- data/elasticsearch-rails-8.0.1/lib/elasticsearch/rails/instrumentation/railtie.rb +44 -0
- data/elasticsearch-rails-8.0.1/lib/elasticsearch/rails/instrumentation.rb +53 -0
- data/elasticsearch-rails-8.0.1/lib/elasticsearch/rails/lograge.rb +57 -0
- data/elasticsearch-rails-8.0.1/lib/elasticsearch/rails/tasks/import.rb +128 -0
- data/elasticsearch-rails-8.0.1/lib/elasticsearch/rails/version.rb +22 -0
- data/elasticsearch-rails-8.0.1/lib/elasticsearch/rails.rb +24 -0
- data/elasticsearch-rails-8.0.1/lib/rails/templates/01-basic.rb +364 -0
- data/elasticsearch-rails-8.0.1/lib/rails/templates/02-pretty.rb +348 -0
- data/elasticsearch-rails-8.0.1/lib/rails/templates/03-expert.rb +358 -0
- data/elasticsearch-rails-8.0.1/lib/rails/templates/04-dsl.rb +146 -0
- data/elasticsearch-rails-8.0.1/lib/rails/templates/05-settings-files.rb +88 -0
- data/elasticsearch-rails-8.0.1/lib/rails/templates/articles.yml.gz +0 -0
- data/elasticsearch-rails-8.0.1/lib/rails/templates/articles_settings.json +1 -0
- data/elasticsearch-rails-8.0.1/lib/rails/templates/index.html.dsl.erb +178 -0
- data/elasticsearch-rails-8.0.1/lib/rails/templates/index.html.erb +178 -0
- data/elasticsearch-rails-8.0.1/lib/rails/templates/indexer.rb +44 -0
- data/elasticsearch-rails-8.0.1/lib/rails/templates/search.css +76 -0
- data/elasticsearch-rails-8.0.1/lib/rails/templates/search_controller_test.dsl.rb +148 -0
- data/elasticsearch-rails-8.0.1/lib/rails/templates/search_controller_test.rb +148 -0
- data/elasticsearch-rails-8.0.1/lib/rails/templates/searchable.dsl.rb +234 -0
- data/elasticsearch-rails-8.0.1/lib/rails/templates/searchable.rb +224 -0
- data/elasticsearch-rails-8.0.1/lib/rails/templates/seeds.rb +75 -0
- data/elasticsearch-rails-8.0.1/spec/instrumentation/log_subscriber_spec.rb +57 -0
- data/elasticsearch-rails-8.0.1/spec/instrumentation_spec.rb +103 -0
- data/elasticsearch-rails-8.0.1/spec/lograge_spec.rb +52 -0
- data/elasticsearch-rails-8.0.1/spec/spec_helper.rb +65 -0
- data/mini-smart-box.gemspec +11 -0
- metadata +76 -0
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# Licensed to Elasticsearch B.V. under one or more contributor
|
|
2
|
+
# license agreements. See the NOTICE file distributed with
|
|
3
|
+
# this work for additional information regarding copyright
|
|
4
|
+
# ownership. Elasticsearch B.V. licenses this file to you under
|
|
5
|
+
# the Apache License, Version 2.0 (the "License"); you may
|
|
6
|
+
# not use this file except in compliance with the License.
|
|
7
|
+
# You may obtain a copy of the License at
|
|
8
|
+
#
|
|
9
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
#
|
|
11
|
+
# Unless required by applicable law or agreed to in writing,
|
|
12
|
+
# software distributed under the License is distributed on an
|
|
13
|
+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
14
|
+
# KIND, either express or implied. See the License for the
|
|
15
|
+
# specific language governing permissions and limitations
|
|
16
|
+
# under the License.
|
|
17
|
+
|
|
18
|
+
require 'test_helper'
|
|
19
|
+
|
|
20
|
+
class SearchControllerTest < ActionController::TestCase
|
|
21
|
+
setup do
|
|
22
|
+
travel_to Time.new(2015, 03, 16, 10, 00, 00, 0)
|
|
23
|
+
|
|
24
|
+
Article.delete_all
|
|
25
|
+
|
|
26
|
+
articles = [
|
|
27
|
+
{ title: 'Article One', abstract: 'One', content: 'One', published_on: 1.day.ago, category_title: 'One', author_first_name: 'John', author_last_name: 'Smith' },
|
|
28
|
+
{ title: 'Article One Another', abstract: '', content: '', published_on: 2.days.ago, category_title: 'One', author_first_name: 'John', author_last_name: 'Smith' },
|
|
29
|
+
{ title: 'Article One Two', abstract: '', content: '', published_on: 10.days.ago, category_title: 'Two', author_first_name: 'Mary', author_last_name: 'Smith' },
|
|
30
|
+
{ title: 'Article Two', abstract: '', content: '', published_on: 12.days.ago, category_title: 'Two', author_first_name: 'Mary', author_last_name: 'Smith' },
|
|
31
|
+
{ title: 'Article Three', abstract: '', content: '', published_on: 12.days.ago, category_title: 'Three', author_first_name: 'Alice', author_last_name: 'Smith' }
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
articles.each do |a|
|
|
35
|
+
article = Article.create! \
|
|
36
|
+
title: a[:title],
|
|
37
|
+
abstract: a[:abstract],
|
|
38
|
+
content: a[:content],
|
|
39
|
+
published_on: a[:published_on]
|
|
40
|
+
|
|
41
|
+
article.categories << Category.find_or_create_by!(title: a[:category_title])
|
|
42
|
+
|
|
43
|
+
article.authors << Author.find_or_create_by!(first_name: a[:author_first_name], last_name: a[:author_last_name])
|
|
44
|
+
|
|
45
|
+
article.save!
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
Article.find_by_title('Article Three').comments.create body: 'One'
|
|
49
|
+
|
|
50
|
+
Sidekiq::Worker.clear_all
|
|
51
|
+
|
|
52
|
+
Article.__elasticsearch__.import force: true
|
|
53
|
+
Article.__elasticsearch__.refresh_index!
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
test "should return search results" do
|
|
57
|
+
get :index, params: { q: 'one' }
|
|
58
|
+
assert_response :success
|
|
59
|
+
assert_equal 3, assigns(:articles).size
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
test "should return search results in comments" do
|
|
63
|
+
get :index, params: { q: 'one', comments: 'y' }
|
|
64
|
+
assert_response :success
|
|
65
|
+
|
|
66
|
+
assert_equal 4, assigns(:articles).size
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
test "should return highlighted snippets" do
|
|
70
|
+
get :index, params: { q: 'one' }
|
|
71
|
+
assert_response :success
|
|
72
|
+
assert_match %r{<em class="label label-highlight">One</em>}, assigns(:articles).first.highlight.title.first
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
test "should return suggestions" do
|
|
76
|
+
get :index, params: { q: 'one' }
|
|
77
|
+
assert_response :success
|
|
78
|
+
|
|
79
|
+
suggestions = assigns(:articles).response.suggestions
|
|
80
|
+
|
|
81
|
+
assert_equal 'one', suggestions['suggest_title'][0]['text']
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
test "should return facets" do
|
|
85
|
+
get :index, params: { q: 'one' }
|
|
86
|
+
assert_response :success
|
|
87
|
+
|
|
88
|
+
aggregations = assigns(:articles).response.response['aggregations']
|
|
89
|
+
|
|
90
|
+
assert_equal 2, aggregations['categories']['categories']['buckets'].size
|
|
91
|
+
assert_equal 2, aggregations['authors']['authors']['buckets'].size
|
|
92
|
+
assert_equal 2, aggregations['published']['published']['buckets'].size
|
|
93
|
+
|
|
94
|
+
assert_equal 'One', aggregations['categories']['categories']['buckets'][0]['key']
|
|
95
|
+
assert_equal 'John Smith', aggregations['authors']['authors']['buckets'][0]['key']
|
|
96
|
+
assert_equal 1425254400000, aggregations['published']['published']['buckets'][0]['key']
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
test "should sort on the published date" do
|
|
100
|
+
get :index, params: { q: 'one', s: 'published_on' }
|
|
101
|
+
assert_response :success
|
|
102
|
+
|
|
103
|
+
assert_equal 3, assigns(:articles).size
|
|
104
|
+
assert_equal '2015-03-15', assigns(:articles)[0].published_on
|
|
105
|
+
assert_equal '2015-03-14', assigns(:articles)[1].published_on
|
|
106
|
+
assert_equal '2015-03-06', assigns(:articles)[2].published_on
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
test "should sort on the published date when no query is provided" do
|
|
110
|
+
get :index, params: { q: '' }
|
|
111
|
+
assert_response :success
|
|
112
|
+
|
|
113
|
+
assert_equal 5, assigns(:articles).size
|
|
114
|
+
assert_equal '2015-03-15', assigns(:articles)[0].published_on
|
|
115
|
+
assert_equal '2015-03-14', assigns(:articles)[1].published_on
|
|
116
|
+
assert_equal '2015-03-06', assigns(:articles)[2].published_on
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
test "should filter search results and the author and published date facets when user selects a category" do
|
|
120
|
+
get :index, params: { q: 'one', c: 'One' }
|
|
121
|
+
assert_response :success
|
|
122
|
+
|
|
123
|
+
assert_equal 2, assigns(:articles).size
|
|
124
|
+
|
|
125
|
+
aggregations = assigns(:articles).response.response['aggregations']
|
|
126
|
+
|
|
127
|
+
assert_equal 1, aggregations['authors']['authors']['buckets'].size
|
|
128
|
+
assert_equal 1, aggregations['published']['published']['buckets'].size
|
|
129
|
+
|
|
130
|
+
# Do NOT filter the category facet
|
|
131
|
+
assert_equal 2, aggregations['categories']['categories']['buckets'].size
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
test "should filter search results and the category and published date facets when user selects a category" do
|
|
135
|
+
get :index, params: { q: 'one', a: 'Mary Smith' }
|
|
136
|
+
assert_response :success
|
|
137
|
+
|
|
138
|
+
assert_equal 1, assigns(:articles).size
|
|
139
|
+
|
|
140
|
+
aggregations = assigns(:articles).response.response['aggregations']
|
|
141
|
+
|
|
142
|
+
assert_equal 1, aggregations['categories']['categories']['buckets'].size
|
|
143
|
+
assert_equal 1, aggregations['published']['published']['buckets'].size
|
|
144
|
+
|
|
145
|
+
# Do NOT filter the authors facet
|
|
146
|
+
assert_equal 2, aggregations['authors']['authors']['buckets'].size
|
|
147
|
+
end
|
|
148
|
+
end
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
# Licensed to Elasticsearch B.V. under one or more contributor
|
|
2
|
+
# license agreements. See the NOTICE file distributed with
|
|
3
|
+
# this work for additional information regarding copyright
|
|
4
|
+
# ownership. Elasticsearch B.V. licenses this file to you under
|
|
5
|
+
# the Apache License, Version 2.0 (the "License"); you may
|
|
6
|
+
# not use this file except in compliance with the License.
|
|
7
|
+
# You may obtain a copy of the License at
|
|
8
|
+
#
|
|
9
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
#
|
|
11
|
+
# Unless required by applicable law or agreed to in writing,
|
|
12
|
+
# software distributed under the License is distributed on an
|
|
13
|
+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
14
|
+
# KIND, either express or implied. See the License for the
|
|
15
|
+
# specific language governing permissions and limitations
|
|
16
|
+
# under the License.
|
|
17
|
+
|
|
18
|
+
module Searchable
|
|
19
|
+
extend ActiveSupport::Concern
|
|
20
|
+
|
|
21
|
+
included do
|
|
22
|
+
include Elasticsearch::Model
|
|
23
|
+
|
|
24
|
+
# Customize the index name
|
|
25
|
+
#
|
|
26
|
+
index_name [Rails.application.engine_name, Rails.env].join('_')
|
|
27
|
+
|
|
28
|
+
# Set up index configuration and mapping
|
|
29
|
+
#
|
|
30
|
+
settings index: { number_of_shards: 1, number_of_replicas: 0 } do
|
|
31
|
+
mapping do
|
|
32
|
+
indexes :title, type: 'text' do
|
|
33
|
+
indexes :title, analyzer: 'snowball'
|
|
34
|
+
indexes :tokenized, analyzer: 'simple'
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
indexes :content, type: 'text' do
|
|
38
|
+
indexes :content, analyzer: 'snowball'
|
|
39
|
+
indexes :tokenized, analyzer: 'simple'
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
indexes :published_on, type: 'date'
|
|
43
|
+
|
|
44
|
+
indexes :authors do
|
|
45
|
+
indexes :full_name, type: 'text' do
|
|
46
|
+
indexes :full_name
|
|
47
|
+
indexes :raw, type: 'keyword'
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
indexes :categories, type: 'keyword'
|
|
52
|
+
|
|
53
|
+
indexes :comments, type: 'nested' do
|
|
54
|
+
indexes :body, analyzer: 'snowball'
|
|
55
|
+
indexes :stars
|
|
56
|
+
indexes :pick
|
|
57
|
+
indexes :user, type: 'keyword'
|
|
58
|
+
indexes :user_location, type: 'text' do
|
|
59
|
+
indexes :user_location
|
|
60
|
+
indexes :raw, type: 'keyword'
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Set up callbacks for updating the index on model changes
|
|
67
|
+
#
|
|
68
|
+
after_commit lambda { Indexer.perform_async(:index, self.class.to_s, self.id) }, on: :create
|
|
69
|
+
after_commit lambda { Indexer.perform_async(:update, self.class.to_s, self.id) }, on: :update
|
|
70
|
+
after_commit lambda { Indexer.perform_async(:delete, self.class.to_s, self.id) }, on: :destroy
|
|
71
|
+
after_touch lambda { Indexer.perform_async(:update, self.class.to_s, self.id) }
|
|
72
|
+
|
|
73
|
+
# Customize the JSON serialization for Elasticsearch
|
|
74
|
+
#
|
|
75
|
+
def as_indexed_json(options={})
|
|
76
|
+
hash = self.as_json(
|
|
77
|
+
include: { authors: { methods: [:full_name], only: [:full_name] },
|
|
78
|
+
comments: { only: [:body, :stars, :pick, :user, :user_location] }
|
|
79
|
+
})
|
|
80
|
+
hash['categories'] = self.categories.map(&:title)
|
|
81
|
+
hash
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Return documents matching the user's query, include highlights and aggregations in response,
|
|
85
|
+
# and implement a "cross" faceted navigation
|
|
86
|
+
#
|
|
87
|
+
# @param q [String] The user query
|
|
88
|
+
# @return [Elasticsearch::Model::Response::Response]
|
|
89
|
+
#
|
|
90
|
+
def self.search(q, options={})
|
|
91
|
+
@search_definition = Elasticsearch::DSL::Search.search do
|
|
92
|
+
query do
|
|
93
|
+
|
|
94
|
+
# If a user query is present...
|
|
95
|
+
#
|
|
96
|
+
unless q.blank?
|
|
97
|
+
bool do
|
|
98
|
+
|
|
99
|
+
# ... search in `title`, `abstract` and `content`, boosting `title`
|
|
100
|
+
#
|
|
101
|
+
should do
|
|
102
|
+
multi_match do
|
|
103
|
+
query q
|
|
104
|
+
fields ['title^10', 'abstract^2', 'content']
|
|
105
|
+
operator 'and'
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# ... search in comment body if user checked the comments checkbox
|
|
110
|
+
#
|
|
111
|
+
if q.present? && options[:comments]
|
|
112
|
+
should do
|
|
113
|
+
nested do
|
|
114
|
+
path :comments
|
|
115
|
+
query do
|
|
116
|
+
multi_match do
|
|
117
|
+
query q
|
|
118
|
+
fields 'comments.body'
|
|
119
|
+
operator 'and'
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# ... otherwise, just return all articles
|
|
128
|
+
else
|
|
129
|
+
match_all
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Filter the search results based on user selection
|
|
134
|
+
#
|
|
135
|
+
post_filter do
|
|
136
|
+
bool do
|
|
137
|
+
must { term categories: options[:category] } if options[:category]
|
|
138
|
+
must { match_all } if options.keys.none? { |k| [:c, :a, :w].include? k }
|
|
139
|
+
must { term 'authors.full_name.raw' => options[:author] } if options[:author]
|
|
140
|
+
must { range published_on: { gte: options[:published_week], lte: "#{options[:published_week]}||+1w" } } if options[:published_week]
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Return top categories for faceted navigation
|
|
145
|
+
#
|
|
146
|
+
aggregation :categories do
|
|
147
|
+
# Filter the aggregation with any selected `author` and `published_week`
|
|
148
|
+
#
|
|
149
|
+
f = Elasticsearch::DSL::Search::Filters::Bool.new
|
|
150
|
+
f.must { match_all }
|
|
151
|
+
f.must { term 'authors.full_name.raw' => options[:author] } if options[:author]
|
|
152
|
+
f.must { range published_on: { gte: options[:published_week], lte: "#{options[:published_week]}||+1w" } } if options[:published_week]
|
|
153
|
+
|
|
154
|
+
filter f.to_hash do
|
|
155
|
+
aggregation :categories do
|
|
156
|
+
terms field: 'categories'
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Return top authors for faceted navigation
|
|
162
|
+
#
|
|
163
|
+
aggregation :authors do
|
|
164
|
+
# Filter the aggregation with any selected `category` and `published_week`
|
|
165
|
+
#
|
|
166
|
+
f = Elasticsearch::DSL::Search::Filters::Bool.new
|
|
167
|
+
f.must { match_all }
|
|
168
|
+
f.must { term categories: options[:category] } if options[:category]
|
|
169
|
+
f.must { range published_on: { gte: options[:published_week], lte: "#{options[:published_week]}||+1w" } } if options[:published_week]
|
|
170
|
+
|
|
171
|
+
filter f do
|
|
172
|
+
aggregation :authors do
|
|
173
|
+
terms field: 'authors.full_name.raw'
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Return the published date ranges for faceted navigation
|
|
179
|
+
#
|
|
180
|
+
aggregation :published do
|
|
181
|
+
# Filter the aggregation with any selected `author` and `category`
|
|
182
|
+
#
|
|
183
|
+
f = Elasticsearch::DSL::Search::Filters::Bool.new
|
|
184
|
+
f.must { match_all }
|
|
185
|
+
f.must { term 'authors.full_name.raw' => options[:author] } if options[:author]
|
|
186
|
+
f.must { term categories: options[:category] } if options[:category]
|
|
187
|
+
|
|
188
|
+
filter f do
|
|
189
|
+
aggregation :published do
|
|
190
|
+
date_histogram do
|
|
191
|
+
field 'published_on'
|
|
192
|
+
interval 'week'
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Highlight the snippets in results
|
|
199
|
+
#
|
|
200
|
+
highlight do
|
|
201
|
+
fields title: { number_of_fragments: 0 },
|
|
202
|
+
abstract: { number_of_fragments: 0 },
|
|
203
|
+
content: { fragment_size: 50 }
|
|
204
|
+
|
|
205
|
+
field 'comments.body', fragment_size: 50 if q.present? && options[:comments]
|
|
206
|
+
|
|
207
|
+
pre_tags '<em class="label label-highlight">'
|
|
208
|
+
post_tags '</em>'
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
case
|
|
212
|
+
# By default, sort by relevance, but when a specific sort option is present, use it ...
|
|
213
|
+
#
|
|
214
|
+
when options[:sort]
|
|
215
|
+
sort options[:sort].to_sym => 'desc'
|
|
216
|
+
track_scores true
|
|
217
|
+
#
|
|
218
|
+
# ... when there's no user query, sort on published date
|
|
219
|
+
#
|
|
220
|
+
when q.blank?
|
|
221
|
+
sort published_on: 'desc'
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Return suggestions unless there's no query from the user
|
|
225
|
+
unless q.blank?
|
|
226
|
+
suggest :suggest_title, text: q, term: { field: 'title.tokenized', suggest_mode: 'always' }
|
|
227
|
+
suggest :suggest_body, text: q, term: { field: 'content.tokenized', suggest_mode: 'always' }
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
__elasticsearch__.search(@search_definition)
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
end
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
# Licensed to Elasticsearch B.V. under one or more contributor
|
|
2
|
+
# license agreements. See the NOTICE file distributed with
|
|
3
|
+
# this work for additional information regarding copyright
|
|
4
|
+
# ownership. Elasticsearch B.V. licenses this file to you under
|
|
5
|
+
# the Apache License, Version 2.0 (the "License"); you may
|
|
6
|
+
# not use this file except in compliance with the License.
|
|
7
|
+
# You may obtain a copy of the License at
|
|
8
|
+
#
|
|
9
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
#
|
|
11
|
+
# Unless required by applicable law or agreed to in writing,
|
|
12
|
+
# software distributed under the License is distributed on an
|
|
13
|
+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
14
|
+
# KIND, either express or implied. See the License for the
|
|
15
|
+
# specific language governing permissions and limitations
|
|
16
|
+
# under the License.
|
|
17
|
+
|
|
18
|
+
module Searchable
|
|
19
|
+
extend ActiveSupport::Concern
|
|
20
|
+
|
|
21
|
+
included do
|
|
22
|
+
include Elasticsearch::Model
|
|
23
|
+
|
|
24
|
+
# Customize the index name
|
|
25
|
+
#
|
|
26
|
+
index_name [Rails.application.engine_name, Rails.env].join('_')
|
|
27
|
+
|
|
28
|
+
# Set up index configuration and mapping
|
|
29
|
+
#
|
|
30
|
+
settings index: { number_of_shards: 1, number_of_replicas: 0 } do
|
|
31
|
+
mapping do
|
|
32
|
+
indexes :title, type: 'text' do
|
|
33
|
+
indexes :title, analyzer: 'snowball'
|
|
34
|
+
indexes :tokenized, analyzer: 'simple'
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
indexes :content, type: 'text' do
|
|
38
|
+
indexes :content, analyzer: 'snowball'
|
|
39
|
+
indexes :tokenized, analyzer: 'simple'
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
indexes :published_on, type: 'date'
|
|
43
|
+
|
|
44
|
+
indexes :authors do
|
|
45
|
+
indexes :full_name, type: 'text' do
|
|
46
|
+
indexes :full_name
|
|
47
|
+
indexes :raw, type: 'keyword'
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
indexes :categories, type: 'keyword'
|
|
52
|
+
|
|
53
|
+
indexes :comments, type: 'nested' do
|
|
54
|
+
indexes :body, analyzer: 'snowball'
|
|
55
|
+
indexes :stars
|
|
56
|
+
indexes :pick
|
|
57
|
+
indexes :user, type: 'keyword'
|
|
58
|
+
indexes :user_location, type: 'text' do
|
|
59
|
+
indexes :user_location
|
|
60
|
+
indexes :raw, type: 'keyword'
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Set up callbacks for updating the index on model changes
|
|
67
|
+
#
|
|
68
|
+
after_commit lambda { Indexer.perform_async(:index, self.class.to_s, self.id) }, on: :create
|
|
69
|
+
after_commit lambda { Indexer.perform_async(:update, self.class.to_s, self.id) }, on: :update
|
|
70
|
+
after_commit lambda { Indexer.perform_async(:delete, self.class.to_s, self.id) }, on: :destroy
|
|
71
|
+
after_touch lambda { Indexer.perform_async(:update, self.class.to_s, self.id) }
|
|
72
|
+
|
|
73
|
+
# Customize the JSON serialization for Elasticsearch
|
|
74
|
+
#
|
|
75
|
+
def as_indexed_json(options={})
|
|
76
|
+
hash = self.as_json(
|
|
77
|
+
include: { authors: { methods: [:full_name], only: [:full_name] },
|
|
78
|
+
comments: { only: [:body, :stars, :pick, :user, :user_location] }
|
|
79
|
+
})
|
|
80
|
+
hash['categories'] = self.categories.map(&:title)
|
|
81
|
+
hash
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Search in title and content fields for `query`, include highlights in response
|
|
85
|
+
#
|
|
86
|
+
# @param query [String] The user query
|
|
87
|
+
# @return [Elasticsearch::Model::Response::Response]
|
|
88
|
+
#
|
|
89
|
+
def self.search(query, options={})
|
|
90
|
+
|
|
91
|
+
# Prefill and set the filters (top-level `post_filter` and aggregation `filter` elements)
|
|
92
|
+
#
|
|
93
|
+
__set_filters = lambda do |key, f|
|
|
94
|
+
@search_definition[:post_filter][:bool] ||= {}
|
|
95
|
+
@search_definition[:post_filter][:bool][:must] ||= []
|
|
96
|
+
@search_definition[:post_filter][:bool][:must] |= [f]
|
|
97
|
+
|
|
98
|
+
@search_definition[:aggregations][key.to_sym][:filter][:bool][:must] ||= []
|
|
99
|
+
@search_definition[:aggregations][key.to_sym][:filter][:bool][:must] |= [f]
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
@search_definition = {
|
|
103
|
+
query: {},
|
|
104
|
+
|
|
105
|
+
highlight: {
|
|
106
|
+
pre_tags: ['<em class="label label-highlight">'],
|
|
107
|
+
post_tags: ['</em>'],
|
|
108
|
+
fields: {
|
|
109
|
+
title: { number_of_fragments: 0 },
|
|
110
|
+
abstract: { number_of_fragments: 0 },
|
|
111
|
+
content: { fragment_size: 50 }
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
|
|
115
|
+
post_filter: { bool: { must: [ match_all: {} ] } },
|
|
116
|
+
|
|
117
|
+
aggregations: {
|
|
118
|
+
categories: {
|
|
119
|
+
filter: { bool: { must: [ match_all: {} ] } },
|
|
120
|
+
aggregations: { categories: { terms: { field: 'categories' } } }
|
|
121
|
+
},
|
|
122
|
+
authors: {
|
|
123
|
+
filter: { bool: { must: [ match_all: {} ] } },
|
|
124
|
+
aggregations: { authors: { terms: { field: 'authors.full_name.raw' } } }
|
|
125
|
+
},
|
|
126
|
+
published: {
|
|
127
|
+
filter: { bool: { must: [ match_all: {} ] } },
|
|
128
|
+
aggregations: {
|
|
129
|
+
published: { date_histogram: { field: 'published_on', interval: 'week' } }
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
unless query.blank?
|
|
136
|
+
@search_definition[:query] = {
|
|
137
|
+
bool: {
|
|
138
|
+
should: [
|
|
139
|
+
{ multi_match: {
|
|
140
|
+
query: query,
|
|
141
|
+
fields: ['title^10', 'abstract^2', 'content'],
|
|
142
|
+
operator: 'and'
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
]
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
else
|
|
149
|
+
@search_definition[:query] = { match_all: {} }
|
|
150
|
+
@search_definition[:sort] = { published_on: 'desc' }
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
if options[:category]
|
|
154
|
+
f = { term: { categories: options[:category] } }
|
|
155
|
+
|
|
156
|
+
__set_filters.(:authors, f)
|
|
157
|
+
__set_filters.(:published, f)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
if options[:author]
|
|
161
|
+
f = { term: { 'authors.full_name.raw' => options[:author] } }
|
|
162
|
+
|
|
163
|
+
__set_filters.(:categories, f)
|
|
164
|
+
__set_filters.(:published, f)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
if options[:published_week]
|
|
168
|
+
f = {
|
|
169
|
+
range: {
|
|
170
|
+
published_on: {
|
|
171
|
+
gte: options[:published_week],
|
|
172
|
+
lte: "#{options[:published_week]}||+1w"
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
__set_filters.(:categories, f)
|
|
178
|
+
__set_filters.(:authors, f)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
if query.present? && options[:comments]
|
|
182
|
+
@search_definition[:query][:bool][:should] ||= []
|
|
183
|
+
@search_definition[:query][:bool][:should] << {
|
|
184
|
+
nested: {
|
|
185
|
+
path: 'comments',
|
|
186
|
+
query: {
|
|
187
|
+
multi_match: {
|
|
188
|
+
query: query,
|
|
189
|
+
fields: ['comments.body'],
|
|
190
|
+
operator: 'and'
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
@search_definition[:highlight][:fields].update 'comments.body' => { fragment_size: 50 }
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
if options[:sort]
|
|
199
|
+
@search_definition[:sort] = { options[:sort] => 'desc' }
|
|
200
|
+
@search_definition[:track_scores] = true
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
unless query.blank?
|
|
204
|
+
@search_definition[:suggest] = {
|
|
205
|
+
text: query,
|
|
206
|
+
suggest_title: {
|
|
207
|
+
term: {
|
|
208
|
+
field: 'title.tokenized',
|
|
209
|
+
suggest_mode: 'always'
|
|
210
|
+
}
|
|
211
|
+
},
|
|
212
|
+
suggest_body: {
|
|
213
|
+
term: {
|
|
214
|
+
field: 'content.tokenized',
|
|
215
|
+
suggest_mode: 'always'
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
__elasticsearch__.search(@search_definition)
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# Licensed to Elasticsearch B.V. under one or more contributor
|
|
2
|
+
# license agreements. See the NOTICE file distributed with
|
|
3
|
+
# this work for additional information regarding copyright
|
|
4
|
+
# ownership. Elasticsearch B.V. licenses this file to you under
|
|
5
|
+
# the Apache License, Version 2.0 (the "License"); you may
|
|
6
|
+
# not use this file except in compliance with the License.
|
|
7
|
+
# You may obtain a copy of the License at
|
|
8
|
+
#
|
|
9
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
#
|
|
11
|
+
# Unless required by applicable law or agreed to in writing,
|
|
12
|
+
# software distributed under the License is distributed on an
|
|
13
|
+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
14
|
+
# KIND, either express or implied. See the License for the
|
|
15
|
+
# specific language governing permissions and limitations
|
|
16
|
+
# under the License.
|
|
17
|
+
|
|
18
|
+
require 'zlib'
|
|
19
|
+
require 'yaml'
|
|
20
|
+
|
|
21
|
+
Zlib::GzipReader.open(File.expand_path('../articles.yml.gz', __FILE__)) do |gzip|
|
|
22
|
+
puts "Reading articles from gzipped YAML..."
|
|
23
|
+
@documents = YAML.respond_to?(:load_documents) ? YAML.load_documents(gzip.read) :
|
|
24
|
+
YAML.load_stream(gzip.read)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Truncate the default ActiveRecord logger output
|
|
28
|
+
ActiveRecord::Base.logger = ActiveSupport::Logger.new(STDERR)
|
|
29
|
+
ActiveRecord::Base.logger.instance_eval do
|
|
30
|
+
@formatter = lambda do |s, d, p, message|
|
|
31
|
+
message
|
|
32
|
+
.gsub(/\[("content", ".*?")\]/m) { |match| match[0..100] + '..."]' }
|
|
33
|
+
.gsub(/\[("body", ".*?")\]/m ) { |match| match[0..100] + '..."]' }
|
|
34
|
+
.strip
|
|
35
|
+
.concat("\n")
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Reduce verbosity and truncate the request body of Elasticsearch logger
|
|
40
|
+
Article.__elasticsearch__.client.transport.tracer.level = Logger::INFO
|
|
41
|
+
Article.__elasticsearch__.client.transport.tracer.formatter = lambda do |s, d, p, message|
|
|
42
|
+
"\n\n" + (message.size > 105 ? message[0..105].concat("...}'") : message) + "\n\n"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Skip model callbacks
|
|
46
|
+
%w| _touch_callbacks
|
|
47
|
+
_commit_callbacks
|
|
48
|
+
after_add_for_categories
|
|
49
|
+
after_add_for_authorships
|
|
50
|
+
after_add_for_authors
|
|
51
|
+
after_add_for_comments |.each do |c|
|
|
52
|
+
Article.class.__send__ :define_method, c do; []; end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
@documents.each do |document|
|
|
56
|
+
article = Article.create! document.slice(:title, :abstract, :content, :url, :shares, :published_on)
|
|
57
|
+
|
|
58
|
+
article.categories = document[:categories].map do |d|
|
|
59
|
+
Category.find_or_create_by! title: d
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
article.authors = document[:authors].map do |d|
|
|
63
|
+
first_name, last_name = d.split(' ').compact.map(&:strip)
|
|
64
|
+
Author.find_or_create_by! first_name: first_name, last_name: last_name
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
document[:comments].each { |d| article.comments.create! d }
|
|
68
|
+
|
|
69
|
+
article.save!
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Remove any jobs from the "elasticsearch" Sidekiq queue
|
|
73
|
+
#
|
|
74
|
+
require 'sidekiq/api'
|
|
75
|
+
Sidekiq::Queue.new("elasticsearch").clear
|