searchkick 5.0.1 → 5.0.4

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: ab19b7696cc775bda54eef31aaf9fea04a13a3027335440023f58f0c3b761fa0
4
- data.tar.gz: 461f230a62f440e84684e9eb42cc85d307b726830eba80999c5648aef13de92e
3
+ metadata.gz: 89c6a97d4c898be7f1f494cc4bfafc8aed5acc202a855588e81f73d86ea7b123
4
+ data.tar.gz: c362bb2916ec0b1fa83d72efd2314e747077b5cd696ed2ece089204be9452010
5
5
  SHA512:
6
- metadata.gz: 5382e2de584271fc9f80802f858329c613f3b9aff7877c9bb9653dab550deb6ed1ac6971cbb2bc306f4d6e67056f16ee98eea04d69f2fd9ff15fd899e37f8962
7
- data.tar.gz: dd8121e4991c9aaad5cd331cdc3adef3c703ee47abf8238ea3cc6feb1b1e5dcd85cb59c9f7ce91c086644d5946c5ccba936f48d0bc27988a59cca81adf161bb3
6
+ metadata.gz: bf1a9191ee97a19afde4c44b84c02b34b7f6b8b3ac464cdd34671dd60cb8a191ec338a028db8221c2a502fadf0059987f957c918221175631e8aa70344371ee7
7
+ data.tar.gz: 32e7b4b9f0899088e709a77389d8fa845ef68a3fd819d3823229eb1ffd99a6121647d5ab82d209e6254e4c242530d7698441d3b97f65a1e2436acbced6b6086f
data/CHANGELOG.md CHANGED
@@ -1,3 +1,16 @@
1
+ ## 5.0.4 (2022-06-16)
2
+
3
+ - Added `max_result_window` option
4
+ - Improved error message for unsupported versions of Elasticsearch
5
+
6
+ ## 5.0.3 (2022-03-13)
7
+
8
+ - Fixed context for index name for inherited models
9
+
10
+ ## 5.0.2 (2022-03-03)
11
+
12
+ - Fixed index name for inherited models
13
+
1
14
  ## 5.0.1 (2022-02-27)
2
15
 
3
16
  - Prefer `mode: :async` over `async: true` for full reindex
data/README.md CHANGED
@@ -50,8 +50,8 @@ Searchkick 5.0 was recently released! See [how to upgrade](#upgrading)
50
50
  Install [Elasticsearch](https://www.elastic.co/downloads/elasticsearch) or [OpenSearch](https://opensearch.org/downloads.html). For Homebrew, use:
51
51
 
52
52
  ```sh
53
- brew install elasticsearch
54
- brew services start elasticsearch
53
+ brew install elastic/tap/elasticsearch-full
54
+ brew services start elasticsearch-full
55
55
  # or
56
56
  brew install opensearch
57
57
  brew services start opensearch
@@ -66,7 +66,7 @@ gem "elasticsearch" # select one
66
66
  gem "opensearch-ruby" # select one
67
67
  ```
68
68
 
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).
69
+ 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
70
 
71
71
  Add searchkick to models you want to search.
72
72
 
@@ -98,7 +98,7 @@ Searchkick supports the complete [Elasticsearch Search API](https://www.elastic.
98
98
  Query like SQL
99
99
 
100
100
  ```ruby
101
- Product.search "apples", where: {in_stock: true}, limit: 10, offset: 50
101
+ Product.search("apples", where: {in_stock: true}, limit: 10, offset: 50)
102
102
  ```
103
103
 
104
104
  Search specific fields
@@ -226,7 +226,7 @@ You can also boost by:
226
226
  Use a `*` for the query.
227
227
 
228
228
  ```ruby
229
- Product.search "*"
229
+ Product.search("*")
230
230
  ```
231
231
 
232
232
  ### Pagination
@@ -235,7 +235,7 @@ Plays nicely with kaminari and will_paginate.
235
235
 
236
236
  ```ruby
237
237
  # controller
238
- @products = Product.search "milk", page: params[:page], per_page: 20
238
+ @products = Product.search("milk", page: params[:page], per_page: 20)
239
239
  ```
240
240
 
241
241
  View with kaminari
@@ -255,13 +255,13 @@ View with will_paginate
255
255
  By default, results must match all words in the query.
256
256
 
257
257
  ```ruby
258
- Product.search "fresh honey" # fresh AND honey
258
+ Product.search("fresh honey") # fresh AND honey
259
259
  ```
260
260
 
261
261
  To change this, use:
262
262
 
263
263
  ```ruby
264
- Product.search "fresh honey", operator: "or" # fresh OR honey
264
+ Product.search("fresh honey", operator: "or") # fresh OR honey
265
265
  ```
266
266
 
267
267
  By default, results must match the entire word - `back` will not match `backpack`. You can change this behavior with:
@@ -275,7 +275,7 @@ end
275
275
  And to search (after you reindex):
276
276
 
277
277
  ```ruby
278
- Product.search "back", fields: [:name], match: :word_start
278
+ Product.search("back", fields: [:name], match: :word_start)
279
279
  ```
280
280
 
281
281
  Available options are:
@@ -297,7 +297,7 @@ The default is `:word`. The most matches will happen with `:word_middle`.
297
297
  To match a field exactly (case-sensitive), use:
298
298
 
299
299
  ```ruby
300
- User.search query, fields: [{email: :exact}, :name]
300
+ Product.search(query, fields: [{email: :exact}, :name])
301
301
  ```
302
302
 
303
303
  ### Phrase Matches
@@ -305,7 +305,7 @@ User.search query, fields: [{email: :exact}, :name]
305
305
  To only match the exact order, use:
306
306
 
307
307
  ```ruby
308
- User.search "fresh honey", match: :phrase
308
+ Product.search("fresh honey", match: :phrase)
309
309
  ```
310
310
 
311
311
  ### Stemming and Language
@@ -426,7 +426,7 @@ end
426
426
  Search with:
427
427
 
428
428
  ```ruby
429
- Product.search query, fields: [:name_tagged]
429
+ Product.search(query, fields: [:name_tagged])
430
430
  ```
431
431
 
432
432
  ### Misspellings
@@ -436,13 +436,13 @@ By default, Searchkick handles misspelled queries by returning results with an [
436
436
  You can change this with:
437
437
 
438
438
  ```ruby
439
- Product.search "zucini", misspellings: {edit_distance: 2} # zucchini
439
+ Product.search("zucini", misspellings: {edit_distance: 2}) # zucchini
440
440
  ```
441
441
 
442
442
  To prevent poor precision and improve performance for correctly spelled queries (which should be a majority for most applications), Searchkick can first perform a search without misspellings, and if there are too few results, perform another with them.
443
443
 
444
444
  ```ruby
445
- Product.search "zuchini", misspellings: {below: 5}
445
+ Product.search("zuchini", misspellings: {below: 5})
446
446
  ```
447
447
 
448
448
  If there are fewer than 5 results, a 2nd search is performed with misspellings enabled. The result of this query is returned.
@@ -450,13 +450,13 @@ If there are fewer than 5 results, a 2nd search is performed with misspellings e
450
450
  Turn off misspellings with:
451
451
 
452
452
  ```ruby
453
- Product.search "zuchini", misspellings: false # no zucchini
453
+ Product.search("zuchini", misspellings: false) # no zucchini
454
454
  ```
455
455
 
456
456
  Specify which fields can include misspellings with:
457
457
 
458
458
  ```ruby
459
- Product.search "zucini", fields: [:name, :color], misspellings: {fields: [:name]}
459
+ Product.search("zucini", fields: [:name, :color], misspellings: {fields: [:name]})
460
460
  ```
461
461
 
462
462
  > When doing this, you must also specify fields to search
@@ -466,7 +466,7 @@ Product.search "zucini", fields: [:name, :color], misspellings: {fields: [:name]
466
466
  If a user searches `butter`, they may also get results for `peanut butter`. To prevent this, use:
467
467
 
468
468
  ```ruby
469
- Product.search "butter", exclude: ["peanut butter"]
469
+ Product.search("butter", exclude: ["peanut butter"])
470
470
  ```
471
471
 
472
472
  You can map queries and terms to exclude with:
@@ -477,7 +477,7 @@ exclude_queries = {
477
477
  "cream" => ["ice cream", "whipped cream"]
478
478
  }
479
479
 
480
- Product.search query, exclude: exclude_queries[query]
480
+ Product.search(query, exclude: exclude_queries[query])
481
481
  ```
482
482
 
483
483
  You can demote results by boosting by a factor less than one:
@@ -499,7 +499,7 @@ gem "gemoji-parser"
499
499
  And use:
500
500
 
501
501
  ```ruby
502
- Product.search "🍨🍰", emoji: true
502
+ Product.search("🍨🍰", emoji: true)
503
503
  ```
504
504
 
505
505
  ## Indexing
@@ -592,6 +592,14 @@ There are four strategies for keeping the index synced with your database.
592
592
  end
593
593
  ```
594
594
 
595
+ And reindex a record or relation manually.
596
+
597
+ ```ruby
598
+ product.reindex
599
+ # or
600
+ store.products.reindex(mode: :async)
601
+ ```
602
+
595
603
  You can also do bulk updates.
596
604
 
597
605
  ```ruby
@@ -608,6 +616,12 @@ Searchkick.callbacks(false) do
608
616
  end
609
617
  ```
610
618
 
619
+ Or override the model’s strategy.
620
+
621
+ ```ruby
622
+ product.reindex(mode: :async) # :inline or :queue
623
+ ```
624
+
611
625
  ### Associations
612
626
 
613
627
  Data is **not** automatically synced when an association is updated. If this is desired, add a callback to reindex:
@@ -651,23 +665,19 @@ end
651
665
  The best starting point to improve your search **by far** is to track searches and conversions. [Searchjoy](https://github.com/ankane/searchjoy) makes it easy.
652
666
 
653
667
  ```ruby
654
- Product.search "apple", track: {user_id: current_user.id}
668
+ Product.search("apple", track: {user_id: current_user.id})
655
669
  ```
656
670
 
657
- [See the docs](https://github.com/ankane/searchjoy) for how to install and use.
658
-
659
- Focus on:
660
-
661
- - top searches with low conversions
662
- - top searches with no results
671
+ [See the docs](https://github.com/ankane/searchjoy) for how to install and use. Focus on top searches with a low conversion rate.
663
672
 
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.
673
+ 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
674
 
666
675
  Add conversion data with:
667
676
 
668
677
  ```ruby
669
678
  class Product < ApplicationRecord
670
- has_many :searches, class_name: "Searchjoy::Search", as: :convertable
679
+ has_many :conversions, class_name: "Searchjoy::Conversion", as: :convertable
680
+ has_many :searches, class_name: "Searchjoy::Search", through: :conversions
671
681
 
672
682
  searchkick conversions: [:conversions] # name of field
673
683
 
@@ -681,15 +691,100 @@ class Product < ApplicationRecord
681
691
  end
682
692
  ```
683
693
 
684
- Reindex and set up a cron job to add new conversions daily.
694
+ 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.
695
+
696
+ ### Performant Conversions
697
+
698
+ A performant way to do conversions is to cache them to prevent N+1 queries. For Postgres, create a migration with:
699
+
700
+ ```ruby
701
+ add_column :products, :search_conversions, :jsonb
702
+ ```
703
+
704
+ For MySQL, use `:json`, and for others, use `:text` with a [JSON serializer](https://api.rubyonrails.org/classes/ActiveRecord/AttributeMethods/Serialization/ClassMethods.html).
705
+
706
+ Next, update your model. Create a separate method for conversion data so you can use [partial reindexing](#partial-reindexing).
707
+
708
+ ```ruby
709
+ class Product < ApplicationRecord
710
+ searchkick conversions: [:conversions]
711
+
712
+ def search_data
713
+ {
714
+ name: name,
715
+ category: category
716
+ }.merge(conversions_data)
717
+ end
718
+
719
+ def conversions_data
720
+ {
721
+ conversions: search_conversions || {}
722
+ }
723
+ end
724
+ end
725
+ ```
726
+
727
+ Deploy and reindex your data. For zero downtime deployment, temporarily set `conversions: false` in your search calls until the data is reindexed.
728
+
729
+ ```ruby
730
+ Product.reindex
731
+ ```
732
+
733
+ Then, create a job to update the conversions column and reindex records with new conversions. Here’s one you can use for Searchjoy:
734
+
735
+ ```ruby
736
+ class UpdateConversionsJob < ApplicationJob
737
+ def perform(class_name, since: nil, update: true, reindex: true)
738
+ model = Searchkick.load_model(class_name)
739
+
740
+ # get records that have a recent conversion
741
+ recently_converted_ids =
742
+ Searchjoy::Conversion.where(convertable_type: class_name).where(created_at: since..)
743
+ .order(:convertable_id).distinct.pluck(:convertable_id)
744
+
745
+ # split into batches
746
+ recently_converted_ids.in_groups_of(1000, false) do |ids|
747
+ if update
748
+ # fetch conversions
749
+ conversions =
750
+ Searchjoy::Conversion.where(convertable_id: ids, convertable_type: class_name)
751
+ .joins(:search).where.not(searchjoy_searches: {user_id: nil})
752
+ .group(:convertable_id, :query).distinct.count(:user_id)
753
+
754
+ # group by record
755
+ conversions_by_record = {}
756
+ conversions.each do |(id, query), count|
757
+ (conversions_by_record[id] ||= {})[query] = count
758
+ end
759
+
760
+ # update conversions column
761
+ model.transaction do
762
+ conversions_by_record.each do |id, conversions|
763
+ model.where(id: id).update_all(search_conversions: conversions)
764
+ end
765
+ end
766
+ end
767
+
768
+ if reindex
769
+ # reindex conversions data
770
+ model.where(id: ids).reindex(:conversions_data)
771
+ end
772
+ end
773
+ end
774
+ end
775
+ ```
776
+
777
+ Run the job:
685
778
 
686
779
  ```ruby
687
- rake searchkick:reindex CLASS=Product
780
+ UpdateConversionsJob.perform_now("Product")
688
781
  ```
689
782
 
690
- This can make a huge difference on the quality of your search.
783
+ And set it up to run daily.
691
784
 
692
- For a more performant way to reindex conversion data, check out [performant conversions](#performant-conversions).
785
+ ```ruby
786
+ UpdateConversionsJob.perform_later("Product", since: 1.day.ago)
787
+ ```
693
788
 
694
789
  ## Personalized Results
695
790
 
@@ -709,7 +804,7 @@ end
709
804
  Reindex and search with:
710
805
 
711
806
  ```ruby
712
- Product.search "milk", boost_where: {orderer_ids: current_user.id}
807
+ Product.search("milk", boost_where: {orderer_ids: current_user.id})
713
808
  ```
714
809
 
715
810
  ## Instant Search / Autocomplete
@@ -733,7 +828,7 @@ end
733
828
  Reindex and search with:
734
829
 
735
830
  ```ruby
736
- Movie.search "jurassic pa", fields: [:title], match: :word_start
831
+ Movie.search("jurassic pa", fields: [:title], match: :word_start)
737
832
  ```
738
833
 
739
834
  Typically, you want to use a JavaScript library like [typeahead.js](https://twitter.github.io/typeahead.js/) or [jQuery UI](https://jqueryui.com/autocomplete/).
@@ -793,7 +888,7 @@ end
793
888
  Reindex and search with:
794
889
 
795
890
  ```ruby
796
- products = Product.search "peantu butta", suggest: true
891
+ products = Product.search("peantu butta", suggest: true)
797
892
  products.suggestions # ["peanut butter"]
798
893
  ```
799
894
 
@@ -804,40 +899,40 @@ products.suggestions # ["peanut butter"]
804
899
  ![Aggregations](https://gist.github.com/ankane/b6988db2802aca68a589b31e41b44195/raw/40febe948427e5bc53ec4e5dc248822855fef76f/facets.png)
805
900
 
806
901
  ```ruby
807
- products = Product.search "chuck taylor", aggs: [:product_type, :gender, :brand]
902
+ products = Product.search("chuck taylor", aggs: [:product_type, :gender, :brand])
808
903
  products.aggs
809
904
  ```
810
905
 
811
906
  By default, `where` conditions apply to aggregations.
812
907
 
813
908
  ```ruby
814
- Product.search "wingtips", where: {color: "brandy"}, aggs: [:size]
909
+ Product.search("wingtips", where: {color: "brandy"}, aggs: [:size])
815
910
  # aggregations for brandy wingtips are returned
816
911
  ```
817
912
 
818
913
  Change this with:
819
914
 
820
915
  ```ruby
821
- Product.search "wingtips", where: {color: "brandy"}, aggs: [:size], smart_aggs: false
916
+ Product.search("wingtips", where: {color: "brandy"}, aggs: [:size], smart_aggs: false)
822
917
  # aggregations for all wingtips are returned
823
918
  ```
824
919
 
825
920
  Set `where` conditions for each aggregation separately with:
826
921
 
827
922
  ```ruby
828
- Product.search "wingtips", aggs: {size: {where: {color: "brandy"}}}
923
+ Product.search("wingtips", aggs: {size: {where: {color: "brandy"}}})
829
924
  ```
830
925
 
831
926
  Limit
832
927
 
833
928
  ```ruby
834
- Product.search "apples", aggs: {store_id: {limit: 10}}
929
+ Product.search("apples", aggs: {store_id: {limit: 10}})
835
930
  ```
836
931
 
837
932
  Order
838
933
 
839
934
  ```ruby
840
- Product.search "wingtips", aggs: {color: {order: {"_key" => "asc"}}} # alphabetically
935
+ Product.search("wingtips", aggs: {color: {order: {"_key" => "asc"}}}) # alphabetically
841
936
  ```
842
937
 
843
938
  [All of these options are supported](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-bucket-terms-aggregation.html#search-aggregations-bucket-terms-aggregation-order)
@@ -846,31 +941,31 @@ Ranges
846
941
 
847
942
  ```ruby
848
943
  price_ranges = [{to: 20}, {from: 20, to: 50}, {from: 50}]
849
- Product.search "*", aggs: {price: {ranges: price_ranges}}
944
+ Product.search("*", aggs: {price: {ranges: price_ranges}})
850
945
  ```
851
946
 
852
947
  Minimum document count
853
948
 
854
949
  ```ruby
855
- Product.search "apples", aggs: {store_id: {min_doc_count: 2}}
950
+ Product.search("apples", aggs: {store_id: {min_doc_count: 2}})
856
951
  ```
857
952
 
858
953
  Script support
859
954
 
860
955
  ```ruby
861
- Product.search "*", aggs: {color: {script: {source: "'Color: ' + _value"}}}
956
+ Product.search("*", aggs: {color: {script: {source: "'Color: ' + _value"}}})
862
957
  ```
863
958
 
864
959
  Date histogram
865
960
 
866
961
  ```ruby
867
- Product.search "pear", aggs: {products_per_year: {date_histogram: {field: :created_at, interval: :year}}}
962
+ Product.search("pear", aggs: {products_per_year: {date_histogram: {field: :created_at, interval: :year}}})
868
963
  ```
869
964
 
870
965
  For other aggregation types, including sub-aggregations, use `body_options`:
871
966
 
872
967
  ```ruby
873
- Product.search "orange", body_options: {aggs: {price: {histogram: {field: :price, interval: 10}}}}
968
+ Product.search("orange", body_options: {aggs: {price: {histogram: {field: :price, interval: 10}}}})
874
969
  ```
875
970
 
876
971
  ## Highlight
@@ -878,7 +973,7 @@ Product.search "orange", body_options: {aggs: {price: {histogram: {field: :price
878
973
  Specify which fields to index with highlighting.
879
974
 
880
975
  ```ruby
881
- class Product < ApplicationRecord
976
+ class Band < ApplicationRecord
882
977
  searchkick highlight: [:name]
883
978
  end
884
979
  ```
@@ -886,7 +981,7 @@ end
886
981
  Highlight the search query in the results.
887
982
 
888
983
  ```ruby
889
- bands = Band.search "cinema", highlight: true
984
+ bands = Band.search("cinema", highlight: true)
890
985
  ```
891
986
 
892
987
  View the highlighted fields with:
@@ -900,19 +995,19 @@ end
900
995
  To change the tag, use:
901
996
 
902
997
  ```ruby
903
- Band.search "cinema", highlight: {tag: "<strong>"}
998
+ Band.search("cinema", highlight: {tag: "<strong>"})
904
999
  ```
905
1000
 
906
1001
  To highlight and search different fields, use:
907
1002
 
908
1003
  ```ruby
909
- Band.search "cinema", fields: [:name], highlight: {fields: [:description]}
1004
+ Band.search("cinema", fields: [:name], highlight: {fields: [:description]})
910
1005
  ```
911
1006
 
912
1007
  By default, the entire field is highlighted. To get small snippets instead, use:
913
1008
 
914
1009
  ```ruby
915
- bands = Band.search "cinema", highlight: {fragment_size: 20}
1010
+ bands = Band.search("cinema", highlight: {fragment_size: 20})
916
1011
  bands.with_highlights(multiple: true).each do |band, highlights|
917
1012
  highlights[:name].join(" and ")
918
1013
  end
@@ -921,7 +1016,7 @@ end
921
1016
  Additional options can be specified for each field:
922
1017
 
923
1018
  ```ruby
924
- Band.search "cinema", fields: [:name], highlight: {fields: {name: {fragment_size: 200}}}
1019
+ Band.search("cinema", fields: [:name], highlight: {fields: {name: {fragment_size: 200}}})
925
1020
  ```
926
1021
 
927
1022
  You can find available highlight options in the [Elasticsearch reference](https://www.elastic.co/guide/en/elasticsearch/reference/current/highlighting.html).
@@ -950,13 +1045,13 @@ end
950
1045
  Reindex and search with:
951
1046
 
952
1047
  ```ruby
953
- Restaurant.search "pizza", where: {location: {near: {lat: 37, lon: -114}, within: "100mi"}} # or 160km
1048
+ Restaurant.search("pizza", where: {location: {near: {lat: 37, lon: -114}, within: "100mi"}}) # or 160km
954
1049
  ```
955
1050
 
956
1051
  Bounded by a box
957
1052
 
958
1053
  ```ruby
959
- Restaurant.search "sushi", where: {location: {top_left: {lat: 38, lon: -123}, bottom_right: {lat: 37, lon: -122}}}
1054
+ Restaurant.search("sushi", where: {location: {top_left: {lat: 38, lon: -123}, bottom_right: {lat: 37, lon: -122}}})
960
1055
  ```
961
1056
 
962
1057
  **Note:** `top_right` and `bottom_left` also work
@@ -964,7 +1059,7 @@ Restaurant.search "sushi", where: {location: {top_left: {lat: 38, lon: -123}, bo
964
1059
  Bounded by a polygon
965
1060
 
966
1061
  ```ruby
967
- Restaurant.search "dessert", where: {location: {geo_polygon: {points: [{lat: 38, lon: -123}, {lat: 39, lon: -123}, {lat: 37, lon: 122}]}}}
1062
+ Restaurant.search("dessert", where: {location: {geo_polygon: {points: [{lat: 38, lon: -123}, {lat: 39, lon: -123}, {lat: 37, lon: 122}]}}})
968
1063
  ```
969
1064
 
970
1065
  ### Boost By Distance
@@ -972,13 +1067,13 @@ Restaurant.search "dessert", where: {location: {geo_polygon: {points: [{lat: 38,
972
1067
  Boost results by distance - closer results are boosted more
973
1068
 
974
1069
  ```ruby
975
- Restaurant.search "noodles", boost_by_distance: {location: {origin: {lat: 37, lon: -122}}}
1070
+ Restaurant.search("noodles", boost_by_distance: {location: {origin: {lat: 37, lon: -122}}})
976
1071
  ```
977
1072
 
978
1073
  Also supports [additional options](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-function-score-query.html#function-decay)
979
1074
 
980
1075
  ```ruby
981
- Restaurant.search "wings", boost_by_distance: {location: {origin: {lat: 37, lon: -122}, function: "linear", scale: "30mi", decay: 0.5}}
1076
+ Restaurant.search("wings", boost_by_distance: {location: {origin: {lat: 37, lon: -122}, function: "linear", scale: "30mi", decay: 0.5}})
982
1077
  ```
983
1078
 
984
1079
  ### Geo Shapes
@@ -1005,19 +1100,19 @@ See the [Elasticsearch documentation](https://www.elastic.co/guide/en/elasticsea
1005
1100
  Find shapes intersecting with the query shape
1006
1101
 
1007
1102
  ```ruby
1008
- Restaurant.search "soup", where: {bounds: {geo_shape: {type: "polygon", coordinates: [[{lat: 38, lon: -123}, ...]]}}}
1103
+ Restaurant.search("soup", where: {bounds: {geo_shape: {type: "polygon", coordinates: [[{lat: 38, lon: -123}, ...]]}}})
1009
1104
  ```
1010
1105
 
1011
1106
  Falling entirely within the query shape
1012
1107
 
1013
1108
  ```ruby
1014
- Restaurant.search "salad", where: {bounds: {geo_shape: {type: "circle", relation: "within", coordinates: [{lat: 38, lon: -123}], radius: "1km"}}}
1109
+ Restaurant.search("salad", where: {bounds: {geo_shape: {type: "circle", relation: "within", coordinates: [{lat: 38, lon: -123}], radius: "1km"}}})
1015
1110
  ```
1016
1111
 
1017
1112
  Not touching the query shape
1018
1113
 
1019
1114
  ```ruby
1020
- Restaurant.search "burger", where: {bounds: {geo_shape: {type: "envelope", relation: "disjoint", coordinates: [{lat: 38, lon: -123}, {lat: 37, lon: -122}]}}}
1115
+ Restaurant.search("burger", where: {bounds: {geo_shape: {type: "envelope", relation: "disjoint", coordinates: [{lat: 38, lon: -123}, {lat: 37, lon: -122}]}}})
1021
1116
  ```
1022
1117
 
1023
1118
  ## Inheritance
@@ -1047,9 +1142,9 @@ Dog.reindex # equivalent, all animals reindexed
1047
1142
  And to search, use:
1048
1143
 
1049
1144
  ```ruby
1050
- Animal.search "*" # all animals
1051
- Dog.search "*" # just dogs
1052
- Animal.search "*", type: [Dog, Cat] # just cats and dogs
1145
+ Animal.search("*") # all animals
1146
+ Dog.search("*") # just dogs
1147
+ Animal.search("*", type: [Dog, Cat]) # just cats and dogs
1053
1148
  ```
1054
1149
 
1055
1150
  **Notes:**
@@ -1057,7 +1152,7 @@ Animal.search "*", type: [Dog, Cat] # just cats and dogs
1057
1152
  1. The `suggest` option retrieves suggestions from the parent at the moment.
1058
1153
 
1059
1154
  ```ruby
1060
- Dog.search "airbudd", suggest: true # suggestions for all animals
1155
+ Dog.search("airbudd", suggest: true) # suggestions for all animals
1061
1156
  ```
1062
1157
  2. This relies on a `type` field that is automatically added to the indexed document. Be wary of defining your own `type` field in `search_data`, as it will take precedence.
1063
1158
 
@@ -1380,6 +1475,8 @@ Create an initializer with multiple hosts:
1380
1475
 
1381
1476
  ```ruby
1382
1477
  ENV["ELASTICSEARCH_URL"] = "https://user:password@host1,https://user:password@host2"
1478
+ # or
1479
+ ENV["OPENSEARCH_URL"] = "https://user:password@host1,https://user:password@host2"
1383
1480
  ```
1384
1481
 
1385
1482
  See [elastic-transport](https://github.com/elastic/elastic-transport-ruby) or [opensearch-transport](https://github.com/opensearch-project/opensearch-ruby/tree/main/opensearch-transport) for a complete list of options.
@@ -1562,7 +1659,7 @@ end
1562
1659
  Reindex and search with:
1563
1660
 
1564
1661
  ```ruby
1565
- Business.search "ice cream", routing: params[:city_id]
1662
+ Business.search("ice cream", routing: params[:city_id])
1566
1663
  ```
1567
1664
 
1568
1665
  ### Partial Reindexing
@@ -1573,11 +1670,12 @@ Reindex a subset of attributes to reduce time spent generating search data and c
1573
1670
  class Product < ApplicationRecord
1574
1671
  def search_data
1575
1672
  {
1576
- name: name
1577
- }.merge(search_prices)
1673
+ name: name,
1674
+ category: category
1675
+ }.merge(prices_data)
1578
1676
  end
1579
1677
 
1580
- def search_prices
1678
+ def prices_data
1581
1679
  {
1582
1680
  price: price,
1583
1681
  sale_price: sale_price
@@ -1589,68 +1687,7 @@ end
1589
1687
  And use:
1590
1688
 
1591
1689
  ```ruby
1592
- Product.reindex(:search_prices)
1593
- ```
1594
-
1595
- ### Performant Conversions
1596
-
1597
- 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.
1598
-
1599
- ```ruby
1600
- class Product < ApplicationRecord
1601
- def search_data
1602
- {
1603
- name: name
1604
- }.merge(search_conversions)
1605
- end
1606
-
1607
- def search_conversions
1608
- {
1609
- conversions: Rails.cache.read("search_conversions:#{self.class.name}:#{id}") || {}
1610
- }
1611
- end
1612
- end
1613
- ```
1614
-
1615
- Create a job to update the cache and reindex records with new conversions.
1616
-
1617
- ```ruby
1618
- class ReindexConversionsJob < ApplicationJob
1619
- def perform(class_name)
1620
- # get records that have a recent conversion
1621
- recently_converted_ids =
1622
- Searchjoy::Search.where("convertable_type = ? AND converted_at > ?", class_name, 1.day.ago)
1623
- .order(:convertable_id).distinct.pluck(:convertable_id)
1624
-
1625
- # split into groups
1626
- recently_converted_ids.in_groups_of(1000, false) do |ids|
1627
- # fetch conversions
1628
- conversions =
1629
- Searchjoy::Search.where(convertable_id: ids, convertable_type: class_name)
1630
- .group(:convertable_id, :query).distinct.count(:user_id)
1631
-
1632
- # group conversions by record
1633
- conversions_by_record = {}
1634
- conversions.each do |(id, query), count|
1635
- (conversions_by_record[id] ||= {})[query] = count
1636
- end
1637
-
1638
- # write to cache
1639
- conversions_by_record.each do |id, conversions|
1640
- Rails.cache.write("search_conversions:#{class_name}:#{id}", conversions)
1641
- end
1642
-
1643
- # partial reindex
1644
- class_name.constantize.where(id: ids).reindex(:search_conversions)
1645
- end
1646
- end
1647
- end
1648
- ```
1649
-
1650
- Run the job with:
1651
-
1652
- ```ruby
1653
- ReindexConversionsJob.perform_later("Product")
1690
+ Product.reindex(:prices_data)
1654
1691
  ```
1655
1692
 
1656
1693
  ## Advanced
@@ -1685,7 +1722,7 @@ end
1685
1722
  And use the `body` option to search:
1686
1723
 
1687
1724
  ```ruby
1688
- products = Product.search body: {query: {match: {name: "milk"}}}
1725
+ products = Product.search(body: {query: {match: {name: "milk"}}})
1689
1726
  ```
1690
1727
 
1691
1728
  View the response with:
@@ -1697,14 +1734,14 @@ products.response
1697
1734
  To modify the query generated by Searchkick, use:
1698
1735
 
1699
1736
  ```ruby
1700
- products = Product.search "milk", body_options: {min_score: 1}
1737
+ products = Product.search("milk", body_options: {min_score: 1})
1701
1738
  ```
1702
1739
 
1703
1740
  or
1704
1741
 
1705
1742
  ```ruby
1706
1743
  products =
1707
- Product.search "apples" do |body|
1744
+ Product.search("apples") do |body|
1708
1745
  body[:min_score] = 1
1709
1746
  end
1710
1747
  ```
@@ -1736,7 +1773,7 @@ Then use `products` and `coupons` as typical results.
1736
1773
  Search across multiple models with:
1737
1774
 
1738
1775
  ```ruby
1739
- Searchkick.search "milk", models: [Product, Category]
1776
+ Searchkick.search("milk", models: [Product, Category])
1740
1777
  ```
1741
1778
 
1742
1779
  Boost specific models with:
@@ -1762,7 +1799,7 @@ end
1762
1799
  You can also scroll batches manually.
1763
1800
 
1764
1801
  ```ruby
1765
- products = Product.search "*", scroll: "1m"
1802
+ products = Product.search("*", scroll: "1m")
1766
1803
  while products.any?
1767
1804
  # process batch ...
1768
1805
 
@@ -1793,7 +1830,7 @@ Product.search("pears", body_options: {track_total_hits: true})
1793
1830
  To query nested data, use dot notation.
1794
1831
 
1795
1832
  ```ruby
1796
- User.search "san", fields: ["address.city"], where: {"address.zip_code" => 12345}
1833
+ Product.search("san", fields: ["store.city"], where: {"store.zip_code" => 12345})
1797
1834
  ```
1798
1835
 
1799
1836
  ## Reference
@@ -1923,7 +1960,7 @@ Searchkick.queue_name = :search_reindex
1923
1960
  Eager load associations
1924
1961
 
1925
1962
  ```ruby
1926
- Product.search "milk", includes: [:brand, :stores]
1963
+ Product.search("milk", includes: [:brand, :stores])
1927
1964
  ```
1928
1965
 
1929
1966
  Eager load different associations by model
@@ -1935,7 +1972,7 @@ Searchkick.search("*", models: [Product, Store], model_includes: {Product => [:
1935
1972
  Run additional scopes on results
1936
1973
 
1937
1974
  ```ruby
1938
- Product.search "milk", scope_results: ->(r) { r.with_attached_images }
1975
+ Product.search("milk", scope_results: ->(r) { r.with_attached_images })
1939
1976
  ```
1940
1977
 
1941
1978
  Specify default fields to search
@@ -2031,13 +2068,25 @@ rake searchkick:reindex:all
2031
2068
  Turn on misspellings after a certain number of characters
2032
2069
 
2033
2070
  ```ruby
2034
- Product.search "api", misspellings: {prefix_length: 2} # api, apt, no ahi
2071
+ Product.search("api", misspellings: {prefix_length: 2}) # api, apt, no ahi
2072
+ ```
2073
+
2074
+ **Note:** With this option, if the query length is the same as `prefix_length`, misspellings are turned off with Elasticsearch 7 and OpenSearch 1
2075
+
2076
+ ```ruby
2077
+ Product.search("ah", misspellings: {prefix_length: 2}) # ah, no aha
2035
2078
  ```
2036
2079
 
2037
- **Note:** With this option, if the query length is the same as `prefix_length`, misspellings are turned off with Elasticsearch 7 and OpenSearch
2080
+ BigDecimal values are indexed as floats by default so they can be used for boosting. Convert them to strings to keep full precision.
2038
2081
 
2039
2082
  ```ruby
2040
- Product.search "ah", misspellings: {prefix_length: 2} # ah, no aha
2083
+ class Product < ApplicationRecord
2084
+ def search_data
2085
+ {
2086
+ units: units.to_s("F")
2087
+ }
2088
+ end
2089
+ end
2041
2090
  ```
2042
2091
 
2043
2092
  ## Gotchas
@@ -418,7 +418,7 @@ module Searchkick
418
418
  true
419
419
  end
420
420
  rescue => e
421
- if Searchkick.transport_error?(e) && e.message.include?("No handler for type [text]")
421
+ 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
422
  raise UnsupportedVersionError
423
423
  end
424
424
 
@@ -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
 
@@ -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) }
@@ -254,6 +254,12 @@ module Searchkick
254
254
  offset = options[:offset] || (page - 1) * per_page + padding
255
255
  scroll = options[:scroll]
256
256
 
257
+ max_result_window = searchkick_options[:max_result_window]
258
+ if max_result_window
259
+ offset = max_result_window if offset > max_result_window
260
+ per_page = max_result_window - offset if offset + per_page > max_result_window
261
+ end
262
+
257
263
  # model and eager loading
258
264
  load = options[:load].nil? ? true : options[:load]
259
265
 
@@ -1,13 +1,20 @@
1
1
  module Searchkick
2
2
  class Relation
3
+ NO_DEFAULT_VALUE = Object.new
4
+
3
5
  # note: modifying body directly is not supported
4
6
  # and has no impact on query after being executed
5
7
  # TODO freeze body object?
6
- delegate :body, :params, to: :@query
8
+ delegate :body, :params, to: :query
7
9
  delegate_missing_to :private_execute
8
10
 
9
11
  def initialize(model, term = "*", **options)
10
- @query = Query.new(model, term, **options)
12
+ @model = model
13
+ @term = term
14
+ @options = options
15
+
16
+ # generate query to validate options
17
+ query
11
18
  end
12
19
 
13
20
  # same as Active Record
@@ -23,14 +30,196 @@ module Searchkick
23
30
  self
24
31
  end
25
32
 
33
+ # experimental
34
+ def limit(value)
35
+ clone.limit!(value)
36
+ end
37
+
38
+ # experimental
39
+ def limit!(value)
40
+ check_loaded
41
+ @options[:limit] = value
42
+ self
43
+ end
44
+
45
+ # experimental
46
+ def offset(value = NO_DEFAULT_VALUE)
47
+ # TODO remove in Searchkick 6
48
+ if value == NO_DEFAULT_VALUE
49
+ private_execute.offset
50
+ else
51
+ clone.offset!(value)
52
+ end
53
+ end
54
+
55
+ # experimental
56
+ def offset!(value)
57
+ check_loaded
58
+ @options[:offset] = value
59
+ self
60
+ end
61
+
62
+ # experimental
63
+ def page(value)
64
+ clone.page!(value)
65
+ end
66
+
67
+ # experimental
68
+ def page!(value)
69
+ check_loaded
70
+ @options[:page] = value
71
+ self
72
+ end
73
+
74
+ # experimental
75
+ def per_page(value = NO_DEFAULT_VALUE)
76
+ # TODO remove in Searchkick 6
77
+ if value == NO_DEFAULT_VALUE
78
+ private_execute.per_page
79
+ else
80
+ clone.per_page!(value)
81
+ end
82
+ end
83
+
84
+ # experimental
85
+ def per_page!(value)
86
+ check_loaded
87
+ @options[:per_page] = value
88
+ self
89
+ end
90
+
91
+ # experimental
92
+ def where(value = NO_DEFAULT_VALUE)
93
+ if value == NO_DEFAULT_VALUE
94
+ Where.new(self)
95
+ else
96
+ clone.where!(value)
97
+ end
98
+ end
99
+
100
+ # experimental
101
+ def where!(value)
102
+ check_loaded
103
+ if @options[:where]
104
+ @options[:where] = {_and: [@options[:where], ensure_permitted(value)]}
105
+ else
106
+ @options[:where] = ensure_permitted(value)
107
+ end
108
+ self
109
+ end
110
+
111
+ # experimental
112
+ def rewhere(value)
113
+ clone.rewhere!(value)
114
+ end
115
+
116
+ # experimental
117
+ def rewhere!(value)
118
+ check_loaded
119
+ @options[:where] = ensure_permitted(value)
120
+ self
121
+ end
122
+
123
+ # experimental
124
+ def order(*values)
125
+ clone.order!(*values)
126
+ end
127
+
128
+ # experimental
129
+ def order!(*values)
130
+ values = values.first if values.size == 1 && values.first.is_a?(Array)
131
+ check_loaded
132
+ (@options[:order] ||= []).concat(values)
133
+ self
134
+ end
135
+
136
+ # experimental
137
+ def reorder(*values)
138
+ clone.reorder!(*values)
139
+ end
140
+
141
+ # experimental
142
+ def reorder!(*values)
143
+ check_loaded
144
+ @options[:order] = values
145
+ self
146
+ end
147
+
148
+ # experimental
149
+ def select(*values, &block)
150
+ if block_given?
151
+ private_execute.select(*values, &block)
152
+ else
153
+ clone.select!(*values)
154
+ end
155
+ end
156
+
157
+ # experimental
158
+ def select!(*values)
159
+ check_loaded
160
+ (@options[:select] ||= []).concat(values)
161
+ self
162
+ end
163
+
164
+ # experimental
165
+ def reselect(*values)
166
+ clone.reselect!(*values)
167
+ end
168
+
169
+ # experimental
170
+ def reselect!(*values)
171
+ check_loaded
172
+ @options[:select] = values
173
+ self
174
+ end
175
+
176
+ # experimental
177
+ def includes(*values)
178
+ clone.includes!(*values)
179
+ end
180
+
181
+ # experimental
182
+ def includes!(*values)
183
+ check_loaded
184
+ (@options[:includes] ||= []).concat(values)
185
+ self
186
+ end
187
+
188
+ # experimental
189
+ def only(*keys)
190
+ Relation.new(@model, @term, **@options.slice(*keys))
191
+ end
192
+
193
+ # experimental
194
+ def except(*keys)
195
+ Relation.new(@model, @term, **@options.except(*keys))
196
+ end
197
+
198
+ def loaded?
199
+ !@execute.nil?
200
+ end
201
+
26
202
  private
27
203
 
28
204
  def private_execute
29
- @execute ||= @query.execute
205
+ @execute ||= query.execute
30
206
  end
31
207
 
32
208
  def query
33
- @query
209
+ @query ||= Query.new(@model, @term, **@options)
210
+ end
211
+
212
+ def check_loaded
213
+ raise Error, "Relation loaded" if loaded?
214
+
215
+ # reset query since options will change
216
+ @query = nil
217
+ end
218
+
219
+ # provides *very* basic protection from unfiltered parameters
220
+ # this is not meant to be comprehensive and may be expanded in the future
221
+ def ensure_permitted(obj)
222
+ obj.to_h
34
223
  end
35
224
  end
36
225
  end
@@ -1,3 +1,3 @@
1
1
  module Searchkick
2
- VERSION = "5.0.1"
2
+ VERSION = "5.0.4"
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
@@ -27,6 +27,7 @@ require "searchkick/relation"
27
27
  require "searchkick/relation_indexer"
28
28
  require "searchkick/results"
29
29
  require "searchkick/version"
30
+ require "searchkick/where"
30
31
 
31
32
  # integrations
32
33
  require "searchkick/railtie" if defined?(Rails)
@@ -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.1
4
+ version: 5.0.4
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-02-27 00:00:00.000000000 Z
11
+ date: 2022-06-17 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activemodel
@@ -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:
@@ -91,7 +92,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
91
92
  - !ruby/object:Gem::Version
92
93
  version: '0'
93
94
  requirements: []
94
- rubygems_version: 3.3.3
95
+ rubygems_version: 3.3.7
95
96
  signing_key:
96
97
  specification_version: 4
97
98
  summary: Intelligent search made easy with Rails and Elasticsearch or OpenSearch