andre / geokit-rails

Official Geokit plugin for Rails/ActiveRecord. Provides location-based goodness for your Rails app. Requires the Geokit gem.

This URL has Read+Write access

geokit-rails / lib / geokit-rails / acts_as_mappable.rb
100644 457 lines (401 sloc) 20.199 kb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
module Geokit
  # Contains the class method acts_as_mappable targeted to be mixed into ActiveRecord.
  # When mixed in, augments find services such that they provide distance calculation
  # query services. The find method accepts additional options:
  #
  # * :origin - can be
  # 1. a two-element array of latititude/longitude -- :origin=>[37.792,-122.393]
  # 2. a geocodeable string -- :origin=>'100 Spear st, San Francisco, CA'
  # 3. an object which responds to lat and lng methods, or latitude and longitude methods,
  # or whatever methods you have specified for lng_column_name and lat_column_name
  #
  # Other finder methods are provided for specific queries. These are:
  #
  # * find_within (alias: find_inside)
  # * find_beyond (alias: find_outside)
  # * find_closest (alias: find_nearest)
  # * find_farthest
  #
  # Counter methods are available and work similarly to finders.
  #
  # If raw SQL is desired, the distance_sql method can be used to obtain SQL appropriate
  # to use in a find_by_sql call.
  module ActsAsMappable
    class UnsupportedAdapter < StandardError ; end
    
    # Mix below class methods into ActiveRecord.
    def self.included(base) # :nodoc:
      base.extend ClassMethods
    end
    
    # Class method to mix into active record.
    module ClassMethods # :nodoc:
      
      # Class method to bring distance query support into ActiveRecord models. By default
      # uses :miles for distance units and performs calculations based upon the Haversine
      # (sphere) formula. These can be changed by setting Geokit::default_units and
      # Geokit::default_formula. Also, by default, uses lat, lng, and distance for respective
      # column names. All of these can be overridden using the :default_units, :default_formula,
      # :lat_column_name, :lng_column_name, and :distance_column_name hash keys.
      #
      # Can also use to auto-geocode a specific column on create. Syntax;
      #
      # acts_as_mappable :auto_geocode=>true
      #
      # By default, it tries to geocode the "address" field. Or, for more customized behavior:
      #
      # acts_as_mappable :auto_geocode=>{:field=>:address,:error_message=>'bad address'}
      #
      # In both cases, it creates a before_validation_on_create callback to geocode the given column.
      # For anything more customized, we recommend you forgo the auto_geocode option
      # and create your own AR callback to handle geocoding.
      def acts_as_mappable(options = {})
        metaclass = (class << self; self; end)
 
        # Mix in the module, but ensure to do so just once.
        return if !defined?(Geokit::Mappable) || metaclass.included_modules.include?(Geokit::ActsAsMappable::SingletonMethods)
 
        send :extend, Geokit::ActsAsMappable::SingletonMethods
        send :include, Geokit::Mappable
 
        cattr_accessor :through
        self.through = options[:through]
 
        if reflection = Geokit::ActsAsMappable.end_of_reflection_chain(self.through, self)
          metaclass.instance_eval do
            [ :distance_column_name, :default_units, :default_formula, :lat_column_name, :lng_column_name, :qualified_lat_column_name, :qualified_lng_column_name ].each do |method_name|
              define_method method_name do
                reflection.klass.send(method_name)
              end
            end
          end
        else
          cattr_accessor :distance_column_name, :default_units, :default_formula, :lat_column_name, :lng_column_name, :qualified_lat_column_name, :qualified_lng_column_name
 
          self.distance_column_name = options[:distance_column_name] || 'distance'
          self.default_units = options[:default_units] || Geokit::default_units
          self.default_formula = options[:default_formula] || Geokit::default_formula
          self.lat_column_name = options[:lat_column_name] || 'lat'
          self.lng_column_name = options[:lng_column_name] || 'lng'
          self.qualified_lat_column_name = "#{table_name}.#{lat_column_name}"
          self.qualified_lng_column_name = "#{table_name}.#{lng_column_name}"
 
          if options.include?(:auto_geocode) && options[:auto_geocode]
            # if the form auto_geocode=>true is used, let the defaults take over by suppling an empty hash
            options[:auto_geocode] = {} if options[:auto_geocode] == true
            cattr_accessor :auto_geocode_field, :auto_geocode_error_message
            self.auto_geocode_field = options[:auto_geocode][:field] || 'address'
            self.auto_geocode_error_message = options[:auto_geocode][:error_message] || 'could not locate address'
 
            # set the actual callback here
            before_validation_on_create :auto_geocode_address
          end
        end
      end
    end
 
    # this is the callback for auto_geocoding
    def auto_geocode_address
      address=self.send(auto_geocode_field).to_s
      geo=Geokit::Geocoders::MultiGeocoder.geocode(address)
 
      if geo.success
        self.send("#{lat_column_name}=", geo.lat)
        self.send("#{lng_column_name}=", geo.lng)
      else
        errors.add(auto_geocode_field, auto_geocode_error_message)
      end
 
      geo.success
    end
 
    def self.end_of_reflection_chain(through, klass)
      while through
        reflection = nil
        if through.is_a?(Hash)
          association, through = through.to_a.first
        else
          association, through = through, nil
        end
 
        if reflection = klass.reflect_on_association(association)
          klass = reflection.klass
        else
          raise ArgumentError, "You gave #{association} in :through, but I could not find it on #{klass}."
        end
      end
 
      reflection
    end
 
    # Instance methods to mix into ActiveRecord.
    module SingletonMethods #:nodoc:
      
      # A proxy to an instance of a finder adapter, inferred from the connection's adapter.
      def adapter
        @adapter ||= begin
          require File.join(File.dirname(__FILE__), 'adapters', connection.adapter_name.downcase)
          klass = Adapters.const_get(connection.adapter_name.camelcase)
          klass.load(self) unless klass.loaded
          klass.new(self)
        rescue LoadError
          raise UnsupportedAdapter, "`#{connection.adapter_name.downcase}` is not a supported adapter."
        end
      end
      
      # Extends the existing find method in potentially two ways:
      # - If a mappable instance exists in the options, adds a distance column.
      # - If a mappable instance exists in the options and the distance column exists in the
      # conditions, substitutes the distance sql for the distance column -- this saves
      # having to write the gory SQL.
      def find(*args)
        prepare_for_find_or_count(:find, args)
        super(*args)
      end
 
      # Extends the existing count method by:
      # - If a mappable instance exists in the options and the distance column exists in the
      # conditions, substitutes the distance sql for the distance column -- this saves
      # having to write the gory SQL.
      def count(*args)
        prepare_for_find_or_count(:count, args)
        super(*args)
      end
 
      # Finds within a distance radius.
      def find_within(distance, options={})
        options[:within] = distance
        find(:all, options)
      end
      alias find_inside find_within
 
      # Finds beyond a distance radius.
      def find_beyond(distance, options={})
        options[:beyond] = distance
        find(:all, options)
      end
      alias find_outside find_beyond
 
      # Finds according to a range. Accepts inclusive or exclusive ranges.
      def find_by_range(range, options={})
        options[:range] = range
        find(:all, options)
      end
 
      # Finds the closest to the origin.
      def find_closest(options={})
        find(:nearest, options)
      end
      alias find_nearest find_closest
 
      # Finds the farthest from the origin.
      def find_farthest(options={})
        find(:farthest, options)
      end
 
      # Finds within rectangular bounds (sw,ne).
      def find_within_bounds(bounds, options={})
        options[:bounds] = bounds
        find(:all, options)
      end
 
      # counts within a distance radius.
      def count_within(distance, options={})
        options[:within] = distance
        count(options)
      end
      alias count_inside count_within
 
      # Counts beyond a distance radius.
      def count_beyond(distance, options={})
        options[:beyond] = distance
        count(options)
      end
      alias count_outside count_beyond
 
      # Counts according to a range. Accepts inclusive or exclusive ranges.
      def count_by_range(range, options={})
        options[:range] = range
        count(options)
      end
 
      # Finds within rectangular bounds (sw,ne).
      def count_within_bounds(bounds, options={})
        options[:bounds] = bounds
        count(options)
      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
      end
 
      private
 
        # Prepares either a find or a count action by parsing through the options and
        # conditionally adding to the select clause for finders.
        def prepare_for_find_or_count(action, args)
          options = args.extract_options!
          #options = defined?(args.extract_options!) ? args.extract_options! : extract_options_from_args!(args)
          # Obtain items affecting distance condition.
          origin = extract_origin_from_options(options)
          units = extract_units_from_options(options)
          formula = extract_formula_from_options(options)
          bounds = extract_bounds_from_options(options)
 
          # Only proceed if this is a geokit-related query
          if origin || bounds
            # if no explicit bounds were given, try formulating them from the point and distance given
            bounds = formulate_bounds_from_distance(options, origin, units) unless bounds
            # Apply select adjustments based upon action.
            add_distance_to_select(options, origin, units, formula) if origin && action == :find
            # Apply the conditions for a bounding rectangle if applicable
            apply_bounds_conditions(options,bounds) if bounds
            # Apply distance scoping and perform substitutions.
            apply_distance_scope(options)
            substitute_distance_in_conditions(options, origin, units, formula) if origin && options.has_key?(:conditions)
            # Order by scoping for find action.
            apply_find_scope(args, options) if action == :find
            # Handle :through
            apply_include_for_through(options)
            # Unfortunatley, we need to do extra work if you use an :include. See the method for more info.
            handle_order_with_include(options,origin,units,formula) if options.include?(:include) && options.include?(:order) && origin
          end
 
          # Restore options minus the extra options that we used for the
          # Geokit API.
          args.push(options)
        end
 
        def apply_include_for_through(options)
          if self.through
            case options[:include]
            when Array
              options[:include] << self.through
            when Hash, String, Symbol
              options[:include] = [ self.through, options[:include] ]
            else
              options[:include] = [ self.through ]
            end
          end
        end
 
        # If we're here, it means that 1) an origin argument, 2) an :include, 3) an :order clause were supplied.
        # Now we have to sub some SQL into the :order clause. The reason is that when you do an :include,
        # ActiveRecord drops the psuedo-column (specificically, distance) which we supplied for :select.
        # So, the 'distance' column isn't available for the :order clause to reference when we use :include.
        def handle_order_with_include(options, origin, units, formula)
          # replace the distance_column_name with the distance sql in order clause
          options[:order].sub!(distance_column_name, distance_sql(origin, units, formula))
        end
 
        # Looks for mapping-specific tokens and makes appropriate translations so that the
        # original finder has its expected arguments. Resets the the scope argument to
        # :first and ensures the limit is set to one.
        def apply_find_scope(args, options)
          case args.first
            when :nearest, :closest
              args[0] = :first
              options[:limit] = 1
              options[:order] = "#{distance_column_name} ASC"
            when :farthest
              args[0] = :first
              options[:limit] = 1
              options[:order] = "#{distance_column_name} DESC"
          end
        end
 
        # 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)
          if distance
            res=Geokit::Bounds.from_point_and_radius(origin,distance,:units=>units)
          else
            nil
          end
        end
 
        # Replace :within, :beyond and :range distance tokens with the appropriate distance
        # where clauses. Removes these tokens from the options hash.
        def apply_distance_scope(options)
          distance_condition = if options.has_key?(:within)
            "#{distance_column_name} <= #{options[:within]}"
          elsif options.has_key?(:beyond)
            "#{distance_column_name} > #{options[:beyond]}"
          elsif options.has_key?(:range)
            "#{distance_column_name} >= #{options[:range].first} AND #{distance_column_name} <#{'=' unless options[:range].exclude_end?} #{options[:range].last}"
          end
 
          if distance_condition
            [:within, :beyond, :range].each { |option| options.delete(option) }
            options[:conditions] = merge_conditions(options[:conditions], distance_condition)
          end
        end
 
        # Alters the conditions to include rectangular bounds conditions.
        def apply_bounds_conditions(options,bounds)
          sw,ne = bounds.sw, bounds.ne
          lng_sql = bounds.crosses_meridian? ? "(#{qualified_lng_column_name}<#{ne.lng} OR #{qualified_lng_column_name}>#{sw.lng})" : "#{qualified_lng_column_name}>#{sw.lng} AND #{qualified_lng_column_name}<#{ne.lng}"
          bounds_sql = "#{qualified_lat_column_name}>#{sw.lat} AND #{qualified_lat_column_name}<#{ne.lat} AND #{lng_sql}"
          options[:conditions] = merge_conditions(options[:conditions], bounds_sql)
        end
 
        # Extracts the origin instance out of the options if it exists and returns
        # 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
        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
        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
        end
 
        def extract_bounds_from_options(options)
          bounds = options.delete(:bounds)
          bounds = Geokit::Bounds.normalize(bounds) if bounds
        end
 
        # Geocode IP address.
        def geocode_ip_address(origin)
          geo_location = Geokit::Geocoders::MultiGeocoder.geocode(origin)
          return geo_location if geo_location.success
          raise Geokit::Geocoders::GeocodeError
        end
 
        # Given a point in a variety of (an address to geocode,
        # an array of [lat,lng], or an object with appropriate lat/lng methods, an IP addres)
        # this method will normalize it into a Geokit::LatLng instance. The only thing this
        # method adds on top of LatLng#normalize is handling of IP addresses
        def normalize_point_to_lat_lng(point)
          res = geocode_ip_address(point) if point.is_a?(String) && /^(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})?$/.match(point)
          res = Geokit::LatLng.normalize(point) unless res
          res
        end
 
        # Augments the select with the distance SQL.
        def add_distance_to_select(options, origin, units=default_units, formula=default_formula)
          if origin
            distance_selector = distance_sql(origin, units, formula) + " AS #{distance_column_name}"
            selector = options.has_key?(:select) && options[:select] ? options[:select] : "*"
            options[:select] = "#{selector}, #{distance_selector}"
          end
        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_conditions(options, origin, units=default_units, formula=default_formula)
          condition = options[:conditions].is_a?(String) ? options[:conditions] : options[:conditions].first
          pattern = Regexp.new("\\b#{distance_column_name}\\b")
          condition.gsub!(pattern, distance_sql(origin, units, formula))
        end
 
        # Returns the distance SQL using the spherical world formula (Haversine). The SQL is tuned
        # to the database in use.
        def sphere_distance_sql(origin, units)
          lat = deg2rad(origin.lat)
          lng = deg2rad(origin.lng)
          multiplier = units_sphere_multiplier(units)
 
          adapter.sphere_distance_sql(lat, lng, multiplier) if adapter
        end
        
        # Returns the distance SQL using the flat-world formula (Phythagorean Theory). The SQL is tuned
        # to the database in use.
        def flat_distance_sql(origin, units)
          lat_degree_units = units_per_latitude_degree(units)
          lng_degree_units = units_per_longitude_degree(origin.lat, units)
          
          adapter.flat_distance_sql(origin, lat_degree_units, lng_degree_units)
        end
    end
  end
end
 
# Extend Array with a sort_by_distance method.
class Array
  # This method creates a "distance" attribute on each object, calculates the
  # distance from the passed origin, and finally sorts the array by the
  # resulting distance.
  def sort_by_distance_from(origin, opts={})
    distance_attribute_name = opts.delete(:distance_attribute_name) || 'distance'
    self.each do |e|
      e.class.send(:attr_accessor, distance_attribute_name) if !e.respond_to?("#{distance_attribute_name}=")
      e.send("#{distance_attribute_name}=", e.distance_to(origin,opts))
    end
    self.sort!{|a,b|a.send(distance_attribute_name) <=> b.send(distance_attribute_name)}
  end
end