Rendering Paperclip url via Elasticsearch without making calls to ActiveRecord DB
ArticleResources this article is dealing with:
Imagine that we have an existing application that is storing file attachments in a Ruby on Rails application with Paperclip library and persisting reference to relational database (e.g.: PostgreSQL).
note: It can be any other Ruby based file attachments library (e.g. CarrierWave, Dragonfly)
So for example we will have a dummy Real Estate application that have model Property
showing #title
and #description
of the properties on the market. Property
has many
images
.
For simplicity of our code example we are rendering just urls via JSON API but same principals will apply if you want to generate server side HTML via ERB, Slim, Haml, …
Relational DB example first
We will setup Paperclip standard setup as described in https://github.com/thoughtbot/paperclip#models
on our Image
model mounting Paperclip on
attachement
with two styles thumb
and screen
.
# Gemfile
# ...
gem 'pg'
gem 'paperclip'
# ...
# app/model/property.rb
class Property < ActiveRecord::Base
# DB attributes :id, :title, :description
has_many :images
end
# app/model/image.rb
class Image < ActiveRecord::Base
# DB attributes :id, attachment_file_name, :updated_at
belongs_to :property
has_attached_file :attachment, styles: { thumb: '100x100>', screen: '1024x1024' }
def screen_url
attachment.url(:screen)
end
def thumb_url
attachment.url(:thumb)
end
end
Our controller looks like this:
# app/controller/properties_controller.rb
class PropertiesController < ApplicationController
def index
render json: properties_as_json, layout: false
end
private
def set_properties
if search_term
@properties = Property.where("title LIKE '%?%'", search_term)
else
@properties = Property.all
end
end
def search_term
params["q"]
end
def properties_as_json
PropertesSerializer.new(collection: @properties).as_json
end
end
# config/routes.rb
# ...
resources :properties, only: [:index]
# ...
# app/serializer/properties_serializer.rb
class PropertesSerializer
attr_reader :collection
def initialize(collection:)
@collection = collection
end
def as_json
collection.map do |property|
{
id: property.id.to_i,
title: property.title,
description: property.description,
images: property.images.map do |i|
{
thumb: i.thumb_url
screen: i.screen_url
}
end
}
end
end
end
JSON representation is generated by PropertesSerializer
which is
just plain old Ruby object generating hash that is turned to JSON on
controller level by Rails built in render json: {}
Result will contain either all records when no search query parameter provided, or just results matching the search query.
curl localhost:3000/properties?q=cool
[
{
"id": 123,
"title": "really cool property",
"description":"foobar",
"images": [
{
"thumb": "http://localhost:3000/..../thumb/foo.jpg"
"screen": "http://localhost:3000/..../screen/foo.jpg"
},
{
"thumb": "http://localhost:3000/..../thumb/bar.jpg"
"screen": "http://localhost:3000/..../screen/bar.jpg"
}
]
}
]
Elasticsearch solution
Now the relational DB solution above is good enough if you have low traffic or really basic search requirements. But once you want to do more sophisticated search queries or faster displaying of search results it’s time to introduce Elasticsearch.
I’ve seen several times people introducing elasticsearch-rails
gem to do
the search query and then just collecting id
of records and using
ActiveRecord to fetch the records.
es_result = Property.search(query: { match_all: {} } )
Property
.where(id: es_result.map(:id).map(:to_i)) #first SQL query call
.each do |property|
property.images # ...will call another SQL query
end
In my opinion that’s an huge waste of
resources and time as elasticsearch-model
and elasticsearch-rails
provide
really good data-mapper solution so that you won’t need to call
relational DB.
In this example we will go with this opinion and we won’t make any SQL call when searching.
We will use ActiveRecord relations just for
constructing Elasticsarch index (#as_indexed_json
method)
Let’s add Elasticsearch Rails libraries to our Gemfile
# Gemfile
# ...
gem 'pg'
gem 'elasticsearch-model'
gem 'elasticsearch-rails'
gem 'paperclip'
# ...
…and to the model we want to search (as described in https://github.com/elastic/elasticsearch-rails#usage)
We will be searching only on Property
model and we will use image
attributes in it’s Document/Index
# app/model/property.rb
class Property < ActiveRecord::Base
include Elasticsearch::Model
include Elasticsearch::Model::Callbacks
mapping do
indexes :title
end
has_many :images
def as_indexed_json
{
id: id,
title: title,
description: description,
images: images.map(&:as_indexed_json)
}
end
end
# app/model/image.rb
class Image < ActiveRecord::Base
belongs_to :property, touch: true
has_attached_file :attachment, styles: { thumb: '100x100>', screen: '1024x1024' }
def screen_url
attachment.url(:screen)
end
def thumb_url
attachment.url(:thumb)
end
def as_indexed_json
{
id: image.id,
updated_at: image.updated_at,
attachment_file_name: image.attachment_file_name,
}
end
end
Now let’s update our controller and instead of ActiveRecord
search use
Elasticsearch search.
# app/controller/properties_controller.rb
class PropertiesController < ApplicationController
def index
render json: properties_as_json, layout: false
end
private
def set_properties
if search_term
@properties = Property.search({
query: {
term: { title: search_term }
}
})
else
@properties = Property.search(query: { match_all: {} })
end
end
def search_term
params["q"]
end
def properties_as_json
PropertesSerializer.new(collection: @properties).as_json
end
end
Property.search
is just alias forProperty.__elasticsearch__.search
provided by Elasticsearch Rails gems
Now here comes the tricky part: “Generating the Paperclip images attachement urls”.
Again easiest way would just be to fetch the Image.find_by(id: es_image.id)
but that would make
Unnecessary SQL calls.
Instead of that we will just initialize Image
instance and we will pass
the attributes required by Paperclip to generate the url. These
attributes are:
id
- inner part of pathupdated_at
- in order to generate timestamp query urls (e.g.:http://localhost/.../foo.jpg?1467114169
)attachment_file_name
- file identifier
depending on your configuration different attributes may be required too.
# app/serializer/properties_serializer.rb
class PropertesSerializer
attr_reader :collection
def initialize(collection:)
@collection = collection
end
def as_json
collection.map do |property|
{
id: property.id,
title: property.title,
description: property.description,
images: property.images.map do |es_image|
{
thumb: es_image_to_image(es_image).thumb_url,
screen: es_image_to_image(es_image).screen_url
}
end
}
end
end
# This image instance is purely for generating urls don't
# persist any data on it
def es_image_to_image(es_image)
Image.new({
id: es_image.id,
updated_at: es_image.updated_at,
attachment_file_name: es_image.attachment_file_name,
})
end
end
Lunch Rails console and run:
Property.__elasticsearch__.create_index!
Property.import
…and restart Rails server.
Now you should have fully functional Elasticsearch that is rendering image urls without the need to access PostgreSQL data.
curl localhost:3000/properties?q=cool
[
{
"id": 123,
"title": "really cool property",
"description":"foobar",
"images": [
{
"thumb": "http://localhost:3000/..../thumb/foo.jpg"
"screen": "http://localhost:3000/..../screen/foo.jpg"
},
{
"thumb": "http://localhost:3000/..../thumb/bar.jpg"
"screen": "http://localhost:3000/..../screen/bar.jpg"
}
]
}
]
note I wrote this article just based on what I’ve implemented in different Rails project few days ago, I didn’t tested the code example so there may be typos. However I’ve created this Gits that contains example that I’m sure works.
Other Elasticsearch examples:
Entire blog website and all the articles can be forked from this Github Repo