Scope patterns
Filterrific makes heavy use of ActiveRecord scopes. The controller forwards the filter settings to the model where each filter setting is passed as argument to the corresponding scope. This page provides resources to help you write awesome scopes for powerful filtering. You have to add these scopes to your Filterrific model.
Please refer to the example application to get the context for the patterns below.
Common scope patterns
- Sanitize your SQL!
- Filter by column values
- Search
- Sort
- Filter by existence of has_many association
- Filter by non-existence of has_many association
- Filter by existence of many-to-many association
- Filter by non-existence of many-to-many association
- Filter by ranges
- Multiple form inputs for single scope
- Scopes vs. Class methods
Sanitize your SQL!
It’s important that all filter settings are sanitized before you apply them to your Filterrific model. Otherwise you would allow an intruder to inject malicious SQL code into your database.
Rails has good documentation on how to prevent SQL injection.
TL;DR:
- OK:
where(country_id: param)
- OK:
where('students.country_id = ?', param)
- NOT OK:
where("students.country_id = '#{ param }'")
Note that attribute mass assignment issues do not apply here since we’re only reading data from the database, and not writing to it.
I love this XKCD cartoon about SQL injection!
Filter by column values
This is the simplest type of scope. You can use it to filter records
with a given attribute or foreign key of a belongs_to
association.
Scope naming convention: with_%{column name}
.
1
2
3
4
5
6
7
8
9
# filters on 'country_id' foreign key
scope :with_country_id, ->(country_ids) {
where(country_id: [*country_ids])
}
# filters on 'gender' attribute
scope :with_gender, ->(genders) {
where(gender: [*genders])
}
The country_id
scope accepts either a single value like 42
, or an array of values
like [1, 2, 3]
. The [*country_ids]
expression always casts it to an
array.
If you want to filter by a column value in a joined table, you can do one of the following:
1
2
3
4
5
6
7
8
9
10
11
# filters on 'name' column in 'countries' table
# using AR hash syntax ...
scope :with_country_name, ->(country_name) {
where(country: { name: country_name }).joins(:country)
}
# or sanitized SQL snippets for more complex needs (e.g., greater than)
scope :with_country_name, ->(country_name) {
where("countries.name = ?", country_name).joins(:country)
}
Search
There are a number of ways to search for records in your relational database.
We will show you a simple example here, and just point to some more advanced techniques further below.
The simplest form is to use SQL’s LIKE
operator. In its most basic use,
it works for both MySQL as well as PostgreSQL:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
scope :search_query, ->(query) {
# Searches the students table on the 'first_name' and 'last_name' columns.
# Matches using LIKE, automatically appends '%' to each term.
# LIKE is case INsensitive with MySQL, however it is case
# sensitive with PostGreSQL. To make it work in both worlds,
# we downcase everything.
return nil if query.blank?
# condition query, parse into individual keywords
terms = query.downcase.split(/\s+/)
# replace "*" with "%" for wildcard searches,
# append '%', remove duplicate '%'s
terms = terms.map { |e|
(e.tr("*", "%") + "%").gsub(/%+/, "%")
}
# configure number of OR conditions for provision
# of interpolation arguments. Adjust this if you
# change the number of OR conditions.
num_or_conds = 2
where(
terms.map { |_term|
"(LOWER(students.first_name) LIKE ? OR LOWER(students.last_name) LIKE ?)"
}.join(" AND "),
*terms.map { |e| [e] * num_or_conds }.flatten,
)
}
The above approach works for simple search functions with small to medium size databases. If you have more advanced needs, you might consider using regular expressions, or the PostgreSQL full text search function.
Remember that you need to use your relational database’s search function, rather than an external search service if you want to be able to combine the search filter with other Filterrific filters.
Sort
A sorting scope allows you to set the sorting of your matching records.
Scope naming convention: sorted_by
. We suggest to stick with this name
as we’ve experienced issues with other names that might collide with
ActiveRecord reserved names.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
scope :sorted_by, ->(sort_option) {
# extract the sort direction from the param value.
direction = /desc$/.match?(sort_option) ? "desc" : "asc"
case sort_option.to_s
when /^created_at_/
# Simple sort on the created_at column.
# Make sure to include the table name to avoid ambiguous column names.
# Joining on other tables is quite common in Filterrific, and almost
# every ActiveRecord table has a 'created_at' column.
order("students.created_at #{direction}")
when /^name_/
# Simple sort on the name colums
order("LOWER(students.last_name) #{direction}, LOWER(students.first_name) #{direction}")
when /^country_name_/
# This sorts by a student's country name, so we need to include
# the country. We can't use JOIN since not all students might have
# a country.
order("LOWER(countries.name) #{direction}").includes(:country).references(:country)
else
raise(ArgumentError, "Invalid sort option: #{sort_option.inspect}")
end
}
Filter by existence of has_many association
This scope filters records who have an associated has_many
object. E.g.,
the scope below finds all students who have posted at least one comment.
Naming convention: with_%{plural association name}
.
1
2
3
4
5
scope :with_comments, -> {
where(
"EXISTS (SELECT 1 from comments WHERE students.id = comments.student_id)",
)
}
You can also apply conditions on the associated record, e.g., students who have posted a comment since a given reference_time:
1
2
3
4
5
6
7
8
9
10
11
12
scope :with_comments_since, ->(reference_time) {
where([
%(
EXISTS (
SELECT 1
FROM comments
WHERE students.id = comments.student_id
AND comments.created_at >= ?)
),
reference_time,
])
}
Filter by non-existence of has_many association
Use this scope to filter records who have NO associated has_many
object.
E.g., the scope below finds all students who have never posted a comment.
Naming convention: without_%{plural association name}
.
1
2
3
4
5
6
7
scope :without_comments, -> {
where(
%(NOT EXISTS (
SELECT 1 FROM comments WHERE comments.student_id = students.id
)),
)
}
Filter by existence of many-to-many association
This scope finds students who have ANY
of the given roles, using EXISTS
and a SQL
sub query. This scope traverses the has_many :through
association between
Student
and Role
via RoleAssignment
.
Naming convention: with_any_%{singular association name}_ids
1
2
3
4
5
6
7
8
9
10
11
12
13
scope :with_any_role_ids, ->(role_ids) {
# get a reference to the join table
role_assignments = RoleAssignment.arel_table
# get a reference to the filtered table
students = Student.arel_table
# let AREL generate a complex SQL query
where(
RoleAssignment
.where(role_assignments[:student_id].eq(students[:id]))
.where(role_assignments[:role_id].in([*role_ids].map(&:to_i)))
.exists,
)
}
Note: you might be tempted to use a simple WHERE
and JOIN
clause, however
you will end up getting duplicate student records, one for each role a student
holds. You shouldn’t use a DISTINCT
clause to eliminate duplicates
since this might interfere with other scopes that we chain in the current
Filterrific query. E.g., your database might complain about sort keys not being
part of the SELECT
statement.
Use the scope below if you want to find students who have ALL
of the given roles:
Naming convention: with_all_%{singular association name}_ids
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
scope :with_all_role_ids, ->(role_ids) {
# get a reference to the join table
role_assignments = RoleAssignment.arel_table
# get a reference to the filtered table
students = Student.arel_table
# let AREL generate a complex SQL query
role_ids.map(&:to_i).inject(self) { |rel, role_id|
rel.where(
RoleAssignment
.where(role_assignments[:student_id].eq(students[:id]))
.where(role_assignments[:role_id].eq(role_id))
.exists,
)
}
}
Filter by non-existence of many-to-many association
This scope finds any students who DO NOT have a given role. It is almost identical
to the previous one. We just appended the .not
operator to invert it.
Naming convention: without_%{singular association name}_ids
1
2
3
4
5
6
7
8
9
10
11
scope :without_role_ids, ->(role_ids) {
role_assignments = RoleAssignment.arel_table
students = Student.arel_table
where(
RoleAssignment
.where(role_assignments[:student_id].eq(students[:id]))
.where(role_assignments[:role_id].in([*role_ids].map(&:to_i)))
.exists
.not,
)
}
Filter by ranges
This scope filters by a student’s signup date range. The only important concept to keep in mind when working with ranges is that you stay consistent with your boundaries. We suggest to always use semi-open intervals for consistency (i.e. you always include the lower boundary and exclude the upper boundary).
Please note that SQL’s BETWEEN
operator is inclusive, i.e. it includes both
boundaries.
Scope naming convention: with_%{column name}_gte
and with_%{column name}_lt
.
gte
is an abbreviation for ‘Greater than or equal’ and lt
is an abbreviation
for ‘Less than’.
1
2
3
4
5
6
7
8
9
# always include the lower boundary for semi open intervals
scope :created_at_gte, ->(reference_time) {
where("students.created_at >= ?", reference_time)
}
# always exclude the upper boundary for semi open intervals
scope :created_at_lt, ->(reference_time) {
where("students.created_at < ?", reference_time)
}
Multiple form inputs for single scope
Sometimes you need multiple form inputs for a single scope. An example would be a scope that finds locations within a certain distance of a city. You’d need two inputs: one for the maximum distance, and one to choose a city.
You can accomplish this using Rails’ fields_for
form helper. It allows you
to combine multiple form input values into a single param:
Let’s use a scope called with_distance
. Declare it as usual in the
filterrific
directive at the top of your model.
Here is the view code, using fields_for
:
1
2
3
4
5
6
7
8
9
10
11
12
13
<%# app/views/students/index.html.erb
Add a `fields_for` helper to your filterrific form:
%>
<%= form_for @filterrific do |f| %>
...
<div>
Distance
<%= f.fields_for :with_distance do |with_distance_fields| %>
<%= with_distance_fields.text_field :max_distance %>
<%= with_distance_fields.text_field :city %>
<% end %>
</div>
...
Then you specify the scope like so:
1
2
3
4
5
6
7
8
scope :with_distance, ->(distance_attrs) {
# `distance_attrs` is a hash with two keys:
# {
# :max_distance => '10',
# :city => 'Vancouver',
# }
where(...)
}
Scopes vs. Class methods
In certain cases, you can replace scopes with class methods. We recommend to use scopes. Please read this blog post to learn more:
http://blog.plataformatec.com.br/2013/02/active-record-scopes-vs-class-methods/