estella 0.2.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: bf61c93e5566773e32ae933239e245236cce24e3
4
+ data.tar.gz: c3d455dacea867bd1e53d325120991ff59cc0979
5
+ SHA512:
6
+ metadata.gz: 56bb154fc881a5670a089247dbb4d83134cbbb9303707835a3138ae987d13385e3598c2b7841ce912e635d5177885cda1441e321db7854cfcc0df87765ee90ea
7
+ data.tar.gz: 4b6ad124cba5f0a4bfd777de3afcf81c6d17af16796ff160825b190a26359942450e1b657ef9024206e3e5c4831c378100355f0a3cfe5e0c2425d8816d1442f0
data/.gitignore ADDED
@@ -0,0 +1 @@
1
+ Gemfile.lock
data/.rspec ADDED
@@ -0,0 +1,5 @@
1
+ --colour
2
+ --drb
3
+ --profile
4
+ --format documentation
5
+
data/.rubocop.yml ADDED
@@ -0,0 +1,5 @@
1
+ AllCops:
2
+ Exclude:
3
+ - vendor/**/*
4
+
5
+ inherit_from: .rubocop_todo.yml
data/.rubocop_todo.yml ADDED
@@ -0,0 +1,78 @@
1
+ # This configuration was generated by
2
+ # `rubocop --auto-gen-config`
3
+ # on 2017-01-24 13:49:04 -0500 using RuboCop version 0.47.1.
4
+ # The point is for the user to remove these configuration records
5
+ # one by one as the offenses are removed from the code base.
6
+ # Note that changes in the inspected code, or installation of new
7
+ # versions of RuboCop, may require this file to be generated again.
8
+
9
+ # Offense count: 3
10
+ Metrics/AbcSize:
11
+ Max: 24
12
+
13
+ # Offense count: 3
14
+ # Configuration parameters: CountComments, ExcludedMethods.
15
+ Metrics/BlockLength:
16
+ Max: 94
17
+
18
+ # Offense count: 26
19
+ # Configuration parameters: AllowHeredoc, AllowURI, URISchemes, IgnoreCopDirectives, IgnoredPatterns.
20
+ # URISchemes: http, https
21
+ Metrics/LineLength:
22
+ Max: 131
23
+
24
+ # Offense count: 1
25
+ # Configuration parameters: CountComments.
26
+ Metrics/MethodLength:
27
+ Max: 14
28
+
29
+ # Offense count: 3
30
+ # Cop supports --auto-correct.
31
+ # Configuration parameters: MaxKeyValuePairs.
32
+ Performance/RedundantMerge:
33
+ Exclude:
34
+ - 'lib/estella/parser.rb'
35
+ - 'lib/estella/searchable.rb'
36
+
37
+ # Offense count: 1
38
+ Style/AccessorMethodName:
39
+ Exclude:
40
+ - 'lib/estella/helpers.rb'
41
+
42
+ # Offense count: 1
43
+ Style/ClassVars:
44
+ Exclude:
45
+ - 'lib/estella/helpers.rb'
46
+
47
+ # Offense count: 7
48
+ Style/Documentation:
49
+ Exclude:
50
+ - 'spec/**/*'
51
+ - 'test/**/*'
52
+ - 'lib/estella/analysis.rb'
53
+ - 'lib/estella/helpers.rb'
54
+ - 'lib/estella/parser.rb'
55
+ - 'lib/estella/query.rb'
56
+ - 'lib/estella/searchable.rb'
57
+
58
+ # Offense count: 3
59
+ # Configuration parameters: MinBodyLength.
60
+ Style/GuardClause:
61
+ Exclude:
62
+ - 'lib/estella/query.rb'
63
+ - 'lib/estella/searchable.rb'
64
+
65
+ # Offense count: 9
66
+ # Cop supports --auto-correct.
67
+ Style/MutableConstant:
68
+ Exclude:
69
+ - 'lib/estella/analysis.rb'
70
+ - 'lib/estella/version.rb'
71
+
72
+ # Offense count: 2
73
+ # Cop supports --auto-correct.
74
+ # Configuration parameters: EnforcedStyle, SupportedStyles.
75
+ # SupportedStyles: only_raise, only_fail, semantic
76
+ Style/SignalException:
77
+ Exclude:
78
+ - 'lib/estella/parser.rb'
data/.travis.yml ADDED
@@ -0,0 +1,13 @@
1
+ language: ruby
2
+
3
+ cache: bundler
4
+
5
+ rvm:
6
+ - 2.2.2
7
+
8
+ before_install:
9
+ - gem update bundler
10
+ - "curl -O https://download.elasticsearch.org/elasticsearch/release/org/elasticsearch/distribution/deb/elasticsearch/2.1.1/elasticsearch-2.1.1.deb && sudo dpkg -i --force-confnew elasticsearch-2.1.1.deb"
11
+ - "echo 'script.inline: on' | sudo tee -a /etc/elasticsearch/elasticsearch.yml"
12
+ - "sudo /etc/init.d/elasticsearch start"
13
+ - "sleep 5"
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ ## Changelog
2
+
3
+ ### 0.2.1 (1/24/2017)
4
+
5
+ * Initial public release as the `estella` gem - [@cavia](https://github.com/cavvia), [@mzikherman](https://github.com/mzikherman).
data/Gemfile ADDED
@@ -0,0 +1,3 @@
1
+ source 'https://rubygems.org'
2
+
3
+ gemspec
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2017 Artsy Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,195 @@
1
+ # estella
2
+
3
+ [![Gem Version](https://badge.fury.io/rb/estella.svg)](https://badge.fury.io/rb/estella)
4
+ [![Build Status](https://travis-ci.org/artsy/estella.svg?branch=master)](https://travis-ci.org/artsy/estella)
5
+ [![License Status](https://git.legal/projects/3493/badge.svg)](https://git.legal/projects/3493)
6
+
7
+ Builds on [elasticsearch-model](https://github.com/elastic/elasticsearch-rails/tree/master/elasticsearch-model) to make your Ruby objects searchable with Elasticsearch. Provides fine-grained control of fields, analysis, filters, weightings and boosts.
8
+
9
+ ## Installation
10
+
11
+ ```
12
+ gem 'estella'
13
+ ```
14
+
15
+ The module will try to use Elasticsearch on `localhost:9200` by default. You can configure your global ES client like so:
16
+
17
+ ```ruby
18
+ Elasticsearch::Model.client = Elasticsearch::Client.new host: 'foo.com', log: true
19
+ ```
20
+
21
+ It is also configurable on a per model basis, see the [doc](https://github.com/elastic/elasticsearch-rails/tree/master/elasticsearch-model#the-elasticsearch-client).
22
+
23
+ ## Indexing
24
+
25
+ Just include the `Estella::Searchable` module and add a `searchable` block in your ActiveRecord or Mongoid model declaring the fields to be indexed like so:
26
+
27
+ ```ruby
28
+ class Artist < ActiveRecord::Base
29
+ include Estella::Searchable
30
+
31
+ searchable do
32
+ field :name, type: :string, analysis: Estella::Analysis::FULLTEXT_ANALYSIS, factor: 1.0
33
+ field :keywords, type: :string, analysis: ['snowball', 'shingle'], factor: 0.5
34
+ field :bio, using: :biography, type: :string, index: :not_analyzed
35
+ field :birth_date, type: :date
36
+ field :follows, type: :integer
37
+ field :published, type: :boolean, filter: true
38
+ boost :follows, modifier: 'log1p', factor: 1E-3
39
+ end
40
+ ...
41
+ end
42
+ ```
43
+
44
+ For a full understanding of the options available for field mappings, see the Elastic [mapping documentation](https://www.elastic.co/guide/en/elasticsearch/reference/2.4/mapping.html).
45
+
46
+ The `filter` option allows the field to be used as a filter at search time.
47
+
48
+ You can optionally provide field weightings to be applied at search time using the `factor` option. These are multipliers.
49
+
50
+ Document-level boosts can be applied with the `boost` declaration, see the [field_value_factor](https://www.elastic.co/guide/en/elasticsearch/reference/2.4/query-dsl-function-score-query.html#function-field-value-factor) documentation for boost options.
51
+
52
+ While `filter`, `boost` and `factor` are query options, Estella allows for their static declaration in the `searchable` block for simplicity - they will be applied at query time by default when using `#estella_search`.
53
+
54
+ You can now create your index mappings with this migration:
55
+
56
+ ```ruby
57
+ Artist.reload_index!
58
+ ```
59
+
60
+ This uses a default index naming scheme based on your model name, which you can override simply by declaring the following in your model:
61
+
62
+ ```ruby
63
+ index_name 'my_index_name'
64
+ ```
65
+
66
+ Start indexing documents simply by creating or saving them:
67
+
68
+ ```ruby
69
+ Artist.create(name: 'Frank Estella', keywords: ['art', 'minimalism'])
70
+ ```
71
+
72
+ Estella adds `after_save` and `after_destroy` callbacks for inline indexing, override these callbacks if you'd like to do your indexing in a background process. For example:
73
+
74
+ ```ruby
75
+ class Artist < ActiveRecord::Base
76
+ include Estella::Searchable
77
+
78
+ # disable estella inline callbacks
79
+ skip_callback(:save, :after, :es_index)
80
+ skip_callback(:destroy, :after, :es_delete)
81
+
82
+ # declare your own
83
+ after_save :delay_es_index
84
+ after_destroy :delay_es_delete
85
+
86
+ ...
87
+ end
88
+ ```
89
+
90
+ ## Custom Analysis
91
+
92
+ Estella defines `standard`, `snowball`, `ngram` and `shingle` analysers by default. These cover most search contexts, including auto-suggest. In order to enable full-text search for a field, use:
93
+
94
+ ```ruby
95
+ analysis: Estella::Analysis::FULLTEXT_ANALYSIS
96
+ ```
97
+
98
+ Or alternatively select your analysis by listing the analysers you want enabled for a given field:
99
+
100
+ ```ruby
101
+ es_field :keywords, type: :string, analysis: ['snowball', 'shingle']
102
+ ```
103
+
104
+ The searchable block takes a `settings` hash in case you require custom analysers or sharding (see [doc](https://www.elastic.co/guide/en/elasticsearch/guide/current/configuring-analyzers.html)):
105
+
106
+ ```ruby
107
+ my_analysis = {
108
+ tokenizer: {
109
+ ...
110
+ },
111
+ filter: {
112
+ ...
113
+ }
114
+ }
115
+
116
+ my_settings = {
117
+ analysis: my_analysis,
118
+ index: {
119
+ number_of_shards: 1,
120
+ number_of_replicas: 1
121
+ }
122
+ }
123
+
124
+ searchable my_settings do
125
+ ...
126
+ end
127
+ ```
128
+
129
+ It will otherwise use Estella defaults.
130
+
131
+ ## Searching
132
+
133
+ Finally perform full-text search:
134
+
135
+ ```ruby
136
+ Artist.estella_search(term: 'frank')
137
+ Artist.estella_search(term: 'minimalism')
138
+ ```
139
+
140
+ Estella searches all analysed text fields by default, using a [multi_match](https://www.elastic.co/guide/en/elasticsearch/guide/current/multi-match-query.html) search. The search will return an array of database records in score order. If you'd like access to the raw Elasticsearch response data use the `raw` option:
141
+
142
+ ```ruby
143
+ Artist.estella_search(term: 'frank', raw: true)
144
+ ```
145
+
146
+ Estella supports filtering on `filter` fields and pagination:
147
+
148
+ ```ruby
149
+ Artist.estella_search(term: 'frank', published: true)
150
+ Artist.estella_search(term: 'frank', size: 10, from: 5)
151
+ ```
152
+
153
+ If you'd like to customize your query further, you can extend `Estella::Query` and override the `query_definition`:
154
+
155
+ ```ruby
156
+ class MyQuery < Estella::Query
157
+ def query_definition
158
+ {
159
+ multi_match: {
160
+ ...
161
+ }
162
+ }
163
+ end
164
+ end
165
+ ```
166
+
167
+ And then override class method `estella_search_query` to direct Estella to use your query object:
168
+
169
+ ```ruby
170
+ class Artist < ActiveRecord::Base
171
+ include Estella::Searchable
172
+
173
+ searchable do
174
+ ...
175
+ end
176
+
177
+ def self.estella_search_query
178
+ MyQuery
179
+ end
180
+ end
181
+
182
+ Artist.estella_search (term: 'frank')
183
+ ```
184
+
185
+ For further search customization, see the [elasticsearch dsl](https://github.com/elastic/elasticsearch-rails/tree/master/elasticsearch-model#the-elasticsearch-dsl).
186
+
187
+ Estella works with any ActiveRecord or Mongoid compatible data models.
188
+
189
+ ## Contributing
190
+
191
+ Just fork the repo and submit a pull request.
192
+
193
+ ## License
194
+
195
+ Copyright (c) 2017 Artsy Inc., [MIT License](LICENSE).
data/RELEASING.md ADDED
@@ -0,0 +1,66 @@
1
+ # Releasing Estella
2
+
3
+ There're no hard rules about when to release estella. Release bug fixes frequenty, features not so frequently and breaking API changes rarely.
4
+
5
+ ### Release
6
+
7
+ Run tests, check that all tests succeed locally.
8
+
9
+ ```
10
+ bundle install
11
+ rake
12
+ ```
13
+
14
+ Check that the last build succeeded in [Travis CI](https://travis-ci.org/dblock/estella) for all supported platforms.
15
+
16
+ Increment the version, modify [lib/estella/version.rb](lib/estella/version.rb).
17
+
18
+ * Increment the third number if the release has bug fixes and/or very minor features, only (eg. change `0.2.1` to `0.2.2`).
19
+ * Increment the second number if the release contains major features or breaking API changes (eg. change `0.2.1` to `0.3.0`).
20
+
21
+ Change "Next Release" in [CHANGELOG.md](CHANGELOG.md) to the new version.
22
+
23
+ ```
24
+ ### 0.2.2 (1/17/2017)
25
+ ```
26
+
27
+ Remove the line with "Your contribution here.", since there will be no more contributions to this release.
28
+
29
+ Commit your changes.
30
+
31
+ ```
32
+ git add README.md CHANGELOG.md lib/estella/version.rb
33
+ git commit -m "Preparing for release, 0.2.2."
34
+ git push origin master
35
+ ```
36
+
37
+ Release.
38
+
39
+ ```
40
+ $ rake release
41
+
42
+ estella 0.2.2 built to pkg/estella-0.2.2.gem.
43
+ Tagged v0.2.2.
44
+ Pushed git commits and tags.
45
+ Pushed estella 0.2.2 to rubygems.org.
46
+ ```
47
+
48
+ ### Prepare for the Next Version
49
+
50
+ Increment the third version number in [lib/estella/version.rb](lib/estella/version.rb).
51
+
52
+ Add the next release to [CHANGELOG.md](CHANGELOG.md).
53
+
54
+ ```
55
+ ### 0.2.3 (Next)
56
+
57
+ * Your contribution here.
58
+ ```
59
+
60
+ Comit your changes.
61
+
62
+ ```
63
+ git add CHANGELOG.md lib/estella/version.rb
64
+ git commit -m "Preparing for next development iteration, 0.2.3."
65
+ git push origin master
66
+ ```
data/Rakefile ADDED
@@ -0,0 +1,16 @@
1
+ require 'rubygems'
2
+ require 'bundler/gem_tasks'
3
+
4
+ Bundler.setup :default, :development
5
+
6
+ require 'rspec/core'
7
+ require 'rspec/core/rake_task'
8
+
9
+ RSpec::Core::RakeTask.new(:spec) do |spec|
10
+ spec.pattern = FileList['spec/**/*_spec.rb']
11
+ end
12
+
13
+ require 'rubocop/rake_task'
14
+ RuboCop::RakeTask.new(:rubocop)
15
+
16
+ task default: [:rubocop, :spec]
data/estella.gemspec ADDED
@@ -0,0 +1,27 @@
1
+ $LOAD_PATH.unshift File.expand_path('../lib', __FILE__)
2
+ require 'estella/version'
3
+
4
+ Gem::Specification.new do |gem|
5
+ gem.name = 'estella'
6
+ gem.homepage = 'https://github.com/artsy/estella'
7
+ gem.license = 'MIT'
8
+ gem.summary = %(Make your Ruby objects searchable with Elasticsearch.)
9
+ gem.version = Estella::VERSION
10
+ gem.description = 'Make your Ruby objects searchable with Elasticsearch.'
11
+ gem.email = ['anil@artsy.net']
12
+ gem.authors = ['Anil Bawa-Cavia', 'Matt Zikherman']
13
+
14
+ gem.files = `git ls-files`.split("\n")
15
+ gem.test_files = `git ls-files -- spec/*`.split("\n")
16
+
17
+ gem.add_runtime_dependency 'elasticsearch-model'
18
+ gem.add_runtime_dependency 'activesupport'
19
+ gem.add_runtime_dependency 'activemodel'
20
+
21
+ gem.add_development_dependency 'rake', '~> 11.0'
22
+ gem.add_development_dependency 'activerecord'
23
+ gem.add_development_dependency 'rspec', '~> 3.1.0'
24
+ gem.add_development_dependency 'rspec-expectations'
25
+ gem.add_development_dependency 'sqlite3'
26
+ gem.add_development_dependency 'rubocop', '0.47.1'
27
+ end
@@ -0,0 +1,61 @@
1
+ module Estella
2
+ module Analysis
3
+ # Default Elasticsearch analysers
4
+ extend ActiveSupport::Concern
5
+
6
+ FRONT_NGRAM_FILTER =
7
+ { type: 'edgeNGram', min_gram: 2, max_gram: 15, side: 'front' }
8
+
9
+ DEFAULT_ANALYZER =
10
+ { type: 'custom', tokenizer: 'standard_tokenizer', filter: %w(lowercase asciifolding) }
11
+
12
+ SNOWBALL_ANALYZER =
13
+ { type: 'custom', tokenizer: 'standard_tokenizer', filter: %w(lowercase asciifolding snowball) }
14
+
15
+ SHINGLE_ANALYZER =
16
+ { type: 'custom', tokenizer: 'standard_tokenizer', filter: %w(shingle lowercase asciifolding) }
17
+
18
+ NGRAM_ANALYZER =
19
+ { type: 'custom', tokenizer: 'standard_tokenizer', filter: %w(lowercase asciifolding front_ngram_filter) }
20
+
21
+ DEFAULT_ANALYSIS = {
22
+ tokenizer: {
23
+ standard_tokenizer: { type: 'standard' }
24
+ },
25
+ filter: {
26
+ front_ngram_filter: FRONT_NGRAM_FILTER
27
+ },
28
+ analyzer: {
29
+ default_analyzer: DEFAULT_ANALYZER,
30
+ snowball_analyzer: SNOWBALL_ANALYZER,
31
+ shingle_analyzer: SHINGLE_ANALYZER,
32
+ ngram_analyzer: NGRAM_ANALYZER,
33
+ search_analyzer: DEFAULT_ANALYZER
34
+ }
35
+ }
36
+
37
+ DEFAULT_FIELDS = {
38
+ default: { type: 'string', analyzer: 'default_analyzer' },
39
+ snowball: { type: 'string', analyzer: 'snowball_analyzer' },
40
+ shingle: { type: 'string', analyzer: 'shingle_analyzer' },
41
+ ngram: { type: 'string', analyzer: 'ngram_analyzer', search_analyzer: 'search_analyzer' }
42
+ }
43
+
44
+ DEFAULT_FIELD_FACTORS = {
45
+ default: 10,
46
+ ngram: 10,
47
+ snowball: 3,
48
+ shingle: 2,
49
+ search: 2
50
+ }
51
+
52
+ FULLTEXT_ANALYSIS = DEFAULT_FIELDS.keys
53
+
54
+ DEFAULT_SETTINGS = if defined? Rails && Rails.env == 'test'
55
+ # Ensure no sharding in test env in order to enforce deterministic scores.
56
+ { analysis: DEFAULT_ANALYSIS, index: { number_of_shards: 1, number_of_replicas: 1 } }
57
+ else
58
+ { analysis: DEFAULT_ANALYSIS }
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,94 @@
1
+ module Estella
2
+ module Helpers
3
+ extend ActiveSupport::Concern
4
+
5
+ @@types = []
6
+
7
+ included do
8
+ index_name search_index_name
9
+
10
+ after_save :es_index
11
+ after_destroy :es_delete
12
+
13
+ attr_accessor :es_indexing
14
+
15
+ @@types << self
16
+ end
17
+
18
+ # track dependent classes for spec support
19
+ def self.types
20
+ @@types
21
+ end
22
+
23
+ def es_index
24
+ self.es_indexing = true
25
+ __elasticsearch__.index_document
26
+ ensure
27
+ self.es_indexing = nil
28
+ end
29
+
30
+ def es_delete
31
+ es_delete_document id
32
+ end
33
+
34
+ def es_transform
35
+ { index: { _id: id.to_s, data: as_indexed_json } }
36
+ end
37
+
38
+ module ClassMethods
39
+ ## Searching
40
+
41
+ def stella_raw_search(params = {})
42
+ __elasticsearch__.search(estella_query(params))
43
+ end
44
+
45
+ # @return an array of database records mapped using an adapter
46
+ def estella_search(params = {})
47
+ rsp = stella_raw_search(params)
48
+ params[:raw] ? rsp.response : rsp.records.to_a
49
+ end
50
+
51
+ ## Indexing
52
+
53
+ # default index naming scheme is pluralized model_name
54
+ def search_index_name
55
+ model_name.route_key
56
+ end
57
+
58
+ def batch_to_bulk(batch_of_ids)
59
+ find(batch_of_ids).map(&:es_transform)
60
+ end
61
+
62
+ def bulk_index(batch_of_ids)
63
+ __elasticsearch__.client.bulk index: index_name, type: model_name.element, body: batch_to_bulk(batch_of_ids)
64
+ end
65
+
66
+ def index_exists?
67
+ __elasticsearch__.client.indices.exists index: index_name
68
+ end
69
+
70
+ def reload_index!
71
+ __elasticsearch__.client.indices.delete index: index_name if index_exists?
72
+ __elasticsearch__.client.indices.create index: index_name, body: { settings: settings.to_hash, mappings: mappings.to_hash }
73
+ end
74
+
75
+ def recreate_index!
76
+ reload_index!
77
+ import
78
+ refresh_index!
79
+ end
80
+
81
+ def refresh_index!
82
+ __elasticsearch__.refresh_index!
83
+ end
84
+
85
+ def set_index_alias!(name)
86
+ __elasticsearch__.client.indices.put_alias index: index_name, name: name
87
+ end
88
+
89
+ def es_delete_document(id)
90
+ __elasticsearch__.client.delete type: document_type, id: id, index: index_name
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,27 @@
1
+ module Estella
2
+ class Parser
3
+ def initialize(model)
4
+ @model = model
5
+ end
6
+
7
+ # document level boost
8
+ # @see https://www.elastic.co/guide/en/elasticsearch/guide/current/boosting-by-popularity.html
9
+ def boost(name, opts = {})
10
+ fail ArgumentError, 'Boost field is not indexed!' unless @model.indexed_fields.include? name
11
+ unless (opts.keys & [:modifier, :factor]).length == 2
12
+ fail ArgumentError, 'Please supply a modifier and a factor for your boost!'
13
+ end
14
+ @model.field_boost = { boost: { field: name }.merge(opts) }
15
+ end
16
+
17
+ # index a field
18
+ def field(name, opts = {})
19
+ using = opts[:using] || name
20
+ analysis = opts[:analysis] & @model.default_analysis_fields.keys
21
+ opts[:fields] ||= Hash[analysis.zip(@model.default_analysis_fields.values_at(*analysis))] if analysis
22
+
23
+ @model.indexed_json.merge!(name => using)
24
+ @model.indexed_fields.merge!(name => opts)
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,125 @@
1
+ module Estella
2
+ class Query
3
+ # Constructs a search query for ES
4
+ attr_accessor :query
5
+ attr_reader :params
6
+
7
+ def initialize(params)
8
+ @params = params
9
+ @query = {
10
+ _source: false,
11
+ query: {},
12
+ filter: {
13
+ bool: { must: [], must_not: [] }
14
+ },
15
+ aggregations: {}
16
+ }
17
+ add_query
18
+ add_filters
19
+ add_pagination
20
+ add_aggregations if params[:aggregations]
21
+ add_sort
22
+ end
23
+
24
+ # override if needed
25
+ def add_aggregations; end
26
+
27
+ # override if needed
28
+ def add_sort; end
29
+
30
+ def must(filter)
31
+ query[:filter][:bool][:must] << filter
32
+ end
33
+
34
+ def exclude(filter)
35
+ query[:filter][:bool][:must_not] << filter
36
+ end
37
+
38
+ def add_pagination
39
+ query[:size] = params[:size] if params[:size]
40
+ query[:from] = params[:from] if params[:from]
41
+ end
42
+
43
+ def add_query
44
+ if params[:term] && params[:indexed_fields]
45
+ add_term_query
46
+ else
47
+ query[:query] = { match_all: {} }
48
+ end
49
+ end
50
+
51
+ # fulltext search across all string fields
52
+ def add_term_query
53
+ query[:query] = {
54
+ function_score: {
55
+ query: query_definition
56
+ }
57
+ }
58
+
59
+ add_field_boost
60
+ end
61
+
62
+ def query_definition
63
+ {
64
+ multi_match: {
65
+ type: 'most_fields',
66
+ fields: term_search_fields,
67
+ query: params[:term]
68
+ }
69
+ }
70
+ end
71
+
72
+ def add_field_boost
73
+ if params[:boost]
74
+ query[:query][:function_score][:field_value_factor] = {
75
+ field: params[:boost][:field],
76
+ modifier: params[:boost][:modifier],
77
+ factor: params[:boost][:factor]
78
+ }
79
+
80
+ if params[:boost][:max]
81
+ query[:query][:function_score][:max_boost] = params[:boost][:max]
82
+ end
83
+ end
84
+ end
85
+
86
+ def field_factors
87
+ Estella::Analysis::DEFAULT_FIELD_FACTORS
88
+ end
89
+
90
+ # search all analysed string fields by default
91
+ # boost them by factor if provided
92
+ def term_search_fields
93
+ params[:indexed_fields]
94
+ .select { |_, opts| opts[:type].to_s == 'string' }
95
+ .reject { |_, opts| opts[:analysis].nil? }
96
+ .map do |field, opts|
97
+ opts[:analysis].map do |analyzer|
98
+ factor = field_factors[analyzer] * opts.fetch(:factor, 1.0)
99
+ "#{field}.#{analyzer}^#{factor}"
100
+ end
101
+ end
102
+ .flatten
103
+ end
104
+
105
+ def add_filters
106
+ if params[:indexed_fields]
107
+ params[:indexed_fields].each do |field, opts|
108
+ must term: { field => params[field] } if opts[:filter] && params[field]
109
+ end
110
+ end
111
+ end
112
+
113
+ def bool_filter(field, param)
114
+ if param
115
+ { term: { field => true } }
116
+ elsif !param.nil?
117
+ { term: { field => false } }
118
+ end
119
+ end
120
+
121
+ def add_bool_filter(field, param)
122
+ must bool_filter(field, param) if bool_filter(field, param)
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,87 @@
1
+ module Estella
2
+ module Searchable
3
+ # Makes your ActiveRecord model searchable via Elasticsearch
4
+ #
5
+ # Just include a block in your model like so:
6
+ #
7
+ # class Artist < ActiveRecord::Base
8
+ # searchable do
9
+ # field :name, type: :string, using: :my_attr, analysis: Estella::Analysis::FULLTEXT_ANALYSIS
10
+ # field :follows, type: :integer
11
+ # ...
12
+ # boost :follows, modifier: 'log1p', factor: 1E-3
13
+ # end
14
+ # end
15
+ #
16
+ # Document boosts are optional.
17
+ # You can now create your index with the following migration:
18
+ #
19
+ # Artist.reload_index!
20
+ # Artist.import
21
+ #
22
+ # And perform full-text search using:
23
+ #
24
+ # Artist.estella_search(term: x)
25
+ #
26
+ extend ActiveSupport::Concern
27
+
28
+ included do
29
+ include Elasticsearch::Model
30
+ include Estella::Helpers
31
+ include Estella::Analysis
32
+
33
+ @indexed_json = {}
34
+ @indexed_fields = {}
35
+ @field_boost = {}
36
+
37
+ class << self
38
+ attr_accessor :indexed_json, :indexed_fields, :field_boost
39
+ end
40
+
41
+ def self.estella_query(params = {})
42
+ params.merge!(field_boost)
43
+ params.merge!(indexed_fields: indexed_fields)
44
+ estella_search_query.new(params).query
45
+ end
46
+
47
+ def self.estella_search_query
48
+ Estella::Query
49
+ end
50
+ end
51
+
52
+ def as_indexed_json(_options = {})
53
+ schema = self.class.indexed_json
54
+ Hash[schema.keys.zip(schema.values.map { |v| v.respond_to?(:call) ? instance_exec(&v) : send(v) })]
55
+ end
56
+
57
+ module ClassMethods
58
+ # support for mongoid::slug
59
+ # indexes slug attribue by default
60
+ def index_slug
61
+ if defined? slug
62
+ indexed_fields.merge!(slug: { type: :string, index: :not_analyzed })
63
+ indexed_json.merge!(slug: :slug)
64
+ end
65
+ end
66
+
67
+ def default_analysis_fields
68
+ Estella::Analysis::DEFAULT_FIELDS
69
+ end
70
+
71
+ # sets up mappings and settings for index
72
+ def searchable(settings = Estella::Analysis::DEFAULT_SETTINGS, &block)
73
+ Estella::Parser.new(self).instance_eval(&block)
74
+ index_slug
75
+ indexed_fields = @indexed_fields
76
+
77
+ settings(settings) do
78
+ mapping do
79
+ indexed_fields.each do |name, opts|
80
+ indexes name, opts.except(:analysis, :using, :factor, :filter)
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,3 @@
1
+ module Estella
2
+ VERSION = '0.2.1'
3
+ end
data/lib/estella.rb ADDED
@@ -0,0 +1,8 @@
1
+ require 'active_support'
2
+ require 'active_model'
3
+ require 'elasticsearch/model'
4
+ require 'estella/query'
5
+ require 'estella/helpers'
6
+ require 'estella/analysis'
7
+ require 'estella/parser'
8
+ require 'estella/searchable'
@@ -0,0 +1,108 @@
1
+ require 'spec_helper'
2
+ require 'estella'
3
+ require 'active_record'
4
+
5
+ describe Estella::Searchable, type: :model do
6
+ before do
7
+ ActiveRecord::Base.establish_connection(adapter: 'sqlite3', database: ':memory:')
8
+ end
9
+
10
+ describe 'searchable model', elasticsearch: true do
11
+ before do
12
+ class SearchableModel < ActiveRecord::Base
13
+ include Estella::Searchable
14
+
15
+ def self.slug
16
+ # mongoid::slug support
17
+ 'foo'
18
+ end
19
+
20
+ searchable do
21
+ field :title, type: :string, analysis: Estella::Analysis::FULLTEXT_ANALYSIS, factor: 1.0
22
+ field :keywords, type: :string, analysis: [:default, :snowball], factor: 0.5
23
+ field :follows_count, type: :integer
24
+ field :published, type: :boolean, filter: true
25
+
26
+ boost :follows_count, modifier: 'log2p', factor: 5E-4, max: 1.0
27
+ end
28
+ end
29
+
30
+ ActiveRecord::Schema.define(version: 1) do
31
+ create_table(:searchable_models) do |t|
32
+ t.string :title
33
+ t.string :keywords
34
+ t.string :slug
35
+ t.boolean :published
36
+ t.integer :follows_count, default: 0
37
+ end
38
+ end
39
+
40
+ SearchableModel.reload_index!
41
+ @jez = SearchableModel.create(title: 'jeremy corbyn', keywords: ['jez'])
42
+ @tez = SearchableModel.create(title: 'theresa may', keywords: ['tez'])
43
+ SearchableModel.refresh_index!
44
+ end
45
+ it 'returns relevant results' do
46
+ expect(SearchableModel.all.size).to eq(2)
47
+ expect(SearchableModel.estella_search(term: 'jeremy')).to eq([@jez])
48
+ expect(SearchableModel.estella_search(term: 'theresa')).to eq([@tez])
49
+ end
50
+ it 'uses ngram analysis by default' do
51
+ expect(SearchableModel.estella_search(term: 'jer')).to eq([@jez])
52
+ expect(SearchableModel.estella_search(term: 'there')).to eq([@tez])
53
+ end
54
+ it 'searches all text fields by default' do
55
+ expect(SearchableModel.estella_search(term: 'jez')).to eq([@jez])
56
+ end
57
+ it 'boosts on follows_count' do
58
+ popular_jeremy = SearchableModel.create(title: 'jeremy corban', follows_count: 20_000)
59
+ SearchableModel.refresh_index!
60
+ expect(SearchableModel.estella_search(term: 'jeremy')).to eq([popular_jeremy, @jez])
61
+ end
62
+ it 'uses factor option to weight fields' do
63
+ @dude = SearchableModel.create(keywords: ['dude'])
64
+ @dude2 = SearchableModel.create(title: 'dude')
65
+ SearchableModel.refresh_index!
66
+ expect(SearchableModel.estella_search(term: 'dude')).to eq([@dude2, @dude])
67
+ end
68
+ it 'returns raw response when raw option is set' do
69
+ expect(SearchableModel.estella_search(term: 'jeremy', raw: true).hits.hits.first['_id']).to eq(@jez.id.to_s)
70
+ end
71
+ it 'indexes slug field by default' do
72
+ SearchableModel.create(title: 'liapunov', slug: 'liapunov')
73
+ SearchableModel.refresh_index!
74
+ expect(SearchableModel.mappings.to_hash[:searchable_model][:properties].keys.include?(:slug)).to eq true
75
+ end
76
+ it 'supports boolean filters' do
77
+ @liapunov = SearchableModel.create(title: 'liapunov', published: true)
78
+ SearchableModel.create(title: 'liapunov unpublished')
79
+ SearchableModel.refresh_index!
80
+ expect(SearchableModel.estella_search(published: true)).to eq [@liapunov]
81
+ end
82
+ it 'does not override field method on class' do
83
+ expect(SearchableModel.methods.include?(:field)).to eq(false)
84
+ end
85
+ end
86
+
87
+ describe 'configuration errors' do
88
+ it 'raises error when boost field is invalid' do
89
+ expect do
90
+ class BadSearchableModel < ActiveRecord::Base
91
+ include Estella::Searchable
92
+ searchable { boost :follows_count }
93
+ end
94
+ end.to raise_error(ArgumentError, 'Boost field is not indexed!')
95
+ end
96
+ it 'raises error when boost params are not set' do
97
+ expect do
98
+ class BadSearchableModel < ActiveRecord::Base
99
+ include Estella::Searchable
100
+ searchable do
101
+ field :follows_count, type: 'integer'
102
+ boost :follows_count
103
+ end
104
+ end
105
+ end.to raise_error(ArgumentError, 'Please supply a modifier and a factor for your boost!')
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,22 @@
1
+ require 'active_support'
2
+ require 'active_model'
3
+ require 'rspec'
4
+
5
+ require File.expand_path('../../lib/estella.rb', __FILE__)
6
+
7
+ RSpec.configure do |config|
8
+ config.mock_with :rspec do |c|
9
+ c.syntax = :expect
10
+ end
11
+
12
+ config.expect_with :rspec do |c|
13
+ c.syntax = :expect
14
+ end
15
+
16
+ config.raise_errors_for_deprecations!
17
+
18
+ config.before(:context, elasticsearch: true) do
19
+ Elasticsearch::Model.client = Elasticsearch::Client.new
20
+ Estella::Helpers.types.each { |type| type.__elasticsearch__.client = nil } # clear memoized clients
21
+ end
22
+ end
metadata ADDED
@@ -0,0 +1,194 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: estella
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.2.1
5
+ platform: ruby
6
+ authors:
7
+ - Anil Bawa-Cavia
8
+ - Matt Zikherman
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2017-01-24 00:00:00.000000000 Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: elasticsearch-model
16
+ requirement: !ruby/object:Gem::Requirement
17
+ requirements:
18
+ - - ">="
19
+ - !ruby/object:Gem::Version
20
+ version: '0'
21
+ type: :runtime
22
+ prerelease: false
23
+ version_requirements: !ruby/object:Gem::Requirement
24
+ requirements:
25
+ - - ">="
26
+ - !ruby/object:Gem::Version
27
+ version: '0'
28
+ - !ruby/object:Gem::Dependency
29
+ name: activesupport
30
+ requirement: !ruby/object:Gem::Requirement
31
+ requirements:
32
+ - - ">="
33
+ - !ruby/object:Gem::Version
34
+ version: '0'
35
+ type: :runtime
36
+ prerelease: false
37
+ version_requirements: !ruby/object:Gem::Requirement
38
+ requirements:
39
+ - - ">="
40
+ - !ruby/object:Gem::Version
41
+ version: '0'
42
+ - !ruby/object:Gem::Dependency
43
+ name: activemodel
44
+ requirement: !ruby/object:Gem::Requirement
45
+ requirements:
46
+ - - ">="
47
+ - !ruby/object:Gem::Version
48
+ version: '0'
49
+ type: :runtime
50
+ prerelease: false
51
+ version_requirements: !ruby/object:Gem::Requirement
52
+ requirements:
53
+ - - ">="
54
+ - !ruby/object:Gem::Version
55
+ version: '0'
56
+ - !ruby/object:Gem::Dependency
57
+ name: rake
58
+ requirement: !ruby/object:Gem::Requirement
59
+ requirements:
60
+ - - "~>"
61
+ - !ruby/object:Gem::Version
62
+ version: '11.0'
63
+ type: :development
64
+ prerelease: false
65
+ version_requirements: !ruby/object:Gem::Requirement
66
+ requirements:
67
+ - - "~>"
68
+ - !ruby/object:Gem::Version
69
+ version: '11.0'
70
+ - !ruby/object:Gem::Dependency
71
+ name: activerecord
72
+ requirement: !ruby/object:Gem::Requirement
73
+ requirements:
74
+ - - ">="
75
+ - !ruby/object:Gem::Version
76
+ version: '0'
77
+ type: :development
78
+ prerelease: false
79
+ version_requirements: !ruby/object:Gem::Requirement
80
+ requirements:
81
+ - - ">="
82
+ - !ruby/object:Gem::Version
83
+ version: '0'
84
+ - !ruby/object:Gem::Dependency
85
+ name: rspec
86
+ requirement: !ruby/object:Gem::Requirement
87
+ requirements:
88
+ - - "~>"
89
+ - !ruby/object:Gem::Version
90
+ version: 3.1.0
91
+ type: :development
92
+ prerelease: false
93
+ version_requirements: !ruby/object:Gem::Requirement
94
+ requirements:
95
+ - - "~>"
96
+ - !ruby/object:Gem::Version
97
+ version: 3.1.0
98
+ - !ruby/object:Gem::Dependency
99
+ name: rspec-expectations
100
+ requirement: !ruby/object:Gem::Requirement
101
+ requirements:
102
+ - - ">="
103
+ - !ruby/object:Gem::Version
104
+ version: '0'
105
+ type: :development
106
+ prerelease: false
107
+ version_requirements: !ruby/object:Gem::Requirement
108
+ requirements:
109
+ - - ">="
110
+ - !ruby/object:Gem::Version
111
+ version: '0'
112
+ - !ruby/object:Gem::Dependency
113
+ name: sqlite3
114
+ requirement: !ruby/object:Gem::Requirement
115
+ requirements:
116
+ - - ">="
117
+ - !ruby/object:Gem::Version
118
+ version: '0'
119
+ type: :development
120
+ prerelease: false
121
+ version_requirements: !ruby/object:Gem::Requirement
122
+ requirements:
123
+ - - ">="
124
+ - !ruby/object:Gem::Version
125
+ version: '0'
126
+ - !ruby/object:Gem::Dependency
127
+ name: rubocop
128
+ requirement: !ruby/object:Gem::Requirement
129
+ requirements:
130
+ - - '='
131
+ - !ruby/object:Gem::Version
132
+ version: 0.47.1
133
+ type: :development
134
+ prerelease: false
135
+ version_requirements: !ruby/object:Gem::Requirement
136
+ requirements:
137
+ - - '='
138
+ - !ruby/object:Gem::Version
139
+ version: 0.47.1
140
+ description: Make your Ruby objects searchable with Elasticsearch.
141
+ email:
142
+ - anil@artsy.net
143
+ executables: []
144
+ extensions: []
145
+ extra_rdoc_files: []
146
+ files:
147
+ - ".gitignore"
148
+ - ".rspec"
149
+ - ".rubocop.yml"
150
+ - ".rubocop_todo.yml"
151
+ - ".travis.yml"
152
+ - CHANGELOG.md
153
+ - Gemfile
154
+ - LICENSE
155
+ - README.md
156
+ - RELEASING.md
157
+ - Rakefile
158
+ - estella.gemspec
159
+ - lib/estella.rb
160
+ - lib/estella/analysis.rb
161
+ - lib/estella/helpers.rb
162
+ - lib/estella/parser.rb
163
+ - lib/estella/query.rb
164
+ - lib/estella/searchable.rb
165
+ - lib/estella/version.rb
166
+ - spec/searchable_spec.rb
167
+ - spec/spec_helper.rb
168
+ homepage: https://github.com/artsy/estella
169
+ licenses:
170
+ - MIT
171
+ metadata: {}
172
+ post_install_message:
173
+ rdoc_options: []
174
+ require_paths:
175
+ - lib
176
+ required_ruby_version: !ruby/object:Gem::Requirement
177
+ requirements:
178
+ - - ">="
179
+ - !ruby/object:Gem::Version
180
+ version: '0'
181
+ required_rubygems_version: !ruby/object:Gem::Requirement
182
+ requirements:
183
+ - - ">="
184
+ - !ruby/object:Gem::Version
185
+ version: '0'
186
+ requirements: []
187
+ rubyforge_project:
188
+ rubygems_version: 2.4.8
189
+ signing_key:
190
+ specification_version: 4
191
+ summary: Make your Ruby objects searchable with Elasticsearch.
192
+ test_files:
193
+ - spec/searchable_spec.rb
194
+ - spec/spec_helper.rb