mongoid-geospatial 7.1.0 → 7.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 88d807a8e7011846e353f18fc6812fb55e578f371464c202fa726976c7fec6e6
4
- data.tar.gz: 32f5124231bfe558a9a591520c90dda408f76995faa2030e67c39bee1e9f7b23
3
+ metadata.gz: 59aa6c29ce76d2edc2c6ba8b30492c713e552b31ad728abe8a43743464576681
4
+ data.tar.gz: d62f1d2814b6e0faa93f1d5d2fc16371d30d37e97d72e5eada5a647bae6e9aab
5
5
  SHA512:
6
- metadata.gz: f8beb1650724d7b54d7a2a35d2376597144d9290360f2571da28681ef11d3e3e0792bf562f0c904f762b0abaf09d43b9f8c8f1747cb4d7753a5f187a6c6d04c5
7
- data.tar.gz: b457c28c88ab87968e76eeb00afca6e376e37cf2432df230fc88260ab44b9984c9cd11708deba53eb19fa4a19d27175d63042c5b142762707fe3bd1019107652
6
+ metadata.gz: fa331e0ce250edf23904a8390442a6cdf831f98d89110f3e5c803b801f80660087444839b1607ac868a31b9aa2dfdeb17d0910294dc83727a281e0fa875b3eff
7
+ data.tar.gz: 1c0b907249ce2ac189bb8f00551dede0b15c59d9c72f218de23c4c746f370060953d065c77c79cb61c209a81607003cfcfdea197ca6893dcc81521b9a5f258c3
data/README.md CHANGED
@@ -46,6 +46,14 @@ class Place
46
46
  end
47
47
  ```
48
48
 
49
+ A pin named `geom` on a 2dsphere index is one include:
50
+
51
+ ```ruby
52
+ include Mongoid::Geospatial::Geom # field :geom, type: Point, sphere: true
53
+ # or a named one:
54
+ geom :pick_up
55
+ ```
56
+
49
57
  Generate indexes on MongoDB via rake:
50
58
 
51
59
  ```
@@ -94,6 +102,15 @@ If you need a hash
94
102
  cafe.location.to_hsh # => { x: -74.026667, y: 40.703056 }
95
103
  ```
96
104
 
105
+ Commonly used
106
+
107
+ ```ruby
108
+ cafe.location.to_lat_lon # => { latitude: 40.703056, longitude: -74.026667 }
109
+ cafe.location.lat # => 40.703056
110
+ cafe.location.lng # => -74.026667
111
+ cafe.location.distance(other) # => km, haversine
112
+ ```
113
+
97
114
  If you are using GeoRuby or RGeo
98
115
 
99
116
  ```ruby
@@ -108,7 +125,7 @@ This lib uses #x and #y everywhere.
108
125
  It's shorter than lat or lng or another variation that also confuses.
109
126
  A point is a 2D mathematical notation, longitude/latitude is when you use that notation to map an sphere. In other words: all longitudes are 'xs' where not all 'xs' are longitudes.
110
127
 
111
- Distance and other geometrical calculations are delegated to the external library of your choice. More info about using RGeo or GeoRuby below. Some built in helpers for mongoid queries:
128
+ Distance is `Point#distance` (haversine, km). Projections and heavier geometry still go to RGeo or GeoRuby. Some built in helpers for mongoid queries:
112
129
 
113
130
  ```ruby
114
131
  # Returns middle point + radius
@@ -215,7 +232,41 @@ Bar.near_sphere(location: person.house)
215
232
  Bar.where(:location.near_sphere => person.house)
216
233
  ```
217
234
 
218
- ### nearby
235
+ ### nearby / within
236
+
237
+ ```ruby
238
+ Bar.nearby(person.house)
239
+ Bar.nearby(person.house, km: 30)
240
+ Bar.within(person.house, 30) # same question, km required
241
+ Bar.nearest(person.house, 30) # the closest one, or nil
242
+ Ride.within(here, 5, field: :drop_up) # two pins? name the one you mean
243
+ City.where(:geom.near_sphere => Mongoid::Geospatial.near_query(geom, 50))
244
+ ```
245
+
246
+ `within` caps by km, nearest first, chainable — on either index. `$nearSphere`
247
+ speaks two dialects and **the field's index picks which**, so the km never moves:
248
+
249
+ ```
250
+ sphere: true { loc: { $nearSphere: { $geometry: {..}, $maxDistance: <metres> } } }
251
+ spatial: true { loc: { $nearSphere: [x, y], $maxDistance: <radians> } }
252
+ km / 6371
253
+ ```
254
+
255
+ Hand the server the wrong one and it answers `NoQueryExecutionPlans` — not a
256
+ wrong count, a raise.
257
+
258
+ **`within(..).first` is not the nearest one.** Mongoid's `#first` and `#last`
259
+ sort by `_id` whenever the criteria carries no sort of its own, and that `_id`
260
+ sort replaces the distance order `$near` put there. It reads as working every
261
+ time the closest document happens to be the oldest. Walk the criteria
262
+ (`.to_a.first`) or ask `nearest`. `Mongoid::Geospatial.near_selector(field, point, km,
263
+ sphere: <bool>)` is the one place both shapes are built; `near_query` is the
264
+ 2dsphere half of it, for a query you write by hand.
265
+
266
+ **Don't call `#first` or `#last` on a `$near` criteria.** Mongoid sorts by
267
+ `_id` when a criteria carries no sort of its own, and that replaces the
268
+ distance order Mongo put there. Walk the criteria instead — `.to_a.first` —
269
+ or use `geo_near`, which returns the distance as a field you can sort on.
219
270
 
220
271
  You can add a `spatial_scope` on your models. So you can query:
221
272
 
@@ -253,12 +304,14 @@ Place.geo_near(:location, [10, 20],
253
304
  query: { category: 'restaurant' }, # Optional: filter documents before geoNear
254
305
  limit: 10)
255
306
 
256
- # Iterate over results
257
- Place.geo_near(:location, [10, 20], spherical: true).each do |place|
258
- # 'place.distance' will be available if distanceField was 'distance' (the default)
259
- # or 'place.dist_calculated' if distanceField was 'dist.calculated'
260
- puts "#{place.name} is #{place.distance || place.dist_calculated} meters away."
307
+ # Iterate over results — HASHES, not documents. Nothing is instantiated:
308
+ # $geoNear adds fields a model has no field for.
309
+ Place.geo_near(:location, [10, 20], spherical: true).each do |doc|
310
+ puts "#{doc['name']} is #{doc['distance']} meters away."
261
311
  end
312
+
313
+ # Want models? ask for them at the call site:
314
+ Place.geo_near(:location, [10, 20]).map { |attrs| Place.instantiate(attrs) }
262
315
  ```
263
316
 
264
317
  Key features and options for `geo_near`:
@@ -282,6 +335,20 @@ Bar.within_polygon(location: [[[x,y],...[x,y]]])
282
335
  Bar.within_polygon(location: street.bbox)
283
336
  ```
284
337
 
338
+ - within_circle / within_spherical_circle
339
+
340
+ Mongoid ships `within_polygon` and `within_box` and stopped there; this gem
341
+ registers the two circle shapes, which is what `radius` and `radius_sphere`
342
+ have been building arguments for all along.
343
+
344
+ ```ruby
345
+ Bar.where(:location.within_spherical_circle => cafe.location.radius_sphere(5, :km))
346
+ Bar.where(:location.within_circle => cafe.location.radius(0.05)) # degrees, flat
347
+ ```
348
+
349
+ `$centerSphere` reads radians (`radius_sphere` hands it those); `$center`
350
+ reads the coordinate system's own units — degrees on a legacy pair.
351
+
285
352
  - intersects_line
286
353
  - intersects_point
287
354
  - intersects_polygon
@@ -295,7 +362,7 @@ external library corresponding object.
295
362
 
296
363
  ### Use RGeo?
297
364
 
298
- https://github.com/dazuma/rgeo
365
+ <https://github.com/dazuma/rgeo>
299
366
 
300
367
  RGeo is a Ruby wrapper for Proj/GEOS.
301
368
  It's perfect when you need to work with complex calculations and projections.
@@ -303,7 +370,7 @@ It'll require more stuff installed to compile/work.
303
370
 
304
371
  ### Use GeoRuby?
305
372
 
306
- https://github.com/nofxx/georuby
373
+ <https://github.com/nofxx/georuby>
307
374
 
308
375
  GeoRuby is a pure Ruby Geometry Library.
309
376
  It's perfect if you want simple calculations and/or keep your stack in pure ruby.
@@ -10,8 +10,8 @@ module Mongoid
10
10
 
11
11
  def reset!
12
12
  # Now self.x and self.y refer to the public module accessors
13
- self.x = Mongoid::Geospatial.lng_symbols
14
- self.y = Mongoid::Geospatial.lat_symbols
13
+ self.x = Mongoid::Geospatial.lng_symbols.dup
14
+ self.y = Mongoid::Geospatial.lat_symbols.dup
15
15
  end
16
16
 
17
17
  # Initialize the configuration
@@ -4,9 +4,16 @@ module Mongoid
4
4
  module Geospatial
5
5
  # Point
6
6
  #
7
- class Point
7
+ class Point # rubocop:disable Metrics/ClassLength
8
8
  include Enumerable
9
+
9
10
  attr_accessor :x, :y, :z
11
+ alias lng x
12
+ alias lon x
13
+ alias lat y
14
+ alias lng= x=
15
+ alias lon= x=
16
+ alias lat= y=
10
17
 
11
18
  def initialize(lon, lat, alt = nil)
12
19
  @x = lon
@@ -25,9 +32,12 @@ module Mongoid
25
32
  end
26
33
  alias to_a mongoize
27
34
  alias to_xy mongoize
35
+ alias to_lng_lat mongoize
28
36
 
29
37
  def [](args)
30
- mongoize[args]
38
+ raise ArgumentError, "Invalid point: #{inspect}" unless (pair = mongoize)
39
+
40
+ pair[args]
31
41
  end
32
42
 
33
43
  def each
@@ -35,24 +45,15 @@ module Mongoid
35
45
  yield y
36
46
  end
37
47
 
38
- #
39
- # Point representation as a Hash
40
- # Optional param: custom keys.
41
- #
42
- # @return [Hash] with { lng_key => x, lat_key => y }
43
- #
44
- def to_hsh(xkey = :x, ykey = :y)
45
- { xkey => x, ykey => y }
46
- end
47
- alias to_hash to_hsh
48
-
49
48
  #
50
49
  # Helper for [self, radius]
51
50
  #
52
51
  # @return [Array] with [self, radius]
53
52
  #
54
53
  def radius(r = 1) # rubocop:disable Naming/MethodParameterName
55
- [mongoize, r]
54
+ return nil unless (pair = mongoize)
55
+
56
+ [pair, r]
56
57
  end
57
58
 
58
59
  #
@@ -63,7 +64,7 @@ module Mongoid
63
64
  # @return [Array] with [self, radius / earth radius]
64
65
  #
65
66
  def radius_sphere(r = 1, unit = :km) # rubocop:disable Naming/MethodParameterName
66
- radius r.to_f / Mongoid::Geospatial.earth_radius[unit]
67
+ radius r.to_f / Mongoid::Geospatial.earth_radius.fetch(unit)
67
68
  end
68
69
 
69
70
  #
@@ -88,6 +89,26 @@ module Mongoid
88
89
  "#{x}, #{y}"
89
90
  end
90
91
 
92
+ #
93
+ # Point representation as a Hash
94
+ # Optional param: custom keys.
95
+ #
96
+ # @return [Hash] with { lng_key => x, lat_key => y }
97
+ #
98
+ def to_hsh(xkey = :x, ykey = :y)
99
+ { xkey => x, ykey => y }
100
+ end
101
+ alias to_hash to_hsh
102
+
103
+ #
104
+ # Point representation more commonly used
105
+ # Latitude, Longitude
106
+ #
107
+ # @return [Hash] with { latitude: y, longitude: x }
108
+ def to_lat_lon
109
+ { latitude: y, longitude: x }
110
+ end
111
+
91
112
  #
92
113
  # Point definition as GeoJSON
93
114
  #
@@ -113,32 +134,21 @@ module Mongoid
113
134
  end
114
135
 
115
136
  #
116
- # Distance calculation methods. Thinking about not using it
117
- # One needs to choose and external lib. GeoRuby or RGeo
118
- #
119
- # Return the distance between the 2D points (ie taking care
120
- # only of the x and y coordinates), assuming the points are
121
- # in projected coordinates. Euclidian distance in whatever
122
- # unit the x and y ordinates are.
123
- # def euclidian_distance(point)
124
- # Math.sqrt((point.x - x)**2 + (point.y - y)**2)
125
- # end
126
-
127
- # # Spherical distance in meters, using 'Haversine' formula.
128
- # # with a radius of 6471000m
129
- # # Assumes x is the lon and y the lat, in degrees (Changed
130
- # in version 1.1).
131
- # # The user has to make sure using this distance makes sense
132
- # (ie she should be in latlon coordinates)
133
- # def spherical_distance(point,r=6370997.0)
134
- # dlat = (point.lat - lat) * DEG2RAD / 2
135
- # dlon = (point.lon - lon) * DEG2RAD / 2
136
-
137
- # a = Math.sin(dlat)**2 + Math.cos(lat * DEG2RAD) *
138
- # Math.cos(point.lat * DEG2RAD) * Math.sin(dlon)**2
139
- # c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a))
140
- # r * c
141
- # end
137
+ # Crow-flies distance. Haversine, Mongo's earth radius (6371 km).
138
+ # RGeo/GeoRuby still win for projections; this is ETA and "how far".
139
+ #
140
+ def distance(other, unit = :km) # rubocop:disable Metrics/AbcSize
141
+ xy = other.is_a?(Point) ? [other.x, other.y] : self.class.mongoize(other)
142
+ raise ArgumentError, "Invalid point: #{other.inspect}" unless xy&.size == 2
143
+
144
+ dlat = (xy[1] - y) * RAD_PER_DEG
145
+ dlon = (xy[0] - x) * RAD_PER_DEG
146
+ a = (Math.sin(dlat / 2)**2) +
147
+ (Math.cos(y * RAD_PER_DEG) * Math.cos(xy[1] * RAD_PER_DEG) *
148
+ (Math.sin(dlon / 2)**2))
149
+ c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
150
+ Mongoid::Geospatial.earth_radius[unit] * c
151
+ end
142
152
 
143
153
  class << self
144
154
  #
@@ -189,7 +199,7 @@ module Mongoid
189
199
  def from_string(str)
190
200
  return nil if str.empty?
191
201
 
192
- str.split(/,|\s/).reject(&:empty?).map(&:to_f)
202
+ from_array(str.split(/,|\s/).reject(&:empty?))
193
203
  end
194
204
 
195
205
  #
@@ -222,6 +232,9 @@ module Mongoid
222
232
  # @return (Array)
223
233
  #
224
234
  def from_hash(hsh)
235
+ coords = hsh[:coordinates] || hsh['coordinates']
236
+ return from_array(coords) if coords
237
+
225
238
  raise 'Hash must have at least 2 items' if hsh.size < 2
226
239
 
227
240
  [from_hash_x(hsh), from_hash_y(hsh)]
@@ -19,16 +19,10 @@ module Mongoid
19
19
  # @return [Array] containing 2 points
20
20
  #
21
21
  def bounding_box
22
- max_x = -Float::MAX
23
- min_x = Float::MAX
24
- max_y = -Float::MAX
25
- min_y = Float::MAX
26
- each do |point|
27
- max_y = point[1] if point[1] > max_y
28
- min_y = point[1] if point[1] < min_y
29
- max_x = point[0] if point[0] > max_x
30
- min_x = point[0] if point[0] < min_x
31
- end
22
+ return nil if empty?
23
+
24
+ min_x, max_x = map { |point| point[0] }.minmax
25
+ min_y, max_y = map { |point| point[1] }.minmax
32
26
  [[min_x, min_y], [max_x, max_y]]
33
27
  end
34
28
  alias bbox bounding_box
@@ -43,7 +37,9 @@ module Mongoid
43
37
  # @return [Array] containing 5 points
44
38
  #
45
39
  def geom_box
46
- xl, yl = bounding_box
40
+ return nil unless (box = bounding_box)
41
+
42
+ xl, yl = box
47
43
  [xl, [xl[0], yl[1]], yl, [yl[0], xl[1]], xl]
48
44
  end
49
45
 
@@ -54,7 +50,9 @@ module Mongoid
54
50
  # @return [Array] containing 1 point [x,y]
55
51
  #
56
52
  def center_point
57
- min, max = *bbox
53
+ return nil unless (box = bbox)
54
+
55
+ min, max = *box
58
56
  [(min[0] + max[0]) / 2.0, (min[1] + max[1]) / 2.0]
59
57
  end
60
58
  alias center center_point
@@ -66,7 +64,9 @@ module Mongoid
66
64
  # @return [Array] [point, r] point and radius in mongoid format
67
65
  #
68
66
  def radius(r = 1) # rubocop:disable Naming/MethodParameterName
69
- [center, r]
67
+ return nil unless (mid = center)
68
+
69
+ [mid, r]
70
70
  end
71
71
 
72
72
  #
@@ -78,7 +78,7 @@ module Mongoid
78
78
  # @return [Array]
79
79
  #
80
80
  def radius_sphere(r = 1, unit = :km) # rubocop:disable Naming/MethodParameterName
81
- radius r.to_f / Mongoid::Geospatial.earth_radius[unit]
81
+ radius r.to_f / Mongoid::Geospatial.earth_radius.fetch(unit)
82
82
  end
83
83
 
84
84
  class << self
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mongoid
4
+ module Geospatial
5
+ #
6
+ # The pin, named `geom`, on a 2dsphere index.
7
+ #
8
+ module Geom
9
+ extend ActiveSupport::Concern
10
+
11
+ included do
12
+ include Mongoid::Geospatial
13
+
14
+ geom
15
+ end
16
+
17
+ def located?
18
+ geom.present?
19
+ end
20
+ end
21
+ end
22
+ end
@@ -10,7 +10,6 @@ Mongoid::Fields.option :spatial do |model, field, _options|
10
10
 
11
11
  model.class_eval do
12
12
  spatial_fields << field.name.to_sym
13
- spatial_fields_indexed << field.name.to_sym
14
13
 
15
14
  # Create 2D index
16
15
  spatial_index field.name
@@ -10,7 +10,6 @@ Mongoid::Fields.option :sphere do |model, field, _options|
10
10
 
11
11
  model.class_eval do
12
12
  spatial_fields << field.name.to_sym
13
- spatial_fields_indexed << field.name.to_sym
14
13
 
15
14
  # Create 2Dsphere index
16
15
  spherical_index field.name
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ #
4
+ # `$geoWithin` with a circle. Mongoid ships `within_polygon` and `within_box`
5
+ # and stopped there — the two circle shapes left with Origin, and `Point#radius`
6
+ # / `#radius_sphere` have been building their argument ever since.
7
+ #
8
+ # Bar.where(:location.within_circle => elvis.location.radius(0.05))
9
+ # Bar.where(:location.within_spherical_circle => elvis.location.radius_sphere(5, :km))
10
+ #
11
+ # `$center` reads its radius in the coordinate system's own units (degrees, for
12
+ # a legacy pair); `$centerSphere` reads radians, which is what `radius_sphere`
13
+ # returns. Guarded: if Mongoid ever registers these, its own wins.
14
+ #
15
+ %i[within_circle within_spherical_circle].each do |name|
16
+ next if Symbol.method_defined?(name)
17
+
18
+ operator = name == :within_circle ? '$center' : '$centerSphere'
19
+ Symbol.add_key(name, :override, '$geoWithin', operator)
20
+ end
@@ -3,6 +3,6 @@
3
3
  module Mongoid
4
4
  # Mongoid Geospatial version
5
5
  module Geospatial
6
- VERSION = '7.1.0'
6
+ VERSION = '7.3.0'
7
7
  end
8
8
  end
@@ -5,6 +5,8 @@ require 'active_support/concern' # Explicitly require for `extend ActiveSupport:
5
5
  require 'mongoid/geospatial/helpers/spatial'
6
6
  require 'mongoid/geospatial/helpers/sphere'
7
7
  require 'mongoid/geospatial/helpers/delegate'
8
+ require 'mongoid/geospatial/helpers/geom'
9
+ require 'mongoid/geospatial/keys'
8
10
 
9
11
  module Mongoid
10
12
  #
@@ -69,6 +71,55 @@ module Mongoid
69
71
  require 'mongoid/geospatial/wrappers/georuby'
70
72
  end
71
73
 
74
+ #
75
+ # A [lng, lat] pair, or nothing. `Point.mongoize` is lenient — it reads
76
+ # "nowhere" as [0.0] — and a half pair is a query Mongo cannot answer.
77
+ #
78
+ def self.mongoize_point!(geom)
79
+ coords = Point.mongoize(geom)
80
+ unless coords.is_a?(Array) && coords.size == 2 && coords.all?(Numeric)
81
+ raise ArgumentError, "Invalid coordinates: #{geom.inspect}"
82
+ end
83
+
84
+ coords
85
+ end
86
+
87
+ #
88
+ # A cap Mongo can measure: a positive number of kilometres.
89
+ #
90
+ def self.km!(km) # rubocop:disable Naming/MethodParameterName
91
+ raise ArgumentError, "Invalid km: #{km.inspect}" unless km.is_a?(Numeric) && km.positive?
92
+
93
+ km
94
+ end
95
+
96
+ #
97
+ # `$nearSphere` value with a km cap, GeoJSON. Metres on the wire.
98
+ # This is the 2dsphere shape — see #near_selector for the other one.
99
+ #
100
+ def self.near_query(geom, km) # rubocop:disable Naming/MethodParameterName
101
+ { '$geometry' => { 'type' => 'Point', 'coordinates' => mongoize_point!(geom) },
102
+ '$maxDistance' => km!(km) * 1_000 }
103
+ end
104
+
105
+ #
106
+ # The whole `{ field => ... }` selector for "within +km+", for either index.
107
+ #
108
+ # `$nearSphere` speaks two dialects and the index picks which:
109
+ #
110
+ # 2dsphere { loc: { $nearSphere: { $geometry: {...}, $maxDistance: <m> } } }
111
+ # 2d { loc: { $nearSphere: [x, y], $maxDistance: <rad> } }
112
+ #
113
+ # Hand the wrong one over and the server answers `NoQueryExecutionPlans`,
114
+ # not a wrong count — so ask the field which it is before building.
115
+ #
116
+ def self.near_selector(field, geom, km, sphere: true) # rubocop:disable Naming/MethodParameterName
117
+ return { field => { '$nearSphere' => near_query(geom, km) } } if sphere
118
+
119
+ { field => { '$nearSphere' => mongoize_point!(geom),
120
+ '$maxDistance' => km!(km) / EARTH_RADIUS_KM.to_f } }
121
+ end
122
+
72
123
  # Methods applied to Document's class
73
124
  module ClassMethods
74
125
  #
@@ -78,7 +129,7 @@ module Mongoid
78
129
  # @param options [Hash] Additional options for the index.
79
130
  #
80
131
  def spatial_index(name, options = {})
81
- spatial_fields_indexed << name
132
+ remember_indexed(name)
82
133
  index({ name => '2d' }, options)
83
134
  end
84
135
 
@@ -89,9 +140,26 @@ module Mongoid
89
140
  # @param options [Hash] Additional options for the index.
90
141
  #
91
142
  def spherical_index(name, options = {})
92
- spatial_fields_indexed << name
143
+ remember_indexed(name)
93
144
  index({ name => '2dsphere' }, options)
94
145
  end
146
+ alias sphere_index spherical_index
147
+
148
+ #
149
+ # One field, one entry, always a Symbol — a field may carry both a 2d
150
+ # and a 2dsphere index, and `spatial: true` calls this on its way in too.
151
+ #
152
+ def remember_indexed(name)
153
+ sym = name.to_sym
154
+ spatial_fields_indexed << sym unless spatial_fields_indexed.include?(sym)
155
+ end
156
+
157
+ #
158
+ # A Point on a 2dsphere index. Default name is `geom`.
159
+ #
160
+ def geom(name = :geom)
161
+ field name, type: Point, sphere: true
162
+ end
95
163
 
96
164
  #
97
165
  # # Queries
@@ -201,28 +269,59 @@ module Mongoid
201
269
  #
202
270
  # @param coordinates [Array, Mongoid::Geospatial::Point] The coordinates (e.g., [lon, lat])
203
271
  # or a Point object to find documents near to.
204
- # @param _options [Hash] Optional hash for future extensions (currently unused).
272
+ # @param km [Numeric, nil] Optional cap in kilometres.
205
273
  #
206
274
  # @return [Mongoid::Criteria] A criteria object for the query.
207
275
  #
208
276
  # Example:
209
277
  # Bar.nearby([10, 20])
210
- # Alarm.nearby(my_point_object)
278
+ # Alarm.nearby(my_point_object, km: 30)
211
279
  #
212
- def nearby(coordinates, _options = {})
213
- if spatial_fields.empty?
214
- raise "No spatial fields defined for #{name} to use with .nearby. " \
215
- "Mark a field with 'spatial: true' or 'sphere: true'."
216
- end
280
+ def nearby(coordinates, km: nil, field: nil) # rubocop:disable Naming/MethodParameterName
281
+ pin, sphere = spatial_field(field)
282
+ return criteria.where(Mongoid::Geospatial.near_selector(pin, coordinates, km, sphere: sphere)) if km
217
283
 
218
- field_name_sym = spatial_fields.first.to_sym
219
- field_definition = fields[field_name_sym.to_s]
284
+ criteria.where(pin.send(sphere ? :near_sphere : :near) => coordinates)
285
+ end
220
286
 
221
- raise "Could not find field definition for spatial field: #{field_name_sym}" unless field_definition
287
+ #
288
+ # The pin `.nearby`, `.within` and `.nearest` read, and whether it is on
289
+ # a sphere. Handed nothing, the first spatial field — a model with two
290
+ # pins (`geom :pick_up`, `geom :drop_up`) has to name the one it means.
291
+ #
292
+ # @return [Array] [field name as a Symbol, sphere?]
293
+ #
294
+ def spatial_field(field = nil)
295
+ sym = (field || spatial_fields.first)&.to_sym
296
+ unless sym && spatial_fields.include?(sym)
297
+ raise ArgumentError, "#{name} has no spatial field #{sym.inspect} — it has #{spatial_fields.inspect}. " \
298
+ "Mark one with 'spatial: true' or 'sphere: true'."
299
+ end
222
300
 
223
- query_operator = field_definition.options[:sphere] ? :near_sphere : :near
301
+ [sym, fields.fetch(sym.to_s).options[:sphere] ? true : false]
302
+ end
224
303
 
225
- criteria.where(field_name_sym.send(query_operator) => coordinates)
304
+ #
305
+ # Documents within +km+ of +geom+, nearest first. Spherical either way:
306
+ # the field's index decides the dialect, see .near_selector.
307
+ #
308
+ def within(geom, km, field: nil) # rubocop:disable Naming/MethodParameterName
309
+ nearby(geom, km: Mongoid::Geospatial.km!(km), field: field)
310
+ end
311
+
312
+ #
313
+ # The single closest document within +km+, or nil.
314
+ #
315
+ # `within(geom, km).first` NOT the nearest one
316
+ # `nearest(geom, km)` the nearest one
317
+ #
318
+ # Mongoid's #first and #last sort by `_id` when the criteria carries no
319
+ # sort of its own (contextual/mongo.rb, `view.sort || { _id: 1 }`), and
320
+ # that _id sort replaces the distance order `$near` put there. It reads
321
+ # as working every time the closest document happens to be the oldest.
322
+ #
323
+ def nearest(geom, km, field: nil) # rubocop:disable Naming/MethodParameterName
324
+ within(geom, km, field: field).limit(1).to_a.first
226
325
  end
227
326
 
228
327
  # Performs a $geoNear aggregation pipeline stage to find documents near a point,
@@ -252,10 +351,12 @@ module Mongoid
252
351
  # geometries (e.g., a Polygon), as it shows which specific point was used for the distance calculation.
253
352
  # Example: `includeLocs: 'matchedPoint'` would add a `matchedPoint` field to each output document.
254
353
  #
255
- # @return [Array<Mongoid::Document>] An array of instantiated Mongoid documents.
256
- # Each document will include its original fields plus any fields added by the `$geoNear` stage,
257
- # such as the field specified by `:distanceField` (e.g., `document.distance`) and `:includeLocs`.
258
- # These additional fields are accessible as dynamic attributes on the model instances.
354
+ # @return [Mongo::Collection::View::Aggregation] The raw pipeline result it
355
+ # yields `BSON::Document` hashes, NOT model instances, so read a field with
356
+ # `doc['name']` and the distance with `doc['distance']` (or whatever
357
+ # `:distanceField` was set to). Nothing is instantiated: `$geoNear` adds fields
358
+ # a document does not have, and a Point field comes back as a raw pair.
359
+ # Need models? `.map { |attrs| Model.instantiate(attrs) }` at the call site.
259
360
  #
260
361
  # @raise [ArgumentError] If coordinates cannot be mongoized.
261
362
  #
@@ -268,20 +369,26 @@ module Mongoid
268
369
  # query: { category: 'restaurant' },
269
370
  # limit: 10)
270
371
  #
271
- # # Iterate over results
272
- # Place.geo_near(:location, [10, 20], spherical: true).each do |place|
273
- # puts "#{place.name} is #{place.distance} meters away." # Assumes distanceField is 'distance'
372
+ # # Iterate over results — hashes, not documents
373
+ # Place.geo_near(:location, [10, 20], spherical: true).each do |doc|
374
+ # puts "#{doc['name']} is #{doc['distance']} meters away."
274
375
  # end
275
376
  #
276
377
  def geo_near(field_name, coordinates, options = {})
277
- mongoized_coords = Mongoid::Geospatial::Point.mongoize(coordinates)
278
-
279
- raise ArgumentError, "Invalid coordinates provided: #{coordinates.inspect}" unless mongoized_coords
378
+ mongoized_coords = Mongoid::Geospatial.mongoize_point!(coordinates)
280
379
 
281
380
  # User-provided options. Work with a copy.
282
381
  user_options = options.dup
283
382
  limit_value = user_options.delete(:limit) # Handled by a separate pipeline stage
284
383
 
384
+ # `km:` is metres on the wire, and metres only holds for a GeoJSON
385
+ # `near` on a sphere. A legacy pair would read `maxDistance` in radians.
386
+ if (km = user_options.delete(:km))
387
+ user_options[:maxDistance] = km.to_f * 1_000
388
+ user_options[:spherical] = true
389
+ mongoized_coords = { 'type' => 'Point', 'coordinates' => mongoized_coords }
390
+ end
391
+
285
392
  # Core $geoNear parameters derived from method arguments, these are not overrideable by user_options.
286
393
  geo_near_core_params = {
287
394
  key: field_name.to_s,
@@ -297,10 +404,8 @@ module Mongoid
297
404
  # Merge user options over defaults, then ensure core parameters are set.
298
405
  geo_near_stage_options = geo_near_defaultable_params.merge(user_options).merge(geo_near_core_params)
299
406
 
300
- # Ensure :spherical is a strict boolean (true/false).
301
- # If user_options provided :spherical, it's already set. If not, the default is used.
302
- # This line ensures the final value is strictly true or false, not just truthy/falsy.
303
- geo_near_stage_options[:spherical] = !geo_near_stage_options[:spherical].nil?
407
+ # $geoNear wants a strict boolean, and honours the caller's choice.
408
+ geo_near_stage_options[:spherical] = geo_near_stage_options[:spherical] ? true : false
304
409
 
305
410
  # Note on performance:
306
411
  # $geoNear is an aggregation pipeline stage. For simple proximity queries,
@@ -315,15 +420,7 @@ module Mongoid
315
420
  # Add $limit stage if limit_value was provided
316
421
  pipeline << { '$limit' => limit_value.to_i } if limit_value
317
422
 
318
- # Execute the aggregation pipeline
319
423
  collection.aggregate(pipeline)
320
-
321
- # Don't instantiate results here.
322
- # aggregated_results = collection.aggregate(pipeline)
323
- # Map the raw Hash results from aggregation to Mongoid model instances.
324
- # Mongoid's #instantiate method correctly handles creating model objects
325
- # and assigning attributes, including dynamic ones like the distanceField.
326
- # aggregated_results.map { |attrs| instantiate(attrs) }
327
424
  end
328
425
  end
329
426
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mongoid-geospatial
3
3
  version: !ruby/object:Gem::Version
4
- version: 7.1.0
4
+ version: 7.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ryan Ong
@@ -16,14 +16,14 @@ dependencies:
16
16
  requirements:
17
17
  - - ">="
18
18
  - !ruby/object:Gem::Version
19
- version: 4.0.0
19
+ version: 7.0.0
20
20
  type: :runtime
21
21
  prerelease: false
22
22
  version_requirements: !ruby/object:Gem::Requirement
23
23
  requirements:
24
24
  - - ">="
25
25
  - !ruby/object:Gem::Version
26
- version: 4.0.0
26
+ version: 7.0.0
27
27
  description: Mongoid Extension that simplifies MongoDB casting and operations on spatial
28
28
  Ruby objects.
29
29
  email:
@@ -45,8 +45,10 @@ files:
45
45
  - lib/mongoid/geospatial/fields/polygon.rb
46
46
  - lib/mongoid/geospatial/geometry_field.rb
47
47
  - lib/mongoid/geospatial/helpers/delegate.rb
48
+ - lib/mongoid/geospatial/helpers/geom.rb
48
49
  - lib/mongoid/geospatial/helpers/spatial.rb
49
50
  - lib/mongoid/geospatial/helpers/sphere.rb
51
+ - lib/mongoid/geospatial/keys.rb
50
52
  - lib/mongoid/geospatial/version.rb
51
53
  - lib/mongoid/geospatial/wrappers/georuby.rb
52
54
  - lib/mongoid/geospatial/wrappers/rgeo.rb