# Distance-Based Filtering & Ordering in Rails with PostGIS (without any gems)

Recently, during the development of the very first feature from [RecAtlas](https://recatlas.app), I had to find a simple way to filter and order by distance, which is very useful when looking for activities near your home.

## The Problem

I needed to filter and sort records by distance from a user's location. Most tutorials will tell you to reach for [geocoder](https://github.com/alexreisner/geocoder) or similar gems, but PostgreSQL already has killer geospatial support through PostGIS. Why add dependencies when your database can do the heavy lifting?

## Installing PostGIS

First, you need to install the PostGIS extension in your PostgreSQL database. Add this migration:

```ruby
class EnablePostgis < ActiveRecord::Migration[8.0]
  def change
    enable_extension 'postgis'
  end
end
```

Then make sure your model has `latitude` and `longitude` columns (use `:decimal` type with precision `10` and scale `6`):

```ruby
class AddCoordinatesToLocations < ActiveRecord::Migration[8.0]
  def change
    add_column :locations, :latitude, :decimal, precision: 10, scale: 6
    add_column :locations, :longitude, :decimal, precision: 10, scale: 6
  end
end
```

## The Solution: Two Simple Scopes

Here's the magic - two scopes that leverage PostGIS functions directly:

```ruby
# Filter: Find all records within X kilometers
scope :within_km_of, ->(lat, lng, radius_km = 5) do
  lat_f = Float(lat) rescue nil
  lng_f = Float(lng) rescue nil
  rad_f = Float(radius_km) rescue 5.0

  next all if lat_f.nil? || lng_f.nil?

  meters = (rad_f.to_f * 1000.0)
  origin_sql = "ST_SetSRID(ST_MakePoint(:lng, :lat), 4326)::geography"
  record_sql = "ST_SetSRID(ST_MakePoint(#{table_name}.longitude, #{table_name}.latitude), 4326)::geography"

  where.not(latitude: nil, longitude: nil)
    .where("ST_DWithin(#{origin_sql}, #{record_sql}, :meters)",
           lat: lat_f, lng: lng_f, meters: meters)
end

# Order: Sort by distance from a point
scope :by_distance, ->(lat, lng, direction = "ASC") do
  lat_f = Float(lat) rescue nil
  lng_f = Float(lng) rescue nil

  next all if lat_f.nil? || lng_f.nil?

  origin_sql = "ST_SetSRID(ST_MakePoint(:lng, :lat), 4326)::geography"
  record_sql = "ST_SetSRID(ST_MakePoint(#{table_name}.longitude, #{table_name}.latitude), 4326)::geography"

  distance_column = Arel.sql("distance_meters")
  ordering = direction.downcase == "desc" ? distance_column.desc.nulls_last : distance_column.asc.nulls_last

  select(Arel.sql("#{table_name}.*, ST_Distance(#{origin_sql}, #{record_sql}) AS distance_meters"))
    .reorder(ordering)
end
```

The key ingredients: `ST_DWithin` for radius filtering (faster than calculating every distance), `ST_Distance` for ordering, and converting decimal coordinates to PostGIS geography objects with SRID 4326 (WGS 84 - the standard GPS coordinate system).

## Using It

Now you can chain these scopes like any ActiveRecord query:

```ruby
# Find stores within 10km of user location
Store.within_km_of(user.latitude, user.longitude, 10)

# Find nearest restaurants, ordered by distance
Restaurant.by_distance(49.2827, -123.1207, "ASC").limit(5)

# Combine with other scopes
Event.where(active: true).within_km_of(lat, lng, 25).by_distance(lat, lng)
```

## Other Use Cases

This pattern works brilliantly for any location-based features:
- **Store locators** - Find nearest retail locations
- **Real estate listings** - Search homes within commute distance
- **Service providers** - Match users with nearby freelancers
- **Event discovery** - Show concerts happening close to you
- **Delivery zones** - Validate if an address is within service area

No gems, no fuss, just solid SQL and PostGIS doing what it does best.

---

*Built for [Rec Atlas](https://recatlas.app), a Rails 8 app aggregating recreational activities across Metro Vancouver.*
