geokit-rails3 0.0.3

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 (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