From 03f8e549b55fae3b93dd8ffb38b02e0f81760535 Mon Sep 17 00:00:00 2001
From: Alex Reisner <alex@alexreisner.com>
Date: Wed, 30 Sep 2009 23:38:30 -0400
Subject: [PATCH] Generate options to ActiveRecord.find rather than raw SQL for
 finding records near a given location.

---
 README.rdoc     | 19 +++---------
 lib/geocoder.rb | 78 ++++++++++++++++++++++++++-----------------------
 2 files changed, 46 insertions(+), 51 deletions(-)

diff --git a/README.rdoc b/README.rdoc
index 6539add0..873631ee 100644
--- a/README.rdoc
+++ b/README.rdoc
@@ -1,9 +1,6 @@
 = Geocoder
 
-Geocoder is a simple plugin for Rails that provides object geocoding (via
-Google Maps) and some utilities for working with geocoded objects. The code can
-be used as a standalone method provider or included in a class to give objects
-geographic awareness.
+Geocoder is a simple plugin for Rails that provides object geocoding (via Google Maps) and some utilities for working with geocoded objects. The code can be used as a standalone method provider or included in a class to give objects geographic awareness.
 
 == Setup
 
@@ -15,14 +12,11 @@ To add geocoding features to a class:
 
   geocoded_by :location
 
-Be sure your class defines read/write attributes +latitude+ and +longitude+ as
-well as a method called +location+ (or whatever name you pass to +geocoded_by+)
-that returns a string suitable for passing to a Google Maps search, for example:
+Be sure your class defines read/write attributes +latitude+ and +longitude+ as well as a method called +location+ (or whatever name you pass to +geocoded_by+) that returns a string suitable for passing to a Google Maps search, for example:
 
   714 Green St, Big Town, MO
 
-If your model has +address+, +city+, +state+, and +country+ attributes your
-+location+ method might look something like this:
+If your model has +address+, +city+, +state+, and +country+ attributes your +location+ method might look something like this:
 
   def location
     [address, city, state, country].compact.join(', ')
@@ -43,14 +37,9 @@ Find distance between object and a point:
 
 Find objects within 20 miles of a point:
 
-  Venue.near('Omaha, NE, US', 20)  # Venue is a geocoded model
+  Venue.find_near('Omaha, NE, US', 20)  # Venue is a geocoded model
 
 Please see the code for more methods and detailed information about arguments.
 
-
-== To-do
-
-* +near+ method should also accept an array of coordinates as its first parameter
-
   
 Copyright (c) 2009 Alex Reisner, released under the MIT license
diff --git a/lib/geocoder.rb b/lib/geocoder.rb
index 3c8a5d5e..745966cd 100644
--- a/lib/geocoder.rb
+++ b/lib/geocoder.rb
@@ -25,7 +25,7 @@ module Geocoder
   # Returns array [lat,lon] if found, nil if not found or if network error.
   #
   def self.fetch_coordinates(query)
-    doc = self.search(query)
+    return nil unless doc = self.search(query)
     
     # Make sure search found a result.
     e = doc.elements['kml/Response/Status/code']
@@ -46,25 +46,16 @@ module Geocoder
 
     ##
     # Find all objects within a radius (in miles) of the given location
-    # (address string).
+    # (address string). Location (the first argument) may be either a string
+    # to geocode or an array of coordinates (<tt>[lat,long]</tt>).
     #
-    def near(location, radius = 100, options = {})
-      latitude, longitude = Geocoder.fetch_coordinates(location)
+    def find_near(location, radius = 20, options = {})
+      latitude, longitude = location.is_a?(Array) ?
+        location : Geocoder.fetch_coordinates(location)
       return [] unless (latitude and longitude)
-      query = nearby_mysql_query(latitude, longitude, radius.to_i, options)
-      find_by_sql(query)
+      all(Geocoder.find_near_options(latitude, longitude, radius, options))
     end
     
-    ##
-    # Generate a MySQL query to find all records within a radius (in miles)
-    # of a point.
-    #
-    def nearby_mysql_query(latitude, longitude, radius = 20, options = {})
-      table = options[:table_name] || self.to_s.tableize
-      options.delete :table_name # don't pass to nearby_mysql_query
-      Geocoder.nearby_mysql_query(table, latitude, longitude, radius, options)
-    end
-      
     ##
     # Get the name of the method that returns the search string.
     #
@@ -114,10 +105,11 @@ module Geocoder
     units = { :mi => 3956, :km => 6371 }
     
     # convert degrees to radians
-    lat1 *= Math::PI / 180
-    lon1 *= Math::PI / 180
-    lat2 *= Math::PI / 180
-    lon2 *= Math::PI / 180
+    lat1 = to_radians(lat1)
+    lon1 = to_radians(lon1)
+    lat2 = to_radians(lat2)
+    lon2 = to_radians(lon2)
+    # compute distances
     dlat = (lat1 - lat2).abs
     dlon = (lon1 - lon2).abs
 
@@ -128,7 +120,15 @@ module Geocoder
   end
   
   ##
-  # Find all records within a radius (in miles) of the given point.
+  # Convert degrees to radians.
+  #
+  def self.to_radians(degrees)
+    degrees * (Math::PI / 180)
+  end
+  
+  ##
+  # Get options hash suitable for passing to ActiveRecord.find to get
+  # records within a radius (in miles) of the given point.
   # Taken from excellent tutorial at:
   # http://www.scribd.com/doc/2569355/Geo-Distance-Search-with-MySQL
   # 
@@ -140,12 +140,13 @@ module Geocoder
   # +limit+     :: number of records to return (for LIMIT SQL clause)
   # +offset+    :: number of records to skip (for LIMIT SQL clause)
   #
-  def self.nearby_mysql_query(table, latitude, longitude, radius = 20, options = {})
+  def self.find_near_options(latitude, longitude, radius = 20, options = {})
     
-    # Alternate column names.
+    # Set defaults/clean up arguments.
     options[:latitude]  ||= 'latitude'
     options[:longitude] ||= 'longitude'
     options[:order]     ||= 'distance ASC'
+    radius                = radius.to_i
     
     # Constrain search to a (radius x radius) square.
     factor = (Math::cos(latitude * Math::PI / 180.0) * 69.0).abs
@@ -153,25 +154,30 @@ module Geocoder
     lon_hi = longitude + (radius / factor);
     lat_lo = latitude  - (radius / 69.0);
     lat_hi = latitude  + (radius / 69.0);
-    where  = "#{options[:latitude]} BETWEEN #{lat_lo} AND #{lat_hi} AND " +
-      "#{options[:longitude]} BETWEEN #{lon_lo} AND #{lon_hi}"
     
     # Build limit clause.
-    limit = ""
+    limit = nil
     if options[:limit] or options[:offset]
       options[:offset] ||= 0
-      limit = "LIMIT #{options[:offset]},#{options[:limit]}"
+      limit = "#{options[:offset]},#{options[:limit]}"
     end
 
-    # Generate query.
-    "SELECT *, 3956 * 2 * ASIN(SQRT(" +
-      "POWER(SIN((#{latitude} - #{options[:latitude]}) * " +
-      "PI() / 180 / 2), 2) + COS(#{latitude} * PI()/180) * " +
-      "COS(#{options[:latitude]} * PI() / 180) * " +
-      "POWER(SIN((#{longitude} - #{options[:longitude]}) * " +
-      "PI() / 180 / 2), 2) )) as distance " +
-      "FROM #{table} WHERE #{where} HAVING distance <= #{radius} " +
-      "ORDER BY #{options[:order]} #{limit}"
+    # Generate hash.
+    {
+      :select => "*, 3956 * 2 * ASIN(SQRT(" +
+        "POWER(SIN((#{latitude} - #{options[:latitude]}) * " +
+        "PI() / 180 / 2), 2) + COS(#{latitude} * PI()/180) * " +
+        "COS(#{options[:latitude]} * PI() / 180) * " +
+        "POWER(SIN((#{longitude} - #{options[:longitude]}) * " +
+        "PI() / 180 / 2), 2) )) as distance",
+      :conditions => [
+        "#{options[:latitude]} BETWEEN ? AND ? AND " +
+        "#{options[:longitude]} BETWEEN ? AND ?",
+        lat_lo, lat_hi, lon_lo, lon_hi],
+      :having => "distance <= #{radius}",
+      :order  => options[:order],
+      :limit  => limit
+    }
   end
 
   ##
-- 
GitLab