Chewy Gem with Active Model Serializers in Ruby on Rails 5.2.3
I’ll talk about how to use Chewy gem and Active Model Serializers on Rails app. Chewy is one of ElasticSearch's clients and extends elasticsearch-ruby with various extensions and utilities. It is similar to elasticsearch-rails but I recently had an opportunity to use it in some projects so I’d like to share its usage.
Set up Mysql, ElasticSearch, and Kibana with Docker
To get started with your ElasticSearch app easily, you can use Docker and Docker-compose. I have MySQL 8.0, which is for Ruby on Rails database and ElasticSearch 6.4.2 and Kibana 6.4.2 as below.
Create a docker-compose.yml
in your project folder:
version: '3'
services:
mysql:
image: mysql:8.0
volumes:
- mysqldata:/var/lib/mysql
command: mysqld --character-set-server=utf8mb4 --collation-server=utf8mb4_0900_as_ci --default-authentication-plugin=mysql_native_password
environment:
MYSQL_USER: root
MYSQL_ROOT_PASSWORD: root
ports:
- 3306:3306
elasticsearch:
image: elasticsearch:6.4.2
volumes:
- esdata:/usr/share/elasticsearch/data
environment:
- "ES_JAVA_OPTS=-Xms1g -Xmx1g"
ports:
- "9200:9200"
kibana:
image: kibana:6.4.2
ports:
- "5601:5601"
volumes:
mysqldata:
driver: local
esdata:
driver: local
And start with:
docker-compose up
Now you can access ElasticSearch with http://localhost:9200
and Kibana with http://localhost:5601
.
Set up Ruby on Rails on API mode
To keep the system gem clean, I’m going to create a Ruby on Rails project under my workspace.
Create a Gemfile
first:
$ mkdir my-chewy-app
$ cd my-chewy-app
$ bundle init
Open Gemfile
and uncomment out gem "rails"
:
# frozen_string_literal: true
source "https://rubygems.org"
git_source(:github) {|repo_name| "https://github.com/#{repo_name}" }
gem "rails"
Install bundles:
bundle install --path vendor/bundle
Create a Rails app with MySQL and API mode:
bundle exec rails new . -B -d mysql --api
If you run into such an error with mysql2
when you bundle install
:
Gem::Ext::BuildError: ERROR: Failed to build gem native extension.
....An error occurred while installing mysql2 (0.5.2), and Bundler cannot continue.
Make sure that `gem install mysql2 -v '0.5.2' --source 'https://rubygems.org/'` succeeds before bundling.
Try this one:
bundle config --local build.mysql2 "--with-ldflags=-L/usr/local/opt/openssl/lib"
And install again:
bundle install --path vendor/bundle
Open config/database.yml
and configure the local database:
# MySQL. Versions 5.1.10 and up are supported.
#
# Install the MySQL driver
# gem install mysql2
#
# Ensure the MySQL gem is defined in your Gemfile
# gem 'mysql2'
#
# And be sure to use new-style password hashing:
# https://dev.mysql.com/doc/refman/5.7/en/password-hashing.html
#
default: &default
adapter: mysql2
encoding: utf8
pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
username: root
password: root
host: 127.0.0.1
Create a database:
bundle exec rails db:create
Now you’re done with setting up Rails app so start with dev server:
bundle exec rails s
Install Chewy gem, Active Model Serializers and Kaminari
Next, you’re going to install chewy and active model serializers.
Add chewy
, active_model_serializers
and kaminari
to Gemfile
:
gem 'chewy'
gem 'active_model_serializers'
gem 'kaminari' # for pagination
Install:
bundle
Create a client setting for chewy
:
bundle exec rails g chewy:install
You can check out usages of chewy on the documentation.
Create User Index
For using ElasticSearch you will need to create an index.
Create a Users model first:
bundle exec rails g model User
Add some columns in db/migrate/xxxx_create_users.rb
:
class CreateUser < ActiveRecord::Migration[5.2]
def change
create_table :users do |t|
t.string :name
t.string :email
t.string :phone
t.integer :status, null: false, default: 0
t.timestamps
end
end
end
Migrate it:
bundle exec rails db:migrate
Edit a model in app/models/user.rb
:
class User < ApplicationRecord
update_index('users#user') { self }
enum status: { unconfirmed: 0, confirmed: 1 }
end
update_index('users#user') { self }
means that specifying index, type, and back-reference for updating after user saves or destroy. Every index is observable by all the related models in Chewy so you don’t have to manually add code that updates the index.
Create a User Index in app/chewy/user_index.rb
:
class UsersIndex < Chewy::Index
settings analysis: {
analyzer: {
email: {
tokenizer: 'keyword',
filter: ['lowercase']
}
}
}
define_type User do
field :name
field :email, analyzer: 'email'
field :phone
end
end
Okay, you finished creating an index.
Add Controller
To use an index that you made, add users controller in your project.
Generate a users controller:
bundle exec rails g controller users
Add users route in config/routes.rb
:
Rails.application.routes.draw do
# For details on the DSL available within this file, see http://guides.rubyonrails.org/routing.html
resources :users do
get :search, on: :collection
end
end
Edit controller:
class UsersController < ApplicationController
def search
@users = UsersIndex.query(query_string: { fields: [:name, :email, :phone], query: search_params[:query], default_operator: 'and' })
render json: @users.to_json, status: :ok
end
private
def search_params
params.permit(:query, :page, :per)
end
end
All right, you added search endpoint and connected to ElasticSearch using UserIndex
. But you don’t have any users on your database and ElasticSearch so you’re going to create a sample user. To simplify, I just want you to create a user on console but you can also use factory-bot for variant users.
Boot console:
bundle exec rails c
Create a user:
irb(main):002:0> User.create(name: 'test1', email: 'test1@example.com', phone: '090111111')
And you can see it created user index at the same time:
(1.4ms) SET NAMES utf8, @@SESSION.sql_mode = CONCAT(CONCAT(@@sql_mode, ',STRICT_ALL_TABLES'), ',NO_AUTO_VALUE_ON_ZERO'), @@SESSION.sql_auto_is_null = 0, @@SESSION.wait_timeout = 2147483
(1.1ms) BEGIN
User Create (1.9ms) INSERT INTO `users` (`name`, `email`, `phone`, `created_at`, `updated_at`) VALUES ('test1', 'test1@example.com', '090111111', '2019-05-03 02:33:12', '2019-05-03 02:33:12')
(10.6ms) COMMIT
User Exists (1.5ms) SELECT 1 AS one FROM `users` WHERE `users`.`id` IN (1) LIMIT 1
User Load (1.6ms) SELECT `users`.* FROM `users` WHERE `users`.`id` IN (1)
UsersIndex::User Import (355.3ms) {:index=>1}
=> #<User id: 1, name: "test1", email: "test1@example.com", phone: "090111111", status: "unconfirmed", created_at: "2019-05-03 02:33:12", updated_at: "2019-05-03 02:33:12">
All right, you can access ElasticSearch as below.
http://localhost:3000/users/search?query=test1
And you’ll see like this:
[
{
attributes: {
id: "1",
name: "test1",
email: "test1@example.com",
phone: "090111111",
_score: 0.2876821,
_explanation: null
},
_data: {
_index: "users",
_type: "user",
_id: "1",
_score: 0.2876821,
_source: {
name: "test1",
email: "test1@example.com",
phone: "090111111"
}
}
}
]
Nice! You can get a response of ElasticSearch with user data and search for information. But actually, at this endpoint, you just might need a user data and you don’t want to use to_json
and exclude only user attributes. For this purpose, you can use the Active Model Serializers.
Use Active Model Serializers
You have already installed active model serializers above so next you add UserSerializer in app/serializers/user_serializer.rb
.
Add UserSerializer:
class UserSerializer < ActiveModel::Serializer
attributes :id, :name, :email, :phone
end
Actually that’s it. And remove to_json
from render json: @users.to_json, status
in app/controllers/users_controller.rb
:
class UsersController < ApplicationController
def search
@users = UsersIndex.query(query_string: { fields: [:name, :email, :phone], query: search_params[:query], default_operator: 'and' })
render json: @users, status: :ok
end
private
def search_params
params.permit(:query, :page, :per)
end
end
And access again:
http://localhost:3000/users/search?query=test1
I expect a JSON object only with the user but you’ve got an error like this:
Because in Chewy::Query
it doesn’t have active model serialization build-in yet so you’ll have to monkey patch it.
Create config/initializers/chewy.rb
and monkey patch it:
# config/initializers/chewy.rb
Chewy::Type.class_eval do
include ActiveModel::Serializers::JSON
end
Reboot server:
$ bundle exec rails s
Access again:
http://localhost:3000/users/search?query=test1
You should get like this:
[
{
id: "1",
name: "test1",
email: "test1@example.com",
phone: "090111111"
}
]
Monkey patching doesn’t look like a good solution but for now, you cannot convert query results into a JSON object directly.
Refactoring Searching
For now, it looks like working totally fine but I want it to be more generic and extendable.
To simplify the controller you can implement a search interface with ActiveModel.
Create a app/searches/user_search.rb
:
# user_search.rb
# frozen_string_literal: true
class UserSearch
include ActiveModel::Model
DEFAULT_PER_PAGE = 10
DEFAULT_PAGE = 0
attr_accessor :query, :page, :per
def search
[query_string].compact.reduce(&:merge).page(page_num).per(per_page)
end
def query_string
index.query(query_string: { fields: [:name, :email, :phone], query: query, default_operator: 'and' }) if query.present?
end
private
def index
UsersIndex
end
def page_num
page || DEFAULT_PAGE
end
def per_page
per || DEFAULT_PER_PAGE
end
end
And introduce UserSearch
to app/controllers/users_controller
:
class UsersController < ApplicationController
def search
user_search = UserSearch.new(search_params)
@users = user_search.search
render json: @users, status: :ok
end
private
def search_params
params.permit(:query, :page, :per)
end
end
I think it’s much simpler and separated each concern of controller and form.
Conclusion
I talked about how to use Chewy with Active Model Serializer for the ElasticSearch app. If you want to use ElasticSearch query directly, you can go to Kibana console and try out a bunch of options.
I hope this article will get you interested.