From 76cef9c3cf9db8c8e7ff5887284d37ff1630dac2 Mon Sep 17 00:00:00 2001 From: Igor Fedoronchuk Date: Sun, 18 Oct 2026 13:34:34 +0200 Subject: [PATCH 01/24] Value policies judge a multi-valued filter element by element A :check_boxes or multi-select filter submits an Array of Strings - the shape that input legitimately has - and the scalar policies refused it as a bypass, so a sanitize:/validate: on such a filter rejected every real submission, and an Array default: was judged as its Array#to_s and raised "violates its own validate:" on the first index request. Arrays are now sanitized and validated element-wise, in the request and at materialization; any other shape stays a refused bypass. --- .../filter_defaults.rb | 45 +++++++++++------ .../policies.rb | 50 +++++++++++++------ spec/integration/policies_spec.rb | 31 ++++++++++++ spec/support/rails_template.rb | 12 +++++ 4 files changed, 107 insertions(+), 31 deletions(-) diff --git a/lib/active_admin_filter_extensions/filter_defaults.rb b/lib/active_admin_filter_extensions/filter_defaults.rb index 4a3831e..b230177 100644 --- a/lib/active_admin_filter_extensions/filter_defaults.rb +++ b/lib/active_admin_filter_extensions/filter_defaults.rb @@ -163,24 +163,17 @@ def validate_default_against_value_policies(attribute, options, result, keys) value = result[key] next if value.nil? - # Scalar values go through as the String the URL will say - a -20 under :digits must - # search as "20" on every format. Multi-valued defaults are not scalar submissions. - if sanitizer && !value.is_a?(Enumerable) + # Judged the way the URL will say it: a scalar as the String it becomes, an Array + # element by element - the shape a multi-valued input submits. + if sanitizer resolved = Policies::Sanitizers.resolve(sanitizer) - sanitized = ::MethodOrProcHelper.render_in_context(self, resolved, value.to_s.dup).to_s - # The same fixpoint check submitted values face: without it the CSV searches the - # once-sanitized value while the HTML converges on another, and nobody raises. - if ::MethodOrProcHelper.render_in_context(self, resolved, sanitized.dup).to_s != sanitized - raise ArgumentError, - "filter :#{attribute}'s sanitize: is not idempotent " \ - "(#{value.inspect} -> #{sanitized.inspect} -> keeps changing) - " \ - "a default must sanitize to a fixpoint" - end - - result[key] = sanitized + result[key] = sanitize_default_value(attribute, resolved, value) end next unless validator - next unless ::MethodOrProcHelper.render_in_context(self, Policies::Validators.resolve(validator), result[key].to_s) + + resolved_validator = Policies::Validators.resolve(validator) + next unless default_value_strings(result[key]) + .any? { |string| ::MethodOrProcHelper.render_in_context(self, resolved_validator, string) } raise ArgumentError, "filter :#{attribute} declares a default that violates its own validate: " \ @@ -188,6 +181,28 @@ def validate_default_against_value_policies(attribute, options, result, keys) end end + # An Array default is submitted element by element; anything else as one String. + def default_value_strings(value) + value.is_a?(Array) ? value.map(&:to_s) : [value.to_s] + end + + def sanitize_default_value(attribute, sanitizer, value) + return value.map { |element| sanitize_default_value(attribute, sanitizer, element) } if value.is_a?(Array) + return value if value.is_a?(Enumerable) + + sanitized = ::MethodOrProcHelper.render_in_context(self, sanitizer, value.to_s.dup).to_s + # The same fixpoint check submitted values face: without it the CSV searches the + # once-sanitized value while the HTML converges on another, and nobody raises. + if ::MethodOrProcHelper.render_in_context(self, sanitizer, sanitized.dup).to_s != sanitized + raise ArgumentError, + "filter :#{attribute}'s sanitize: is not idempotent " \ + "(#{value.inspect} -> #{sanitized.inspect} -> keeps changing) - " \ + "a default must sanitize to a fixpoint" + end + + sanitized + end + private def requested_filtering_params diff --git a/lib/active_admin_filter_extensions/policies.rb b/lib/active_admin_filter_extensions/policies.rb index 7c8287c..860d470 100644 --- a/lib/active_admin_filter_extensions/policies.rb +++ b/lib/active_admin_filter_extensions/policies.rb @@ -231,34 +231,26 @@ def enforce_scalar_policies(q, attribute, options, keys) original = value - # Ransack executes a Hash- or Array-wrapped value byte-identically to the scalar; - # a policed key that is not a plain string is a bypass, not a shape to ignore. - unless value.is_a?(String) + # A `:check_boxes` or multi-select filter submits an Array of Strings - the shape + # that input legitimately has - so the policies judge it element by element. Any + # other shape (a Hash, a nested Array) executes byte-identically to the scalar on + # Ransack's side and is a bypass, not a shape to ignore. + unless policed_value_shape?(value) apply_invalid_filter_policy(attribute, options, scalar_reset_scope(options, keys, key), :invalid) next end if (sanitizer = options[:sanitize]) sanitizer = Sanitizers.resolve(sanitizer) - # On dup'd copies: a sanitizer that mutates in place and returns the same - # object would otherwise be compared against itself and wave its loop through. - sanitized = ::MethodOrProcHelper.render_in_context(self, sanitizer, value.dup).to_s - # The sanitized value lands in the URL and comes back for - # another pass - a sanitizer that keeps rewriting its own output redirect-loops - # the index into unreachability. Catch it on the first request instead. - if sanitized != value && - ::MethodOrProcHelper.render_in_context(self, sanitizer, sanitized.dup).to_s != sanitized - raise ArgumentError, - "filter :#{attribute}'s sanitize: is not idempotent " "(#{value.inspect} -> #{sanitized.inspect} -> keeps changing) - " "that is an infinite redirect" - end - + sanitized = sanitize_policed_value(attribute, sanitizer, value) value = sanitized q[key] = value end next unless (validator = options[:validate]) - message = ::MethodOrProcHelper.render_in_context(self, Validators.resolve(validator), value) + validator = Validators.resolve(validator) + message = Array(value).lazy.filter_map { |element| ::MethodOrProcHelper.render_in_context(self, validator, element) }.first # nil AND false mean "valid" - `condition && "message"` is how Ruby writes these. next unless message @@ -269,6 +261,32 @@ def enforce_scalar_policies(q, attribute, options, keys) end end + # A String, or an Array of Strings - what a multi-valued input submits. + def policed_value_shape?(value) + value.is_a?(String) || (value.is_a?(Array) && value.all?(String)) + end + + # Element-wise for an Array, so a multi-valued filter is sanitized the way it is + # submitted. On dup'd copies: a sanitizer that mutates in place and returns the same + # object would otherwise be compared against itself and wave its loop through. + def sanitize_policed_value(attribute, sanitizer, value) + return value.map { |element| sanitize_policed_value(attribute, sanitizer, element) } if value.is_a?(Array) + + sanitized = ::MethodOrProcHelper.render_in_context(self, sanitizer, value.dup).to_s + # The sanitized value lands in the URL and comes back for another pass - a sanitizer + # that keeps rewriting its own output redirect-loops the index into unreachability. + # Catch it on the first request instead. + if sanitized != value && + ::MethodOrProcHelper.render_in_context(self, sanitizer, sanitized.dup).to_s != sanitized + raise ArgumentError, + "filter :#{attribute}'s sanitize: is not idempotent " \ + "(#{value.inspect} -> #{sanitized.inspect} -> keeps changing) - " \ + "that is an infinite redirect" + end + + sanitized + end + # On a window-policied filter a value violation resets ALL its keys, not just the # offender: half the admin's window completed from the default pair is a window nobody # asked for and nothing validated. diff --git a/spec/integration/policies_spec.rb b/spec/integration/policies_spec.rb index 7c8ca73..cef7ec5 100644 --- a/spec/integration/policies_spec.rb +++ b/spec/integration/policies_spec.rb @@ -93,6 +93,37 @@ def query_of(url) end end + describe "a multi-valued filter" do + it "lets its Array submission through" do + visit "/admin/policed_boxes_posts?q%5Bstatus_in%5D%5B%5D=draft" + + expect(page).to have_no_content("No x statuses.") + expect(page).to have_content("drop me") + expect(page).to have_no_content("keep me") + end + + it "judges each element and resets the key on a bad one" do + visit "/admin/policed_boxes_posts?q%5Bstatus_in%5D%5B%5D=draft&q%5Bstatus_in%5D%5B%5D=xdraft" + + expect(page).to have_content("No x statuses.") + expect(page).to have_content("keep me") + expect(page).to have_no_content("drop me") + end + + it "still refuses a Hash-wrapped value on the same filter" do + visit "/admin/policed_boxes_posts?q%5Bstatus_in%5D%5Bvalue%5D=draft" + + expect(page).to have_content("has an invalid value") + end + + it "materializes an Array default beside its validator" do + visit "/admin/policed_boxes_posts" + + expect(page).to have_content("keep me") + expect(page).to have_no_content("drop me") + end + end + describe "message: overrides the violation text" do it "speaks the filter's own words for a built-in validator" do visit "/admin/ip_guarded_posts?q%5Btitle_cont%5D=not-an-ip" diff --git a/spec/support/rails_template.rb b/spec/support/rails_template.rb index d981541..4f1c149 100644 --- a/spec/support/rails_template.rb +++ b/spec/support/rails_template.rb @@ -95,6 +95,18 @@ def current_admin_user filter :status, as: :check_boxes, collection: %w[draft published], extensions: { default: ["published"] } end + ActiveAdmin.register Post, as: "PolicedBoxesPost" do + # A multi-valued filter under a value policy: _in[] submits an Array of Strings, which + # is the shape this input legitimately has - not a bypass. + filter :status, as: :check_boxes, collection: %w[draft published], + extensions: { default: ["published"], + validate: lambda { |value| + next "Letters only." unless value.match?(/\\A[a-z]+\\z/) + + "No x statuses." if value.start_with?("x") + } } + end + ActiveAdmin.register Post, as: "BooleanPost" do filter :starred, extensions: { default: true } end From 7124112696dc747ec4098584197c97fa4b93fbaf Mon Sep 17 00:00:00 2001 From: Igor Fedoronchuk Date: Sun, 18 Oct 2026 13:34:45 +0200 Subject: [PATCH 02/24] The dev bundle resolves the Rails the project supports CI already runs 8.0 and 8.1 and the README states that floor; the dev pin still resolved 7.2, so a local `rake spec` tested a Rails this gem no longer supports. --- Gemfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile b/Gemfile index fb68db9..4d98681 100644 --- a/Gemfile +++ b/Gemfile @@ -4,7 +4,7 @@ source "https://rubygems.org" gemspec -default_rails_version = "7.2.0" +default_rails_version = "8.0.0" default_activeadmin_version = "3.5.0" gem "rails", "~> #{ENV['RAILS'] || default_rails_version}" From 0acb57bf162bd4e22299c8dacbec7c38ed8e0029 Mon Sep 17 00:00:00 2001 From: Igor Fedoronchuk Date: Sun, 18 Oct 2026 13:36:03 +0200 Subject: [PATCH 03/24] require_any_filter renders an empty result, not the blank slate The guard blanked the collection but left the request without a `q`, and Active Admin reads that as "this table has no rows yet" - so the warning arrived beside "There are no Posts yet" and a Create link. A present-but-empty `q` still reads as a bare request everywhere in the gem, so no redirect and no defaults decision changes. --- lib/active_admin_filter_extensions/policies.rb | 3 +++ spec/integration/require_any_filter_spec.rb | 10 ++++++++++ 2 files changed, 13 insertions(+) diff --git a/lib/active_admin_filter_extensions/policies.rb b/lib/active_admin_filter_extensions/policies.rb index 860d470..3e00631 100644 --- a/lib/active_admin_filter_extensions/policies.rb +++ b/lib/active_admin_filter_extensions/policies.rb @@ -137,6 +137,9 @@ def enforce_require_any_filter return if notice.nil? || any_declared_filter_answered? @filter_policy_render_empty = true + # Active Admin reads a q-less index as "this table has no rows" and renders the blank + # slate, Create link and all; a present-but-empty q renders an empty result instead. + params[:q] ||= {} flash.now[:warning] = notice.is_a?(String) ? notice : I18n.t(notice, scope: :active_admin_filter_extensions) end diff --git a/spec/integration/require_any_filter_spec.rb b/spec/integration/require_any_filter_spec.rb index bace84a..cbfec91 100644 --- a/spec/integration/require_any_filter_spec.rb +++ b/spec/integration/require_any_filter_spec.rb @@ -21,6 +21,16 @@ expect(page).to have_no_content("drop me") end + it "renders an empty result, not Active Admin's blank slate" do + # The guard blanks the collection; with no `q` in the request Active Admin reads that as + # "this table has no rows at all" and offers a Create link beside the warning. + visit "/admin/picky_posts" + + expect(page).to have_content("No Picky Posts found") + expect(page).to have_no_content("There are no Picky Posts yet") + expect(page).to have_no_link("Create one") + end + it "answers once asked" do visit "/admin/picky_posts?q%5Btitle_cont%5D=keep" From be129c0cad0933729c70aa851830a56541d40adc Mon Sep 17 00:00:00 2001 From: Igor Fedoronchuk Date: Sun, 18 Oct 2026 13:44:26 +0200 Subject: [PATCH 04/24] The extensions: container leaves Formtastic required: and min: alone Both are documented Formtastic input options, and the stray-key guard raised ArgumentError for either of them at the top level - so an app declaring the legal `filter :position, as: :number, min: 0` booted green and then 500'd on every index of that resource. They now pass through to the input untouched, and the gem answers to them only inside extensions:; the form strip drops exactly the keys the container declared instead of every name the gem knows. --- README.md | 19 +++++++++++++++ .../filter_options.rb | 24 +++++++++++++++++-- .../filters_form.rb | 2 +- .../search_keys.rb | 2 +- spec/integration/extensions_container_spec.rb | 8 +++++++ spec/support/rails_template.rb | 6 +++++ 6 files changed, 57 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 0d4cb67..288d1c7 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,25 @@ gem "active_admin_filter_extensions", github: "activeadmin-plugins/active_admin_ Not on RubyGems yet; pin the tag. +## The extensions: container + +Everything this gem adds lives under one filter option, so its names can never collide with +Formtastic's. A gem key left flat raises rather than quietly meaning something else: + +```ruby +filter :title_cont, as: :string, extensions: { default: "acme" } # right +filter :title_cont, as: :string, default: "acme" # raises +``` + +With one carve-out: `required:` and `min:` are Formtastic input options in their own right, so +flat they belong to the form and pass through untouched. This gem answers to them only inside +`extensions:`. + +```ruby +filter :position, as: :number, min: 0 # Formtastic's - renders min="0" +filter :created_at, as: :date_range, extensions: { min: -> { 2.months.ago } } # the gem's floor +``` + ## Usage A default is a **value**. Which Ransack key it lands under is the input's business, and the diff --git a/lib/active_admin_filter_extensions/filter_options.rb b/lib/active_admin_filter_extensions/filter_options.rb index 5202977..feaa60c 100644 --- a/lib/active_admin_filter_extensions/filter_options.rb +++ b/lib/active_admin_filter_extensions/filter_options.rb @@ -11,6 +11,22 @@ module FilterOptions # form is given anything. NOT_FOR_INPUT = %i[extensions if unless].freeze EXTENSION_KEYS = %i[default required with sanitize validate on_invalid max_range min message].freeze + # required: and min: are documented Formtastic input options in their own right. The gem + # answers to them only INSIDE extensions:, so flat they are the host's: no stray-key raise, + # no policy, straight through to the input. + HOST_INPUT_KEYS = %i[required min].freeze + STRAY_KEYS = (EXTENSION_KEYS - HOST_INPUT_KEYS).freeze + # Where #merge_filter_extensions parks the host's own required:/min:, out of reach of the + # gem's flat reads until #input_options hands them back to the input. + HOST_INPUT_OPTIONS = :extensions_host_input_options + + # What the form may see: the gem's container and conditions out, the keys the container + # itself declared out, and the host's own options back in. + def self.input_options(options) + options.except(*NOT_FOR_INPUT, HOST_INPUT_OPTIONS, + *(options[:extensions] || {}).keys.map(&:to_sym)) + .merge(options[HOST_INPUT_OPTIONS] || {}) + end protected @@ -31,7 +47,7 @@ def visible_filters # (required:, most notably). Internally the sub-hash merges flat; a gem key left outside # extensions: raises rather than silently meaning something else to Formtastic. def merge_filter_extensions(attribute, options) - stray = options.keys.map(&:to_sym) & EXTENSION_KEYS + stray = options.keys.map(&:to_sym) & STRAY_KEYS if stray.any? raise ArgumentError, "filter :#{attribute}: #{stray.map(&:inspect).join(', ')} " "belong inside extensions: { ... }" end @@ -42,7 +58,11 @@ def merge_filter_extensions(attribute, options) raise ArgumentError, "filter :#{attribute}: unknown extensions: #{unknown.map(&:inspect).join(', ')} - " "the gem ships #{EXTENSION_KEYS.map(&:inspect).join(', ')}" end - options.merge(extensions) + # The host's own required:/min: are parked rather than merged: left in, the gem would read + # a flat min: as a window policy on an input that submits one key. + host = options.slice(*HOST_INPUT_KEYS) + merged = options.except(*HOST_INPUT_KEYS).merge(extensions) + host.any? ? merged.merge(HOST_INPUT_OPTIONS => host) : merged end end end diff --git a/lib/active_admin_filter_extensions/filters_form.rb b/lib/active_admin_filter_extensions/filters_form.rb index 71a26ce..a061750 100644 --- a/lib/active_admin_filter_extensions/filters_form.rb +++ b/lib/active_admin_filter_extensions/filters_form.rb @@ -25,7 +25,7 @@ def filters_sidebar_section # method, so that this gem carries no copy of a method body that belongs to Active Admin. module ViewHelper def active_admin_filters_form_for(search, filters, options = {}) - super(search, filters.transform_values { |opts| opts.except(*FilterOptions::NOT_FOR_INPUT, *FilterOptions::EXTENSION_KEYS) }, options) + super(search, filters.transform_values { |opts| FilterOptions.input_options(opts) }, options) end end diff --git a/lib/active_admin_filter_extensions/search_keys.rb b/lib/active_admin_filter_extensions/search_keys.rb index 0e7b855..55aac28 100644 --- a/lib/active_admin_filter_extensions/search_keys.rb +++ b/lib/active_admin_filter_extensions/search_keys.rb @@ -46,7 +46,7 @@ def build_filter_input(attribute, options) # a way nothing reports: `Proc#[]` is `call`, so Formtastic asking `input_html[:multiple]` # invokes it, gets a truthy Hash back, and a `:select` derives `_in` rather than `_eq`. def filter_input_options(options, builder) - options = options.except(*FilterOptions::NOT_FOR_INPUT, *FilterOptions::EXTENSION_KEYS) + options = FilterOptions.input_options(options) return options unless options[:input_html].is_a?(Proc) options.merge(input_html: builder.template.instance_exec(&options[:input_html])) diff --git a/spec/integration/extensions_container_spec.rb b/spec/integration/extensions_container_spec.rb index e266df4..0aa3f21 100644 --- a/spec/integration/extensions_container_spec.rb +++ b/spec/integration/extensions_container_spec.rb @@ -11,6 +11,14 @@ .to raise_error(ArgumentError, /:default.*belong inside extensions:/m) end + it "leaves a flat required: and min: to Formtastic" do + # `filter :position, as: :numeric, min: 0` is legal Active Admin. Annexed, the gem read + # min: as a window policy on a single-ended input and 500'd every index of the resource. + visit "/admin/host_option_posts" + + expect(page).to have_css("input[name='q[position]'][min='0']") + end + it "raises for an unknown key inside extensions:" do expect { visit "/admin/banana_policy_key_posts" } .to raise_error(ArgumentError, /unknown extensions: :defult/m) diff --git a/spec/support/rails_template.rb b/spec/support/rails_template.rb index 4f1c149..88c2f32 100644 --- a/spec/support/rails_template.rb +++ b/spec/support/rails_template.rb @@ -280,6 +280,12 @@ def filter_defaults_apply? filter :title_cont, as: :string, default: "keep" end + ActiveAdmin.register Post, as: "HostOptionPost" do + # required: and min: are Formtastic input options in their own right. The gem answers to + # them only inside extensions:, so flat they belong to the form - not to a window policy. + filter :position, as: :number, required: true, min: 0 + end + ActiveAdmin.register Post, as: "BananaPolicyKeyPost" do # A typo inside extensions: raises instead of silently guarding nothing. filter :title_cont, as: :string, extensions: { defult: "keep" } From 541ab176038523bb2eb21bdab4a0ecd0332f3c4d Mon Sep 17 00:00:00 2001 From: Igor Fedoronchuk Date: Sun, 18 Oct 2026 13:45:10 +0200 Subject: [PATCH 05/24] rake setup regenerates the sample app from scratch `rails new` git-inits the generated app, so the directory survives `git clean -fdx spec/rails` and the generator writes into the surviving tree - leaving resources an older template declared in place while the task re-stamps the digest, which is exactly the stale-app green the stamp exists to deny. --- tasks/test.rake | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tasks/test.rake b/tasks/test.rake index 7686c20..76bd3de 100644 --- a/tasks/test.rake +++ b/tasks/test.rake @@ -17,6 +17,11 @@ task :setup do ].join(" ") app_dir = "spec/rails/rails-#{Rails::VERSION::STRING}" + # From scratch, always: `rails new` git-inits the app, so a nested repo survives + # `git clean -fdx` and the generator would then write into the surviving tree - leaving a + # stale resource behind a freshly stamped digest, which is a green the stamp exists to deny. + require "fileutils" + FileUtils.rm_rf(app_dir) sh "bundle exec rails new #{app_dir} #{args}" # Stamped so rails_helper can refuse an app generated from an older template instead of From af0059d187e54969cc672818d1594591ca47dc83 Mon Sep 17 00:00:00 2001 From: Igor Fedoronchuk Date: Sun, 18 Oct 2026 13:46:52 +0200 Subject: [PATCH 06/24] A window policy on an underivable key warns instead of raising Key derivation falls back to the declared filter name on a proxy page with no resource Ransack can search - and a window policy then demanded two keys and got one, raising "a window policy needs a two-ended input" on every request: the exact 500 the fallback was added to prevent. The fallback now records itself, and a fallen-back single key logs once per resource and leaves the window unenforced. A genuinely derived single key still raises - that is a real configuration error. --- .../policies.rb | 31 +++++++++++++++++++ spec/data_access_spec.rb | 17 ++++++++++ 2 files changed, 48 insertions(+) diff --git a/lib/active_admin_filter_extensions/policies.rb b/lib/active_admin_filter_extensions/policies.rb index 3e00631..8713990 100644 --- a/lib/active_admin_filter_extensions/policies.rb +++ b/lib/active_admin_filter_extensions/policies.rb @@ -220,9 +220,17 @@ def strip_ransack_predicate(name) def policy_search_keys(attribute, options) filter_search_keys(attribute, options) rescue ArgumentError + fallback_search_key_attributes << attribute [attribute.to_s] end + # Which filters got their key from the fallback rather than from their input. A window + # policy reads this: one key is a configuration error when the input said so, and merely + # unknowable when nothing could ask the input. + def fallback_search_key_attributes + @fallback_search_key_attributes ||= [] + end + def enforce_scalar_policies(q, attribute, options, keys) return unless options[:sanitize] || options[:validate] @@ -306,6 +314,10 @@ def enforce_window_policies(q, attribute, options, keys) return unless options[:max_range] || options[:min] unless keys.size == 2 + # Nothing could ask the input what it submits, so one key is not a verdict about the + # declaration - and raising here is the 500 the fallback exists to prevent. + return warn_underivable_window(attribute) if fallback_search_key_attributes.include?(attribute) + raise ArgumentError, "filter :#{attribute} declares max_range:/min: but its input submits " "#{keys.join(' and ')} - a window policy needs a two-ended input" end @@ -321,6 +333,25 @@ def enforce_window_policies(q, attribute, options, keys) apply_invalid_filter_policy(attribute, options, keys, violation.first, violation.last) end + # Once per resource: an index is hot, and the declaration is the same on every request. + def warn_underivable_window(attribute) + warned = self.class.instance_variable_get(:@extensions_underivable_windows) || + self.class.instance_variable_set(:@extensions_underivable_windows, []) + return if warned.include?(attribute) + + warned << attribute + policy_logger&.warn( + "filter :#{attribute} declares max_range:/min: but its search key could not be " \ + "derived, so the window is not enforced - name the predicates with a Hash" + ) + nil + end + + # Kept for the unit suite, which deliberately runs without Rails loaded. + def policy_logger + ::Rails.logger if defined?(::Rails) + end + def window_violation(options, from_raw, to_raw) from = parse_window_bound(from_raw) to = parse_window_bound(to_raw) diff --git a/spec/data_access_spec.rb b/spec/data_access_spec.rb index 49b9c0f..1566951 100644 --- a/spec/data_access_spec.rb +++ b/spec/data_access_spec.rb @@ -193,5 +193,22 @@ def filter_search_keys(_attribute, _options) controller.send(:enforce_filter_value_policies) expect(controller.params[:q]["time_start_gteq"]).to eq("2026-01-01") end + + context "when the filter declares a window policy" do + # The fallback gives one key, and a window policy demands two - so the very page the + # fallback keeps alive raised "needs a two-ended input" on every request instead. + let(:filters) { { time_start_gteq: { extensions: { max_range: 86_400 } } } } + let(:params) { { q: { "time_start_gteq" => "2026-01-01" } } } + + it "logs and skips the window instead of raising" do + warnings = [] + logger = Object.new + logger.define_singleton_method(:warn) { |message| warnings << message } + controller.define_singleton_method(:policy_logger) { logger } + + expect { controller.send(:enforce_filter_value_policies) }.not_to raise_error + expect(warnings.first).to match(/could not be derived/) + end + end end end From f1ab5df48adbab0f769b556de5ecd8bbf845152b Mon Sep 17 00:00:00 2001 From: Igor Fedoronchuk Date: Sun, 18 Oct 2026 13:48:20 +0200 Subject: [PATCH 07/24] :reject degrades to :reset off the HTML path It issued redirect_back/url_for for a CSV or JSON index too, where the redirect module deliberately does nothing: the flash would never be rendered and url_for drops the format, so the export was answered with an HTML page. Those formats now take the :reset path and stay bounded in one request. --- README.md | 3 ++- lib/active_admin_filter_extensions/policies.rb | 16 +++++++++++++--- spec/integration/on_invalid_spec.rb | 10 ++++++++++ 3 files changed, 25 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 288d1c7..99bf9d1 100644 --- a/README.md +++ b/README.md @@ -254,7 +254,8 @@ filter :name_eq, extensions: { validate: ->(v) { ... }, on_invalid: :empty } * `:reset` - drop the offending key(s), `flash[:error]`, the declared `default:` refills. * `:reject` - `flash[:error]` and bounce back the way the request came (bare index when - there is no referer). + there is no referer). Off the HTML path - a CSV or JSON index - it degrades to `:reset`: + there is nowhere to bounce to and no flash to read. * `:empty` - render an empty collection with the message, keeping the admin's input in the form: "nothing found because the value is malformed" stops masquerading as an honestly empty result. diff --git a/lib/active_admin_filter_extensions/policies.rb b/lib/active_admin_filter_extensions/policies.rb index 8713990..7157b1a 100644 --- a/lib/active_admin_filter_extensions/policies.rb +++ b/lib/active_admin_filter_extensions/policies.rb @@ -414,10 +414,13 @@ def apply_invalid_filter_policy(attribute, options, keys, message, interpolation case options.fetch(:on_invalid, :reset) when :reset - flash.now[:error] = text - filter_policy_reset_keys.concat(keys) - keys.each { |key| params[:q].delete(key) } + reset_violating_filter_keys(text, keys) when :reject + # Only where a redirect means something. A CSV or JSON index is answered in one + # request everywhere else in the gem, a flash it cannot render would be lost, and + # url_for drops the format - so off the HTML path the export is bounded the :reset way. + return reset_violating_filter_keys(text, keys) unless request.format.html? + flash[:error] = text # Browsers keep the original Referer across a 302, so when back IS this index the # bounce would re-reject itself to the browser's redirect cap. Home instead. @@ -439,6 +442,13 @@ def apply_invalid_filter_policy(attribute, options, keys, message, interpolation end end + # Drop the offending key(s) and let the declared default refill through the merge. + def reset_violating_filter_keys(text, keys) + flash.now[:error] = text + filter_policy_reset_keys.concat(keys) + keys.each { |key| params[:q].delete(key) } + end + # Read by apply_filtering: an :empty policy keeps the request intact - the form must # still show what the admin typed - and blanks the collection instead. def filter_policy_render_empty? diff --git a/spec/integration/on_invalid_spec.rb b/spec/integration/on_invalid_spec.rb index bb98cae..b7e38a8 100644 --- a/spec/integration/on_invalid_spec.rb +++ b/spec/integration/on_invalid_spec.rb @@ -50,6 +50,16 @@ def query_of(url) page.driver.header("Referer", nil) end + it "degrades to :reset off the HTML path" do + # A CSV index is answered in one request everywhere else in the gem, a flash it cannot + # render would be lost, and url_for drops the format - so the export is bounded the + # :reset way instead of being bounced into an HTML page. + visit "/admin/reject_posts.csv?q%5Bposition_eq%5D=abc" + + expect(page.response_headers["Content-Type"]).to include("text/csv") + expect(page.body).to include("keep me") + end + it "bounces once when value and window policies are violated together" do visit "/admin/reject_window_posts?q%5Bpublished_date_gteq%5D=2020-01-01X&q%5Bpublished_date_lteq%5D=2026-01-01X" From da08e2cbdc39814ff2e7476e8d4b18fba22cf8ba Mon Sep 17 00:00:00 2001 From: Igor Fedoronchuk Date: Sun, 18 Oct 2026 13:49:58 +0200 Subject: [PATCH 08/24] The redirect compares filters as a URL can carry them An Array default with a blank member - `-> { [current_admin_user.team_id] }` for an admin who has none - survived the blank-reject, serialized to ?q[k][]= and parsed back as [""], which normalization drops: the defaults re-applied and the two sides never agreed, so the index answered ERR_TOO_MANY_REDIRECTS for exactly those admins. Both sides of the comparison are now normalized element-wise, and `false` still survives as the answer it is. --- .../redirect.rb | 20 +++++++++++++++---- spec/integration/redirect_spec.rb | 10 ++++++++++ spec/support/rails_template.rb | 7 +++++++ 3 files changed, 33 insertions(+), 4 deletions(-) diff --git a/lib/active_admin_filter_extensions/redirect.rb b/lib/active_admin_filter_extensions/redirect.rb index eb10107..df1b570 100644 --- a/lib/active_admin_filter_extensions/redirect.rb +++ b/lib/active_admin_filter_extensions/redirect.rb @@ -10,6 +10,20 @@ module ActiveAdminFilterExtensions # redirect on every bare visit, and a bookmarked URL pins the defaults of the day it was # made rather than following the code. module Redirect + # The filters as a URL can carry them, so that the two sides of the comparison below are + # comparable at all. A value the URL drops or hands back in another shape must be absent on + # both sides or the hop alternates with the bare address forever: an empty Array or Hash is + # dropped by to_query entirely, ?q[k]= comes back with the blank stripped, and an Array of + # blanks comes back as the empty Array. `false` round-trips, and is a real answer. + def self.canonical_filters(filters) + filters.each_with_object({}) do |(key, value), result| + value = value.reject { |element| element != false && element.blank? } if value.is_a?(Enumerable) && !value.is_a?(Hash) + next if value != false && value.blank? + + result[key] = value + end + end + def self.included(base) # ActiveAdmin.before_load fires on every dev-mode reload against the same non-reloaded # controller class, and Ruby runs this hook on every include call - without the guard the @@ -36,10 +50,8 @@ def self.included(base) # so the equality below compares Hashes on both sides. requested = nil unless requested.is_a?(Hash) - # A blank value is one the URL cannot round-trip: an empty Array or Hash is dropped - # by to_query entirely, and ?q[k]= comes back in with the blank stripped - redirecting - # on either alternates with the bare URL forever. Blanks stay out of the redirect. - effective = effective.reject { |_, value| value != false && value.blank? } + effective = Redirect.canonical_filters(effective) + requested = Redirect.canonical_filters(requested) if requested # One redirect, not a loop, whatever #filter_defaults_apply? has been widened to: once # the query string says exactly what the search runs, the two sides are equal and the diff --git a/spec/integration/redirect_spec.rb b/spec/integration/redirect_spec.rb index 41e4897..81ee25e 100644 --- a/spec/integration/redirect_spec.rb +++ b/spec/integration/redirect_spec.rb @@ -112,6 +112,16 @@ def query_of(url) expect(page).to have_content("drop me") end + it "does not chase an Array default whose members are all blank" do + # [nil] survives a plain blank-reject, serializes to ?q[k][]= and parses back as [""] - + # which normalization drops, so the defaults re-apply and the two sides never agree. + visit "/admin/nil_member_default_posts" + + expect(URI.parse(page.current_url).query).to be_nil + expect(page).to have_content("keep me") + expect(page).to have_content("drop me") + end + it "does not redirect a resource that declares no defaults" do visit "/admin/no_defaults_posts" diff --git a/spec/support/rails_template.rb b/spec/support/rails_template.rb index 88c2f32..b75a3d3 100644 --- a/spec/support/rails_template.rb +++ b/spec/support/rails_template.rb @@ -154,6 +154,13 @@ def current_admin_user filter :position, as: :numeric, extensions: { default: { in: -> { [] } } } end + ActiveAdmin.register Post, as: "NilMemberDefaultPost" do + # `default: -> { [current_admin_user.team_id] }` for an admin with no team: an Array whose + # only member is blank survives a plain blank-reject but cannot round-trip through a URL. + filter :status, as: :check_boxes, collection: %w[draft published], + extensions: { default: -> { [nil] } } + end + ActiveAdmin.register Post, as: "BananaBlankDefaultPost" do # A blank default is a value ?q[k]= cannot round-trip (the blank is stripped on the way # back in) - it must stay out of the redirect, not loop it. From 6bc73a6e3bfbd8fd49e3c09812da53c7731d80e8 Mon Sep 17 00:00:00 2001 From: Igor Fedoronchuk Date: Sun, 18 Oct 2026 13:51:58 +0200 Subject: [PATCH 09/24] A with: member whose default resolves to nil opts out, not errors The group-completeness check read the materialized defaults, so it could not tell a member that declares no default: from one whose Proc answered nil for this admin - the gem's own convention for imposing nothing - and the bare index raised the half-group configuration error per admin. Only a missing declaration raises now; a runtime nil leaves the group uncompletable, so it imposes nothing rather than searching half of itself. --- .../filter_defaults.rb | 17 +++++++++++++++-- spec/integration/with_spec.rb | 11 +++++++++++ spec/support/rails_template.rb | 8 ++++++++ 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/lib/active_admin_filter_extensions/filter_defaults.rb b/lib/active_admin_filter_extensions/filter_defaults.rb index b230177..ec3dcd8 100644 --- a/lib/active_admin_filter_extensions/filter_defaults.rb +++ b/lib/active_admin_filter_extensions/filter_defaults.rb @@ -55,11 +55,22 @@ def complete_with_groups(defaults) with_groups.each do |keys| next unless keys.any? { |key| defaults.key?(key) } + # A member that opted out this request leaves the group uncompletable, and half a group + # is the search the all-or-none contract refuses - so the group imposes nothing. + next defaults = defaults.except(*keys) if keys.any? { |key| nil_default_filter_keys.include?(key) } + defaults = filter_default_values.slice(*keys).merge(defaults) end defaults end + # The keys of filters that declare a default: which resolved to nil for this request. A nil + # Proc result imposes nothing - that is how a per-admin default opts out - so it is not the + # missing declaration the with: completeness check refuses. + def nil_default_filter_keys + @nil_default_filter_keys ||= [] + end + # The defaults of keys a policy just reset - refilled whatever filter_defaults_apply? # says, because the reset itself is what emptied them. def reset_filter_default_values @@ -110,7 +121,9 @@ def filter_default_values before = result.keys add_filter_default_value(result, attribute, options) - (@filter_default_keys ||= {})[attribute] = result.keys - before + added = result.keys - before + (@filter_default_keys ||= {})[attribute] = added + nil_default_filter_keys.concat(policy_search_keys(attribute, options)) if added.empty? # Value policies first: the window must be judged on what the sanitizer stored. validate_default_against_value_policies(attribute, options, result, result.keys - before) validate_default_against_policies(attribute, options, result) @@ -122,7 +135,7 @@ def filter_default_values # enforcement even runs. def validate_defaults_against_with_groups(defaults) with_groups.each do |keys| - answered = keys.select { |key| defaults.key?(key) } + answered = keys.select { |key| defaults.key?(key) || nil_default_filter_keys.include?(key) } next if answered.empty? || answered.size == keys.size raise ArgumentError, diff --git a/spec/integration/with_spec.rb b/spec/integration/with_spec.rb index 7d76ddd..b0f3660 100644 --- a/spec/integration/with_spec.rb +++ b/spec/integration/with_spec.rb @@ -94,6 +94,17 @@ def query_of(url) .to eq("title_cont" => "keep", "status_eq" => "published", "starred_eq" => "false") end + it "imposes nothing when a member's default resolves to nil" do + # A nil Proc result is how a per-admin default opts out. Read as a missing declaration it + # raised the half-group config error on every bare visit, per admin. + visit "/admin/nil_paired_posts" + + expect(page).to have_no_content("must be given together") + expect(URI.parse(page.current_url).query).to be_nil + expect(page).to have_content("keep me") + expect(page).to have_content("drop me") + end + it "raises when defaults answer only half the group" do # The fallback is held to the same all-or-none contract the enforcement imposes. expect { visit "/admin/banana_half_paired_posts" } diff --git a/spec/support/rails_template.rb b/spec/support/rails_template.rb index b75a3d3..68b4858 100644 --- a/spec/support/rails_template.rb +++ b/spec/support/rails_template.rb @@ -394,6 +394,14 @@ def filter_defaults_apply? filter :starred end + ActiveAdmin.register Post, as: "NilPairedPost" do + # A member of a with: group whose default opts out for this admin. Declared, so the group + # is complete; nil, so it imposes nothing - and neither does the partner. + filter :status, as: :select, collection: %w[draft published], + extensions: { default: "published", with: :starred_eq } + filter :starred, extensions: { default: -> { nil } } + end + ActiveAdmin.register Post, as: "BananaHalfPairedPost" do # Half the with: group declares a default - the fallback would execute the very # half-group the enforcement refuses. The suite proves that raises. From 288869c51c0e37f01bcd3deba30726a3180b70b1 Mon Sep 17 00:00:00 2001 From: Igor Fedoronchuk Date: Sun, 18 Oct 2026 13:53:35 +0200 Subject: [PATCH 10/24] A min: Proc resolving to nil imposes nothing It reached nil.in_time_zone instead, so a per-admin retention floor that opts out - the convention a nil default: already follows - answered NoMethodError on every request carrying a bound. The floor is now resolved once per call and a nil one simply does not narrow the window. --- .../policies.rb | 27 +++++++++++-------- spec/integration/window_spec.rb | 10 +++++++ spec/support/rails_template.rb | 6 +++++ 3 files changed, 32 insertions(+), 11 deletions(-) diff --git a/lib/active_admin_filter_extensions/policies.rb b/lib/active_admin_filter_extensions/policies.rb index 7157b1a..7ba1d7d 100644 --- a/lib/active_admin_filter_extensions/policies.rb +++ b/lib/active_admin_filter_extensions/policies.rb @@ -359,13 +359,10 @@ def window_violation(options, from_raw, to_raw) return [:range_invalid, {}] if (from_raw.present? && from.nil?) || (to_raw.present? && to.nil?) return [:range_invalid, {}] if from && to && from > to - if (floor = options[:min]) - # in_time_zone, not to_time: the bounds are parsed in Time.zone, and Date#to_time - # speaks the SERVER zone - apart, a floor-equal bound is off by the offset. - floor = ::MethodOrProcHelper.render_in_context(self, floor).in_time_zone - if [from, to].compact.any? { |bound| bound < floor } - return [:before_floor, { floor: floor.to_date }] - end + floor = window_floor(options) + + if floor && [from, to].compact.any? { |bound| bound < floor } + return [:before_floor, { floor: floor.to_date }] end if (max = options[:max_range]) @@ -385,14 +382,22 @@ def window_violation(options, from_raw, to_raw) # A min: standing alone still refuses a window with no lower bound - everything # before the floor is the query the floor exists to refuse. After max_range:, whose # too-wide verdict is the more actionable one when both are declared. - if from.nil? && to && (floor = options[:min]) - floor = ::MethodOrProcHelper.render_in_context(self, floor).in_time_zone - return [:before_floor, { floor: floor.to_date }] - end + return [:before_floor, { floor: floor.to_date }] if from.nil? && to && floor nil end + # `min:` resolved for this request, once. nil imposes nothing - that is how a per-admin + # floor opts out, the way a nil default: does. + def window_floor(options) + floor = options[:min] + return nil unless floor + + # in_time_zone, not to_time: the bounds are parsed in Time.zone, and Date#to_time speaks + # the SERVER zone - apart, a floor-equal bound is off by the offset. + ::MethodOrProcHelper.render_in_context(self, floor)&.in_time_zone + end + def parse_window_bound(raw) return nil if raw.blank? # An Array/Hash-wrapped bound would date-parse via #to_s while Ransack silently drops diff --git a/spec/integration/window_spec.rb b/spec/integration/window_spec.rb index 3f51268..544551e 100644 --- a/spec/integration/window_spec.rb +++ b/spec/integration/window_spec.rb @@ -63,6 +63,16 @@ def visit_window(gteq: nil, lteq: nil) .to eq("published_date_gteq" => "2026-05-01", "published_date_lteq" => "2026-06-30") end + it "imposes nothing when a min: Proc resolves to nil" do + # An upper bound alone: both floor lookups are on this path, and nil.in_time_zone 500'd + # the request the floor was only meant to narrow. + visit "/admin/nil_floor_posts?q%5Bpublished_date_lteq%5D=2026-06-30" + + expect(page).to have_no_content("cannot start before") + expect(page).to have_content("keep me") + expect(page).to have_content("drop me") + end + it "resets the whole pair when a value policy rejects one bound" do # Half the admin's window completed from the default pair is a window nobody asked for # and nothing validated - the CSV would execute it without the HTML's healing hop. diff --git a/spec/support/rails_template.rb b/spec/support/rails_template.rb index 68b4858..43ae8af 100644 --- a/spec/support/rails_template.rb +++ b/spec/support/rails_template.rb @@ -264,6 +264,12 @@ def filter_defaults_apply? default: -> { Date.new(2026, 5, 1)..Date.new(2026, 6, 30) } } end + ActiveAdmin.register Post, as: "NilFloorPost" do + # A per-admin retention floor that opts out for this admin - nil imposes nothing, the way + # a nil default: does. + filter :published_date, as: :date_range, extensions: { min: -> { nil } } + end + ActiveAdmin.register Post, as: "ValidatedWindowPost" do # Value AND window policies with a default: a scalar reset must not leave half the # admin's window to be completed from the default pair unchecked. From e0f056193b054ae15752cfd64c6fffcbbe803575 Mon Sep 17 00:00:00 2001 From: Igor Fedoronchuk Date: Sun, 18 Oct 2026 13:55:37 +0200 Subject: [PATCH 11/24] A violation message is never worth a 500 Three ways the message path failed instead of speaking: a validate: returning one of the shipped parameterized symbols - which the docs invite - raised I18n::MissingInterpolationArgument because there was nothing to interpolate %{limit} from; a `message:` override was used verbatim, so the app's own words could never name %{limit} or %{floor}; and a require_any_filter notice naming a key no locale defines flashed the raw "Translation missing" line. Overrides interpolate now, and a missing key or interpolation falls back to the shipped text. --- .../policies.rb | 31 ++++++++++++++----- spec/integration/policies_spec.rb | 18 +++++++++++ spec/integration/require_any_filter_spec.rb | 7 +++++ spec/support/rails_template.rb | 21 +++++++++++++ 4 files changed, 70 insertions(+), 7 deletions(-) diff --git a/lib/active_admin_filter_extensions/policies.rb b/lib/active_admin_filter_extensions/policies.rb index 7ba1d7d..3a561b7 100644 --- a/lib/active_admin_filter_extensions/policies.rb +++ b/lib/active_admin_filter_extensions/policies.rb @@ -141,7 +141,8 @@ def enforce_require_any_filter # slate, Create link and all; a present-but-empty q renders an empty result instead. params[:q] ||= {} flash.now[:warning] = - notice.is_a?(String) ? notice : I18n.t(notice, scope: :active_admin_filter_extensions) + notice.is_a?(String) ? notice : I18n.t(notice, scope: :active_admin_filter_extensions, + default: :require_any_filter) end # Ransack silently ignores a condition nobody declared - junk, a stale bookmark after @@ -462,15 +463,31 @@ def filter_policy_render_empty? def filter_policy_message(attribute, options, message, interpolations = {}) return message if message.is_a?(String) - # `message:` on the filter speaks for every symbolic violation of this filter - the - # app's own words without duplicating a whole validator to say them. - return options[:message] if options[:message] values = { filter: options[:label] || attribute.to_s.humanize }.merge(interpolations) + # `message:` on the filter speaks for every symbolic violation of this filter - the + # app's own words without duplicating a whole validator to say them. Interpolated, so + # those words can name %{limit} and %{floor} as the shipped texts do. + if (override = options[:message]) + return begin + I18n.interpolate(override, values) + rescue I18n::ArgumentError + override + end + end + # The English texts ship as plain i18n (config/locales/en.yml); an application - # overrides them under the same keys. Unknown symbols read as :invalid. - I18n.t(message, scope: :active_admin_filter_extensions, - default: :invalid, **values) + # overrides them under the same keys. + policy_translation(message, values) + end + + # A validate: may answer any Symbol in the gem's scope, including the parameterized ones - + # which have nothing to interpolate from. A violation message is not worth a 500, so an + # unknown key and a missing interpolation both read as :invalid. + def policy_translation(message, values) + I18n.t(message, scope: :active_admin_filter_extensions, raise: true, **values) + rescue I18n::ArgumentError + I18n.t(:invalid, scope: :active_admin_filter_extensions, **values) end end end diff --git a/spec/integration/policies_spec.rb b/spec/integration/policies_spec.rb index cef7ec5..bfde8b6 100644 --- a/spec/integration/policies_spec.rb +++ b/spec/integration/policies_spec.rb @@ -139,6 +139,24 @@ def query_of(url) expect(page).to have_no_content("translation missing") end + it "interpolates the override" do + # Used verbatim, the app's own words could never name %{limit} or %{floor}. + visit "/admin/interpolated_message_posts?q%5Bpublished_date_gteq%5D=2020-01-01" \ + "&q%5Bpublished_date_lteq%5D=2026-01-01" + + expect(page).to have_content("Pick at most 90 days.") + end + + it "falls back to the generic text for a parameterized symbol it cannot interpolate" do + # The docs invite a validate: that returns a gem scope symbol; the parameterized ones + # have nothing to interpolate from, and raising there 500s the request the policy is + # meant to answer. + visit "/admin/parameterized_symbol_posts?q%5Btitle_cont%5D=keep123" + + expect(page).to have_content("Title cont filter has an invalid value.") + expect(page).to have_no_content("translation missing") + end + it "stays quiet for a valid value" do visit "/admin/ip_guarded_posts?q%5Btitle_cont%5D=10.0.0.0%2F8" diff --git a/spec/integration/require_any_filter_spec.rb b/spec/integration/require_any_filter_spec.rb index cbfec91..950ea35 100644 --- a/spec/integration/require_any_filter_spec.rb +++ b/spec/integration/require_any_filter_spec.rb @@ -86,6 +86,13 @@ expect(page).to have_content("keep me") end + it "falls back to the shipped text for a notice key no locale defines" do + visit "/admin/banana_notice_key_posts" + + expect(page).to have_content("You must specify at least one filter") + expect(page).to have_no_content("translation missing") + end + it "notices when a policy just reset the only question" do # The validator empties q; without re-checking afterwards the whole table renders with no # warning - the unfiltered dump the guard was declared to forbid. diff --git a/spec/support/rails_template.rb b/spec/support/rails_template.rb index 43ae8af..605638d 100644 --- a/spec/support/rails_template.rb +++ b/spec/support/rails_template.rb @@ -493,6 +493,27 @@ def filter_defaults_apply? extensions: { validate: ->(value) { value.match?(/\\d/) && :bogus_key } } end + ActiveAdmin.register Post, as: "ParameterizedSymbolPost" do + # A validate: answering one of the gem's own parameterized symbols - which the docs + # invite - with nothing to interpolate %{limit} from. + filter :title_cont, as: :string, + extensions: { validate: ->(value) { value.match?(/\\d/) && :range_too_wide } } + end + + ActiveAdmin.register Post, as: "InterpolatedMessagePost" do + # message: in the app's own words, naming the gem's own interpolation. + filter :published_date, as: :date_range, + extensions: { max_range: 90.days, + message: "Pick at most %{limit} days." } + end + + ActiveAdmin.register Post, as: "BananaNoticeKeyPost" do + # require_any_filter named with a Symbol no locale defines. + require_any_filter notice: :no_such_notice_key + + filter :title_cont, as: :string + end + ActiveAdmin.register Post, as: "NoDefaultsPost" do filter :title end From cb5266883bebbc873a69abfa74204ee11a04099c Mon Sep 17 00:00:00 2001 From: Igor Fedoronchuk Date: Sun, 18 Oct 2026 13:57:19 +0200 Subject: [PATCH 12/24] A required: window reads every predicate of its own filter It looked only for its derived key, so an admin picking "equals" on a :string filter sent title_eq, the group saw no title_cont, and the default merged in beside the admin's own condition - two conditions on one column, both frozen into the canonical URL, greeted by the defaults notice. Any real predicate on the filter's attribute now counts as an answer, read the way the resource-level guard reads one: a predicate-less stem is still no answer, and still no way to switch a mandatory window off. --- .../required_filters.rb | 21 ++++++++++++++++++- spec/integration/required_spec.rb | 19 +++++++++++++++++ spec/support/rails_template.rb | 8 +++++++ 3 files changed, 47 insertions(+), 1 deletion(-) diff --git a/lib/active_admin_filter_extensions/required_filters.rb b/lib/active_admin_filter_extensions/required_filters.rb index 031c625..825d489 100644 --- a/lib/active_admin_filter_extensions/required_filters.rb +++ b/lib/active_admin_filter_extensions/required_filters.rb @@ -48,12 +48,31 @@ def required_conditions(attribute, options) # whose unless: keys. def required_filter_defaults_absent_from(requested) required_filter_default_values.each_with_object({}) do |(_attribute, entry), result| - next if entry[:keys].any? { |key| requested[key].present? } + next if required_group_answered?(requested, entry[:keys]) next if entry[:if].any? && entry[:if].none? { |key| requested[key].present? } next if entry[:unless].any? { |key| requested[key].present? } result.merge!(entry[:defaults]) end end + + # Any predicate of the filter's own attribute is an answer about it - the admin picking + # "equals" on a :string filter submits title_eq where the derived key is title_cont, and + # completing that from the default searches two conditions on one column. Read exactly the + # way #any_declared_filter_answered? reads a question: an exact declared key, or a real + # predicate on the declared attribute - never a predicate-less stem Ransack ignores, which + # would switch a mandatory window off with a key that searches nothing. + def required_group_answered?(requested, keys) + bases = keys.map { |key| strip_ransack_predicate(key) } + + requested.any? do |key, value| + next false unless value.present? + + key = key.to_s + next true if keys.include?(key) + + key != strip_ransack_predicate(key) && bases.include?(strip_ransack_predicate(key)) + end + end end end diff --git a/spec/integration/required_spec.rb b/spec/integration/required_spec.rb index 6a31a19..bb8e5ca 100644 --- a/spec/integration/required_spec.rb +++ b/spec/integration/required_spec.rb @@ -30,6 +30,25 @@ def query_of(url) expect(page).to have_no_content("drop me") end + it "counts a sibling predicate of its own filter as an answer" do + # Judged on the derived key alone, the default merged in beside the admin's own condition: + # two conditions on one column, both in the canonical URL, and the notice greeting it. + visit "/admin/required_sibling_posts?q%5Btitle_eq%5D=drop%20me" + + expect(query_of(page.current_url)["q"]).to eq("title_eq" => "drop me") + expect(page).to have_content("drop me") + expect(page).to have_no_content("Showing kept titles by default") + end + + it "still applies beside a predicate-less stem Ransack ignores" do + # q[title] is no condition at all - counting it would switch a mandatory window off with a + # key that searches nothing. + visit "/admin/required_sibling_posts?q%5Btitle%5D=drop" + + expect(query_of(page.current_url)["q"]).to include("title_cont" => "keep") + expect(page).to have_content("keep me") + end + it "applies even to a blank filters-form submission" do # A required window has no 'show me everything' escape - that is what required means. visit "/admin/required_posts?commit=Filter" diff --git a/spec/support/rails_template.rb b/spec/support/rails_template.rb index 605638d..38b40ea 100644 --- a/spec/support/rails_template.rb +++ b/spec/support/rails_template.rb @@ -232,6 +232,14 @@ def filter_defaults_apply? extensions: { default: "published", required: { if: :title_cont } } end + ActiveAdmin.register Post, as: "RequiredSiblingPost" do + default_filters_notice "Showing kept titles by default" + + # A :string filter carries the predicate dropdown: picking "equals" submits title_eq + # where the derived key is title_cont. Both are answers about the same column. + filter :title, extensions: { default: "keep", required: true } + end + ActiveAdmin.register Post, as: "RequiredBananaPost" do # required with nothing to fall back on - the suite proves that raises. filter :title_cont, as: :string, extensions: { required: true } From 6e4437ecc75859602826236f721ea3d89519daf4 Mon Sep 17 00:00:00 2001 From: Igor Fedoronchuk Date: Sun, 18 Oct 2026 13:59:44 +0200 Subject: [PATCH 13/24] Structural keys are stripped only where a policy can be bypassed The strip was global, so a plain resource's hand-built q[g] OR link - the standard Active Admin workaround - searched nothing and exported the whole table, and a Ransack sort was dropped from the search and erased from the canonical URL by the 302. Stripping now applies to resources that declare something of this gem's, where a structural key really is a bypass, and q[s]/q[sorts] are left alone everywhere: sorting is no condition, so it reaches Ransack untouched and is not an answer to the question the defaults exist to answer. --- README.md | 8 +++++- .../filter_defaults.rb | 28 +++++++++++++++---- spec/integration/structural_keys_spec.rb | 20 +++++++++++++ spec/spec_helper.rb | 1 + 4 files changed, 51 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 99bf9d1..e4c81ea 100644 --- a/README.md +++ b/README.md @@ -325,7 +325,7 @@ visit, and a bookmarked URL pins the defaults of the day it was made rather than the code. Non-HTML requests (CSV, JSON) are never redirected; the defaults filter them all the same. -Three prints in small type: +Four prints in small type: * **Code that rewrites `q` in a `before_action`** (sanitizers that delete invalid ranges, say) runs after the redirect has already compared the request against the defaults - the @@ -335,6 +335,12 @@ Three prints in small type: * **The redirect runs after authentication but before Active Admin's index authorization**, so an authenticated admin without read rights on a resource still receives the 302 whose Location spells out that resource's defaults. Keep per-role secrets out of defaults. +* **Ransack's structural keys are stripped on resources that declare something of this + gem's.** `q[m]`, `q[g]` and `q[c]` turn a required, capped window into `window OR anything` + and nest conditions no value policy ever sees, so where a policy guards the filters they + are a bypass rather than an answer. A resource that declares no extension keeps them - the + hand-built `q[g]` OR link is the standard Active Admin workaround. Sorting (`q[s]`) is + never a condition and is never touched. * **Anything that reads `params[:q]` for the effective filters** (drill-down links, exports) should read `filtering_params` instead - on the rendered page they agree, but only `filtering_params` is also right on the non-redirected formats. diff --git a/lib/active_admin_filter_extensions/filter_defaults.rb b/lib/active_admin_filter_extensions/filter_defaults.rb index ec3dcd8..c896001 100644 --- a/lib/active_admin_filter_extensions/filter_defaults.rb +++ b/lib/active_admin_filter_extensions/filter_defaults.rb @@ -10,9 +10,14 @@ module ActiveAdminFilterExtensions module FilterDefaults # Ransack reads more than conditions out of q: `m` switches the combinator to OR - turning # a required, capped window into `window OR anything` - and `g` nests whole condition - # groups no value policy ever saw. No Active Admin filter form submits them; here they are - # a bypass, not an answer, and they are stripped before anything reads the request. - RANSACK_STRUCTURE = %w[m g c s conditions sorts groupings combinator].freeze + # groups no value policy ever saw. No Active Admin filter form submits them; where this gem + # guards the filters they are a bypass, not an answer, and they are stripped before anything + # reads the request. On a resource that declares nothing of this gem's there is no policy to + # bypass, and `q[g]` is the app's own standard OR workaround - see #strip_ransack_structure?. + RANSACK_STRUCTURE = %w[m g c conditions groupings combinator].freeze + # Sorting is not a condition: it reaches Ransack untouched, and it is not an answer to the + # question the defaults exist to answer. + RANSACK_SORT = %w[s sorts].freeze protected @@ -111,7 +116,7 @@ def filter_defaults_engaged? # Judged on the NORMALIZED request: a q holding nothing but blanks or structural keys is # a bare request on every format - the CSV gets no canonicalizing hop to say otherwise. def filter_defaults_apply? - requested_filtering_params.blank? && params[:commit].blank? + (requested_filtering_params || {}).except(*RANSACK_SORT).blank? && params[:commit].blank? end # @return [Hash] the search values declared with `filter ..., default:` @@ -232,11 +237,24 @@ def requested_filtering_params requested = requested.transform_values do |value| value.is_a?(Array) ? value.reject { |element| element.blank? && element != false } : value end + strip_structure = strip_ransack_structure? requested.reject do |key, value| - RANSACK_STRUCTURE.include?(key.to_s) || (value.blank? && value != false) + (strip_structure && RANSACK_STRUCTURE.include?(key.to_s)) || (value.blank? && value != false) end end + # Whether this resource declares anything of this gem's. Only then is a structural key a + # bypass of something; on every other resource it is the app's own search and stripping it + # silently widens the export to the whole table. + def strip_ransack_structure? + return @strip_ransack_structure if defined?(@strip_ransack_structure) + + @strip_ransack_structure = + active_admin_config.require_any_filter_notice.present? || + active_admin_config.default_filters_notice.present? || + visible_filters.any? { |_attribute, options| options.key?(:extensions) } + end + # A default is a value, and where that value goes is the input's business - see SearchKeys. # # filter :status, as: :select, default: "active" diff --git a/spec/integration/structural_keys_spec.rb b/spec/integration/structural_keys_spec.rb index 59305b2..e1894b4 100644 --- a/spec/integration/structural_keys_spec.rb +++ b/spec/integration/structural_keys_spec.rb @@ -47,6 +47,26 @@ def query_of(url) expect(page.body).not_to include("drop me") end + it "leaves a plain resource's own grouping alone" do + # q[g] is the standard Active Admin workaround for an OR search. On a resource that + # declares no extension there is no policy to bypass, and stripping it answered a narrowed + # export with the whole table. + visit "/admin/plain_posts.csv?q%5Bg%5D%5B0%5D%5Btitle_cont%5D=keep" + + expect(page.body).to include("keep me") + expect(page.body).not_to include("drop me") + end + + it "keeps a Ransack sort, which is no condition" do + # Stripped, q[s] was dropped from the search and erased from the canonical URL - the 302 + # handed back a link that had lost the sort it was made for. + visit "/admin/posts?q%5Bs%5D=title+desc" + + expect(query_of(page.current_url)["q"]) + .to eq("s" => "title desc", "status_eq" => "published", + "published_date_gteq" => "2026-01-01") + end + it "strips q[g] groupings no value policy ever saw" do visit "/admin/guarded_posts?q%5Bg%5D%5B0%5D%5Btitle_cont%5D=keep123" diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 96a4dc4..07dc014 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -32,6 +32,7 @@ class FakeController # The resource settings the policy layer reads unconditionally in a real install. Config = Struct.new(:filters) do def require_any_filter_notice = nil + def default_filters_notice = nil end def self.before_action(*); end # the guard registers one; FakeController has no chain From 895d3d72694052b3478e902acfc7d77e3f500f7a Mon Sep 17 00:00:00 2001 From: Igor Fedoronchuk Date: Sun, 18 Oct 2026 14:01:31 +0200 Subject: [PATCH 14/24] A with: group is judged by member, not by key A two-ended member submits two keys, so one date bound beside the partner counted as two of three: the group reset itself with "must be given together" and, with no defaults to refill, rendered the whole table - the scan the guard exists to prevent. The fallback side had the same arithmetic, where a half-open Range default read as a half-declared group and raised. Either bound now answers its member, the touched-is-answered rule required: already follows. --- .../filter_defaults.rb | 13 +++++++---- .../policies.rb | 14 +++++++++--- spec/integration/with_spec.rb | 22 +++++++++++++++++++ spec/support/rails_template.rb | 8 +++++++ 4 files changed, 50 insertions(+), 7 deletions(-) diff --git a/lib/active_admin_filter_extensions/filter_defaults.rb b/lib/active_admin_filter_extensions/filter_defaults.rb index c896001..077d90c 100644 --- a/lib/active_admin_filter_extensions/filter_defaults.rb +++ b/lib/active_admin_filter_extensions/filter_defaults.rb @@ -140,13 +140,18 @@ def filter_default_values # enforcement even runs. def validate_defaults_against_with_groups(defaults) with_groups.each do |keys| - answered = keys.select { |key| defaults.key?(key) || nil_default_filter_keys.include?(key) } - next if answered.empty? || answered.size == keys.size + # By member, so a half-open Range default - which fills only the end it has - answers + # its two-ended member rather than reading as half a group. + members = with_group_members(keys) + answered = members.select do |member| + member.any? { |key| defaults.key?(key) || nil_default_filter_keys.include?(key) } + end + next if answered.empty? || answered.size == members.size raise ArgumentError, "filters #{keys.join(', ')} are declared with: one another, but only " \ - "#{answered.join(', ')} declare defaults - the fallback must answer the whole " \ - "group or none of it" + "#{answered.flatten.join(', ')} declare defaults - the fallback must answer the " \ + "whole group or none of it" end end diff --git a/lib/active_admin_filter_extensions/policies.rb b/lib/active_admin_filter_extensions/policies.rb index 3a561b7..90fd510 100644 --- a/lib/active_admin_filter_extensions/policies.rb +++ b/lib/active_admin_filter_extensions/policies.rb @@ -167,18 +167,26 @@ def enforce_with_groups(q) # raw present? check while Ransack drops it - half the group executing silently. answered = requested_filtering_params || {} with_groups.each do |keys| - present = keys.select { |key| answered.key?(key) } - next if present.empty? || present.size == keys.size + members = with_group_members(keys) + present = members.select { |member| member.any? { |key| answered.key?(key) } } + next if present.empty? || present.size == members.size filter_policy_errors << keys filter_policy_reset_keys.concat(keys) names = keys.map { |k| strip_ransack_predicate(k).humanize }.uniq.join(" and ") flash.now[:warning] = I18n.t(:given_together, scope: :active_admin_filter_extensions, filters: names) - present.each { |key| q.delete(key) } + present.flatten.each { |key| q.delete(key) } end end + # A group's members, not its keys: a two-ended filter submits two of them and is answered + # by either bound - the touched-is-answered rule required: already follows. Counted as + # separate keys, one bound beside the partner read as a half-answered group. + def with_group_members(keys) + keys.group_by { |key| strip_ransack_predicate(key) }.values + end + # `with: :year_eq` on a filter: its own search keys and the named partners are one # group - some-but-not-all answered resets the present ones with the warning. Declared # on either member; mirrored declarations dedupe to the one group they both name. diff --git a/spec/integration/with_spec.rb b/spec/integration/with_spec.rb index b0f3660..8816f6d 100644 --- a/spec/integration/with_spec.rb +++ b/spec/integration/with_spec.rb @@ -60,6 +60,28 @@ def query_of(url) expect(page.body).not_to include("drop me") end + it "counts a two-ended member answered by either bound" do + # Each bound counted as a group key of its own, so one date bound beside the partner read + # as two of three: both reset, and with no defaults the whole table rendered - the scan the + # guard exists to prevent. + visit "/admin/paired_date_posts?q%5Bpublished_date_gteq%5D=2026-01-01&q%5Bposition_eq%5D=20" + + expect(page).to have_no_content("must be given together") + expect(page).to have_content("keep me") + expect(page).to have_no_content("drop me") + end + + it "counts a two-ended member whose default fills only one end" do + # Same arithmetic on the fallback side: the half-open default answered one of three keys + # and read as a half-declared group. + visit "/admin/open_paired_posts" + + expect(query_of(page.current_url)["q"]) + .to eq("published_date_gteq" => "2026-05-01", "position_eq" => "20") + expect(page).to have_content("keep me") + expect(page).to have_no_content("drop me") + end + it "lets a fully-answered group through" do visit "/admin/paired_posts?q%5Bstatus_eq%5D=draft&q%5Bstarred_eq%5D=false" diff --git a/spec/support/rails_template.rb b/spec/support/rails_template.rb index 38b40ea..b10c669 100644 --- a/spec/support/rails_template.rb +++ b/spec/support/rails_template.rb @@ -431,6 +431,14 @@ def filter_defaults_apply? filter :position end + ActiveAdmin.register Post, as: "OpenPairedPost" do + # A two-ended member of a with: group whose default fills only the end it has - the group + # is answered by that bound, not left half-declared. + filter :published_date, as: :date_range, + extensions: { default: -> { Date.new(2026, 5, 1).. }, with: :position_eq } + filter :position, extensions: { default: { eq: 20 } } + end + ActiveAdmin.register Post, as: "PickyPost" do # The active-calls shape: this index answers no question at all until it is asked one. require_any_filter notice: "You must specify at least one filter" From 64d5d76840c03c3c0f6607fa7dbcf324ee10a806 Mon Sep 17 00:00:00 2001 From: Igor Fedoronchuk Date: Sun, 18 Oct 2026 14:02:58 +0200 Subject: [PATCH 15/24] An exclusive Range default raises instead of widening The input submits gteq and lteq, so `a...b` stored b as the upper bound and searched the day the Range excluded - then the redirect froze that wrong bound into every copied URL. Every other Range ambiguity here raises; this one changed what was searched quietly. An endless exclusive Range has no end to exclude and still passes. --- README.md | 2 ++ lib/active_admin_filter_extensions/filter_defaults.rb | 7 +++++++ spec/integration/filter_defaults_spec.rb | 8 ++++++++ spec/support/rails_template.rb | 7 +++++++ 4 files changed, 24 insertions(+) diff --git a/README.md b/README.md index e4c81ea..057537c 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,8 @@ filter :title, extensions: { default: "acme" } **A Range** fills a two-ended input, one bound per end; leave an end off and that end is left to the admin. Handing a single value to a two-ended input raises, rather than picking an end for you. +So does an exclusive range (`a...b`): the input submits `gteq` and `lteq`, so the excluded end has +no spelling there and would be searched after all. Anything relative to now belongs in a Proc, as above. `filter` runs when the resource file is loaded, so `default: 1.week.ago..` would pin the window to the moment the process booted and let diff --git a/lib/active_admin_filter_extensions/filter_defaults.rb b/lib/active_admin_filter_extensions/filter_defaults.rb index 077d90c..f2b56c4 100644 --- a/lib/active_admin_filter_extensions/filter_defaults.rb +++ b/lib/active_admin_filter_extensions/filter_defaults.rb @@ -295,6 +295,13 @@ def assign_derived_filter_default(result, attribute, options, default) raise_filter_default_error(attribute, "a Range needs an input with two ends, and this one submits #{names.join(' and ')}") end + # The input submits gteq and lteq, so an excluded end has no spelling here: stored as + # lteq it would search the very bound the Range left out, quietly and in every copied + # URL. An endless exclusive Range has no end to exclude and is fine. + if default.exclude_end? && !default.end.nil? + raise_filter_default_error(attribute, "an exclusive Range has no gteq/lteq spelling, so its end would be searched after all - use an inclusive range, or name the predicates with a Hash") + end + result[names.first] = default.begin unless default.begin.nil? result[names.last] = default.end unless default.end.nil? else diff --git a/spec/integration/filter_defaults_spec.rb b/spec/integration/filter_defaults_spec.rb index 8167160..74d55e3 100644 --- a/spec/integration/filter_defaults_spec.rb +++ b/spec/integration/filter_defaults_spec.rb @@ -52,6 +52,14 @@ expect { visit "/admin/ambiguous_posts" } .to raise_error(ArgumentError, /filter :published_date declares a `default:`.*single value cannot say which to fill/m) end + + it "says so for an exclusive Range instead of widening it" do + # The inputs submit gteq and lteq, so an excluded end has no spelling: stored as lteq it + # searched the day the Range left out, and the redirect froze that bound into every + # copied URL. Every other Range ambiguity here raises; this one changed data quietly. + expect { visit "/admin/banana_exclusive_range_posts" } + .to raise_error(ArgumentError, /exclusive Range.*use an inclusive range/m) + end end # Filtering the collection is only half of it: the admin has to be able to see what the page diff --git a/spec/support/rails_template.rb b/spec/support/rails_template.rb index b10c669..7718e14 100644 --- a/spec/support/rails_template.rb +++ b/spec/support/rails_template.rb @@ -131,6 +131,13 @@ def current_admin_user filter :published_date, as: :date_range, extensions: { default: Date.new(2026, 1, 1) } end + ActiveAdmin.register Post, as: "BananaExclusiveRangePost" do + # The excluded end has no gteq/lteq spelling - the suite proves that raises instead of + # searching the day the Range left out. + filter :published_date, as: :date_range, + extensions: { default: -> { Date.new(2026, 1, 1)...Date.new(2026, 2, 1) } } + end + ActiveAdmin.register Post, as: "ProcInputHtmlPost" do filter :status, as: :select, collection: %w[draft published], input_html: proc { { class: "select2" } }, extensions: { default: "published" } From d96df3fd15c3415bd9ed42a3f748768bf5c6f13c Mon Sep 17 00:00:00 2001 From: Igor Fedoronchuk Date: Sun, 18 Oct 2026 14:05:00 +0200 Subject: [PATCH 16/24] require_any_filter counts a scope tab as a question Clicking a scope rendered an empty page with "You must specify at least one filter" beside the tab Active Admin had just put a count on - so scopes were unusable on exactly the resources this guard targets. --- README.md | 4 +++- lib/active_admin_filter_extensions/policies.rb | 4 ++++ spec/integration/require_any_filter_spec.rb | 10 ++++++++++ spec/support/rails_template.rb | 3 +++ 4 files changed, 20 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 057537c..32450f5 100644 --- a/README.md +++ b/README.md @@ -242,7 +242,9 @@ require_any_filter notice: "You must specify at least one filter" `require_any_filter` makes an index answer no question until it is asked one: a bare visit - a blank form submission included - renders an empty collection with the notice, never the -whole table. Resource-level on purpose: "any filter at all" belongs to no single filter. +whole table. Resource-level on purpose: "any filter at all" belongs to no single filter. A +scope tab counts as a question: Active Admin puts a count on it, and answering it with an empty +page would contradict the tab. ## on_invalid: what a violated policy does diff --git a/lib/active_admin_filter_extensions/policies.rb b/lib/active_admin_filter_extensions/policies.rb index 90fd510..bc7ba7d 100644 --- a/lib/active_admin_filter_extensions/policies.rb +++ b/lib/active_admin_filter_extensions/policies.rb @@ -150,6 +150,10 @@ def enforce_require_any_filter # when a declared filter submits it as-is (scope-named filters carry no predicate), or # when it carries a real predicate on a declared filter's attribute. def any_declared_filter_answered? + # A scope tab is a question, and the one Active Admin puts a count on: answering it + # with an empty page and "specify at least one filter" contradicts the tab. + return true if params[:scope].present? + exact = visible_filters.flat_map { |attribute, options| policy_search_keys(attribute, options) } bases = exact.map { |key| strip_ransack_predicate(key) } diff --git a/spec/integration/require_any_filter_spec.rb b/spec/integration/require_any_filter_spec.rb index 950ea35..6299954 100644 --- a/spec/integration/require_any_filter_spec.rb +++ b/spec/integration/require_any_filter_spec.rb @@ -79,6 +79,16 @@ expect(page).to have_no_content("keep me") end + it "counts a scope tab as asked" do + # Active Admin puts a count on the tab, so rendering it empty beside "specify at least one + # filter" contradicted the tab the admin had just clicked. + visit "/admin/picky_posts?scope=published" + + expect(page).to have_no_content("You must specify at least one filter") + expect(page).to have_content("keep me") + expect(page).to have_no_content("drop me") + end + it "counts a sibling predicate of a declared filter as asked" do visit "/admin/picky_posts?q%5Btitle_eq%5D=keep%20me" diff --git a/spec/support/rails_template.rb b/spec/support/rails_template.rb index 7718e14..86e35e2 100644 --- a/spec/support/rails_template.rb +++ b/spec/support/rails_template.rb @@ -450,6 +450,9 @@ def filter_defaults_apply? # The active-calls shape: this index answers no question at all until it is asked one. require_any_filter notice: "You must specify at least one filter" + # A scope tab is a question too - and the one Active Admin puts a count on. + scope(:published) { |posts| posts.where(status: "published") } + filter :title_cont, as: :string, extensions: { validate: ->(value) { value.match?(/\\d/) && "Title must not contain digits." } } filter :status, as: :select, collection: %w[draft published] From 68b1e7eb37eb199f35d6420bb330bfb60405e6cf Mon Sep 17 00:00:00 2001 From: Igor Fedoronchuk Date: Sun, 18 Oct 2026 14:10:30 +0200 Subject: [PATCH 17/24] Four small edges in the policy and option paths A max_range: shorter than a day divided to "a maximum date range of 0 days"; the cap is now named in the units it was declared in, and the shipped text no longer hardcodes "days". A string-keyed extensions: container passed the unknown-key check, which symbolizes, and then merged under a key the gem never reads - a declared default that quietly did nothing. The container is symbolized first. `rescue StandardError` around key derivation reported every internal failure as "could not work out which search key it submits", which policy_search_keys answered by guarding a key of its own invention. It now rescues NameError, which is what a resource with no `ransack` and a missing input class both raise. `required:` read a boolean false as unanswered. Rack never delivers one - ?q[k]=false arrives as the String - so this is reachable only through an override of #filtering_params, but `false` is an answer everywhere else in the gem. --- README.md | 3 +- config/locales/en.yml | 2 +- .../filter_options.rb | 4 +- .../policies.rb | 10 ++++- .../required_filters.rb | 11 ++++-- .../search_keys.rb | 6 ++- spec/data_access_spec.rb | 38 +++++++++++++++++++ spec/integration/extensions_container_spec.rb | 12 ++++++ spec/integration/window_spec.rb | 9 +++++ spec/support/rails_template.rb | 12 +++++- 10 files changed, 97 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 32450f5..8203b65 100644 --- a/README.md +++ b/README.md @@ -286,7 +286,8 @@ refills. An open upper bound is measured against now; a FUTURE lower bound with bound selects a not-yet-written window and passes. A default that would itself violate the policy raises on first use - resetting into a refused window is a loop, not a fallback. Messages are i18n keys under `active_admin_filter_extensions.*` (`range_invalid`, -`range_too_wide` with `%{limit}`, `before_floor` with `%{floor}`), shipped with English +`range_too_wide` with `%{limit}` (the cap in the units it was declared in, "90 days" or +"6 hours"), `before_floor` with `%{floor}`), shipped with English defaults. ## Value policies: sanitize and validate diff --git a/config/locales/en.yml b/config/locales/en.yml index 956663b..403dc48 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -4,7 +4,7 @@ en: active_admin_filter_extensions: invalid: "%{filter} filter has an invalid value." range_invalid: "%{filter} filter has an invalid date range value." - range_too_wide: "%{filter} filter allows a maximum date range of %{limit} days." + range_too_wide: "%{filter} filter allows a maximum date range of %{limit}." before_floor: "%{filter} filter cannot start before %{floor}." given_together: "%{filters} filters must be given together." require_any_filter: "You must specify at least one filter" diff --git a/lib/active_admin_filter_extensions/filter_options.rb b/lib/active_admin_filter_extensions/filter_options.rb index feaa60c..11b1a01 100644 --- a/lib/active_admin_filter_extensions/filter_options.rb +++ b/lib/active_admin_filter_extensions/filter_options.rb @@ -52,7 +52,9 @@ def merge_filter_extensions(attribute, options) raise ArgumentError, "filter :#{attribute}: #{stray.map(&:inspect).join(', ')} " "belong inside extensions: { ... }" end - extensions = options[:extensions] || {} + # Symbolized, because the unknown-key check below symbolizes too: a string-keyed container + # otherwise passed it and then merged under a key the gem never reads. + extensions = (options[:extensions] || {}).transform_keys(&:to_sym) unknown = extensions.keys.map(&:to_sym) - EXTENSION_KEYS if unknown.any? raise ArgumentError, "filter :#{attribute}: unknown extensions: #{unknown.map(&:inspect).join(', ')} - " "the gem ships #{EXTENSION_KEYS.map(&:inspect).join(', ')}" diff --git a/lib/active_admin_filter_extensions/policies.rb b/lib/active_admin_filter_extensions/policies.rb index bc7ba7d..6ace68b 100644 --- a/lib/active_admin_filter_extensions/policies.rb +++ b/lib/active_admin_filter_extensions/policies.rb @@ -381,14 +381,14 @@ def window_violation(options, from_raw, to_raw) if (max = options[:max_range]) # An upper bound alone is an infinitely wide window - the outage query the cap # exists to refuse. - return [:range_too_wide, { limit: (max / 1.day).to_i }] if from.nil? && to + return [:range_too_wide, { limit: window_limit_text(max) }] if from.nil? && to if from # An open upper bound is measured against now; a FUTURE lower bound with no upper # bound selects a not-yet-written window and passes - measuring it against now # would read it as inverted. return nil if to.nil? && from >= Time.current - return [:range_too_wide, { limit: (max / 1.day).to_i }] if (to || Time.current) - from > max + return [:range_too_wide, { limit: window_limit_text(max) }] if (to || Time.current) - from > max end end @@ -400,6 +400,12 @@ def window_violation(options, from_raw, to_raw) nil end + # The cap in the units it was declared in: divided into whole days, every cap shorter than + # one reported itself as "0 days". + def window_limit_text(max) + max.is_a?(::ActiveSupport::Duration) ? max.inspect : "#{max.to_i / 1.day} days" + end + # `min:` resolved for this request, once. nil imposes nothing - that is how a per-admin # floor opts out, the way a nil default: does. def window_floor(options) diff --git a/lib/active_admin_filter_extensions/required_filters.rb b/lib/active_admin_filter_extensions/required_filters.rb index 825d489..05a9837 100644 --- a/lib/active_admin_filter_extensions/required_filters.rb +++ b/lib/active_admin_filter_extensions/required_filters.rb @@ -49,8 +49,8 @@ def required_conditions(attribute, options) def required_filter_defaults_absent_from(requested) required_filter_default_values.each_with_object({}) do |(_attribute, entry), result| next if required_group_answered?(requested, entry[:keys]) - next if entry[:if].any? && entry[:if].none? { |key| requested[key].present? } - next if entry[:unless].any? { |key| requested[key].present? } + next if entry[:if].any? && entry[:if].none? { |key| answered_value?(requested[key]) } + next if entry[:unless].any? { |key| answered_value?(requested[key]) } result.merge!(entry[:defaults]) end @@ -66,7 +66,7 @@ def required_group_answered?(requested, keys) bases = keys.map { |key| strip_ransack_predicate(key) } requested.any? do |key, value| - next false unless value.present? + next false unless answered_value?(value) key = key.to_s next true if keys.include?(key) @@ -74,5 +74,10 @@ def required_group_answered?(requested, keys) key != strip_ransack_predicate(key) && bases.include?(strip_ransack_predicate(key)) end end + + # `false` is a value, not a blank - the rule the normalized request already follows. + def answered_value?(value) + value.present? || value == false + end end end diff --git a/lib/active_admin_filter_extensions/search_keys.rb b/lib/active_admin_filter_extensions/search_keys.rb index 55aac28..1ee1210 100644 --- a/lib/active_admin_filter_extensions/search_keys.rb +++ b/lib/active_admin_filter_extensions/search_keys.rb @@ -29,7 +29,11 @@ def filter_search_keys(attribute, options) end names.map { |name| name.to_s[/\Aq\[([^\]]+)\]/, 1] || name.to_s } - rescue StandardError => e + # Narrow on purpose: #policy_search_keys answers an ArgumentError from here by searching + # the declared filter name instead, so a catch-all would turn any internal failure into a + # policy quietly guarding the wrong key. A resource with no `ransack`, and an input class + # that does not exist, are both NameErrors - and they are what "cannot derive" means. + rescue NameError => e raise_filter_default_error(attribute, "could not work out which search key it submits (#{e.class}: #{e.message}) - name the predicate with a Hash instead") end diff --git a/spec/data_access_spec.rb b/spec/data_access_spec.rb index 1566951..ac9e70c 100644 --- a/spec/data_access_spec.rb +++ b/spec/data_access_spec.rb @@ -174,6 +174,44 @@ def controller.filter_defaults_apply? = true end end + describe "a required filter answered with false" do + # Rack never delivers a boolean - `?q[starred_eq]=false` arrives as the String - but an + # override of #filtering_params can, and `false` is an answer everywhere else in the gem. + let(:filters) { { starred_eq: { extensions: { default: true, required: true } } } } + let(:params) { { q: { "starred_eq" => false } } } + + it "is answered, so no default engages" do + expect(controller.send(:filter_defaults_engaged?)).to be(false) + end + end + + describe "key derivation" do + # Deriving builds a real input, so the unit suite reaches it through a stub. A catch-all + # rescue turned every internal failure into "could not work out which search key it + # submits", which policy_search_keys then answered with a key of its own invention. + def deriving(error) + Class.new do + include ActiveAdminFilterExtensions::SearchKeys + define_method(:build_filter_input) { |_attribute, _options| raise error } + end.new + end + + it "reports an input it cannot build" do + # No `ransack` on the resource - what a proxy page backed by an HTTP API answers. + controller = deriving(NoMethodError.new("undefined method 'ransack'")) + + expect { controller.send(:filter_search_keys, :created_at, {}) } + .to raise_error(ArgumentError, /could not work out which search key it submits/) + end + + it "lets a failure that is not a derivation failure through" do + controller = deriving(RuntimeError.new("boom")) + + expect { controller.send(:filter_search_keys, :created_at, {}) } + .to raise_error(RuntimeError, "boom") + end + end + describe "a policy on a filter whose search key cannot be derived" do # The derivation needs a resource Ransack can search; a proxy page backed by an HTTP API # has none, and a policy must not turn every index of such a page into a 500. The declared diff --git a/spec/integration/extensions_container_spec.rb b/spec/integration/extensions_container_spec.rb index 0aa3f21..8fb8ef4 100644 --- a/spec/integration/extensions_container_spec.rb +++ b/spec/integration/extensions_container_spec.rb @@ -6,6 +6,18 @@ # are loud: a gem key left flat would otherwise mean something else to Formtastic (required:), # and a typo inside would silently guard nothing. RSpec.describe "the extensions: container", type: :feature do + it "reads a string-keyed container" do + # The unknown-key check symbolizes before comparing, so "default" => ... passed it and then + # merged under a String key the gem never reads: a declared default that quietly did nothing. + Post.create!(title: "keep me", status: "published", position: 20) + Post.create!(title: "drop me", status: "draft", position: 1) + + visit "/admin/string_keyed_extensions_posts" + + expect(page).to have_content("keep me") + expect(page).to have_no_content("drop me") + end + it "raises for a gem option left outside extensions:" do expect { visit "/admin/banana_flat_option_posts" } .to raise_error(ArgumentError, /:default.*belong inside extensions:/m) diff --git a/spec/integration/window_spec.rb b/spec/integration/window_spec.rb index 544551e..198e268 100644 --- a/spec/integration/window_spec.rb +++ b/spec/integration/window_spec.rb @@ -63,6 +63,15 @@ def visit_window(gteq: nil, lteq: nil) .to eq("published_date_gteq" => "2026-05-01", "published_date_lteq" => "2026-06-30") end + it "names a sub-day cap in the units it was declared in" do + # Divided into whole days, a cap shorter than one read "a maximum date range of 0 days". + visit "/admin/short_window_posts?q%5Bpublished_date_gteq%5D=2026-01-01" \ + "&q%5Bpublished_date_lteq%5D=2026-06-30" + + expect(page).to have_content("maximum date range of 6 hours") + expect(page).to have_no_content("of 0 days") + end + it "imposes nothing when a min: Proc resolves to nil" do # An upper bound alone: both floor lookups are on this path, and nil.in_time_zone 500'd # the request the floor was only meant to narrow. diff --git a/spec/support/rails_template.rb b/spec/support/rails_template.rb index 86e35e2..f5b625a 100644 --- a/spec/support/rails_template.rb +++ b/spec/support/rails_template.rb @@ -279,6 +279,16 @@ def filter_defaults_apply? default: -> { Date.new(2026, 5, 1)..Date.new(2026, 6, 30) } } end + ActiveAdmin.register Post, as: "ShortWindowPost" do + # A cap shorter than a day - reported in whole days it read "0 days". + filter :published_date, as: :date_range, extensions: { max_range: 6.hours } + end + + ActiveAdmin.register Post, as: "StringKeyedExtensionsPost" do + # A string-keyed container: accepted by the unknown-key check and then silently inert. + filter :title_cont, as: :string, extensions: { "default" => "keep" } + end + ActiveAdmin.register Post, as: "NilFloorPost" do # A per-admin retention floor that opts out for this admin - nil imposes nothing, the way # a nil default: does. @@ -530,7 +540,7 @@ def filter_defaults_apply? # message: in the app's own words, naming the gem's own interpolation. filter :published_date, as: :date_range, extensions: { max_range: 90.days, - message: "Pick at most %{limit} days." } + message: "Pick at most %{limit}." } end ActiveAdmin.register Post, as: "BananaNoticeKeyPost" do From bf599f5f73dbdfe978f5c516eeff25b643cb8ab1 Mon Sep 17 00:00:00 2001 From: Igor Fedoronchuk Date: Sun, 18 Oct 2026 14:25:01 +0200 Subject: [PATCH 18/24] An Array default's blank members are not values to validate [nil, "published"] turned nil into "", the validator refused it, and materialization raised on every request - so an Array default with a per-admin member resolving to nothing broke the index for exactly those admins. Blank members are dropped before the per-element check, the way request normalization drops them on the way back in; a wholly blank Array is already absent. --- .../filter_defaults.rb | 10 ++++++++-- spec/integration/policies_spec.rb | 10 ++++++++++ spec/support/rails_template.rb | 13 +++++++++++++ 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/lib/active_admin_filter_extensions/filter_defaults.rb b/lib/active_admin_filter_extensions/filter_defaults.rb index f2b56c4..39becff 100644 --- a/lib/active_admin_filter_extensions/filter_defaults.rb +++ b/lib/active_admin_filter_extensions/filter_defaults.rb @@ -204,9 +204,15 @@ def validate_default_against_value_policies(attribute, options, result, keys) end end - # An Array default is submitted element by element; anything else as one String. + # An Array default is submitted element by element; anything else as one String. Blank + # members are dropped the way #requested_filtering_params drops them on the way back in: + # [nil, "published"] rides the URL as ?q[k][]=&q[k][]=published and searches the second + # alone, so judging the blank refuses a value nothing ever searches. A wholly blank Array + # is already absent. def default_value_strings(value) - value.is_a?(Array) ? value.map(&:to_s) : [value.to_s] + return [value.to_s] unless value.is_a?(Array) + + value.map(&:to_s).reject(&:blank?) end def sanitize_default_value(attribute, sanitizer, value) diff --git a/spec/integration/policies_spec.rb b/spec/integration/policies_spec.rb index bfde8b6..361b06f 100644 --- a/spec/integration/policies_spec.rb +++ b/spec/integration/policies_spec.rb @@ -116,6 +116,16 @@ def query_of(url) expect(page).to have_content("has an invalid value") end + it "ignores a blank member of an Array default" do + # nil became "", the validator refused it, and materialization raised on every request - + # for exactly the admins whose per-admin member resolved to nothing. + visit "/admin/nil_member_policed_posts" + + expect(query_of(page.current_url)["q"]).to eq("status_in" => ["published"]) + expect(page).to have_content("keep me") + expect(page).to have_no_content("drop me") + end + it "materializes an Array default beside its validator" do visit "/admin/policed_boxes_posts" diff --git a/spec/support/rails_template.rb b/spec/support/rails_template.rb index f5b625a..4d710af 100644 --- a/spec/support/rails_template.rb +++ b/spec/support/rails_template.rb @@ -107,6 +107,19 @@ def current_admin_user } } end + ActiveAdmin.register Post, as: "NilMemberPolicedPost" do + # An Array default with a blank member beside a real one - `[current_admin_user.team, + # "published"]` for an admin with no team. The blank is not a value the request would + # search, so it is not one the validator should judge. + filter :status, as: :check_boxes, collection: %w[draft published], + extensions: { default: [nil, "published"], + validate: lambda { |value| + next "Letters only." unless value.match?(/\\A[a-z]+\\z/) + + "No x statuses." if value.start_with?("x") + } } + end + ActiveAdmin.register Post, as: "BooleanPost" do filter :starred, extensions: { default: true } end From 97bf4118d9244fc7b2146f48295f22318ca5e371 Mon Sep 17 00:00:00 2001 From: Igor Fedoronchuk Date: Sun, 18 Oct 2026 14:26:25 +0200 Subject: [PATCH 19/24] A blank array element is not a submission to validate ?q[status_in][]=&q[status_in][]=draft is the shape a multi-select submits, and normalization discards the blank before searching - but the validator saw it, flashed an error about a value nothing searches and reset a legitimate selection to the default. Blank elements are skipped now. A blank SCALAR is still judged: the sanitizer may have just emptied it, which is what :empty exists to report. --- lib/active_admin_filter_extensions/policies.rb | 8 +++++++- spec/integration/policies_spec.rb | 11 +++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/lib/active_admin_filter_extensions/policies.rb b/lib/active_admin_filter_extensions/policies.rb index 6ace68b..b351ea8 100644 --- a/lib/active_admin_filter_extensions/policies.rb +++ b/lib/active_admin_filter_extensions/policies.rb @@ -274,7 +274,13 @@ def enforce_scalar_policies(q, attribute, options, keys) next unless (validator = options[:validate]) validator = Validators.resolve(validator) - message = Array(value).lazy.filter_map { |element| ::MethodOrProcHelper.render_in_context(self, validator, element) }.first + # Blank ELEMENTS are the ones #requested_filtering_params discards before searching - + # ?q[k][]=&q[k][]=draft is the shape a multi-select submits - so judging one refuses + # a value nothing searches and resets a legitimate selection. A blank scalar is a + # different thing and still judged: the sanitizer may have just emptied it. + elements = value.is_a?(Array) ? value.reject(&:blank?) : [value] + message = elements.lazy + .filter_map { |element| ::MethodOrProcHelper.render_in_context(self, validator, element) }.first # nil AND false mean "valid" - `condition && "message"` is how Ruby writes these. next unless message diff --git a/spec/integration/policies_spec.rb b/spec/integration/policies_spec.rb index 361b06f..6b14a48 100644 --- a/spec/integration/policies_spec.rb +++ b/spec/integration/policies_spec.rb @@ -110,6 +110,17 @@ def query_of(url) expect(page).to have_no_content("drop me") end + it "ignores a blank element beside a real selection" do + # ?q[status_in][]=&q[status_in][]=draft is the shape a multi-select submits, and + # normalization drops the blank before searching - judged, it flashed an error about a + # value nothing searches and reset a legitimate selection to the default. + visit "/admin/policed_boxes_posts?q%5Bstatus_in%5D%5B%5D=&q%5Bstatus_in%5D%5B%5D=draft" + + expect(page).to have_no_content("Letters only.") + expect(page).to have_content("drop me") + expect(page).to have_no_content("keep me") + end + it "still refuses a Hash-wrapped value on the same filter" do visit "/admin/policed_boxes_posts?q%5Bstatus_in%5D%5Bvalue%5D=draft" From 01f5abfc399d4c492ca5de83a44a8bc1cd59b727 Mon Sep 17 00:00:00 2001 From: Igor Fedoronchuk Date: Sun, 18 Oct 2026 14:27:44 +0200 Subject: [PATCH 20/24] require_any_filter counts only a scope Active Admin resolves Any non-blank scope satisfied the guard, so ?scope=does_not_exist - which Active Admin resolves to nothing and applies as nothing - returned the whole table from an index declared to answer nothing unasked, on CSV as well as HTML. The check now asks #current_scope, which is nil for an id no resource declares. --- lib/active_admin_filter_extensions/policies.rb | 7 +++++-- spec/integration/require_any_filter_spec.rb | 18 ++++++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/lib/active_admin_filter_extensions/policies.rb b/lib/active_admin_filter_extensions/policies.rb index b351ea8..e0477eb 100644 --- a/lib/active_admin_filter_extensions/policies.rb +++ b/lib/active_admin_filter_extensions/policies.rb @@ -151,8 +151,11 @@ def enforce_require_any_filter # when it carries a real predicate on a declared filter's attribute. def any_declared_filter_answered? # A scope tab is a question, and the one Active Admin puts a count on: answering it - # with an empty page and "specify at least one filter" contradicts the tab. - return true if params[:scope].present? + # with an empty page and "specify at least one filter" contradicts the tab. One Active + # Admin actually resolves, though - #current_scope is nil for an id no resource + # declares, and an unresolved scope narrows nothing, so counting its mere presence + # hands back the whole table behind a scope-shaped URL. + return true if params[:scope].present? && current_scope.present? exact = visible_filters.flat_map { |attribute, options| policy_search_keys(attribute, options) } bases = exact.map { |key| strip_ransack_predicate(key) } diff --git a/spec/integration/require_any_filter_spec.rb b/spec/integration/require_any_filter_spec.rb index 6299954..1b194ac 100644 --- a/spec/integration/require_any_filter_spec.rb +++ b/spec/integration/require_any_filter_spec.rb @@ -89,6 +89,24 @@ expect(page).to have_no_content("drop me") end + it "treats a scope no resource declares as unasked" do + # Active Admin resolves an unknown scope id to nothing and applies no scope, so counting + # its mere presence hands back the whole table behind a scope-shaped URL. + visit "/admin/picky_posts?scope=does_not_exist" + + expect(page).to have_content("You must specify at least one filter") + expect(page).to have_no_content("keep me") + expect(page).to have_no_content("drop me") + end + + it "treats a scope no resource declares as unasked on CSV too" do + # The format that gets no canonicalizing hop - and no warning to read either. + visit "/admin/picky_posts.csv?scope=does_not_exist" + + expect(page.body).not_to include("keep me") + expect(page.body).not_to include("drop me") + end + it "counts a sibling predicate of a declared filter as asked" do visit "/admin/picky_posts?q%5Btitle_eq%5D=keep%20me" From e2e68a1e27b23131bf8e901cf2d8057a0cce6f9c Mon Sep 17 00:00:00 2001 From: Igor Fedoronchuk Date: Sun, 18 Oct 2026 14:28:56 +0200 Subject: [PATCH 21/24] One wrapper for the values a default submits Array.wrap collapses the explicit Array branch, and it is the right wrapper rather than Array(): it asks for to_ary, so a Hash default - which add_filter_default_value can store under a predicate key - stays one value to judge instead of being splatted into its pairs. It also unifies the blank handling the branch left out: a blank scalar default now yields nothing to validate instead of being judged as "". That is a behavior change, and the convention it joins - a nil default imposes nothing, a blank value is no answer anywhere else in the gem. A blank default cannot ride the URL and Ransack strips it, so refusing one raised over a value that never reaches a search. --- .../filter_defaults.rb | 17 +++++++++-------- spec/integration/policies_spec.rb | 10 ++++++++++ spec/support/rails_template.rb | 8 ++++++++ 3 files changed, 27 insertions(+), 8 deletions(-) diff --git a/lib/active_admin_filter_extensions/filter_defaults.rb b/lib/active_admin_filter_extensions/filter_defaults.rb index 39becff..81a33cd 100644 --- a/lib/active_admin_filter_extensions/filter_defaults.rb +++ b/lib/active_admin_filter_extensions/filter_defaults.rb @@ -204,15 +204,16 @@ def validate_default_against_value_policies(attribute, options, result, keys) end end - # An Array default is submitted element by element; anything else as one String. Blank - # members are dropped the way #requested_filtering_params drops them on the way back in: - # [nil, "published"] rides the URL as ?q[k][]=&q[k][]=published and searches the second - # alone, so judging the blank refuses a value nothing ever searches. A wholly blank Array - # is already absent. + # The values a default actually submits: an Array element by element, anything else as the + # one String the URL will say. Blank ones are dropped the way #requested_filtering_params + # drops them on the way back in - [nil, "published"] rides the URL as + # ?q[k][]=&q[k][]=published and searches the second alone, and a blank scalar cannot ride + # it at all - so judging either refuses a value nothing ever searches. + # + # Array.wrap, not Array(): it asks for to_ary, so a Hash default stays one value to judge + # instead of being splatted into its pairs. def default_value_strings(value) - return [value.to_s] unless value.is_a?(Array) - - value.map(&:to_s).reject(&:blank?) + Array.wrap(value).map(&:to_s).reject(&:blank?) end def sanitize_default_value(attribute, sanitizer, value) diff --git a/spec/integration/policies_spec.rb b/spec/integration/policies_spec.rb index 6b14a48..a4b0c83 100644 --- a/spec/integration/policies_spec.rb +++ b/spec/integration/policies_spec.rb @@ -168,6 +168,16 @@ def query_of(url) expect(page).to have_content("Pick at most 90 days.") end + it "does not refuse a blank default" do + # Blank is no value: the URL cannot carry it and Ransack would strip it, so judging it + # raised over a default that never reaches a search. + visit "/admin/blank_default_policed_posts" + + expect(URI.parse(page.current_url).query).to be_nil + expect(page).to have_content("keep me") + expect(page).to have_content("drop me") + end + it "falls back to the generic text for a parameterized symbol it cannot interpolate" do # The docs invite a validate: that returns a gem scope symbol; the parameterized ones # have nothing to interpolate from, and raising there 500s the request the policy is diff --git a/spec/support/rails_template.rb b/spec/support/rails_template.rb index 4d710af..f51e11d 100644 --- a/spec/support/rails_template.rb +++ b/spec/support/rails_template.rb @@ -181,6 +181,14 @@ def current_admin_user extensions: { default: -> { [nil] } } end + ActiveAdmin.register Post, as: "BlankDefaultPolicedPost" do + # A blank default beside a validate:. Blank is no value - the URL cannot even carry it - + # so there is nothing for the validator to refuse. + filter :title_cont, as: :string, + extensions: { default: "", + validate: ->(value) { "Letters only." unless value.match?(/\\A[a-z]+\\z/) } } + end + ActiveAdmin.register Post, as: "BananaBlankDefaultPost" do # A blank default is a value ?q[k]= cannot round-trip (the blank is stripped on the way # back in) - it must stay out of the redirect, not loop it. From 92754cde3d12f13f6c9c63e89e0c1affafdde76c Mon Sep 17 00:00:00 2001 From: Igor Fedoronchuk Date: Sun, 18 Oct 2026 14:30:03 +0200 Subject: [PATCH 22/24] The defaults notice reads the defaults that actually reach the search #filter_defaults_engaged? inspected the materialized defaults, not the set #complete_with_groups hands Ransack, so a with: group suppressed because one member opted out still counted as engaged: a resource with default_filters_notice announced a default slice while rendering the unfiltered collection. It now asks the same completion the merge path asks. --- lib/active_admin_filter_extensions/filter_defaults.rb | 7 +++++-- spec/integration/with_spec.rb | 10 ++++++++++ spec/support/rails_template.rb | 2 ++ 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/lib/active_admin_filter_extensions/filter_defaults.rb b/lib/active_admin_filter_extensions/filter_defaults.rb index 81a33cd..3af37a6 100644 --- a/lib/active_admin_filter_extensions/filter_defaults.rb +++ b/lib/active_admin_filter_extensions/filter_defaults.rb @@ -94,8 +94,11 @@ def filter_defaults_engaged? requested = requested_filtering_params || {} if filter_defaults_apply? # At least one default must actually reach the search: a widened apply? beside a - # request that answered every defaulted key itself engaged nothing. - return filter_default_values.keys.any? { |key| requested[key].nil? } + # request that answered every defaulted key itself engaged nothing. Through + # #complete_with_groups, which is what decides that on the merge path - a with: group + # one member opted out of imposes nothing, and announcing it would describe a default + # slice over the unfiltered collection. + return complete_with_groups(filter_default_values).keys.any? { |key| requested[key].nil? } end required_filter_defaults_absent_from(requested).any? diff --git a/spec/integration/with_spec.rb b/spec/integration/with_spec.rb index 8816f6d..d0af623 100644 --- a/spec/integration/with_spec.rb +++ b/spec/integration/with_spec.rb @@ -127,6 +127,16 @@ def query_of(url) expect(page).to have_content("drop me") end + it "says nothing about a default slice the suppressed group is not showing" do + # The group imposed nothing, so the collection is unfiltered - and the notice announced a + # default slice over the whole table, which is the notice lying about the page. + visit "/admin/nil_paired_posts" + + expect(page).to have_no_content("Showing published starred posts by default") + expect(page).to have_content("keep me") + expect(page).to have_content("drop me") + end + it "raises when defaults answer only half the group" do # The fallback is held to the same all-or-none contract the enforcement imposes. expect { visit "/admin/banana_half_paired_posts" } diff --git a/spec/support/rails_template.rb b/spec/support/rails_template.rb index f51e11d..0b16302 100644 --- a/spec/support/rails_template.rb +++ b/spec/support/rails_template.rb @@ -447,6 +447,8 @@ def filter_defaults_apply? end ActiveAdmin.register Post, as: "NilPairedPost" do + default_filters_notice "Showing published starred posts by default" + # A member of a with: group whose default opts out for this admin. Declared, so the group # is complete; nil, so it imposes nothing - and neither does the partner. filter :status, as: :select, collection: %w[draft published], From 10cfc6af7d990518def2e36e6ab5c876179de52b Mon Sep 17 00:00:00 2001 From: Igor Fedoronchuk Date: Sun, 18 Oct 2026 14:31:15 +0200 Subject: [PATCH 23/24] A with: group combines only the keys one input submits together Grouping members by the stripped attribute also merged two single-ended filters declared separately on one column - position_gteq with: position_lteq - so submitting one bound passed as a whole group and a default on one half passed the all-or-none check. Members are now built from the key sets the inputs themselves submit, so a genuinely two-ended input is still answered by either bound while separately declared partners stay distinct. --- .../policies.rb | 19 ++++++++++++++- spec/integration/with_spec.rb | 23 +++++++++++++++++++ spec/support/rails_template.rb | 14 +++++++++++ 3 files changed, 55 insertions(+), 1 deletion(-) diff --git a/lib/active_admin_filter_extensions/policies.rb b/lib/active_admin_filter_extensions/policies.rb index e0477eb..c52f96e 100644 --- a/lib/active_admin_filter_extensions/policies.rb +++ b/lib/active_admin_filter_extensions/policies.rb @@ -190,8 +190,25 @@ def enforce_with_groups(q) # A group's members, not its keys: a two-ended filter submits two of them and is answered # by either bound - the touched-is-answered rule required: already follows. Counted as # separate keys, one bound beside the partner read as a half-answered group. + # + # Only keys ONE input submits together are combined. Grouping by the stripped attribute + # instead would also merge two single-ended filters declared separately on one column + # (position_gteq with: position_lteq), and half that answer would pass as a whole group. def with_group_members(keys) - keys.group_by { |key| strip_ransack_predicate(key) }.values + shared = multi_key_filter_keys.filter_map do |input_keys| + own = input_keys & keys + own if own.size > 1 + end + + (keys - shared.flatten).map { |key| [key] } + shared + end + + # The key sets of filters whose input submits more than one - the two-ended ones. + def multi_key_filter_keys + @multi_key_filter_keys ||= visible_filters.filter_map do |attribute, options| + keys = policy_search_keys(attribute, options) + keys if keys.size > 1 + end end # `with: :year_eq` on a filter: its own search keys and the named partners are one diff --git a/spec/integration/with_spec.rb b/spec/integration/with_spec.rb index d0af623..1c05618 100644 --- a/spec/integration/with_spec.rb +++ b/spec/integration/with_spec.rb @@ -82,6 +82,29 @@ def query_of(url) expect(page).to have_no_content("drop me") end + it "keeps separately declared partners on one attribute distinct" do + # Two single-ended filters are two members, not one two-ended input: grouping them by the + # stripped attribute made half an answer pass as a whole group. + visit "/admin/split_pair_posts?q%5Bposition_gteq%5D=10" + + expect(page).to have_content("must be given together") + expect(page).to have_content("keep me") + expect(page).to have_content("drop me") + end + + it "lets both halves of a split pair through" do + visit "/admin/split_pair_posts?q%5Bposition_gteq%5D=10&q%5Bposition_lteq%5D=30" + + expect(page).to have_no_content("must be given together") + expect(page).to have_content("keep me") + expect(page).to have_no_content("drop me") + end + + it "raises when only one half of a split pair declares a default" do + expect { visit "/admin/banana_split_pair_posts" } + .to raise_error(ArgumentError, /answer the whole\s+group or none/m) + end + it "lets a fully-answered group through" do visit "/admin/paired_posts?q%5Bstatus_eq%5D=draft&q%5Bstarred_eq%5D=false" diff --git a/spec/support/rails_template.rb b/spec/support/rails_template.rb index 0b16302..589d4b1 100644 --- a/spec/support/rails_template.rb +++ b/spec/support/rails_template.rb @@ -464,6 +464,20 @@ def filter_defaults_apply? filter :starred end + ActiveAdmin.register Post, as: "SplitPairPost" do + # Two single-ended filters on ONE attribute, linked by with:. Grouped by stripped + # attribute they look like one two-ended input, so half an answer passed as a whole group. + filter :position_gteq, as: :string, extensions: { with: :position_lteq } + filter :position_lteq, as: :string + end + + ActiveAdmin.register Post, as: "BananaSplitPairPost" do + # Only one half of a split pair declares a default - the fallback would run the very + # half-group the enforcement refuses. The suite proves that raises. + filter :position_gteq, as: :string, extensions: { default: "10", with: :position_lteq } + filter :position_lteq, as: :string + end + ActiveAdmin.register Post, as: "PairedDatePost" do # Declared on ONE member - the partner's side must trip the same group - and on a # two-ended filter, whose predicates the warning must still humanize away. From 8a02966817179a9ca0404207abeac489e4a96632 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 18 Oct 2026 12:41:31 +0000 Subject: [PATCH 24/24] Validate sanitized array values correctly Co-authored-by: Fivell <120269+Fivell@users.noreply.github.com> --- lib/active_admin_filter_extensions/policies.rb | 11 ++++++----- spec/integration/on_invalid_spec.rb | 10 ++++++++++ spec/integration/policies_spec.rb | 7 +++++++ spec/support/rails_template.rb | 5 +++++ 4 files changed, 28 insertions(+), 5 deletions(-) diff --git a/lib/active_admin_filter_extensions/policies.rb b/lib/active_admin_filter_extensions/policies.rb index c52f96e..109909e 100644 --- a/lib/active_admin_filter_extensions/policies.rb +++ b/lib/active_admin_filter_extensions/policies.rb @@ -284,6 +284,8 @@ def enforce_scalar_policies(q, attribute, options, keys) next end + value = value.reject(&:blank?) if value.is_a?(Array) + if (sanitizer = options[:sanitize]) sanitizer = Sanitizers.resolve(sanitizer) sanitized = sanitize_policed_value(attribute, sanitizer, value) @@ -294,11 +296,10 @@ def enforce_scalar_policies(q, attribute, options, keys) next unless (validator = options[:validate]) validator = Validators.resolve(validator) - # Blank ELEMENTS are the ones #requested_filtering_params discards before searching - - # ?q[k][]=&q[k][]=draft is the shape a multi-select submits - so judging one refuses - # a value nothing searches and resets a legitimate selection. A blank scalar is a - # different thing and still judged: the sanitizer may have just emptied it. - elements = value.is_a?(Array) ? value.reject(&:blank?) : [value] + # Blank input array elements are discarded before searching - ?q[k][]=&q[k][]=draft + # is the shape a multi-select submits - so remove them before sanitizing. Sanitized + # elements are all judged, including ones the sanitizer emptied. + elements = value.is_a?(Array) ? value : [value] message = elements.lazy .filter_map { |element| ::MethodOrProcHelper.render_in_context(self, validator, element) }.first # nil AND false mean "valid" - `condition && "message"` is how Ruby writes these. diff --git a/spec/integration/on_invalid_spec.rb b/spec/integration/on_invalid_spec.rb index b7e38a8..7f9460b 100644 --- a/spec/integration/on_invalid_spec.rb +++ b/spec/integration/on_invalid_spec.rb @@ -94,6 +94,16 @@ def query_of(url) expect(page).to have_no_content("drop me") end + it "validates array elements emptied by the sanitizer and preserves the original input" do + visit "/admin/empty_digits_posts?q%5Bposition_eq%5D%5B%5D=abc" + + expect(page).to have_content("filter has an invalid value") + expect(query_of(page.current_url)["q"]).to eq("position_eq" => ["abc"]) + expect(page).to have_field("q[position_eq]", with: "0") + expect(page).to have_no_content("keep me") + expect(page).to have_no_content("drop me") + end + it "leaves a valid search alone" do visit "/admin/empty_posts?q%5Btitle_cont%5D=drop" diff --git a/spec/integration/policies_spec.rb b/spec/integration/policies_spec.rb index a4b0c83..4dcd085 100644 --- a/spec/integration/policies_spec.rb +++ b/spec/integration/policies_spec.rb @@ -121,6 +121,13 @@ def query_of(url) expect(page).to have_no_content("keep me") end + it "removes blank elements before sanitizing a multi-valued filter" do + visit "/admin/integer_sanitized_boxes_posts?q%5Bposition_in%5D%5B%5D=&q%5Bposition_in%5D%5B%5D=20" + + expect(page).to have_content("keep me") + expect(page).to have_no_content("drop me") + end + it "still refuses a Hash-wrapped value on the same filter" do visit "/admin/policed_boxes_posts?q%5Bstatus_in%5D%5Bvalue%5D=draft" diff --git a/spec/support/rails_template.rb b/spec/support/rails_template.rb index 589d4b1..bf64ad8 100644 --- a/spec/support/rails_template.rb +++ b/spec/support/rails_template.rb @@ -107,6 +107,11 @@ def current_admin_user } } end + ActiveAdmin.register Post, as: "IntegerSanitizedBoxesPost" do + filter :position, as: :check_boxes, collection: [1, 20], + extensions: { sanitize: ->(value) { Integer(value, 10).to_s }, validate: :integer } + end + ActiveAdmin.register Post, as: "NilMemberPolicedPost" do # An Array default with a blank member beside a real one - `[current_admin_user.team, # "published"]` for an admin with no team. The blank is not a value the request would