jnewland-scoped_search 0.7.1

Sign up to get free protection for your applications and to get access to all the features.
data/CHANGELOG ADDED
@@ -0,0 +1,12 @@
1
+ scoped_search 0.3.0
2
+ ===================
3
+ - Detection of column types so they can be handled properly
4
+ - Date based queries supported on date and time fields
5
+
6
+ scoped_search 0.2.0
7
+ ===================
8
+ - OR keyword supported in query language
9
+
10
+ scoped_search 0.1.0
11
+ ===================
12
+ - Initial version
data/LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright (c) 2008 Willem van Bergen
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.textile ADDED
@@ -0,0 +1,76 @@
1
+ h1. "scoped_search":http://github.com/wvanbergen/scoped_search/tree/master
2
+
3
+ This simple plugin will make it easy to search your ActiveRecord models. Searching is performed using a query string, which should be passed to the named_scope <tt>search_for</tt> that uses SQL %LIKE% conditions for searching (ILIKE for Postgres). You can specify what fields should be used for searching.
4
+
5
+ * "scoped_search installation":#INSTALLATION
6
+ * "scoped_search usage":#USAGE
7
+ * "scoped_search license":#LICENSE
8
+
9
+ <a id="INSTALLATION"/> </a>
10
+ h2. Installing scoped_search
11
+
12
+ <pre><code>
13
+ gem install wvanbergen-scoped_search
14
+
15
+ </code></pre>
16
+
17
+ <a id="USAGE"/> </a>
18
+ h2. Usage
19
+
20
+ First, you have to specify in what columns should be searched:
21
+
22
+ <pre><code>
23
+ class User < ActiveRecord::Base
24
+ searchable_on :first_name, :last_name
25
+ end
26
+
27
+ </code></pre>
28
+
29
+ Now, the *search_for* scope is available for queries. You should pass a query string to the scope.
30
+ This can be empty or nil, in which case all no search conditions are set (and all records will be returned).
31
+
32
+ <pre><code>
33
+ User.search_for(params[:q]).each { |project| ... }
34
+
35
+ </code></pre>
36
+
37
+ You can also search on associate models. This works with *belongs_to*, *has_one*, *has_many*,
38
+ *has_many :through*, and *HABTM*. For example if a User *has_many* Notes (title, content, created_at, updated_at)
39
+
40
+ <pre><code>
41
+ class User < ActiveRecord::Base
42
+ has_many: notes
43
+ searchable_on :first_name, :last_name, :notes_title, :notes_content
44
+ end
45
+
46
+ </code></pre>
47
+
48
+ The search query language is simple. It supports these constructs:
49
+ * words: some search keywords
50
+ * phrases: "a single search phrase"
51
+ * negation: "look for this" -"but do not look for this phrase and this" -word
52
+ * OR words/phrases: word/phrase OR word/phrase. Example: "Hello World" OR "Hello Moon"
53
+ * dates: mm/dd/yyyy, dd/mm/yyyy, yyyy/mm/dd, yyyy-mm-dd
54
+ * date ranges: > date, >= date, < date, <= date, date TO date. Examples: > mm/dd/yyyy, < yyyy-mm-dd
55
+
56
+ This functionality is build on named_scope. The searchable_on statement creates
57
+ a named_scope *search_for*. Because of this, you can actually chain the call with
58
+ other scopes. For example, this can be very useful if you only want to search in
59
+ projects that are accessible by a given user.
60
+
61
+ <pre><code>
62
+ class Project < ActiveRecord::Base
63
+ searchable_on :name, :description
64
+ named_scope :accessible_by, lambda { |user| ... }
65
+ end
66
+
67
+ # using chained named_scopes and will_paginate
68
+ Project.accessible_by(current_user).search_for(params[:q]).paginate(:page => params[:page], :include => :tasks)
69
+
70
+ </code></pre>
71
+
72
+ <a id="LICENSE"/> </a>
73
+ h2. License
74
+
75
+ This plugin is released under the MIT license. Please contact (willem AT vanbergen DOT org) if you have any suggestions or remarks.
76
+
data/Rakefile ADDED
@@ -0,0 +1,70 @@
1
+ require 'rubygems'
2
+
3
+ load 'test/tasks.rake'
4
+
5
+ desc 'Default: run unit tests.'
6
+ task :default => :test
7
+
8
+ namespace :gem do
9
+
10
+ desc "Sets the version and date of the scoped_search gem. Requires the VERSION environment variable."
11
+ task :version => [:manifest] do
12
+
13
+ require 'date'
14
+
15
+ new_version = ENV['VERSION']
16
+ raise "VERSION is required" unless /\d+(\.\d+)*/ =~ new_version
17
+
18
+ spec_file = Dir['*.gemspec'].first
19
+
20
+ spec = File.read(spec_file)
21
+ spec.gsub!(/^(\s*s\.version\s*=\s*)('|")(.+)('|")(\s*)$/) { "#{$1}'#{new_version}'#{$5}" }
22
+ spec.gsub!(/^(\s*s\.date\s*=\s*)('|")(.+)('|")(\s*)$/) { "#{$1}'#{Date.today.strftime('%Y-%m-%d')}'#{$5}" }
23
+ File.open(spec_file, 'w') { |f| f << spec }
24
+ end
25
+
26
+ task :tag => [:version] do
27
+
28
+ new_version = ENV['VERSION']
29
+ raise "VERSION is required" unless /\d+(\.\d+)*/ =~ new_version
30
+
31
+ sh "git add scoped_search.gemspec .manifest"
32
+ sh "git commit -m \"Set gem version to #{new_version}\""
33
+ sh "git push origin"
34
+ sh "git tag -a \"scoped_search-#{new_version}\" -m \"Tagged version #{new_version}\""
35
+ sh "git push --tags"
36
+ end
37
+
38
+ desc "Builds a ruby gem for scoped_search"
39
+ task :build => [:manifest] do
40
+ system %[gem build scoped_search.gemspec]
41
+ end
42
+
43
+ desc %{Update ".manifest" with the latest list of project filenames. Respect\
44
+ .gitignore by excluding everything that git ignores. Update `files` and\
45
+ `test_files` arrays in "*.gemspec" file if it's present.}
46
+ task :manifest do
47
+ list = Dir['**/*'].sort
48
+ spec_file = Dir['*.gemspec'].first
49
+ list -= [spec_file] if spec_file
50
+
51
+ File.read('.gitignore').each_line do |glob|
52
+ glob = glob.chomp.sub(/^\//, '')
53
+ list -= Dir[glob]
54
+ list -= Dir["#{glob}/**/*"] if File.directory?(glob) and !File.symlink?(glob)
55
+ puts "excluding #{glob}"
56
+ end
57
+
58
+ if spec_file
59
+ spec = File.read spec_file
60
+ spec.gsub! /^(\s* s.(test_)?files \s* = \s* )( \[ [^\]]* \] | %w\( [^)]* \) )/mx do
61
+ assignment = $1
62
+ bunch = $2 ? list.grep(/^test.*_test\.rb$/) : list
63
+ '%s%%w(%s)' % [assignment, bunch.join(' ')]
64
+ end
65
+
66
+ File.open(spec_file, 'w') {|f| f << spec }
67
+ end
68
+ File.open('.manifest', 'w') {|f| f << list.join("\n") }
69
+ end
70
+ end
data/TODO ADDED
@@ -0,0 +1,15 @@
1
+ TODO items for named_scope
2
+ ==========================
3
+ Contact willem AT vanbergen DOT org if you want to help out
4
+
5
+ 0.8.0
6
+
7
+
8
+ 0.9.0
9
+
10
+
11
+ 1.0.0
12
+ - Extensive testing
13
+
14
+ Documentation & testing:
15
+ - Add rdoc en comments to code
data/init.rb ADDED
@@ -0,0 +1 @@
1
+ require 'scoped_search'
@@ -0,0 +1,78 @@
1
+ module ScopedSearch
2
+
3
+ module ClassMethods
4
+
5
+ def self.extended(base)
6
+ require 'scoped_search/reg_tokens'
7
+ require 'scoped_search/query_language_parser'
8
+ require 'scoped_search/query_conditions_builder'
9
+ end
10
+
11
+ # Creates a named scope in the class it was called upon
12
+ def searchable_on(*fields)
13
+ if fields.first.class.to_s == 'Hash'
14
+ if fields.first.has_key?(:only)
15
+ fields = fields.first[:only]
16
+ elsif fields.first.has_key?(:except)
17
+ fields = self.column_names.collect { |column|
18
+ fields.first[:except].include?(column.to_sym) ? nil : column.to_sym }.compact
19
+ end
20
+ end
21
+
22
+ assoc_models = self.reflections.collect { |m| m[0] }
23
+ assoc_fields = fields - self.column_names.collect { |column| column.to_sym }
24
+ fields -= assoc_fields
25
+
26
+ assoc_groupings = {}
27
+ assoc_models.each do |assoc_model|
28
+ assoc_groupings[assoc_model] = []
29
+ assoc_fields.each do |assoc_field|
30
+ unless assoc_field.to_s.match(/^#{assoc_model.to_s}_/).nil?
31
+ assoc_groupings[assoc_model] << assoc_field.to_s.sub(/^#{assoc_model.to_s}_/, '').to_sym
32
+ end
33
+ end
34
+ end
35
+
36
+ assoc_groupings = assoc_groupings.delete_if {|group, field_group| field_group.empty?}
37
+
38
+ self.cattr_accessor :scoped_search_fields, :scoped_search_assoc_groupings
39
+ self.scoped_search_fields = fields
40
+ self.scoped_search_assoc_groupings = assoc_groupings
41
+ self.named_scope :search_for, lambda { |keywords| self.build_scoped_search_conditions(keywords) }
42
+ end
43
+
44
+ # Build a hash that is used for the named_scope search_for.
45
+ # This function will split the search_string into keywords, and search for all the keywords
46
+ # in the fields that were provided to searchable_on
47
+ def build_scoped_search_conditions(search_string)
48
+ if search_string.nil? || search_string.strip.blank?
49
+ return {:conditions => nil}
50
+ else
51
+ query_fields = {}
52
+ self.scoped_search_fields.each do |field|
53
+ field_name = connection.quote_table_name(table_name) + "." + connection.quote_column_name(field)
54
+ query_fields[field_name] = self.columns_hash[field.to_s].type
55
+ end
56
+
57
+ assoc_models_to_include = []
58
+ self.scoped_search_assoc_groupings.each do |group|
59
+ assoc_models_to_include << group[0]
60
+ group[1].each do |group_field|
61
+ field_name = connection.quote_table_name(group[0].to_s.pluralize) + "." + connection.quote_column_name(group_field)
62
+ query_fields[field_name] = self.reflections[group[0]].klass.columns_hash[group_field.to_s].type
63
+ end
64
+ end
65
+
66
+ search_conditions = QueryLanguageParser.parse(search_string)
67
+ conditions = QueryConditionsBuilder.build_query(search_conditions, query_fields)
68
+
69
+ retVal = {:conditions => conditions}
70
+ retVal[:include] = assoc_models_to_include unless assoc_models_to_include.empty?
71
+
72
+ return retVal
73
+ end
74
+ end
75
+ end
76
+ end
77
+
78
+ ActiveRecord::Base.send(:extend, ScopedSearch::ClassMethods)
@@ -0,0 +1,189 @@
1
+ module ScopedSearch
2
+
3
+ class QueryConditionsBuilder
4
+ ## ActiveRecord::Base.connection.adapter_name
5
+
6
+ # Build the query
7
+ def self.build_query(search_conditions, query_fields)
8
+ self.new.build(search_conditions, query_fields)
9
+ end
10
+
11
+ def initialize
12
+ @query_fields = nil
13
+ @query_params = {}
14
+
15
+ @sql_like = 'LIKE'
16
+
17
+ if ActiveRecord::Base.connected? and ActiveRecord::Base.connection.adapter_name.downcase == 'postgresql'
18
+ @sql_like = 'ILIKE'
19
+ end
20
+ end
21
+
22
+
23
+ # Build the query
24
+ #
25
+ # Hash query_options : A hash of fields and field types.
26
+ #
27
+ # Exampe:
28
+ # search_conditions = [["Wes", :like], ["Hays", :not], ["Hello World", :like], ["Goodnight Moon", :not],
29
+ # ["Bob OR Wes", :or], ["Happy cow OR Sad Frog", :or], ["Man made OR Dogs", :or],
30
+ # ["Cows OR Frog Toys", :or], ['9/28/1980, :datetime]]
31
+ # query_fields = {:first_name => :string, :created_at => :datetime}
32
+ #
33
+ # Exceptons :
34
+ # 1) If urlParams does not contain a :controller key.
35
+ def build(search_conditions, query_fields)
36
+ raise 'search_conditions must be a hash' unless search_conditions.class.to_s == 'Array'
37
+ raise 'query_fields must be a hash' unless query_fields.class.to_s == 'Hash'
38
+ @query_fields = query_fields
39
+
40
+ conditions = []
41
+
42
+ search_conditions.each_with_index do |search_condition, index|
43
+ keyword_name = "keyword_#{index}".to_sym
44
+ conditions << case search_condition.last
45
+ #when :integer: integer_conditions(keyword_name, search_condition.first)
46
+
47
+ when :like: like_condition(keyword_name, search_condition.first)
48
+ when :not: not_like_condition(keyword_name, search_condition.first)
49
+
50
+ when :or: or_condition(keyword_name, search_condition.first)
51
+
52
+ when :less_than_date: less_than_date(keyword_name, search_condition.first)
53
+ when :less_than_or_equal_to_date: less_than_or_equal_to_date(keyword_name, search_condition.first)
54
+ when :as_of_date: as_of_date(keyword_name, search_condition.first)
55
+ when :greater_than_date: greater_than_date(keyword_name, search_condition.first)
56
+ when :greater_than_or_equal_to_date: greater_than_or_equal_to_date(keyword_name, search_condition.first)
57
+
58
+ when :between_dates: between_dates(keyword_name, search_condition.first)
59
+ end
60
+ end
61
+
62
+ [conditions.compact.join(' AND '), @query_params]
63
+ end
64
+
65
+
66
+ private
67
+
68
+ # def integer_condition(keyword_name, value)
69
+ # end
70
+
71
+ def like_condition(keyword_name, value)
72
+ @query_params[keyword_name] = "%#{value}%"
73
+ retVal = []
74
+ @query_fields.each do |field, field_type| #|key,value|
75
+ if field_type == :string or field_type == :text
76
+ retVal << "#{field} #{@sql_like} :#{keyword_name.to_s}"
77
+ end
78
+ end
79
+ "(#{retVal.join(' OR ')})"
80
+ end
81
+
82
+ def not_like_condition(keyword_name, value)
83
+ @query_params[keyword_name] = "%#{value}%"
84
+ retVal = []
85
+ @query_fields.each do |field, field_type| #|key,value|
86
+ if field_type == :string or field_type == :text
87
+ retVal << "(#{field} NOT #{@sql_like} :#{keyword_name.to_s} OR #{field} IS NULL)"
88
+ end
89
+ end
90
+ "(#{retVal.join(' AND ')})"
91
+ end
92
+
93
+ def or_condition(keyword_name, value)
94
+ retVal = []
95
+ word1, word2 = value.split(' OR ')
96
+ keyword_name_a = "#{keyword_name.to_s}a".to_sym
97
+ keyword_name_b = "#{keyword_name.to_s}b".to_sym
98
+ @query_params[keyword_name_a] = "%#{word1}%"
99
+ @query_params[keyword_name_b] = "%#{word2}%"
100
+ @query_fields.each do |field, field_type| #|key,value|
101
+ if field_type == :string or field_type == :text
102
+ retVal << "(#{field} #{@sql_like} :#{keyword_name_a.to_s} OR #{field} #{@sql_like} :#{keyword_name_b.to_s})"
103
+ end
104
+ end
105
+ "(#{retVal.join(' OR ')})"
106
+ end
107
+
108
+ def less_than_date(keyword_name, value)
109
+ helper_date_operation('<', keyword_name, value)
110
+ end
111
+
112
+ def less_than_or_equal_to_date(keyword_name, value)
113
+ helper_date_operation('<=', keyword_name, value)
114
+ end
115
+
116
+ def as_of_date(keyword_name, value)
117
+ retVal = []
118
+ begin
119
+ dt = Date.parse(value) # This will throw an exception if it is not valid
120
+ @query_params[keyword_name] = dt.to_s
121
+ @query_fields.each do |field, field_type| #|key,value|
122
+ if field_type == :date or field_type == :datetime or field_type == :timestamp
123
+ retVal << "#{field} = :#{keyword_name.to_s}"
124
+ end
125
+ end
126
+ rescue
127
+ # do not search on any date columns since the date is invalid
128
+ end
129
+
130
+ # Search the text fields for the date as well as it could be in text.
131
+ # Also still search on the text columns for an invalid date as it could
132
+ # have a different meaning.
133
+ keyword_name_b = "#{keyword_name}b".to_sym
134
+ @query_params[keyword_name_b] = "%#{value}%"
135
+ @query_fields.each do |field, field_type| #|key,value|
136
+ if field_type == :string or field_type == :text
137
+ retVal << "#{field} #{@sql_like} :#{keyword_name_b.to_s}"
138
+ end
139
+ end
140
+
141
+ "(#{retVal.join(' OR ')})"
142
+ end
143
+
144
+ def greater_than_date(keyword_name, value)
145
+ helper_date_operation('>', keyword_name, value)
146
+ end
147
+
148
+ def greater_than_or_equal_to_date(keyword_name, value)
149
+ helper_date_operation('>=', keyword_name, value)
150
+ end
151
+
152
+ def between_dates(keyword_name, value)
153
+ date1, date2 = value.split(' TO ')
154
+ dt1 = Date.parse(date1) # This will throw an exception if it is not valid
155
+ dt2 = Date.parse(date2) # This will throw an exception if it is not valid
156
+ keyword_name_a = "#{keyword_name.to_s}a".to_sym
157
+ keyword_name_b = "#{keyword_name.to_s}b".to_sym
158
+ @query_params[keyword_name_a] = dt1.to_s
159
+ @query_params[keyword_name_b] = dt2.to_s
160
+
161
+ retVal = []
162
+ @query_fields.each do |field, field_type| #|key,value|
163
+ if field_type == :date or field_type == :datetime or field_type == :timestamp
164
+ retVal << "(#{field} BETWEEN :#{keyword_name_a.to_s} AND :#{keyword_name_b.to_s})"
165
+ end
166
+ end
167
+ "(#{retVal.join(' OR ')})"
168
+ rescue
169
+ # The date is not valid so just ignore it
170
+ return nil
171
+ end
172
+
173
+
174
+ def helper_date_operation(operator, keyword_name, value)
175
+ dt = Date.parse(value) # This will throw an exception if it is not valid
176
+ @query_params[keyword_name] = dt.to_s
177
+ retVal = []
178
+ @query_fields.each do |field, field_type| #|key,value|
179
+ if field_type == :date or field_type == :datetime or field_type == :timestamp
180
+ retVal << "#{field} #{operator} :#{keyword_name.to_s}"
181
+ end
182
+ end
183
+ "(#{retVal.join(' OR ')})"
184
+ rescue
185
+ # The date is not valid so just ignore it
186
+ return nil
187
+ end
188
+ end
189
+ end