Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions docs/docs/getting-started/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ Ransack.configure do |config|
# Strip leading and trailing whitespace from string search values.
# Default is true.
config.strip_whitespace = false

# Treat blank values as conditions to search for, rather than as absent.
# Default is true (blank values are ignored).
config.ignore_blank_values = false
end
```

Expand Down Expand Up @@ -83,6 +87,49 @@ grouped form. See

:::

## Blank values

By default Ransack ignores a condition whose value is blank — an empty string,
or an array of only blank values. This is what makes an HTML search form behave
sensibly: a form submitted with its fields left empty returns every record
rather than none.

```ruby
Person.ransack(name_eq: "").result.to_sql
# => SELECT "people".* FROM "people"
```

For a JSON API this is often the wrong default, because there an empty value is
usually an explicit filter rather than an untouched form field. Setting
`ignore_blank_values` to `false` makes Ransack search for the blank value
instead of dropping it:

```ruby
Ransack.configure { |config| config.ignore_blank_values = false }

Person.ransack(name_eq: "").result.to_sql
# => SELECT "people".* FROM "people" WHERE "people"."name" = ''

Person.ransack(id_in: []).result.to_a
# => [] (an empty allowlist matches nothing, rather than matching everything)
```

A `nil` value is ignored under either setting, so params that were never sent
are still not turned into conditions.

On a non-string column a blank has no literal to compare against, so it is
treated as `NULL`: `parent_id_eq: ""` becomes `parent_id IS NULL`, and the
same applies to boolean and date columns. Inside an `_in` or with a comparison
such as `_gt`, a blank matches nothing at all rather than everything.

:::caution

Do not turn this off for a search backed by an HTML form. A blank text input
posts `""`, so with `ignore_blank_values = false` an untouched field becomes
`WHERE column = ''` and the form returns nothing.

:::

## Sorting NULLs

`fields_sort_option` controls where `NULL`s are placed when sorting:
Expand Down
24 changes: 23 additions & 1 deletion lib/ransack/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@ def []=(key, value)
default_arrow: nil,
sanitize_scope_args: true,
fields_sort_option: nil,
strip_whitespace: true
strip_whitespace: true,
ignore_blank_values: true
}

def configure
Expand Down Expand Up @@ -208,6 +209,27 @@ def strip_whitespace=(boolean)
self.options[:strip_whitespace] = boolean
end

# By default, Ransack ignores search conditions whose value is blank — an
# empty string, or an array containing only blank values. This is what makes
# a search form submitted with empty fields return every record rather than
# none, and it is almost always what an HTML form wants.
#
# Set this to false to treat a blank value as a value to search *for*:
# `name_eq: ''` then generates `WHERE name = ''`, and `id_in: []` generates a
# condition matching nothing rather than being dropped. This is usually what
# a JSON API wants, where an empty value is an explicit filter rather than an
# untouched form field.
#
# A `nil` value is ignored either way.
#
# Ransack.configure do |config|
# config.ignore_blank_values = false
# end
#
def ignore_blank_values=(boolean)
self.options[:ignore_blank_values] = boolean
end

def arel_predicate_with_suffix(arel_predicate, suffix)
if arel_predicate === Proc
proc { |v| "#{arel_predicate.call(v)}#{suffix}" }
Expand Down
27 changes: 24 additions & 3 deletions lib/ransack/predicate.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,17 @@ class Predicate
attr_reader :name, :arel_predicate, :type, :formatter, :validator,
:compound, :wants_array, :case_insensitive

# Consulted at search time rather than captured at predicate-definition
# time, so that `Ransack.options[:ignore_blank_values]` set in an
# initializer applies to the predicates registered before it ran.
DEFAULT_VALIDATOR = lambda do |v|
if Ransack.options[:ignore_blank_values]
v.respond_to?(:empty?) ? !v.empty? : !v.nil?
else
!v.nil?
end
Comment on lines +12 to +14

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

Reply from Claude (Claude Code), acting on behalf of @scarroll32.

Exactly right, and it was the worst possible failure for this option — the explicit blank vanished and the search returned every row. Fixed in 5931352: when blanks are meaningful, Predicate#validate runs the validator on the value as given rather than the cast value. The condition is then built and Arel renders the cast nil as IS NULL for eq on integer, boolean and date columns; inside _in or with _gt-style predicates it matches nothing, which is the safe direction. Four specs cover integer eq, boolean eq, _in and _gt, and the docs say what a blank means on a typed column.

end

class << self

def names
Expand Down Expand Up @@ -37,8 +48,7 @@ def initialize(opts = {})
@arel_predicate = opts[:arel_predicate]
@type = opts[:type]
@formatter = opts[:formatter]
@validator = opts[:validator] ||
lambda { |v| v.respond_to?(:empty?) ? !v.empty? : !v.nil? }
@validator = opts[:validator] || DEFAULT_VALIDATOR
@compound = opts[:compound]
@wants_array = opts.fetch(:wants_array,
@compound || Constants::IN_NOT_IN.include?(@arel_predicate))
Expand All @@ -64,7 +74,18 @@ def format(val)
end

def validate(vals, type = @type)
vals.any? { |v| validator.call(type ? v.cast(type) : v.value) }
# An explicitly empty array is a meaningful filter (matching nothing)
# rather than an absent one, but only when blank values are not ignored.
return true if vals.empty? && wants_array &&
!Ransack.options[:ignore_blank_values]

# When blank values are meaningful, validate the value as given. Casting
# first would turn '' into nil for an integer or boolean column and the
# explicit blank would be dropped — leaving no condition at all.
vals.any? do |v|
value = type && Ransack.options[:ignore_blank_values] ? v.cast(type) : v.value
validator.call(value)
end
end

def negative?
Expand Down
15 changes: 11 additions & 4 deletions lib/ransack/search.rb
Original file line number Diff line number Diff line change
Expand Up @@ -177,11 +177,18 @@ def collapse_multiparameter_attributes!(attrs)
attrs
end

# True when every element of a condition's value is blank, i.e. the field
# was submitted empty. `false` is a real value, and an explicit nil is kept
# so `name_in: [nil]` still reaches the query.
# True when a condition's value should be dropped before building. With
# `ignore_blank_values` on (the default) a blank value means "this form
# field was left empty"; with it off, only nil is treated that way and a
# blank value is a value to search for. `false` is always a real value, and
# an explicit nil inside an array is kept so `name_in: [nil]` still reaches
# the query. The `c:` pruning below shares this, so it follows the option.
def blank_condition_value?(value)
[*value].all? { |i| i.blank? && i != false && !i.nil? }
if Ransack.options[:ignore_blank_values]
[*value].all? { |i| i.blank? && i != false && !i.nil? }
else
value.nil? || (value.is_a?(Array) && !value.empty? && value.all?(&:nil?))
end
end

# The low-level `c:` API nests its values a level deeper than the shorthand
Expand Down
85 changes: 85 additions & 0 deletions spec/ransack/search_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,91 @@ module Ransack
Search.new(Person, name_eq: 'foobar')
end

context 'blank value handling' do
after { Ransack.configure { |c| c.ignore_blank_values = true } }

context 'when ignore_blank_values is true (the default)' do
it 'removes empty string conditions before building' do
expect_any_instance_of(Search).to receive(:build).with({})
Search.new(Person, name_eq: '')
end

it 'removes empty array conditions before building' do
expect_any_instance_of(Search).to receive(:build).with({})
Search.new(Person, name_in: [])
end

it 'removes conditions that are blank after whitespace stripping' do
expect_any_instance_of(Search).to receive(:build).with({})
Search.new(Person, name_eq: ' ')
end
end

context 'when ignore_blank_values is false' do
before { Ransack.configure { |c| c.ignore_blank_values = false } }

it 'keeps empty string conditions before building' do
expect_any_instance_of(Search).to receive(:build)
.with({ 'name_eq' => '' })
Search.new(Person, name_eq: '')
end

it 'keeps empty array conditions before building' do
expect_any_instance_of(Search).to receive(:build)
.with({ 'name_in' => [] })
Search.new(Person, name_in: [])
end

it 'still removes nil conditions before building' do
expect_any_instance_of(Search).to receive(:build).with({})
Search.new(Person, name_eq: nil)
end

it 'searches for the empty string with an eq predicate' do
s = Search.new(Person, name_eq: '')
field = "#{quote_table_name('people')}.#{quote_column_name('name')}"
expect(s.result.to_sql).to include "#{field} = ''"
end

it 'matches nothing for an empty array with an in predicate' do
s = Search.new(Person, name_in: [])
expect(s.result.to_a).to eq []
end

# A blank on a non-string column would be cast to nil before
# validation and the condition silently dropped — returning every
# row, which is the one outcome this option exists to prevent.
it 'treats a blank on an integer column as NULL' do
s = Search.new(Person, parent_id_eq: '')
field = "#{quote_table_name('people')}.#{quote_column_name('parent_id')}"
expect(s.result.to_sql).to include "#{field} IS NULL"
end

it 'treats a blank on a boolean column as NULL' do
s = Search.new(Person, awesome_eq: '')
field = "#{quote_table_name('people')}.#{quote_column_name('awesome')}"
expect(s.result.to_sql).to include "#{field} IS NULL"
end

it 'matches nothing for a blank inside an in predicate on an integer column' do
Person.create!(salary: 100)
expect(Search.new(Person, salary_in: ['']).result.to_a).to eq []
end

it 'matches nothing for a blank with a comparison predicate on an integer column' do
Person.create!(salary: 100)
expect(Search.new(Person, salary_gt: '').result.to_a).to eq []
end

it 'keeps the condition on the search object' do
s = Search.new(Person, children_name_eq: '')
condition = s.base[:children_name_eq]
expect(condition).not_to be_nil
expect(condition.values.first.value).to eq ''
end
end
end

context 'whitespace stripping' do
context 'when whitespace_strip option is true' do
before do
Expand Down
Loading