searchkick 1.4.2 → 1.5.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
  SHA1:
3
- metadata.gz: 777b21bb34049337d1ee1c20fcf51eeaf94ab604
4
- data.tar.gz: 9219dfb5026333d38ce0486f5dbee7d2b3d54838
3
+ metadata.gz: 915b713c1d61ef9709e1c95f7c588b49013b4cdd
4
+ data.tar.gz: cdbefb8ecb273fc877de64cc25356bcd13bf02a8
5
5
  SHA512:
6
- metadata.gz: 9461e485b83ecf1169e9bb305c5296e9484d8bfc6b141b204b9870698cd8efe3f79a43687758c87ed390ba9bf15bcec227111a3ed3075d1aceb71bca38093b0d
7
- data.tar.gz: 85c318dae06729ff723e27dcbcf19906f09418d7b90d7a00bfd7ad50f20a3e6f860faedbeb4d486809b47bf8ac687afb7c8e87b7917d5bddc0686f04fef756f5
6
+ metadata.gz: 961fed20bdf8f9227f735b58fa5fd0f29566ca57396f45eaf7c9779749a2f637510b65187064e0b607d64847572a447729d9c56df08df2317a1e1814d787c3f8
7
+ data.tar.gz: d1b7c56bc92cc95e8308dd35bdd365d457217bf0e272f2cedd3d6cf625b8f1c715e0687859be8e005c1f828f3df00f6a150dc7d5f37a271cf508741f19e473cd
data/CHANGELOG.md CHANGED
@@ -1,3 +1,8 @@
1
+ ## 1.5.0
2
+
3
+ - Added support for geo shape indexing and queries
4
+ - Added `_and`, `_or`, `_not` to `where` option
5
+
1
6
  ## 1.4.2
2
7
 
3
8
  - Added support for directional synonyms
data/README.md CHANGED
@@ -94,9 +94,7 @@ where: {
94
94
  aisle_id: {not: [25, 30]}, # not in
95
95
  user_ids: {all: [1, 3]}, # all elements in array
96
96
  category: /frozen .+/, # regexp
97
- or: [
98
- [{in_stock: true}, {backordered: true}]
99
- ]
97
+ _or: [{in_stock: true}, {backordered: true}]
100
98
  }
101
99
  ```
102
100
 
@@ -907,6 +905,69 @@ Also supports [additional options](https://www.elastic.co/guide/en/elasticsearch
907
905
  City.search "san", boost_by_distance: {field: :location, origin: {lat: 37, lon: -122}, function: :linear, scale: "30mi", decay: 0.5}
908
906
  ```
909
907
 
908
+ ### Geo Shapes
909
+
910
+ You can also index and search geo shapes.
911
+
912
+ ```ruby
913
+ class City < ActiveRecord::Base
914
+ searchkick geo_shape: {
915
+ bounds: {tree: "geohash", precision: "1km"}
916
+ }
917
+
918
+ def search_data
919
+ attributes.merge(
920
+ bounds: {
921
+ type: "envelope",
922
+ coordinates: [{lat: 4, lon: 1}, {lat: 2, lon: 3}]
923
+ }
924
+ )
925
+ end
926
+ end
927
+ ```
928
+
929
+ The `geo_shape` hash is passed through to Elasticsearch without modification. Please see the [geo shape documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/geo-shape.html) for options.
930
+
931
+ Any geospatial data type can be held in the index or give as a search query. It is up to you to ensure that it is a valid GeoJSON representation. Possible shapes are:
932
+
933
+ * **point**: single lat/lon pair
934
+ * **multipoint**: array of points
935
+ * **linestring**: array of at least two lat/lon pairs
936
+ * **multilinestring**: array of lines
937
+ * **polygon**: an array of paths, each being an array of at least four lat/lon pairs whose first and last points are the same. Paths after the first represent exclusions. Elasticsearch will return an error if a polygon contains two consecutive identical points, intersects itself or is not closed.
938
+ * **multipolygon**: array of polygons
939
+ * **envelope**: a bounding box defined by top left and bottom right points
940
+ * **circle**: a bounding circle defined by center point and radius
941
+ * **geometrycollection**: an array of separate GeoJSON objects possibly of various types
942
+
943
+ See the [Elasticsearch documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/geo-shape.html) for details. GeoJSON coordinates are usually given as an array of `[lon, lat]` points but searchkick can also take objects with `lon` and `lat` keys.
944
+
945
+ Once a geo shape index is established, you can include a geo shape filter in any search. This also takes a GeoJSON shape and will return a list of items based on their overlap with that shape.
946
+
947
+ Find shapes (of any kind) intersecting with the query shape
948
+
949
+ ```ruby
950
+ City.search "san", where: {bounds: {geo_shape: {type: "polygon", coordinates: [[{lat: 38, lon: -123}, ...]]}}}
951
+ ```
952
+
953
+ Falling entirely within the query shape
954
+
955
+ ```ruby
956
+ City.search "san", where: {bounds: {geo_shape: {type: "circle", relation: "within", coordinates: [{lat: 38, lon: -123}], radius: "1km"}}}
957
+ ```
958
+
959
+ Not touching the query shape
960
+
961
+ ```ruby
962
+ City.search "san", where: {bounds: {geo_shape: {type: "envelope", relation: "disjoint", coordinates: [{lat: 38, lon: -123}, {lat: 37, lon: -122}]}}}
963
+ ```
964
+
965
+ Containing the query shape (Elasticsearch 2.2+)
966
+
967
+ ```ruby
968
+ City.search "san", where: {bounds: {geo_shape: {type: "envelope", relation: "contains", coordinates: [{lat: 38, lon: -123}, {lat: 37, lon: -122}]}}}
969
+ ```
970
+
910
971
  ### Routing
911
972
 
912
973
  Searchkick supports [Elasticsearch’s routing feature](https://www.elastic.co/blog/customizing-your-document-routing).
@@ -281,6 +281,11 @@ module Searchkick
281
281
  }
282
282
  end
283
283
 
284
+ options[:geo_shape] = options[:geo_shape].product([{}]).to_h if options[:geo_shape].is_a?(Array)
285
+ (options[:geo_shape] || {}).each do |field, shape_options|
286
+ mapping[field] = shape_options.merge(type: "geo_shape")
287
+ end
288
+
284
289
  (options[:unsearchable] || []).map(&:to_s).each do |field|
285
290
  mapping[field] = {
286
291
  type: default_type,
@@ -784,6 +784,24 @@ module Searchkick
784
784
  filters << {bool: {should: or_clause.map { |or_statement| {bool: {filter: where_filters(or_statement)}} }}}
785
785
  end
786
786
  end
787
+ elsif field == :_or
788
+ if below20?
789
+ filters << {or: value.map { |or_statement| {and: where_filters(or_statement)} }}
790
+ else
791
+ filters << {bool: {should: value.map { |or_statement| {bool: {filter: where_filters(or_statement)}} }}}
792
+ end
793
+ elsif field == :_not
794
+ if below20?
795
+ filters << {not: {and: where_filters(value)}}
796
+ else
797
+ filters << {bool: {must_not: where_filters(value)}}
798
+ end
799
+ elsif field == :_and
800
+ if below20?
801
+ filters << {and: value.map { |or_statement| {and: where_filters(or_statement)} }}
802
+ else
803
+ filters << {bool: {must: value.map { |or_statement| {bool: {filter: where_filters(or_statement)}} }}}
804
+ end
787
805
  else
788
806
  # expand ranges
789
807
  if value.is_a?(Range)
@@ -810,6 +828,17 @@ module Searchkick
810
828
  field => op_value
811
829
  }
812
830
  }
831
+ when :geo_shape
832
+ shape = op_value.except(:relation)
833
+ shape[:coordinates] = coordinate_array(shape[:coordinates]) if shape[:coordinates]
834
+ filters << {
835
+ geo_shape: {
836
+ field => {
837
+ relation: op_value[:relation] || "intersects",
838
+ shape: shape
839
+ }
840
+ }
841
+ }
813
842
  when :top_left
814
843
  filters << {
815
844
  geo_bounding_box: {
@@ -925,6 +954,19 @@ module Searchkick
925
954
  end
926
955
  end
927
956
 
957
+ # Recursively descend through nesting of arrays until we reach either a lat/lon object or an array of numbers,
958
+ # eventually returning the same structure with all values transformed to [lon, lat].
959
+ #
960
+ def coordinate_array(value)
961
+ if value.is_a?(Hash)
962
+ [value[:lon], value[:lat]]
963
+ elsif value.is_a?(Array) and !value[0].is_a?(Numeric)
964
+ value.map {|a| coordinate_array(a) }
965
+ else
966
+ value
967
+ end
968
+ end
969
+
928
970
  def location_value(value)
929
971
  if value.is_a?(Array)
930
972
  value.map(&:to_f).reverse
@@ -1,3 +1,3 @@
1
1
  module Searchkick
2
- VERSION = "1.4.2"
2
+ VERSION = "1.5.0"
3
3
  end
@@ -0,0 +1,172 @@
1
+ require_relative "test_helper"
2
+
3
+ class GeoShapeTest < Minitest::Test
4
+ def setup
5
+ Region.destroy_all
6
+ store [
7
+ {
8
+ name: "Region A",
9
+ text: "The witch had a cat",
10
+ territory: {
11
+ type: "polygon",
12
+ coordinates: [[[30, 40], [35, 45], [40, 40], [40, 30], [30, 30], [30, 40]]]
13
+ }
14
+ },
15
+ {
16
+ name: "Region B",
17
+ text: "and a very tall hat",
18
+ territory: {
19
+ type: "polygon",
20
+ coordinates: [[[50, 60], [55, 65], [60, 60], [60, 50], [50, 50], [50, 60]]]
21
+ }
22
+ },
23
+ {
24
+ name: "Region C",
25
+ text: "and long ginger hair which she wore in a plait",
26
+ territory: {
27
+ type: "polygon",
28
+ coordinates: [[[10, 20], [15, 25], [20, 20], [20, 10], [10, 10], [10, 20]]]
29
+ }
30
+ }
31
+ ], Region
32
+ end
33
+
34
+ def test_circle
35
+ assert_search "*", ["Region A"], {
36
+ where: {
37
+ territory: {
38
+ geo_shape: {
39
+ type: "circle",
40
+ coordinates: {lat: 28.0, lon: 38.0},
41
+ radius: "444000m"
42
+ }
43
+ }
44
+ }
45
+ }, Region
46
+ end
47
+
48
+ def test_envelope
49
+ assert_search "*", ["Region A"], {
50
+ where: {
51
+ territory: {
52
+ geo_shape: {
53
+ type: "envelope",
54
+ coordinates: [[28, 42], [32, 38]]
55
+ }
56
+ }
57
+ }
58
+ }, Region
59
+ end
60
+
61
+ def test_polygon
62
+ assert_search "*", ["Region A"], {
63
+ where: {
64
+ territory: {
65
+ geo_shape: {
66
+ type: "polygon",
67
+ coordinates: [[[38, 42], [42, 42], [42, 38], [38, 38], [38, 42]]]
68
+ }
69
+ }
70
+ }
71
+ }, Region
72
+ end
73
+
74
+ def test_multipolygon
75
+ assert_search "*", ["Region A", "Region B"], {
76
+ where: {
77
+ territory: {
78
+ geo_shape: {
79
+ type: "multipolygon",
80
+ coordinates: [
81
+ [[[38, 42], [42, 42], [42, 38], [38, 38], [38, 42]]],
82
+ [[[58, 62], [62, 62], [62, 58], [58, 58], [58, 62]]]
83
+ ]
84
+ }
85
+ }
86
+ }
87
+ }, Region
88
+ end
89
+
90
+ def test_disjoint
91
+ assert_search "*", ["Region B", "Region C"], {
92
+ where: {
93
+ territory: {
94
+ geo_shape: {
95
+ type: "envelope",
96
+ relation: "disjoint",
97
+ coordinates: [[28, 42], [32, 38]]
98
+ }
99
+ }
100
+ }
101
+ }, Region
102
+ end
103
+
104
+ def test_within
105
+ assert_search "*", ["Region A"], {
106
+ where: {
107
+ territory: {
108
+ geo_shape: {
109
+ type: "envelope",
110
+ relation: "within",
111
+ coordinates: [[20,50], [50,20]]
112
+ }
113
+ }
114
+ }
115
+ }, Region
116
+ end
117
+
118
+ def test_search_math
119
+ assert_search "witch", ["Region A"], {
120
+ where: {
121
+ territory: {
122
+ geo_shape: {
123
+ type: "envelope",
124
+ coordinates: [[28, 42], [32, 38]]
125
+ }
126
+ }
127
+ }
128
+ }, Region
129
+ end
130
+
131
+ def test_search_no_match
132
+ assert_search "ginger hair", [], {
133
+ where: {
134
+ territory: {
135
+ geo_shape: {
136
+ type: "envelope",
137
+ coordinates: [[28, 42], [32, 38]]
138
+ }
139
+ }
140
+ }
141
+ }, Region
142
+ end
143
+
144
+ def test_contains
145
+ skip if elasticsearch_below22?
146
+ assert_search "*", ["Region C"], {
147
+ where: {
148
+ territory: {
149
+ geo_shape: {
150
+ type: "envelope",
151
+ relation: "contains",
152
+ coordinates: [[12, 13], [13, 12]]
153
+ }
154
+ }
155
+ }
156
+ }, Region
157
+ end
158
+
159
+ def test_latlon
160
+ assert_search "*", ["Region A"], {
161
+ where: {
162
+ territory: {
163
+ geo_shape: {
164
+ type: "envelope",
165
+ coordinates: [{lat: 42, lon: 28}, {lat: 38, lon: 32}]
166
+ }
167
+ }
168
+ }
169
+ }, Region
170
+ end
171
+
172
+ end
@@ -22,7 +22,7 @@ class HighlightTest < Minitest::Test
22
22
  store [{name: "Two Door Cinema Club", color: "Cinema Orange"}]
23
23
  highlight = Product.search("cinema", fields: [:name, :color], highlight: {fields: [:name]}).with_details.first[1][:highlight]
24
24
  assert_equal "Two Door <em>Cinema</em> Club", highlight[:name]
25
- assert_equal nil, highlight[:color]
25
+ assert_nil highlight[:color]
26
26
  end
27
27
 
28
28
  def test_field_options
data/test/index_test.rb CHANGED
@@ -28,6 +28,11 @@ class IndexTest < Minitest::Test
28
28
  assert !old_index.exists?
29
29
  end
30
30
 
31
+ def test_total_docs
32
+ store_names ["Product A"]
33
+ assert_equal 1, Product.searchkick_index.total_docs
34
+ end
35
+
31
36
  def test_mapping
32
37
  store_names ["Dollar Tree"], Store
33
38
  assert_equal [], Store.search(query: {match: {name: "dollar"}}).map(&:name)
data/test/model_test.rb CHANGED
@@ -36,7 +36,7 @@ class ModelTest < Minitest::Test
36
36
 
37
37
  def test_multiple_models
38
38
  store_names ["Product A"]
39
- store_names ["Product B"], Store
40
- assert_equal Product.all + Store.all, Searchkick.search("product", index_name: [Product, Store], order: "name").to_a
39
+ store_names ["Product B"], Speaker
40
+ assert_equal Product.all + Speaker.all, Searchkick.search("product", index_name: [Product, Speaker], fields: [:name], order: "name").to_a
41
41
  end
42
42
  end
data/test/suggest_test.rb CHANGED
@@ -70,7 +70,12 @@ class SuggestTest < Minitest::Test
70
70
  protected
71
71
 
72
72
  def assert_suggest(term, expected, options = {})
73
- assert_equal expected, Product.search(term, options.merge(suggest: true)).suggestions.first
73
+ result = Product.search(term, options.merge(suggest: true)).suggestions.first
74
+ if expected.nil?
75
+ assert_nil result
76
+ else
77
+ assert_equal expected, result
78
+ end
74
79
  end
75
80
 
76
81
  # any order
data/test/test_helper.rb CHANGED
@@ -25,6 +25,10 @@ def elasticsearch_below50?
25
25
  Searchkick.server_below?("5.0.0-alpha1")
26
26
  end
27
27
 
28
+ def elasticsearch_below22?
29
+ Searchkick.server_below?("2.2.0")
30
+ end
31
+
28
32
  def elasticsearch_below20?
29
33
  Searchkick.server_below?("2.0.0")
30
34
  end
@@ -93,6 +97,13 @@ if defined?(Mongoid)
93
97
  field :name
94
98
  end
95
99
 
100
+ class Region
101
+ include Mongoid::Document
102
+
103
+ field :name
104
+ field :text
105
+ end
106
+
96
107
  class Speaker
97
108
  include Mongoid::Document
98
109
 
@@ -143,6 +154,14 @@ elsif defined?(NoBrainer)
143
154
  field :name, type: String
144
155
  end
145
156
 
157
+ class Region
158
+ include NoBrainer::Document
159
+
160
+ field :id, type: Object
161
+ field :name, type: String
162
+ field :text, type: Text
163
+ end
164
+
146
165
  class Speaker
147
166
  include NoBrainer::Document
148
167
 
@@ -234,6 +253,11 @@ else
234
253
  t.string :name
235
254
  end
236
255
 
256
+ ActiveRecord::Migration.create_table :regions do |t|
257
+ t.string :name
258
+ t.text :text
259
+ end
260
+
237
261
  ActiveRecord::Migration.create_table :speakers do |t|
238
262
  t.string :name
239
263
  end
@@ -250,6 +274,9 @@ else
250
274
  has_many :products
251
275
  end
252
276
 
277
+ class Region < ActiveRecord::Base
278
+ end
279
+
253
280
  class Speaker < ActiveRecord::Base
254
281
  end
255
282
 
@@ -340,6 +367,23 @@ class Store
340
367
  end
341
368
  end
342
369
 
370
+ class Region
371
+ searchkick \
372
+ geo_shape: {
373
+ territory: {tree: "quadtree", precision: "10km"}
374
+ }
375
+
376
+ attr_accessor :territory
377
+
378
+ def search_data
379
+ {
380
+ name: name,
381
+ text: text,
382
+ territory: territory
383
+ }
384
+ end
385
+ end
386
+
343
387
  class Speaker
344
388
  searchkick \
345
389
  conversions: ["conversions_a", "conversions_b"]
@@ -370,6 +414,7 @@ Product.create!(name: "Set mapping")
370
414
  Store.reindex
371
415
  Animal.reindex
372
416
  Speaker.reindex
417
+ Region.reindex
373
418
 
374
419
  class Minitest::Test
375
420
  def setup
data/test/where_test.rb CHANGED
@@ -31,6 +31,14 @@ class WhereTest < Minitest::Test
31
31
  assert_search "product", ["Product A", "Product B", "Product C"], where: {or: [[{in_stock: true}, {store_id: 3}]]}
32
32
  assert_search "product", ["Product A", "Product B", "Product C"], where: {or: [[{orders_count: [2, 4]}, {store_id: [1, 2]}]]}
33
33
  assert_search "product", ["Product A", "Product D"], where: {or: [[{orders_count: 1}, {created_at: {gte: now - 1}, backordered: true}]]}
34
+ # _or
35
+ assert_search "product", ["Product A", "Product B", "Product C"], where: {_or: [{in_stock: true}, {store_id: 3}]}
36
+ assert_search "product", ["Product A", "Product B", "Product C"], where: {_or: [{orders_count: [2, 4]}, {store_id: [1, 2]}]}
37
+ assert_search "product", ["Product A", "Product D"], where: {_or: [{orders_count: 1}, {created_at: {gte: now - 1}, backordered: true}]}
38
+ # _and
39
+ assert_search "product", ["Product A"], where: {_and: [{in_stock: true}, {backordered: true}]}
40
+ # _not
41
+ assert_search "product", ["Product B", "Product C"], where: {_not: {_or: [{orders_count: 1}, {created_at: {gte: now - 1}, backordered: true}]}}
34
42
  # all
35
43
  assert_search "product", ["Product A", "Product C"], where: {user_ids: {all: [1, 3]}}
36
44
  assert_search "product", [], where: {user_ids: {all: [1, 2, 3, 4]}}
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: 1.4.2
4
+ version: 1.5.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: 2016-12-22 00:00:00.000000000 Z
11
+ date: 2016-12-23 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activemodel
@@ -140,6 +140,7 @@ files:
140
140
  - test/gemfiles/mongoid4.gemfile
141
141
  - test/gemfiles/mongoid5.gemfile
142
142
  - test/gemfiles/nobrainer.gemfile
143
+ - test/geo_shape_test.rb
143
144
  - test/highlight_test.rb
144
145
  - test/index_test.rb
145
146
  - test/inheritance_test.rb
@@ -209,6 +210,7 @@ test_files:
209
210
  - test/gemfiles/mongoid4.gemfile
210
211
  - test/gemfiles/mongoid5.gemfile
211
212
  - test/gemfiles/nobrainer.gemfile
213
+ - test/geo_shape_test.rb
212
214
  - test/highlight_test.rb
213
215
  - test/index_test.rb
214
216
  - test/inheritance_test.rb