2 # Contains the class method acts_as_mappable targeted to be mixed into ActiveRecord.
3 # When mixed in, augments find services such that they provide distance calculation
4 # query services. The find method accepts additional options:
7 # 1. a two-element array of latititude/longitude -- :origin=>[37.792,-122.393]
8 # 2. a geocodeable string -- :origin=>'100 Spear st, San Francisco, CA'
9 # 3. an object which responds to lat and lng methods, or latitude and longitude methods,
10 # or whatever methods you have specified for lng_column_name and lat_column_name
12 # Other finder methods are provided for specific queries. These are:
14 # * find_within (alias: find_inside)
15 # * find_beyond (alias: find_outside)
16 # * find_closest (alias: find_nearest)
19 # Counter methods are available and work similarly to finders.
21 # If raw SQL is desired, the distance_sql method can be used to obtain SQL appropriate
22 # to use in a find_by_sql call.
24 # Mix below class methods into ActiveRecord.
25 def self.included(base) # :nodoc:
26 base.extend ClassMethods
29 # Class method to mix into active record.
30 module ClassMethods # :nodoc:
31 # Class method to bring distance query support into ActiveRecord models. By default
32 # uses :miles for distance units and performs calculations based upon the Haversine
33 # (sphere) formula. These can be changed by setting GeoKit::default_units and
34 # GeoKit::default_formula. Also, by default, uses lat, lng, and distance for respective
35 # column names. All of these can be overridden using the :default_units, :default_formula,
36 # :lat_column_name, :lng_column_name, and :distance_column_name hash keys.
38 # Can also use to auto-geocode a specific column on create. Syntax;
40 # acts_as_mappable :auto_geocode=>true
42 # By default, it tries to geocode the "address" field. Or, for more customized behavior:
44 # acts_as_mappable :auto_geocode=>{:field=>:address,:error_message=>'bad address'}
46 # In both cases, it creates a before_validation_on_create callback to geocode the given column.
47 # For anything more customized, we recommend you forgo the auto_geocode option
48 # and create your own AR callback to handle geocoding.
49 def acts_as_mappable(options = {})
50 # Mix in the module, but ensure to do so just once.
51 return if self.included_modules.include?(GeoKit::ActsAsMappable::InstanceMethods)
52 send :include, GeoKit::ActsAsMappable::InstanceMethods
53 # include the Mappable module.
54 send :include, Mappable
56 # Handle class variables.
57 cattr_accessor :distance_column_name, :default_units, :default_formula, :lat_column_name, :lng_column_name, :qualified_lat_column_name, :qualified_lng_column_name
58 self.distance_column_name = options[:distance_column_name] || 'distance'
59 self.default_units = options[:default_units] || GeoKit::default_units
60 self.default_formula = options[:default_formula] || GeoKit::default_formula
61 self.lat_column_name = options[:lat_column_name] || 'lat'
62 self.lng_column_name = options[:lng_column_name] || 'lng'
63 self.qualified_lat_column_name = "#{table_name}.#{lat_column_name}"
64 self.qualified_lng_column_name = "#{table_name}.#{lng_column_name}"
65 if options.include?(:auto_geocode) && options[:auto_geocode]
66 # if the form auto_geocode=>true is used, let the defaults take over by suppling an empty hash
67 options[:auto_geocode] = {} if options[:auto_geocode] == true
68 cattr_accessor :auto_geocode_field, :auto_geocode_error_message
69 self.auto_geocode_field = options[:auto_geocode][:field] || 'address'
70 self.auto_geocode_error_message = options[:auto_geocode][:error_message] || 'could not locate address'
72 # set the actual callback here
73 before_validation_on_create :auto_geocode_address
78 # this is the callback for auto_geocoding
79 def auto_geocode_address
80 address=self.send(auto_geocode_field)
81 geo=GeoKit::Geocoders::MultiGeocoder.geocode(address)
84 self.send("#{lat_column_name}=", geo.lat)
85 self.send("#{lng_column_name}=", geo.lng)
87 errors.add(auto_geocode_field, auto_geocode_error_message)
93 # Instance methods to mix into ActiveRecord.
94 module InstanceMethods #:nodoc:
95 # Mix class methods into module.
96 def self.included(base) # :nodoc:
97 base.extend SingletonMethods
100 # Class singleton methods to mix into ActiveRecord.
101 module SingletonMethods # :nodoc:
102 # Extends the existing find method in potentially two ways:
103 # - If a mappable instance exists in the options, adds a distance column.
104 # - If a mappable instance exists in the options and the distance column exists in the
105 # conditions, substitutes the distance sql for the distance column -- this saves
106 # having to write the gory SQL.
108 prepare_for_find_or_count(:find, args)
112 # Extends the existing count method by:
113 # - If a mappable instance exists in the options and the distance column exists in the
114 # conditions, substitutes the distance sql for the distance column -- this saves
115 # having to write the gory SQL.
117 prepare_for_find_or_count(:count, args)
121 # Finds within a distance radius.
122 def find_within(distance, options={})
123 options[:within] = distance
126 alias find_inside find_within
128 # Finds beyond a distance radius.
129 def find_beyond(distance, options={})
130 options[:beyond] = distance
133 alias find_outside find_beyond
135 # Finds according to a range. Accepts inclusive or exclusive ranges.
136 def find_by_range(range, options={})
137 options[:range] = range
141 # Finds the closest to the origin.
142 def find_closest(options={})
143 find(:nearest, options)
145 alias find_nearest find_closest
147 # Finds the farthest from the origin.
148 def find_farthest(options={})
149 find(:farthest, options)
152 # Finds within rectangular bounds (sw,ne).
153 def find_within_bounds(bounds, options={})
154 options[:bounds] = bounds
158 # counts within a distance radius.
159 def count_within(distance, options={})
160 options[:within] = distance
163 alias count_inside count_within
165 # Counts beyond a distance radius.
166 def count_beyond(distance, options={})
167 options[:beyond] = distance
170 alias count_outside count_beyond
172 # Counts according to a range. Accepts inclusive or exclusive ranges.
173 def count_by_range(range, options={})
174 options[:range] = range
178 # Finds within rectangular bounds (sw,ne).
179 def count_within_bounds(bounds, options={})
180 options[:bounds] = bounds
184 # Returns the distance calculation to be used as a display column or a condition. This
185 # is provide for anyone wanting access to the raw SQL.
186 def distance_sql(origin, units=default_units, formula=default_formula)
189 sql = sphere_distance_sql(origin, units)
191 sql = flat_distance_sql(origin, units)
198 # Prepares either a find or a count action by parsing through the options and
199 # conditionally adding to the select clause for finders.
200 def prepare_for_find_or_count(action, args)
201 options = extract_options_from_args!(args)
202 # Obtain items affecting distance condition.
203 origin = extract_origin_from_options(options)
204 units = extract_units_from_options(options)
205 formula = extract_formula_from_options(options)
206 bounds = extract_bounds_from_options(options)
207 # if no explicit bounds were given, try formulating them from the point and distance given
208 bounds = formulate_bounds_from_distance(options, origin, units) unless bounds
209 # Apply select adjustments based upon action.
210 add_distance_to_select(options, origin, units, formula) if origin && action == :find
211 # Apply the conditions for a bounding rectangle if applicable
212 apply_bounds_conditions(options,bounds) if bounds
213 # Apply distance scoping and perform substitutions.
214 apply_distance_scope(options)
215 substitute_distance_in_conditions(options, origin, units, formula) if origin && options.has_key?(:conditions)
216 # Order by scoping for find action.
217 apply_find_scope(args, options) if action == :find
218 # Unfortunatley, we need to do extra work if you use an :include. See the method for more info.
219 handle_order_with_include(options,origin,units,formula) if options.include?(:include) && options.include?(:order) && origin
220 # Restore options minus the extra options that we used for the
225 # If we're here, it means that 1) an origin argument, 2) an :include, 3) an :order clause were supplied.
226 # Now we have to sub some SQL into the :order clause. The reason is that when you do an :include,
227 # ActiveRecord drops the psuedo-column (specificically, distance) which we supplied for :select.
228 # So, the 'distance' column isn't available for the :order clause to reference when we use :include.
229 def handle_order_with_include(options, origin, units, formula)
230 # replace the distance_column_name with the distance sql in order clause
231 options[:order].sub!(distance_column_name, distance_sql(origin, units, formula))
234 # Looks for mapping-specific tokens and makes appropriate translations so that the
235 # original finder has its expected arguments. Resets the the scope argument to
236 # :first and ensures the limit is set to one.
237 def apply_find_scope(args, options)
242 options[:order] = "#{distance_column_name} ASC"
246 options[:order] = "#{distance_column_name} DESC"
250 # If it's a :within query, add a bounding box to improve performance.
251 # This only gets called if a :bounds argument is not otherwise supplied.
252 def formulate_bounds_from_distance(options, origin, units)
253 distance = options[:within] if options.has_key?(:within)
254 distance = options[:range].last-(options[:range].exclude_end?? 1 : 0) if options.has_key?(:range)
256 res=GeoKit::Bounds.from_point_and_radius(origin,distance,:units=>units)
262 # Replace :within, :beyond and :range distance tokens with the appropriate distance
263 # where clauses. Removes these tokens from the options hash.
264 def apply_distance_scope(options)
265 distance_condition = "#{distance_column_name} <= #{options[:within]}" if options.has_key?(:within)
266 distance_condition = "#{distance_column_name} > #{options[:beyond]}" if options.has_key?(:beyond)
267 distance_condition = "#{distance_column_name} >= #{options[:range].first} AND #{distance_column_name} <#{'=' unless options[:range].exclude_end?} #{options[:range].last}" if options.has_key?(:range)
268 [:within, :beyond, :range].each { |option| options.delete(option) } if distance_condition
270 options[:conditions]=augment_conditions(options[:conditions],distance_condition) if distance_condition
273 # This method lets you transparently add a new condition to a query without
274 # worrying about whether it currently has conditions, or what kind of conditions they are
277 # Takes the current conditions (which can be an array or a string, or can be nil/false),
278 # and a SQL string. It inserts the sql into the existing conditions, and returns new conditions
279 # (which can be a string or an array
280 def augment_conditions(current_conditions,sql)
281 if current_conditions && current_conditions.is_a?(String)
282 res="#{current_conditions} AND #{sql}"
283 elsif current_conditions && current_conditions.is_a?(Array)
284 current_conditions[0]="#{current_conditions[0]} AND #{sql}"
285 res=current_conditions
292 # Alters the conditions to include rectangular bounds conditions.
293 def apply_bounds_conditions(options,bounds)
294 sw,ne=bounds.sw,bounds.ne
295 lng_sql= bounds.crosses_meridian? ? "#{qualified_lng_column_name}<#{sw.lng} OR #{qualified_lng_column_name}>#{ne.lng}" : "#{qualified_lng_column_name}>#{sw.lng} AND #{qualified_lng_column_name}<#{ne.lng}"
296 bounds_sql="#{qualified_lat_column_name}>#{sw.lat} AND #{qualified_lat_column_name}<#{ne.lat} AND #{lng_sql}"
297 options[:conditions]=augment_conditions(options[:conditions],bounds_sql)
300 # Extracts the origin instance out of the options if it exists and returns
301 # it. If there is no origin, looks for latitude and longitude values to
302 # create an origin. The side-effect of the method is to remove these
303 # option keys from the hash.
304 def extract_origin_from_options(options)
305 origin = options.delete(:origin)
306 res = normalize_point_to_lat_lng(origin) if origin
310 # Extract the units out of the options if it exists and returns it. If
311 # there is no :units key, it uses the default. The side effect of the
312 # method is to remove the :units key from the options hash.
313 def extract_units_from_options(options)
314 units = options[:units] || default_units
315 options.delete(:units)
319 # Extract the formula out of the options if it exists and returns it. If
320 # there is no :formula key, it uses the default. The side effect of the
321 # method is to remove the :formula key from the options hash.
322 def extract_formula_from_options(options)
323 formula = options[:formula] || default_formula
324 options.delete(:formula)
328 def extract_bounds_from_options(options)
329 bounds = options.delete(:bounds)
330 bounds = GeoKit::Bounds.normalize(bounds) if bounds
333 # Geocode IP address.
334 def geocode_ip_address(origin)
335 geo_location = GeoKit::Geocoders::IpGeocoder.geocode(origin)
336 return geo_location if geo_location.success
337 raise GeoKit::Geocoders::GeocodeError
341 # Given a point in a variety of (an address to geocode,
342 # an array of [lat,lng], or an object with appropriate lat/lng methods, an IP addres)
343 # this method will normalize it into a GeoKit::LatLng instance. The only thing this
344 # method adds on top of LatLng#normalize is handling of IP addresses
345 def normalize_point_to_lat_lng(point)
346 res = geocode_ip_address(point) if point.is_a?(String) && /^(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})?$/.match(point)
347 res = GeoKit::LatLng.normalize(point) unless res
351 # Augments the select with the distance SQL.
352 def add_distance_to_select(options, origin, units=default_units, formula=default_formula)
354 distance_selector = distance_sql(origin, units, formula) + " AS #{distance_column_name}"
355 selector = options.has_key?(:select) && options[:select] ? options[:select] : "*"
356 options[:select] = "#{selector}, #{distance_selector}"
360 # Looks for the distance column and replaces it with the distance sql. If an origin was not
361 # passed in and the distance column exists, we leave it to be flagged as bad SQL by the database.
362 # Conditions are either a string or an array. In the case of an array, the first entry contains
364 def substitute_distance_in_conditions(options, origin, units=default_units, formula=default_formula)
365 original_conditions = options[:conditions]
366 condition = original_conditions.is_a?(String) ? original_conditions : original_conditions.first
367 pattern = Regexp.new("\s*#{distance_column_name}(\s<>=)*")
368 condition = condition.gsub(pattern, distance_sql(origin, units, formula))
369 original_conditions = condition if original_conditions.is_a?(String)
370 original_conditions[0] = condition if original_conditions.is_a?(Array)
371 options[:conditions] = original_conditions
374 # Returns the distance SQL using the spherical world formula (Haversine). The SQL is tuned
375 # to the database in use.
376 def sphere_distance_sql(origin, units)
377 lat = deg2rad(origin.lat)
378 lng = deg2rad(origin.lng)
379 multiplier = units_sphere_multiplier(units)
380 case connection.adapter_name.downcase
383 (ACOS(COS(#{lat})*COS(#{lng})*COS(RADIANS(#{qualified_lat_column_name}))*COS(RADIANS(#{qualified_lng_column_name}))+
384 COS(#{lat})*SIN(#{lng})*COS(RADIANS(#{qualified_lat_column_name}))*SIN(RADIANS(#{qualified_lng_column_name}))+
385 SIN(#{lat})*SIN(RADIANS(#{qualified_lat_column_name})))*#{multiplier})
389 (ACOS(COS(#{lat})*COS(#{lng})*COS(RADIANS(#{qualified_lat_column_name}))*COS(RADIANS(#{qualified_lng_column_name}))+
390 COS(#{lat})*SIN(#{lng})*COS(RADIANS(#{qualified_lat_column_name}))*SIN(RADIANS(#{qualified_lng_column_name}))+
391 SIN(#{lat})*SIN(RADIANS(#{qualified_lat_column_name})))*#{multiplier})
394 sql = "unhandled #{connection.adapter_name.downcase} adapter"
398 # Returns the distance SQL using the flat-world formula (Phythagorean Theory). The SQL is tuned
399 # to the database in use.
400 def flat_distance_sql(origin, units)
401 lat_degree_units = units_per_latitude_degree(units)
402 lng_degree_units = units_per_longitude_degree(origin.lat, units)
403 case connection.adapter_name.downcase
406 SQRT(POW(#{lat_degree_units}*(#{origin.lat}-#{qualified_lat_column_name}),2)+
407 POW(#{lng_degree_units}*(#{origin.lng}-#{qualified_lng_column_name}),2))
411 SQRT(POW(#{lat_degree_units}*(#{origin.lat}-#{qualified_lat_column_name}),2)+
412 POW(#{lng_degree_units}*(#{origin.lng}-#{qualified_lng_column_name}),2))
415 sql = "unhandled #{connection.adapter_name.downcase} adapter"
423 # Extend Array with a sort_by_distance method.
424 # This method creates a "distance" attribute on each object,
425 # calculates the distance from the passed origin,
426 # and finally sorts the array by the resulting distance.
428 def sort_by_distance_from(origin, opts={})
429 distance_attribute_name = opts.delete(:distance_attribute_name) || 'distance'
431 e.class.send(:attr_accessor, distance_attribute_name) if !e.respond_to? "#{distance_attribute_name}="
432 e.send("#{distance_attribute_name}=", origin.distance_to(e,opts))
434 self.sort!{|a,b|a.send(distance_attribute_name) <=> b.send(distance_attribute_name)}