diff --git a/lib/full_text_search/hooks/issue_query_any_searchable.rb b/lib/full_text_search/hooks/issue_query_any_searchable.rb index 346c5d1b..0bcdac7b 100644 --- a/lib/full_text_search/hooks/issue_query_any_searchable.rb +++ b/lib/full_text_search/hooks/issue_query_any_searchable.rb @@ -1,9 +1,104 @@ module FullTextSearch module Hooks module IssueQueryAnySearchable + include FullTextSearch::ConditionBuilder + def sql_for_any_searchable_field(field, operator, value) - # TODO: Implement AND searches across multiple fields. - super(field, operator, value) + query = value.first + response = self.class.connection.select_value( + build_any_searchable_query(query, build_filter_condition) + ) + command = Groonga::Command.find("select").new("select", {}) + r = Groonga::Client::Response.parse(command, response) + if r.success? + issue_ids = r.records.map { |row| row["issue_id"] } + build_issue_id_condition(issue_ids, operator) + else + if Rails.env.production? + logger.warn(r.message) + "" + else + raise r.message + end + end + end + + private + + def compute_target_project_ids + target_ids = Project.allowed_to(User.current, :view_issues).pluck(:id) + compute_target_ids = if respond_to?(:project) && project + [project.id] + elsif has_filter?("project_id") + case values_for("project_id").first + when "mine" + User.current.projects.ids + when "bookmarks" + User.current.bookmarked_project_ids + else + values_for("project_id") + end + else + [] + end + target_ids &= compute_target_ids if compute_target_ids.present? + target_ids + end + + def compute_target_issue_ids + return unless has_filter?("status_id") + staus_opened = operator_for("status_id") == "o" + Issue.visible.open(staus_opened).ids + end + + def build_filter_condition + conditions = [] + target_project_ids = compute_target_project_ids + if target_project_ids.present? + conditions << "in_values(project_id, #{target_project_ids.join(",")})" + end + target_issue_ids = compute_target_issue_ids + if target_issue_ids.present? + conditions << "in_values(issue_id, #{target_issue_ids.join(",")})" + end + conditions << "1==1" if conditions.empty? + build_condition("&&", conditions) + end + + def any_searchable_issues_index_name + "index_issue_contents_pgroonga" + end + + def build_any_searchable_query(query, filter_condition) + sql = case ActiveRecord::Base.connection_db_config.adapter + when "postgresql" + <<-SQL.strip_heredoc + SELECT pgroonga_command( + 'select', + ARRAY[ + 'table', pgroonga_table_name('#{any_searchable_issues_index_name}'), + 'match_columns', 'content', + 'output_columns', 'issue_id', + 'query', pgroonga_query_escape(:query), + 'filter', '#{filter_condition}', + 'limit', '-1' + ] + )::json + SQL + when "mysql2" + # TODO: build query using Mroonga. + end + ActiveRecord::Base.send(:sanitize_sql_array, [sql, query: query]) + end + + def build_issue_id_condition(issue_ids, operator) + return operator == "!~" ? "1=1" : "1=0" if issue_ids.empty? + + if operator == "!~" + "#{Issue.table_name}.id NOT IN (#{issue_ids.join(",")})" + else + "#{Issue.table_name}.id IN (#{issue_ids.join(",")})" + end end end end diff --git a/test/unit/full_text_search/issue_query_any_searchable_test.rb b/test/unit/full_text_search/issue_query_any_searchable_test.rb index ecda94ef..da8559b6 100644 --- a/test/unit/full_text_search/issue_query_any_searchable_test.rb +++ b/test/unit/full_text_search/issue_query_any_searchable_test.rb @@ -2,25 +2,61 @@ module FullTextSearch class IssueQueryAnySearchableTest < ActiveSupport::TestCase + include ActiveJob::TestHelper + setup do unless IssueQuery.method_defined?(:sql_for_any_searchable_field) skip("Required feature 'sql_for_any_searchable_field' does not exist.") end + unless Redmine::Database.postgresql? + skip("Required PGroonga now. We will support Mroonga soon.") + end + User.current = nil + Attachment.destroy_all + Issue.destroy_all + IssueContent.destroy_all + Journal.destroy_all end def test_or_one_word - Issue.destroy_all - subject_groonga = Issue.generate!(subject: "Groonga") - description_groonga = Issue.generate!(description: "Groonga") - without_groonga = Issue.generate!(subject: "no-keyword", - description: "no-keyword") - journal_groonga = Issue.generate!.journals.create!(notes: "Groonga") + subject_groonga = + perform_enqueued_jobs(only: FullTextSearch::UpdateIssueContentJob) do + Issue.generate!(subject: "ぐるんが") + end + description_groonga = + perform_enqueued_jobs(only: FullTextSearch::UpdateIssueContentJob) do + Issue.generate!(description: "ぐるんが") + end + without_groonga = + perform_enqueued_jobs(only: FullTextSearch::UpdateIssueContentJob) do + Issue.generate!(subject: "no-keyword", + description: "no-keyword") + end + journal_groonga = + perform_enqueued_jobs(only: FullTextSearch::UpdateIssueContentJob) do + Issue.generate!.journals.create!(notes: "ぐるんが") + end + attachment_groonga = + perform_enqueued_jobs(only: FullTextSearch::UpdateIssueContentJob) do + issue = Issue.generate! + issue.save_attachments( + [ + { + "file" => mock_file_with_options( + :original_filename => "groonga.txt"), + "description" => "ぐるんが" + } + ] + ) + issue.save! + issue + end query = IssueQuery.new( :name => "_", :filters => { "any_searchable" => { :operator => "~", - :values => ["Groonga"] + :values => ["ぐるんが"] } }, :sort_criteria => [["id", "asc"]] @@ -28,7 +64,295 @@ def test_or_one_word expected_issues = [ subject_groonga, description_groonga, - journal_groonga.issue + journal_groonga.issue, + attachment_groonga + ] + assert_equal(expected_issues, query.issues) + end + + def test_and_two_words + subject_groonga_description_pgroonga = + perform_enqueued_jobs(only: FullTextSearch::UpdateIssueContentJob) do + Issue.generate!(subject: "ぐるんが", + description: "ぴーじーるんが") + end + subject_pgroonga_description_groonga = + perform_enqueued_jobs(only: FullTextSearch::UpdateIssueContentJob) do + Issue.generate!(subject: "ぴーじーるんが", + description: "ぐるんが") + end + subject_groonga = + perform_enqueued_jobs(only: FullTextSearch::UpdateIssueContentJob) do + Issue.generate!(description: "ぐるんが") + end + description_pgroonga = + perform_enqueued_jobs(only: FullTextSearch::UpdateIssueContentJob) do + Issue.generate!(description: "ぴーじーるんが") + end + without_keywords = + perform_enqueued_jobs(only: FullTextSearch::UpdateIssueContentJob) do + Issue.generate!(subject: "no-keyword", + description: "no-keyword") + end + subject_pgroonga_journal_groonga = + perform_enqueued_jobs(only: FullTextSearch::UpdateIssueContentJob) do + Issue.generate!(subject: "ぴーじーるんが") + .journals.create!(notes: "ぐるんが") + end + subject_groonga_attachment_pgroonga = + perform_enqueued_jobs(only: FullTextSearch::UpdateIssueContentJob) do + issue = Issue.generate!(subject: "ぐるんが") + issue.save_attachments( + [ + { + "file" => mock_file_with_options( + :original_filename => "pgroonga.txt"), + "description" => "ぴーじーるんが" + } + ] + ) + issue.save! + issue + end + query = IssueQuery.new( + :name => "_", + :filters => { + "any_searchable" => { + :operator => "~", + :values => ["ぐるんが ぴーじーるんが"] + } + }, + :sort_criteria => [["id", "asc"]] + ) + expected_issues = [ + subject_groonga_description_pgroonga, + subject_pgroonga_description_groonga, + subject_pgroonga_journal_groonga.issue, + subject_groonga_attachment_pgroonga + ] + assert_equal(expected_issues, query.issues) + end + + def test_not_and_two_words + subject_groonga_description_pgroonga = + perform_enqueued_jobs(only: FullTextSearch::UpdateIssueContentJob) do + Issue.generate!(subject: "ぐるんが", + description: "ぴーじーるんが") + end + subject_pgroonga_description_groonga = + perform_enqueued_jobs(only: FullTextSearch::UpdateIssueContentJob) do + Issue.generate!(subject: "ぴーじーるんが", + description: "ぐるんが") + end + subject_groonga = + perform_enqueued_jobs(only: FullTextSearch::UpdateIssueContentJob) do + Issue.generate!(description: "ぐるんが") + end + description_pgroonga = + perform_enqueued_jobs(only: FullTextSearch::UpdateIssueContentJob) do + Issue.generate!(description: "ぴーじーるんが") + end + without_keywords = + perform_enqueued_jobs(only: FullTextSearch::UpdateIssueContentJob) do + Issue.generate!(subject: "no-keyword", + description: "no-keyword") + end + subject_pgroonga_journal_groonga = + perform_enqueued_jobs(only: FullTextSearch::UpdateIssueContentJob) do + Issue.generate!(subject: "ぴーじーるんが") + .journals.create!(notes: "ぐるんが") + end + subject_groonga_attachment_pgroonga = + perform_enqueued_jobs(only: FullTextSearch::UpdateIssueContentJob) do + issue = Issue.generate!(subject: "ぐるんが") + issue.save_attachments( + [ + { + "file" => mock_file_with_options( + :original_filename => "pgroonga.txt"), + "description" => "ぴーじーるんが" + } + ] + ) + issue.save! + issue + end + query = IssueQuery.new( + :name => "_", + :filters => { + "any_searchable" => { + :operator => "!~", + :values => ["ぐるんが ぴーじーるんが"] + } + }, + :sort_criteria => [["id", "asc"]] + ) + expected_issues = [ + subject_groonga, + description_pgroonga, + without_keywords + ] + assert_equal(expected_issues, query.issues) + end + + def test_and_two_words_within_my_projects + my_user = User.find(1) + project = Project.generate! + User.add_to_project(my_user, project) + + # User's project issues. + subject_groonga_description_pgroonga = + perform_enqueued_jobs(only: FullTextSearch::UpdateIssueContentJob) do + Issue.generate!(project: project, + subject: "ぐるんが", + description: "ぴーじーるんが") + end + without_keywords = + perform_enqueued_jobs(only: FullTextSearch::UpdateIssueContentJob) do + Issue.generate!(project: project, + subject: "no-keyword", + description: "no-keyword") + end + subject_pgroonga_journal_groonga = + perform_enqueued_jobs(only: FullTextSearch::UpdateIssueContentJob) do + Issue.generate!(project: project, subject: "ぴーじーるんが") + .journals.create!(notes: "ぐるんが") + end + # Another project issue. + subject_pgroonga_description_groonga = + perform_enqueued_jobs(only: FullTextSearch::UpdateIssueContentJob) do + Issue.generate!(subject: "ぴーじーるんが", + description: "ぐるんが") + end + + User.current = my_user + query = IssueQuery.new( + :name => "_", + :filters => { + "any_searchable" => { + :operator => "~", + :values => ["ぐるんが ぴーじーるんが"] + }, + "project_id" => { + :operator => "=", + :values => ["mine"] + }, + }, + :sort_criteria => [["id", "asc"]] + ) + expected_issues = [ + subject_groonga_description_pgroonga, + subject_pgroonga_journal_groonga.issue + ] + assert_equal(expected_issues, query.issues) + end + + def test_and_two_words_within_bookmarks + bookmark_user = User.find(1) + bookmarked_project = + Project.where(id: [bookmark_user.bookmarked_project_ids]) + .first + no_bookmarked_project = Project.generate! + + # User's bookmarked project issues. + subject_groonga_description_pgroonga = + perform_enqueued_jobs(only: FullTextSearch::UpdateIssueContentJob) do + Issue.generate!(project: bookmarked_project, + subject: "ぐるんが", + description: "ぴーじーるんが") + end + without_keywords = + perform_enqueued_jobs(only: FullTextSearch::UpdateIssueContentJob) do + Issue.generate!(project: bookmarked_project, + subject: "no-keyword", + description: "no-keyword") + end + subject_pgroonga_journal_groonga = + perform_enqueued_jobs(only: FullTextSearch::UpdateIssueContentJob) do + Issue.generate!(project: bookmarked_project, subject: "ぴーじーるんが") + .journals.create!(notes: "ぐるんが") + end + # Another project issue. + subject_pgroonga_description_groonga = + perform_enqueued_jobs(only: FullTextSearch::UpdateIssueContentJob) do + Issue.generate!(project: no_bookmarked_project, + subject: "ぴーじーるんが", + description: "ぐるんが") + end + User.current = bookmark_user + query = IssueQuery.new( + :name => "_", + :filters => { + "any_searchable" => { + :operator => "~", + :values => ["ぐるんが ぴーじーるんが"] + }, + "project_id" => { + :operator => "=", + :values => ["bookmarks"] + }, + }, + :sort_criteria => [["id", "asc"]] + ) + expected_issues = [ + subject_groonga_description_pgroonga, + subject_pgroonga_journal_groonga.issue + ] + assert_equal(expected_issues, query.issues) + end + + def test_and_two_words_for_open_issues_within_my_projects + my_user = User.find(1) + project = Project.generate! + User.add_to_project(my_user, project) + + # User's project issues. + subject_groonga_description_pgroonga = + perform_enqueued_jobs(only: FullTextSearch::UpdateIssueContentJob) do + Issue.generate!(project: project, + subject: "ぐるんが", + description: "ぴーじーるんが") + end + without_keywords = + perform_enqueued_jobs(only: FullTextSearch::UpdateIssueContentJob) do + Issue.generate!(project: project, + subject: "no-keyword", + description: "no-keyword") + end + subject_pgroonga_journal_groonga = + perform_enqueued_jobs(only: FullTextSearch::UpdateIssueContentJob) do + Issue.generate!(project: project, subject: "ぴーじーるんが") + .journals.create!(notes: "ぐるんが") + end + # Closed issue. + closed_subject_pgroonga_description_groonga = + perform_enqueued_jobs(only: FullTextSearch::UpdateIssueContentJob) do + Issue.generate!(project: project, + subject: "ぴーじーるんが", + description: "ぐるんが") + .close! + end + User.current = my_user + query = IssueQuery.new( + :name => "_", + :filters => { + "any_searchable" => { + :operator => "~", + :values => ["ぐるんが ぴーじーるんが"] + }, + "project_id" => { + :operator => "=", + :values => ["mine"] + }, + 'status_id' => { + :operator => 'o' + } + }, + :sort_criteria => [["id", "asc"]] + ) + expected_issues = [ + subject_groonga_description_pgroonga, + subject_pgroonga_journal_groonga.issue ] assert_equal(expected_issues, query.issues) end