diff --git a/docs/docs/getting-started/configuration.md b/docs/docs/getting-started/configuration.md index 58e9f06f2..b9266460f 100644 --- a/docs/docs/getting-started/configuration.md +++ b/docs/docs/getting-started/configuration.md @@ -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 ``` @@ -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: diff --git a/lib/ransack/configuration.rb b/lib/ransack/configuration.rb index e9776391d..6a94ac093 100644 --- a/lib/ransack/configuration.rb +++ b/lib/ransack/configuration.rb @@ -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 @@ -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}" } diff --git a/lib/ransack/predicate.rb b/lib/ransack/predicate.rb index 649418116..e41d9735b 100644 --- a/lib/ransack/predicate.rb +++ b/lib/ransack/predicate.rb @@ -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 + end + class << self def names @@ -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)) @@ -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? diff --git a/lib/ransack/search.rb b/lib/ransack/search.rb index 303107957..8d69bddd6 100644 --- a/lib/ransack/search.rb +++ b/lib/ransack/search.rb @@ -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 diff --git a/spec/ransack/search_spec.rb b/spec/ransack/search_spec.rb index 2f7250acb..4d71bb315 100644 --- a/spec/ransack/search_spec.rb +++ b/spec/ransack/search_spec.rb @@ -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