Build a (better) search form in Rails with Active Model
You are being redirected to https://thoughtbot.com/blog/rails-search-form-tutorial
I recently had the opportunity to refactor a custom search form on a client project, and wanted to share some highlights through a distilled example.
Our base
We’ll start with all logic placed in the controller and view.
def index
@posts = sort_posts(Post.all).then { filter_posts(_1) }
end
private
def sort_posts(scope)
if (order = params.dig(:query, :sort))
column, direction = order.split(" ")
if column.presence_in(%w[title created_at]) && direction.presence_in(%w[asc desc])
scope.order("#{column} #{direction}")
else
[]
end
else
scope.order(created_at: :desc)
end
end
def filter_posts(scope)
filter_by_title_or_body(scope)
.then { filter_by_status(_1) }
.then { filter_by_author(_1) }
end
def filter_by_title_or_body(scope)
if (title_or_body = params.dig(:query, :title_or_body_contains).presence)
scope.where("title LIKE ? or body LIKE ?", "%#{title_or_body}%", "%#{title_or_body}%")
else
scope
end
end
def filter_by_status(scope)
if (status = params.dig(:query, :status_in)&.compact_blank&.presence)
scope.where(status:)
else
scope
end
end
def filter_by_author(scope)
if (author_id = params.dig(:query, :author_id_eq).presence)
scope.where(author_id:)
else
scope
end
end
Note that the controller is responsible for validating the parameters to ensure they aren’t tampered with.
if column.presence_in(%w[title created_at]) && direction.presence_in(%w[asc desc])
It also sets default sort values.
if (order = params.dig(:query, :sort))
# ..
else
scope.order(created_at: :desc)
end
Now let’s take a look at the corresponding form:
<h1>Posts</h1>
<%= form_with scope: :query, url: posts_path, method: :get do |form| %>
<div>
<%= form.label :title_or_body_contains, "Title or body contains" %>
<%= form.search_field :title_or_body_contains, value: params.dig(:query, :title_or_body_contains) %>
</div>
<div>
<%= form.label :sort, "Sort by" %>
<%= form.select :sort, options_for_select(
[
["Title - A to Z", "title asc"],
["Title - Z to A", "title desc"],
["Created At - Newest to Oldest", "created_at desc"],
["Created At - Oldest to Newest", "created_at asc"]
], params.dig(:query, :sort) || "created_at desc"
) %>
</div>
<div>
<%= form.label :author_id_eq, "Authored by" %>
<%= form.select :author_id_eq, options_from_collection_for_select(Author.all, "id", "name", params.dig(:query, :author_id_eq).to_i), {prompt: "Any"} %>
</div>
<%= field_set_tag "Status" do %>
<%= form.collection_check_boxes(:status_in, Post.statuses.keys, :to_s, :capitalize) do |builder| %>
<%= builder.label { builder.check_box(checked: params.dig(:query, :status_in)&.include?(builder.value)) + builder.text } %>
<% end %>
<% end %>
<%= submit_tag "Search" %>
<%= link_to "Reset", posts_path %>
<% end %>
Note that it’s responsible for setting the value
based on the params
.
Additionally, the options for sort
, author_id_eq
, and status_in
are
rendered directly in the view.
Although this code works, we can simplify it while improving ergonomics by extracting it into a model.
Extract logic into a model
As mentioned before, the controller is responsible for validation and setting default values. Those sound like the responsibilities of a model.
Let’s start by extracting the logic, and mapping the parameters to attributes.
# app/models/post/query.rb
class Post::Query
include ActiveModel::Model
include ActiveModel::Attributes
attribute :author_id_eq, :big_integer
attribute :column, :string
attribute :direction, :string
attribute :sort, :string, default: "created_at desc"
attribute :status_in, default: []
attribute :title_or_body_contains, :string
validates :column, inclusion: { in: %w[created_at title] }
validates :direction, inclusion: { in: %w[asc desc] }
def initialize(...)
super
self.sort = sort
end
def sort=(value)
super
column, direction = sort.split(" ")
assign_attributes(column:, direction:)
end
def results
if valid?
sort_posts.then { filter_posts(_1) }
else
[]
end
end
private
def sort_posts(scope = Post.all)
scope.order("#{column} #{direction}")
end
def filter_posts(scope)
filter_by_title_or_body(scope)
.then { filter_by_status(_1) }
.then { filter_by_author(_1) }
end
def filter_by_title_or_body(scope)
if (title_or_body = title_or_body_contains.presence)
scope.where("title LIKE ? or body LIKE ?", "%#{title_or_body}%", "%#{title_or_body}%")
else
scope
end
end
def filter_by_status(scope)
if (status = status_in.compact_blank.presence)
scope.where(status:)
else
scope
end
end
def filter_by_author(scope)
if (author_id = author_id_eq.presence)
scope.where(author_id:)
else
scope
end
end
end
Now we can effectively validate our attributes through the ActiveModel::Validations API instead of doing this in the controller.
We’re also able to set default values thanks to the ActiveModel::Attributes
API. Note that we assign the column
and direction
attributes from the sort
value on initialization, or when setting the sort
value directly.
Before
def sort_posts(scope)
if (order = params.dig(:query, :sort))
column, direction = order.split(" ")
if column.presence_in(%w[title created_at]) && direction.presence_in(%w[asc desc])
scope.order("#{column} #{direction}")
else
[]
end
else
scope.order(created_at: :desc)
end
end
After
def results
if valid?
sort_posts.then { filter_posts(_1) }
else
[]
end
end
With our logic extracted, we’re now able to refactor our controller.
--- a/app/controllers/posts_controller.rb
+++ b/app/controllers/posts_controller.rb
@@ -3,7 +3,8 @@ class PostsController < ApplicationController
# GET /posts or /posts.json
def index
- @posts = sort_posts(Post.all).then { filter_posts(_1) }
+ @query = Post::Query.new(params.fetch(:query, {}).permit(:author_id_eq, :sort, :title_or_body_contains, status_in: []))
+ @posts = @query.results
end
# GET /posts/1 or /posts/1.json
@@ -67,48 +68,4 @@ class PostsController < ApplicationController
def post_params
params.require(:post).permit(:title, :body, :status, :author_id)
end
-
- def sort_posts(scope)
- if (order = params.dig(:query, :sort))
- column, direction = order.split(" ")
-
- if column.presence_in(%w[title created_at]) && direction.presence_in(%w[asc desc])
- scope.order("#{column} #{direction}")
- else
- []
- end
- else
- scope.order(created_at: :desc)
- end
- end
-
- def filter_posts(scope)
- filter_by_title_or_body(scope)
- .then { filter_by_status(_1) }
- .then { filter_by_author(_1) }
- end
-
- def filter_by_title_or_body(scope)
- if (title_or_body = params.dig(:query, :title_or_body_contains).presence)
- scope.where("title LIKE ? or body LIKE ?", "%#{title_or_body}%", "%#{title_or_body}%")
- else
- scope
- end
- end
-
- def filter_by_status(scope)
- if (status = params.dig(:query, :status_in)&.compact_blank&.presence)
- scope.where(status:)
- else
- scope
- end
- end
-
- def filter_by_author(scope)
- if (author_id = params.dig(:query, :author_id_eq).presence)
- scope.where(author_id:)
- else
- scope
- end
- end
end
Update the form
Since form_with pairs well with model objects, we can simplify
our existing form by passing in our new model to the model
option.
This will alleviate the need to manually set the value
based on the params
.
Whatever values are set to the Post::Query
during initialization will
automatically be set to the corresponding field’s value
.
--- a/app/views/posts/index.html.erb
+++ b/app/views/posts/index.html.erb
@@ -4,33 +4,24 @@
<h1>Posts</h1>
-<%= form_with scope: :query, url: posts_path, method: :get do |form| %>
+<%= form_with model: @query, scope: :query, url: posts_path, method: :get do |form| %>
<div>
<%= form.label :title_or_body_contains, "Title or body contains" %>
- <%= form.search_field :title_or_body_contains, value: params.dig(:query, :title_or_body_contains) %>
+ <%= form.search_field :title_or_body_contains %>
</div>
<div>
<%= form.label :sort, "Sort by" %>
- <%= form.select :sort, options_for_select(
- [
- ["Title - A to Z", "title asc"],
- ["Title - Z to A", "title desc"],
- ["Created At - Newest to Oldest", "created_at desc"],
- ["Created At - Oldest to Newest", "created_at asc"]
- ], params.dig(:query, :sort) || "created_at desc"
- ) %>
+ <%= form.select :sort, @query.options_for_sort %>
</div>
<div>
<%= form.label :author_id_eq, "Authored by" %>
- <%= form.select :author_id_eq, options_from_collection_for_select(Author.all, "id", "name", params.dig(:query, :author_id_eq).to_i), {prompt: "Any"} %>
+ <%= form.select :author_id_eq, @query.options_for_authored_by, {prompt: "Any"} %>
</div>
<%= field_set_tag "Status" do %>
- <%= form.collection_check_boxes(:status_in, Post.statuses.keys, :to_s, :capitalize) do |builder| %>
- <%= builder.label { builder.check_box(checked: params.dig(:query, :status_in)&.include?(builder.value)) + builder.text } %>
- <% end %>
+ <%= form.collection_check_boxes(:status_in, @query.options_for_status, :to_s, :capitalize) %>
<% end %>
<%= submit_tag "Search" %>
You’ll also note that we were able to simplify how the sort
, author_id_eq
and status_in
options are set by placing that logic in the model.
--- a/app/models/post/query.rb
+++ b/app/models/post/query.rb
@@ -12,6 +12,23 @@ class Post::Query
validates :column, inclusion: { in: %w[created_at title] }
validates :direction, inclusion: { in: %w[asc desc] }
+ def options_for_sort
+ [
+ [ "Title - A to Z", "title asc" ],
+ [ "Title - Z to A", "title desc" ],
+ [ "Created At - Newest to Oldest", "created_at desc" ],
+ [ "Created At - Oldest to Newest", "created_at asc" ]
+ ]
+ end
+
+ def options_for_authored_by
+ Author.all.collect { [ _1.name, _1.id ] }
+ end
+
+ def options_for_status
+ Post.statuses.keys
+ end
+
def initialize(...)
super
self.sort = sort
A final touch
With our refactor nearly complete, there’s still an opportunity to make a minor
improvement by having the url
generated for us.
To achieve this, we’ll need to reach for resolve. What this does is map
Post::Query
to posts_url
, so that form_with
knows how to build the url
automatically. This happens by default with Active Record objects.
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -12,4 +12,8 @@ Rails.application.routes.draw do
# Defines the root path route ("/")
root "posts#index"
+
+ resolve "Post::Query" do |model|
+ route_for :posts
+ end
end
With the change to our routes, we can remove the url
from our form, and rely
on record identification.
--- a/app/views/posts/index.html.erb
+++ b/app/views/posts/index.html.erb
@@ -4,7 +4,7 @@
<h1>Posts</h1>
-<%= form_with model: @query, scope: :query, url: posts_path, method: :get do |form| %>
+<%= form_with model: @query, scope: :query, method: :get do |form| %>
<div>
<%= form.label :title_or_body_contains, "Title or body contains" %>
<%= form.search_field :title_or_body_contains %>
Wrapping up
What we’ve done here is created a form object, but for a GET
request.
While this concept isn’t new and extends beyond search forms, the key takeaway
is how effectively form_with
integrates with model objects. By using a model
object, we were able to drastically simplify our controller and form, and create
something that better adheres to Rails’ conventions.