DEV Community

Kate Travers
Kate Travers

Posted on

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 bungle 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 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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] 
Enter fullscreen mode Exit fullscreen mode

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>) 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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

Top comments (0)