The problem
Let’s say you want to build an app where users can search nearby ramen shops, and who doesn’t love a good hot bowl of ramen? You want to prioritize performance and scalability. Your current stack consists of Ruby on Rails and PostgresSQL (awesome open-source software), but with this, it is not clear how to manage all the geolocation stuff. You need to store the spatial information for each ramen shop, in other words: latitude, and longitude. But then what do we do with all this information? You need to be able to search nearby shops, don’t you? This is where PostGIS comes into play.
PostGIS is a Postgres open-source extension which extends a spatial database. It adds support for geographic objects allowing location queries to be run in SQL.
For this tutorial, we’ll be using the following versions:
- Rails 6.1.1.4
- Ruby 3.0.2
- Postgres 12
- PostGIS 3.0
Setting up our Rails application
First of all, we need to create our Rails application with PostgresSQL as the database.
rails new ramen-shops-geolocation --database=postgresql
Adding PostGIS
Next, we need to set up the PostGIS extension into our application. First, we need to download PostGIS locally with Homebrew. Just be patient, it might take a few minutes…
brew install postgis
Then you must modify your config/database.yml file to use the PostGIS adapter. You will need to change the adapter field from postgresql to postgis:
default: &default
adapter: postgis
encoding: unicode
Add the active record PostGIS adapter to your application's gemfile
gem 'activerecord-postgis-adapter'
It extends the standard postgresql adapter to provide support for the spatial data types and features added by the PostGIS extension.
Once you modify config/database.yml and the adapter is set up. Create your database normally with:
rails db:create
The database will be created with all the PostGIS functions already installed. You can verify the installation by checking the PostGIS version in your database. First, connect to the development database any way you prefer, here I'll use psql:
psql -d ramen_shops_geolocation_development -h localhost -U postgres
Now run the following command
ramen_shops_geolocation_development=# SELECT PostGIS_Version();
postgis_version
— — — — — — — — — — — — — — — — — — — -
3.0 USE_GEOS=1 USE_PROJ=1 USE_STATS=1
(1 row)
We can see here the version installed for PostGIS is 3.0. So we are all set up!
. . .
Installing PostGIS on an existing database
There might be a situation where you want to set up PostGIS into an already existing database. There is a couple of ways to accomplish this.
Remember you’ll still need to modify the adapter in config/database.yml in advance.
- Install the extension directly into the database with any database client with this command:
CREATE EXTENSION postgis;
2. The activerecord-postgis-adapter gem provides a rake task to install the extension into an existing database.
rake db:gis:setup
. . .
Now it's time to create our spatial table where we are going to store all our ramen shop locations. Let's generate our migration file
rails g migration CreateRamenShops
There are several data types PostGIS provides to work with spatial data, we will be using ST_Point to locate a point in a geographic map. You can see the list of all the data types supported here. We'll be using a few of those later.
class CreateRamenShops < ActiveRecord::Migration[6.1]
def change
create_table :ramen_shops do |t|
t.string :name, null: false
t.boolean :takeout, default: true
t.st_point :lonlat, geographic: true, null: false
t.float :longitude, null: false
t.float :latitude, null: false
t.timestamps
end
end
end
And finally, we need to run the migration with rails db:migrateand create ourselves a model to interact with the ramen_shops table.
class RamenShop < ActiveRecord::Base
validates :name, presence: true
validates :latitude, presence: true
validates :longitude, presence: true
validates :lonlat, presence: true
validates :takeout, inclusion: { in: [ true, false ] }
before_validation :lonlat_geo_point
private
def lonlat_geo_point
self.lonlat = Geo.point(longitude, latitude)
end
end
I added a callback to create the ST_Point value based on the latitude & longitude parameters we receive when we add a new shop.
Building a Helper Class for the PostGIS adapter
The Rails PostGIS adapter is based on a library named RGeo, we can build this helper class to ease the creation of geo objects in our application.
SRID
A spatial reference identifier (SRID) is a unique identifier associated with a specific coordinate system, tolerance, and resolution. Basically is a spatial system of reference for geospatial data, in this case, we will be using SRID 4326, which is the spatial system used for GPS satellite navigation systems.
WKT
Well-known text (WKT) is a text markup language for representing vector geometry objects. It is used to transfer and store the same information in a more compact form convenient for computer processing but that is not human-readable. This is the format Postgres (PostGIS) receives and displays geographic data types in.
class Geo
SRID = 4326
def self.factory
@@factory ||= RGeo::Geographic.spherical_factory(srid: SRID)
end
def self.pairs_to_points(pairs)
pairs.map { |pair| point(pair[0], pair[1]) }
end
def self.point(longitude, latitude)
factory.point(longitude, latitude)
end
def self.line_string(points)
factory.line_string(points)
end
def self.polygon(points)
line = line_string(points)
factory.polygon(line)
end
def self.to_wkt(feature)
"srid=#{SRID};#{feature}"
end
end
As you can see in the Geo class, we are adding support for other data types such as line_string & polygon, we’ll make use of those later.
Let's add some data to our database
Now that our application is all set up, we can add some seed data to our ramen_shops table and start doing our location searches.
I took the liberty of compiling a list of ramen shops in Manhattan for our seed data:
RamenShop.create!(
name: "Ippudo 5th Avenue",
takeout: true,
latitude: 40.75754071859819,
longitude: -73.98020031125338
)
RamenShop.create!(
name: "ICHIRAN Ramen NY Times Square",
takeout: true,
latitude: 40.76050714100315,
longitude: -73.98340617705145
)
RamenShop.create!(
name: "Ivan Ramen",
takeout: true,
latitude: 40.7213882148076,
longitude: -73.98437321611266
)
RamenShop.create!(
name: "Nakamura",
takeout: true,
latitude: 40.71846088725975,
longitude: -73.98518860764264
)
RamenShop.create!(
name: "Ramen Ishida",
takeout: true,
latitude: 40.720314876320835,
longitude: -73.98866475048092
)
Searching for nearby records
In this case, we will be using a static starting point, in the next article we will be working with a dynamic location using the Google Maps API integration, but for now, this would do.
Let's say we are currently in the Empire State Building. These are the coordinates
- latitude: 40.74907425288062
- longitude: -73.98572671410412
Here's the list of distances so we can confirm our searches are correct:
- 1.04 km to Ippudo 5th Avenue
- 3.1 km to Ivan Ramen
- 3.2 km to Ramen Ishida
- 1.3 km to ICHIRAN ramen NY Times Square
- 3.4 km Nakamura ramen
So, for the first search, we will be looking for ramen shops pretty close to us, we don't really want to walk in the cold so much, so let's search for locations within 1 km. Prior to executing the query, we need to create our reference point. We'll be using the helper we created previously.
reference_point = Geo.to_wkt(Geo.point(-73.98572671410412, 40.74907425288062))
RamenShop.where(
'ST_DWithin(lonlat, :point, :distance)',
{ point: reference_point, distance: 1 * 1000 } # wants meters not kms
)
RESULTS:
RamenShop Load (63.1ms) SELECT "ramen_shops".* FROM "ramen_shops" WHERE (ST_DWithin(lonlat, 'srid=4326;POINT (-73.98572671410412 40.74907425288062)', 1000))
=> []
Aaaand…. oh, what a bummer, there are no ramen shops that close, but it seems our results are correct based on the distances list I shared before. Now we will be searching for shops within 2 km, we should be getting ICHIRAN & Ippudo locations.
reference_point = Geo.to_wkt(Geo.point(-73.98572671410412, 40.74907425288062))
RamenShop.where(
'ST_DWithin(lonlat, :point, :distance)',
{ point: reference_point, distance: 2 * 1000 } # wants meters not kms
)
RESULTS:
eRamenShop Load (14.0ms) SELECT "ramen_shops".* FROM "ramen_shops" WHERE (ST_DWithin(lonlat, 'srid=4326;POINT (-73.98572671410412 40.74907425288062)', 2000))
=>
[#<RamenShop:0x00007fabe56ef580
id: 1,
name: "Ippudo 5th Avenue",
takeout: true,
lonlat: #<RGeo::Geographic::SphericalPointImpl:0x3c28 "POINT (-73.98020031125338 40.75754071859819)">,
longitude: -73.98020031125338,
latitude: 40.75754071859819,
created_at: Tue, 28 Dec 2021 23:15:55.406010000 UTC +00:00,
updated_at: Tue, 28 Dec 2021 23:15:55.406010000 UTC +00:00>,
#<RamenShop:0x00007fabe56ece48
id: 2,
name: "ICHIRAN Ramen NY Times Square",
takeout: true,
lonlat: #<RGeo::Geographic::SphericalPointImpl:0x3c3c "POINT (-73.98340617705145 40.76050714100315)">,
longitude: -73.98340617705145,
latitude: 40.76050714100315,
created_at: Tue, 28 Dec 2021 23:15:55.422877000 UTC +00:00,
updated_at: Tue, 28 Dec 2021 23:15:55.422877000 UTC +00:00>]
Now let try to get all our records by increasing the distance to 5 kms.
reference_point = Geo.to_wkt(Geo.point(-73.98572671410412, 40.74907425288062))
RamenShop.where(
'ST_DWithin(lonlat, :point, :distance)',
{ point: reference_point, distance: 5 * 1000 } # wants meters not kms
)
RESULTS:
RamenShop Load (486.9ms) SELECT "ramen_shops".* FROM "ramen_shops" WHERE (ST_DWithin(lonlat, 'srid=4326;POINT (-73.98572671410412 40.74907425288062)', 5000))
=>
[#<RamenShop:0x00007fb848aecab0
id: 1,
name: "Ippudo 5th Avenue",
takeout: true,
lonlat: #<RGeo::Geographic::SphericalPointImpl:0x3674 "POINT (-73.98020031125338 40.75754071859819)">,
longitude: -73.98020031125338,
latitude: 40.75754071859819,
created_at: Tue, 28 Dec 2021 23:15:55.406010000 UTC +00:00,
updated_at: Tue, 28 Dec 2021 23:15:55.406010000 UTC +00:00>,
#<RamenShop:0x00007fb848b38e38
id: 2,
name: "ICHIRAN Ramen NY Times Square",
takeout: true,
lonlat: #<RGeo::Geographic::SphericalPointImpl:0x3688 "POINT (-73.98340617705145 40.76050714100315)">,
longitude: -73.98340617705145,
latitude: 40.76050714100315,
created_at: Tue, 28 Dec 2021 23:15:55.422877000 UTC +00:00,
updated_at: Tue, 28 Dec 2021 23:15:55.422877000 UTC +00:00>,
#<RamenShop:0x00007fb848b38d70
id: 3,
name: "Ivan Ramen",
takeout: true,
lonlat: #<RGeo::Geographic::SphericalPointImpl:0x369c "POINT (-73.98437321611266 40.7213882148076)">,
longitude: -73.98437321611266,
latitude: 40.7213882148076,
created_at: Tue, 28 Dec 2021 23:15:55.426797000 UTC +00:00,
updated_at: Tue, 28 Dec 2021 23:15:55.426797000 UTC +00:00>,
#<RamenShop:0x00007fb848b38ca8
id: 4,
name: "Nakamura",
takeout: true,
lonlat: #<RGeo::Geographic::SphericalPointImpl:0x36b0 "POINT (-73.98518860764264 40.71846088725975)">,
longitude: -73.98518860764264,
latitude: 40.71846088725975,
created_at: Tue, 28 Dec 2021 23:15:55.430473000 UTC +00:00,
updated_at: Tue, 28 Dec 2021 23:15:55.430473000 UTC +00:00>,
#<RamenShop:0x00007fb848b38be0
id: 5,
name: "Ramen Ishida",
takeout: true,
lonlat: #<RGeo::Geographic::SphericalPointImpl:0x36c4 "POINT (-73.98866475048092 40.720314876320835)">,
longitude: -73.98866475048092,
latitude: 40.720314876320835,
created_at: Tue, 28 Dec 2021 23:15:55.434570000 UTC +00:00,
updated_at: Tue, 28 Dec 2021 23:15:55.434570000 UTC +00:00>]
Final words
The great thing about working with this is that we can nest this query just as any other active record query. In Part ll, we will revisit this and expand our search engine. In addition to that, we will be including the google maps API to support a dynamic location and be able to add more shops to our application in real-time. And finally, we'll look into more advanced searching methods that PostGIS provides, such as bounding box and polygon searching.
References
- https://github.com/rgeo/activerecord-postgis-adapter
- https://pganalyze.com/blog/postgis-rails-geocoder
- https://postgis.net/
Thanks to Jordán and Sergio Álvarez