##// END OF EJS Templates
Cache search result ids for faster search pagination (#18631)....
Jean-Philippe Lang -
r13388:717f491f503d
parent child
Show More
@@ -1,84 +1,82
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2014 Jean-Philippe Lang
2 # Copyright (C) 2006-2014 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 class SearchController < ApplicationController
18 class SearchController < ApplicationController
19 before_filter :find_optional_project
19 before_filter :find_optional_project
20
20
21 @@search_cache_store ||= ActiveSupport::Cache.lookup_store :memory_store
22
23 def index
21 def index
24 @question = params[:q] || ""
22 @question = params[:q] || ""
25 @question.strip!
23 @question.strip!
26 @all_words = params[:all_words] ? params[:all_words].present? : true
24 @all_words = params[:all_words] ? params[:all_words].present? : true
27 @titles_only = params[:titles_only] ? params[:titles_only].present? : false
25 @titles_only = params[:titles_only] ? params[:titles_only].present? : false
28
26
29 # quick jump to an issue
27 # quick jump to an issue
30 if (m = @question.match(/^#?(\d+)$/)) && (issue = Issue.visible.find_by_id(m[1].to_i))
28 if (m = @question.match(/^#?(\d+)$/)) && (issue = Issue.visible.find_by_id(m[1].to_i))
31 redirect_to issue_path(issue)
29 redirect_to issue_path(issue)
32 return
30 return
33 end
31 end
34
32
35 projects_to_search =
33 projects_to_search =
36 case params[:scope]
34 case params[:scope]
37 when 'all'
35 when 'all'
38 nil
36 nil
39 when 'my_projects'
37 when 'my_projects'
40 User.current.projects
38 User.current.projects
41 when 'subprojects'
39 when 'subprojects'
42 @project ? (@project.self_and_descendants.active.to_a) : nil
40 @project ? (@project.self_and_descendants.active.to_a) : nil
43 else
41 else
44 @project
42 @project
45 end
43 end
46
44
47 @object_types = Redmine::Search.available_search_types.dup
45 @object_types = Redmine::Search.available_search_types.dup
48 if projects_to_search.is_a? Project
46 if projects_to_search.is_a? Project
49 # don't search projects
47 # don't search projects
50 @object_types.delete('projects')
48 @object_types.delete('projects')
51 # only show what the user is allowed to view
49 # only show what the user is allowed to view
52 @object_types = @object_types.select {|o| User.current.allowed_to?("view_#{o}".to_sym, projects_to_search)}
50 @object_types = @object_types.select {|o| User.current.allowed_to?("view_#{o}".to_sym, projects_to_search)}
53 end
51 end
54
52
55 @scope = @object_types.select {|t| params[t]}
53 @scope = @object_types.select {|t| params[t]}
56 @scope = @object_types if @scope.empty?
54 @scope = @object_types if @scope.empty?
57
55
58 fetcher = Redmine::Search::Fetcher.new(
56 fetcher = Redmine::Search::Fetcher.new(
59 @question, User.current, @scope, projects_to_search,
57 @question, User.current, @scope, projects_to_search,
60 :all_words => @all_words, :titles_only => @titles_only
58 :all_words => @all_words, :titles_only => @titles_only, :cache => params[:page].present?
61 )
59 )
62
60
63 if fetcher.tokens.present?
61 if fetcher.tokens.present?
64 @result_count = fetcher.result_count
62 @result_count = fetcher.result_count
65 @result_count_by_type = fetcher.result_count_by_type
63 @result_count_by_type = fetcher.result_count_by_type
66 @tokens = fetcher.tokens
64 @tokens = fetcher.tokens
67
65
68 @result_pages = Paginator.new @result_count, 10, params['page']
66 @result_pages = Paginator.new @result_count, 10, params['page']
69 @results = fetcher.results(@result_pages.offset, @result_pages.per_page)
67 @results = fetcher.results(@result_pages.offset, @result_pages.per_page)
70 else
68 else
71 @question = ""
69 @question = ""
72 end
70 end
73 render :layout => false if request.xhr?
71 render :layout => false if request.xhr?
74 end
72 end
75
73
76 private
74 private
77 def find_optional_project
75 def find_optional_project
78 return true unless params[:id]
76 return true unless params[:id]
79 @project = Project.find(params[:id])
77 @project = Project.find(params[:id])
80 check_project_privacy
78 check_project_privacy
81 rescue ActiveRecord::RecordNotFound
79 rescue ActiveRecord::RecordNotFound
82 render_404
80 render_404
83 end
81 end
84 end
82 end
@@ -1,56 +1,64
1 require File.expand_path('../boot', __FILE__)
1 require File.expand_path('../boot', __FILE__)
2
2
3 require 'rails/all'
3 require 'rails/all'
4
4
5 Bundler.require(*Rails.groups)
5 Bundler.require(*Rails.groups)
6
6
7 module RedmineApp
7 module RedmineApp
8 class Application < Rails::Application
8 class Application < Rails::Application
9 # Settings in config/environments/* take precedence over those specified here.
9 # Settings in config/environments/* take precedence over those specified here.
10 # Application configuration should go into files in config/initializers
10 # Application configuration should go into files in config/initializers
11 # -- all .rb files in that directory are automatically loaded.
11 # -- all .rb files in that directory are automatically loaded.
12
12
13 # Custom directories with classes and modules you want to be autoloadable.
13 # Custom directories with classes and modules you want to be autoloadable.
14 config.autoload_paths += %W(#{config.root}/lib)
14 config.autoload_paths += %W(#{config.root}/lib)
15
15
16 # Only load the plugins named here, in the order given (default is alphabetical).
16 # Only load the plugins named here, in the order given (default is alphabetical).
17 # :all can be used as a placeholder for all plugins not explicitly named.
17 # :all can be used as a placeholder for all plugins not explicitly named.
18 # config.plugins = [ :exception_notification, :ssl_requirement, :all ]
18 # config.plugins = [ :exception_notification, :ssl_requirement, :all ]
19
19
20 config.active_record.store_full_sti_class = true
20 config.active_record.store_full_sti_class = true
21 config.active_record.default_timezone = :local
21 config.active_record.default_timezone = :local
22
22
23 # Set Time.zone default to the specified zone and make Active Record auto-convert to this zone.
23 # Set Time.zone default to the specified zone and make Active Record auto-convert to this zone.
24 # Run "rake -D time" for a list of tasks for finding time zone names. Default is UTC.
24 # Run "rake -D time" for a list of tasks for finding time zone names. Default is UTC.
25 # config.time_zone = 'Central Time (US & Canada)'
25 # config.time_zone = 'Central Time (US & Canada)'
26
26
27 # The default locale is :en and all translations from config/locales/*.rb,yml are auto loaded.
27 # The default locale is :en and all translations from config/locales/*.rb,yml are auto loaded.
28 # config.i18n.load_path += Dir[Rails.root.join('my', 'locales', '*.{rb,yml}').to_s]
28 # config.i18n.load_path += Dir[Rails.root.join('my', 'locales', '*.{rb,yml}').to_s]
29 # config.i18n.default_locale = :de
29 # config.i18n.default_locale = :de
30
30
31 I18n.enforce_available_locales = true
31 I18n.enforce_available_locales = true
32
32
33 # Configure the default encoding used in templates for Ruby 1.9.
33 # Configure the default encoding used in templates for Ruby 1.9.
34 config.encoding = "utf-8"
34 config.encoding = "utf-8"
35
35
36 # Configure sensitive parameters which will be filtered from the log file.
36 # Configure sensitive parameters which will be filtered from the log file.
37 config.filter_parameters += [:password]
37 config.filter_parameters += [:password]
38
38
39 # Enable the asset pipeline
39 # Enable the asset pipeline
40 config.assets.enabled = false
40 config.assets.enabled = false
41
41
42 # Version of your assets, change this if you want to expire all your assets
42 # Version of your assets, change this if you want to expire all your assets
43 config.assets.version = '1.0'
43 config.assets.version = '1.0'
44
44
45 config.action_mailer.perform_deliveries = false
45 config.action_mailer.perform_deliveries = false
46
46
47 # Do not include all helpers
47 # Do not include all helpers
48 config.action_controller.include_all_helpers = false
48 config.action_controller.include_all_helpers = false
49
49
50 # Specific cache for search results, the default file store cache is not
51 # a good option as it could grow fast. A memory store (32MB max) is used
52 # as the default. If you're running multiple server processes, it's
53 # recommended to switch to a shared cache store (eg. mem_cache_store).
54 # See http://guides.rubyonrails.org/caching_with_rails.html#cache-stores
55 # for more options (same options as config.cache_store).
56 config.redmine_search_cache_store = :memory_store
57
50 config.session_store :cookie_store, :key => '_redmine_session'
58 config.session_store :cookie_store, :key => '_redmine_session'
51
59
52 if File.exists?(File.join(File.dirname(__FILE__), 'additional_environment.rb'))
60 if File.exists?(File.join(File.dirname(__FILE__), 'additional_environment.rb'))
53 instance_eval File.read(File.join(File.dirname(__FILE__), 'additional_environment.rb'))
61 instance_eval File.read(File.join(File.dirname(__FILE__), 'additional_environment.rb'))
54 end
62 end
55 end
63 end
56 end
64 end
@@ -1,138 +1,174
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2014 Jean-Philippe Lang
2 # Copyright (C) 2006-2014 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 module Redmine
18 module Redmine
19 module Search
19 module Search
20
20
21 mattr_accessor :available_search_types
21 mattr_accessor :available_search_types
22 @@available_search_types = []
22 @@available_search_types = []
23
23
24 class << self
24 class << self
25 def map(&block)
25 def map(&block)
26 yield self
26 yield self
27 end
27 end
28
28
29 # Registers a search provider
29 # Registers a search provider
30 def register(search_type, options={})
30 def register(search_type, options={})
31 search_type = search_type.to_s
31 search_type = search_type.to_s
32 @@available_search_types << search_type unless @@available_search_types.include?(search_type)
32 @@available_search_types << search_type unless @@available_search_types.include?(search_type)
33 end
33 end
34
35 # Returns the cache store for search results
36 # Can be configured with config.redmine_search_cache_store= in config/application.rb
37 def cache_store
38 @@cache_store ||= begin
39 # if config.search_cache_store was not previously set, a no method error would be raised
40 config = Rails.application.config.redmine_search_cache_store rescue :memory_store
41 if config
42 ActiveSupport::Cache.lookup_store config
43 end
44 end
45 end
34 end
46 end
35
47
36 class Fetcher
48 class Fetcher
37 attr_reader :tokens
49 attr_reader :tokens
38
50
39 def initialize(question, user, scope, projects, options={})
51 def initialize(question, user, scope, projects, options={})
40 @user = user
52 @user = user
41 @question = question.strip
53 @question = question.strip
42 @scope = scope
54 @scope = scope
43 @projects = projects
55 @projects = projects
56 @cache = options.delete(:cache)
44 @options = options
57 @options = options
45
58
46 # extract tokens from the question
59 # extract tokens from the question
47 # eg. hello "bye bye" => ["hello", "bye bye"]
60 # eg. hello "bye bye" => ["hello", "bye bye"]
48 @tokens = @question.scan(%r{((\s|^)"[\s\w]+"(\s|$)|\S+)}).collect {|m| m.first.gsub(%r{(^\s*"\s*|\s*"\s*$)}, '')}
61 @tokens = @question.scan(%r{((\s|^)"[\s\w]+"(\s|$)|\S+)}).collect {|m| m.first.gsub(%r{(^\s*"\s*|\s*"\s*$)}, '')}
49 # tokens must be at least 2 characters long
62 # tokens must be at least 2 characters long
50 @tokens = @tokens.uniq.select {|w| w.length > 1 }
63 @tokens = @tokens.uniq.select {|w| w.length > 1 }
51 # no more than 5 tokens to search for
64 # no more than 5 tokens to search for
52 @tokens.slice! 5..-1
65 @tokens.slice! 5..-1
53 end
66 end
54
67
68 # Returns the total result count
55 def result_count
69 def result_count
56 result_ids.size
70 result_ids.size
57 end
71 end
58
72
73 # Returns the result count by type
59 def result_count_by_type
74 def result_count_by_type
60 ret = Hash.new {|h,k| h[k] = 0}
75 ret = Hash.new {|h,k| h[k] = 0}
61 result_ids.group_by(&:first).each do |scope, ids|
76 result_ids.group_by(&:first).each do |scope, ids|
62 ret[scope] += ids.size
77 ret[scope] += ids.size
63 end
78 end
64 ret
79 ret
65 end
80 end
66
81
82 # Returns the results for the given offset and limit
67 def results(offset, limit)
83 def results(offset, limit)
68 result_ids_to_load = result_ids[offset, limit] || []
84 result_ids_to_load = result_ids[offset, limit] || []
69
85
70 results_by_scope = Hash.new {|h,k| h[k] = []}
86 results_by_scope = Hash.new {|h,k| h[k] = []}
71 result_ids_to_load.group_by(&:first).each do |scope, scope_and_ids|
87 result_ids_to_load.group_by(&:first).each do |scope, scope_and_ids|
72 klass = scope.singularize.camelcase.constantize
88 klass = scope.singularize.camelcase.constantize
73 results_by_scope[scope] += klass.search_results_from_ids(scope_and_ids.map(&:last))
89 results_by_scope[scope] += klass.search_results_from_ids(scope_and_ids.map(&:last))
74 end
90 end
75
91
76 result_ids_to_load.map do |scope, id|
92 result_ids_to_load.map do |scope, id|
77 results_by_scope[scope].detect {|record| record.id == id}
93 results_by_scope[scope].detect {|record| record.id == id}
78 end.compact
94 end.compact
79 end
95 end
80
96
97 # Returns the results ids, sorted by rank
81 def result_ids
98 def result_ids
82 @ranks_and_ids ||= load_result_ids
99 @ranks_and_ids ||= load_result_ids_from_cache
83 end
100 end
84
101
85 private
102 private
86
103
104 def project_ids
105 Array.wrap(@projects).map(&:id)
106 end
107
108 def load_result_ids_from_cache
109 if Redmine::Search.cache_store
110 cache_key = ActiveSupport::Cache.expand_cache_key(
111 [@question, @user.id, @scope.sort, @options, project_ids.sort]
112 )
113
114 Redmine::Search.cache_store.fetch(cache_key, :force => !@cache) do
115 load_result_ids
116 end
117 else
118 load_result_ids
119 end
120 end
121
87 def load_result_ids
122 def load_result_ids
88 ret = []
123 ret = []
89 # get all the results ranks and ids
124 # get all the results ranks and ids
90 @scope.each do |scope|
125 @scope.each do |scope|
91 klass = scope.singularize.camelcase.constantize
126 klass = scope.singularize.camelcase.constantize
92 ranks_and_ids_in_scope = klass.search_result_ranks_and_ids(@tokens, User.current, @projects, @options)
127 ranks_and_ids_in_scope = klass.search_result_ranks_and_ids(@tokens, User.current, @projects, @options)
93 # converts timestamps to integers for faster sort
128 # converts timestamps to integers for faster sort
94 ret += ranks_and_ids_in_scope.map {|rank, id| [scope, [rank.to_i, id]]}
129 ret += ranks_and_ids_in_scope.map {|rank, id| [scope, [rank.to_i, id]]}
95 end
130 end
96 # sort results, higher rank and id first
131 # sort results, higher rank and id first
97 ret.sort! {|a,b| b.last <=> a.last}
132 ret.sort! {|a,b| b.last <=> a.last}
133 # only keep ids now that results are sorted
98 ret.map! {|scope, r| [scope, r.last]}
134 ret.map! {|scope, r| [scope, r.last]}
99 ret
135 ret
100 end
136 end
101 end
137 end
102
138
103 module Controller
139 module Controller
104 def self.included(base)
140 def self.included(base)
105 base.extend(ClassMethods)
141 base.extend(ClassMethods)
106 end
142 end
107
143
108 module ClassMethods
144 module ClassMethods
109 @@default_search_scopes = Hash.new {|hash, key| hash[key] = {:default => nil, :actions => {}}}
145 @@default_search_scopes = Hash.new {|hash, key| hash[key] = {:default => nil, :actions => {}}}
110 mattr_accessor :default_search_scopes
146 mattr_accessor :default_search_scopes
111
147
112 # Set the default search scope for a controller or specific actions
148 # Set the default search scope for a controller or specific actions
113 # Examples:
149 # Examples:
114 # * search_scope :issues # => sets the search scope to :issues for the whole controller
150 # * search_scope :issues # => sets the search scope to :issues for the whole controller
115 # * search_scope :issues, :only => :index
151 # * search_scope :issues, :only => :index
116 # * search_scope :issues, :only => [:index, :show]
152 # * search_scope :issues, :only => [:index, :show]
117 def default_search_scope(id, options = {})
153 def default_search_scope(id, options = {})
118 if actions = options[:only]
154 if actions = options[:only]
119 actions = [] << actions unless actions.is_a?(Array)
155 actions = [] << actions unless actions.is_a?(Array)
120 actions.each {|a| default_search_scopes[controller_name.to_sym][:actions][a.to_sym] = id.to_s}
156 actions.each {|a| default_search_scopes[controller_name.to_sym][:actions][a.to_sym] = id.to_s}
121 else
157 else
122 default_search_scopes[controller_name.to_sym][:default] = id.to_s
158 default_search_scopes[controller_name.to_sym][:default] = id.to_s
123 end
159 end
124 end
160 end
125 end
161 end
126
162
127 def default_search_scopes
163 def default_search_scopes
128 self.class.default_search_scopes
164 self.class.default_search_scopes
129 end
165 end
130
166
131 # Returns the default search scope according to the current action
167 # Returns the default search scope according to the current action
132 def default_search_scope
168 def default_search_scope
133 @default_search_scope ||= default_search_scopes[controller_name.to_sym][:actions][action_name.to_sym] ||
169 @default_search_scope ||= default_search_scopes[controller_name.to_sym][:actions][action_name.to_sym] ||
134 default_search_scopes[controller_name.to_sym][:default]
170 default_search_scopes[controller_name.to_sym][:default]
135 end
171 end
136 end
172 end
137 end
173 end
138 end
174 end
General Comments 0
You need to be logged in to leave comments. Login now