searchkick 1.4.2 → 1.5.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  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