From d7cbc98ddea975e0503f3d99a2e9d59a68c04e76 Mon Sep 17 00:00:00 2001 From: MAEDA Go Date: Mon, 21 Sep 2026 10:57:02 +0900 Subject: [PATCH] Show a warning listing the search terms actually used when a text filter value exceeds the limit of 5 terms --- app/helpers/queries_helper.rb | 5 +++ app/models/query.rb | 26 ++++++++++++++++ app/views/calendars/show.html.erb | 1 + app/views/gantts/show.html.erb | 1 + app/views/queries/_query_form.html.erb | 1 + config/locales/en.yml | 1 + lib/redmine/search.rb | 37 +++++++++++++++++------ test/functional/issues_controller_test.rb | 22 ++++++++++++++ test/unit/lib/redmine/search_test.rb | 8 +++++ test/unit/query_test.rb | 37 +++++++++++++++++++++++ 10 files changed, 129 insertions(+), 10 deletions(-) diff --git a/app/helpers/queries_helper.rb b/app/helpers/queries_helper.rb index 5b86413b4..3028608f6 100644 --- a/app/helpers/queries_helper.rb +++ b/app/helpers/queries_helper.rb @@ -66,6 +66,11 @@ module QueriesHelper s end + # Renders the warnings about the filters of the given query + def render_query_filter_warnings(query) + safe_join(query.filter_warnings.map {|warning| content_tag('p', warning, :class => 'warning')}) + end + def query_filters_hidden_tags(query) tags = ''.html_safe query.filters.each do |field, options| diff --git a/app/models/query.rb b/app/models/query.rb index 824dc027d..d28efec1a 100644 --- a/app/models/query.rb +++ b/app/models/query.rb @@ -348,6 +348,11 @@ class Query < ApplicationRecord :tree => ["=", "~", "!*", "*"] } + # Filter types and operators whose value is split into tokens + # by Redmine::Search::Tokenizer + TOKENIZED_FILTER_TYPES = [:string, :text, :search].freeze + TOKENIZED_FILTER_OPERATORS = ["~", "*~", "!~", "^", "$"].freeze + class_attribute :available_columns self.available_columns = [] @@ -547,6 +552,27 @@ class Query < ApplicationRecord errors.add(:base, m) end + # Returns warning messages about filters that are applied only partially. + # Unlike errors, warnings do not prevent the query from being run + def filter_warnings + return [] unless filters + + filters.keys.filter_map do |field| + next unless TOKENIZED_FILTER_TYPES.include?(type_for(field)) && + TOKENIZED_FILTER_OPERATORS.include?(operator_for(field)) + + tokenizer = Redmine::Search::Tokenizer.new(values_for(field)&.first) + next unless tokenizer.truncated? + + connector = l(:'support.array.words_connector') + tokens = tokenizer.tokens.map {|token| %("#{token}")}.join(connector) + message = l(:notice_search_tokens_truncated, + :max => Redmine::Search::Tokenizer::MAX_TOKENS, + :tokens => tokens.truncate(100, :separator => connector)) + "#{label_for(field)}: #{message}" + end + end + def editable_by?(user) return false unless user diff --git a/app/views/calendars/show.html.erb b/app/views/calendars/show.html.erb index 66e3d9081..8db9d00c6 100644 --- a/app/views/calendars/show.html.erb +++ b/app/views/calendars/show.html.erb @@ -51,6 +51,7 @@ <% end %> <%= error_messages_for 'query' %> +<%= render_query_filter_warnings @query %> <% if @query.valid? %> <%= render :partial => 'common/calendar', :locals => {:calendar => @calendar} %> diff --git a/app/views/gantts/show.html.erb b/app/views/gantts/show.html.erb index 4772bc11c..80c1325c0 100644 --- a/app/views/gantts/show.html.erb +++ b/app/views/gantts/show.html.erb @@ -10,6 +10,7 @@ <%= render partial: 'query_form', locals: {project: @project, query: @query, gantt: @gantt} %> <%= error_messages_for 'query' %> +<%= render_query_filter_warnings @query %> <% if @query.valid? %> <%= render partial: 'chart', locals: {gantt: @gantt, query: @query} %> diff --git a/app/views/queries/_query_form.html.erb b/app/views/queries/_query_form.html.erb index 22a4c9171..4ceacef44 100644 --- a/app/views/queries/_query_form.html.erb +++ b/app/views/queries/_query_form.html.erb @@ -77,6 +77,7 @@ <%= error_messages_for @query %> +<%= render_query_filter_warnings @query %> <%= javascript_tag do %> $(function ($) { diff --git a/config/locales/en.yml b/config/locales/en.yml index 91890c4ac..a6269a79f 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -188,6 +188,7 @@ en: notice_unable_delete_time_entry: Unable to delete time log entry. notice_issue_done_ratios_updated: Issue done ratios updated. notice_gantt_chart_truncated: "The chart was truncated because it exceeds the maximum number of items that can be displayed (%{max})" + notice_search_tokens_truncated: "A maximum of %{max} search terms can be used. Only the following terms were used: %{tokens}" notice_issue_successful_create: "Issue %{id} created." notice_issue_update_conflict: "The issue has been updated by an other user while you were editing it." notice_account_deleted: "Your account has been permanently deleted." diff --git a/lib/redmine/search.rb b/lib/redmine/search.rb index 3605d866f..fdcb386bd 100644 --- a/lib/redmine/search.rb +++ b/lib/redmine/search.rb @@ -127,22 +127,39 @@ module Redmine end class Tokenizer + # Maximum number of tokens to search for + MAX_TOKENS = 5 + def initialize(question) @question = question.to_s end + # Returns the tokens to search for (no more than MAX_TOKENS) def tokens - # extract tokens from the question - # eg. hello "bye bye" => ["hello", "bye bye"] - tokens = @question.scan(/"[^"]+"|[^\p{Zs}]+/).map do |token| - # Remove quotes from quoted tokens, strip surrounding whitespace - # e.g. "\" foo bar \"" => "foo bar" - token.gsub(/\A"\p{Zs}*|\p{Zs}*"\Z/, '') + all_tokens.first(MAX_TOKENS) + end + + # Returns true if some tokens are ignored because the question + # contains more than MAX_TOKENS tokens + def truncated? + all_tokens.size > MAX_TOKENS + end + + private + + def all_tokens + @all_tokens ||= begin + # extract tokens from the question + # eg. hello "bye bye" => ["hello", "bye bye"] + tokens = @question.scan(/"[^"]+"|[^\p{Zs}]+/).map do |token| + # Remove quotes from quoted tokens, strip surrounding whitespace + # e.g. "\" foo bar \"" => "foo bar" + token.gsub(/\A"\p{Zs}*|\p{Zs}*"\Z/, '') + end + # tokens must be at least 2 characters long + # but for Chinese characters (Chinese HANZI/Japanese KANJI), tokens can be one character + tokens.uniq.select{|w| w.length > 1 || w =~ /\p{Han}/} end - # tokens must be at least 2 characters long - # but for Chinese characters (Chinese HANZI/Japanese KANJI), tokens can be one character - # no more than 5 tokens to search for - tokens.uniq.select{|w| w.length > 1 || w =~ /\p{Han}/}.first 5 end end diff --git a/test/functional/issues_controller_test.rb b/test/functional/issues_controller_test.rb index d23a0145f..b05687377 100644 --- a/test/functional/issues_controller_test.rb +++ b/test/functional/issues_controller_test.rb @@ -180,6 +180,28 @@ class IssuesControllerTest < Redmine::ControllerTest assert_query_filters [['tracker_id', '=', '1']] end + def test_index_with_too_many_tokens_in_filter_should_display_warning_and_results + get( + :index, + :params => { + :project_id => 1, + :set_filter => 1, + :f => ['subject'], + :op => {'subject' => '*~'}, + :v => {'subject' => ['one two print three four']} + } + ) + assert_response :success + + assert_select '#errorExplanation', 0 + assert_select 'p.warning', :count => 1, + :text => 'Subject: A maximum of 5 search terms can be used. Only the following terms were used: ' \ + '"one", "two", "print", "", "three"' + assert_not_include '', response.body + # Issues are still displayed using the first 5 tokens + assert_select 'table.issues tr.issue' + end + def test_index_with_short_filters to_test = { 'status_id' => { diff --git a/test/unit/lib/redmine/search_test.rb b/test/unit/lib/redmine/search_test.rb index e2d77cca0..755499885 100644 --- a/test/unit/lib/redmine/search_test.rb +++ b/test/unit/lib/redmine/search_test.rb @@ -35,4 +35,12 @@ class Redmine::Search::Tokenize < ActiveSupport::TestCase value = '"phrase one" "phrase two"' assert_equal ["phrase one", "phrase two"], Redmine::Search::Tokenizer.new(value).tokens end + + def test_tokenize_should_return_no_more_than_max_tokens + tokenizer = Redmine::Search::Tokenizer.new('one two three four five six seven') + assert_equal %w[one two three four five], tokenizer.tokens + assert tokenizer.truncated? + + assert_not Redmine::Search::Tokenizer.new('one two three four five').truncated? + end end diff --git a/test/unit/query_test.rb b/test/unit/query_test.rb index ebf7fc991..3bbfdcb01 100644 --- a/test/unit/query_test.rb +++ b/test/unit/query_test.rb @@ -796,6 +796,43 @@ class QueryTest < ActiveSupport::TestCase result.each {|issue| assert issue.subject =~ /(close|block)/i} end + def test_filter_warnings_should_include_used_tokens + query = IssueQuery.new( + :name => '_', + :filters => { + 'subject' => {:operator => '*~', :values => ['one two three four five six seven']}, + 'description' => {:operator => '~', :values => ['one two three four five']}, + 'any_searchable' => {:operator => '!~', :values => ['a one "two three" one four five six seven']} + } + ) + assert query.valid? + assert_equal( + [ + 'Subject: A maximum of 5 search terms can be used. Only the following terms were used: "one", "two", "three", "four", "five"', + 'Any searchable text: A maximum of 5 search terms can be used. Only the following terms were used: "one", "two three", "four", "five", "six"' + ], + query.filter_warnings + ) + end + + def test_filter_warnings_should_ignore_operators_and_filters_that_do_not_tokenize_the_value + query = IssueQuery.new( + :name => '_', + :filters => { + 'subject' => {:operator => '*', :values => ['']}, + 'status_id' => {:operator => 'o', :values => ['']}, + 'parent_id' => {:operator => '~', :values => ['1,2,3,4,5,6,7']} + } + ) + assert_equal [], query.filter_warnings + + user_query = UserQuery.new( + :name => '_', + :filters => {'login' => {:operator => '=', :values => ['one two three four five six']}} + ) + assert_equal [], user_query.filter_warnings + end + def test_operator_contains_any_of_with_any_searchable_text User.current = User.find(1) query = IssueQuery.new( -- 2.55.0