Commit 1fd43efcd4d189fcb1943e8dc17cfedf0df54f54

Authored by Andrew Kane
2 parents 6281297d 3a08814c

Merged master

  1 +## 0.5.3
  2 +
  3 +- Fixed bug w/ word_* queries
  4 +
  5 +## 0.5.2
  6 +
  7 +- Use after_commit hook for ActiveRecord to prevent data inconsistencies
  8 +
  9 +## 0.5.1
  10 +
  11 +- Replaced stop words with common terms query
  12 +- Added language option
  13 +- Fixed bug with empty array in where clause
  14 +- Fixed bug with MongoDB integer _id
  15 +- Fixed reindex bug when callbacks disabled
  16 +
  17 +## 0.5.0
  18 +
  19 +- Better control over partial matches
  20 +- Added merge_mappings option
  21 +- Added batch_size option
  22 +- Fixed bug with nil where clauses
  23 +
1 ## 0.4.2 24 ## 0.4.2
2 25
3 - Added `should_index?` method to control which records are indexed 26 - Added `should_index?` method to control which records are indexed
@@ -3,5 +3,7 @@ source 'https://rubygems.org' @@ -3,5 +3,7 @@ source 'https://rubygems.org'
3 # Specify your gem's dependencies in searchkick.gemspec 3 # Specify your gem's dependencies in searchkick.gemspec
4 gemspec 4 gemspec
5 5
  6 +gem "sqlite3"
  7 +gem "activerecord"
6 # gem "activerecord", "~> 3.2.0" 8 # gem "activerecord", "~> 3.2.0"
7 # gem "activerecord", "~> 3.1.0" 9 # gem "activerecord", "~> 3.1.0"
@@ -21,12 +21,14 @@ Plus: @@ -21,12 +21,14 @@ Plus:
21 - “Did you mean” suggestions 21 - “Did you mean” suggestions
22 - works with ActiveRecord and Mongoid 22 - works with ActiveRecord and Mongoid
23 23
24 -:zap: Even better with [Searchjoy](http://ankane.github.io/searchjoy/)  
25 -  
26 :tangerine: Battle-tested at [Instacart](https://www.instacart.com) 24 :tangerine: Battle-tested at [Instacart](https://www.instacart.com)
27 25
28 [![Build Status](https://travis-ci.org/ankane/searchkick.png?branch=master)](https://travis-ci.org/ankane/searchkick) 26 [![Build Status](https://travis-ci.org/ankane/searchkick.png?branch=master)](https://travis-ci.org/ankane/searchkick)
29 27
  28 +We highly recommend tracking queries and conversions
  29 +
  30 +:zap: [Searchjoy](http://ankane.github.io/searchjoy/) makes it easy
  31 +
30 ## Get Started 32 ## Get Started
31 33
32 [Install Elasticsearch](http://www.elasticsearch.org/guide/en/elasticsearch/reference/current/setup.html). For Homebrew, use: 34 [Install Elasticsearch](http://www.elasticsearch.org/guide/en/elasticsearch/reference/current/setup.html). For Homebrew, use:
@@ -148,6 +150,44 @@ To change this, use: @@ -148,6 +150,44 @@ To change this, use:
148 Product.search "fresh honey", partial: true # fresh OR honey 150 Product.search "fresh honey", partial: true # fresh OR honey
149 ``` 151 ```
150 152
  153 +By default, results must match the entire word - `back` will not match `backpack`. You can change this behavior with:
  154 +
  155 +```ruby
  156 +class Product < ActiveRecord::Base
  157 + searchkick word_start: [:name]
  158 +end
  159 +```
  160 +
  161 +And to search:
  162 +
  163 +```ruby
  164 +Product.search "back", fields: [{name: :word_start}]
  165 +```
  166 +
  167 +Available options are:
  168 +
  169 +```ruby
  170 +:word # default
  171 +:word_start
  172 +:word_middle
  173 +:word_end
  174 +:text_start
  175 +:text_middle
  176 +:text_end
  177 +```
  178 +
  179 +### Language
  180 +
  181 +Searchkick defaults to English for stemming. To change this, use:
  182 +
  183 +```ruby
  184 +class Product < ActiveRecord::Base
  185 + searchkick language: "German"
  186 +end
  187 +```
  188 +
  189 +[See the list of languages](http://www.elasticsearch.org/guide/en/elasticsearch/reference/current/analysis-snowball-tokenfilter.html)
  190 +
151 ### Synonyms 191 ### Synonyms
152 192
153 ```ruby 193 ```ruby
@@ -193,7 +233,7 @@ Searchkick uses `find_in_batches` to import documents. To eager load associatio @@ -193,7 +233,7 @@ Searchkick uses `find_in_batches` to import documents. To eager load associatio
193 233
194 ```ruby 234 ```ruby
195 class Product < ActiveRecord::Base 235 class Product < ActiveRecord::Base
196 - scope :search_import, includes(:searches) 236 + scope :search_import, -> { includes(:searches) }
197 end 237 end
198 ``` 238 ```
199 239
@@ -293,14 +333,14 @@ First, specify which fields use this feature. This is necessary since autocompl @@ -293,14 +333,14 @@ First, specify which fields use this feature. This is necessary since autocompl
293 333
294 ```ruby 334 ```ruby
295 class City < ActiveRecord::Base 335 class City < ActiveRecord::Base
296 - searchkick autocomplete: ["name"] 336 + searchkick text_start: [:name]
297 end 337 end
298 ``` 338 ```
299 339
300 Reindex and search with: 340 Reindex and search with:
301 341
302 ```ruby 342 ```ruby
303 -City.search "san fr", autocomplete: true 343 +City.search "san fr", fields: [{name: :text_start}]
304 ``` 344 ```
305 345
306 Typically, you want to use a Javascript library like [typeahead.js](http://twitter.github.io/typeahead.js/) or [jQuery UI](http://jqueryui.com/autocomplete/). 346 Typically, you want to use a Javascript library like [typeahead.js](http://twitter.github.io/typeahead.js/) or [jQuery UI](http://jqueryui.com/autocomplete/).
@@ -314,7 +354,7 @@ First, add a controller action. @@ -314,7 +354,7 @@ First, add a controller action.
314 class CitiesController < ApplicationController 354 class CitiesController < ApplicationController
315 355
316 def autocomplete 356 def autocomplete
317 - render json: City.search(params[:query], autocomplete: true, limit: 10).map(&:name) 357 + render json: City.search(params[:query], fields: [{name: :text_start}], limit: 10).map(&:name)
318 end 358 end
319 359
320 end 360 end
@@ -454,8 +494,9 @@ Dog.reindex # equivalent @@ -454,8 +494,9 @@ Dog.reindex # equivalent
454 And to search, use: 494 And to search, use:
455 495
456 ```ruby 496 ```ruby
457 -Animal.search "*" # all animals  
458 -Dog.search "*" # just dogs 497 +Animal.search "*" # all animals
  498 +Dog.search "*" # just dogs
  499 +Animal.search "*", type: [Dog, Cat] # just cats and dogs [master]
459 ``` 500 ```
460 501
461 **Note:** The `suggest` option retrieves suggestions from the parent at the moment. 502 **Note:** The `suggest` option retrieves suggestions from the parent at the moment.
@@ -530,6 +571,24 @@ And use the `query` option to search: @@ -530,6 +571,24 @@ And use the `query` option to search:
530 Product.search query: {match: {name: "milk"}} 571 Product.search query: {match: {name: "milk"}}
531 ``` 572 ```
532 573
  574 +To keep the mappings and settings generated by Searchkick, use:
  575 +
  576 +```ruby
  577 +class Product < ActiveRecord::Base
  578 + searchkick merge_mappings: true, mappings: {...}
  579 +end
  580 +```
  581 +
  582 +## Experimental [master]
  583 +
  584 +Modify the query generated by Searchkick.
  585 +
  586 +```ruby
  587 +query = Product.search "2% Milk", execute: false
  588 +query.body[:query] = {match_all: {}}
  589 +products = query.execute
  590 +```
  591 +
533 ## Reference 592 ## Reference
534 593
535 Searchkick requires Elasticsearch `0.90.0` or higher. 594 Searchkick requires Elasticsearch `0.90.0` or higher.
@@ -574,9 +633,9 @@ end @@ -574,9 +633,9 @@ end
574 or temporarily 633 or temporarily
575 634
576 ```ruby 635 ```ruby
577 -Product.disable_search_callbacks # use Searchkick.disable_callbacks for all models 636 +Product.disable_search_callbacks # or use Searchkick.disable_callbacks for all models
578 ExpensiveProductsTask.execute 637 ExpensiveProductsTask.execute
579 -Product.enable_search_callbacks 638 +Product.enable_search_callbacks # or use Searchkick.enable_callbacks for all models
580 Product.reindex 639 Product.reindex
581 ``` 640 ```
582 641
@@ -601,6 +660,50 @@ class Product &lt; ActiveRecord::Base @@ -601,6 +660,50 @@ class Product &lt; ActiveRecord::Base
601 end 660 end
602 ``` 661 ```
603 662
  663 +Change import batch size
  664 +
  665 +```ruby
  666 +class Product < ActiveRecord::Base
  667 + searchkick batch_size: 200 # defaults to 1000
  668 +end
  669 +```
  670 +
  671 +Asynchronous reindexing
  672 +
  673 +```ruby
  674 +class Product < ActiveRecord::Base
  675 + searchkick callbacks: false
  676 +
  677 + # add the callbacks manually
  678 +
  679 + # ActiveRecord - one callback
  680 + after_commit :reindex_async
  681 +
  682 + # Mongoid - two callbacks
  683 + after_save :reindex_async
  684 + after_destroy :reindex_async
  685 +
  686 + def reindex_async
  687 + # delayed job
  688 + delay.reindex
  689 + end
  690 +end
  691 +```
  692 +
  693 +Reindex conditionally
  694 +
  695 +**Note:** With ActiveRecord, use this feature with caution - [transaction rollbacks can cause data inconstencies](https://github.com/elasticsearch/elasticsearch-rails/blob/master/elasticsearch-model/README.md#custom-callbacks)
  696 +
  697 +```ruby
  698 +class Product < ActiveRecord::Base
  699 + searchkick callbacks: false
  700 +
  701 + # add the callbacks manually
  702 + after_save :reindex, if: proc{|model| model.name_changed? } # use your own condition
  703 + after_destroy :reindex
  704 +end
  705 +```
  706 +
604 Reindex all models (Rails only) 707 Reindex all models (Rails only)
605 708
606 ```sh 709 ```sh
@@ -655,11 +758,14 @@ For convenience, this is set by default in the test environment. @@ -655,11 +758,14 @@ For convenience, this is set by default in the test environment.
655 758
656 Thanks to Karel Minarik for [Tire](https://github.com/karmi/tire), Jaroslav Kalistsuk for [zero downtime reindexing](https://gist.github.com/jarosan/3124884), and Alex Leschenko for [Elasticsearch autocomplete](https://github.com/leschenko/elasticsearch_autocomplete). 759 Thanks to Karel Minarik for [Tire](https://github.com/karmi/tire), Jaroslav Kalistsuk for [zero downtime reindexing](https://gist.github.com/jarosan/3124884), and Alex Leschenko for [Elasticsearch autocomplete](https://github.com/leschenko/elasticsearch_autocomplete).
657 760
658 -## TODO 761 +## Roadmap
659 762
660 -- Generate autocomplete predictions from past search queries  
661 -- Automatic failover  
662 -- Make Searchkick work with any language 763 +- Search multiple fields for different terms
  764 +- Search across models
  765 +- Search nested objects
  766 +- Add section on testing
  767 +- Much finer customization
  768 +- More transparency into generated queries (for advanced use)
663 769
664 ## History 770 ## History
665 771
@@ -673,3 +779,11 @@ Everyone is encouraged to help improve this project. Here are a few ways you can @@ -673,3 +779,11 @@ Everyone is encouraged to help improve this project. Here are a few ways you can
673 - Fix bugs and [submit pull requests](https://github.com/ankane/searchkick/pulls) 779 - Fix bugs and [submit pull requests](https://github.com/ankane/searchkick/pulls)
674 - Write, clarify, or fix documentation 780 - Write, clarify, or fix documentation
675 - Suggest or add new features 781 - Suggest or add new features
  782 +
  783 +To get started with development and testing:
  784 +
  785 +1. Clone the repo
  786 +2. Install PostgreSQL and create a database called `searchkick_test` (`psql -d postgres -c "create database searchkick_test"`)
  787 +3. Install Elasticsearch
  788 +4. `bundle`
  789 +5. `rake test`
lib/searchkick.rb
@@ -3,6 +3,7 @@ require &quot;searchkick/version&quot; @@ -3,6 +3,7 @@ require &quot;searchkick/version&quot;
3 require "searchkick/index" 3 require "searchkick/index"
4 require "searchkick/reindex" 4 require "searchkick/reindex"
5 require "searchkick/results" 5 require "searchkick/results"
  6 +require "searchkick/query"
6 require "searchkick/search" 7 require "searchkick/search"
7 require "searchkick/similar" 8 require "searchkick/similar"
8 require "searchkick/model" 9 require "searchkick/model"
lib/searchkick/model.rb
@@ -19,8 +19,12 @@ module Searchkick @@ -19,8 +19,12 @@ module Searchkick
19 extend Searchkick::Reindex 19 extend Searchkick::Reindex
20 include Searchkick::Similar 20 include Searchkick::Similar
21 21
22 - after_save :reindex  
23 - after_destroy :reindex 22 + if respond_to?(:after_commit)
  23 + after_commit :reindex, if: proc{ self.class.search_callbacks? }
  24 + else
  25 + after_save :reindex, if: proc{ self.class.search_callbacks? }
  26 + after_destroy :reindex, if: proc{ self.class.search_callbacks? }
  27 + end
24 28
25 def self.enable_search_callbacks 29 def self.enable_search_callbacks
26 class_variable_set :@@searchkick_callbacks, true 30 class_variable_set :@@searchkick_callbacks, true
@@ -39,13 +43,11 @@ module Searchkick @@ -39,13 +43,11 @@ module Searchkick
39 end 43 end
40 44
41 def reindex 45 def reindex
42 - if self.class.search_callbacks?  
43 - index = self.class.searchkick_index  
44 - if destroyed? or !should_index?  
45 - index.remove self  
46 - else  
47 - index.store self  
48 - end 46 + index = self.class.searchkick_index
  47 + if destroyed? or !should_index?
  48 + index.remove self
  49 + else
  50 + index.store self
49 end 51 end
50 end 52 end
51 53
@@ -60,7 +62,9 @@ module Searchkick @@ -60,7 +62,9 @@ module Searchkick
60 source = source.inject({}){|memo,(k,v)| memo[k.to_s] = v; memo} 62 source = source.inject({}){|memo,(k,v)| memo[k.to_s] = v; memo}
61 63
62 # Mongoid 4 hack 64 # Mongoid 4 hack
63 - source["_id"] = source["_id"].to_s if source["_id"] 65 + if defined?(BSON::ObjectId) and source["_id"].is_a?(BSON::ObjectId)
  66 + source["_id"] = source["_id"].to_s
  67 + end
64 68
65 options = self.class.searchkick_options 69 options = self.class.searchkick_options
66 70
lib/searchkick/query.rb 0 → 100644
@@ -0,0 +1,429 @@ @@ -0,0 +1,429 @@
  1 +module Searchkick
  2 + class Query
  3 + attr_reader :klass, :term, :options
  4 + attr_accessor :body
  5 +
  6 + def initialize(klass, term, options = {})
  7 + if term.is_a?(Hash)
  8 + options = term
  9 + term = nil
  10 + else
  11 + term = term.to_s
  12 + end
  13 +
  14 + @klass = klass
  15 + @term = term
  16 + @options = options
  17 +
  18 + fields =
  19 + if options[:fields]
  20 + if options[:autocomplete]
  21 + options[:fields].map{|f| "#{f}.autocomplete" }
  22 + else
  23 + options[:fields].map do |value|
  24 + k, v = value.is_a?(Hash) ? value.to_a.first : [value, :word]
  25 + "#{k}.#{v == :word ? "analyzed" : v}"
  26 + end
  27 + end
  28 + else
  29 + if options[:autocomplete]
  30 + (searchkick_options[:autocomplete] || []).map{|f| "#{f}.autocomplete" }
  31 + else
  32 + ["_all"]
  33 + end
  34 + end
  35 +
  36 + operator = options[:operator] || (options[:partial] ? "or" : "and")
  37 +
  38 + # model and eagar loading
  39 + load = options[:load].nil? ? true : options[:load]
  40 + load = (options[:include] ? {include: options[:include]} : true) if load
  41 +
  42 + # pagination
  43 + page = [options[:page].to_i, 1].max
  44 + per_page = (options[:limit] || options[:per_page] || 100000).to_i
  45 + offset = options[:offset] || (page - 1) * per_page
  46 + index_name = options[:index_name] || searchkick_index.name
  47 +
  48 + conversions_field = searchkick_options[:conversions]
  49 + personalize_field = searchkick_options[:personalize]
  50 +
  51 + all = term == "*"
  52 +
  53 + if options[:query]
  54 + payload = options[:query]
  55 + elsif options[:similar]
  56 + payload = {
  57 + more_like_this: {
  58 + fields: fields,
  59 + like_text: term,
  60 + min_doc_freq: 1,
  61 + min_term_freq: 1,
  62 + analyzer: "searchkick_search2"
  63 + }
  64 + }
  65 + elsif all
  66 + payload = {
  67 + match_all: {}
  68 + }
  69 + else
  70 + if options[:autocomplete]
  71 + payload = {
  72 + multi_match: {
  73 + fields: fields,
  74 + query: term,
  75 + analyzer: "searchkick_autocomplete_search"
  76 + }
  77 + }
  78 + else
  79 + queries = []
  80 + fields.each do |field|
  81 + if field == "_all" or field.end_with?(".analyzed")
  82 + shared_options = {
  83 + fields: [field],
  84 + query: term,
  85 + use_dis_max: false,
  86 + operator: operator,
  87 + cutoff_frequency: 0.001
  88 + }
  89 + queries.concat [
  90 + {multi_match: shared_options.merge(boost: 10, analyzer: "searchkick_search")},
  91 + {multi_match: shared_options.merge(boost: 10, analyzer: "searchkick_search2")}
  92 + ]
  93 + if options[:misspellings] != false
  94 + distance = (options[:misspellings].is_a?(Hash) && options[:misspellings][:distance]) || 1
  95 + queries.concat [
  96 + {multi_match: shared_options.merge(fuzziness: distance, max_expansions: 3, analyzer: "searchkick_search")},
  97 + {multi_match: shared_options.merge(fuzziness: distance, max_expansions: 3, analyzer: "searchkick_search2")}
  98 + ]
  99 + end
  100 + else
  101 + analyzer = field.match(/\.word_(start|middle|end)\z/) ? "searchkick_word_search" : "searchkick_autocomplete_search"
  102 + queries << {
  103 + multi_match: {
  104 + fields: [field],
  105 + query: term,
  106 + analyzer: analyzer
  107 + }
  108 + }
  109 + end
  110 + end
  111 +
  112 + payload = {
  113 + dis_max: {
  114 + queries: queries
  115 + }
  116 + }
  117 + end
  118 +
  119 + if conversions_field and options[:conversions] != false
  120 + # wrap payload in a bool query
  121 + payload = {
  122 + bool: {
  123 + must: payload,
  124 + should: {
  125 + nested: {
  126 + path: conversions_field,
  127 + score_mode: "total",
  128 + query: {
  129 + custom_score: {
  130 + query: {
  131 + match: {
  132 + query: term
  133 + }
  134 + },
  135 + script: "doc['count'].value"
  136 + }
  137 + }
  138 + }
  139 + }
  140 + }
  141 + }
  142 + end
  143 + end
  144 +
  145 + custom_filters = []
  146 +
  147 + if options[:boost]
  148 + custom_filters << {
  149 + filter: {
  150 + exists: {
  151 + field: options[:boost]
  152 + }
  153 + },
  154 + script: "log(doc['#{options[:boost]}'].value + 2.718281828)"
  155 + }
  156 + end
  157 +
  158 + if options[:user_id] and personalize_field
  159 + custom_filters << {
  160 + filter: {
  161 + term: {
  162 + personalize_field => options[:user_id]
  163 + }
  164 + },
  165 + boost: 100
  166 + }
  167 + end
  168 +
  169 + if options[:personalize]
  170 + custom_filters << {
  171 + filter: {
  172 + term: options[:personalize]
  173 + },
  174 + boost: 100
  175 + }
  176 + end
  177 +
  178 + if custom_filters.any?
  179 + payload = {
  180 + custom_filters_score: {
  181 + query: payload,
  182 + filters: custom_filters,
  183 + score_mode: "total"
  184 + }
  185 + }
  186 + end
  187 +
  188 + payload = {
  189 + query: payload,
  190 + size: per_page,
  191 + from: offset
  192 + }
  193 + payload[:explain] = options[:explain] if options[:explain]
  194 +
  195 + # order
  196 + if options[:order]
  197 + order = options[:order].is_a?(Enumerable) ? options[:order] : {options[:order] => :asc}
  198 + payload[:sort] = Hash[ order.map{|k, v| [k.to_s == "id" ? :_id : k, v] } ]
  199 + end
  200 +
  201 + # filters
  202 + filters = where_filters(options[:where])
  203 + if filters.any?
  204 + payload[:filter] = {
  205 + and: filters
  206 + }
  207 + end
  208 +
  209 + # facets
  210 + facet_limits = {}
  211 + if options[:facets]
  212 + facets = options[:facets] || {}
  213 + if facets.is_a?(Array) # convert to more advanced syntax
  214 + facets = Hash[ facets.map{|f| [f, {}] } ]
  215 + end
  216 +
  217 + payload[:facets] = {}
  218 + facets.each do |field, facet_options|
  219 + # ask for extra facets due to
  220 + # https://github.com/elasticsearch/elasticsearch/issues/1305
  221 +
  222 + if facet_options[:ranges]
  223 + payload[:facets][field] = {
  224 + range: {
  225 + field.to_sym => facet_options[:ranges]
  226 + }
  227 + }
  228 + else
  229 + payload[:facets][field] = {
  230 + terms: {
  231 + field: field,
  232 + size: facet_options[:limit] ? facet_options[:limit] + 150 : 100000
  233 + }
  234 + }
  235 + end
  236 +
  237 + facet_limits[field] = facet_options[:limit] if facet_options[:limit]
  238 +
  239 + # offset is not possible
  240 + # http://elasticsearch-users.115913.n3.nabble.com/Is-pagination-possible-in-termsStatsFacet-td3422943.html
  241 +
  242 + facet_filters = where_filters(facet_options[:where])
  243 + if facet_filters.any?
  244 + payload[:facets][field][:facet_filter] = {
  245 + and: {
  246 + filters: facet_filters
  247 + }
  248 + }
  249 + end
  250 + end
  251 + end
  252 +
  253 + # suggestions
  254 + if options[:suggest]
  255 + suggest_fields = (searchkick_options[:suggest] || []).map(&:to_s)
  256 + # intersection
  257 + suggest_fields = suggest_fields & options[:fields].map(&:to_s) if options[:fields]
  258 + if suggest_fields.any?
  259 + payload[:suggest] = {text: term}
  260 + suggest_fields.each do |field|
  261 + payload[:suggest][field] = {
  262 + phrase: {
  263 + field: "#{field}.suggest"
  264 + }
  265 + }
  266 + end
  267 + end
  268 + end
  269 +
  270 + # highlight
  271 + if options[:highlight]
  272 + payload[:highlight] = {
  273 + fields: Hash[ fields.map{|f| [f, {}] } ]
  274 + }
  275 + if options[:highlight].is_a?(Hash) and tag = options[:highlight][:tag]
  276 + payload[:highlight][:pre_tags] = [tag]
  277 + payload[:highlight][:post_tags] = [tag.to_s.gsub(/\A</, "</")]
  278 + end
  279 + end
  280 +
  281 + # An empty array will cause only the _id and _type for each hit to be returned
  282 + # http://www.elasticsearch.org/guide/reference/api/search/fields/
  283 + payload[:fields] = [] if load
  284 +
  285 + tire_options = {load: load, size: per_page, from: offset}
  286 + if options[:type] or klass != searchkick_klass
  287 + tire_options[:type] = [options[:type] || klass].flatten.map(&:document_type)
  288 + end
  289 +
  290 + @body = payload
  291 + @facet_limits = facet_limits
  292 + end
  293 +
  294 + def searchkick_index
  295 + klass.searchkick_index
  296 + end
  297 +
  298 + def searchkick_options
  299 + klass.searchkick_options
  300 + end
  301 +
  302 + def searchkick_klass
  303 + klass.searchkick_klass
  304 + end
  305 +
  306 + def document_type
  307 + klass.document_type
  308 + end
  309 +
  310 + def execute
  311 + begin
  312 + response = Searchkick.client.search(index: searchkick_index.name, body: body)
  313 + rescue => e # TODO rescue type
  314 + status_code = e.message[0..3].to_i
  315 + if status_code == 404
  316 + raise "Index missing - run #{searchkick_klass.name}.reindex"
  317 + elsif status_code == 500 and (e.message.include?("IllegalArgumentException[minimumSimilarity >= 1]") or e.message.include?("No query registered for [multi_match]"))
  318 + raise "Upgrade Elasticsearch to 0.90.0 or greater"
  319 + else
  320 + raise e
  321 + end
  322 + end
  323 +
  324 + # apply facet limit in client due to
  325 + # https://github.com/elasticsearch/elasticsearch/issues/1305
  326 + @facet_limits.each do |field, limit|
  327 + field = field.to_s
  328 + facet = response["facets"][field]
  329 + response["facets"][field]["terms"] = facet["terms"].first(limit)
  330 + response["facets"][field]["other"] = facet["total"] - facet["terms"].sum{|term| term["count"] }
  331 + end
  332 +
  333 + Searchkick::Results.new(response, @search.options.merge(term: term, model_name: searchkick_klass.model_name))
  334 + end
  335 +
  336 + private
  337 +
  338 + def where_filters(where)
  339 + filters = []
  340 + (where || {}).each do |field, value|
  341 + field = :_id if field.to_s == "id"
  342 +
  343 + if field == :or
  344 + value.each do |or_clause|
  345 + filters << {or: or_clause.map{|or_statement| {and: where_filters(or_statement)} }}
  346 + end
  347 + else
  348 + # expand ranges
  349 + if value.is_a?(Range)
  350 + value = {gte: value.first, (value.exclude_end? ? :lt : :lte) => value.last}
  351 + end
  352 +
  353 + if value.is_a?(Array)
  354 + value = {in: value}
  355 + end
  356 +
  357 + if value.is_a?(Hash)
  358 + value.each do |op, op_value|
  359 + case op
  360 + when :within, :bottom_right
  361 + # do nothing
  362 + when :near
  363 + filters << {
  364 + geo_distance: {
  365 + field => op_value.map(&:to_f).reverse,
  366 + distance: value[:within] || "50mi"
  367 + }
  368 + }
  369 + when :top_left
  370 + filters << {
  371 + geo_bounding_box: {
  372 + field => {
  373 + top_left: op_value.map(&:to_f).reverse,
  374 + bottom_right: value[:bottom_right].map(&:to_f).reverse
  375 + }
  376 + }
  377 + }
  378 + when :not # not equal
  379 + filters << {not: term_filters(field, op_value)}
  380 + when :all
  381 + filters << {terms: {field => op_value, execution: "and"}}
  382 + when :in
  383 + filters << term_filters(field, op_value)
  384 + else
  385 + range_query =
  386 + case op
  387 + when :gt
  388 + {from: op_value, include_lower: false}
  389 + when :gte
  390 + {from: op_value, include_lower: true}
  391 + when :lt
  392 + {to: op_value, include_upper: false}
  393 + when :lte
  394 + {to: op_value, include_upper: true}
  395 + else
  396 + raise "Unknown where operator"
  397 + end
  398 + # issue 132
  399 + if existing = filters.find{ |f| f[:range] && f[:range][field] }
  400 + existing[:range][field].merge!(range_query)
  401 + else
  402 + filters << {range: {field => range_query}}
  403 + end
  404 + end
  405 + end
  406 + else
  407 + filters << term_filters(field, value)
  408 + end
  409 + end
  410 + end
  411 + filters
  412 + end
  413 +
  414 + def term_filters(field, value)
  415 + if value.is_a?(Array) # in query
  416 + if value.any?
  417 + {or: value.map{|v| term_filters(field, v) }}
  418 + else
  419 + {terms: {field => value}} # match nothing
  420 + end
  421 + elsif value.nil?
  422 + {missing: {"field" => field, existence: true, null_value: true}}
  423 + else
  424 + {term: {field => value}}
  425 + end
  426 + end
  427 +
  428 + end
  429 +end
0 \ No newline at end of file 430 \ No newline at end of file
lib/searchkick/reindex.rb
@@ -44,17 +44,20 @@ module Searchkick @@ -44,17 +44,20 @@ module Searchkick
44 end 44 end
45 45
46 def self.extended(klass) 46 def self.extended(klass)
47 - (@descendents ||= []) << klass 47 + @descendents ||= []
  48 + @descendents << klass unless @descendents.include?(klass)
48 end 49 end
49 50
50 private 51 private
51 52
52 def searchkick_import(index) 53 def searchkick_import(index)
  54 + batch_size = searchkick_options[:batch_size] || 1000
  55 +
53 # use scope for import 56 # use scope for import
54 scope = searchkick_klass 57 scope = searchkick_klass
55 scope = scope.search_import if scope.respond_to?(:search_import) 58 scope = scope.search_import if scope.respond_to?(:search_import)
56 if scope.respond_to?(:find_in_batches) 59 if scope.respond_to?(:find_in_batches)
57 - scope.find_in_batches do |batch| 60 + scope.find_in_batches batch_size: batch_size do |batch|
58 index.import batch.select{|item| item.should_index? } 61 index.import batch.select{|item| item.should_index? }
59 end 62 end
60 else 63 else
@@ -63,7 +66,7 @@ module Searchkick @@ -63,7 +66,7 @@ module Searchkick
63 items = [] 66 items = []
64 scope.all.each do |item| 67 scope.all.each do |item|
65 items << item if item.should_index? 68 items << item if item.should_index?
66 - if items.length % 1000 == 0 69 + if items.length % batch_size == 0
67 index.import items 70 index.import items
68 items = [] 71 items = []
69 end 72 end
@@ -75,7 +78,7 @@ module Searchkick @@ -75,7 +78,7 @@ module Searchkick
75 def searchkick_index_options 78 def searchkick_index_options
76 options = searchkick_options 79 options = searchkick_options
77 80
78 - if options[:mappings] 81 + if options[:mappings] and !options[:merge_mappings]
79 settings = options[:settings] || {} 82 settings = options[:settings] || {}
80 mappings = options[:mappings] 83 mappings = options[:mappings]
81 else 84 else
@@ -85,24 +88,24 @@ module Searchkick @@ -85,24 +88,24 @@ module Searchkick
85 searchkick_keyword: { 88 searchkick_keyword: {
86 type: "custom", 89 type: "custom",
87 tokenizer: "keyword", 90 tokenizer: "keyword",
88 - filter: ["lowercase", "snowball"] 91 + filter: ["lowercase", "searchkick_stemmer"]
89 }, 92 },
90 default_index: { 93 default_index: {
91 type: "custom", 94 type: "custom",
92 tokenizer: "standard", 95 tokenizer: "standard",
93 # synonym should come last, after stemming and shingle 96 # synonym should come last, after stemming and shingle
94 - # shingle must come before snowball  
95 - filter: ["standard", "lowercase", "asciifolding", "stop", "searchkick_index_shingle", "snowball"] 97 + # shingle must come before searchkick_stemmer
  98 + filter: ["standard", "lowercase", "asciifolding", "searchkick_index_shingle", "searchkick_stemmer"]
96 }, 99 },
97 searchkick_search: { 100 searchkick_search: {
98 type: "custom", 101 type: "custom",
99 tokenizer: "standard", 102 tokenizer: "standard",
100 - filter: ["standard", "lowercase", "asciifolding", "stop", "searchkick_search_shingle", "snowball"] 103 + filter: ["standard", "lowercase", "asciifolding", "searchkick_search_shingle", "searchkick_stemmer"]
101 }, 104 },
102 searchkick_search2: { 105 searchkick_search2: {
103 type: "custom", 106 type: "custom",
104 tokenizer: "standard", 107 tokenizer: "standard",
105 - filter: ["standard", "lowercase", "asciifolding", "stop", "snowball"] 108 + filter: ["standard", "lowercase", "asciifolding", "searchkick_stemmer"]
106 }, 109 },
107 # https://github.com/leschenko/elasticsearch_autocomplete/blob/master/lib/elasticsearch_autocomplete/analyzers.rb 110 # https://github.com/leschenko/elasticsearch_autocomplete/blob/master/lib/elasticsearch_autocomplete/analyzers.rb
108 searchkick_autocomplete_index: { 111 searchkick_autocomplete_index: {
@@ -115,10 +118,50 @@ module Searchkick @@ -115,10 +118,50 @@ module Searchkick
115 tokenizer: "keyword", 118 tokenizer: "keyword",
116 filter: ["lowercase", "asciifolding"] 119 filter: ["lowercase", "asciifolding"]
117 }, 120 },
  121 + searchkick_word_search: {
  122 + type: "custom",
  123 + tokenizer: "standard",
  124 + filter: ["lowercase", "asciifolding"]
  125 + },
118 searchkick_suggest_index: { 126 searchkick_suggest_index: {
119 type: "custom", 127 type: "custom",
120 tokenizer: "standard", 128 tokenizer: "standard",
121 filter: ["lowercase", "asciifolding", "searchkick_suggest_shingle"] 129 filter: ["lowercase", "asciifolding", "searchkick_suggest_shingle"]
  130 + },
  131 + searchkick_suggest_index: {
  132 + type: "custom",
  133 + tokenizer: "standard",
  134 + filter: ["lowercase", "asciifolding", "searchkick_suggest_shingle"]
  135 + },
  136 + searchkick_text_start_index: {
  137 + type: "custom",
  138 + tokenizer: "keyword",
  139 + filter: ["lowercase", "asciifolding", "searchkick_edge_ngram"]
  140 + },
  141 + searchkick_text_middle_index: {
  142 + type: "custom",
  143 + tokenizer: "keyword",
  144 + filter: ["lowercase", "asciifolding", "searchkick_ngram"]
  145 + },
  146 + searchkick_text_end_index: {
  147 + type: "custom",
  148 + tokenizer: "keyword",
  149 + filter: ["lowercase", "asciifolding", "reverse", "searchkick_edge_ngram", "reverse"]
  150 + },
  151 + searchkick_word_start_index: {
  152 + type: "custom",
  153 + tokenizer: "standard",
  154 + filter: ["lowercase", "asciifolding", "searchkick_edge_ngram"]
  155 + },
  156 + searchkick_word_middle_index: {
  157 + type: "custom",
  158 + tokenizer: "standard",
  159 + filter: ["lowercase", "asciifolding", "searchkick_ngram"]
  160 + },
  161 + searchkick_word_end_index: {
  162 + type: "custom",
  163 + tokenizer: "standard",
  164 + filter: ["lowercase", "asciifolding", "reverse", "searchkick_edge_ngram", "reverse"]
122 } 165 }
123 }, 166 },
124 filter: { 167 filter: {
@@ -136,6 +179,20 @@ module Searchkick @@ -136,6 +179,20 @@ module Searchkick
136 searchkick_suggest_shingle: { 179 searchkick_suggest_shingle: {
137 type: "shingle", 180 type: "shingle",
138 max_shingle_size: 5 181 max_shingle_size: 5
  182 + },
  183 + searchkick_edge_ngram: {
  184 + type: "edgeNGram",
  185 + min_gram: 1,
  186 + max_gram: 50
  187 + },
  188 + searchkick_ngram: {
  189 + type: "nGram",
  190 + min_gram: 1,
  191 + max_gram: 50
  192 + },
  193 + searchkick_stemmer: {
  194 + type: "snowball",
  195 + language: options[:language] || "English"
139 } 196 }
140 }, 197 },
141 tokenizer: { 198 tokenizer: {
@@ -152,7 +209,7 @@ module Searchkick @@ -152,7 +209,7 @@ module Searchkick
152 settings.merge!(number_of_shards: 1, number_of_replicas: 0) 209 settings.merge!(number_of_shards: 1, number_of_replicas: 0)
153 end 210 end
154 211
155 - settings.merge!(options[:settings] || {}) 212 + settings.deep_merge!(options[:settings] || {})
156 213
157 # synonyms 214 # synonyms
158 synonyms = options[:synonyms] || [] 215 synonyms = options[:synonyms] || []
@@ -193,10 +250,12 @@ module Searchkick @@ -193,10 +250,12 @@ module Searchkick
193 } 250 }
194 end 251 end
195 252
196 - # autocomplete and suggest  
197 - autocomplete = (options[:autocomplete] || []).map(&:to_s)  
198 - suggest = (options[:suggest] || []).map(&:to_s)  
199 - (autocomplete + suggest).uniq.each do |field| 253 + mapping_options = Hash[
  254 + [:autocomplete, :suggest, :text_start, :text_middle, :text_end, :word_start, :word_middle, :word_end]
  255 + .map{|type| [type, (options[type] || []).map(&:to_s)] }
  256 + ]
  257 +
  258 + mapping_options.values.flatten.uniq.each do |field|
200 field_mapping = { 259 field_mapping = {
201 type: "multi_field", 260 type: "multi_field",
202 fields: { 261 fields: {
@@ -206,12 +265,13 @@ module Searchkick @@ -206,12 +265,13 @@ module Searchkick
206 # http://www.elasticsearch.org/guide/en/elasticsearch/reference/current/search-request-highlighting.html#_fast_vector_highlighter 265 # http://www.elasticsearch.org/guide/en/elasticsearch/reference/current/search-request-highlighting.html#_fast_vector_highlighter
207 } 266 }
208 } 267 }
209 - if autocomplete.include?(field)  
210 - field_mapping[:fields]["autocomplete"] = {type: "string", index: "analyzed", analyzer: "searchkick_autocomplete_index"}  
211 - end  
212 - if suggest.include?(field)  
213 - field_mapping[:fields]["suggest"] = {type: "string", index: "analyzed", analyzer: "searchkick_suggest_index"} 268 +
  269 + mapping_options.each do |type, fields|
  270 + if fields.include?(field)
  271 + field_mapping[:fields][type] = {type: "string", index: "analyzed", analyzer: "searchkick_#{type}_index"}
  272 + end
214 end 273 end
  274 +
215 mapping[field] = field_mapping 275 mapping[field] = field_mapping
216 end 276 end
217 277
@@ -246,7 +306,7 @@ module Searchkick @@ -246,7 +306,7 @@ module Searchkick
246 } 306 }
247 ] 307 ]
248 } 308 }
249 - } 309 + }.deep_merge(options[:mappings] || {})
250 end 310 end
251 311
252 { 312 {
lib/searchkick/results.rb
@@ -23,5 +23,10 @@ module Searchkick @@ -23,5 +23,10 @@ module Searchkick
23 def __find_records_by_ids(klass, ids) 23 def __find_records_by_ids(klass, ids)
24 @options[:load] === true ? klass.find(ids) : klass.includes(@options[:load][:include]).find(ids) 24 @options[:load] === true ? klass.find(ids) : klass.includes(@options[:load][:include]).find(ids)
25 end 25 end
  26 +
  27 + def model_name
  28 + @options[:model_name]
  29 + end
  30 +
26 end 31 end
27 end 32 end
lib/searchkick/search.rb
@@ -2,355 +2,12 @@ module Searchkick @@ -2,355 +2,12 @@ module Searchkick
2 module Search 2 module Search
3 3
4 def search(term, options = {}) 4 def search(term, options = {})
5 - if term.is_a?(Hash)  
6 - options = term  
7 - term = nil 5 + query = Searchkick::Query.new(self, term, options)
  6 + if options[:execute] == false
  7 + query
8 else 8 else
9 - term = term.to_s 9 + query.execute
10 end 10 end
11 -  
12 - fields =  
13 - if options[:fields]  
14 - if options[:autocomplete]  
15 - options[:fields].map{|f| "#{f}.autocomplete" }  
16 - else  
17 - options[:fields].map{|f| "#{f}.analyzed" }  
18 - end  
19 - else  
20 - if options[:autocomplete]  
21 - (searchkick_options[:autocomplete] || []).map{|f| "#{f}.autocomplete" }  
22 - else  
23 - ["_all"]  
24 - end  
25 - end  
26 -  
27 - operator = options[:partial] ? "or" : "and"  
28 -  
29 - # model and eagar loading  
30 - load = options[:load].nil? ? true : options[:load]  
31 - load = (options[:include] ? {include: options[:include]} : true) if load  
32 -  
33 - # pagination  
34 - page = [options[:page].to_i, 1].max  
35 - per_page = (options[:limit] || options[:per_page] || 100000).to_i  
36 - offset = options[:offset] || (page - 1) * per_page  
37 - index_name = options[:index_name] || searchkick_index.name  
38 -  
39 - conversions_field = searchkick_options[:conversions]  
40 - personalize_field = searchkick_options[:personalize]  
41 -  
42 - all = term == "*"  
43 -  
44 - if options[:query]  
45 - payload = options[:query]  
46 - elsif options[:similar]  
47 - payload = {  
48 - more_like_this: {  
49 - fields: fields,  
50 - like_text: term,  
51 - min_doc_freq: 1,  
52 - min_term_freq: 1,  
53 - analyzer: "searchkick_search2"  
54 - }  
55 - }  
56 - elsif all  
57 - payload = {  
58 - match_all: {}  
59 - }  
60 - else  
61 - if options[:autocomplete]  
62 - payload = {  
63 - multi_match: {  
64 - fields: fields,  
65 - query: term,  
66 - analyzer: "searchkick_autocomplete_search"  
67 - }  
68 - }  
69 - else  
70 - shared_options = {  
71 - fields: fields,  
72 - query: term,  
73 - use_dis_max: false,  
74 - operator: operator  
75 - }  
76 - queries = [  
77 - {multi_match: shared_options.merge(boost: 10, analyzer: "searchkick_search")},  
78 - {multi_match: shared_options.merge(boost: 10, analyzer: "searchkick_search2")}  
79 - ]  
80 - if options[:misspellings] != false  
81 - distance = (options[:misspellings].is_a?(Hash) && options[:misspellings][:distance]) || 1  
82 - queries.concat [  
83 - {multi_match: shared_options.merge(fuzziness: distance, max_expansions: 3, analyzer: "searchkick_search")},  
84 - {multi_match: shared_options.merge(fuzziness: distance, max_expansions: 3, analyzer: "searchkick_search2")}  
85 - ]  
86 - end  
87 - payload = {  
88 - dis_max: {  
89 - queries: queries  
90 - }  
91 - }  
92 - end  
93 -  
94 - if conversions_field and options[:conversions] != false  
95 - # wrap payload in a bool query  
96 - payload = {  
97 - bool: {  
98 - must: payload,  
99 - should: {  
100 - nested: {  
101 - path: conversions_field,  
102 - score_mode: "total",  
103 - query: {  
104 - custom_score: {  
105 - query: {  
106 - match: {  
107 - query: term  
108 - }  
109 - },  
110 - script: "doc['count'].value"  
111 - }  
112 - }  
113 - }  
114 - }  
115 - }  
116 - }  
117 - end  
118 - end  
119 -  
120 - custom_filters = []  
121 -  
122 - if options[:boost]  
123 - custom_filters << {  
124 - filter: {  
125 - exists: {  
126 - field: options[:boost]  
127 - }  
128 - },  
129 - script: "log(doc['#{options[:boost]}'].value + 2.718281828)"  
130 - }  
131 - end  
132 -  
133 - if options[:user_id] and personalize_field  
134 - custom_filters << {  
135 - filter: {  
136 - term: {  
137 - personalize_field => options[:user_id]  
138 - }  
139 - },  
140 - boost: 100  
141 - }  
142 - end  
143 -  
144 - if custom_filters.any?  
145 - payload = {  
146 - custom_filters_score: {  
147 - query: payload,  
148 - filters: custom_filters,  
149 - score_mode: "total"  
150 - }  
151 - }  
152 - end  
153 -  
154 - payload = {  
155 - query: payload,  
156 - size: per_page,  
157 - from: offset  
158 - }  
159 - payload[:explain] = options[:explain] if options[:explain]  
160 -  
161 - # order  
162 - if options[:order]  
163 - order = options[:order].is_a?(Enumerable) ? options[:order] : {options[:order] => :asc}  
164 - payload[:sort] = order  
165 - end  
166 -  
167 - # where  
168 - where_filters =  
169 - proc do |where|  
170 - filters = []  
171 - (where || {}).each do |field, value|  
172 - field = :_id if field.to_s == "id"  
173 -  
174 - if field == :or  
175 - value.each do |or_clause|  
176 - filters << {or: or_clause.map{|or_statement| {and: where_filters.call(or_statement)} }}  
177 - end  
178 - else  
179 - # expand ranges  
180 - if value.is_a?(Range)  
181 - value = {gte: value.first, (value.exclude_end? ? :lt : :lte) => value.last}  
182 - end  
183 -  
184 - if value.is_a?(Array) # in query  
185 - filters << {terms: {field => value}}  
186 - elsif value.is_a?(Hash)  
187 - if value[:near]  
188 - filters << {  
189 - geo_distance: {  
190 - field => value.delete(:near).map(&:to_f).reverse,  
191 - distance: value.delete(:within) || "50mi"  
192 - }  
193 - }  
194 - end  
195 -  
196 - if value[:top_left]  
197 - filters << {  
198 - geo_bounding_box: {  
199 - field => {  
200 - top_left: value.delete(:top_left).map(&:to_f).reverse,  
201 - bottom_right: value.delete(:bottom_right).map(&:to_f).reverse  
202 - }  
203 - }  
204 - }  
205 - end  
206 -  
207 - value.each do |op, op_value|  
208 - if op == :not # not equal  
209 - if op_value.is_a?(Array)  
210 - filters << {not: {terms: {field => op_value}}}  
211 - else  
212 - filters << {not: {term: {field => op_value}}}  
213 - end  
214 - elsif op == :all  
215 - filters << {terms: {field => op_value, execution: "and"}}  
216 - else  
217 - range_query =  
218 - case op  
219 - when :gt  
220 - {from: op_value, include_lower: false}  
221 - when :gte  
222 - {from: op_value, include_lower: true}  
223 - when :lt  
224 - {to: op_value, include_upper: false}  
225 - when :lte  
226 - {to: op_value, include_upper: true}  
227 - else  
228 - raise "Unknown where operator"  
229 - end  
230 - filters << {range: {field => range_query}}  
231 - end  
232 - end  
233 - else  
234 - if value.nil?  
235 - filters << {missing: {"field" => field, existence: true, null_value: true}}  
236 - else  
237 - filters << {term: {field => value}}  
238 - end  
239 - end  
240 - end  
241 - end  
242 - filters  
243 - end  
244 -  
245 - # filters  
246 - filters = where_filters.call(options[:where])  
247 - if filters.any?  
248 - payload[:filter] = {  
249 - and: filters  
250 - }  
251 - end  
252 -  
253 - # facets  
254 - facet_limits = {}  
255 - if options[:facets]  
256 - facets = options[:facets] || {}  
257 - if facets.is_a?(Array) # convert to more advanced syntax  
258 - facets = Hash[ facets.map{|f| [f, {}] } ]  
259 - end  
260 -  
261 - payload[:facets] = {}  
262 - facets.each do |field, facet_options|  
263 - # ask for extra facets due to  
264 - # https://github.com/elasticsearch/elasticsearch/issues/1305  
265 -  
266 - if facet_options[:ranges]  
267 - payload[:facets][field] = {  
268 - range: {  
269 - field.to_sym => facet_options[:ranges]  
270 - }  
271 - }  
272 - else  
273 - payload[:facets][field] = {  
274 - terms: {  
275 - field: field,  
276 - size: facet_options[:limit] ? facet_options[:limit] + 150 : 100000  
277 - }  
278 - }  
279 - end  
280 -  
281 - facet_limits[field] = facet_options[:limit] if facet_options[:limit]  
282 -  
283 - # offset is not possible  
284 - # http://elasticsearch-users.115913.n3.nabble.com/Is-pagination-possible-in-termsStatsFacet-td3422943.html  
285 -  
286 - facet_filters = where_filters.call(facet_options[:where])  
287 - if facet_filters.any?  
288 - payload[:facets][field][:facet_filter] = {  
289 - and: {  
290 - filters: facet_filters  
291 - }  
292 - }  
293 - end  
294 - end  
295 - end  
296 -  
297 - # suggestions  
298 - if options[:suggest]  
299 - suggest_fields = (searchkick_options[:suggest] || []).map(&:to_s)  
300 - # intersection  
301 - suggest_fields = suggest_fields & options[:fields].map(&:to_s) if options[:fields]  
302 - if suggest_fields.any?  
303 - payload[:suggest] = {text: term}  
304 - suggest_fields.each do |field|  
305 - payload[:suggest][field] = {  
306 - phrase: {  
307 - field: "#{field}.suggest"  
308 - }  
309 - }  
310 - end  
311 - end  
312 - end  
313 -  
314 - # highlight  
315 - if options[:highlight]  
316 - payload[:highlight] = {  
317 - fields: Hash[ fields.map{|f| [f, {}] } ]  
318 - }  
319 - if options[:highlight].is_a?(Hash) and tag = options[:highlight][:tag]  
320 - payload[:highlight][:pre_tags] = [tag]  
321 - payload[:highlight][:post_tags] = [tag.to_s.gsub(/\A</, "</")]  
322 - end  
323 - end  
324 -  
325 - # An empty array will cause only the _id and _type for each hit to be returned  
326 - # http://www.elasticsearch.org/guide/reference/api/search/fields/  
327 - payload[:fields] = [] if load  
328 -  
329 - tire_options = {load: load, payload: payload, size: per_page, from: offset}  
330 - tire_options[:type] = document_type if self != searchkick_klass  
331 - begin  
332 - response = Searchkick.client.search index: index_name, body: payload  
333 - rescue => e  
334 - status_code = e.message[0..3].to_i  
335 - if status_code == 404  
336 - raise "Index missing - run #{searchkick_klass.name}.reindex"  
337 - elsif status_code == 500 and (e.message.include?("IllegalArgumentException[minimumSimilarity >= 1]") or e.message.include?("No query registered for [multi_match]"))  
338 - raise "Upgrade Elasticsearch to 0.90.0 or greater"  
339 - else  
340 - raise e  
341 - end  
342 - end  
343 -  
344 - # apply facet limit in client due to  
345 - # https://github.com/elasticsearch/elasticsearch/issues/1305  
346 - facet_limits.each do |field, limit|  
347 - field = field.to_s  
348 - facet = response["facets"][field]  
349 - response["facets"][field]["terms"] = facet["terms"].first(limit)  
350 - response["facets"][field]["other"] = facet["total"] - facet["terms"].sum{|term| term["count"] }  
351 - end  
352 -  
353 - Searchkick::Results.new(response, search.options.merge(term: term))  
354 end 11 end
355 12
356 end 13 end
lib/searchkick/version.rb
1 module Searchkick 1 module Searchkick
2 - VERSION = "0.4.2" 2 + VERSION = "0.5.3"
3 end 3 end
searchkick.gemspec
@@ -23,6 +23,4 @@ Gem::Specification.new do |spec| @@ -23,6 +23,4 @@ Gem::Specification.new do |spec|
23 spec.add_development_dependency "bundler", "~> 1.3" 23 spec.add_development_dependency "bundler", "~> 1.3"
24 spec.add_development_dependency "rake" 24 spec.add_development_dependency "rake"
25 spec.add_development_dependency "minitest", "~> 4.7" 25 spec.add_development_dependency "minitest", "~> 4.7"
26 - spec.add_development_dependency "activerecord"  
27 - spec.add_development_dependency "pg"  
28 end 26 end
test/autocomplete_test.rb
@@ -17,4 +17,39 @@ class TestAutocomplete &lt; Minitest::Unit::TestCase @@ -17,4 +17,39 @@ class TestAutocomplete &lt; Minitest::Unit::TestCase
17 assert_search "hum", ["Hummus"], autocomplete: true, fields: [:name] 17 assert_search "hum", ["Hummus"], autocomplete: true, fields: [:name]
18 end 18 end
19 19
  20 + def test_text_start
  21 + store_names ["Where in the World is Carmen San Diego"]
  22 + assert_search "where in the world is", ["Where in the World is Carmen San Diego"], fields: [{name: :text_start}]
  23 + assert_search "in the world", [], fields: [{name: :text_start}]
  24 + end
  25 +
  26 + def test_text_middle
  27 + store_names ["Where in the World is Carmen San Diego"]
  28 + assert_search "where in the world is", ["Where in the World is Carmen San Diego"], fields: [{name: :text_middle}]
  29 + assert_search "n the wor", ["Where in the World is Carmen San Diego"], fields: [{name: :text_middle}]
  30 + assert_search "men san diego", ["Where in the World is Carmen San Diego"], fields: [{name: :text_middle}]
  31 + assert_search "world carmen", [], fields: [{name: :text_middle}]
  32 + end
  33 +
  34 + def test_text_end
  35 + store_names ["Where in the World is Carmen San Diego"]
  36 + assert_search "men san diego", ["Where in the World is Carmen San Diego"], fields: [{name: :text_end}]
  37 + assert_search "carmen san", [], fields: [{name: :text_end}]
  38 + end
  39 +
  40 + def test_word_start
  41 + store_names ["Where in the World is Carmen San Diego"]
  42 + assert_search "car san wor", ["Where in the World is Carmen San Diego"], fields: [{name: :word_start}]
  43 + end
  44 +
  45 + def test_word_middle
  46 + store_names ["Where in the World is Carmen San Diego"]
  47 + assert_search "orl", ["Where in the World is Carmen San Diego"], fields: [{name: :word_middle}]
  48 + end
  49 +
  50 + def test_word_end
  51 + store_names ["Where in the World is Carmen San Diego"]
  52 + assert_search "rld men ego", ["Where in the World is Carmen San Diego"], fields: [{name: :word_end}]
  53 + end
  54 +
20 end 55 end
test/boost_test.rb
@@ -57,4 +57,14 @@ class TestBoost &lt; Minitest::Unit::TestCase @@ -57,4 +57,14 @@ class TestBoost &lt; Minitest::Unit::TestCase
57 assert_first "tomato", "Tomato B", user_id: 2 57 assert_first "tomato", "Tomato B", user_id: 2
58 end 58 end
59 59
  60 + def test_personalize
  61 + store [
  62 + {name: "Tomato A"},
  63 + {name: "Tomato B", user_ids: [1, 2, 3]},
  64 + {name: "Tomato C"},
  65 + {name: "Tomato D"}
  66 + ]
  67 + assert_first "tomato", "Tomato B", personalize: {user_ids: 2}
  68 + end
  69 +
60 end 70 end
test/index_test.rb
@@ -32,4 +32,17 @@ class TestIndex &lt; Minitest::Unit::TestCase @@ -32,4 +32,17 @@ class TestIndex &lt; Minitest::Unit::TestCase
32 assert_equal ["Dollar Tree"], Store.search(query: {match: {name: "Dollar Tree"}}).map(&:name) 32 assert_equal ["Dollar Tree"], Store.search(query: {match: {name: "Dollar Tree"}}).map(&:name)
33 end 33 end
34 34
  35 + if defined?(ActiveRecord)
  36 +
  37 + def test_transaction
  38 + Product.transaction do
  39 + store_names ["Product A"]
  40 + raise ActiveRecord::Rollback
  41 + end
  42 +
  43 + assert_search "product", []
  44 + end
  45 +
  46 + end
  47 +
35 end 48 end
test/inheritance_test.rb
@@ -25,6 +25,19 @@ class TestInheritance &lt; Minitest::Unit::TestCase @@ -25,6 +25,19 @@ class TestInheritance &lt; Minitest::Unit::TestCase
25 assert_equal 2, Animal.search("bear").size 25 assert_equal 2, Animal.search("bear").size
26 end 26 end
27 27
  28 + def test_force_one_type
  29 + store_names ["Green Bear"], Dog
  30 + store_names ["Blue Bear"], Cat
  31 + assert_equal ["Blue Bear"], Animal.search("bear", type: [Cat]).map(&:name)
  32 + end
  33 +
  34 + def test_force_multiple_types
  35 + store_names ["Green Bear"], Dog
  36 + store_names ["Blue Bear"], Cat
  37 + store_names ["Red Bear"], Animal
  38 + assert_equal ["Green Bear", "Blue Bear"], Animal.search("bear", type: [Dog, Cat]).map(&:name)
  39 + end
  40 +
28 def test_child_autocomplete 41 def test_child_autocomplete
29 store_names ["Max"], Cat 42 store_names ["Max"], Cat
30 store_names ["Mark"], Dog 43 store_names ["Mark"], Dog
test/match_test.rb
@@ -139,4 +139,9 @@ class TestMatch &lt; Minitest::Unit::TestCase @@ -139,4 +139,9 @@ class TestMatch &lt; Minitest::Unit::TestCase
139 assert_search "*", ["Product A", "Product B"] 139 assert_search "*", ["Product A", "Product B"]
140 end 140 end
141 141
  142 + def test_to_be_or_not_to_be
  143 + store_names ["to be or not to be"]
  144 + assert_search "to be", ["to be or not to be"]
  145 + end
  146 +
142 end 147 end
test/query_test.rb 0 → 100644
@@ -0,0 +1,14 @@ @@ -0,0 +1,14 @@
  1 +require_relative "test_helper"
  2 +
  3 +class TestQuery < Minitest::Unit::TestCase
  4 +
  5 + def test_basic
  6 + store_names ["Milk", "Apple"]
  7 + query = Product.search("milk", execute: false)
  8 + # query.body = {query: {match_all: {}}}
  9 + # query.body = {query: {match: {name: "Apple"}}}
  10 + query.body[:query] = {match_all: {}}
  11 + assert_equal ["Apple", "Milk"], query.execute.map(&:name).sort
  12 + end
  13 +
  14 +end
test/sql_test.rb
@@ -40,7 +40,7 @@ class TestSql &lt; Minitest::Unit::TestCase @@ -40,7 +40,7 @@ class TestSql &lt; Minitest::Unit::TestCase
40 store [ 40 store [
41 {name: "Product A", store_id: 1, in_stock: true, backordered: true, created_at: now, orders_count: 4, user_ids: [1, 2, 3]}, 41 {name: "Product A", store_id: 1, in_stock: true, backordered: true, created_at: now, orders_count: 4, user_ids: [1, 2, 3]},
42 {name: "Product B", store_id: 2, in_stock: true, backordered: false, created_at: now - 1, orders_count: 3, user_ids: [1]}, 42 {name: "Product B", store_id: 2, in_stock: true, backordered: false, created_at: now - 1, orders_count: 3, user_ids: [1]},
43 - {name: "Product C", store_id: 3, in_stock: false, backordered: true, created_at: now - 2, orders_count: 2}, 43 + {name: "Product C", store_id: 3, in_stock: false, backordered: true, created_at: now - 2, orders_count: 2, user_ids: [1, 3]},
44 {name: "Product D", store_id: 4, in_stock: false, backordered: false, created_at: now - 3, orders_count: 1}, 44 {name: "Product D", store_id: 4, in_stock: false, backordered: false, created_at: now - 3, orders_count: 1},
45 ] 45 ]
46 assert_search "product", ["Product A", "Product B"], where: {in_stock: true} 46 assert_search "product", ["Product A", "Product B"], where: {in_stock: true}
@@ -60,13 +60,21 @@ class TestSql &lt; Minitest::Unit::TestCase @@ -60,13 +60,21 @@ class TestSql &lt; Minitest::Unit::TestCase
60 assert_search "product", ["Product A", "Product B"], where: {store_id: [1, 2]} 60 assert_search "product", ["Product A", "Product B"], where: {store_id: [1, 2]}
61 assert_search "product", ["Product B", "Product C", "Product D"], where: {store_id: {not: 1}} 61 assert_search "product", ["Product B", "Product C", "Product D"], where: {store_id: {not: 1}}
62 assert_search "product", ["Product C", "Product D"], where: {store_id: {not: [1, 2]}} 62 assert_search "product", ["Product C", "Product D"], where: {store_id: {not: [1, 2]}}
  63 + assert_search "product", ["Product A"], where: {user_ids: {lte: 2, gte: 2}}
63 # or 64 # or
64 assert_search "product", ["Product A", "Product B", "Product C"], where: {or: [[{in_stock: true}, {store_id: 3}]]} 65 assert_search "product", ["Product A", "Product B", "Product C"], where: {or: [[{in_stock: true}, {store_id: 3}]]}
65 assert_search "product", ["Product A", "Product B", "Product C"], where: {or: [[{orders_count: [2, 4]}, {store_id: [1, 2]}]]} 66 assert_search "product", ["Product A", "Product B", "Product C"], where: {or: [[{orders_count: [2, 4]}, {store_id: [1, 2]}]]}
66 assert_search "product", ["Product A", "Product D"], where: {or: [[{orders_count: 1}, {created_at: {gte: now - 1}, backordered: true}]]} 67 assert_search "product", ["Product A", "Product D"], where: {or: [[{orders_count: 1}, {created_at: {gte: now - 1}, backordered: true}]]}
67 # all 68 # all
68 - assert_search "product", ["Product A"], where: {user_ids: {all: [1, 3]}} 69 + assert_search "product", ["Product A", "Product C"], where: {user_ids: {all: [1, 3]}}
69 assert_search "product", [], where: {user_ids: {all: [1, 2, 3, 4]}} 70 assert_search "product", [], where: {user_ids: {all: [1, 2, 3, 4]}}
  71 + # any / nested terms
  72 + assert_search "product", ["Product B", "Product C"], where: {user_ids: {not: [2], in: [1,3]}}
  73 + # not / exists
  74 + assert_search "product", ["Product D"], where: {user_ids: nil}
  75 + assert_search "product", ["Product A", "Product B", "Product C"], where: {user_ids: {not: nil}}
  76 + assert_search "product", ["Product A", "Product C", "Product D"], where: {user_ids: [3, nil]}
  77 + assert_search "product", ["Product B"], where: {user_ids: {not: [3, nil]}}
70 end 78 end
71 79
72 def test_where_string 80 def test_where_string
@@ -90,6 +98,35 @@ class TestSql &lt; Minitest::Unit::TestCase @@ -90,6 +98,35 @@ class TestSql &lt; Minitest::Unit::TestCase
90 assert_search "product", ["Product A"], where: {id: product.id.to_s} 98 assert_search "product", ["Product A"], where: {id: product.id.to_s}
91 end 99 end
92 100
  101 + def test_where_empty
  102 + store_names ["Product A"]
  103 + assert_search "product", ["Product A"], where: {}
  104 + end
  105 +
  106 + def test_where_empty_array
  107 + store_names ["Product A"]
  108 + assert_search "product", [], where: {store_id: []}
  109 + end
  110 +
  111 + # http://elasticsearch-users.115913.n3.nabble.com/Numeric-range-quey-or-filter-in-an-array-field-possible-or-not-td4042967.html
  112 + # https://gist.github.com/jprante/7099463
  113 + def test_where_range_array
  114 + store [
  115 + {name: "Product A", user_ids: [11, 23, 13, 16, 17, 23.6]},
  116 + {name: "Product B", user_ids: [1, 2, 3, 4, 5, 6, 7, 8, 8.9, 9.1, 9.4]},
  117 + {name: "Product C", user_ids: [101, 230, 150, 200]}
  118 + ]
  119 + assert_search "product", ["Product A"], where: {user_ids: {gt: 10, lt: 23.9}}
  120 + end
  121 +
  122 + def test_where_range_array_again
  123 + store [
  124 + {name: "Product A", user_ids: [19, 32, 42]},
  125 + {name: "Product B", user_ids: [13, 40, 52]}
  126 + ]
  127 + assert_search "product", ["Product A"], where: {user_ids: {gt: 26, lt: 36}}
  128 + end
  129 +
93 def test_near 130 def test_near
94 store [ 131 store [
95 {name: "San Francisco", latitude: 37.7833, longitude: -122.4167}, 132 {name: "San Francisco", latitude: 37.7833, longitude: -122.4167},
@@ -133,12 +170,34 @@ class TestSql &lt; Minitest::Unit::TestCase @@ -133,12 +170,34 @@ class TestSql &lt; Minitest::Unit::TestCase
133 assert_order "product", ["Product A", "Product B", "Product C", "Product D"], order: "name" 170 assert_order "product", ["Product A", "Product B", "Product C", "Product D"], order: "name"
134 end 171 end
135 172
  173 + def test_order_id
  174 + store_names ["Product A", "Product B"]
  175 + product_a = Product.where(name: "Product A").first
  176 + product_b = Product.where(name: "Product B").first
  177 + assert_order "product", [product_a, product_b].sort_by(&:id).map(&:name), order: {id: :asc}
  178 + end
  179 +
  180 + def test_order_multiple
  181 + store [
  182 + {name: "Product A", color: "blue", store_id: 1},
  183 + {name: "Product B", color: "red", store_id: 3},
  184 + {name: "Product C", color: "red", store_id: 2}
  185 + ]
  186 + assert_order "product", ["Product A", "Product B", "Product C"], order: {color: :asc, store_id: :desc}
  187 + end
  188 +
136 def test_partial 189 def test_partial
137 store_names ["Honey"] 190 store_names ["Honey"]
138 assert_search "fresh honey", [] 191 assert_search "fresh honey", []
139 assert_search "fresh honey", ["Honey"], partial: true 192 assert_search "fresh honey", ["Honey"], partial: true
140 end 193 end
141 194
  195 + def test_operator
  196 + store_names ["Honey"]
  197 + assert_search "fresh honey", []
  198 + assert_search "fresh honey", ["Honey"], operator: "or"
  199 + end
  200 +
142 def test_misspellings 201 def test_misspellings
143 store_names ["abc", "abd", "aee"] 202 store_names ["abc", "abd", "aee"]
144 assert_search "abc", ["abc"], misspellings: false 203 assert_search "abc", ["abc"], misspellings: false
test/test_helper.rb
@@ -56,9 +56,9 @@ else @@ -56,9 +56,9 @@ else
56 ActiveRecord::Base.time_zone_aware_attributes = true 56 ActiveRecord::Base.time_zone_aware_attributes = true
57 57
58 # migrations 58 # migrations
59 - ActiveRecord::Base.establish_connection :adapter => "postgresql", :database => "searchkick_test" 59 + ActiveRecord::Base.establish_connection adapter: "sqlite3", database: ":memory:"
60 60
61 - ActiveRecord::Migration.create_table :products, :force => true do |t| 61 + ActiveRecord::Migration.create_table :products do |t|
62 t.string :name 62 t.string :name
63 t.integer :store_id 63 t.integer :store_id
64 t.boolean :in_stock 64 t.boolean :in_stock
@@ -71,11 +71,11 @@ else @@ -71,11 +71,11 @@ else
71 t.timestamps 71 t.timestamps
72 end 72 end
73 73
74 - ActiveRecord::Migration.create_table :stores, :force => true do |t| 74 + ActiveRecord::Migration.create_table :stores do |t|
75 t.string :name 75 t.string :name
76 end 76 end
77 77
78 - ActiveRecord::Migration.create_table :animals, :force => true do |t| 78 + ActiveRecord::Migration.create_table :animals do |t|
79 t.string :name 79 t.string :name
80 t.string :type 80 t.string :type
81 end 81 end
@@ -112,12 +112,18 @@ class Product @@ -112,12 +112,18 @@ class Product
112 suggest: [:name, :color], 112 suggest: [:name, :color],
113 conversions: "conversions", 113 conversions: "conversions",
114 personalize: "user_ids", 114 personalize: "user_ids",
115 - locations: ["location", "multiple_locations"] 115 + locations: ["location", "multiple_locations"],
  116 + text_start: [:name],
  117 + text_middle: [:name],
  118 + text_end: [:name],
  119 + word_start: [:name],
  120 + word_middle: [:name],
  121 + word_end: [:name]
116 122
117 attr_accessor :conversions, :user_ids 123 attr_accessor :conversions, :user_ids
118 124
119 def search_data 125 def search_data
120 - serializable_hash.merge conversions: conversions, user_ids: user_ids, location: [latitude, longitude], multiple_locations: [[latitude, longitude], [0, 0]] 126 + serializable_hash.except("id").merge conversions: conversions, user_ids: user_ids, location: [latitude, longitude], multiple_locations: [[latitude, longitude], [0, 0]]
121 end 127 end
122 128
123 def should_index? 129 def should_index?
@@ -150,6 +156,7 @@ class Minitest::Unit::TestCase @@ -150,6 +156,7 @@ class Minitest::Unit::TestCase
150 156
151 def setup 157 def setup
152 Product.destroy_all 158 Product.destroy_all
  159 + Store.destroy_all
153 Animal.destroy_all 160 Animal.destroy_all
154 end 161 end
155 162