From 76d663530d18f1f5b52b6a3ee00d0fd6badad85b Mon Sep 17 00:00:00 2001 From: Alex Reisner <alex@alexreisner.com> Date: Sat, 29 Sep 2012 10:50:01 -0400 Subject: [PATCH] Move SQL calculation methods to new module. --- lib/geocoder/sql.rb | 90 +++++++++++++++ lib/geocoder/stores/active_record.rb | 160 ++++++++------------------- 2 files changed, 134 insertions(+), 116 deletions(-) create mode 100644 lib/geocoder/sql.rb diff --git a/lib/geocoder/sql.rb b/lib/geocoder/sql.rb new file mode 100644 index 00000000..e5877561 --- /dev/null +++ b/lib/geocoder/sql.rb @@ -0,0 +1,90 @@ +module Geocoder + module Sql + extend self + + ## + # Distance calculation based on the excellent tutorial at: + # http://www.scribd.com/doc/2569355/Geo-Distance-Search-with-MySQL + # + def full_distance(latitude, longitude, lat_attr, lon_attr, options = {}) + earth = Geocoder::Calculations.earth_radius(options[:units] || :mi) + + "#{earth} * 2 * ASIN(SQRT(" + + "POWER(SIN((#{latitude} - #{lat_attr}) * PI() / 180 / 2), 2) + " + + "COS(#{latitude} * PI() / 180) * COS(#{lat_attr} * PI() / 180) * " + + "POWER(SIN((#{longitude} - #{lon_attr}) * PI() / 180 / 2), 2)" + + "))" + end + + def approx_distance(latitude, longitude, lat_attr, lon_attr, options = {}) + dx = Geocoder::Calculations.longitude_degree_distance(30, options[:units] || :mi) + dy = Geocoder::Calculations.latitude_degree_distance(options[:units] || :mi) + + # sin of 45 degrees = average x or y component of vector + factor = Math.sin(Math::PI / 4) + + "(#{dy} * ABS(#{lat_attr} - #{latitude}) * #{factor}) + " + + "(#{dx} * ABS(#{lon_attr} - #{longitude}) * #{factor})" + end + + def within_bounding_box(sw_lat, sw_lng, ne_lat, ne_lng, lat_attr, lon_attr) + spans = "#{lat_attr} BETWEEN #{sw_lat} AND #{ne_lat} AND " + # handle box that spans 180 longitude + if sw_lng.to_f > ne_lng.to_f + spans + "#{lon_attr} BETWEEN #{sw_lng} AND 180 OR " + + "#{lon_attr} BETWEEN -180 AND #{ne_lng}" + else + spans + "#{lon_attr} BETWEEN #{sw_lng} AND #{ne_lng}" + end + end + + ## + # Fairly accurate bearing calculation. Takes a latitude, longitude, + # and an options hash which must include a :bearing value + # (:linear or :spherical). + # + # Based on: + # http://www.beginningspatial.com/calculating_bearing_one_point_another + # + def full_bearing(latitude, longitude, lat_attr, lon_attr, options = {}) + case options[:bearing] + when :linear + "CAST(" + + "DEGREES(ATAN2( " + + "RADIANS(#{lon_attr} - #{longitude}), " + + "RADIANS(#{lat_attr} - #{latitude})" + + ")) + 360 " + + "AS decimal) % 360" + when :spherical + "CAST(" + + "DEGREES(ATAN2( " + + "SIN(RADIANS(#{lon_attr} - #{longitude})) * " + + "COS(RADIANS(#{lat_attr})), (" + + "COS(RADIANS(#{latitude})) * SIN(RADIANS(#{lat_attr}))" + + ") - (" + + "SIN(RADIANS(#{latitude})) * COS(RADIANS(#{lat_attr})) * " + + "COS(RADIANS(#{lon_attr} - #{longitude}))" + + ")" + + ")) + 360 " + + "AS decimal) % 360" + end + end + + ## + # Totally lame bearing calculation. Basically useless except that it + # returns *something* in databases without trig functions. + # + def approx_bearing(latitude, longitude, lat_attr, lon_attr, options = {}) + "CASE " + + "WHEN (#{lat_attr} >= #{latitude} AND " + + "#{lon_attr} >= #{longitude}) THEN 45.0 " + + "WHEN (#{lat_attr} < #{latitude} AND " + + "#{lon_attr} >= #{longitude}) THEN 135.0 " + + "WHEN (#{lat_attr} < #{latitude} AND " + + "#{lon_attr} < #{longitude}) THEN 225.0 " + + "WHEN (#{lat_attr} >= #{latitude} AND " + + "#{lon_attr} < #{longitude}) THEN 315.0 " + + "END" + end + end +end diff --git a/lib/geocoder/stores/active_record.rb b/lib/geocoder/stores/active_record.rb index cc315abe..c57ac8b6 100644 --- a/lib/geocoder/stores/active_record.rb +++ b/lib/geocoder/stores/active_record.rb @@ -1,3 +1,4 @@ +require 'geocoder/sql' require 'geocoder/stores/base' ## @@ -43,7 +44,6 @@ module Geocoder::Store end } - ## # Find all objects within the area of a given bounding box. # Bounds must be an array of locations specifying the southwest @@ -52,17 +52,15 @@ module Geocoder::Store # scope :within_bounding_box, lambda{ |bounds| sw_lat, sw_lng, ne_lat, ne_lng = bounds.flatten if bounds - unless sw_lat && sw_lng && ne_lat && ne_lng - return select(select_clause(nil, "NULL", "NULL")).where(false_condition) - end - spans = "#{geocoder_options[:latitude]} BETWEEN #{sw_lat} AND #{ne_lat} AND " - spans << if sw_lng.to_f > ne_lng.to_f # handle box that spans 180 longitude - "#{geocoder_options[:longitude]} BETWEEN #{sw_lng} AND 180 OR " + - "#{geocoder_options[:longitude]} BETWEEN -180 AND #{ne_lng}" + if sw_lat && sw_lng && ne_lat && ne_lng + {:conditions => Geocoder::Sql.within_bounding_box( + sw_lat, sw_lng, ne_lat, ne_lng, + full_column_name(geocoder_options[:latitude]), + full_column_name(geocoder_options[:longitude]) + )} else - "#{geocoder_options[:longitude]} BETWEEN #{sw_lng} AND #{ne_lng}" + select(select_clause(nil, "NULL", "NULL")).where(false_condition) end - { :conditions => spans } } end end @@ -100,19 +98,22 @@ module Geocoder::Store # * +:exclude+ - an object to exclude (used by the +nearbys+ method) # def near_scope_options(latitude, longitude, radius = 20, options = {}) - if using_sqlite? - approx_near_scope_options(latitude, longitude, radius, options) - else - full_near_scope_options(latitude, longitude, radius, options) - end + method_prefix = using_sqlite? ? "approx" : "full" + send( + method_prefix + "_near_scope_options", + latitude, longitude, radius, options + ) end def distance_from_sql_options(latitude, longitude, options = {}) - if using_sqlite? - approx_distance_from_sql(latitude, longitude, options) - else - full_distance_from_sql(latitude, longitude, options) - end + method_prefix = using_sqlite? ? "approx" : "full" + Geocoder::Sql.send( + method_prefix + "_distance", + latitude, longitude, + full_column_name(geocoder_options[:latitude]), + full_column_name(geocoder_options[:longitude]), + options + ) end ## @@ -120,18 +121,18 @@ module Geocoder::Store # SQRT(), PI(), and trigonometric functions SIN(), COS(), ASIN(), # ATAN2(), DEGREES(), and RADIANS(). # - # Bearing calculation based on: - # http://www.beginningspatial.com/calculating_bearing_one_point_another - # def full_near_scope_options(latitude, longitude, radius, options) - lat_attr = geocoder_options[:latitude] - lon_attr = geocoder_options[:longitude] options[:bearing] ||= (options[:method] || geocoder_options[:method] || Geocoder::Configuration.distances) - bearing = full_bearing_sql(latitude, longitude, options) + bearing = Geocoder::Sql.full_bearing( + latitude, longitude, + full_column_name(geocoder_options[:latitude]), + full_column_name(geocoder_options[:longitude]), + options + ) options[:units] ||= (geocoder_options[:units] || Geocoder::Configuration.units) - distance = full_distance_from_sql(latitude, longitude, options) + distance = distance_from_sql_options(latitude, longitude, options) conditions = ["#{distance} <= ?", radius] default_near_scope_options(latitude, longitude, radius, options).merge( :select => select_clause(options[:select], distance, bearing), @@ -139,37 +140,6 @@ module Geocoder::Store ) end - ## - # Distance calculation based on the excellent tutorial at: - # http://www.scribd.com/doc/2569355/Geo-Distance-Search-with-MySQL - # - def full_distance_from_sql(latitude, longitude, options) - lat_attr = geocoder_options[:latitude] - lon_attr = geocoder_options[:longitude] - - earth = Geocoder::Calculations.earth_radius(options[:units] || :mi) - - "#{earth} * 2 * ASIN(SQRT(" + - "POWER(SIN((#{latitude} - #{full_column_name(lat_attr)}) * PI() / 180 / 2), 2) + " + - "COS(#{latitude} * PI() / 180) * COS(#{full_column_name(lat_attr)} * PI() / 180) * " + - "POWER(SIN((#{longitude} - #{full_column_name(lon_attr)}) * PI() / 180 / 2), 2)" + - "))" - end - - def approx_distance_from_sql(latitude, longitude, options) - lat_attr = geocoder_options[:latitude] - lon_attr = geocoder_options[:longitude] - - dx = Geocoder::Calculations.longitude_degree_distance(30, options[:units] || :mi) - dy = Geocoder::Calculations.latitude_degree_distance(options[:units] || :mi) - - # sin of 45 degrees = average x or y component of vector - factor = Math.sin(Math::PI / 4) - - "(#{dy} * ABS(#{full_column_name(lat_attr)} - #{latitude}) * #{factor}) + " + - "(#{dx} * ABS(#{full_column_name(lon_attr)} - #{longitude}) * #{factor})" - end - ## # Scope options hash for use with a database without trigonometric # functions, like SQLite. Approach is to find objects within a square @@ -180,79 +150,36 @@ module Geocoder::Store # only exist for interface consistency--not intended for production! # def approx_near_scope_options(latitude, longitude, radius, options) - lat_attr = geocoder_options[:latitude] - lon_attr = geocoder_options[:longitude] unless options.include?(:bearing) options[:bearing] = (options[:method] || \ geocoder_options[:method] || \ Geocoder::Configuration.distances) end - bearing = options[:bearing] ? - approx_bearing_sql(latitude, longitude) : false + if options[:bearing] + bearing = Geocoder::Sql.approx_bearing( + latitude, longitude, + full_column_name(geocoder_options[:latitude]), + full_column_name(geocoder_options[:longitude]) + ) + else + bearing = false + end options[:units] ||= (geocoder_options[:units] || Geocoder::Configuration.units) - distance = approx_distance_from_sql(latitude, longitude, options) + distance = distance_from_sql_options(latitude, longitude, options) b = Geocoder::Calculations.bounding_box([latitude, longitude], radius, options) - conditions = [ - "#{full_column_name(lat_attr)} BETWEEN ? AND ? AND #{full_column_name(lon_attr)} BETWEEN ? AND ?"] + - [b[0], b[2], b[1], b[3] + args = b + [ + full_column_name(geocoder_options[:latitude]), + full_column_name(geocoder_options[:longitude]) ] + conditions = Geocoder::Sql.within_bounding_box(*args) default_near_scope_options(latitude, longitude, radius, options).merge( :select => select_clause(options[:select], distance, bearing), :conditions => add_exclude_condition(conditions, options[:exclude]) ) end - ## - # SQL for bearing calculation. Takes a latitude, longitude, and an - # options has which must include a :bearing value - # (:linear or :spherical). - # - def full_bearing_sql(latitude, longitude, options) - lat_attr = geocoder_options[:latitude] - lon_attr = geocoder_options[:longitude] - case options[:bearing] - when :linear - "CAST(" + - "DEGREES(ATAN2( " + - "RADIANS(#{full_column_name(lon_attr)} - #{longitude}), " + - "RADIANS(#{full_column_name(lat_attr)} - #{latitude})" + - ")) + 360 " + - "AS decimal) % 360" - when :spherical - "CAST(" + - "DEGREES(ATAN2( " + - "SIN(RADIANS(#{full_column_name(lon_attr)} - #{longitude})) * " + - "COS(RADIANS(#{full_column_name(lat_attr)})), (" + - "COS(RADIANS(#{latitude})) * SIN(RADIANS(#{full_column_name(lat_attr)}))" + - ") - (" + - "SIN(RADIANS(#{latitude})) * COS(RADIANS(#{full_column_name(lat_attr)})) * " + - "COS(RADIANS(#{full_column_name(lon_attr)} - #{longitude}))" + - ")" + - ")) + 360 " + - "AS decimal) % 360" - end - end - - ## - # SQL for really lame bearing calculation. - # - def approx_bearing_sql(latitude, longitude) - lat_attr = geocoder_options[:latitude] - lon_attr = geocoder_options[:longitude] - "CASE " + - "WHEN (#{full_column_name(lat_attr)} >= #{latitude} AND " + - "#{full_column_name(lon_attr)} >= #{longitude}) THEN 45.0 " + - "WHEN (#{full_column_name(lat_attr)} < #{latitude} AND " + - "#{full_column_name(lon_attr)} >= #{longitude}) THEN 135.0 " + - "WHEN (#{full_column_name(lat_attr)} < #{latitude} AND " + - "#{full_column_name(lon_attr)} < #{longitude}) THEN 225.0 " + - "WHEN (#{full_column_name(lat_attr)} >= #{latitude} AND " + - "#{full_column_name(lon_attr)} < #{longitude}) THEN 315.0 " + - "END" - end - ## # Generate the SELECT clause. # @@ -279,9 +206,10 @@ module Geocoder::Store ## # Adds a condition to exclude a given object by ID. - # The given conditions MUST be an array. + # Expects conditions as an array or string. Returns array. # def add_exclude_condition(conditions, exclude) + conditions = [conditions] if conditions.is_a?(String) if exclude conditions[0] << " AND #{full_column_name(primary_key)} != ?" conditions << exclude.id -- GitLab