Arel tables
In a client project we were asked to implement a server-side search to be filtered in the frontend.
Reasons: many entries are expected and the usual way to load a huge JSON file to the frontend coming from the serializer would be too heavy once in production.
The codebase uses interactors and Arel.
My understanding is that Arel is a way to write SQL queries in a syntax that is a mix between raw SQL, ActiveRecord and its own DSL, really, in the end.
There two major moving parts for this to work.
1) We have a serializer that gets attributes from the database and sends them to the frontend,
2) Write the (interactors) code to make the queries to the backend.
1. Serializer
Let’s imagine a recipe book where users have their own profile and post recipes.
Some attributes are on the object itself, say in the table recipes we have recipe.name
,
recipe.cost
.
Other attributes are derived from other associations, for example the author who
entered it as recipe.user.name
and recipe.user.location.name
to determine
what region the recipe is from.
The serializer will look something like:
module Cookbook
class RecipeSerializer < ApplicationSerializer
attributes(
:name,
:cost,
:user_id
)
attribute(:author) { object.user.name }
attribute(:region) { object.user.location.name }
end
end
2. Interactors
To get these attributes we need to do a couple of things.
- Write the Arel queries
- Make a
foreign_attributes_scopes.rb
file to be included in your model, to collect these queries.
Include this in your Recipe model: include ForeignAttributeScopes
Interactors
Case 1 when the attribute is on our table but we need to format or extract it in a different way, or extract some other data out of it, to return as end result, examples are dates ensuring format and time zone, or casting numbers for amounts in decimals etc. We write raw SQL:
Let us imagine we have the cost for the ingredients expressed in cents.
module Query
class FormatCostFromRecipes
include Interactor
def call
context.extract = Arel::Nodes::SqlLiteral.new(
"CAST((recipes.cost_cents / 100) AS DECIMAL(19,2))"
)
end
end
end
Case 2 when we need to make a (left, called OuterJoin in Arel land) join to reach another table, so we make a join statement, then we access the table and query it:
module Query
class JoinsRecipesToUsers
include Interactor
include InteractorJoins
def call
recipes = Cookbook::Recipe.arel_table
users = User.arel_table
context.join = recipes.create_join(
users,
recipes.create_on(users[:id].eq(recipes[:user_id])),
Arel::Nodes::OuterJoin
)
end
end
end
Case 3 is when we have case 2 but we need to make multiple joins in order to get to the table we need. So we make all the necessary joins files like case 1, plus another file to organize such joins, and query that in the foreign_attributes_scopes file.
Let’s imagine we want to join recipes to users, and then users to the user’s location to get the region the recipe is from. This is to show how to organize your query, that you had created in case 2 for both recipes to users and users to locations, assuming you have a foreign key on these tables.
module Query
class JoinsRecipesToLocations
include Interactor::Organizer
organize(
JoinsRecipesToUsers,
JoinsUsersToLocations
)
end
end
Now we can fill out our foreign_attributes_scopes.rb
module Cookbook
class Recipes
module ForeignAttributeScopes
extend ActiveSupport::Concern
ACTIVE_RECORD_JOINS = {
author: {
joins: Query::JoinsRecipesToUsers.call.joins,
identifier: User.arel_table[:name]
},
cost: {
identifier: Query::FormatCostFromRecipe.call.extract
},
region: {
joins: Query::JoinsRecipesToLocations.call.joins,
identifier: Geography::Locations.arel_table[:name]
}
}.freeze
included do
include ActiveRecordJoins
end
end
end
end
We can also leverage Alias
if we want to type less, for example
in the code snippet of case 2 we did not want to repeat, maybe in a complex file
that uses it many times, or to extract the alias to a separate definition file
and be able to call it in the various files in the queries, since it will be
heavily used, we could do:
Instead of
recipes = Cookbook::Recipe.arel_table
Make a new file alias_recipes.rb
module Query
class AliasRecipes
include Interactor
def call
context.alias = Cookbook::Recipe.arel_table.alias("recipes")
end
end
end
And use is in the query as (reference case 2 snippet of code):
def call
recipes = AliasRecipes.call.alias
users = User.arel_table
[etc etc]
Conclusion
I found it helpful to look at the schema, the serializer, and also using the rails console to see the SQL output to help me build the queries. Or if I had a SQL client that worked, building the queries by hand would have been helpful.
How do you test it in real life? Load the page in the browser and inspect in the Chrome inspector, in the Network tab the response we are getting from the controller.
How do you test it with specs? A request spec to test the response of the controller may be helpful, and a feature spec, because this is probably a very much end-user driven feature.
You can also read this beautiful blog post, I read after I wrote all this.