geokit-rails3 0.0.3

Sign up to get free protection for your applications and to get access to all the features.
Files changed (46) hide show
  1. data/.gitignore +30 -0
  2. data/CHANGELOG.rdoc +46 -0
  3. data/CONFIG.markdown +67 -0
  4. data/Gemfile +3 -0
  5. data/Gemfile.lock +89 -0
  6. data/MIT-LICENSE +20 -0
  7. data/README.markdown +554 -0
  8. data/Rakefile +17 -0
  9. data/geokit-rails3.gemspec +29 -0
  10. data/lib/geokit-rails.rb +10 -0
  11. data/lib/geokit-rails3/acts_as_mappable.rb +433 -0
  12. data/lib/geokit-rails3/adapters/abstract.rb +31 -0
  13. data/lib/geokit-rails3/adapters/mysql.rb +22 -0
  14. data/lib/geokit-rails3/adapters/postgresql.rb +22 -0
  15. data/lib/geokit-rails3/adapters/sqlserver.rb +43 -0
  16. data/lib/geokit-rails3/core_extensions.rb +14 -0
  17. data/lib/geokit-rails3/defaults.rb +21 -0
  18. data/lib/geokit-rails3/geocoder_control.rb +18 -0
  19. data/lib/geokit-rails3/ip_geocode_lookup.rb +44 -0
  20. data/lib/geokit-rails3/railtie.rb +38 -0
  21. data/lib/geokit-rails3/version.rb +3 -0
  22. data/test/acts_as_mappable_test.rb +473 -0
  23. data/test/boot.rb +32 -0
  24. data/test/database.yml +20 -0
  25. data/test/fixtures/companies.yml +7 -0
  26. data/test/fixtures/custom_locations.yml +54 -0
  27. data/test/fixtures/locations.yml +54 -0
  28. data/test/fixtures/mock_addresses.yml +17 -0
  29. data/test/fixtures/mock_families.yml +2 -0
  30. data/test/fixtures/mock_houses.yml +9 -0
  31. data/test/fixtures/mock_organizations.yml +5 -0
  32. data/test/fixtures/mock_people.yml +5 -0
  33. data/test/fixtures/stores.yml +0 -0
  34. data/test/models/company.rb +3 -0
  35. data/test/models/custom_location.rb +12 -0
  36. data/test/models/location.rb +4 -0
  37. data/test/models/mock_address.rb +4 -0
  38. data/test/models/mock_family.rb +3 -0
  39. data/test/models/mock_house.rb +3 -0
  40. data/test/models/mock_organization.rb +4 -0
  41. data/test/models/mock_person.rb +4 -0
  42. data/test/models/store.rb +3 -0
  43. data/test/schema.rb +60 -0
  44. data/test/tasks.rake +38 -0
  45. data/test/test_helper.rb +23 -0
  46. metadata +236 -0
data/Rakefile ADDED
@@ -0,0 +1,17 @@
1
+ require 'bundler'
2
+ Bundler::GemHelper.install_tasks
3
+
4
+ require 'rake/rdoctask'
5
+ Rake::RDocTask.new do |rdoc|
6
+ version = File.exist?('VERSION') ? File.read('VERSION') : ""
7
+
8
+ rdoc.rdoc_dir = 'rdoc'
9
+ rdoc.title = "geokit-rails3 #{version}"
10
+ rdoc.rdoc_files.include('README*')
11
+ rdoc.rdoc_files.include('lib/**/*.rb')
12
+ end
13
+
14
+ load 'test/tasks.rake'
15
+
16
+ desc 'Default: run unit tests.'
17
+ task :default => :test
@@ -0,0 +1,29 @@
1
+ # -*- encoding: utf-8 -*-
2
+ require File.expand_path("../lib/geokit-rails3/version", __FILE__)
3
+
4
+ Gem::Specification.new do |s|
5
+ s.name = "geokit-rails3"
6
+ s.version = GeokitRails3::VERSION
7
+ s.platform = Gem::Platform::RUBY
8
+ s.authors = ["Andre Lewis", "Bill Eisenhauer", "Jeremy Lecour"]
9
+ s.email = ["andre@earthcode.com", "bill_eisenhauer@yahoo.com", "jeremy.lecour@gmail.com"]
10
+ s.homepage = "http://github.com/jlecour/geokit-rails3"
11
+ s.summary = "Integrate Geokit with Rails 3"
12
+ s.description = "Port of the Rails plugin \"geokit-rails\" to Rails 3, as a gem"
13
+
14
+ s.required_rubygems_version = ">= 1.3.6"
15
+ # s.rubyforge_project = "test_gem"
16
+
17
+ s.add_runtime_dependency 'rails', '~> 3.0.0'
18
+ s.add_runtime_dependency 'geokit', '~> 1.5.0'
19
+
20
+ s.add_development_dependency "bundler", "~> 1.0.0"
21
+ s.add_development_dependency "rcov", "~> 0.9.9"
22
+ s.add_development_dependency "mocha", "~> 0.9.8"
23
+ s.add_development_dependency "mysql", "~> 2.8.1"
24
+
25
+ s.files = `git ls-files`.split("\n")
26
+ s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
27
+ s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
28
+ s.require_paths = ["lib"]
29
+ end
@@ -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,433 @@
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
+ end
73
+ end
74
+ end
75
+ end # Glue
76
+
77
+ extend ActiveSupport::Concern
78
+
79
+ included do
80
+ include Geokit::Mappable
81
+ end
82
+
83
+ # Class methods included in models when +acts_as_mappable+ is called
84
+ module ClassMethods
85
+
86
+ # A proxy to an instance of a finder adapter, inferred from the connection's adapter.
87
+ def adapter
88
+ @adapter ||= begin
89
+ require File.join(File.dirname(__FILE__), 'adapters', connection.adapter_name.downcase)
90
+ klass = Adapters.const_get(connection.adapter_name.camelcase)
91
+ klass.load(self) unless klass.loaded
92
+ klass.new(self)
93
+ rescue LoadError
94
+ raise UnsupportedAdapter, "`#{connection.adapter_name.downcase}` is not a supported adapter."
95
+ end
96
+ end
97
+
98
+ # Extends the existing find method in potentially two ways:
99
+ # - If a mappable instance exists in the options, adds a distance column.
100
+ # - If a mappable instance exists in the options and the distance column exists in the
101
+ # conditions, substitutes the distance sql for the distance column -- this saves
102
+ # having to write the gory SQL.
103
+ def find(*args)
104
+ prepare_for_find_or_count(:find, args)
105
+ super(*args)
106
+ end
107
+
108
+ # Extends the existing count method by:
109
+ # - If a mappable instance exists in the options and the distance column exists in the
110
+ # conditions, substitutes the distance sql for the distance column -- this saves
111
+ # having to write the gory SQL.
112
+ def count(*args)
113
+ prepare_for_find_or_count(:count, args)
114
+ super(*args)
115
+ end
116
+
117
+ # Finds within a distance radius.
118
+ def find_within(distance, options={})
119
+ options[:within] = distance
120
+ find(:all, options)
121
+ end
122
+ alias find_inside find_within
123
+
124
+ # Finds beyond a distance radius.
125
+ def find_beyond(distance, options={})
126
+ options[:beyond] = distance
127
+ find(:all, options)
128
+ end
129
+ alias find_outside find_beyond
130
+
131
+ # Finds according to a range. Accepts inclusive or exclusive ranges.
132
+ def find_by_range(range, options={})
133
+ options[:range] = range
134
+ find(:all, options)
135
+ end
136
+
137
+ # Finds the closest to the origin.
138
+ def find_closest(options={})
139
+ find(:nearest, options)
140
+ end
141
+ alias find_nearest find_closest
142
+
143
+ # Finds the farthest from the origin.
144
+ def find_farthest(options={})
145
+ find(:farthest, options)
146
+ end
147
+
148
+ # Finds within rectangular bounds (sw,ne).
149
+ def find_within_bounds(bounds, options={})
150
+ options[:bounds] = bounds
151
+ find(:all, options)
152
+ end
153
+
154
+ # counts within a distance radius.
155
+ def count_within(distance, options={})
156
+ options[:within] = distance
157
+ count(options)
158
+ end
159
+ alias count_inside count_within
160
+
161
+ # Counts beyond a distance radius.
162
+ def count_beyond(distance, options={})
163
+ options[:beyond] = distance
164
+ count(options)
165
+ end
166
+ alias count_outside count_beyond
167
+
168
+ # Counts according to a range. Accepts inclusive or exclusive ranges.
169
+ def count_by_range(range, options={})
170
+ options[:range] = range
171
+ count(options)
172
+ end
173
+
174
+ # Finds within rectangular bounds (sw,ne).
175
+ def count_within_bounds(bounds, options={})
176
+ options[:bounds] = bounds
177
+ count(options)
178
+ end
179
+
180
+ # Returns the distance calculation to be used as a display column or a condition. This
181
+ # is provide for anyone wanting access to the raw SQL.
182
+ def distance_sql(origin, units=default_units, formula=default_formula)
183
+ case formula
184
+ when :sphere
185
+ sql = sphere_distance_sql(origin, units)
186
+ when :flat
187
+ sql = flat_distance_sql(origin, units)
188
+ end
189
+ sql
190
+ end
191
+
192
+ private
193
+
194
+ # Prepares either a find or a count action by parsing through the options and
195
+ # conditionally adding to the select clause for finders.
196
+ def prepare_for_find_or_count(action, args)
197
+ options = args.extract_options!
198
+ #options = defined?(args.extract_options!) ? args.extract_options! : extract_options_from_args!(args)
199
+ # Obtain items affecting distance condition.
200
+ origin = extract_origin_from_options(options)
201
+ units = extract_units_from_options(options)
202
+ formula = extract_formula_from_options(options)
203
+ bounds = extract_bounds_from_options(options)
204
+
205
+ # Only proceed if this is a geokit-related query
206
+ if origin || bounds
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
+ # Handle :through
219
+ apply_include_for_through(options)
220
+ # Unfortunatley, we need to do extra work if you use an :include. See the method for more info.
221
+ handle_order_with_include(options,origin,units,formula) if options.include?(:include) && options.include?(:order) && origin
222
+ end
223
+
224
+ # Restore options minus the extra options that we used for the
225
+ # Geokit API.
226
+ args.push(options)
227
+ end
228
+
229
+ def apply_include_for_through(options)
230
+ if self.through
231
+ case options[:include]
232
+ when Array
233
+ options[:include] << self.through
234
+ when Hash, String, Symbol
235
+ options[:include] = [ self.through, options[:include] ]
236
+ else
237
+ options[:include] = [ self.through ]
238
+ end
239
+ end
240
+ end
241
+
242
+ # If we're here, it means that 1) an origin argument, 2) an :include, 3) an :order clause were supplied.
243
+ # Now we have to sub some SQL into the :order clause. The reason is that when you do an :include,
244
+ # ActiveRecord drops the psuedo-column (specificically, distance) which we supplied for :select.
245
+ # So, the 'distance' column isn't available for the :order clause to reference when we use :include.
246
+ def handle_order_with_include(options, origin, units, formula)
247
+ # replace the distance_column_name with the distance sql in order clause
248
+ options[:order].sub!(distance_column_name, distance_sql(origin, units, formula))
249
+ end
250
+
251
+ # Looks for mapping-specific tokens and makes appropriate translations so that the
252
+ # original finder has its expected arguments. Resets the the scope argument to
253
+ # :first and ensures the limit is set to one.
254
+ def apply_find_scope(args, options)
255
+ case args.first
256
+ when :nearest, :closest
257
+ args[0] = :first
258
+ options[:limit] = 1
259
+ options[:order] = "#{distance_column_name} ASC"
260
+ when :farthest
261
+ args[0] = :first
262
+ options[:limit] = 1
263
+ options[:order] = "#{distance_column_name} DESC"
264
+ end
265
+ end
266
+
267
+ # If it's a :within query, add a bounding box to improve performance.
268
+ # This only gets called if a :bounds argument is not otherwise supplied.
269
+ def formulate_bounds_from_distance(options, origin, units)
270
+ distance = options[:within] if options.has_key?(:within)
271
+ distance = options[:range].last-(options[:range].exclude_end?? 1 : 0) if options.has_key?(:range)
272
+ if distance
273
+ res=Geokit::Bounds.from_point_and_radius(origin,distance,:units=>units)
274
+ else
275
+ nil
276
+ end
277
+ end
278
+
279
+ # Replace :within, :beyond and :range distance tokens with the appropriate distance
280
+ # where clauses. Removes these tokens from the options hash.
281
+ def apply_distance_scope(options)
282
+ distance_condition = if options.has_key?(:within)
283
+ "#{distance_column_name} <= #{options[:within]}"
284
+ elsif options.has_key?(:beyond)
285
+ "#{distance_column_name} > #{options[:beyond]}"
286
+ elsif options.has_key?(:range)
287
+ "#{distance_column_name} >= #{options[:range].first} AND #{distance_column_name} <#{'=' unless options[:range].exclude_end?} #{options[:range].last}"
288
+ end
289
+
290
+ if distance_condition
291
+ [:within, :beyond, :range].each { |option| options.delete(option) }
292
+ options[:conditions] = merge_conditions(options[:conditions], distance_condition)
293
+ end
294
+ end
295
+
296
+ # Alters the conditions to include rectangular bounds conditions.
297
+ def apply_bounds_conditions(options,bounds)
298
+ sw,ne = bounds.sw, bounds.ne
299
+ 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}"
300
+ bounds_sql = "#{qualified_lat_column_name}>#{sw.lat} AND #{qualified_lat_column_name}<#{ne.lat} AND #{lng_sql}"
301
+ options[:conditions] = merge_conditions(options[:conditions], bounds_sql)
302
+ end
303
+
304
+ # Extracts the origin instance out of the options if it exists and returns
305
+ # it. If there is no origin, looks for latitude and longitude values to
306
+ # create an origin. The side-effect of the method is to remove these
307
+ # option keys from the hash.
308
+ def extract_origin_from_options(options)
309
+ origin = options.delete(:origin)
310
+ res = normalize_point_to_lat_lng(origin) if origin
311
+ res
312
+ end
313
+
314
+ # Extract the units out of the options if it exists and returns it. If
315
+ # there is no :units key, it uses the default. The side effect of the
316
+ # method is to remove the :units key from the options hash.
317
+ def extract_units_from_options(options)
318
+ units = options[:units] || default_units
319
+ options.delete(:units)
320
+ units
321
+ end
322
+
323
+ # Extract the formula out of the options if it exists and returns it. If
324
+ # there is no :formula key, it uses the default. The side effect of the
325
+ # method is to remove the :formula key from the options hash.
326
+ def extract_formula_from_options(options)
327
+ formula = options[:formula] || default_formula
328
+ options.delete(:formula)
329
+ formula
330
+ end
331
+
332
+ def extract_bounds_from_options(options)
333
+ bounds = options.delete(:bounds)
334
+ bounds = Geokit::Bounds.normalize(bounds) if bounds
335
+ end
336
+
337
+ # Geocode IP address.
338
+ def geocode_ip_address(origin)
339
+ geo_location = Geokit::Geocoders::MultiGeocoder.geocode(origin)
340
+ return geo_location if geo_location.success
341
+ raise Geokit::Geocoders::GeocodeError
342
+ end
343
+
344
+ # Given a point in a variety of (an address to geocode,
345
+ # an array of [lat,lng], or an object with appropriate lat/lng methods, an IP addres)
346
+ # this method will normalize it into a Geokit::LatLng instance. The only thing this
347
+ # method adds on top of LatLng#normalize is handling of IP addresses
348
+ def normalize_point_to_lat_lng(point)
349
+ res = geocode_ip_address(point) if point.is_a?(String) && /^(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})?$/.match(point)
350
+ res = Geokit::LatLng.normalize(point) unless res
351
+ res
352
+ end
353
+
354
+ # Augments the select with the distance SQL.
355
+ def add_distance_to_select(options, origin, units=default_units, formula=default_formula)
356
+ if origin
357
+ distance_selector = distance_sql(origin, units, formula) + " AS #{distance_column_name}"
358
+ selector = options.has_key?(:select) && options[:select] ? options[:select] : "*"
359
+ options[:select] = "#{selector}, #{distance_selector}"
360
+ end
361
+ end
362
+
363
+ # Looks for the distance column and replaces it with the distance sql. If an origin was not
364
+ # passed in and the distance column exists, we leave it to be flagged as bad SQL by the database.
365
+ # Conditions are either a string or an array. In the case of an array, the first entry contains
366
+ # the condition.
367
+ def substitute_distance_in_conditions(options, origin, units=default_units, formula=default_formula)
368
+ condition = options[:conditions].is_a?(String) ? options[:conditions] : options[:conditions].first
369
+ pattern = Regexp.new("\\b#{distance_column_name}\\b")
370
+ condition.gsub!(pattern, distance_sql(origin, units, formula))
371
+ end
372
+
373
+ # Returns the distance SQL using the spherical world formula (Haversine). The SQL is tuned
374
+ # to the database in use.
375
+ def sphere_distance_sql(origin, units)
376
+ lat = deg2rad(origin.lat)
377
+ lng = deg2rad(origin.lng)
378
+ multiplier = units_sphere_multiplier(units)
379
+
380
+ adapter.sphere_distance_sql(lat, lng, multiplier) if adapter
381
+ end
382
+
383
+ # Returns the distance SQL using the flat-world formula (Phythagorean Theory). The SQL is tuned
384
+ # to the database in use.
385
+ def flat_distance_sql(origin, units)
386
+ lat_degree_units = units_per_latitude_degree(units)
387
+ lng_degree_units = units_per_longitude_degree(origin.lat, units)
388
+
389
+ adapter.flat_distance_sql(origin, lat_degree_units, lng_degree_units)
390
+ end
391
+
392
+ end # ClassMethods
393
+
394
+ # this is the callback for auto_geocoding
395
+ def auto_geocode_address
396
+ address=self.send(auto_geocode_field).to_s
397
+ geo=Geokit::Geocoders::MultiGeocoder.geocode(address)
398
+
399
+ if geo.success
400
+ self.send("#{lat_column_name}=", geo.lat)
401
+ self.send("#{lng_column_name}=", geo.lng)
402
+ else
403
+ errors.add(auto_geocode_field, auto_geocode_error_message)
404
+ end
405
+
406
+ geo.success
407
+ end
408
+
409
+ def self.end_of_reflection_chain(through, klass)
410
+ while through
411
+ reflection = nil
412
+ if through.is_a?(Hash)
413
+ association, through = through.to_a.first
414
+ else
415
+ association, through = through, nil
416
+ end
417
+
418
+ if reflection = klass.reflect_on_association(association)
419
+ klass = reflection.klass
420
+ else
421
+ raise ArgumentError, "You gave #{association} in :through, but I could not find it on #{klass}."
422
+ end
423
+ end
424
+
425
+ reflection
426
+ end
427
+
428
+ end # ActsAsMappable
429
+ end # Geokit
430
+
431
+
432
+
433
+ # ActiveRecord::Base.extend Geokit::ActsAsMappable