searchkick 5.0.2 → 5.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml 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