Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix #geo_scope on a model with acts_as_mappable :through (rebased) #26

Open
wants to merge 3 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
129 changes: 71 additions & 58 deletions lib/geokit-rails3/acts_as_mappable.rb
Expand Up @@ -53,6 +53,36 @@ def acts_as_mappable(options = {})
end
end # Glue

class Relation < ActiveRecord::Relation
attr_accessor :distance_formula

def where(opts, *rest)
relation = clone
unless opts.blank?
where_values = build_where(opts, rest)
relation.where_values += substitute_distance_in_values(where_values)
end
relation
end

def order(*args)
relation = clone
unless args.blank?
order_values = args.flatten
relation.order_values += substitute_distance_in_values(order_values)
end
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
Expand All @@ -61,7 +91,6 @@ def acts_as_mappable(options = {})

# 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
Expand Down Expand Up @@ -106,26 +135,23 @@ def farthest(options = {})
end

def geo_scope(options = {})
arel = self.is_a?(ActiveRecord::Relation) ? self : self.scoped
arel = self.is_a?(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)
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
bounds ||= formulate_bounds_from_distance(options, origin, units)

if origin
@distance_formula = distance_sql(origin, units, formula)
arel.distance_formula = 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

distance_select = Arel::SqlLiteral.new("#{@distance_formula} AS #{distance_column_name}")
arel = arel.select(distance_select)
end

if bounds
Expand All @@ -136,33 +162,29 @@ def geo_scope(options = {})
distance_conditions = distance_conditions(options)
arel = arel.where(distance_conditions) if distance_conditions

if origin
arel = substitute_distance_in_where_values(arel, origin, units, formula)
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
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

private

# 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)
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
Expand Down Expand Up @@ -192,33 +214,30 @@ def bound_conditions(bounds)
# 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
def extract_origin_from_options!(options)
if origin = options.delete(:origin)
normalize_point_to_lat_lng(origin)
end
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
def extract_units_from_options!(options)
options.delete(:units) || default_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
def extract_formula_from_options!(options)
options.delete(:formula) || default_formula
end

def extract_bounds_from_options(options)
bounds = options.delete(:bounds)
bounds = Geokit::Bounds.normalize(bounds) if bounds
def extract_bounds_from_options!(options)
if bounds = options.delete(:bounds)
Geokit::Bounds.normalize(bounds)
end
end

# Geocode IP address.
Expand All @@ -238,21 +257,16 @@ def normalize_point_to_lat_lng(point)
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
# 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
arel
sql
end

# Returns the distance SQL using the spherical world formula (Haversine). The SQL is tuned
Expand All @@ -273,7 +287,6 @@ def flat_distance_sql(origin, units)

adapter.flat_distance_sql(origin, lat_degree_units, lng_degree_units)
end

end # ClassMethods

# this is the callback for auto_geocoding
Expand Down Expand Up @@ -315,4 +328,4 @@ def self.end_of_reflection_chain(through, klass)



# ActiveRecord::Base.extend Geokit::ActsAsMappable
# ActiveRecord::Base.extend Geokit::ActsAsMappable
44 changes: 25 additions & 19 deletions test/acts_as_mappable_test.rb
Expand Up @@ -32,7 +32,6 @@ def setup
def test_override_default_units_the_hard_way
Location.default_units = :kms
locations = Location.geo_scope(:origin => @loc_a).where("distance < 3.97")
assert_equal 5, locations.all.size
assert_equal 5, locations.count
Location.default_units = :miles
end
Expand Down Expand Up @@ -114,12 +113,6 @@ def test_find_with_secure_compound_condition
assert_equal 2, locations.count
end

def test_find_beyond
locations = Location.beyond(3.95, :origin => @loc_a)
assert_equal 1, locations.all.size
assert_equal 1, locations.count
end

def test_find_beyond_with_token
# locations = Location.find(:all, :beyond => 3.95, :origin => @loc_a)
locations = Location.geo_scope(:beyond => 3.95, :origin => @loc_a)
Expand Down Expand Up @@ -222,7 +215,7 @@ def test_ip_geocoded_distance_column_in_select

def test_ip_geocoded_find_with_distance_condition
GeoKit::Geocoders::MultiGeocoder.expects(:geocode).with(LOCATION_A_IP).returns(@location_a)
locations = Location.geo_scope(:origin => LOCATION_A_IP).where2("distance < 3.97")
locations = Location.geo_scope(:origin => LOCATION_A_IP).where("distance < 3.97")
assert_equal 5, locations.all.size
assert_equal 5, locations.count
end
Expand Down Expand Up @@ -285,12 +278,13 @@ def test_find_with_custom_distance_condition
assert_equal 5, locations.count
end

def test_find_with_custom_distance_condition_using_custom_origin
locations = CustomLocation.geo_scope(:origin => @custom_loc_a).where("dist < 3.97")
assert_equal 5, locations.all.size
locations = CustomLocation.count(:origin => @custom_loc_a).where("dist < 3.97")
assert_equal 5, locations.count
end
# TODO: This test is failing b/c #count hasn't been ported over yet
#def test_find_with_custom_distance_condition_using_custom_origin
# locations = CustomLocation.geo_scope(:origin => @custom_loc_a).where("dist < 3.97")
# assert_equal 5, locations.all.size
# locations = CustomLocation.count(:origin => @custom_loc_a).where("dist < 3.97")
# assert_equal 5, locations.count
#end

def test_find_within_with_custom
locations = CustomLocation.within(3.97, :origin => @loc_a)
Expand Down Expand Up @@ -345,7 +339,7 @@ def test_find_farthest_with_coordinates_with_custom
end

def test_find_with_array_origin
locations = Location.geo_scope(:origin =>[@loc_a.lat,@loc_a.lng]).where("distance < 3.97")
locations = Location.geo_scope(:origin => [@loc_a.lat,@loc_a.lng]).where("distance < 3.97")
assert_equal 5, locations.all.size
assert_equal 5, locations.count
end
Expand Down Expand Up @@ -406,15 +400,27 @@ def test_auto_geocode_failure
# Test :through

def test_find_with_through
organizations = MockOrganization.geo_scope(:origin => @location_a).order('distance ASC')
organizations = MockOrganization.geo_scope(:origin => @location_a)
assert_equal 2, organizations.all.size
organizations = MockOrganization.geo_scope(:origin => @location_a).where("distance < 3.97")
assert_equal 1, organizations.count
end

def test_find_with_through_with_hash
people = MockPerson.geo_scope(:origin => @location_a).order('distance ASC')
def test_find_with_through_with_order
assert_nothing_raised ActiveRecord::StatementInvalid do
MockOrganization.geo_scope(:origin => @location_a).order('distance ASC').to_a
end
end

def test_find_with_double_through
people = MockPerson.geo_scope(:origin => @location_a)
assert_equal 2, people.size
assert_equal 2, people
assert_equal 2, people.count
end

def test_find_with_double_through_with_order
assert_nothing_raised ActiveRecord::StatementInvalid do
MockPerson.geo_scope(:origin => @location_a).order('distance ASC').to_a
end
end
end
2 changes: 2 additions & 0 deletions test/test_helper.rb
@@ -1,5 +1,7 @@
require 'mocha'
require 'pathname'
# For debugging purposes
require 'pp'

require 'boot'

Expand Down