estella 0.2.1

Sign up to get free protection for your applications and to get access to all the features.
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