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 | 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 | 978 | ### Routing |
911 | 979 | |
912 | 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 | 281 | } |
282 | 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 | 289 | (options[:unsearchable] || []).map(&:to_s).each do |field| |
285 | 290 | mapping[field] = { |
286 | 291 | type: default_type, | ... | ... |
lib/searchkick/query.rb
... | ... | @@ -828,6 +828,17 @@ module Searchkick |
828 | 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 | 842 | when :top_left |
832 | 843 | filters << { |
833 | 844 | geo_bounding_box: { |
... | ... | @@ -943,6 +954,19 @@ module Searchkick |
943 | 954 | end |
944 | 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 | 970 | def location_value(value) |
947 | 971 | if value.is_a?(Array) |
948 | 972 | value.map(&:to_f).reverse | ... | ... |
... | ... | @@ -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 | 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_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 | 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 | ... | ... |