How to Implement Custom Attribute Methods (Ransackers) for Advanced Data Manipulation in Rails
Ransackers let you define custom searchable attributes in ActiveRecord models by wrapping Arel nodes, enabling complex SQL generation for computed values, JSON queries, and sub-queries without leaving the Rails query interface.
The Ransack gem (activerecord-hackery/ransack) provides a powerful DSL for building search forms, but standard column-based searching often falls short when you need to query computed data or complex data structures. By implementing custom ransackers, you can expose arbitrary Arel expressions as searchable attributes, letting users filter by anything from reversed strings to JSONB keys and correlated subqueries.
Understanding the Ransacker Architecture
A ransacker is essentially a lightweight wrapper object that instructs Ransack how to build SQL for a custom virtual attribute.
Core Implementation in lib/ransack/ransacker.rb
The Ransack::Ransacker class, located in lib/ransack/ransacker.rb, stores the configuration and callable logic that transforms user input into Arel nodes. Each instance tracks:
- name: The symbol used in search predicates (e.g.,
:reversed_name) - type: The result type (
:string,:date,:integer) for type casting—defaults to:string - args: Arguments passed to the block, defaulting to
[:parent]to receive the Arel table - formatter: An optional proc that preprocesses user-supplied values before comparison
- callable: The block or method that constructs the Arel node
When initialized, the class delegates execution via attr_from, which invokes the callable with the appropriate arguments:
def attr_from(bindable)
call(*args.map { |arg| bindable.send(arg) })
end
Registration via ActiveRecord Adapter
When you call ransacker :foo in a model, the Ransack::Adapters::ActiveRecord::Base module (loaded by lib/ransack/active_record.rb) registers the definition in a class-level _ransackers hash. During a search, Ransack::Search looks up the ransacker by name, builds the Arel node via attr_from, applies any formatter, and combines it with the requested predicate (_eq, _cont, etc.).
Implementing Custom Ransackers for Data Manipulation
Because ransackers can return any Arel expression, you can implement sophisticated query patterns directly in your models.
Transforming Values with Formatters
Use a formatter proc to preprocess search values while defining the ransacker to transform the underlying column data. This example reverses the search input and matches against the database column:
# app/models/person.rb
class Person < ApplicationRecord
ransacker :reversed_name, formatter: proc { |v| v.reverse } do |parent|
parent.table[:name]
end
end
In your controller and view:
# app/controllers/people_controller.rb
def index
@q = Person.ransack(params[:q])
@people = @q.result(distinct: true)
end
<!-- app/views/people/index.html.erb -->
<%= search_form_for @q do |f| %>
<%= f.search_field :reversed_name_eq, placeholder: "Reverse of name" %>
<%= f.submit "Search" %>
<% end %>
Users typing "ecilA" will match records where name equals "Alice".
Querying JSONB Keys (PostgreSQL)
Expose specific keys within JSONB columns as searchable attributes using Arel infix operations:
# app/models/product.rb
class Product < ApplicationRecord
ransacker :link_type do |parent|
Arel::Nodes::InfixOperation.new(
'->>', parent.table[:properties], Arel::Nodes.build_quoted('link_type')
)
end
end
This generates SQL like:
SELECT "products".* FROM "products"
WHERE "products"."properties" ->> 'link_type' = 'twitter';
Concatenating Multiple Columns
Create a computed full-name search that is case-insensitive and handles multi-byte characters:
# app/models/user.rb
class User < ApplicationRecord
ransacker :full_name, formatter: proc { |v| v.mb_chars.downcase.to_s } do |parent|
Arel::Nodes::NamedFunction.new(
'LOWER',
[
Arel::Nodes::NamedFunction.new(
'concat_ws',
[
Arel::Nodes::SqlLiteral.new("' '"),
parent.table[:first_name],
parent.table[:last_name]
]
)
]
)
end
end
Users can now search full_name_cont to match across both first and last names as if they were a single normalized column.
Accepting Dynamic Arguments with ransacker_args
For complex logic requiring runtime parameters, use the args option to accept ransacker_args:
# app/models/person.rb
class Person < ApplicationRecord
ransacker :author_max_title_of_article_where_body_length_between,
args: [:parent, :ransacker_args] do |parent, args|
min, max = args
subquery = <<-SQL
(SELECT MAX(articles.title)
FROM articles
WHERE articles.person_id = people.id
AND CHAR_LENGTH(articles.body) BETWEEN #{min.to_i} AND #{max.to_i}
GROUP BY articles.person_id)
SQL
Arel.sql(subquery)
end
end
Invoke with explicit arguments:
Person.ransack(
conditions: [{
attributes: {
'0' => {
name: 'author_max_title_of_article_where_body_length_between',
ransacker_args: [10, 100]
}
},
predicate_name: 'cont',
values: ['Rails']
}]
).result
This returns people whose longest article title (among articles with body length between 10-100 characters) contains "Rails".
Boolean Existence Checks via Sub-queries
Implement boolean filters based on the existence of related records using raw SQL sub-queries:
# app/models/book.rb
class Book < ApplicationRecord
ransacker :price_exists do |parent|
Arel.sql("(SELECT EXISTS (SELECT 1 FROM prices WHERE prices.book_id = books.id))")
end
end
In the view:
<%= f.select :price_exists_true, [["Any", 2], ["No", 0], ["Yes", 1]] %>
This pattern filters books based on whether associated price records exist, generating efficient SQL EXISTS clauses.
How Ransackers Generate SQL
The lifecycle from form parameter to SQL clause follows a specific path through the Ransack internals:
- Lookup:
Ransack::Searchqueries the model's_ransackershash (populated bylib/ransack/active_record.rb) to find the named ransacker. - Binding: The search object calls
attr_from, passing the bindable context (typically the parent model's Arel table). - Execution: The ransacker's
callableblock executes, returning an Arel node—whether a simple column reference, a function call, or a sub-query. - Formatting: If a
formatteris defined, the user-supplied value passes through it before comparison. - Composition: Ransack combines the generated Arel node with the predicate method (like
eqormatches) to produce the final SQL condition.
As implemented in lib/ransack/ransacker.rb, this architecture separates the concerns of SQL generation from search logic, allowing you to define complex database operations declaratively while maintaining full access to Arel's expressive power.
Summary
- Ransackers are custom searchable attributes defined via the
ransackerDSL in ActiveRecord models, backed by theRansack::Ransackerclass inlib/ransack/ransacker.rb. - Each ransacker stores a callable (block or method) that builds Arel nodes, an optional formatter for input transformation, and a type for casting.
- Registration occurs in the
_ransackersclass attribute vialib/ransack/active_record.rb, making custom attributes available toRansack::Search. - You can query JSONB keys, concatenate columns, accept dynamic arguments via
ransacker_args, and execute sub-queries by returning appropriate Arel nodes or raw SQL. - The
formatteroption preprocesses search values, enabling transformations like case normalization or string reversal before database comparison.
Frequently Asked Questions
What is the difference between a ransacker and a regular ActiveRecord scope?
A ransacker exposes a virtual column that can be used with any Ransack predicate (_eq, _cont, _gteq, etc.) in search forms, whereas a scope requires predefined logic and cannot be dynamically combined with other search parameters. Ransackers appear as fields in search_form_for helpers and generate SQL through the attr_from method, while scopes are method-based filters you call directly on the relation.
How do I specify the return type for proper casting in my ransacker?
Set the :type option when defining the ransacker to match your database operation's result. According to the source in lib/ransack/ransacker.rb, valid types include :string, :date, :datetime, :integer, :float, and :boolean. The type determines how Ransack casts user input before generating the comparison. For example, ransacker :custom_date, type: :date ensures date-specific predicates work correctly.
Can I use associations inside a ransacker block?
Yes. While the default args: [:parent] provides the base table's Arel reference, you can construct joins or sub-queries manually using Arel. The fourth example above demonstrates querying an associated articles table via a correlated subquery. For simple associations, ensure you preload or join the association in your controller's result call to avoid N+1 queries, as ransackers only affect the WHERE clause generation.
Why does my ransacker raise an error when I use it with the _cont predicate?
The _cont predicate attempts to use SQL LIKE patterns, which requires the ransacker to return a string-compatible Arel node. If your ransacker returns a boolean, integer, or complex expression incompatible with LIKE, Ransack will generate invalid SQL. Either use predicate aliases compatible with your return type (like _eq for booleans) or wrap your ransacker logic in a string conversion function such as CAST or COALESCE within the Arel node construction.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →