Commit 94081b284fb9b876659f957d1bc83eaa73b92dd8

Authored by Andrew Kane
2 parents 04e98f10 dea4942c

Merge branch 'master' of https://github.com/spanner/searchkick into spanner-master

@@ -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
  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 + op_value[:coordinates] = coordinate_array(op_value[:coordinates]) if op_value[:coordinates]
  833 + relation = op_value.delete(:relation) || 'intersects'
  834 + filters << {
  835 + geo_shape: {
  836 + field => {
  837 + relation: relation,
  838 + shape: op_value
  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
test/geo_shape_test.rb 0 โ†’ 100644
@@ -0,0 +1,172 @@ @@ -0,0 +1,172 @@
  1 +require_relative "test_helper"
  2 +
  3 +class GeoShapeTest < Minitest::Test
  4 + def setup
  5 + super
  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"]
@@ -371,12 +415,16 @@ Store.reindex @@ -371,12 +415,16 @@ Store.reindex
371 Animal.reindex 415 Animal.reindex
372 Speaker.reindex 416 Speaker.reindex
373 417
  418 +Region.searchkick_index.delete if Region.searchkick_index.exists?
  419 +Region.reindex
  420 +
374 class Minitest::Test 421 class Minitest::Test
375 def setup 422 def setup
376 Product.destroy_all 423 Product.destroy_all
377 Store.destroy_all 424 Store.destroy_all
378 Animal.destroy_all 425 Animal.destroy_all
379 Speaker.destroy_all 426 Speaker.destroy_all
  427 + Region.destroy_all
380 end 428 end
381 429
382 protected 430 protected