activerecord-hackery/ransack

View on GitHub
docs/docs/going-further/ransackers.md

Summary

Maintainability
Test Coverage
---
sidebar_position: 6
title: Ransackers
---

## Add custom search functions

The main premise behind Ransack is to provide access to **Arel predicate methods**. Ransack provides special methods, called _ransackers_, for creating additional search functions via Arel.

A `ransacker` method can **return any Arel node that allows the usual predicate methods**. Custom `ransacker`s are an expert feature, and require a thorough understanding of Arel.

## Arel

Here are some resources for more information about Arel:

* [Using Arel to Compose SQL Queries](https://robots.thoughtbot.com/using-arel-to-compose-sql-queries)
* [The definitive guide to Arel, the SQL manager for Ruby](http://jpospisil.com/2014/06/16/the-definitive-guide-to-arel-the-sql-manager-for-ruby.html)
* [Creating Advanced Active Record DB Queries with Arel](https://www.cloudbees.com/blog/creating-advanced-active-record-db-queries-arel)

Ransacker methods enable search customization and are placed in the model. Arguments may be passed to a ransacker method via `ransacker_args` (see Example #6 below).

Ransackers, like scopes, are not a cure-all. Many use cases can be better solved with a standard Ransack search on a dedicated database search field, which is faster, index-able, and scales better than converting/ransacking data on the fly.

## Example Ransackers

### Search on field

_Search on the `name` field reversed:_
```ruby
# in the model:
ransacker :reversed_name, formatter: proc { |v| v.reverse } do |parent|
  parent.table[:name]
end
```
### Search using Datetime

_Convert a user `string` input and a database `datetime` field to the same `date` format to find all records with a `datetime` field (`created_at` in this example) equal to that date :_

```ruby
# in the model:
ransacker :created_at do
  Arel.sql('date(created_at)')
end
```
```erb
in the view:
<%= f.search_field(
  :created_at_date_equals, placeholder: t(:date_format)
  ) %>
...
<%= sort_link(@search, :created_at, default_order: :desc) %>
```

```ruby
# config/initializers/ransack.rb
Ransack.configure do |config|
  config.add_predicate 'date_equals',
    arel_predicate: 'eq',
    formatter: proc { |v| v.to_date },
    validator: proc { |v| v.present? },
    type: :string
end
```

#### 2.1
It seems to be enough to change the model only, but don't forget to define the type that will returned as well.

```ruby
# in the model:
ransacker :created_at, type: :date do
  Arel.sql('date(created_at)')
end
```

#### 2.2. Postgresql with time zones

If you're using different time zones for Rails and Postgresql you should expect to have some problems using the above solution.
Example:
- Rails at GMT -03:00
- Postgresql at GMT -00:00 (UTC)

A timestamp like `2019-07-18 01:21:29.826484` will be truncated to `2019-07-18`.
But for your Rails application `2019-07-18 01:21:29.826484` is `2019-07-17 22:21:29.826484` at your time zone (GMT -03:00). So it should be truncated to `2019-07-17` instead.


So, you should convert the timestamp to your current Rails time zone before extracting the date.

```ruby
# in the model:
ransacker :created_at, type: :date do
  Arel.sql("date(created_at at time zone 'UTC' at time zone '#{Time.zone.name}')")
end
```

Note that `Time.zone.name` should return a time zone string suitable for Postgresql.

### Postgres columns

_Search on a fixed key in a jsonb / hstore column:_

In this example, we are searching a table with a column called `properties` for records containing a key called `link_type`.

For anything up to and including Rails 4.1, add this to your model
```ruby
ransacker :link_type do |parent|    
  Arel::Nodes::InfixOperation.new('->>', parent.table[:properties], 'link_type')
end
```
When using Rails 4.2+ (Arel 6.0+), wrap the value in a `build_quoted` call
```ruby
ransacker :link_type do |parent|    
  Arel::Nodes::InfixOperation.new('->>', parent.table[:properties], Arel::Nodes.build_quoted('link_type'))
end
```
In the view, with a search on `link_type_eq` using a collection select (for example with options like 'twitter', 'facebook', etc.), if the user selects 'twitter', Ransack will run a query like:
```
SELECT * FROM "foos" WHERE "foos"."properties" ->> 'link_type' = 'twitter';
```

To use the JSONB contains operator @> see here: [[PostgreSQL JSONB searches]].

### Type conversions

_Convert an `integer` database field to a `string` in order to be able to use a `cont` predicate (instead of the usual `eq` which works out of the box with integers) to find all records where an integer field (`id` in this example) **contains** an input string:_

Simple version, using PostgreSQL:
```ruby
# in the model:
ransacker :id do
  Arel.sql("to_char(id, '9999999')")
end
```
and the same, using MySQL:
```ruby
ransacker :id do
  Arel.sql("CONVERT(#{table_name}.id, CHAR(8))")
end
```
A more complete version (using PostgreSQL) that adds the table name to avoid ambiguity and strips spaces from the input:
```ruby
ransacker :id do
  Arel.sql(
    "regexp_replace(
      to_char(\"#{table_name}\".\"id\", '9999999'), ' ', '', 'g')"
  )
end
```
In the view, for all 3 versions:
```erb
<%= f.search_field :id_cont, placeholder: 'Id' %>
...
<%= sort_link(@search, :id) %>
```

### Concatenated fields

_Search on a concatenated full name from `first_name` and `last_name` (several examples):_
```ruby
# in the model:
ransacker :full_name do |parent|
  Arel::Nodes::InfixOperation.new('||',
    parent.table[:first_name], parent.table[:last_name])
end

# or, to insert a space between `first_name` and `last_name`:
ransacker :full_name do |parent|
  Arel::Nodes::InfixOperation.new('||',
    Arel::Nodes::InfixOperation.new('||',
      parent.table[:first_name], ' '
    ),
    parent.table[:last_name]
  )
end
# Caveat: with Arel >= 6 the separator ' ' string in the
# preceding example needs to be quoted as follows:
ransacker :full_name do |parent|
  Arel::Nodes::InfixOperation.new('||',
    Arel::Nodes::InfixOperation.new('||',
      parent.table[:first_name], Arel::Nodes.build_quoted(' ')
    ),
    parent.table[:last_name]
  )
end

# works also in mariadb
ransacker :full_name do |parent|
  Arel::Nodes::NamedFunction.new('concat_ws',
    [Arel::Nodes::SqlLiteral.new("' '"), parent.table[:first_name], parent.table[:last_name]])
end

# case insensitive lookup
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
```

### Passing arguments

_Passing arguments to a ransacker:_
Arguments may be passed to a ransacker method via `ransacker_args`:
```ruby

class Person
  ransacker :author_max_title_of_article_where_body_length_between,
  args: [:parent, :ransacker_args] do |parent, args|
    min, max = args
    query = <<-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(query)
  end
end

# Usage
Person.ransack(
  conditions: [{
    attributes: {
      '0' => {
        name: 'author_max_title_of_article_where_body_length_between',
        ransacker_args: [10, 100]
      }
    },
    predicate_name: 'cont',
    values: ['Ransackers can take arguments']
  }]
)

=> SELECT "people".* FROM "people" WHERE (
     (SELECT MAX(articles.title)
        FROM articles
       WHERE articles.person_id = people.id
         AND CHAR_LENGTH(articles.body) BETWEEN 10 AND 100
       GROUP BY articles.person_id
     )
   LIKE '%Ransackers can take arguments%')
   ORDER BY "people"."id" DESC
```

### Dropdowns

_Adding the attribute values associated with a column name to a searchable attribute in a dropdown options (instead of a traditional column name coming from a table). This is useful if using an associated table which is acting as a join table between a parent table and domain table. This will cache the data as the selections:_

```ruby
# in the model:
Model.pluck(:name).each do |ground|
  ransacker ground.to_sym do |parent|
    Arel::Nodes::InfixOperation.new('AND',
      Arel::Nodes::InfixOperation.new('=', parent.table[:gor_name], ground),
      parent.table[:status]
    )
  end
end

# This will not include the column names in the dropdown
def self.ransackable_attributes(auth_object = nil)
  %w() + _ransackers.keys
end
```

### Testing for existence

_Testing for the existence of a row in another table via a join:_

```ruby
# in the model:
ransacker :price_exists do |parent|
  # SQL syntax for PostgreSQL -- others may differ
  # This returns boolean true or false
  Arel.sql("(select exists (select 1 from prices where prices.book_id = books.id))")
end
```

In the view
```haml
  %td= f.select :price_exists_true, [["Any", 2], ["No", 0], ["Yes", 1]]
```

### Associations

_Performing a query on an association with a differing class name:_

Say we have a model "SalesAccount", which represents a relationship between two users,
one being designated as a "sales_rep". We want to query SalesAccounts by
the name of the sales_rep:

```ruby
# in the model:
class SalesAccount < ActiveRecord::Base
  belongs_to :user
  belongs_to :sales_rep, class_name: :User

# in the controller:
  # The line below would lead to errors thrown later if not for the
  # "joins(:sales_reps)".
  @q = SalesAccount.includes(:user).joins(:sales_rep).ransack(params[:q])
  @sales_accounts = @q.result(distinct: true)
```

In the view:
```erb
<%= f.search_field :sales_rep_name_start %>
```

### Search on translations

_Search for a translated value in a jsonb column:_

_Note: There is also a gem, [Mobility Ransack](https://github.com/shioyama/mobility-ransack), which allows you to search on translated attributes independent of their storage backend._

This will work with any `jsonb` data type. In this case I have a column translated with [Mobility](https://github.com/shioyama/mobility) called `name` with the value `{'en': "Hello", 'es': "Hola"}`.

```ruby
ransacker :name do |parent|    
  Arel::Nodes::InfixOperation.new('->>', parent.table[:name], Arel::Nodes.build_quoted(Mobility.locale))
end
```

_If using Rails 4.1 or under, remove the `build_quoted` call._

You can then search for `name_eq` or `name_cont` and it will do the proper SQL.

***

Please feel free to contribute further code examples!