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