-
Notifications
You must be signed in to change notification settings - Fork 95
/
acts_as_mappable.rb
356 lines (294 loc) · 13 KB
/
acts_as_mappable.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
require 'active_record'
require 'active_support/concern'
module Geokit
module ActsAsMappable
class UnsupportedAdapter < StandardError ; end
# Add the +acts_as_mappable+ method into ActiveRecord subclasses
module Glue # :nodoc:
extend ActiveSupport::Concern
module ClassMethods # :nodoc:
def acts_as_mappable(options = {})
metaclass = (class << self; self; end)
include Geokit::ActsAsMappable
cattr_accessor :through
self.through = options[:through]
if reflection = Geokit::ActsAsMappable.end_of_reflection_chain(self.through, self)
metaclass.instance_eval do
[ :distance_column_name, :default_units, :default_formula, :lat_column_name, :lng_column_name, :qualified_lat_column_name, :qualified_lng_column_name ].each do |method_name|
define_method method_name do
reflection.klass.send(method_name)
end
end
end
else
cattr_accessor :distance_column_name, :default_units, :default_formula, :lat_column_name, :lng_column_name, :qualified_lat_column_name, :qualified_lng_column_name
self.distance_column_name = options[:distance_column_name] || 'distance'
self.default_units = options[:default_units] || Geokit::default_units
self.default_formula = options[:default_formula] || Geokit::default_formula
self.lat_column_name = options[:lat_column_name] || 'lat'
self.lng_column_name = options[:lng_column_name] || 'lng'
self.qualified_lat_column_name = "#{table_name}.#{lat_column_name}"
self.qualified_lng_column_name = "#{table_name}.#{lng_column_name}"
if options.include?(:auto_geocode) && options[:auto_geocode]
# if the form auto_geocode=>true is used, let the defaults take over by suppling an empty hash
options[:auto_geocode] = {} if options[:auto_geocode] == true
cattr_accessor :auto_geocode_field, :auto_geocode_error_message
self.auto_geocode_field = options[:auto_geocode][:field] || 'address'
self.auto_geocode_error_message = options[:auto_geocode][:error_message] || 'could not locate address'
# set the actual callback here
before_validation :auto_geocode_address, :on => :create
end
end
end
end
end # Glue
class Relation < ActiveRecord::Relation
attr_accessor :distance_formula
def where(opts, *rest)
return self if opts.blank?
relation = clone
where_values = build_where(opts, rest)
relation.where_values += substitute_distance_in_values(where_values)
relation
end
def order(*args)
return self if args.blank?
relation = clone
order_values = args.flatten
relation.order_values += substitute_distance_in_values(order_values)
relation
end
private
def substitute_distance_in_values(values)
return values unless @distance_formula
# substitute distance with the actual distance equation
pattern = Regexp.new("\\b#{@klass.distance_column_name}\\b")
values.map {|value| value.is_a?(String) ? value.gsub(pattern, @distance_formula) : value }
end
end
extend ActiveSupport::Concern
included do
include Geokit::Mappable
end
# Class methods included in models when +acts_as_mappable+ is called
module ClassMethods
# A proxy to an instance of a finder adapter, inferred from the connection's adapter.
def adapter
@adapter ||= begin
require File.join(File.dirname(__FILE__), 'adapters', connection.adapter_name.downcase)
klass = Adapters.const_get(connection.adapter_name.camelcase)
klass.load(self) unless klass.loaded
klass.new(self)
rescue LoadError
raise UnsupportedAdapter, "`#{connection.adapter_name.downcase}` is not a supported adapter."
end
end
def within(distance, options = {})
options[:within] = distance
geo_scope(options)
end
alias inside within
def beyond(distance, options = {})
options[:beyond] = distance
geo_scope(options)
end
alias outside beyond
def in_range(range, options = {})
options[:range] = range
geo_scope(options)
end
def in_bounds(bounds, options = {})
options[:bounds] = bounds
geo_scope(options)
end
def by_distance(options = {})
geo_scope(options).order("#{distance_column_name} asc")
end
def closest(options = {})
by_distance(options).first(1)
end
alias nearest closest
def farthest(options = {})
by_distance(options).last(1)
end
def geo_scope(options = {})
arel = self.is_a?(ActiveRecord::Relation) ? self : self.scoped
origin = extract_origin_from_options(options)
units = extract_units_from_options(options)
formula = extract_formula_from_options(options)
bounds = extract_bounds_from_options(options)
if origin || bounds
bounds = formulate_bounds_from_distance(options, origin, units) unless bounds
if origin
arel.distance_formula = distance_sql(origin, units, formula)
if arel.select_values.blank?
star_select = Arel::SqlLiteral.new(arel.quoted_table_name + '.*')
arel = arel.select(star_select)
end
end
if bounds
bound_conditions = bound_conditions(bounds)
arel = arel.where(bound_conditions) if bound_conditions
end
distance_conditions = distance_conditions(options)
arel = arel.where(distance_conditions) if distance_conditions
if self.through
arel = arel.includes(self.through)
end
end
arel
end
# Returns the distance calculation to be used as a display column or a condition. This
# is provide for anyone wanting access to the raw SQL.
def distance_sql(origin, units=default_units, formula=default_formula)
case formula
when :sphere
sql = sphere_distance_sql(origin, units)
when :flat
sql = flat_distance_sql(origin, units)
end
sql
end
private
# Override ActiveRecord::Base.relation to return an instance of Geokit::ActsAsMappable::Relation.
# TODO: Do we need to override JoinDependency#relation too?
def relation
# NOTE: This cannot be @relation as ActiveRecord already uses this to
# cache *its* Relation object
@_geokit_relation ||= Relation.new(self, arel_table)
finder_needs_type_condition? ? @_geokit_relation.where(type_condition) : @_geokit_relation
end
# If it's a :within query, add a bounding box to improve performance.
# This only gets called if a :bounds argument is not otherwise supplied.
def formulate_bounds_from_distance(options, origin, units)
distance = options[:within] if options.has_key?(:within)
distance = options[:range].last-(options[:range].exclude_end?? 1 : 0) if options.has_key?(:range)
if distance
res=Geokit::Bounds.from_point_and_radius(origin,distance,:units=>units)
else
nil
end
end
def distance_conditions(options)
res = if options.has_key?(:within)
"#{distance_column_name} <= #{options[:within]}"
elsif options.has_key?(:beyond)
"#{distance_column_name} > #{options[:beyond]}"
elsif options.has_key?(:range)
"#{distance_column_name} >= #{options[:range].first} AND #{distance_column_name} <#{'=' unless options[:range].exclude_end?} #{options[:range].last}"
end
Arel::SqlLiteral.new("(#{res})") if res.present?
end
def bound_conditions(bounds)
sw,ne = bounds.sw, bounds.ne
lng_sql = bounds.crosses_meridian? ? "(#{qualified_lng_column_name}<#{ne.lng} OR #{qualified_lng_column_name}>#{sw.lng})" : "#{qualified_lng_column_name}>#{sw.lng} AND #{qualified_lng_column_name}<#{ne.lng}"
res = "#{qualified_lat_column_name}>#{sw.lat} AND #{qualified_lat_column_name}<#{ne.lat} AND #{lng_sql}"
Arel::SqlLiteral.new("(#{res})") if res.present?
end
# Extracts the origin instance out of the options if it exists and returns
# it. If there is no origin, looks for latitude and longitude values to
# create an origin. The side-effect of the method is to remove these
# option keys from the hash.
def extract_origin_from_options(options)
origin = options.delete(:origin)
res = normalize_point_to_lat_lng(origin) if origin
res
end
# Extract the units out of the options if it exists and returns it. If
# there is no :units key, it uses the default. The side effect of the
# method is to remove the :units key from the options hash.
def extract_units_from_options(options)
units = options[:units] || default_units
options.delete(:units)
units
end
# Extract the formula out of the options if it exists and returns it. If
# there is no :formula key, it uses the default. The side effect of the
# method is to remove the :formula key from the options hash.
def extract_formula_from_options(options)
formula = options[:formula] || default_formula
options.delete(:formula)
formula
end
def extract_bounds_from_options(options)
bounds = options.delete(:bounds)
bounds = Geokit::Bounds.normalize(bounds) if bounds
end
# Geocode IP address.
def geocode_ip_address(origin)
geo_location = Geokit::Geocoders::MultiGeocoder.geocode(origin)
return geo_location if geo_location.success
raise Geokit::Geocoders::GeocodeError
end
# Given a point in a variety of (an address to geocode,
# an array of [lat,lng], or an object with appropriate lat/lng methods, an IP addres)
# this method will normalize it into a Geokit::LatLng instance. The only thing this
# method adds on top of LatLng#normalize is handling of IP addresses
def normalize_point_to_lat_lng(point)
res = geocode_ip_address(point) if point.is_a?(String) && /^(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})?$/.match(point)
res = Geokit::LatLng.normalize(point) unless res
res
end
# Looks for the distance column and replaces it with the distance sql. If an origin was not
# passed in and the distance column exists, we leave it to be flagged as bad SQL by the database.
# Conditions are either a string or an array. In the case of an array, the first entry contains
# the condition.
def substitute_distance_in_where_values(arel, origin, units=default_units, formula=default_formula)
pattern = Regexp.new("\\b#{distance_column_name}\\b")
value = distance_sql(origin, units, formula)
arel.where_values.map! do |where_value|
if where_value.is_a?(String)
where_value.gsub(pattern, value)
else
where_value
end
end
arel
end
# Returns the distance SQL using the spherical world formula (Haversine). The SQL is tuned
# to the database in use.
def sphere_distance_sql(origin, units)
lat = deg2rad(origin.lat)
lng = deg2rad(origin.lng)
multiplier = units_sphere_multiplier(units)
adapter.sphere_distance_sql(lat, lng, multiplier) if adapter
end
# Returns the distance SQL using the flat-world formula (Phythagorean Theory). The SQL is tuned
# to the database in use.
def flat_distance_sql(origin, units)
lat_degree_units = units_per_latitude_degree(units)
lng_degree_units = units_per_longitude_degree(origin.lat, units)
adapter.flat_distance_sql(origin, lat_degree_units, lng_degree_units)
end
end # ClassMethods
# this is the callback for auto_geocoding
def auto_geocode_address
address=self.send(auto_geocode_field).to_s
geo=Geokit::Geocoders::MultiGeocoder.geocode(address)
if geo.success
self.send("#{lat_column_name}=", geo.lat)
self.send("#{lng_column_name}=", geo.lng)
else
errors.add(auto_geocode_field, auto_geocode_error_message)
end
geo.success
end
def self.end_of_reflection_chain(through, klass)
while through
reflection = nil
if through.is_a?(Hash)
association, through = through.to_a.first
else
association, through = through, nil
end
if reflection = klass.reflect_on_association(association)
klass = reflection.klass
else
raise ArgumentError, "You gave #{association} in :through, but I could not find it on #{klass}."
end
end
reflection
end
end # ActsAsMappable
end # Geokit
# ActiveRecord::Base.extend Geokit::ActsAsMappable