Commit 40a19060d39d6e70eab1a6d437e70cc00536ca60
Exists in
master
and in
21 other branches
Merge branch 'spanner-master'
Showing
6 changed files
with
315 additions
and
0 deletions
Show diff stats
CHANGELOG.md
README.md
@@ -907,6 +907,74 @@ Also supports [additional options](https://www.elastic.co/guide/en/elasticsearch | @@ -907,6 +907,74 @@ Also supports [additional options](https://www.elastic.co/guide/en/elasticsearch | ||
907 | City.search "san", boost_by_distance: {field: :location, origin: {lat: 37, lon: -122}, function: :linear, scale: "30mi", decay: 0.5} | 907 | City.search "san", boost_by_distance: {field: :location, origin: {lat: 37, lon: -122}, function: :linear, scale: "30mi", decay: 0.5} |
908 | ``` | 908 | ``` |
909 | 909 | ||
910 | +### Geo Shapes [master] | ||
911 | + | ||
912 | +You can also pass through complex or varied shapes as GeoJSON objects. | ||
913 | + | ||
914 | +```ruby | ||
915 | +class City < ActiveRecord::Base | ||
916 | + searchkick geo_shapes: { | ||
917 | + bounds: {tree: "geohash", precision: "1km"} | ||
918 | + perimeter: {tree: "quadtree", precision: "10m"} | ||
919 | + } | ||
920 | + | ||
921 | + def search_data | ||
922 | + attributes.merge { | ||
923 | + bounds: { | ||
924 | + type: "envelope", | ||
925 | + coordinates: [{lat: 4, lon: 1}, {lat: 2, lon: 3}] | ||
926 | + }, | ||
927 | + perimeter: { | ||
928 | + type: "polygon", | ||
929 | + coordinates: [[{lat: 1, lon: 2}, {lat: 3, lon: 4}, {lat: 5, lon: 6}, ...]] | ||
930 | + } | ||
931 | + } | ||
932 | + end | ||
933 | +end | ||
934 | +``` | ||
935 | + | ||
936 | +The `geo_shapes` hash is passed through to elasticsearch without modification. Please see the [geo_shape data type documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/geo-shape.html) for options. | ||
937 | + | ||
938 | +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. The possible shapes are: | ||
939 | + | ||
940 | +* **point**: single lat/lon pair | ||
941 | +* **multipoint**: array of points | ||
942 | +* **linestring**: array of at least two lat/lon pairs | ||
943 | +* **multilinestring**: array of lines | ||
944 | +* **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. | ||
945 | +* **multipolygon**: array of polygons | ||
946 | +* **envelope**: a bounding box defined by top left and bottom right points | ||
947 | +* **circle**: a bounding circle defined by center point and radius | ||
948 | +* **geometrycollection**: an array of separate geoJSON objects possibly of various types | ||
949 | + | ||
950 | +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. | ||
951 | + | ||
952 | +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. | ||
953 | + | ||
954 | +Find shapes (of any kind) intersecting with the query shape | ||
955 | + | ||
956 | +```ruby | ||
957 | +City.search "san", where: {bounds: {geo_shape: {type: "polygon", coordinates: [[{lat: 38, lon: -123}, ...]]}}} | ||
958 | +``` | ||
959 | + | ||
960 | +Falling entirely within the query shape | ||
961 | + | ||
962 | +```ruby | ||
963 | +City.search "san", where: {bounds: {geo_shape: {type: "circle", relation: "within", coordinates: [{lat: 38, lon: -123}], radius: "1km"}}} | ||
964 | +``` | ||
965 | + | ||
966 | +Not touching the query shape | ||
967 | + | ||
968 | +```ruby | ||
969 | +City.search "san", where: {bounds: {geo_shape: {type: "envelope", relation: "disjoint", coordinates: [{lat: 38, lon: -123}, {lat: 37, lon: -122}]}}} | ||
970 | +``` | ||
971 | + | ||
972 | +Containing the query shape (ElasticSearch 2.2+) | ||
973 | + | ||
974 | +```ruby | ||
975 | +City.search "san", where: {bounds: {geo_shape: {type: "envelope", relation: "contains", coordinates: [{lat: 38, lon: -123}, {lat: 37, lon: -122}]}}} | ||
976 | +``` | ||
977 | + | ||
910 | ### Routing | 978 | ### Routing |
911 | 979 | ||
912 | Searchkick supports [Elasticsearchโs routing feature](https://www.elastic.co/blog/customizing-your-document-routing). | 980 | Searchkick supports [Elasticsearchโs routing feature](https://www.elastic.co/blog/customizing-your-document-routing). |
lib/searchkick/index_options.rb
@@ -281,6 +281,11 @@ module Searchkick | @@ -281,6 +281,11 @@ module Searchkick | ||
281 | } | 281 | } |
282 | end | 282 | end |
283 | 283 | ||
284 | + options[:geo_shapes] = options[:geo_shapes].product([{}]).to_h if options[:geo_shapes].is_a?(Array) | ||
285 | + (options[:geo_shapes] || {}).each do |field, shape_options| | ||
286 | + mapping[field] = shape_options.merge(type: "geo_shape") | ||
287 | + end | ||
288 | + | ||
284 | (options[:unsearchable] || []).map(&:to_s).each do |field| | 289 | (options[:unsearchable] || []).map(&:to_s).each do |field| |
285 | mapping[field] = { | 290 | mapping[field] = { |
286 | type: default_type, | 291 | type: default_type, |
lib/searchkick/query.rb
@@ -828,6 +828,17 @@ module Searchkick | @@ -828,6 +828,17 @@ module Searchkick | ||
828 | field => op_value | 828 | field => op_value |
829 | } | 829 | } |
830 | } | 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 | + } | ||
831 | when :top_left | 842 | when :top_left |
832 | filters << { | 843 | filters << { |
833 | geo_bounding_box: { | 844 | geo_bounding_box: { |
@@ -943,6 +954,19 @@ module Searchkick | @@ -943,6 +954,19 @@ module Searchkick | ||
943 | end | 954 | end |
944 | end | 955 | end |
945 | 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 | + | ||
946 | def location_value(value) | 970 | def location_value(value) |
947 | if value.is_a?(Array) | 971 | if value.is_a?(Array) |
948 | value.map(&:to_f).reverse | 972 | value.map(&:to_f).reverse |
@@ -0,0 +1,172 @@ | @@ -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 |
test/test_helper.rb
@@ -25,6 +25,10 @@ def elasticsearch_below50? | @@ -25,6 +25,10 @@ def elasticsearch_below50? | ||
25 | Searchkick.server_below?("5.0.0-alpha1") | 25 | Searchkick.server_below?("5.0.0-alpha1") |
26 | end | 26 | end |
27 | 27 | ||
28 | +def elasticsearch_below22? | ||
29 | + Searchkick.server_below?("2.2.0") | ||
30 | +end | ||
31 | + | ||
28 | def elasticsearch_below20? | 32 | def elasticsearch_below20? |
29 | Searchkick.server_below?("2.0.0") | 33 | Searchkick.server_below?("2.0.0") |
30 | end | 34 | end |
@@ -93,6 +97,13 @@ if defined?(Mongoid) | @@ -93,6 +97,13 @@ if defined?(Mongoid) | ||
93 | field :name | 97 | field :name |
94 | end | 98 | end |
95 | 99 | ||
100 | + class Region | ||
101 | + include Mongoid::Document | ||
102 | + | ||
103 | + field :name | ||
104 | + field :text | ||
105 | + end | ||
106 | + | ||
96 | class Speaker | 107 | class Speaker |
97 | include Mongoid::Document | 108 | include Mongoid::Document |
98 | 109 | ||
@@ -143,6 +154,14 @@ elsif defined?(NoBrainer) | @@ -143,6 +154,14 @@ elsif defined?(NoBrainer) | ||
143 | field :name, type: String | 154 | field :name, type: String |
144 | end | 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 | class Speaker | 165 | class Speaker |
147 | include NoBrainer::Document | 166 | include NoBrainer::Document |
148 | 167 | ||
@@ -234,6 +253,11 @@ else | @@ -234,6 +253,11 @@ else | ||
234 | t.string :name | 253 | t.string :name |
235 | end | 254 | end |
236 | 255 | ||
256 | + ActiveRecord::Migration.create_table :regions do |t| | ||
257 | + t.string :name | ||
258 | + t.text :text | ||
259 | + end | ||
260 | + | ||
237 | ActiveRecord::Migration.create_table :speakers do |t| | 261 | ActiveRecord::Migration.create_table :speakers do |t| |
238 | t.string :name | 262 | t.string :name |
239 | end | 263 | end |
@@ -250,6 +274,9 @@ else | @@ -250,6 +274,9 @@ else | ||
250 | has_many :products | 274 | has_many :products |
251 | end | 275 | end |
252 | 276 | ||
277 | + class Region < ActiveRecord::Base | ||
278 | + end | ||
279 | + | ||
253 | class Speaker < ActiveRecord::Base | 280 | class Speaker < ActiveRecord::Base |
254 | end | 281 | end |
255 | 282 | ||
@@ -340,6 +367,23 @@ class Store | @@ -340,6 +367,23 @@ class Store | ||
340 | end | 367 | end |
341 | end | 368 | end |
342 | 369 | ||
370 | +class Region | ||
371 | + searchkick \ | ||
372 | + geo_shapes: { | ||
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 | class Speaker | 387 | class Speaker |
344 | searchkick \ | 388 | searchkick \ |
345 | conversions: ["conversions_a", "conversions_b"] | 389 | conversions: ["conversions_a", "conversions_b"] |
@@ -370,6 +414,7 @@ Product.create!(name: "Set mapping") | @@ -370,6 +414,7 @@ Product.create!(name: "Set mapping") | ||
370 | Store.reindex | 414 | Store.reindex |
371 | Animal.reindex | 415 | Animal.reindex |
372 | Speaker.reindex | 416 | Speaker.reindex |
417 | +Region.reindex | ||
373 | 418 | ||
374 | class Minitest::Test | 419 | class Minitest::Test |
375 | def setup | 420 | def setup |