jackruss-geokit-rails3 0.1.5.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (41) 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/sqlite.rb +53 -0
  9. data/lib/geokit-rails3/adapters/sqlserver.rb +43 -0
  10. data/lib/geokit-rails3/core_extensions.rb +14 -0
  11. data/lib/geokit-rails3/defaults.rb +21 -0
  12. data/lib/geokit-rails3/geocoder_control.rb +18 -0
  13. data/lib/geokit-rails3/ip_geocode_lookup.rb +44 -0
  14. data/lib/geokit-rails3/railtie.rb +38 -0
  15. data/lib/geokit-rails3/version.rb +3 -0
  16. data/test/acts_as_mappable_test.rb +420 -0
  17. data/test/boot.rb +32 -0
  18. data/test/database.yml +24 -0
  19. data/test/fixtures/companies.yml +7 -0
  20. data/test/fixtures/custom_locations.yml +54 -0
  21. data/test/fixtures/locations.yml +54 -0
  22. data/test/fixtures/mock_addresses.yml +17 -0
  23. data/test/fixtures/mock_families.yml +2 -0
  24. data/test/fixtures/mock_houses.yml +9 -0
  25. data/test/fixtures/mock_organizations.yml +5 -0
  26. data/test/fixtures/mock_people.yml +5 -0
  27. data/test/fixtures/stores.yml +0 -0
  28. data/test/ip_geocode_lookup_test.disabled.rb +82 -0
  29. data/test/models/company.rb +3 -0
  30. data/test/models/custom_location.rb +12 -0
  31. data/test/models/location.rb +4 -0
  32. data/test/models/mock_address.rb +4 -0
  33. data/test/models/mock_family.rb +3 -0
  34. data/test/models/mock_house.rb +3 -0
  35. data/test/models/mock_organization.rb +4 -0
  36. data/test/models/mock_person.rb +4 -0
  37. data/test/models/store.rb +3 -0
  38. data/test/schema.rb +60 -0
  39. data/test/tasks.rake +38 -0
  40. data/test/test_helper.rb +23 -0
  41. metadata +255 -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