Geocoder Dos and Do Nots
I was working on a Rails project recently where we needed to 1) track the location of fine art delivery trucks and 2) give dispatchers visibility into which trucks were closest to a given location. Perfect use case for the Ruby geocoder
gem, right?
This gem is a long-respected fan favorite in the Ruby community, first released in 2009 and used by upwards of 23.5k projects. However, this was my first time working with it, and I managed to botch my first implementation. I wanted to share my journey from sad path to happy path, in hopes that it’ll help someone else out there, too.
Setup
We’re starting with the following schema:
# app/models/truck.rb
class Truck < ActiveRecord::Base
def location
# TODO
end
def set_location(latitude:, longitude:)
# TODO
end
end
# schema.rb
ActiveRecord::Schema.define(version: 20191008222320) do
create_table "trucks", force: :cascade do |t|
t.string "name", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
end
end
What Didn’t Work 🙅♀️
For my first pass, I opted to create a separate locations
table. The idea was to maintain separation of concerns between trucks
and locations
, keeping them loosely coupled so they’d both be easier to extend (or deprecate, in case this whole truck tracking idea didn’t work out).
I also didn’t originally like the idea of tacking latitude
and longitude
on to the existing trucks
table. Those didn’t seem like inherit properties of a truck; instead, in my mind, a truck has many locations, current and past. Another bonus of a separate locations
table is that it allows us to keep track of a truck’s past locations, which also seemed appealing to me (in case we wanted to recreate a timeline of the truck’s movements, for example).
So with that rationale in mind, I added a locations
table, model, and associations:
# app/models/location.rb
class Location < ActiveRecord::Base
belongs_to :truck
end
# app/models/truck.rb
class Truck < ActiveRecord::Base
has_many :locations
def address
# TODO
end
def location
return nil if locations.empty?
most_recent_location = locations.order(created_at: :desc).first
[most_recent_location.latitude, most_recent_location.longitude]
end
def set_location(latitude:, longitude:)
locations.create(latitude: latitude, longitude: longitude)
end
end
# schema.rb
ActiveRecord::Schema.define(version: 20191008351012) do
create_table "trucks", force: :cascade do |t|
# same as above
end
create_table "locations", force: :cascade do |t|
t.integer "truck_id", null: false
t.decimal "latitude", precision: 10, scale: 6, null: false
t.decimal "longitude", precision: 10, scale: 6, null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["truck_id"], name: "index_locations_on_truck_id"
end
end
Next, I checked the geocoder docs. Per the docs, there’s four things necessary to geocode an object:
1. Provide a method that returns an address to geocode
I’d read somewhere it was ok to stub this out to return nil, so I opted to start there, since knowing a truck’s address wasn’t one of our requirements.
2. Provide a way to store latitude and longitude coordinates.
The docs suggest adding :latitude
and :longitude
attributes to the model you’re geocoding (in our case, Truck
), but I figured I could overwrite these by adding methods that hooked into the associated Location
attributes instead.
3. Tell geocoder where to find the object’s address
I opted to start with the default examples provided in the docs: geocoded_by :address
4. Add after_validation :geocode
to model
…all of which lead to the following updates to my Truck
model:
# app/models/truck.rb
class Truck < ActiveRecord::Base
has_many :locations
geocoded_by :address
after_validation :geocode
def address
nil # stubbed, not necessary for our requirements
end
def latitude
location.try(:first)
end
def longitude
location.try(:last)
end
def location
return nil if locations.empty?
most_recent_location = locations.order(created_at: :desc).first
[most_recent_location.latitude, most_recent_location.longitude]
end
def set_location(latitude:, longitude:)
locations.create(latitude: latitude, longitude: longitude)
end
end
This configuration worked fine in terms of setting and getting location:
irb(main):001:0> truck = Truck.first
Truck Load (0.1ms) SELECT "trucks".* FROM "trucks" ORDER BY "trucks"."id" ASC LIMIT ? [["LIMIT", 1]]
=> #<Truck id: 1, name: "Maquette Fine Art Services", created_at: "2019-10-10 04:10:58", updated_at: "2019-10-10 04:10:58">
irb(main):002:0> truck.location
Location Exists (0.2ms) SELECT 1 AS one FROM "locations" WHERE "locations"."truck_id" = ? LIMIT ? [["truck_id", 1], ["LIMIT", 1]]
=> nil
irb(main):003:0> truck.set_location(latitude: 37.782267, longitude: -122.391248)
(0.2ms) begin transaction
SQL (2.9ms) INSERT INTO "locations" ("truck_id", "latitude", "longitude", "created_at", "updated_at") VALUES (?, ?, ?, ?, ?) [["truck_id", 1], ["latitude", 37.782267], ["longitude", -122.391248], ["created_at", "2019-10-10 04:12:12.953575"], ["updated_at", "2019-10-10 04:12:12.953575"]]
(5.5ms) commit transaction
=> #<Location id: 1, truck_id: 1, latitude: 0.37782267e2, longitude: -0.122391248e3, created_at: "2019-10-10 04:12:12", updated_at: "2019-10-10 04:12:12">
irb(main):004:0> truck.location
Location Load (1.3ms) SELECT "locations"."latitude", "locations"."longitude" FROM "locations" WHERE "locations"."truck_id" = ? ORDER BY "locations"."created_at" DESC LIMIT ? [["truck_id", 1], ["LIMIT", 1]]
=> [37.782267, 122.391248]
However, it broke down immediately when I tried to actually geocode anything:
irb(main):005:0> truck.geocode
Location Load (0.5ms) SELECT "locations"."latitude", "locations"."longitude" FROM "locations" WHERE "locations"."truck_id" = ? ORDER BY "locations"."created_at" DESC LIMIT ? [["truck_id", 1], ["LIMIT", 1]]
Traceback (most recent call last):
1: from (irb):5
NoMethodError (undefined method `latitude=' for #<Truck:0x00007ff3c2b58098>)
There’s not really a good workaround for this error. Adding setter methods for Truck#latitude=
and Truck#longitude=
doesn’t do the trick, because we don’t want to create new Location
records with only one of those attributes filled in. I thought about adding some kind of temp Location
object that’d be persisted after both attributes were filled in, but at that point, the code smell was pretty obvious. It shouldn’t be this difficult. I was clearly doing something wrong.
What Worked 🏄♀️
I went back to the geocoder
docs and recognized that latitude
and longitude
attributes had to live on the model I wanted to geocode (Truck
). That’s what the library expects, and things start to get gnarly if you try to override that.
Plus after giving it more thought, there’s not much to be gained by separating out these properties into a separate table.
- There’s no immediate need to keep track of past locations, so no need to make a premature optimization to support it.
- It’s just as easy to drop columns as a table, should I want to deprecate this functionality in the future.
With that in mind, the next step was to undo the work I did to add locations
, then run a migration to add latitude
and longitude
to trucks
.
# app/models/truck.rb
class Truck < ActiveRecord::Base
geocoded_by :address
after_validation :geocode
def address
nil # stubbed, not necessary for our requirements
end
def location
[latitude, longitude]
end
def set_location(latitude:, longitude:)
update(latitude: latitude, longitude: longitude)
end
end
# schema.rb
ActiveRecord::Schema.define(version: 20191008351012) do
create_table "trucks", force: :cascade do |t|
t.string "name", null: false
t.decimal "latitude", precision: 10, scale: 6, null: false
t.decimal "longitude", precision: 10, scale: 6, null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
end
end
To the great suprise of no one, following the docs’ recommended implementation allowed me to greatly simplify my code, and – most importantly – actually get it working.
irb(main):006:0> truck.set_location(latitude: 37.782267, longitude: -122.391248)
(0.1ms) begin transaction
(0.1ms) commit transaction
=> true
irb(main):007:0> truck.geocoded?
=> true
Now that we can properly geocode our Truck
s, we have access to helpful methods like near
and distance
:
irb(main):008:0> nearby_trucks = Truck.near([truck.latitude + 0.1, truck.longitude + 0.1], 50)
Truck Load (0.6ms) SELECT trucks.*, (69.09332411348201 * ABS(trucks.latitude - 37.882267) * 0.7071067811865475) + (59.836573914187355 * ABS(trucks.longitude - -122.29124800000001) * 0.7071067811865475) AS distance, CASE WHEN (trucks.latitude >= 37.882267 AND trucks.longitude >= -122.29124800000001) THEN 45.0 WHEN (trucks.latitude < 37.882267 AND trucks.longitude >= -122.29124800000001) THEN 135.0 WHEN (trucks.latitude < 37.882267 AND trucks.longitude < -122.29124800000001) THEN 225.0 WHEN (trucks.latitude >= 37.882267 AND trucks.longitude < -122.29124800000001) THEN 315.0 END AS bearing FROM "trucks" WHERE (trucks.latitude BETWEEN 37.15860808444576 AND 38.60592591555424 AND trucks.longitude BETWEEN -123.20811433750569 AND -121.37438166249433) ORDER BY distance ASC LIMIT ? [["LIMIT", 11]]
=> #<ActiveRecord::Relation [#<Truck id: 1, name: "Maquette Fine Art Services", created_at: "2019-10-13 18:22:51", updated_at: "2019-10-13 18:23:55", latitude: 0.37782267e2, longitude: -0.122391248e3>]>
irb(main):008:0> nearby_trucks.first.distance
Truck Load (1.2ms) SELECT trucks.*, (69.09332411348201 * ABS(trucks.latitude - 37.882267) * 0.7071067811865475) + (59.836573914187355 * ABS(trucks.longitude - -122.29124800000001) * 0.7071067811865475) AS distance, CASE WHEN (trucks.latitude >= 37.882267 AND trucks.longitude >= -122.29124800000001) THEN 45.0 WHEN (trucks.latitude < 37.882267 AND trucks.longitude >= -122.29124800000001) THEN 135.0 WHEN (trucks.latitude < 37.882267 AND trucks.longitude < -122.29124800000001) THEN 225.0 WHEN (trucks.latitude >= 37.882267 AND trucks.longitude < -122.29124800000001) THEN 315.0 END AS bearing FROM "trucks" WHERE (trucks.latitude BETWEEN 37.15860808444576 AND 38.60592591555424 AND trucks.longitude BETWEEN -123.20811433750569 AND -121.37438166249433) ORDER BY distance ASC LIMIT ? [["LIMIT", 1]]
=> 9.116720519305336
Takeaways
Working with the geocoder
gem for the first time was a good reminder not to over-architect too early, and most importantly, go with what the docs recommend. Especially with an established, widely-used library like geocoder
, you can trust the library authors to steer you in the right direction, especially for straight-forward use cases like mine. Let’s hope future Kate remembers this next time she tackles a new library.