geokit-rails3-1beta 0.2.0.beta1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. data/lib/geokit-rails3.rb +10 -0
  2. data/lib/geokit-rails3/acts_as_mappable.old.rb +437 -0
  3. data/lib/geokit-rails3/acts_as_mappable.rb +318 -0
  4. data/lib/geokit-rails3/adapters/abstract.rb +31 -0
  5. data/lib/geokit-rails3/adapters/mysql.rb +22 -0
  6. data/lib/geokit-rails3/adapters/mysql2.rb +22 -0
  7. data/lib/geokit-rails3/adapters/postgresql.rb +22 -0
  8. data/lib/geokit-rails3/adapters/sqlserver.rb +43 -0
  9. data/lib/geokit-rails3/core_extensions.rb +14 -0
  10. data/lib/geokit-rails3/defaults.rb +21 -0
  11. data/lib/geokit-rails3/geocoder_control.rb +18 -0
  12. data/lib/geokit-rails3/ip_geocode_lookup.rb +44 -0
  13. data/lib/geokit-rails3/railtie.rb +38 -0
  14. data/lib/geokit-rails3/version.rb +3 -0
  15. data/test/acts_as_mappable_test.rb +420 -0
  16. data/test/boot.rb +32 -0
  17. data/test/database.yml +20 -0
  18. data/test/fixtures/companies.yml +7 -0
  19. data/test/fixtures/custom_locations.yml +54 -0
  20. data/test/fixtures/locations.yml +54 -0
  21. data/test/fixtures/mock_addresses.yml +17 -0
  22. data/test/fixtures/mock_families.yml +2 -0
  23. data/test/fixtures/mock_houses.yml +9 -0
  24. data/test/fixtures/mock_organizations.yml +5 -0
  25. data/test/fixtures/mock_people.yml +5 -0
  26. data/test/fixtures/stores.yml +0 -0
  27. data/test/ip_geocode_lookup_test.disabled.rb +82 -0
  28. data/test/models/company.rb +3 -0
  29. data/test/models/custom_location.rb +12 -0
  30. data/test/models/location.rb +4 -0
  31. data/test/models/mock_address.rb +4 -0
  32. data/test/models/mock_family.rb +3 -0
  33. data/test/models/mock_house.rb +3 -0
  34. data/test/models/mock_organization.rb +4 -0
  35. data/test/models/mock_person.rb +4 -0
  36. data/test/models/store.rb +3 -0
  37. data/test/schema.rb +60 -0
  38. data/test/tasks.rake +38 -0
  39. data/test/test_helper.rb +23 -0
  40. metadata +206 -0
@@ -0,0 +1,10 @@
1
+ require 'geokit'
2
+
3
+ require 'geokit-rails3/railtie'
4
+ require 'geokit-rails3/core_extensions'
5
+
6
+ require 'geokit-rails3/defaults'
7
+ require 'geokit-rails3/adapters/abstract'
8
+ require 'geokit-rails3/acts_as_mappable'
9
+ require 'geokit-rails3/geocoder_control'
10
+ require 'geokit-rails3/ip_geocode_lookup'
@@ -0,0 +1,437 @@
1
+ require 'active_record'
2
+ require 'active_support/concern'
3
+
4
+ module Geokit
5
+ # Contains the class method acts_as_mappable targeted to be mixed into ActiveRecord.
6
+ # When mixed in, augments find services such that they provide distance calculation
7
+ # query services. The find method accepts additional options:
8
+ #
9
+ # * :origin - can be
10
+ # 1. a two-element array of latititude/longitude -- :origin=>[37.792,-122.393]
11
+ # 2. a geocodeable string -- :origin=>'100 Spear st, San Francisco, CA'
12
+ # 3. an object which responds to lat and lng methods, or latitude and longitude methods,
13
+ # or whatever methods you have specified for lng_column_name and lat_column_name
14
+ #
15
+ # Other finder methods are provided for specific queries. These are:
16
+ #
17
+ # * find_within (alias: find_inside)
18
+ # * find_beyond (alias: find_outside)
19
+ # * find_closest (alias: find_nearest)
20
+ # * find_farthest
21
+ #
22
+ # Counter methods are available and work similarly to finders.
23
+ #
24
+ # If raw SQL is desired, the distance_sql method can be used to obtain SQL appropriate
25
+ # to use in a find_by_sql call.
26
+ module ActsAsMappable
27
+
28
+ class UnsupportedAdapter < StandardError ; end
29
+
30
+ # Add the +acts_as_mappable+ method into ActiveRecord subclasses
31
+ module Glue # :nodoc:
32
+ extend ActiveSupport::Concern
33
+
34
+ module ClassMethods # :nodoc:
35
+ def acts_as_mappable(options = {})
36
+ metaclass = (class << self; self; end)
37
+
38
+ self.send :include, Geokit::ActsAsMappable
39
+
40
+ cattr_accessor :through
41
+ self.through = options[:through]
42
+
43
+ if reflection = Geokit::ActsAsMappable.end_of_reflection_chain(self.through, self)
44
+ metaclass.instance_eval do
45
+ [ :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|
46
+ define_method method_name do
47
+ reflection.klass.send(method_name)
48
+ end
49
+ end
50
+ end
51
+ else
52
+ cattr_accessor :distance_column_name, :default_units, :default_formula, :lat_column_name, :lng_column_name, :qualified_lat_column_name, :qualified_lng_column_name
53
+
54
+ self.distance_column_name = options[:distance_column_name] || 'distance'
55
+ self.default_units = options[:default_units] || Geokit::default_units
56
+ self.default_formula = options[:default_formula] || Geokit::default_formula
57
+ self.lat_column_name = options[:lat_column_name] || 'lat'
58
+ self.lng_column_name = options[:lng_column_name] || 'lng'
59
+ self.qualified_lat_column_name = "#{table_name}.#{lat_column_name}"
60
+ self.qualified_lng_column_name = "#{table_name}.#{lng_column_name}"
61
+
62
+ if options.include?(:auto_geocode) && options[:auto_geocode]
63
+ # if the form auto_geocode=>true is used, let the defaults take over by suppling an empty hash
64
+ options[:auto_geocode] = {} if options[:auto_geocode] == true
65
+ cattr_accessor :auto_geocode_field, :auto_geocode_error_message
66
+ self.auto_geocode_field = options[:auto_geocode][:field] || 'address'
67
+ self.auto_geocode_error_message = options[:auto_geocode][:error_message] || 'could not locate address'
68
+
69
+ # set the actual callback here
70
+ before_validation :auto_geocode_address, :on => :create
71
+ end
72
+
73
+ scope :nearest, lambda { |origin|
74
+ limit(1)
75
+ }
76
+ end
77
+ end
78
+ end
79
+ end # Glue
80
+
81
+ extend ActiveSupport::Concern
82
+
83
+ included do
84
+ include Geokit::Mappable
85
+ end
86
+
87
+ # Class methods included in models when +acts_as_mappable+ is called
88
+ module ClassMethods
89
+
90
+ # A proxy to an instance of a finder adapter, inferred from the connection's adapter.
91
+ def adapter
92
+ @adapter ||= begin
93
+ require File.join(File.dirname(__FILE__), 'adapters', connection.adapter_name.downcase)
94
+ klass = Adapters.const_get(connection.adapter_name.camelcase)
95
+ klass.load(self) unless klass.loaded
96
+ klass.new(self)
97
+ rescue LoadError
98
+ raise UnsupportedAdapter, "`#{connection.adapter_name.downcase}` is not a supported adapter."
99
+ end
100
+ end
101
+
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 = args.extract_options!
202
+ #options = defined?(args.extract_options!) ? args.extract_options! : extract_options_from_args!(args)
203
+ # Obtain items affecting distance condition.
204
+ origin = extract_origin_from_options(options)
205
+ units = extract_units_from_options(options)
206
+ formula = extract_formula_from_options(options)
207
+ bounds = extract_bounds_from_options(options)
208
+
209
+ # Only proceed if this is a geokit-related query
210
+ if origin || bounds
211
+ # if no explicit bounds were given, try formulating them from the point and distance given
212
+ bounds = formulate_bounds_from_distance(options, origin, units) unless bounds
213
+ # Apply select adjustments based upon action.
214
+ add_distance_to_select(options, origin, units, formula) if origin && action == :find
215
+ # Apply the conditions for a bounding rectangle if applicable
216
+ apply_bounds_conditions(options,bounds) if bounds
217
+ # Apply distance scoping and perform substitutions.
218
+ apply_distance_scope(options)
219
+ substitute_distance_in_conditions(options, origin, units, formula) if origin && options.has_key?(:conditions)
220
+ # Order by scoping for find action.
221
+ apply_find_scope(args, options) if action == :find
222
+ # Handle :through
223
+ apply_include_for_through(options)
224
+ # Unfortunatley, we need to do extra work if you use an :include. See the method for more info.
225
+ handle_order_with_include(options,origin,units,formula) if options.include?(:include) && options.include?(:order) && origin
226
+ end
227
+
228
+ # Restore options minus the extra options that we used for the
229
+ # Geokit API.
230
+ args.push(options)
231
+ end
232
+
233
+ def apply_include_for_through(options)
234
+ if self.through
235
+ case options[:include]
236
+ when Array
237
+ options[:include] << self.through
238
+ when Hash, String, Symbol
239
+ options[:include] = [ self.through, options[:include] ]
240
+ else
241
+ options[:include] = [ self.through ]
242
+ end
243
+ end
244
+ end
245
+
246
+ # If we're here, it means that 1) an origin argument, 2) an :include, 3) an :order clause were supplied.
247
+ # Now we have to sub some SQL into the :order clause. The reason is that when you do an :include,
248
+ # ActiveRecord drops the psuedo-column (specificically, distance) which we supplied for :select.
249
+ # So, the 'distance' column isn't available for the :order clause to reference when we use :include.
250
+ def handle_order_with_include(options, origin, units, formula)
251
+ # replace the distance_column_name with the distance sql in order clause
252
+ options[:order].sub!(distance_column_name, distance_sql(origin, units, formula))
253
+ end
254
+
255
+ # Looks for mapping-specific tokens and makes appropriate translations so that the
256
+ # original finder has its expected arguments. Resets the the scope argument to
257
+ # :first and ensures the limit is set to one.
258
+ def apply_find_scope(args, options)
259
+ case args.first
260
+ when :nearest, :closest
261
+ args[0] = :first
262
+ options[:limit] = 1
263
+ options[:order] = "#{distance_column_name} ASC"
264
+ when :farthest
265
+ args[0] = :first
266
+ options[:limit] = 1
267
+ options[:order] = "#{distance_column_name} DESC"
268
+ end
269
+ end
270
+
271
+ # If it's a :within query, add a bounding box to improve performance.
272
+ # This only gets called if a :bounds argument is not otherwise supplied.
273
+ def formulate_bounds_from_distance(options, origin, units)
274
+ distance = options[:within] if options.has_key?(:within)
275
+ distance = options[:range].last-(options[:range].exclude_end?? 1 : 0) if options.has_key?(:range)
276
+ if distance
277
+ res=Geokit::Bounds.from_point_and_radius(origin,distance,:units=>units)
278
+ else
279
+ nil
280
+ end
281
+ end
282
+
283
+ # Replace :within, :beyond and :range distance tokens with the appropriate distance
284
+ # where clauses. Removes these tokens from the options hash.
285
+ def apply_distance_scope(options)
286
+ distance_condition = if options.has_key?(:within)
287
+ "#{distance_column_name} <= #{options[:within]}"
288
+ elsif options.has_key?(:beyond)
289
+ "#{distance_column_name} > #{options[:beyond]}"
290
+ elsif options.has_key?(:range)
291
+ "#{distance_column_name} >= #{options[:range].first} AND #{distance_column_name} <#{'=' unless options[:range].exclude_end?} #{options[:range].last}"
292
+ end
293
+
294
+ if distance_condition
295
+ [:within, :beyond, :range].each { |option| options.delete(option) }
296
+ options[:conditions] = merge_conditions(options[:conditions], distance_condition)
297
+ end
298
+ end
299
+
300
+ # Alters the conditions to include rectangular bounds conditions.
301
+ def apply_bounds_conditions(options,bounds)
302
+ sw,ne = bounds.sw, bounds.ne
303
+ 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}"
304
+ bounds_sql = "#{qualified_lat_column_name}>#{sw.lat} AND #{qualified_lat_column_name}<#{ne.lat} AND #{lng_sql}"
305
+ options[:conditions] = merge_conditions(options[:conditions], bounds_sql)
306
+ end
307
+
308
+ # Extracts the origin instance out of the options if it exists and returns
309
+ # it. If there is no origin, looks for latitude and longitude values to
310
+ # create an origin. The side-effect of the method is to remove these
311
+ # option keys from the hash.
312
+ def extract_origin_from_options(options)
313
+ origin = options.delete(:origin)
314
+ res = normalize_point_to_lat_lng(origin) if origin
315
+ res
316
+ end
317
+
318
+ # Extract the units out of the options if it exists and returns it. If
319
+ # there is no :units key, it uses the default. The side effect of the
320
+ # method is to remove the :units key from the options hash.
321
+ def extract_units_from_options(options)
322
+ units = options[:units] || default_units
323
+ options.delete(:units)
324
+ units
325
+ end
326
+
327
+ # Extract the formula out of the options if it exists and returns it. If
328
+ # there is no :formula key, it uses the default. The side effect of the
329
+ # method is to remove the :formula key from the options hash.
330
+ def extract_formula_from_options(options)
331
+ formula = options[:formula] || default_formula
332
+ options.delete(:formula)
333
+ formula
334
+ end
335
+
336
+ def extract_bounds_from_options(options)
337
+ bounds = options.delete(:bounds)
338
+ bounds = Geokit::Bounds.normalize(bounds) if bounds
339
+ end
340
+
341
+ # Geocode IP address.
342
+ def geocode_ip_address(origin)
343
+ geo_location = Geokit::Geocoders::MultiGeocoder.geocode(origin)
344
+ return geo_location if geo_location.success
345
+ raise Geokit::Geocoders::GeocodeError
346
+ end
347
+
348
+ # Given a point in a variety of (an address to geocode,
349
+ # an array of [lat,lng], or an object with appropriate lat/lng methods, an IP addres)
350
+ # this method will normalize it into a Geokit::LatLng instance. The only thing this
351
+ # method adds on top of LatLng#normalize is handling of IP addresses
352
+ def normalize_point_to_lat_lng(point)
353
+ res = geocode_ip_address(point) if point.is_a?(String) && /^(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})?$/.match(point)
354
+ res = Geokit::LatLng.normalize(point) unless res
355
+ res
356
+ end
357
+
358
+ # Augments the select with the distance SQL.
359
+ def add_distance_to_select(options, origin, units=default_units, formula=default_formula)
360
+ if origin
361
+ distance_selector = distance_sql(origin, units, formula) + " AS #{distance_column_name}"
362
+ selector = options.has_key?(:select) && options[:select] ? options[:select] : "*"
363
+ options[:select] = "#{selector}, #{distance_selector}"
364
+ end
365
+ end
366
+
367
+ # Looks for the distance column and replaces it with the distance sql. If an origin was not
368
+ # passed in and the distance column exists, we leave it to be flagged as bad SQL by the database.
369
+ # Conditions are either a string or an array. In the case of an array, the first entry contains
370
+ # the condition.
371
+ def substitute_distance_in_conditions(options, origin, units=default_units, formula=default_formula)
372
+ condition = options[:conditions].is_a?(String) ? options[:conditions] : options[:conditions].first
373
+ pattern = Regexp.new("\\b#{distance_column_name}\\b")
374
+ condition.gsub!(pattern, distance_sql(origin, units, formula))
375
+ end
376
+
377
+ # Returns the distance SQL using the spherical world formula (Haversine). The SQL is tuned
378
+ # to the database in use.
379
+ def sphere_distance_sql(origin, units)
380
+ lat = deg2rad(origin.lat)
381
+ lng = deg2rad(origin.lng)
382
+ multiplier = units_sphere_multiplier(units)
383
+
384
+ adapter.sphere_distance_sql(lat, lng, multiplier) if adapter
385
+ end
386
+
387
+ # Returns the distance SQL using the flat-world formula (Phythagorean Theory). The SQL is tuned
388
+ # to the database in use.
389
+ def flat_distance_sql(origin, units)
390
+ lat_degree_units = units_per_latitude_degree(units)
391
+ lng_degree_units = units_per_longitude_degree(origin.lat, units)
392
+
393
+ adapter.flat_distance_sql(origin, lat_degree_units, lng_degree_units)
394
+ end
395
+
396
+ end # ClassMethods
397
+
398
+ # this is the callback for auto_geocoding
399
+ def auto_geocode_address
400
+ address=self.send(auto_geocode_field).to_s
401
+ geo=Geokit::Geocoders::MultiGeocoder.geocode(address)
402
+
403
+ if geo.success
404
+ self.send("#{lat_column_name}=", geo.lat)
405
+ self.send("#{lng_column_name}=", geo.lng)
406
+ else
407
+ errors.add(auto_geocode_field, auto_geocode_error_message)
408
+ end
409
+
410
+ geo.success
411
+ end
412
+
413
+ def self.end_of_reflection_chain(through, klass)
414
+ while through
415
+ reflection = nil
416
+ if through.is_a?(Hash)
417
+ association, through = through.to_a.first
418
+ else
419
+ association, through = through, nil
420
+ end
421
+
422
+ if reflection = klass.reflect_on_association(association)
423
+ klass = reflection.klass
424
+ else
425
+ raise ArgumentError, "You gave #{association} in :through, but I could not find it on #{klass}."
426
+ end
427
+ end
428
+
429
+ reflection
430
+ end
431
+
432
+ end # ActsAsMappable
433
+ end # Geokit
434
+
435
+
436
+
437
+ # ActiveRecord::Base.extend Geokit::ActsAsMappable
@@ -0,0 +1,318 @@
1
+ require 'active_record'
2
+ require 'active_support/concern'
3
+
4
+ module Geokit
5
+ module ActsAsMappable
6
+
7
+ class UnsupportedAdapter < StandardError ; end
8
+
9
+ # Add the +acts_as_mappable+ method into ActiveRecord subclasses
10
+ module Glue # :nodoc:
11
+ extend ActiveSupport::Concern
12
+
13
+ module ClassMethods # :nodoc:
14
+ def acts_as_mappable(options = {})
15
+ metaclass = (class << self; self; end)
16
+
17
+ include Geokit::ActsAsMappable
18
+
19
+ cattr_accessor :through
20
+ self.through = options[:through]
21
+
22
+ if reflection = Geokit::ActsAsMappable.end_of_reflection_chain(self.through, self)
23
+ metaclass.instance_eval do
24
+ [ :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|
25
+ define_method method_name do
26
+ reflection.klass.send(method_name)
27
+ end
28
+ end
29
+ end
30
+ else
31
+ cattr_accessor :distance_column_name, :default_units, :default_formula, :lat_column_name, :lng_column_name, :qualified_lat_column_name, :qualified_lng_column_name
32
+
33
+ self.distance_column_name = options[:distance_column_name] || 'distance'
34
+ self.default_units = options[:default_units] || Geokit::default_units
35
+ self.default_formula = options[:default_formula] || Geokit::default_formula
36
+ self.lat_column_name = options[:lat_column_name] || 'lat'
37
+ self.lng_column_name = options[:lng_column_name] || 'lng'
38
+ self.qualified_lat_column_name = "#{table_name}.#{lat_column_name}"
39
+ self.qualified_lng_column_name = "#{table_name}.#{lng_column_name}"
40
+
41
+ if options.include?(:auto_geocode) && options[:auto_geocode]
42
+ # if the form auto_geocode=>true is used, let the defaults take over by suppling an empty hash
43
+ options[:auto_geocode] = {} if options[:auto_geocode] == true
44
+ cattr_accessor :auto_geocode_field, :auto_geocode_error_message
45
+ self.auto_geocode_field = options[:auto_geocode][:field] || 'address'
46
+ self.auto_geocode_error_message = options[:auto_geocode][:error_message] || 'could not locate address'
47
+
48
+ # set the actual callback here
49
+ before_validation :auto_geocode_address, :on => :create
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end # Glue
55
+
56
+ extend ActiveSupport::Concern
57
+
58
+ included do
59
+ include Geokit::Mappable
60
+ end
61
+
62
+ # Class methods included in models when +acts_as_mappable+ is called
63
+ module ClassMethods
64
+
65
+ # A proxy to an instance of a finder adapter, inferred from the connection's adapter.
66
+ def adapter
67
+ @adapter ||= begin
68
+ require File.join(File.dirname(__FILE__), 'adapters', connection.adapter_name.downcase)
69
+ klass = Adapters.const_get(connection.adapter_name.camelcase)
70
+ klass.load(self) unless klass.loaded
71
+ klass.new(self)
72
+ rescue LoadError
73
+ raise UnsupportedAdapter, "`#{connection.adapter_name.downcase}` is not a supported adapter."
74
+ end
75
+ end
76
+
77
+ def within(distance, options = {})
78
+ options[:within] = distance
79
+ geo_scope(options)
80
+ end
81
+ alias inside within
82
+
83
+ def beyond(distance, options = {})
84
+ options[:beyond] = distance
85
+ geo_scope(options)
86
+ end
87
+ alias outside beyond
88
+
89
+ def in_range(range, options = {})
90
+ options[:range] = range
91
+ geo_scope(options)
92
+ end
93
+
94
+ def in_bounds(bounds, options = {})
95
+ options[:bounds] = bounds
96
+ geo_scope(options)
97
+ end
98
+
99
+ def closest(options = {})
100
+ geo_scope(options).order("#{distance_column_name} asc").limit(1)
101
+ end
102
+ alias nearest closest
103
+
104
+ def farthest(options = {})
105
+ geo_scope(options).order("#{distance_column_name} desc").limit(1)
106
+ end
107
+
108
+ def geo_scope(options = {})
109
+ arel = self.is_a?(ActiveRecord::Relation) ? self : self.scoped
110
+
111
+ origin = extract_origin_from_options(options)
112
+ units = extract_units_from_options(options)
113
+ formula = extract_formula_from_options(options)
114
+ bounds = extract_bounds_from_options(options)
115
+
116
+ if origin || bounds
117
+ bounds = formulate_bounds_from_distance(options, origin, units) unless bounds
118
+
119
+ if origin
120
+ @distance_formula = distance_sql(origin, units, formula)
121
+
122
+ if arel.select_values.blank?
123
+ star_select = Arel::SqlLiteral.new(arel.quoted_table_name + '.*')
124
+ arel = arel.select(star_select)
125
+ end
126
+
127
+ distance_select = Arel::SqlLiteral.new("#{@distance_formula} AS #{distance_column_name}")
128
+ arel = arel.select(distance_select)
129
+ end
130
+
131
+ if bounds
132
+ bound_conditions = bound_conditions(bounds)
133
+ arel = arel.where(bound_conditions) if bound_conditions
134
+ end
135
+
136
+ distance_conditions = distance_conditions(options)
137
+ arel = arel.where(distance_conditions) if distance_conditions
138
+
139
+ if origin
140
+ arel = substitute_distance_in_where_values(arel, origin, units, formula)
141
+ end
142
+ end
143
+
144
+ arel
145
+ end
146
+
147
+ # Returns the distance calculation to be used as a display column or a condition. This
148
+ # is provide for anyone wanting access to the raw SQL.
149
+ def distance_sql(origin, units=default_units, formula=default_formula)
150
+ case formula
151
+ when :sphere
152
+ sql = sphere_distance_sql(origin, units)
153
+ when :flat
154
+ sql = flat_distance_sql(origin, units)
155
+ end
156
+ sql
157
+ end
158
+
159
+ private
160
+
161
+ # If it's a :within query, add a bounding box to improve performance.
162
+ # This only gets called if a :bounds argument is not otherwise supplied.
163
+ def formulate_bounds_from_distance(options, origin, units)
164
+ distance = options[:within] if options.has_key?(:within)
165
+ distance = options[:range].last-(options[:range].exclude_end?? 1 : 0) if options.has_key?(:range)
166
+ if distance
167
+ res=Geokit::Bounds.from_point_and_radius(origin,distance,:units=>units)
168
+ else
169
+ nil
170
+ end
171
+ end
172
+
173
+ def distance_conditions(options)
174
+ res = if options.has_key?(:within)
175
+ "#{distance_column_name} <= #{options[:within]}"
176
+ elsif options.has_key?(:beyond)
177
+ "#{distance_column_name} > #{options[:beyond]}"
178
+ elsif options.has_key?(:range)
179
+ "#{distance_column_name} >= #{options[:range].first} AND #{distance_column_name} <#{'=' unless options[:range].exclude_end?} #{options[:range].last}"
180
+ end
181
+ Arel::SqlLiteral.new("(#{res})") if res.present?
182
+ end
183
+
184
+ def bound_conditions(bounds)
185
+ sw,ne = bounds.sw, bounds.ne
186
+ 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}"
187
+ res = "#{qualified_lat_column_name}>#{sw.lat} AND #{qualified_lat_column_name}<#{ne.lat} AND #{lng_sql}"
188
+ Arel::SqlLiteral.new("(#{res})") if res.present?
189
+ end
190
+
191
+ # Extracts the origin instance out of the options if it exists and returns
192
+ # it. If there is no origin, looks for latitude and longitude values to
193
+ # create an origin. The side-effect of the method is to remove these
194
+ # option keys from the hash.
195
+ def extract_origin_from_options(options)
196
+ origin = options.delete(:origin)
197
+ res = normalize_point_to_lat_lng(origin) if origin
198
+ res
199
+ end
200
+
201
+ # Extract the units out of the options if it exists and returns it. If
202
+ # there is no :units key, it uses the default. The side effect of the
203
+ # method is to remove the :units key from the options hash.
204
+ def extract_units_from_options(options)
205
+ units = options[:units] || default_units
206
+ options.delete(:units)
207
+ units
208
+ end
209
+
210
+ # Extract the formula out of the options if it exists and returns it. If
211
+ # there is no :formula key, it uses the default. The side effect of the
212
+ # method is to remove the :formula key from the options hash.
213
+ def extract_formula_from_options(options)
214
+ formula = options[:formula] || default_formula
215
+ options.delete(:formula)
216
+ formula
217
+ end
218
+
219
+ def extract_bounds_from_options(options)
220
+ bounds = options.delete(:bounds)
221
+ bounds = Geokit::Bounds.normalize(bounds) if bounds
222
+ end
223
+
224
+ # Geocode IP address.
225
+ def geocode_ip_address(origin)
226
+ geo_location = Geokit::Geocoders::MultiGeocoder.geocode(origin)
227
+ return geo_location if geo_location.success
228
+ raise Geokit::Geocoders::GeocodeError
229
+ end
230
+
231
+ # Given a point in a variety of (an address to geocode,
232
+ # an array of [lat,lng], or an object with appropriate lat/lng methods, an IP addres)
233
+ # this method will normalize it into a Geokit::LatLng instance. The only thing this
234
+ # method adds on top of LatLng#normalize is handling of IP addresses
235
+ def normalize_point_to_lat_lng(point)
236
+ res = geocode_ip_address(point) if point.is_a?(String) && /^(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})?$/.match(point)
237
+ res = Geokit::LatLng.normalize(point) unless res
238
+ res
239
+ end
240
+
241
+ # Looks for the distance column and replaces it with the distance sql. If an origin was not
242
+ # passed in and the distance column exists, we leave it to be flagged as bad SQL by the database.
243
+ # Conditions are either a string or an array. In the case of an array, the first entry contains
244
+ # the condition.
245
+ def substitute_distance_in_where_values(arel, origin, units=default_units, formula=default_formula)
246
+ pattern = Regexp.new("\\b#{distance_column_name}\\b")
247
+ value = distance_sql(origin, units, formula)
248
+ arel.where_values.map! do |where_value|
249
+ if where_value.is_a?(String)
250
+ where_value.gsub(pattern, value)
251
+ else
252
+ where_value
253
+ end
254
+ end
255
+ arel
256
+ end
257
+
258
+ # Returns the distance SQL using the spherical world formula (Haversine). The SQL is tuned
259
+ # to the database in use.
260
+ def sphere_distance_sql(origin, units)
261
+ lat = deg2rad(origin.lat)
262
+ lng = deg2rad(origin.lng)
263
+ multiplier = units_sphere_multiplier(units)
264
+
265
+ adapter.sphere_distance_sql(lat, lng, multiplier) if adapter
266
+ end
267
+
268
+ # Returns the distance SQL using the flat-world formula (Phythagorean Theory). The SQL is tuned
269
+ # to the database in use.
270
+ def flat_distance_sql(origin, units)
271
+ lat_degree_units = units_per_latitude_degree(units)
272
+ lng_degree_units = units_per_longitude_degree(origin.lat, units)
273
+
274
+ adapter.flat_distance_sql(origin, lat_degree_units, lng_degree_units)
275
+ end
276
+
277
+ end # ClassMethods
278
+
279
+ # this is the callback for auto_geocoding
280
+ def auto_geocode_address
281
+ address=self.send(auto_geocode_field).to_s
282
+ geo=Geokit::Geocoders::MultiGeocoder.geocode(address)
283
+
284
+ if geo.success
285
+ self.send("#{lat_column_name}=", geo.lat)
286
+ self.send("#{lng_column_name}=", geo.lng)
287
+ else
288
+ errors.add(auto_geocode_field, auto_geocode_error_message)
289
+ end
290
+
291
+ geo.success
292
+ end
293
+
294
+ def self.end_of_reflection_chain(through, klass)
295
+ while through
296
+ reflection = nil
297
+ if through.is_a?(Hash)
298
+ association, through = through.to_a.first
299
+ else
300
+ association, through = through, nil
301
+ end
302
+
303
+ if reflection = klass.reflect_on_association(association)
304
+ klass = reflection.klass
305
+ else
306
+ raise ArgumentError, "You gave #{association} in :through, but I could not find it on #{klass}."
307
+ end
308
+ end
309
+
310
+ reflection
311
+ end
312
+
313
+ end # ActsAsMappable
314
+ end # Geokit
315
+
316
+
317
+
318
+ # ActiveRecord::Base.extend Geokit::ActsAsMappable