geokit-ar 0.0.1

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.
data/lib/geokit-ar.rb ADDED
@@ -0,0 +1,10 @@
1
+ require 'geokit'
2
+
3
+ require 'geokit-ar/core_extensions'
4
+
5
+ require 'geokit-ar/defaults'
6
+ require 'geokit-ar/adapters/abstract'
7
+ require 'geokit-ar/acts_as_mappable'
8
+
9
+ ActiveRecord::Base.send(:include, Geokit::ActsAsMappable::Glue)
10
+ Geokit::Geocoders.logger = ActiveRecord::Base.logger
@@ -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
@@ -0,0 +1,31 @@
1
+ module Geokit
2
+ module Adapters
3
+ class Abstract
4
+ class NotImplementedError < StandardError ; end
5
+
6
+ cattr_accessor :loaded
7
+
8
+ class << self
9
+ def load(klass) ; end
10
+ end
11
+
12
+ def initialize(klass)
13
+ @owner = klass
14
+ end
15
+
16
+ def method_missing(method, *args, &block)
17
+ return @owner.send(method, *args, &block) if @owner.respond_to?(method)
18
+ super
19
+ end
20
+
21
+ def sphere_distance_sql(lat, lng, multiplier)
22
+ raise NotImplementedError, '#sphere_distance_sql is not implemented'
23
+ end
24
+
25
+ def flat_distance_sql(origin, lat_degree_units, lng_degree_units)
26
+ raise NotImplementedError, '#flat_distance_sql is not implemented'
27
+ end
28
+
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,22 @@
1
+ module Geokit
2
+ module Adapters
3
+ class MySQL < Abstract
4
+
5
+ def sphere_distance_sql(lat, lng, multiplier)
6
+ %|
7
+ (ACOS(least(1,COS(#{lat})*COS(#{lng})*COS(RADIANS(#{qualified_lat_column_name}))*COS(RADIANS(#{qualified_lng_column_name}))+
8
+ COS(#{lat})*SIN(#{lng})*COS(RADIANS(#{qualified_lat_column_name}))*SIN(RADIANS(#{qualified_lng_column_name}))+
9
+ SIN(#{lat})*SIN(RADIANS(#{qualified_lat_column_name}))))*#{multiplier})
10
+ |
11
+ end
12
+
13
+ def flat_distance_sql(origin, lat_degree_units, lng_degree_units)
14
+ %|
15
+ SQRT(POW(#{lat_degree_units}*(#{origin.lat}-#{qualified_lat_column_name}),2)+
16
+ POW(#{lng_degree_units}*(#{origin.lng}-#{qualified_lng_column_name}),2))
17
+ |
18
+ end
19
+
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,22 @@
1
+ module Geokit
2
+ module Adapters
3
+ class Mysql2 < Abstract
4
+
5
+ def sphere_distance_sql(lat, lng, multiplier)
6
+ %|
7
+ (ACOS(least(1,COS(#{lat})*COS(#{lng})*COS(RADIANS(#{qualified_lat_column_name}))*COS(RADIANS(#{qualified_lng_column_name}))+
8
+ COS(#{lat})*SIN(#{lng})*COS(RADIANS(#{qualified_lat_column_name}))*SIN(RADIANS(#{qualified_lng_column_name}))+
9
+ SIN(#{lat})*SIN(RADIANS(#{qualified_lat_column_name}))))*#{multiplier})
10
+ |
11
+ end
12
+
13
+ def flat_distance_sql(origin, lat_degree_units, lng_degree_units)
14
+ %|
15
+ SQRT(POW(#{lat_degree_units}*(#{origin.lat}-#{qualified_lat_column_name}),2)+
16
+ POW(#{lng_degree_units}*(#{origin.lng}-#{qualified_lng_column_name}),2))
17
+ |
18
+ end
19
+
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,22 @@
1
+ module Geokit
2
+ module Adapters
3
+ class PostgreSQL < Abstract
4
+
5
+ def sphere_distance_sql(lat, lng, multiplier)
6
+ %|
7
+ (ACOS(least(1,COS(#{lat})*COS(#{lng})*COS(RADIANS(#{qualified_lat_column_name}))*COS(RADIANS(#{qualified_lng_column_name}))+
8
+ COS(#{lat})*SIN(#{lng})*COS(RADIANS(#{qualified_lat_column_name}))*SIN(RADIANS(#{qualified_lng_column_name}))+
9
+ SIN(#{lat})*SIN(RADIANS(#{qualified_lat_column_name}))))*#{multiplier})
10
+ |
11
+ end
12
+
13
+ def flat_distance_sql(origin, lat_degree_units, lng_degree_units)
14
+ %|
15
+ SQRT(POW(#{lat_degree_units}*(#{origin.lat}-#{qualified_lat_column_name}),2)+
16
+ POW(#{lng_degree_units}*(#{origin.lng}-#{qualified_lng_column_name}),2))
17
+ |
18
+ end
19
+
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,53 @@
1
+ module Geokit
2
+ module Adapters
3
+ class SQLite < Abstract
4
+
5
+ def self.add_numeric(name)
6
+ @@connection.create_function name, 1, :numeric do |func, *args|
7
+ func.result = yield(*args)
8
+ end
9
+ end
10
+
11
+ def self.add_math(name)
12
+ add_numeric name do |*n|
13
+ Math.send name, *n
14
+ end
15
+ end
16
+
17
+ class << self
18
+ def load(klass)
19
+ @@connection = klass.connection.raw_connection
20
+ # Define the functions needed
21
+ add_math 'sqrt'
22
+ add_math 'cos'
23
+ add_math 'acos'
24
+ add_math 'sin'
25
+
26
+ add_numeric('pow') { |n, m| n**m }
27
+ add_numeric('radians') { |n| n * Math::PI / 180 }
28
+ add_numeric('least') { |*args| args.min }
29
+ end
30
+ end
31
+
32
+ def sphere_distance_sql(lat, lng, multiplier)
33
+ %|
34
+ (CASE WHEN #{qualified_lat_column_name} IS NULL OR #{qualified_lng_column_name} IS NULL THEN NULL ELSE
35
+ (ACOS(least(1,COS(#{lat})*COS(#{lng})*COS(RADIANS(#{qualified_lat_column_name}))*COS(RADIANS(#{qualified_lng_column_name}))+
36
+ COS(#{lat})*SIN(#{lng})*COS(RADIANS(#{qualified_lat_column_name}))*SIN(RADIANS(#{qualified_lng_column_name}))+
37
+ SIN(#{lat})*SIN(RADIANS(#{qualified_lat_column_name}))))*#{multiplier})
38
+ END)
39
+ |
40
+ end
41
+
42
+ def flat_distance_sql(origin, lat_degree_units, lng_degree_units)
43
+ %|
44
+ (CASE WHEN #{qualified_lat_column_name} IS NULL OR #{qualified_lng_column_name} IS NULL THEN NULL ELSE
45
+ SQRT(POW(#{lat_degree_units}*(#{origin.lat}-#{qualified_lat_column_name}),2)+
46
+ POW(#{lng_degree_units}*(#{origin.lng}-#{qualified_lng_column_name}),2))
47
+ END)
48
+ |
49
+ end
50
+
51
+ end
52
+ end
53
+ end