Commit 40a19060d39d6e70eab1a6d437e70cc00536ca60

Authored by Andrew Kane
2 parents 04e98f10 0f8d9bac

Merge branch 'spanner-master'

CHANGELOG.md
1 1 ## 1.4.3 [unreleased]
2 2  
  3 +- Added support for geo shape indexing and queries
3 4 - Added `_and`, `_or`, `_not` to `where` option
4 5  
5 6 ## 1.4.2
... ...
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
... ...
test/geo_shape_test.rb 0 โ†’ 100644
... ... @@ -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: &quot;Set mapping&quot;)
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
... ...