searchkick 5.0.2 → 5.3.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5ebb326348913a8532f1f4e7771bcdb57dda1abe4cc668e4f5bf8fe44bdbc85b
4
- data.tar.gz: 57b63c4444b9dbe26bb8decc4ab4dca81e4e037e6642d5728b96549aea0b2131
3
+ metadata.gz: 18ce029db59c59e668c7c5f4d1e05702d9631f307736251856658eb9b6a20618
4
+ data.tar.gz: c4f72347ed409d994bb44d16a48da881165ce825e0dc00192a7acba2b687318f
5
5
  SHA512:
6
- metadata.gz: f901dfae13328bb168e60cc72559ee2fd624f801456fee1628272c83ed402c4d00a83d3cac2ba978e6ce67c76853ca2dc5c1c0048b11ac34d74bb2b18bc3b974
7
- data.tar.gz: '086eb84edef491f27c254cb09ee5bce9ec07bcec46f79b2ca65404041e67bfccd877e30e92e6393ca2117c353ec71e8c632c50615c71e481e89c30c071934dd5'
6
+ metadata.gz: b17985dfd054b0fe5579ea1057ab9f052a7dbf54c091628ade76fb86616b7c938ea616f3d0367d486e63d66d037b86866543ae902350c24f9b63f25e079e3859
7
+ data.tar.gz: b576abbc0681e466638164b9b92c8edc5e91c771fd3c538d5b4f8c07597c939be882d4dc9b3cb89e8e43ae913804960c93fdfa2ab8eab1b1cee9566f86adcbd5
data/CHANGELOG.md CHANGED
@@ -1,3 +1,61 @@
1
+ ## 5.3.0 (2023-07-02)
2
+
3
+ - Fixed error with `cutoff_frequency`
4
+ - Dropped support for Ruby < 3 and Active Record < 6.1
5
+ - Dropped support for Mongoid < 7
6
+
7
+ ## 5.2.4 (2023-05-11)
8
+
9
+ - Fixed error with non-string routing and `:async` mode
10
+
11
+ ## 5.2.3 (2023-04-12)
12
+
13
+ - Fixed error with missing records and multiple models
14
+
15
+ ## 5.2.2 (2023-04-01)
16
+
17
+ - Fixed `total_docs` method
18
+ - Fixed deprecation warning with Active Support 7.1
19
+
20
+ ## 5.2.1 (2023-02-21)
21
+
22
+ - Added support for `redis-client` gem
23
+
24
+ ## 5.2.0 (2023-02-08)
25
+
26
+ - Added model name to warning about missing records
27
+ - Fixed unnecessary data loading when reindexing relations with `:async` and `:queue` modes
28
+
29
+ ## 5.1.2 (2023-01-29)
30
+
31
+ - Fixed error with missing point in time
32
+
33
+ ## 5.1.1 (2022-12-05)
34
+
35
+ - Added support for strings for `offset` and `per_page`
36
+
37
+ ## 5.1.0 (2022-10-12)
38
+
39
+ - Added support for fractional search timeout
40
+ - Fixed search timeout with `elasticsearch` 8+ and `opensearch-ruby` gems
41
+ - Fixed search timeout not applying to `multi_search`
42
+
43
+ ## 5.0.5 (2022-10-09)
44
+
45
+ - Added `model` method to `Searchkick::Relation`
46
+ - Fixed deprecation warning with `redis` gem
47
+ - Fixed `respond_to?` method on relation loading relation
48
+ - Fixed `Relation loaded` error for non-mutating methods on relation
49
+
50
+ ## 5.0.4 (2022-06-16)
51
+
52
+ - Added `max_result_window` option
53
+ - Improved error message for unsupported versions of Elasticsearch
54
+
55
+ ## 5.0.3 (2022-03-13)
56
+
57
+ - Fixed context for index name for inherited models
58
+
1
59
  ## 5.0.2 (2022-03-03)
2
60
 
3
61
  - Fixed index name for inherited models
data/LICENSE.txt CHANGED
@@ -1,4 +1,4 @@
1
- Copyright (c) 2013-2021 Andrew Kane
1
+ Copyright (c) 2013-2023 Andrew Kane
2
2
 
3
3
  MIT License
4
4
 
data/README.md CHANGED
@@ -43,15 +43,13 @@ Check out [Searchjoy](https://github.com/ankane/searchjoy) for analytics and [Au
43
43
  - [Reference](#reference)
44
44
  - [Contributing](#contributing)
45
45
 
46
- Searchkick 5.0 was recently released! See [how to upgrade](#upgrading)
47
-
48
46
  ## Getting Started
49
47
 
50
48
  Install [Elasticsearch](https://www.elastic.co/downloads/elasticsearch) or [OpenSearch](https://opensearch.org/downloads.html). For Homebrew, use:
51
49
 
52
50
  ```sh
53
- brew install elasticsearch
54
- brew services start elasticsearch
51
+ brew install elastic/tap/elasticsearch-full
52
+ brew services start elasticsearch-full
55
53
  # or
56
54
  brew install opensearch
57
55
  brew services start opensearch
@@ -66,7 +64,7 @@ gem "elasticsearch" # select one
66
64
  gem "opensearch-ruby" # select one
67
65
  ```
68
66
 
69
- The latest version works with Elasticsearch 7 and 8 and OpenSearch 1. For Elasticsearch 6, use version 4.6.3 and [this readme](https://github.com/ankane/searchkick/blob/v4.6.3/README.md).
67
+ The latest version works with Elasticsearch 7 and 8 and OpenSearch 1 and 2. For Elasticsearch 6, use version 4.6.3 and [this readme](https://github.com/ankane/searchkick/blob/v4.6.3/README.md).
70
68
 
71
69
  Add searchkick to models you want to search.
72
70
 
@@ -292,12 +290,18 @@ Option | Matches | Example
292
290
 
293
291
  The default is `:word`. The most matches will happen with `:word_middle`.
294
292
 
293
+ To specify different matching for different fields, use:
294
+
295
+ ```ruby
296
+ Product.search(query, fields: [{name: :word_start}, {brand: :word_middle}])
297
+ ```
298
+
295
299
  ### Exact Matches
296
300
 
297
301
  To match a field exactly (case-sensitive), use:
298
302
 
299
303
  ```ruby
300
- Product.search(query, fields: [{email: :exact}, :name])
304
+ Product.search(query, fields: [{name: :exact}])
301
305
  ```
302
306
 
303
307
  ### Phrase Matches
@@ -323,11 +327,11 @@ end
323
327
  See the [list of languages](https://www.elastic.co/guide/en/elasticsearch/reference/current/analysis-stemmer-tokenfilter.html#analysis-stemmer-tokenfilter-configure-parms). A few languages require plugins:
324
328
 
325
329
  - `chinese` - [analysis-ik plugin](https://github.com/medcl/elasticsearch-analysis-ik)
326
- - `chinese2` - [analysis-smartcn plugin](https://www.elastic.co/guide/en/elasticsearch/plugins/7.4/analysis-smartcn.html)
327
- - `japanese` - [analysis-kuromoji plugin](https://www.elastic.co/guide/en/elasticsearch/plugins/7.4/analysis-kuromoji.html)
330
+ - `chinese2` - [analysis-smartcn plugin](https://www.elastic.co/guide/en/elasticsearch/plugins/current/analysis-smartcn.html)
331
+ - `japanese` - [analysis-kuromoji plugin](https://www.elastic.co/guide/en/elasticsearch/plugins/current/analysis-kuromoji.html)
328
332
  - `korean` - [analysis-openkoreantext plugin](https://github.com/open-korean-text/elasticsearch-analysis-openkoreantext)
329
- - `korean2` - [analysis-nori plugin](https://www.elastic.co/guide/en/elasticsearch/plugins/7.4/analysis-nori.html)
330
- - `polish` - [analysis-stempel plugin](https://www.elastic.co/guide/en/elasticsearch/plugins/7.4/analysis-stempel.html)
333
+ - `korean2` - [analysis-nori plugin](https://www.elastic.co/guide/en/elasticsearch/plugins/current/analysis-nori.html)
334
+ - `polish` - [analysis-stempel plugin](https://www.elastic.co/guide/en/elasticsearch/plugins/current/analysis-stempel.html)
331
335
  - `ukrainian` - [analysis-ukrainian plugin](https://www.elastic.co/guide/en/elasticsearch/plugins/7.4/analysis-ukrainian.html)
332
336
  - `vietnamese` - [analysis-vietnamese plugin](https://github.com/duydo/elasticsearch-analysis-vietnamese)
333
337
 
@@ -592,6 +596,14 @@ There are four strategies for keeping the index synced with your database.
592
596
  end
593
597
  ```
594
598
 
599
+ And reindex a record or relation manually.
600
+
601
+ ```ruby
602
+ product.reindex
603
+ # or
604
+ store.products.reindex(mode: :async)
605
+ ```
606
+
595
607
  You can also do bulk updates.
596
608
 
597
609
  ```ruby
@@ -608,6 +620,12 @@ Searchkick.callbacks(false) do
608
620
  end
609
621
  ```
610
622
 
623
+ Or override the model’s strategy.
624
+
625
+ ```ruby
626
+ product.reindex(mode: :async) # :inline or :queue
627
+ ```
628
+
611
629
  ### Associations
612
630
 
613
631
  Data is **not** automatically synced when an association is updated. If this is desired, add a callback to reindex:
@@ -654,20 +672,16 @@ The best starting point to improve your search **by far** is to track searches a
654
672
  Product.search("apple", track: {user_id: current_user.id})
655
673
  ```
656
674
 
657
- [See the docs](https://github.com/ankane/searchjoy) for how to install and use.
658
-
659
- Focus on:
675
+ [See the docs](https://github.com/ankane/searchjoy) for how to install and use. Focus on top searches with a low conversion rate.
660
676
 
661
- - top searches with low conversions
662
- - top searches with no results
663
-
664
- Searchkick can then use the conversion data to learn what users are looking for. If a user searches for “ice cream” and adds Ben & Jerry’s Chunky Monkey to the cart (our conversion metric at Instacart), that item gets a little more weight for similar searches.
677
+ Searchkick can then use the conversion data to learn what users are looking for. If a user searches for “ice cream” and adds Ben & Jerry’s Chunky Monkey to the cart (our conversion metric at Instacart), that item gets a little more weight for similar searches. This can make a huge difference on the quality of your search.
665
678
 
666
679
  Add conversion data with:
667
680
 
668
681
  ```ruby
669
682
  class Product < ApplicationRecord
670
- has_many :searches, class_name: "Searchjoy::Search", as: :convertable
683
+ has_many :conversions, class_name: "Searchjoy::Conversion", as: :convertable
684
+ has_many :searches, class_name: "Searchjoy::Search", through: :conversions
671
685
 
672
686
  searchkick conversions: [:conversions] # name of field
673
687
 
@@ -681,15 +695,100 @@ class Product < ApplicationRecord
681
695
  end
682
696
  ```
683
697
 
684
- Reindex and set up a cron job to add new conversions daily.
698
+ Reindex and set up a cron job to add new conversions daily. For zero downtime deployment, temporarily set `conversions: false` in your search calls until the data is reindexed.
699
+
700
+ ### Performant Conversions
701
+
702
+ A performant way to do conversions is to cache them to prevent N+1 queries. For Postgres, create a migration with:
685
703
 
686
704
  ```ruby
687
- rake searchkick:reindex CLASS=Product
705
+ add_column :products, :search_conversions, :jsonb
688
706
  ```
689
707
 
690
- This can make a huge difference on the quality of your search.
708
+ For MySQL, use `:json`, and for others, use `:text` with a [JSON serializer](https://api.rubyonrails.org/classes/ActiveRecord/AttributeMethods/Serialization/ClassMethods.html).
709
+
710
+ Next, update your model. Create a separate method for conversion data so you can use [partial reindexing](#partial-reindexing).
711
+
712
+ ```ruby
713
+ class Product < ApplicationRecord
714
+ searchkick conversions: [:conversions]
691
715
 
692
- For a more performant way to reindex conversion data, check out [performant conversions](#performant-conversions).
716
+ def search_data
717
+ {
718
+ name: name,
719
+ category: category
720
+ }.merge(conversions_data)
721
+ end
722
+
723
+ def conversions_data
724
+ {
725
+ conversions: search_conversions || {}
726
+ }
727
+ end
728
+ end
729
+ ```
730
+
731
+ Deploy and reindex your data. For zero downtime deployment, temporarily set `conversions: false` in your search calls until the data is reindexed.
732
+
733
+ ```ruby
734
+ Product.reindex
735
+ ```
736
+
737
+ Then, create a job to update the conversions column and reindex records with new conversions. Here’s one you can use for Searchjoy:
738
+
739
+ ```ruby
740
+ class UpdateConversionsJob < ApplicationJob
741
+ def perform(class_name, since: nil, update: true, reindex: true)
742
+ model = Searchkick.load_model(class_name)
743
+
744
+ # get records that have a recent conversion
745
+ recently_converted_ids =
746
+ Searchjoy::Conversion.where(convertable_type: class_name).where(created_at: since..)
747
+ .order(:convertable_id).distinct.pluck(:convertable_id)
748
+
749
+ # split into batches
750
+ recently_converted_ids.in_groups_of(1000, false) do |ids|
751
+ if update
752
+ # fetch conversions
753
+ conversions =
754
+ Searchjoy::Conversion.where(convertable_id: ids, convertable_type: class_name)
755
+ .joins(:search).where.not(searchjoy_searches: {user_id: nil})
756
+ .group(:convertable_id, :query).distinct.count(:user_id)
757
+
758
+ # group by record
759
+ conversions_by_record = {}
760
+ conversions.each do |(id, query), count|
761
+ (conversions_by_record[id] ||= {})[query] = count
762
+ end
763
+
764
+ # update conversions column
765
+ model.transaction do
766
+ conversions_by_record.each do |id, conversions|
767
+ model.where(id: id).update_all(search_conversions: conversions)
768
+ end
769
+ end
770
+ end
771
+
772
+ if reindex
773
+ # reindex conversions data
774
+ model.where(id: ids).reindex(:conversions_data)
775
+ end
776
+ end
777
+ end
778
+ end
779
+ ```
780
+
781
+ Run the job:
782
+
783
+ ```ruby
784
+ UpdateConversionsJob.perform_now("Product")
785
+ ```
786
+
787
+ And set it up to run daily.
788
+
789
+ ```ruby
790
+ UpdateConversionsJob.perform_later("Product", since: 1.day.ago)
791
+ ```
693
792
 
694
793
  ## Personalized Results
695
794
 
@@ -1011,7 +1110,7 @@ Restaurant.search("soup", where: {bounds: {geo_shape: {type: "polygon", coordina
1011
1110
  Falling entirely within the query shape
1012
1111
 
1013
1112
  ```ruby
1014
- Restaurant.search("salad", where: {bounds: {geo_shape: {type: "circle", relation: "within", coordinates: [{lat: 38, lon: -123}], radius: "1km"}}})
1113
+ Restaurant.search("salad", where: {bounds: {geo_shape: {type: "circle", relation: "within", coordinates: {lat: 38, lon: -123}, radius: "1km"}}})
1015
1114
  ```
1016
1115
 
1017
1116
  Not touching the query shape
@@ -1575,11 +1674,12 @@ Reindex a subset of attributes to reduce time spent generating search data and c
1575
1674
  class Product < ApplicationRecord
1576
1675
  def search_data
1577
1676
  {
1578
- name: name
1579
- }.merge(search_prices)
1677
+ name: name,
1678
+ category: category
1679
+ }.merge(prices_data)
1580
1680
  end
1581
1681
 
1582
- def search_prices
1682
+ def prices_data
1583
1683
  {
1584
1684
  price: price,
1585
1685
  sale_price: sale_price
@@ -1591,68 +1691,7 @@ end
1591
1691
  And use:
1592
1692
 
1593
1693
  ```ruby
1594
- Product.reindex(:search_prices)
1595
- ```
1596
-
1597
- ### Performant Conversions
1598
-
1599
- Split out conversions into a separate method so you can use partial reindexing, and cache conversions to prevent N+1 queries. Be sure to use a centralized cache store like Memcached or Redis.
1600
-
1601
- ```ruby
1602
- class Product < ApplicationRecord
1603
- def search_data
1604
- {
1605
- name: name
1606
- }.merge(search_conversions)
1607
- end
1608
-
1609
- def search_conversions
1610
- {
1611
- conversions: Rails.cache.read("search_conversions:#{self.class.name}:#{id}") || {}
1612
- }
1613
- end
1614
- end
1615
- ```
1616
-
1617
- Create a job to update the cache and reindex records with new conversions.
1618
-
1619
- ```ruby
1620
- class ReindexConversionsJob < ApplicationJob
1621
- def perform(class_name)
1622
- # get records that have a recent conversion
1623
- recently_converted_ids =
1624
- Searchjoy::Search.where("convertable_type = ? AND converted_at > ?", class_name, 1.day.ago)
1625
- .order(:convertable_id).distinct.pluck(:convertable_id)
1626
-
1627
- # split into groups
1628
- recently_converted_ids.in_groups_of(1000, false) do |ids|
1629
- # fetch conversions
1630
- conversions =
1631
- Searchjoy::Search.where(convertable_id: ids, convertable_type: class_name)
1632
- .group(:convertable_id, :query).distinct.count(:user_id)
1633
-
1634
- # group conversions by record
1635
- conversions_by_record = {}
1636
- conversions.each do |(id, query), count|
1637
- (conversions_by_record[id] ||= {})[query] = count
1638
- end
1639
-
1640
- # write to cache
1641
- conversions_by_record.each do |id, conversions|
1642
- Rails.cache.write("search_conversions:#{class_name}:#{id}", conversions)
1643
- end
1644
-
1645
- # partial reindex
1646
- class_name.constantize.where(id: ids).reindex(:search_conversions)
1647
- end
1648
- end
1649
- end
1650
- ```
1651
-
1652
- Run the job with:
1653
-
1654
- ```ruby
1655
- ReindexConversionsJob.perform_later("Product")
1694
+ Product.reindex(:prices_data)
1656
1695
  ```
1657
1696
 
1658
1697
  ## Advanced
@@ -1798,6 +1837,10 @@ To query nested data, use dot notation.
1798
1837
  Product.search("san", fields: ["store.city"], where: {"store.zip_code" => 12345})
1799
1838
  ```
1800
1839
 
1840
+ ## Nearest Neighbors
1841
+
1842
+ You can use custom mapping and searching to index vectors and perform k-nearest neighbor search. See the examples for [Elasticsearch](examples/elasticsearch_knn.rb) and [OpenSearch](examples/opensearch_knn.rb).
1843
+
1801
1844
  ## Reference
1802
1845
 
1803
1846
  Reindex one record
@@ -2036,12 +2079,24 @@ Turn on misspellings after a certain number of characters
2036
2079
  Product.search("api", misspellings: {prefix_length: 2}) # api, apt, no ahi
2037
2080
  ```
2038
2081
 
2039
- **Note:** With this option, if the query length is the same as `prefix_length`, misspellings are turned off with Elasticsearch 7 and OpenSearch
2082
+ **Note:** With this option, if the query length is the same as `prefix_length`, misspellings are turned off with Elasticsearch 7 and OpenSearch 1
2040
2083
 
2041
2084
  ```ruby
2042
2085
  Product.search("ah", misspellings: {prefix_length: 2}) # ah, no aha
2043
2086
  ```
2044
2087
 
2088
+ BigDecimal values are indexed as floats by default so they can be used for boosting. Convert them to strings to keep full precision.
2089
+
2090
+ ```ruby
2091
+ class Product < ApplicationRecord
2092
+ def search_data
2093
+ {
2094
+ units: units.to_s("F")
2095
+ }
2096
+ end
2097
+ end
2098
+ ```
2099
+
2045
2100
  ## Gotchas
2046
2101
 
2047
2102
  ### Consistency
@@ -67,7 +67,8 @@ module Searchkick
67
67
  index: name,
68
68
  body: {
69
69
  query: {match_all: {}},
70
- size: 0
70
+ size: 0,
71
+ track_total_hits: true
71
72
  }
72
73
  )
73
74
 
@@ -418,7 +419,7 @@ module Searchkick
418
419
  true
419
420
  end
420
421
  rescue => e
421
- if Searchkick.transport_error?(e) && e.message.include?("No handler for type [text]")
422
+ if Searchkick.transport_error?(e) && (e.message.include?("No handler for type [text]") || e.message.include?("class java.util.ArrayList cannot be cast to class java.util.Map"))
422
423
  raise UnsupportedVersionError
423
424
  end
424
425
 
@@ -19,7 +19,7 @@ module Searchkick
19
19
  mappings = generate_mappings.deep_symbolize_keys.deep_merge(custom_mappings)
20
20
  end
21
21
 
22
- set_deep_paging(settings) if options[:deep_paging]
22
+ set_deep_paging(settings) if options[:deep_paging] || options[:max_result_window]
23
23
 
24
24
  {
25
25
  settings: settings,
@@ -525,7 +525,7 @@ module Searchkick
525
525
  def set_deep_paging(settings)
526
526
  if !settings.dig(:index, :max_result_window) && !settings[:"index.max_result_window"]
527
527
  settings[:index] ||= {}
528
- settings[:index][:max_result_window] = 1_000_000_000
528
+ settings[:index][:max_result_window] = options[:max_result_window] || 1_000_000_000
529
529
  end
530
530
  end
531
531
 
@@ -31,7 +31,7 @@ module Searchkick
31
31
  params << "#{CGI.escape(k.to_s)}=#{CGI.escape(v.to_s)}"
32
32
  end
33
33
 
34
- debug " #{color(name, YELLOW, true)} #{index}#{type ? "/#{type.join(',')}" : ''}/_search#{params.any? ? '?' + params.join('&') : nil} #{payload[:query][:body].to_json}"
34
+ debug " #{color(name, YELLOW, bold: true)} #{index}#{type ? "/#{type.join(',')}" : ''}/_search#{params.any? ? '?' + params.join('&') : nil} #{payload[:query][:body].to_json}"
35
35
  end
36
36
 
37
37
  def request(event)
@@ -41,7 +41,7 @@ module Searchkick
41
41
  payload = event.payload
42
42
  name = "#{payload[:name]} (#{event.duration.round(1)}ms)"
43
43
 
44
- debug " #{color(name, YELLOW, true)} #{payload.except(:name).to_json}"
44
+ debug " #{color(name, YELLOW, bold: true)} #{payload.except(:name).to_json}"
45
45
  end
46
46
 
47
47
  def multi_search(event)
@@ -51,7 +51,7 @@ module Searchkick
51
51
  payload = event.payload
52
52
  name = "#{payload[:name]} (#{event.duration.round(1)}ms)"
53
53
 
54
- debug " #{color(name, YELLOW, true)} _msearch #{payload[:body]}"
54
+ debug " #{color(name, YELLOW, bold: true)} _msearch #{payload[:body]}"
55
55
  end
56
56
  end
57
57
  end
@@ -3,8 +3,15 @@ require "faraday"
3
3
  module Searchkick
4
4
  class Middleware < Faraday::Middleware
5
5
  def call(env)
6
- if env[:method] == :get && env[:url].path.to_s.end_with?("/_search")
6
+ path = env[:url].path.to_s
7
+ if path.end_with?("/_search")
7
8
  env[:request][:timeout] = Searchkick.search_timeout
9
+ elsif path.end_with?("/_msearch")
10
+ # assume no concurrent searches for timeout for now
11
+ searches = env[:request_body].count("\n") / 2
12
+ # do not allow timeout to exceed Searchkick.timeout
13
+ timeout = [Searchkick.search_timeout * searches, Searchkick.timeout].min
14
+ env[:request][:timeout] = timeout
8
15
  end
9
16
  @app.call(env)
10
17
  end
@@ -5,7 +5,7 @@ module Searchkick
5
5
 
6
6
  unknown_keywords = options.keys - [:_all, :_type, :batch_size, :callbacks, :case_sensitive, :conversions, :deep_paging, :default_fields,
7
7
  :filterable, :geo_shape, :highlight, :ignore_above, :index_name, :index_prefix, :inheritance, :language,
8
- :locations, :mappings, :match, :merge_mappings, :routing, :searchable, :search_synonyms, :settings, :similarity,
8
+ :locations, :mappings, :match, :max_result_window, :merge_mappings, :routing, :searchable, :search_synonyms, :settings, :similarity,
9
9
  :special_characters, :stem, :stemmer, :stem_conversions, :stem_exclusion, :stemmer_override, :suggest, :synonyms, :text_end,
10
10
  :text_middle, :text_start, :unscope, :word, :word_end, :word_middle, :word_start]
11
11
  raise ArgumentError, "unknown keywords: #{unknown_keywords.join(", ")}" if unknown_keywords.any?
@@ -66,7 +66,7 @@ module Searchkick
66
66
  alias_method Searchkick.search_method_name, :searchkick_search if Searchkick.search_method_name
67
67
 
68
68
  def searchkick_index(name: nil)
69
- index_name = name || searchkick_index_name
69
+ index_name = name || searchkick_klass.searchkick_index_name
70
70
  index_name = index_name.call if index_name.respond_to?(:call)
71
71
  index_cache = class_variable_get(:@@searchkick_index_cache)
72
72
  index_cache.fetch(index_name) { Searchkick::Index.new(index_name, searchkick_options) }
@@ -88,9 +88,9 @@ module Searchkick
88
88
  if options[:index_name]
89
89
  options[:index_name]
90
90
  elsif options[:index_prefix].respond_to?(:call)
91
- -> { [options[:index_prefix].call, searchkick_klass.model_name.plural, Searchkick.env, Searchkick.index_suffix].compact.join("_") }
91
+ -> { [options[:index_prefix].call, model_name.plural, Searchkick.env, Searchkick.index_suffix].compact.join("_") }
92
92
  else
93
- [options.key?(:index_prefix) ? options[:index_prefix] : Searchkick.index_prefix, searchkick_klass.model_name.plural, Searchkick.env, Searchkick.index_suffix].compact.join("_")
93
+ [options.key?(:index_prefix) ? options[:index_prefix] : Searchkick.index_prefix, model_name.plural, Searchkick.env, Searchkick.index_suffix].compact.join("_")
94
94
  end
95
95
  end
96
96
  end
@@ -199,7 +199,11 @@ module Searchkick
199
199
  def handle_error(e)
200
200
  status_code = e.message[1..3].to_i
201
201
  if status_code == 404
202
- raise MissingIndexError, "Index missing - run #{reindex_command}"
202
+ if e.message.include?("No search context found for id")
203
+ raise MissingIndexError, "No search context found for id"
204
+ else
205
+ raise MissingIndexError, "Index missing - run #{reindex_command}"
206
+ end
203
207
  elsif status_code == 500 && (
204
208
  e.message.include?("IllegalArgumentException[minimumSimilarity >= 1]") ||
205
209
  e.message.include?("No query registered for [multi_match]") ||
@@ -251,9 +255,15 @@ module Searchkick
251
255
  default_limit = searchkick_options[:deep_paging] ? 1_000_000_000 : 10_000
252
256
  per_page = (options[:limit] || options[:per_page] || default_limit).to_i
253
257
  padding = [options[:padding].to_i, 0].max
254
- offset = options[:offset] || (page - 1) * per_page + padding
258
+ offset = (options[:offset] || (page - 1) * per_page + padding).to_i
255
259
  scroll = options[:scroll]
256
260
 
261
+ max_result_window = searchkick_options[:max_result_window]
262
+ if max_result_window
263
+ offset = max_result_window if offset > max_result_window
264
+ per_page = max_result_window - offset if offset + per_page > max_result_window
265
+ end
266
+
257
267
  # model and eager loading
258
268
  load = options[:load].nil? ? true : options[:load]
259
269
 
@@ -363,7 +373,7 @@ module Searchkick
363
373
  field_misspellings = misspellings && (!misspellings_fields || misspellings_fields.include?(base_field(field)))
364
374
 
365
375
  if field == "_all" || field.end_with?(".analyzed")
366
- shared_options[:cutoff_frequency] = 0.001 unless operator.to_s == "and" || field_misspellings == false || (!below73? && !track_total_hits?)
376
+ shared_options[:cutoff_frequency] = 0.001 unless operator.to_s == "and" || field_misspellings == false || (!below73? && !track_total_hits?) || match_type == :match_phrase || !below80? || Searchkick.opensearch?
367
377
  qs << shared_options.merge(analyzer: "searchkick_search")
368
378
 
369
379
  # searchkick_search and searchkick_search2 are the same for some languages
@@ -499,7 +509,7 @@ module Searchkick
499
509
  set_highlights(payload, fields) if options[:highlight]
500
510
 
501
511
  # timeout shortly after client times out
502
- payload[:timeout] ||= "#{Searchkick.search_timeout + 1}s"
512
+ payload[:timeout] ||= "#{((Searchkick.search_timeout + 1) * 1000).round}ms"
503
513
 
504
514
  # An empty array will cause only the _id and _type for each hit to be returned
505
515
  # https://www.elastic.co/guide/en/elasticsearch/reference/current/search-request-source-filtering.html
@@ -1160,5 +1170,9 @@ module Searchkick
1160
1170
  def below710?
1161
1171
  Searchkick.server_below?("7.10.0")
1162
1172
  end
1173
+
1174
+ def below80?
1175
+ Searchkick.server_below?("8.0.0")
1176
+ end
1163
1177
  end
1164
1178
  end
@@ -10,7 +10,7 @@ module Searchkick
10
10
 
11
11
  # supports single and multiple ids
12
12
  def push(record_ids)
13
- Searchkick.with_redis { |r| r.lpush(redis_key, record_ids) }
13
+ Searchkick.with_redis { |r| r.call("LPUSH", redis_key, record_ids) }
14
14
  end
15
15
 
16
16
  def push_records(records)
@@ -34,11 +34,11 @@ module Searchkick
34
34
  # TODO use reliable queuing
35
35
  def reserve(limit: 1000)
36
36
  if supports_rpop_with_count?
37
- Searchkick.with_redis { |r| r.call("rpop", redis_key, limit) }.to_a
37
+ Searchkick.with_redis { |r| r.call("RPOP", redis_key, limit) }.to_a
38
38
  else
39
39
  record_ids = []
40
40
  Searchkick.with_redis do |r|
41
- while record_ids.size < limit && (record_id = r.rpop(redis_key))
41
+ while record_ids.size < limit && (record_id = r.call("RPOP", redis_key))
42
42
  record_ids << record_id
43
43
  end
44
44
  end
@@ -47,11 +47,11 @@ module Searchkick
47
47
  end
48
48
 
49
49
  def clear
50
- Searchkick.with_redis { |r| r.del(redis_key) }
50
+ Searchkick.with_redis { |r| r.call("DEL", redis_key) }
51
51
  end
52
52
 
53
53
  def length
54
- Searchkick.with_redis { |r| r.llen(redis_key) }
54
+ Searchkick.with_redis { |r| r.call("LLEN", redis_key) }
55
55
  end
56
56
 
57
57
  private
@@ -65,11 +65,16 @@ module Searchkick
65
65
  end
66
66
 
67
67
  def redis_version
68
- @redis_version ||= Searchkick.with_redis { |r| Gem::Version.new(r.info["redis_version"]) }
68
+ @redis_version ||=
69
+ Searchkick.with_redis do |r|
70
+ info = r.call("INFO")
71
+ matches = /redis_version:(\S+)/.match(info)
72
+ Gem::Version.new(matches[1])
73
+ end
69
74
  end
70
75
 
71
76
  def escape(value)
72
- value.gsub("|", "||")
77
+ value.to_s.gsub("|", "||")
73
78
  end
74
79
  end
75
80
  end
@@ -8,6 +8,9 @@ module Searchkick
8
8
  delegate :body, :params, to: :query
9
9
  delegate_missing_to :private_execute
10
10
 
11
+ attr_reader :model
12
+ alias_method :klass, :model
13
+
11
14
  def initialize(model, term = "*", **options)
12
15
  @model = model
13
16
  @term = term
@@ -26,20 +29,22 @@ module Searchkick
26
29
 
27
30
  def execute
28
31
  Searchkick.warn("The execute method is no longer needed")
29
- private_execute
30
- self
32
+ load
31
33
  end
32
34
 
35
+ # experimental
33
36
  def limit(value)
34
37
  clone.limit!(value)
35
38
  end
36
39
 
40
+ # experimental
37
41
  def limit!(value)
38
42
  check_loaded
39
43
  @options[:limit] = value
40
44
  self
41
45
  end
42
46
 
47
+ # experimental
43
48
  def offset(value = NO_DEFAULT_VALUE)
44
49
  # TODO remove in Searchkick 6
45
50
  if value == NO_DEFAULT_VALUE
@@ -49,22 +54,26 @@ module Searchkick
49
54
  end
50
55
  end
51
56
 
57
+ # experimental
52
58
  def offset!(value)
53
59
  check_loaded
54
60
  @options[:offset] = value
55
61
  self
56
62
  end
57
63
 
64
+ # experimental
58
65
  def page(value)
59
66
  clone.page!(value)
60
67
  end
61
68
 
69
+ # experimental
62
70
  def page!(value)
63
71
  check_loaded
64
72
  @options[:page] = value
65
73
  self
66
74
  end
67
75
 
76
+ # experimental
68
77
  def per_page(value = NO_DEFAULT_VALUE)
69
78
  # TODO remove in Searchkick 6
70
79
  if value == NO_DEFAULT_VALUE
@@ -74,24 +83,139 @@ module Searchkick
74
83
  end
75
84
  end
76
85
 
86
+ # experimental
77
87
  def per_page!(value)
78
88
  check_loaded
79
89
  @options[:per_page] = value
80
90
  self
81
91
  end
82
92
 
93
+ # experimental
94
+ def where(value = NO_DEFAULT_VALUE)
95
+ if value == NO_DEFAULT_VALUE
96
+ Where.new(self)
97
+ else
98
+ clone.where!(value)
99
+ end
100
+ end
101
+
102
+ # experimental
103
+ def where!(value)
104
+ check_loaded
105
+ if @options[:where]
106
+ @options[:where] = {_and: [@options[:where], ensure_permitted(value)]}
107
+ else
108
+ @options[:where] = ensure_permitted(value)
109
+ end
110
+ self
111
+ end
112
+
113
+ # experimental
114
+ def rewhere(value)
115
+ clone.rewhere!(value)
116
+ end
117
+
118
+ # experimental
119
+ def rewhere!(value)
120
+ check_loaded
121
+ @options[:where] = ensure_permitted(value)
122
+ self
123
+ end
124
+
125
+ # experimental
126
+ def order(*values)
127
+ clone.order!(*values)
128
+ end
129
+
130
+ # experimental
131
+ def order!(*values)
132
+ values = values.first if values.size == 1 && values.first.is_a?(Array)
133
+ check_loaded
134
+ (@options[:order] ||= []).concat(values)
135
+ self
136
+ end
137
+
138
+ # experimental
139
+ def reorder(*values)
140
+ clone.reorder!(*values)
141
+ end
142
+
143
+ # experimental
144
+ def reorder!(*values)
145
+ check_loaded
146
+ @options[:order] = values
147
+ self
148
+ end
149
+
150
+ # experimental
151
+ def select(*values, &block)
152
+ if block_given?
153
+ private_execute.select(*values, &block)
154
+ else
155
+ clone.select!(*values)
156
+ end
157
+ end
158
+
159
+ # experimental
160
+ def select!(*values)
161
+ check_loaded
162
+ (@options[:select] ||= []).concat(values)
163
+ self
164
+ end
165
+
166
+ # experimental
167
+ def reselect(*values)
168
+ clone.reselect!(*values)
169
+ end
170
+
171
+ # experimental
172
+ def reselect!(*values)
173
+ check_loaded
174
+ @options[:select] = values
175
+ self
176
+ end
177
+
178
+ # experimental
179
+ def includes(*values)
180
+ clone.includes!(*values)
181
+ end
182
+
183
+ # experimental
184
+ def includes!(*values)
185
+ check_loaded
186
+ (@options[:includes] ||= []).concat(values)
187
+ self
188
+ end
189
+
190
+ # experimental
83
191
  def only(*keys)
84
192
  Relation.new(@model, @term, **@options.slice(*keys))
85
193
  end
86
194
 
195
+ # experimental
87
196
  def except(*keys)
88
197
  Relation.new(@model, @term, **@options.except(*keys))
89
198
  end
90
199
 
200
+ # experimental
201
+ def load
202
+ private_execute
203
+ self
204
+ end
205
+
91
206
  def loaded?
92
207
  !@execute.nil?
93
208
  end
94
209
 
210
+ def respond_to_missing?(method_name, include_all)
211
+ Results.new(nil, nil, nil).respond_to?(method_name, include_all) || super
212
+ end
213
+
214
+ # TODO uncomment in 6.0
215
+ # def to_yaml
216
+ # private_execute.to_a.to_yaml
217
+ # end
218
+
95
219
  private
96
220
 
97
221
  def private_execute
@@ -108,5 +232,16 @@ module Searchkick
108
232
  # reset query since options will change
109
233
  @query = nil
110
234
  end
235
+
236
+ # provides *very* basic protection from unfiltered parameters
237
+ # this is not meant to be comprehensive and may be expanded in the future
238
+ def ensure_permitted(obj)
239
+ obj.to_h
240
+ end
241
+
242
+ def initialize_copy(other)
243
+ super
244
+ @execute = nil
245
+ end
111
246
  end
112
247
  end
@@ -14,12 +14,17 @@ module Searchkick
14
14
  relation = relation.search_import
15
15
  end
16
16
 
17
- # remove unneeded loading for async
18
- if mode == :async
17
+ # remove unneeded loading for async and queue
18
+ if mode == :async || mode == :queue
19
19
  if relation.respond_to?(:primary_key)
20
- relation = relation.select(relation.primary_key).except(:includes, :preload)
20
+ relation = relation.except(:includes, :preload)
21
+ unless mode == :queue && relation.klass.method_defined?(:search_routing)
22
+ relation = relation.except(:select).select(relation.primary_key)
23
+ end
21
24
  elsif relation.respond_to?(:only)
22
- relation = relation.only(:_id)
25
+ unless mode == :queue && relation.klass.method_defined?(:search_routing)
26
+ relation = relation.only(:_id)
27
+ end
23
28
  end
24
29
  end
25
30
 
@@ -42,11 +47,11 @@ module Searchkick
42
47
  end
43
48
 
44
49
  def batches_left
45
- Searchkick.with_redis { |r| r.scard(batches_key) }
50
+ Searchkick.with_redis { |r| r.call("SCARD", batches_key) }
46
51
  end
47
52
 
48
53
  def batch_completed(batch_id)
49
- Searchkick.with_redis { |r| r.srem(batches_key, batch_id) }
54
+ Searchkick.with_redis { |r| r.call("SREM", batches_key, [batch_id]) }
50
55
  end
51
56
 
52
57
  private
@@ -134,7 +139,7 @@ module Searchkick
134
139
  end
135
140
 
136
141
  def batch_job(class_name, batch_id, record_ids)
137
- Searchkick.with_redis { |r| r.sadd(batches_key, batch_id) }
142
+ Searchkick.with_redis { |r| r.call("SADD", batches_key, [batch_id]) }
138
143
  Searchkick::BulkReindexJob.perform_later(
139
144
  class_name: class_name,
140
145
  index_name: index.name,
@@ -3,6 +3,7 @@ module Searchkick
3
3
  include Enumerable
4
4
  extend Forwardable
5
5
 
6
+ # TODO remove klass and options in 6.0
6
7
  attr_reader :klass, :response, :options
7
8
 
8
9
  def_delegators :results, :each, :any?, :empty?, :size, :length, :slice, :[], :to_ary
@@ -13,6 +14,7 @@ module Searchkick
13
14
  @options = options
14
15
  end
15
16
 
17
+ # TODO make private in 6.0
16
18
  def results
17
19
  @results ||= with_hit.map(&:first)
18
20
  end
@@ -302,7 +304,7 @@ module Searchkick
302
304
  def build_hits
303
305
  @build_hits ||= begin
304
306
  if missing_records.any?
305
- Searchkick.warn("Records in search index do not exist in database: #{missing_records.map { |v| v[:id] }.join(", ")}")
307
+ Searchkick.warn("Records in search index do not exist in database: #{missing_records.map { |v| "#{Array(v[:model]).map(&:model_name).sort.join("/")} #{v[:id]}" }.join(", ")}")
306
308
  end
307
309
  with_hit_and_missing_records[0]
308
310
  end
@@ -1,3 +1,3 @@
1
1
  module Searchkick
2
- VERSION = "5.0.2"
2
+ VERSION = "5.3.0"
3
3
  end
@@ -0,0 +1,11 @@
1
+ module Searchkick
2
+ class Where
3
+ def initialize(relation)
4
+ @relation = relation
5
+ end
6
+
7
+ def not(value)
8
+ @relation.where(_not: value)
9
+ end
10
+ end
11
+ end
data/lib/searchkick.rb CHANGED
@@ -10,26 +10,27 @@ require "hashie"
10
10
  require "forwardable"
11
11
 
12
12
  # modules
13
- require "searchkick/controller_runtime"
14
- require "searchkick/index"
15
- require "searchkick/index_cache"
16
- require "searchkick/index_options"
17
- require "searchkick/indexer"
18
- require "searchkick/hash_wrapper"
19
- require "searchkick/log_subscriber"
20
- require "searchkick/model"
21
- require "searchkick/multi_search"
22
- require "searchkick/query"
23
- require "searchkick/reindex_queue"
24
- require "searchkick/record_data"
25
- require "searchkick/record_indexer"
26
- require "searchkick/relation"
27
- require "searchkick/relation_indexer"
28
- require "searchkick/results"
29
- require "searchkick/version"
13
+ require_relative "searchkick/controller_runtime"
14
+ require_relative "searchkick/index"
15
+ require_relative "searchkick/index_cache"
16
+ require_relative "searchkick/index_options"
17
+ require_relative "searchkick/indexer"
18
+ require_relative "searchkick/hash_wrapper"
19
+ require_relative "searchkick/log_subscriber"
20
+ require_relative "searchkick/model"
21
+ require_relative "searchkick/multi_search"
22
+ require_relative "searchkick/query"
23
+ require_relative "searchkick/reindex_queue"
24
+ require_relative "searchkick/record_data"
25
+ require_relative "searchkick/record_indexer"
26
+ require_relative "searchkick/relation"
27
+ require_relative "searchkick/relation_indexer"
28
+ require_relative "searchkick/results"
29
+ require_relative "searchkick/version"
30
+ require_relative "searchkick/where"
30
31
 
31
32
  # integrations
32
- require "searchkick/railtie" if defined?(Rails)
33
+ require_relative "searchkick/railtie" if defined?(Rails)
33
34
 
34
35
  module Searchkick
35
36
  # requires faraday
@@ -134,8 +135,9 @@ module Searchkick
134
135
  @opensearch
135
136
  end
136
137
 
137
- def self.server_below?(version)
138
- server_version = opensearch? ? "7.10.2" : self.server_version
138
+ # TODO always check true version in Searchkick 6
139
+ def self.server_below?(version, true_version = false)
140
+ server_version = !true_version && opensearch? ? "7.10.2" : self.server_version
139
141
  Gem::Version.new(server_version.split("-")[0]) < Gem::Version.new(version.split("-")[0])
140
142
  end
141
143
 
@@ -283,7 +285,7 @@ module Searchkick
283
285
  relation
284
286
  end
285
287
 
286
- # private
288
+ # public (for reindexing conversions)
287
289
  def self.load_model(class_name, allow_child: false)
288
290
  model = class_name.safe_constantize
289
291
  raise Error, "Could not find class: #{class_name}" unless model
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: searchkick
3
3
  version: !ruby/object:Gem::Version
4
- version: 5.0.2
4
+ version: 5.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrew Kane
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2022-03-03 00:00:00.000000000 Z
11
+ date: 2023-07-02 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activemodel
@@ -16,14 +16,14 @@ dependencies:
16
16
  requirements:
17
17
  - - ">="
18
18
  - !ruby/object:Gem::Version
19
- version: '5.2'
19
+ version: '6.1'
20
20
  type: :runtime
21
21
  prerelease: false
22
22
  version_requirements: !ruby/object:Gem::Requirement
23
23
  requirements:
24
24
  - - ">="
25
25
  - !ruby/object:Gem::Version
26
- version: '5.2'
26
+ version: '6.1'
27
27
  - !ruby/object:Gem::Dependency
28
28
  name: hashie
29
29
  requirement: !ruby/object:Gem::Requirement
@@ -71,6 +71,7 @@ files:
71
71
  - lib/searchkick/relation_indexer.rb
72
72
  - lib/searchkick/results.rb
73
73
  - lib/searchkick/version.rb
74
+ - lib/searchkick/where.rb
74
75
  - lib/tasks/searchkick.rake
75
76
  homepage: https://github.com/ankane/searchkick
76
77
  licenses:
@@ -84,14 +85,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
84
85
  requirements:
85
86
  - - ">="
86
87
  - !ruby/object:Gem::Version
87
- version: '2.6'
88
+ version: '3'
88
89
  required_rubygems_version: !ruby/object:Gem::Requirement
89
90
  requirements:
90
91
  - - ">="
91
92
  - !ruby/object:Gem::Version
92
93
  version: '0'
93
94
  requirements: []
94
- rubygems_version: 3.3.3
95
+ rubygems_version: 3.4.10
95
96
  signing_key:
96
97
  specification_version: 4
97
98
  summary: Intelligent search made easy with Rails and Elasticsearch or OpenSearch