Rails 4 Elasticsearch geospatial searching and model integration

In this quick post, I'll show you how simple it can be to integration Elasticsearch geospatial queries into a Rails 4 model.

# create new directory for project
mkdir rails-elasticsearch-geo
cd rails-elasticsearch-geo

# Per RVM (optional)
echo rails-elasticsearch-geo > .ruby-gemset
echo ruby-2.2.2 > .ruby-version
# reload RVM
cd .

# install rails gem
gem install rails

# create rails project
rails new . -d postgresql

# update your database.yml file credentials as necessary,
# and create a new postgresql user if needed
# ex: createuser -P -s -e rails-elasticsearch-geo

# create initial database
rake db:migrate

I added the Elasticsearch gems; edit file: Gemfile; added:

gem 'elasticsearch-rails', git: 'git://github.com/elasticsearch/elasticsearch-rails.git'
gem 'elasticsearch-model', git: 'git://github.com/elasticsearch/elasticsearch-rails.git'

..and ran bundle install to install the gems.

Generated the rails model, and updated the schema:

rails g model Location city:string state:string lat:float lon:float
rake db:migrate

Created a seeds script to parse the HTML from a website (http://www.realestate3d.com/gps/latlong.htm), collect latitude and longitude from the markup, and create the Location models. edit file: db/seeds.rb, added:

File.open(Rails.root.join('db', 'seeds', 'lat-lon.html'), 'r') do |f|
  f.each_line do |line|

    # match
    begin
      # match format: [ASH]  42.78   71.52  Nashua,NH
      next unless /^\[.*?\](\s+[\d\.]+\s+){2}[\w \(\)-\/']+,\w+/i =~ line
    rescue => e
      next
    end

    # parse
    matches = line.match /^\[.*?\]\s+([\d\.]+)\s+([\d\.]+)\s+(.*),(.*)/i
    (lat, lon, city, state) = matches[1..4]

    # create
    Location.create!({
      city: city,
      state: state,
      lat: lat,
      lon: lon
    })

  end
end

Before executing the seeds script, I revised the Location model to integrate with Elasticsearch; edit file: app/models/location.rb

require 'elasticsearch/model'

class Location < ActiveRecord::Base
  ELASTICSEARCH_MAX_RESULTS = 25

  include Elasticsearch::Model
  include Elasticsearch::Model::Callbacks
  include Elasticsearch::Model::Indexing

  mapping do
    indexes :location, type: 'geo_point'
    indexes :city,     type: 'string'
    indexes :state,    type: 'string'
  end

  def as_indexed_json(_options = {})
    as_json(only: %w(city state))
      .merge(location: {
        lat: lat.to_f, lon: lon.to_f
      })
  end

  def self.search(query = nil, options = {})
    options ||= {}

    # empty search not allowed, for now
    return nil if query.blank? && options.blank?

    # define search definition
    search_definition = {
      query: {
        bool: {
          must: []
        }
      }
    }

    unless options.blank?
      search_definition[:from] = 0
      search_definition[:size] = ELASTICSEARCH_MAX_RESULTS
    end

    # query
    if query.present?
      search_definition[:query][:bool][:must] << {
        multi_match: {
          query: query,
          fields: %w(city state),
          operator: 'and'
        }
      }
    end

    # geo spatial
    if options[:lat].present? && options[:lon].present?
      options[:distance] ||= 100

      search_definition[:query][:bool][:must] << {
        filtered: {
          filter: {
            geo_distance: {
              distance: "#{options[:distance]}mi",
              location: {
                lat: options[:lat].to_f,
                lon: options[:lon].to_f
              }
            }
          }
        }
      }
    end

    __elasticsearch__.search(search_definition)
  end
end

To ensure the mapping is set correctly before importing, force create the index:

rails c
> Location.__elasticsearch__.create_index! force: true

The mapping can now be seen here: http://localhost:9200/locations/_mapping

{
  "locations": {
    "mappings": {
      "location": {
        "properties": {
          "city": {
            "type": "string"
          },
          "location": {
            "type": "geo_point"
          },
          "state": {
            "type": "string"
          }
        }
      }
    }
  }
}

Seed the database. Because of the three included model includes, Elasticsearch will be populated as well.

rake db:seed

Execute some geo queries via rails console!

Location.search('Nashua').results.map &:_source
=> [{"city"=>"Nashua", "state"=>"NH", "location"=>{"lat"=>42.78, "lon"=>71.52}}]

Location.search('NH').results.map &:_source
=> [{"city"=>"Laconia", "state"=>"NH", "location"=>{"lat"=>43.57, "lon"=>71.43}},
 {"city"=>"Berlin", "state"=>"NH", "location"=>{"lat"=>44.58, "lon"=>71.18}},
 {"city"=>"Lebanon", "state"=>"NH", "location"=>{"lat"=>43.63, "lon"=>72.3}},
 {"city"=>"Keene", "state"=>"NH", "location"=>{"lat"=>42.9, "lon"=>72.27}},
 {"city"=>"Wolfeboro", "state"=>"NH", "location"=>{"lat"=>44.0, "lon"=>71.38}},
 {"city"=>"Concord", "state"=>"NH", "location"=>{"lat"=>43.2, "lon"=>71.5}},
 {"city"=>"Manchester", "state"=>"NH", "location"=>{"lat"=>42.93, "lon"=>71.43}},
 {"city"=>"Nashua", "state"=>"NH", "location"=>{"lat"=>42.78, "lon"=>71.52}},
 {"city"=>"Jaffrey", "state"=>"NH", "location"=>{"lat"=>42.8, "lon"=>72.0}},
 {"city"=>"Mt Washingtn", "state"=>"NH", "location"=>{"lat"=>44.27, "lon"=>71.3}}]

Location.search(nil, {lat: 42.78, lon: 71.52, distance: 25}).results.map &:_source
=> [{"city"=>"Manchester", "state"=>"NH", "location"=>{"lat"=>42.93, "lon"=>71.43}},
 {"city"=>"Nashua", "state"=>"NH", "location"=>{"lat"=>42.78, "lon"=>71.52}},
 {"city"=>"Jaffrey", "state"=>"NH", "location"=>{"lat"=>42.8, "lon"=>72.0}},
 {"city"=>"Bedford", "state"=>"MA", "location"=>{"lat"=>42.47, "lon"=>71.28}},
 {"city"=>"Fort Devens", "state"=>"MA", "location"=>{"lat"=>42.57, "lon"=>71.6}},
 {"city"=>"Lawrence", "state"=>"MA", "location"=>{"lat"=>42.72, "lon"=>71.12}}]