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 }}]