Geocoder Dos and Do Nots

Published Tuesday, October 08, 2019

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.

Gob Bluth has made a huge mistake

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 Trucks, 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.

References