]> projects.mako.cc - selectricity/blob - vendor/plugins/geokit/lib/geo_kit/acts_as_mappable.rb
improved dependency information
[selectricity] / vendor / plugins / geokit / lib / geo_kit / acts_as_mappable.rb
1 module GeoKit
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:
5   #
6   # * :origin - can be 
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
11   #
12   # Other finder methods are provided for specific queries.  These are:
13   #
14   # * find_within (alias: find_inside)
15   # * find_beyond (alias: find_outside)
16   # * find_closest (alias: find_nearest)
17   # * find_farthest
18   #
19   # Counter methods are available and work similarly to finders.  
20   #
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.
23   module ActsAsMappable 
24     # Mix below class methods into ActiveRecord.
25     def self.included(base) # :nodoc:
26       base.extend ClassMethods
27     end
28     
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.
37       # 
38       # Can also use to auto-geocode a specific column on create. Syntax;
39       #   
40       #   acts_as_mappable :auto_geocode=>true
41       # 
42       # By default, it tries to geocode the "address" field. Or, for more customized behavior:
43       #   
44       #   acts_as_mappable :auto_geocode=>{:field=>:address,:error_message=>'bad address'}
45       #   
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
55         
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'
71           
72           # set the actual callback here
73           before_validation_on_create :auto_geocode_address        
74         end
75       end
76     end
77     
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)
82   
83       if geo.success
84         self.send("#{lat_column_name}=", geo.lat)
85         self.send("#{lng_column_name}=", geo.lng)
86       else
87         errors.add(auto_geocode_field, auto_geocode_error_message) 
88       end
89       
90       geo.success
91     end
92     
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
98       end
99       
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.
107         def find(*args)
108           prepare_for_find_or_count(:find, args)
109           super(*args)
110         end     
111         
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.
116         def count(*args)
117           prepare_for_find_or_count(:count, args)
118           super(*args)
119         end
120         
121         # Finds within a distance radius.
122         def find_within(distance, options={})
123           options[:within] = distance
124           find(:all, options)
125         end
126         alias find_inside find_within
127                 
128         # Finds beyond a distance radius.
129         def find_beyond(distance, options={})
130           options[:beyond] = distance
131           find(:all, options)
132         end
133         alias find_outside find_beyond
134         
135         # Finds according to a range.  Accepts inclusive or exclusive ranges.
136         def find_by_range(range, options={})
137           options[:range] = range
138           find(:all, options)
139         end
140         
141         # Finds the closest to the origin.
142         def find_closest(options={})
143           find(:nearest, options)
144         end
145         alias find_nearest find_closest
146         
147         # Finds the farthest from the origin.
148         def find_farthest(options={})
149           find(:farthest, options)
150         end
151
152         # Finds within rectangular bounds (sw,ne).
153         def find_within_bounds(bounds, options={})
154           options[:bounds] = bounds
155           find(:all, options)
156         end
157         
158         # counts within a distance radius.
159         def count_within(distance, options={})
160           options[:within] = distance
161           count(options)
162         end
163         alias count_inside count_within
164
165         # Counts beyond a distance radius.
166         def count_beyond(distance, options={})
167           options[:beyond] = distance
168           count(options)
169         end
170         alias count_outside count_beyond
171         
172         # Counts according to a range.  Accepts inclusive or exclusive ranges.
173         def count_by_range(range, options={})
174           options[:range] = range
175           count(options)
176         end
177
178         # Finds within rectangular bounds (sw,ne).
179         def count_within_bounds(bounds, options={})
180           options[:bounds] = bounds
181           count(options)
182         end
183                 
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)
187           case formula
188           when :sphere
189             sql = sphere_distance_sql(origin, units)
190           when :flat
191             sql = flat_distance_sql(origin, units)
192           end
193           sql
194         end   
195
196         private
197         
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
221           # GeoKit API.
222           args.push(options)   
223         end
224         
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))
232         end
233         
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)
238           case args.first
239             when :nearest
240               args[0] = :first
241               options[:limit] = 1
242               options[:order] = "#{distance_column_name} ASC"
243             when :farthest
244               args[0] = :first
245               options[:limit] = 1
246               options[:order] = "#{distance_column_name} DESC"
247           end
248         end
249         
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)
255           if distance
256             res=GeoKit::Bounds.from_point_and_radius(origin,distance,:units=>units)
257           else 
258             nil
259           end
260         end
261         
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
269           
270           options[:conditions]=augment_conditions(options[:conditions],distance_condition) if distance_condition
271         end
272
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
275         # (string or array).
276         # 
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
286           else
287             res=sql
288           end
289           res
290         end
291
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)          
298         end
299
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
307           res
308         end
309         
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)
316           units
317         end
318         
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)
325           formula
326         end
327         
328         def extract_bounds_from_options(options)
329           bounds = options.delete(:bounds)
330           bounds = GeoKit::Bounds.normalize(bounds) if bounds
331         end
332        
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
338         end
339         
340
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
348           res       
349         end
350
351         # Augments the select with the distance SQL.
352         def add_distance_to_select(options, origin, units=default_units, formula=default_formula)
353           if origin
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}"  
357           end   
358         end
359
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
363         # the condition.
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
372         end
373         
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
381           when "mysql"
382             sql=<<-SQL_END 
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})
386                   SQL_END
387           when "postgresql"
388             sql=<<-SQL_END 
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})
392                   SQL_END
393           else
394             sql = "unhandled #{connection.adapter_name.downcase} adapter"
395           end        
396         end
397         
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
404           when "mysql"
405             sql=<<-SQL_END
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))
408                   SQL_END
409           when "postgresql"
410             sql=<<-SQL_END
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))
413                   SQL_END
414           else
415             sql = "unhandled #{connection.adapter_name.downcase} adapter"
416           end
417         end
418       end
419     end
420   end
421 end
422
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.
427 class Array
428   def sort_by_distance_from(origin, opts={})
429     distance_attribute_name = opts.delete(:distance_attribute_name) || 'distance'    
430     self.each do |e|
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))
433     end
434     self.sort!{|a,b|a.send(distance_attribute_name) <=> b.send(distance_attribute_name)}
435   end
436 end

Benjamin Mako Hill || Want to submit a patch?