From 0ee7e116200f3b5cc94dc3505c32e2bcc3bc2172 Mon Sep 17 00:00:00 2001 From: Oleksii Kriuchykhin Date: Fri, 5 May 2017 16:41:23 +0200 Subject: [PATCH] Add advanced search options [SCI-1158] --- app/assets/javascripts/search.js | 12 +++ app/controllers/search_controller.rb | 123 ++++++++++------------ app/models/asset.rb | 100 +++++++++++------- app/models/checklist.rb | 30 +++--- app/models/comment.rb | 17 +-- app/models/concerns/searchable_model.rb | 66 +++++++++--- app/models/experiment.rb | 15 +-- app/models/my_module.rb | 15 +-- app/models/my_module_group.rb | 24 ++--- app/models/project.rb | 15 +-- app/models/protocol.rb | 19 ++-- app/models/report.rb | 32 ++---- app/models/result.rb | 29 +++-- app/models/sample.rb | 23 ++-- app/models/step.rb | 23 ++-- app/models/table.rb | 87 +++++++++------ app/models/tag.rb | 23 ++-- app/views/search/index.html.erb | 97 ++++++++++++++--- app/views/search/results/_assets.html.erb | 2 +- config/initializers/assets.rb | 1 + config/locales/en.yml | 4 + 21 files changed, 425 insertions(+), 332 deletions(-) create mode 100644 app/assets/javascripts/search.js diff --git a/app/assets/javascripts/search.js b/app/assets/javascripts/search.js new file mode 100644 index 000000000..66b158f39 --- /dev/null +++ b/app/assets/javascripts/search.js @@ -0,0 +1,12 @@ +$(document.body).ready(function() { + $('#search_whole_word').click(function() { + if ($(this).prop('checked') === true) { + $('#search_whole_phrase').prop('checked', false); + } + }); + $('#search_whole_phrase').click(function() { + if ($(this).prop('checked') === true) { + $('#search_whole_word').prop('checked', false); + } + }); +}); diff --git a/app/controllers/search_controller.rb b/app/controllers/search_controller.rb index 176429936..56c3e5ecb 100644 --- a/app/controllers/search_controller.rb +++ b/app/controllers/search_controller.rb @@ -2,9 +2,7 @@ class SearchController < ApplicationController before_filter :load_vars, only: :index def index - if not @search_query - redirect_to new_search_path - end + redirect_to new_search_path unless @search_query count_search_results @@ -41,47 +39,60 @@ class SearchController < ApplicationController private def load_vars - @search_query = params[:q] || '' + query = params[:q].strip || '' @search_category = params[:category] || '' @search_category = @search_category.to_sym @search_page = params[:page].to_i || 1 - @display_query = @search_query + @search_case = params[:match_case] == 'true' + @search_whole_word = params[:whole_word] == 'true' + @search_whole_phrase = params[:whole_phrase] == 'true' + @display_query = query - if @search_query.length < Constants::NAME_MIN_LENGTH - flash[:error] = t 'general.query.length_too_short', - min_length: Constants::NAME_MIN_LENGTH - return redirect_to :back - end - - # splits the search query to validate all entries - @splited_query = @search_query.split - - if @splited_query.first.length < Constants::NAME_MIN_LENGTH - flash[:error] = t 'general.query.length_too_short', - min_length: Constants::NAME_MIN_LENGTH - redirect_to :back - elsif @splited_query.first.length > Constants::TEXT_MAX_LENGTH - flash[:error] = t 'general.query.length_too_long', - max_length: Constants::TEXT_MAX_LENGTH - redirect_to :back - elsif @splited_query.length > 1 - @search_query = '' - @splited_query.each_with_index do |w, i| - if w.length >= Constants::NAME_MIN_LENGTH - @search_query += "#{@splited_query[i]} " - end + if @search_whole_phrase || query.count(' ').zero? + if query.length < Constants::NAME_MIN_LENGTH + flash[:error] = t('general.query.length_too_short', + min_length: Constants::NAME_MIN_LENGTH) + redirect_to :back + elsif query.length > Constants::TEXT_MAX_LENGTH + flash[:error] = t('general.query.length_too_long', + max_length: Constants::TEXT_MAX_LENGTH) + redirect_to :back + else + @search_query = query end else - @search_query = @splited_query.join(' ') + # splits the search query to validate all entries + splited_query = query.split + @search_query = '' + splited_query.each_with_index do |w, i| + if w.length >= Constants::NAME_MIN_LENGTH && + w.length <= Constants::TEXT_MAX_LENGTH + @search_query += "#{splited_query[i]} " + end + end + if @search_query.empty? + flash[:error] = t('general.query.wrong_query', + min_length: Constants::NAME_MIN_LENGTH, + max_length: Constants::TEXT_MAX_LENGTH) + redirect_to :back + else + @search_query.strip! + end end - @search_page = 1 if @search_page < 1 end protected def search_by_name(model) - model.search(current_user, true, @search_query, @search_page) + model.search(current_user, + true, + @search_query, + @search_page, + nil, + match_case: @search_case, + whole_word: @search_whole_word, + whole_phrase: @search_whole_phrase) end def count_by_name(model) @@ -122,16 +133,14 @@ class SearchController < ApplicationController def search_projects @project_results = [] - if @project_search_count > 0 - @project_results = search_by_name Project - end + @project_results = search_by_name(Project) if @project_search_count > 0 @search_count = @project_search_count end def search_experiments @experiment_results = [] if @experiment_search_count > 0 - @experiment_results = search_by_name Experiment + @experiment_results = search_by_name(Experiment) end @search_count = @experiment_search_count end @@ -139,96 +148,76 @@ class SearchController < ApplicationController def search_workflows @workflow_results = [] if @workflow_search_count > 0 - @workflow_results = search_by_name MyModuleGroup + @workflow_results = search_by_name(MyModuleGroup) end @search_count = @workflow_search_count end def search_modules @module_results = [] - if @module_search_count > 0 - @module_results = search_by_name MyModule - end + @module_results = search_by_name(MyModule) if @module_search_count > 0 @search_count = @module_search_count end def search_results @result_results = [] - if @result_search_count > 0 - @result_results = search_by_name Result - end + @result_results = search_by_name(Result) if @result_search_count > 0 @search_count = @result_search_count end def search_tags @tag_results = [] - if @tag_search_count > 0 - @tag_results = search_by_name Tag - end + @tag_results = search_by_name(Tag) if @tag_search_count > 0 @search_count = @tag_search_count end def search_reports @report_results = [] - if @report_search_count > 0 - @report_results = search_by_name Report - end + @report_results = search_by_name(Report) if @report_search_count > 0 @search_count = @report_search_count end def search_protocols @protocol_results = [] - if @protocol_search_count > 0 - @protocol_results = search_by_name Protocol - end + @protocol_results = search_by_name(Protocol) if @protocol_search_count > 0 @search_count = @protocol_search_count end def search_steps @step_results = [] - if @step_search_count > 0 - @step_results = search_by_name Step - end + @step_results = search_by_name(Step) if @step_search_count > 0 @search_count = @step_search_count end def search_checklists @checklist_results = [] if @checklist_search_count > 0 - @checklist_results = search_by_name Checklist + @checklist_results = search_by_name(Checklist) end @search_count = @checklist_search_count end def search_samples @sample_results = [] - if @sample_search_count > 0 - @sample_results = search_by_name Sample - end + @sample_results = search_by_name(Sample) if @sample_search_count > 0 @search_count = @sample_search_count end def search_assets @asset_results = [] - if @asset_search_count > 0 - @asset_results = search_by_name Asset - end + @asset_results = search_by_name(Asset) if @asset_search_count > 0 @search_count = @asset_search_count end def search_tables @table_results = [] - if @table_search_count > 0 - @table_results = search_by_name Table - end + @table_results = search_by_name(Table) if @table_search_count > 0 @search_count = @table_search_count end def search_comments @comment_results = [] - if @comment_search_count > 0 - @comment_results = search_by_name Comment - end + @comment_results = search_by_name(Comment) if @comment_search_count > 0 @search_count = @comment_search_count end end diff --git a/app/models/asset.rb b/app/models/asset.rb index f0943bffd..20a340a9f 100644 --- a/app/models/asset.rb +++ b/app/models/asset.rb @@ -91,7 +91,9 @@ class Asset < ActiveRecord::Base user, include_archived, query = nil, - page = 1 + page = 1, + _current_team = nil, + options = {} ) step_ids = Step @@ -107,42 +109,67 @@ class Asset < ActiveRecord::Base .distinct .pluck('result_assets.id') - if query - a_query = query.strip - .gsub("_","\\_") - .gsub("%","\\%") - .split(/\s+/) - .map {|t| "%" + t + "%" } - else - a_query = query - end - - # Trim whitespace and replace it with OR character. Make prefixed - # wildcard search term and escape special characters. - # For example, search term 'demo project' is transformed to - # 'demo:*|project:*' which makes word inclusive search with postfix - # wildcard. - - s_query = query.gsub(/[!()&|:]/, " ") - .strip - .split(/\s+/) - .map {|t| t + ":*" } - .join("|") - .gsub('\'', '"') - - ids = Asset + ids = + Asset .select(:id) .distinct - .joins("LEFT OUTER JOIN step_assets ON step_assets.asset_id = assets.id") - .joins("LEFT OUTER JOIN result_assets ON result_assets.asset_id = assets.id") - .joins("LEFT JOIN asset_text_data ON assets.id = asset_text_data.asset_id") - .where("(step_assets.id IN (?) OR result_assets.id IN (?))", step_ids, result_ids) - .where( - "(assets.file_file_name ILIKE ANY (array[?]) " + + .joins('LEFT OUTER JOIN step_assets ON step_assets.asset_id = assets.id') + .joins('LEFT OUTER JOIN result_assets ON ' \ + 'result_assets.asset_id = assets.id') + .joins('LEFT JOIN asset_text_data ON ' \ + 'assets.id = asset_text_data.asset_id') + .where('(step_assets.id IN (?) OR result_assets.id IN (?))', + step_ids, result_ids) + + a_query = s_query = '' + + if options[:whole_word].to_s == 'true' || + options[:whole_phrase].to_s == 'true' + like = options[:match_case].to_s == 'true' ? '~' : '~*' + s_query = query.gsub(/[!()&|:]/, ' ') + .strip + .split(/\s+/) + .map { |t| t + ':*' } + if options[:whole_word].to_s == 'true' + a_query = query.split + .map { |a| Regexp.escape(a) } + .join('|') + s_query = s_query.join('|') + else + a_query = Regexp.escape(query) + s_query = s_query.join('&') + end + a_query = '\\y(' + a_query + ')\\y' + s_query = s_query.tr('\'', '"') + + ids = ids.where( + "(trim_html_tags(assets.file_file_name) #{like} ? " \ "OR asset_text_data.data_vector @@ to_tsquery(?))", a_query, s_query ) + else + like = options[:match_case].to_s == 'true' ? 'LIKE' : 'ILIKE' + a_query = query.split.map { |a| "%#{sanitize_sql_like(a)}%" } + + # Trim whitespace and replace it with OR character. Make prefixed + # wildcard search term and escape special characters. + # For example, search term 'demo project' is transformed to + # 'demo:*|project:*' which makes word inclusive search with postfix + # wildcard. + s_query = query.gsub(/[!()&|:]/, ' ') + .strip + .split(/\s+/) + .map { |t| t + ':*' } + .join('|') + .tr('\'', '"') + ids = ids.where( + "(trim_html_tags(assets.file_file_name) #{like} ANY (array[?]) " \ + "OR asset_text_data.data_vector @@ to_tsquery(?))", + a_query, + s_query + ) + end # Show all results if needed if page != Constants::SEARCH_NO_LIMIT @@ -152,11 +179,12 @@ class Asset < ActiveRecord::Base end Asset - .joins("LEFT JOIN asset_text_data ON assets.id = asset_text_data.asset_id") - .select("assets.*") - .select("ts_headline(data, to_tsquery('" + s_query + "'), - 'StartSel=, StopSel=') headline") - .where("assets.id IN (?)", ids) + .joins('LEFT JOIN asset_text_data ON ' \ + ' assets.id = asset_text_data.asset_id') + .select('assets.*') + .select("ts_headline(data, to_tsquery('" + s_query + + "'), 'StartSel=, StopSel=') headline") + .where('assets.id IN (?)', ids) end def is_image? diff --git a/app/models/checklist.rb b/app/models/checklist.rb index 738761c80..1de22b364 100644 --- a/app/models/checklist.rb +++ b/app/models/checklist.rb @@ -22,27 +22,25 @@ class Checklist < ActiveRecord::Base reject_if: :all_blank, allow_destroy: true - def self.search(user, include_archived, query = nil, page = 1) + def self.search(user, + include_archived, + query = nil, + page = 1, + _current_team = nil, + options= {}) step_ids = Step .search(user, include_archived, nil, Constants::SEARCH_NO_LIMIT) .pluck(:id) - if query - a_query = query.strip - .gsub("_","\\_") - .gsub("%","\\%") - .split(/\s+/) - .map {|t| "%" + t + "%" } - else - a_query = query - end - - new_query = Checklist - .distinct - .where("checklists.step_id IN (?)", step_ids) - .joins("LEFT JOIN checklist_items ON checklists.id = checklist_items.checklist_id") - .where_attributes_like(["checklists.name", "checklist_items.text"], a_query) + new_query = + Checklist + .distinct + .where('checklists.step_id IN (?)', step_ids) + .joins('LEFT JOIN checklist_items ON ' \ + 'checklists.id = checklist_items.checklist_id') + .where_attributes_like(['checklists.name', 'checklist_items.text'], + query, options) # Show all results if needed if page == Constants::SEARCH_NO_LIMIT diff --git a/app/models/comment.rb b/app/models/comment.rb index 8515e18ce..20cb791c9 100644 --- a/app/models/comment.rb +++ b/app/models/comment.rb @@ -15,7 +15,9 @@ class Comment < ActiveRecord::Base user, include_archived, query = nil, - page = 1 + page = 1, + _current_team = nil, + options = {} ) project_ids = Project @@ -34,16 +36,6 @@ class Comment < ActiveRecord::Base .search(user, include_archived, nil, Constants::SEARCH_NO_LIMIT) .pluck(:id) - if query - a_query = query.strip - .gsub('_', '\\_') - .gsub('%', '\\%') - .split(/\s+/) - .map { |t| '%' + t + '%' } - else - a_query = query - end - new_query = Comment.distinct .joins(:user) @@ -57,7 +49,8 @@ class Comment < ActiveRecord::Base step_ids, 'StepComment', result_ids, 'ResultComment' ) - .where_attributes_like(['message', 'users.full_name'], a_query) + .where_attributes_like(['message', 'users.full_name'], + query, options) # Show all results if needed if page == Constants::SEARCH_NO_LIMIT diff --git a/app/models/concerns/searchable_model.rb b/app/models/concerns/searchable_model.rb index 54c5b32fa..76f0aa89e 100644 --- a/app/models/concerns/searchable_model.rb +++ b/app/models/concerns/searchable_model.rb @@ -2,36 +2,67 @@ module SearchableModel extend ActiveSupport::Concern included do - # Helper function for relations that # adds OR ILIKE where clause for all specified attributes # for the given search query - scope :where_attributes_like, ->(attributes, query) do + scope :where_attributes_like, lambda { |attributes, query, options = {}| + return unless query attrs = [] - if attributes.blank? or query.blank? + if attributes.blank? # Do nothing in this case elsif attributes.is_a? Symbol attrs = [attributes.to_s] elsif attributes.is_a? String attrs = [attributes] elsif attributes.is_a? Array - attrs = attributes.collect { |a| a.to_s } + attrs = attributes.collect(&:to_s) else - raise ArgumentError, ":attributes must be an array, symbol or string" + raise ArgumentError, ':attributes must be an array, symbol or string' end - if query.is_a? Array - + if options[:whole_word].to_s == 'true' || + options[:whole_phrase].to_s == 'true' unless attrs.empty? + like = options[:match_case].to_s == 'true' ? '~' : '~*' + + if options[:whole_word].to_s == 'true' + a_query = query.split + .map { |a| Regexp.escape(a) } + .join('|') + else + a_query = Regexp.escape(query) + end + a_query = '\\y(' + a_query + ')\\y' where_str = (attrs.map.with_index do |a, i| - "(trim_html_tags(#{a})) ILIKE ANY (array[ :t#{i}]) OR " + "(trim_html_tags(#{a})) #{like} :t#{i} OR " end ).join[0..-5] - vals = (attrs.map.with_index do |_, i| - ["t#{i}".to_sym, query] - end - ).to_h + vals = ( + attrs.map.with_index do |_, i| + ["t#{i}".to_sym, a_query] + end + ).to_h + + return where(where_str, vals) + end + end + + like = options[:match_case].to_s == 'true' ? 'LIKE' : 'ILIKE' + + if query.count(' ') > 0 + unless attrs.empty? + a_query = query.split.map { |a| "%#{sanitize_sql_like(a)}%" } + where_str = + (attrs.map.with_index do |a, i| + "(trim_html_tags(#{a})) #{like} ANY (array[:t#{i}]) OR " + end + ).join[0..-5] + vals = ( + attrs.map.with_index do |_, i| + ["t#{i}".to_sym, a_query] + end + ).to_h return where(where_str, vals) end @@ -39,15 +70,18 @@ module SearchableModel unless attrs.empty? where_str = (attrs.map.with_index do |a, i| - "(trim_html_tags(#{a})) ILIKE :t#{i} OR " + "(trim_html_tags(#{a})) #{like} :t#{i} OR " end ).join[0..-5] - vals = (attrs.map.with_index { |_, i| ["t#{i}".to_sym, query.to_s] }) - .to_h + vals = ( + attrs.map.with_index do |_, i| + ["t#{i}".to_sym, "%#{sanitize_sql_like(query.to_s)}%"] + end + ).to_h return where(where_str, vals) end end - end + } end end diff --git a/app/models/experiment.rb b/app/models/experiment.rb index e314ba7bb..cabc7d54b 100644 --- a/app/models/experiment.rb +++ b/app/models/experiment.rb @@ -39,19 +39,14 @@ class Experiment < ActiveRecord::Base include_archived, query = nil, page = 1, - current_team = nil + current_team = nil, + options = {} ) project_ids = Project .search(user, include_archived, nil, Constants::SEARCH_NO_LIMIT) .pluck(:id) - if query - a_query = '%' + query.strip.gsub('_', '\\_').gsub('%', '\\%') + '%' - else - a_query = query - end - if current_team projects_ids = Project @@ -65,19 +60,19 @@ class Experiment < ActiveRecord::Base new_query = Experiment .where('experiments.project_id IN (?)', projects_ids) - .where_attributes_like([:name], a_query) + .where_attributes_like([:name, :description], query, options) return include_archived ? new_query : new_query.is_archived(false) elsif include_archived new_query = Experiment .where(project: project_ids) - .where_attributes_like([:name, :description], a_query) + .where_attributes_like([:name, :description], query, options) else new_query = Experiment .is_archived(false) .where(project: project_ids) - .where_attributes_like([:name, :description], a_query) + .where_attributes_like([:name, :description], query, options) end # Show all results if needed diff --git a/app/models/my_module.rb b/app/models/my_module.rb index ffb2caa32..58c01d774 100644 --- a/app/models/my_module.rb +++ b/app/models/my_module.rb @@ -47,19 +47,14 @@ class MyModule < ActiveRecord::Base include_archived, query = nil, page = 1, - current_team = nil + current_team = nil, + options = {} ) exp_ids = Experiment .search(user, include_archived, nil, Constants::SEARCH_NO_LIMIT) .pluck(:id) - if query - a_query = '%' + query.strip.gsub('_', '\\_').gsub('%', '\\%') + '%' - else - a_query = query - end - if current_team experiments_ids = Experiment .search(user, @@ -71,7 +66,7 @@ class MyModule < ActiveRecord::Base new_query = MyModule .distinct .where('my_modules.experiment_id IN (?)', experiments_ids) - .where_attributes_like([:name, :description], a_query) + .where_attributes_like([:name, :description], query, options) if include_archived return new_query @@ -82,13 +77,13 @@ class MyModule < ActiveRecord::Base new_query = MyModule .distinct .where('my_modules.experiment_id IN (?)', exp_ids) - .where_attributes_like([:name, :description], a_query) + .where_attributes_like([:name, :description], query, options) else new_query = MyModule .distinct .where('my_modules.experiment_id IN (?)', exp_ids) .where('my_modules.archived = ?', false) - .where_attributes_like([:name, :description], a_query) + .where_attributes_like([:name, :description], query, options) end # Show all results if needed diff --git a/app/models/my_module_group.rb b/app/models/my_module_group.rb index 9efa6b368..f965876c4 100644 --- a/app/models/my_module_group.rb +++ b/app/models/my_module_group.rb @@ -11,27 +11,21 @@ class MyModuleGroup < ActiveRecord::Base belongs_to :created_by, foreign_key: 'created_by_id', class_name: 'User' has_many :my_modules, inverse_of: :my_module_group, dependent: :nullify - def self.search(user, include_archived, query = nil, page = 1) + def self.search(user, + include_archived, + query = nil, + page = 1, + _current_team = nil, + options = {}) exp_ids = Experiment .search(user, include_archived, nil, Constants::SEARCH_NO_LIMIT) .pluck(:id) - - if query - a_query = query.strip - .gsub("_","\\_") - .gsub("%","\\%") - .split(/\s+/) - .map {|t| "%" + t + "%" } - else - a_query = query - end - new_query = MyModuleGroup - .distinct - .where("my_module_groups.experiment_id IN (?)", exp_ids) - .where_attributes_like("my_module_groups.name", a_query) + .distinct + .where('my_module_groups.experiment_id IN (?)', exp_ids) + .where_attributes_like('my_module_groups.name', query, options) # Show all results if needed if page == Constants::SEARCH_NO_LIMIT diff --git a/app/models/project.rb b/app/models/project.rb index d86761d62..0ba2f943c 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -31,15 +31,10 @@ class Project < ActiveRecord::Base include_archived, query = nil, page = 1, - current_team = nil + current_team = nil, + options = {} ) - if query - a_query = '%' + query.strip.gsub('_', '\\_').gsub('%', '\\%') + '%' - else - a_query = query - end - if current_team new_query = Project @@ -53,7 +48,7 @@ class Project < ActiveRecord::Base user.id ) end - new_query = new_query.where_attributes_like(:name, a_query) + new_query = new_query.where_attributes_like(:name, query, options) if include_archived return new_query @@ -75,7 +70,7 @@ class Project < ActiveRecord::Base 'user_projects.user_id = ?', user.id ) - .where_attributes_like('projects.name', a_query) + .where_attributes_like('projects.name', query, options) else new_query = @@ -86,7 +81,7 @@ class Project < ActiveRecord::Base 'user_projects.user_id = ?', user.id ) - .where_attributes_like('projects.name', a_query) + .where_attributes_like('projects.name', query, options) .where('projects.archived = ?', false) end end diff --git a/app/models/protocol.rb b/app/models/protocol.rb index c7077892f..dc8270bef 100644 --- a/app/models/protocol.rb +++ b/app/models/protocol.rb @@ -106,7 +106,12 @@ class Protocol < ActiveRecord::Base has_many :protocol_keywords, through: :protocol_protocol_keywords has_many :steps, inverse_of: :protocol, dependent: :destroy - def self.search(user, include_archived, query = nil, page = 1) + def self.search(user, + include_archived, + query = nil, + page = 1, + _current_team = nil, + options = {}) team_ids = Team.joins(:user_teams) .where('user_teams.user_id = ?', user.id) .distinct @@ -157,16 +162,6 @@ class Protocol < ActiveRecord::Base ) end - if query - a_query = query.strip - .gsub('_', '\\_') - .gsub('%', '\\%') - .split(/\s+/) - .map { |t| '%' + t + '%' } - else - a_query = query - end - new_query = new_query .distinct .joins('LEFT JOIN protocol_protocol_keywords ON ' \ @@ -181,7 +176,7 @@ class Protocol < ActiveRecord::Base 'protocols.authors', 'protocol_keywords.name' ], - a_query + query, options ) # Show all results if needed diff --git a/app/models/report.rb b/app/models/report.rb index dc2e4da7a..9c862fbd8 100644 --- a/app/models/report.rb +++ b/app/models/report.rb @@ -22,7 +22,9 @@ class Report < ActiveRecord::Base user, include_archived, query = nil, - page = 1 + page = 1, + _current_team = nil, + options = {} ) project_ids = @@ -30,28 +32,14 @@ class Report < ActiveRecord::Base .search(user, include_archived, nil, Constants::SEARCH_NO_LIMIT) .pluck(:id) - if query - a_query = query.strip - .gsub("_","\\_") - .gsub("%","\\%") - .split(/\s+/) - .map {|t| "%" + t + "%" } - else - a_query = query - end - - new_query = Report + new_query = + Report .distinct - .joins("LEFT OUTER JOIN users ON users.id = reports.user_id OR users.id = reports.last_modified_by_id") - .where("reports.project_id IN (?)", project_ids) - .where("reports.user_id = (?)", user.id) - .where_attributes_like( - [ - :name, - :description - ], - a_query - ) + .joins('LEFT OUTER JOIN users ON users.id = reports.user_id ' \ + 'OR users.id = reports.last_modified_by_id') + .where('reports.project_id IN (?)', project_ids) + .where('reports.user_id = (?)', user.id) + .where_attributes_like([:name, :description], query, options) # Show all results if needed if page == Constants::SEARCH_NO_LIMIT diff --git a/app/models/result.rb b/app/models/result.rb index a962e0afd..1af0e7c3d 100644 --- a/app/models/result.rb +++ b/app/models/result.rb @@ -29,30 +29,27 @@ class Result < ActiveRecord::Base accepts_nested_attributes_for :asset accepts_nested_attributes_for :table - def self.search(user, include_archived, query = nil, page = 1) + def self.search(user, + include_archived, + query = nil, + page = 1, + _current_team = nil, + options = {}) module_ids = MyModule .search(user, include_archived, nil, Constants::SEARCH_NO_LIMIT) .pluck(:id) - if query - a_query = query.strip - .gsub("_","\\_") - .gsub("%","\\%") - .split(/\s+/) - .map {|t| "%" + t + "%" } - else - a_query = query - end - - new_query = Result + new_query = + Result .distinct - .joins("LEFT JOIN result_texts ON results.id = result_texts.result_id") - .where("results.my_module_id IN (?)", module_ids) - .where_attributes_like(["results.name", "result_texts.text"], a_query) + .joins('LEFT JOIN result_texts ON results.id = result_texts.result_id') + .where('results.my_module_id IN (?)', module_ids) + .where_attributes_like(['results.name', 'result_texts.text'], + query, options) unless include_archived - new_query = new_query.where("results.archived = ?", false) + new_query = new_query.where('results.archived = ?', false) end # Show all results if needed diff --git a/app/models/sample.rb b/app/models/sample.rb index fcab9bcdc..c668ba2a6 100644 --- a/app/models/sample.rb +++ b/app/models/sample.rb @@ -19,56 +19,51 @@ class Sample < ActiveRecord::Base def self.search( user, - include_archived, + _include_archived, query = nil, page = 1, - current_team = nil + current_team = nil, + options = {} ) team_ids = Team.joins(:user_teams) .where('user_teams.user_id = ?', user.id) .distinct .pluck(:id) - if query - a_query = '%' + query.strip.gsub('_', '\\_').gsub('%', '\\%') + '%' - else - a_query = query - end - if current_team new_query = Sample .distinct .where('samples.team_id = ?', current_team.id) - .where_attributes_like(['samples.name'], a_query) + .where_attributes_like(['samples.name'], query, options) return new_query else user_ids = User .joins(:user_teams) .where('user_teams.team_id IN (?)', team_ids) - .where_attributes_like(['users.full_name'], a_query) + .where_attributes_like(['users.full_name'], query, options) .pluck(:id) sample_ids = Sample .joins(:user) .where('team_id IN (?)', team_ids) - .where_attributes_like(['name'], a_query) + .where_attributes_like(['name'], query, options) .pluck(:id) sample_type_ids = SampleType .where('team_id IN (?)', team_ids) - .where_attributes_like(['name'], a_query) + .where_attributes_like(['name'], query, options) .pluck(:id) sample_group_ids = SampleGroup .where('team_id IN (?)', team_ids) - .where_attributes_like(['name'], a_query) + .where_attributes_like(['name'], query, options) .pluck(:id) sample_custom_fields = SampleCustomField .joins(:sample) .where('samples.team_id IN (?)', team_ids) - .where_attributes_like(['value'], a_query) + .where_attributes_like(['value'], query, options) .pluck(:id) new_query = Sample .distinct diff --git a/app/models/step.rb b/app/models/step.rb index df1db9e11..49c5363c6 100644 --- a/app/models/step.rb +++ b/app/models/step.rb @@ -41,26 +41,21 @@ class Step < ActiveRecord::Base after_destroy :cascade_after_destroy before_save :set_last_modified_by - def self.search(user, include_archived, query = nil, page = 1) + def self.search(user, + include_archived, + query = nil, + page = 1, + _current_team = nil, + options = {}) protocol_ids = Protocol .search(user, include_archived, nil, Constants::SEARCH_NO_LIMIT) .pluck(:id) - if query - a_query = query.strip - .gsub("_","\\_") - .gsub("%","\\%") - .split(/\s+/) - .map {|t| "%" + t + "%" } - else - a_query = query - end - new_query = Step - .distinct - .where("steps.protocol_id IN (?)", protocol_ids) - .where_attributes_like([:name, :description], a_query) + .distinct + .where('steps.protocol_id IN (?)', protocol_ids) + .where_attributes_like([:name, :description], query, options) # Show all results if needed if page == Constants::SEARCH_NO_LIMIT diff --git a/app/models/table.rb b/app/models/table.rb index 27f1e694a..437dcd99c 100644 --- a/app/models/table.rb +++ b/app/models/table.rb @@ -21,7 +21,12 @@ class Table < ActiveRecord::Base after_save :update_ts_index #accepts_nested_attributes_for :table - def self.search(user, include_archived, query = nil, page = 1) + def self.search(user, + include_archived, + query = nil, + page = 1, + _current_team = nil, + options = {}) step_ids = Step .search(user, include_archived, nil, Constants::SEARCH_NO_LIMIT) @@ -36,43 +41,63 @@ class Table < ActiveRecord::Base .distinct .pluck('result_tables.id') - if query - a_query = query.strip - .gsub('_', '\\_') - .gsub('%', '\\%') - .split(/\s+/) - .map { |t| '%' + t + '%' } - else - a_query = query - end - - # Trim whitespace and replace it with OR character. Make prefixed - # wildcard search term and escape special characters. - # For example, search term 'demo project' is transformed to - # 'demo:*|project:*' which makes word inclusive search with postfix - # wildcard. - s_query = query.gsub(/[!()&|:]/, " ") - .strip - .split(/\s+/) - .map {|t| t + ":*" } - .join("|") - .gsub('\'', '"') - table_query = Table .distinct - .joins("LEFT OUTER JOIN step_tables ON step_tables.table_id = tables.id") - .joins("LEFT OUTER JOIN result_tables ON result_tables.table_id = tables.id") - .joins("LEFT OUTER JOIN results ON result_tables.result_id = results.id") - .where("step_tables.id IN (?) OR result_tables.id IN (?)", step_ids, result_ids) - .where( - '(trim_html_tags(tables.name) ILIKE ANY (array[?])'\ - 'OR tables.data_vector @@ to_tsquery(?))', + .joins('LEFT OUTER JOIN step_tables ON step_tables.table_id = tables.id') + .joins('LEFT OUTER JOIN result_tables ON ' \ + 'result_tables.table_id = tables.id') + .joins('LEFT OUTER JOIN results ON result_tables.result_id = results.id') + .where('step_tables.id IN (?) OR result_tables.id IN (?)', + step_ids, result_ids) + + if options[:whole_word].to_s == 'true' || + options[:whole_phrase].to_s == 'true' + like = options[:match_case].to_s == 'true' ? '~' : '~*' + s_query = query.gsub(/[!()&|:]/, ' ') + .strip + .split(/\s+/) + .map { |t| t + ':*' } + if options[:whole_word].to_s == 'true' + a_query = query.split + .map { |a| Regexp.escape(a) } + .join('|') + s_query = s_query.join('|') + else + a_query = Regexp.escape(query) + s_query = s_query.join('&') + end + a_query = '\\y(' + a_query + ')\\y' + s_query = s_query.tr('\'', '"') + + new_query = table_query.where( + "(trim_html_tags(tables.name) #{like} ?" \ + "OR tables.data_vector @@ to_tsquery(?))", a_query, s_query ) + else + like = options[:match_case].to_s == 'true' ? 'LIKE' : 'ILIKE' + a_query = query.split.map { |a| "%#{sanitize_sql_like(a)}%" } - new_query = table_query + # Trim whitespace and replace it with OR character. Make prefixed + # wildcard search term and escape special characters. + # For example, search term 'demo project' is transformed to + # 'demo:*|project:*' which makes word inclusive search with postfix + # wildcard. + s_query = query.gsub(/[!()&|:]/, ' ') + .strip + .split(/\s+/) + .map { |t| t + ':*' } + .join('|') + .tr('\'', '"') + new_query = table_query.where( + "(trim_html_tags(tables.name) #{like} ANY (array[?])" \ + "OR tables.data_vector @@ to_tsquery(?))", + a_query, + s_query + ) + end # Show all results if needed if page == Constants::SEARCH_NO_LIMIT diff --git a/app/models/tag.rb b/app/models/tag.rb index 0c93b4209..27e044cf4 100644 --- a/app/models/tag.rb +++ b/app/models/tag.rb @@ -16,26 +16,21 @@ class Tag < ActiveRecord::Base has_many :my_module_tags, inverse_of: :tag, :dependent => :destroy has_many :my_modules, through: :my_module_tags - def self.search(user, include_archived, query = nil, page = 1) + def self.search(user, + include_archived, + query = nil, + page = 1, + _current_team = nil, + options = {}) project_ids = Project .search(user, include_archived, nil, Constants::SEARCH_NO_LIMIT) .pluck(:id) - if query - a_query = query.strip - .gsub("_","\\_") - .gsub("%","\\%") - .split(/\s+/) - .map {|t| "%" + t + "%" } - else - a_query = query - end - new_query = Tag - .distinct - .where("tags.project_id IN (?)", project_ids) - .where_attributes_like(:name, a_query) + .distinct + .where('tags.project_id IN (?)', project_ids) + .where_attributes_like(:name, query, options) # Show all results if needed if page == Constants::SEARCH_NO_LIMIT diff --git a/app/views/search/index.html.erb b/app/views/search/index.html.erb index 9265253da..8a95664f6 100644 --- a/app/views/search/index.html.erb +++ b/app/views/search/index.html.erb @@ -1,20 +1,57 @@ <% provide(:head_title, t("search.index.head_title")) %> -

<%= t('search.index.results_title_html', query: @display_query) %>

+ <%= form_tag search_path, method: :get do %> <%= hidden_field_tag :q, @search_query %> <%= hidden_field_tag :category, @search_category %>
-
+
<% end %> + +<%= javascript_include_tag('search') %> diff --git a/app/views/search/results/_assets.html.erb b/app/views/search/results/_assets.html.erb index 672f228d4..f33c3b0fe 100644 --- a/app/views/search/results/_assets.html.erb +++ b/app/views/search/results/_assets.html.erb @@ -15,7 +15,7 @@ <% if asset.headline.present? && !asset.headline.empty? && asset.headline.include?("") %> <% end %> diff --git a/config/initializers/assets.rb b/config/initializers/assets.rb index bd87bd47f..212bb6680 100644 --- a/config/initializers/assets.rb +++ b/config/initializers/assets.rb @@ -71,6 +71,7 @@ Rails.application.config.assets.precompile += %w(notifications.js) Rails.application.config.assets.precompile += %w(users/invite_users_modal.js) Rails.application.config.assets.precompile += %w(samples/sample_types_groups.js) Rails.application.config.assets.precompile += %w(highlightjs-github-theme.css) +Rails.application.config.assets.precompile += %w(search.js) # Libraries needed for Handsontable formulas Rails.application.config.assets.precompile += %w(lodash.js) diff --git a/config/locales/en.yml b/config/locales/en.yml index 906f19bd1..66efc3771 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -100,6 +100,9 @@ en: archive: "Archive" search: + whole_word: "Match any whole word" + whole_phrase: "Match whole phrase" + match_case: "Match case" index: head_title: "Search" page_title: "Search" @@ -1597,6 +1600,7 @@ en: length_too_long: "is too long (maximum is %{max_length} characters)" length_too_short: "is too short (minimum is %{min_length} characters)" query: + wrong_query: "All words in search query are either too short (minimum is %{min_length} characters) or too long (maximum is %{max_length} characters)" length_too_long: "Search query is too long (maximum is %{max_length} characters)" length_too_short: "Search query is too short (minimum is %{min_length} characters)" busy: "The server is still processing your request. If you leave this page, the changes will be lost! Are you sure you want to continue?"