searchable_record 0.0.2 → 0.0.3

Sign up to get free protection for your applications and to get access to all the features.
data/CHANGELOG.rdoc ADDED
@@ -0,0 +1,11 @@
1
+ === 0.0.3 released 2009-01-14
2
+
3
+ * Small code refactorization, documentation clarifications.
4
+
5
+ === 0.0.2 released 2008-08-26
6
+
7
+ * Changed specs to use RSpec syntax instead of test/spec.
8
+
9
+ === 0.0.1 released 2008-03-03
10
+
11
+ * First release.
data/Manifest ADDED
@@ -0,0 +1,11 @@
1
+ CHANGELOG.rdoc
2
+ lib/searchable_record/core.rb
3
+ lib/searchable_record/util.rb
4
+ lib/searchable_record/version.rb
5
+ lib/searchable_record.rb
6
+ Manifest
7
+ Rakefile
8
+ README.rdoc
9
+ spec/searchable_record_spec.rb
10
+ spec/searchable_record_spec_helper.rb
11
+ spec/util_spec.rb
data/README.rdoc ADDED
@@ -0,0 +1,164 @@
1
+ = SearchableRecord
2
+
3
+ SearchableRecord is a small Ruby on Rails plugin that makes the parsing of
4
+ query parameters from URLs easy for resources, allowing the requester to
5
+ control the items (records) shown in the resource's representation.
6
+
7
+ The implementation is a helper module (a mixin) for ActiveRecord models. It
8
+ is used by including SearchableRecord module in a model.
9
+
10
+ The mixin provides a class method, <tt>SearchableRecord#find_queried</tt>,
11
+ to the class that includes it. The method is a front-end to
12
+ <tt>ActiveRecord::Base#find</tt>: it parses query parameters against the
13
+ given rules and calls <tt>find</tt> accordingly, returning the results of
14
+ <tt>find</tt>.
15
+
16
+ == A usage example
17
+
18
+ The following example, although a bit contrived, allows the client to
19
+
20
+ * limit the number of items as the result of the search
21
+ (<tt>limit</tt> parameter),
22
+ * set an offset for the items (<tt>offset</tt> parameter, intended to be
23
+ used together with <tt>limit</tt>),
24
+ * sort the items either in ascending (<tt>sort</tt> parameter) or
25
+ descending (<tt>rsort</tt> parameter) order by items' type and name,
26
+ * to limit the result by matching only items that were update before
27
+ (<tt>until</tt> parameter) or after (<tt>since</tt> parameter) a certain
28
+ date, and
29
+ * to limit the result by matching only items with certain kind of
30
+ types (<tt>type</tt> parameter) or names (<tt>name</tt> parameter), or
31
+ both (for a name, a conversion to the client supplied parameter must be
32
+ applied before matching the name in the database).
33
+
34
+ First, we need resource items. Let us presume the application allows its
35
+ clients to query <tt>Item</tt> type of resources:
36
+
37
+ class Item < ActiveRecord::Base
38
+ include SearchableRecord
39
+ end
40
+
41
+ By including SearchableRecord module to Item, the method
42
+ <tt>find_queried</tt> becomes available. The method can be called, for
43
+ example, in <tt>ItemController</tt> to parse the client's query parameters:
44
+
45
+ Item.find_queried(:all, query_params, rules, options)
46
+
47
+ In the beginning of this example, we stated requirements what the clients
48
+ are allowed to query. These requirements are expressed as the following
49
+ rules:
50
+
51
+ rules = {
52
+ :limit => nil, # key as a flag; the value for the key is not used
53
+ :offset => nil, # key as a flag
54
+ :sort => { "name" => "items.name", "created" => "items.created_at" },
55
+ :rsort => nil, # rsort is allowed according to rules in :sort (key as a flag)
56
+ :since => "items.created_at", # cast parameter value as the default type
57
+ :until => "items.created_at", # cast parameter value as the default type
58
+ :patterns => { :type => "items.type", # match the pattern with the default operator and converter
59
+ :name => { :column => "items.name",
60
+ :converter => lambda { |val| "%#{val.gsub('_', '.')}%" } } }
61
+ # match the pattern with the default operator
62
+ }
63
+
64
+ These rules are fed to <tt>find_queried</tt> as the third argument.
65
+
66
+ In addition, the application may to require options to be passed to
67
+ <tt>find</tt>:
68
+
69
+ options = {
70
+ :include => [ :owners ],
71
+ :conditions => "items.flag = 'f'"
72
+ }
73
+
74
+ These can be supplied to <tt>find_queried</tt> as the fourth argument.
75
+
76
+ The second argument to <tt>find_queried</tt> is the query parameters
77
+ <tt>ItemController</tt> receives. For example, the client uses the URL
78
+ <tt>http://example-site.org/items?limit=5&offset=4&rsort=name&since=2008-02-28&name=foo_bar</tt>
79
+ to fetch a representation of the application's resource containing the
80
+ items. The action results to the following parameters:
81
+
82
+ query_params = params
83
+
84
+ # => query_params = {
85
+ # 'offset' => '4',
86
+ # 'limit' => '5',
87
+ # 'rsort' => 'name',
88
+ # 'until' => '2008-02-28',
89
+ # 'name' => 'foo_bar',
90
+ # ...
91
+ # # plus Rails-specific parameters, such as 'action' and 'controller'
92
+ # }
93
+
94
+ With these query parameters and arguments, <tt>find_queried</tt> calls
95
+ <tt>find</tt> with the following arguments:
96
+
97
+ Item.find(:all,
98
+ :include => [ :owners ],
99
+ :order => "items.name desc",
100
+ :offset => 4,
101
+ :limit => 5,
102
+ :conditions => [ "(items.flag = 'f') and (items.created_at <= cast(:until as datetime)) and (items.name like :name)",
103
+ { :until => "2008-02-28", :name => "%foo.bar%" } ])
104
+
105
+ This particular search results to at most 5 items that are
106
+
107
+ * from offset 4 (that is, items from positions 5 to 9),
108
+ * sorted in descending order by items' names,
109
+ * updated since 2008-02-28, and
110
+ * have <tt>foo.bar</tt> in their name.
111
+
112
+ See <tt>find_queried</tt> method in SearchableRecord::ClassMethods for
113
+ details.
114
+
115
+ == Installation
116
+
117
+ In order to install the plugin as a Ruby gem for a Rails application, edit
118
+ the <tt>environment.rb</tt> file of the application to contain the following
119
+ line:
120
+
121
+ config.gem "searchable_record"
122
+
123
+ (This requires Rails version 2.1 or above.)
124
+
125
+ Then install the gem, either using the Rakefile of the Rails application:
126
+
127
+ $ rake gems:install
128
+
129
+ ...or with the <tt>gem</tt> tool:
130
+
131
+ $ gem install searchable_record
132
+
133
+ Use git to get the source code for modifications and hacks:
134
+
135
+ $ git clone git://gitorious.org/searchable-rec/mainline.git
136
+
137
+ == Contacting
138
+
139
+ Please send feedback by email to Tuomas Kareinen < tkareine (at) gmail (dot)
140
+ com >.
141
+
142
+ == Legal notes
143
+
144
+ This software is licensed under the terms of the "MIT license":
145
+
146
+ Copyright (c) 2008-2009 Tuomas Kareinen
147
+
148
+ Permission is hereby granted, free of charge, to any person obtaining a copy
149
+ of this software and associated documentation files (the "Software"), to
150
+ deal in the Software without restriction, including without limitation the
151
+ rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
152
+ sell copies of the Software, and to permit persons to whom the Software is
153
+ furnished to do so, subject to the following conditions:
154
+
155
+ The above copyright notice and this permission notice shall be included in
156
+ all copies or substantial portions of the Software.
157
+
158
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
159
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
160
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
161
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
162
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
163
+ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
164
+ IN THE SOFTWARE.
data/Rakefile CHANGED
@@ -1,35 +1,39 @@
1
- require 'rubygems'
2
- require 'hoe'
3
- require 'spec/rake/spectask'
1
+ require "rubygems"
2
+ require "spec/rake/spectask"
3
+ require File.dirname(__FILE__) << "/lib/searchable_record/version"
4
+ require "echoe"
4
5
 
5
- $LOAD_PATH.unshift(File.dirname(__FILE__) + "/lib")
6
+ task :default => :spec
6
7
 
7
- require 'searchable_record'
8
-
9
- Hoe.new('searchable_record', SearchableRecord::Meta::VERSION.to_s) do |p|
10
- p.name = "searchable_record"
11
- p.rubyforge_name = 'searchable-rec' # If different than lowercase project name
8
+ Echoe.new("searchable_record") do |p|
12
9
  p.author = "Tuomas Kareinen"
13
- p.email = 'tkareine@gmail.com'
14
- p.summary = "SearchableRecord is a small Ruby on Rails plugin that makes the parsing of
15
- query parameters from URLs easy for resources, allowing the requester to
16
- control the items (records) shown in the resource's representation."
17
- p.description = p.paragraphs_of('README.txt', 1..3).join("\n\n")
10
+ p.email = "tkareine@gmail.com"
11
+ p.project = "searchable-rec" # If different than the project name in lowercase.
12
+ p.version = SearchableRecord::Version.to_s
18
13
  p.url = "http://searchable-rec.rubyforge.org"
19
- # p.clean_globs = ['test/actual'] # Remove this directory on "rake clean"
20
- p.remote_rdoc_dir = '' # Release to root
21
- p.changes = p.paragraphs_of('History.txt', 0..1).join("\n\n")
22
- p.extra_deps = ['activesupport']
14
+ p.summary =<<-END
15
+ SearchableRecord is a small Ruby on Rails plugin that makes the parsing of
16
+ query parameters from URLs easy for resources, allowing the requester to
17
+ control the items (records) shown in the resource's representation.
18
+ END
19
+ p.runtime_dependencies = %w(activesupport)
20
+ p.ignore_pattern = "release-script.txt"
21
+ p.rdoc_pattern = ["*.rdoc", "lib/**/*.rb"]
23
22
  end
24
23
 
25
24
  desc "Run specs."
26
- Spec::Rake::SpecTask.new('spec') do |t|
27
- t.spec_files = FileList['spec/**/*.rb']
25
+ Spec::Rake::SpecTask.new("spec") do |t|
26
+ t.spec_files = FileList["spec/**/*.rb"]
28
27
  t.spec_opts = ["--format", "specdoc"]
29
28
  #t.warning = true
30
29
  end
31
30
 
31
+ desc "Find code smells."
32
+ task :roodi do
33
+ sh("roodi '**/*.rb'")
34
+ end
35
+
32
36
  desc "Search unfinished parts of source code."
33
37
  task :todo do
34
- FileList['**/*.rb'].egrep /#.*(TODO|FIXME)/
38
+ FileList["**/*.rb"].egrep /#.*(TODO|FIXME)/
35
39
  end
@@ -0,0 +1,270 @@
1
+ # See SearchableRecord::ClassMethods#find_queried for usage documentation.
2
+ module SearchableRecord
3
+ def self.included(base_class) #:nodoc:
4
+ base_class.class_eval do
5
+ extend ClassMethods
6
+
7
+ @@searchable_record_settings = {
8
+ :cast_since_as => "datetime",
9
+ :cast_until_as => "datetime",
10
+ :pattern_operator => "like",
11
+ :pattern_converter => lambda { |val| "%#{val}%" }
12
+ }
13
+
14
+ cattr_accessor :searchable_record_settings
15
+ end
16
+ end
17
+
18
+ private
19
+
20
+ module ClassMethods
21
+ # === Description
22
+ #
23
+ # Parses the query parameters the client has given in the URL of the
24
+ # HTTP request. With the query parameters, the client may set a limit,
25
+ # an offset, or an ordering for the items in the search result. In
26
+ # addition, the client may limit the output by allowing only certain
27
+ # records that match to specific patterns.
28
+ #
29
+ # What the client user is allowed to query is defined by specific rules
30
+ # passed to the method as a Hash argument. Query parameters that are not
31
+ # explicitly stated in the rules are silently discarded.
32
+ #
33
+ # Essentially, the method is a frontend for
34
+ # <tt>ActiveRecord::Base#find</tt>. The method
35
+ #
36
+ # 1. parses the query parameters the client has given in the URL for
37
+ # the HTTP request (+query_params+) against the rules (+rules+), and
38
+ # 2. calls <tt>find</tt> with the parsed options.
39
+ #
40
+ # ==== Parsing rules
41
+ #
42
+ # The parsing rules must be given as a Hash. The recognized keys are the
43
+ # following:
44
+ #
45
+ # * <tt>:limit</tt>, allowing limiting the number of matching items
46
+ # (the same effect as with <tt>find</tt>). The value for the key is
47
+ # irrelevant; use +nil+. The rule enables query parameter "limit"
48
+ # that accepts an integer value.
49
+ # * <tt>:offset</tt>, allowing skipping matching items (the same effect
50
+ # as with <tt>find</tt>). The value for the key is irrelevant; use
51
+ # +nil+. The rule enables query parameter "offset" that accepts an
52
+ # integer value.
53
+ # * <tt>:sort</tt>, which determines the ordering of matching items
54
+ # (the same effect as with the <tt>:order</tt> option of
55
+ # <tt>find</tt>). The value is a Hash of
56
+ # <tt>"<parameter_value>" => "<table>.<column>"</tt> pairs. The rule
57
+ # enables query parameter "sort" that accepts keys from the Hash as
58
+ # its legal values.
59
+ # * <tt>:rsort</tt>, for reverse sort. Uses the rules of
60
+ # <tt>:sort</tt>; thus, use +nil+ as the value if you want to enable
61
+ # "rsort" query parameter.
62
+ # * <tt>:since</tt>, which sets a lower timedate limit. The value is
63
+ # either a string naming the database table column that has
64
+ # timestamps (using the type from default settings'
65
+ # <tt>:cast_since_as</tt> entry) or a Hash that contains entries like
66
+ # <tt>:column => "<table>.<column>"</tt> and
67
+ # <tt>:cast_as => "<sql_timedate_type>"</tt>. The rule enables query
68
+ # parameter "since" that accepts timedate values.
69
+ # * <tt>:until</tt>, which sets an upper timedate limit. It is used
70
+ # like <tt>:since</tt>.
71
+ # * <tt>:patterns</tt>, where the value is a Hash containing patterns.
72
+ # The keys in the Hash correspond to additional query parameters,
73
+ # while the corresponding values to the keys correspond to database
74
+ # table columns. For each pattern, the value is either directly a
75
+ # string, or a Hash containing an entry like
76
+ # <tt>:column => "<table>.<column>"</tt>. A pattern's Hash may
77
+ # contain two optional entries in addition to <tt>:column</tt>:
78
+ # <tt>:converter => lambda { |val| <conversion_operation_for_val> }</tt>
79
+ # and <tt>:operator => "<sql_pattern_operator>"</tt>.
80
+ # - <tt>:converter</tt> expects a block that modifies the input
81
+ # value; if the key is not given, the converter specified in
82
+ # <tt>:pattern_converter</tt> in the default settings is used
83
+ # instead.
84
+ # - <tt>:operator</tt> specifies a custom match operator for the
85
+ # pattern; if the key is not given, the operator specified in
86
+ # <tt>:pattern_operator</tt> in the default settings is used
87
+ # instead.
88
+ #
89
+ # If both +sort+ and +rsort+ parameters are given in the URL and both
90
+ # are allowed query parameter by the rules, +sort+ is favored over
91
+ # +rsort+. Unlike with +sort+ and +rsort+ rules (+rsort+ uses the rules
92
+ # of +sort+), the rules for +since+ and +until+ are independent from
93
+ # each other.
94
+ #
95
+ # For usage examples, see the example in README.txt and the specs that
96
+ # come with the plugin.
97
+ #
98
+ # ==== Default settings for rules
99
+ #
100
+ # The default settings for the rules are accessible and modifiable by
101
+ # calling the method +searchable_record_settings+. The settings are
102
+ # stored as a Hash; the following keys are recognized:
103
+ #
104
+ # * <tt>:cast_since_as</tt>,
105
+ # * <tt>:cast_until_as</tt>,
106
+ # * <tt>:pattern_operator</tt>, and
107
+ # * <tt>:pattern_converter</tt>.
108
+ #
109
+ # See the parsing rules above how the default settings are used.
110
+ #
111
+ # === Arguments
112
+ #
113
+ # +extend+:: The same as the first argument to <tt>find</tt> (such as <tt>:all</tt>).
114
+ # +query_params+:: The (unsafe) query parameters from the URL as a Hash.
115
+ # +rules+:: The parsing rules as a Hash.
116
+ # +options+:: Additional options for <tt>find</tt>, such as <tt>:include => [ :an_association ]</tt>.
117
+ #
118
+ # === Return
119
+ #
120
+ # The same as with <tt>ActiveRecord::Base#find</tt>.
121
+ def find_queried(extend, query_params, rules, options = { })
122
+ # Ensure the proper types of arguments.
123
+ query_params = query_params.to_hash
124
+ rules = rules.to_hash
125
+ options = options.to_hash
126
+
127
+ query_params = preserve_allowed_query_params(query_params, rules)
128
+
129
+ unless query_params.empty?
130
+ parse_offset(options, query_params)
131
+ parse_limit(options, query_params)
132
+ parse_order(options, query_params, rules)
133
+ parse_conditional_rules(options, query_params, rules)
134
+ end
135
+
136
+ logger.debug("find_queried: query_params=<<#{query_params.inspect}>>, resulted options=<<#{options.inspect}>>")
137
+
138
+ self.find(extend, options)
139
+ end
140
+
141
+ private
142
+
143
+ def preserve_allowed_query_params(query_params, rules)
144
+ allowed_keys = rules.keys
145
+
146
+ # Add pattern matching parameters to the list of allowed keys.
147
+ allowed_keys.delete(:patterns)
148
+ if rules[:patterns]
149
+ allowed_keys += rules[:patterns].keys
150
+ end
151
+
152
+ # Do not affect the passed query parameters.
153
+ Util.pruned_dup(query_params, allowed_keys)
154
+ end
155
+
156
+ def parse_offset(options, query_params)
157
+ if query_params[:offset]
158
+ value = Util.parse_positive_int(query_params[:offset])
159
+ options[:offset] = value unless value.nil?
160
+ end
161
+ end
162
+
163
+ def parse_limit(options, query_params)
164
+ if query_params[:limit]
165
+ value = Util.parse_positive_int(query_params[:limit])
166
+ options[:limit] = value unless value.nil?
167
+ end
168
+ end
169
+
170
+ def parse_order(options, query_params, rules)
171
+ # Sort is favored over rsort.
172
+
173
+ if query_params[:rsort]
174
+ raise ArgumentError, "No sort rule specified." if rules[:sort].nil?
175
+
176
+ sort_by = rules[:sort][query_params[:rsort]]
177
+ options[:order] = "#{sort_by} desc" unless sort_by.nil?
178
+ end
179
+
180
+ if query_params[:sort]
181
+ sort_by = rules[:sort][query_params[:sort]]
182
+ options[:order] = sort_by unless sort_by.nil?
183
+ end
184
+ end
185
+
186
+ def parse_conditional_rules(options, query_params, rules)
187
+ cond_strs = [ ]
188
+ cond_syms = { }
189
+
190
+ # The hash query_params is not empty, therefore, it contains at least
191
+ # some of the allowed query parameters (as Symbols) below. Those
192
+ # parameters that are not identified are ignored silently.
193
+
194
+ parse_since_and_until(cond_strs, cond_syms, query_params, rules)
195
+ parse_patterns(cond_strs, cond_syms, query_params, rules)
196
+
197
+ construct_conditions(options, cond_strs, cond_syms) unless cond_strs.empty?
198
+ end
199
+
200
+ def parse_since_and_until(cond_strs, cond_syms, query_params, rules)
201
+ if query_params[:since]
202
+ parse_datetime(cond_strs, cond_syms, query_params, rules, :since)
203
+ end
204
+
205
+ if query_params[:until]
206
+ parse_datetime(cond_strs, cond_syms, query_params, rules, :until)
207
+ end
208
+ end
209
+
210
+ def parse_datetime(cond_strs, cond_syms, query_params, rules, type)
211
+ rule = rules[type]
212
+ cast_type = searchable_record_settings["cast_#{type}_as".to_sym]
213
+
214
+ if rule.respond_to?(:to_hash)
215
+ column = rule[:column]
216
+
217
+ # Use custom cast type.
218
+ cast_type = rule[:cast_as] unless rule[:cast_as].nil?
219
+ else
220
+ column = rule
221
+ end
222
+
223
+ case type
224
+ when :since then op = ">="
225
+ when :until then op = "<="
226
+ else raise ArgumentError, "Could not determine comparison operator for datetime."
227
+ end
228
+
229
+ cond_strs << "(#{column} #{op} cast(:#{type} as #{cast_type}))"
230
+ cond_syms[type] = query_params[type]
231
+ end
232
+
233
+ def parse_patterns(cond_strs, cond_syms, query_params, rules)
234
+ if rules[:patterns]
235
+ rules[:patterns].each do |param, rule|
236
+ parse_pattern(cond_strs, cond_syms, query_params, param, rule)
237
+ end
238
+ end
239
+ end
240
+
241
+ def parse_pattern(cond_strs, cond_syms, query_params, param, rule)
242
+ if query_params[param]
243
+ match_op = searchable_record_settings[:pattern_operator]
244
+ conversion_blk = searchable_record_settings[:pattern_converter]
245
+
246
+ if rule.respond_to?(:to_hash)
247
+ column = rule[:column]
248
+
249
+ # Use custom pattern match operator.
250
+ match_op = rule[:operator] unless rule[:operator].nil?
251
+
252
+ # Use custom converter.
253
+ conversion_blk = rule[:converter] unless rule[:converter].nil?
254
+ else
255
+ column = rule
256
+ end
257
+
258
+ cond_strs << "(#{column} #{match_op} :#{param})"
259
+ cond_syms[param] = conversion_blk.call(query_params[param])
260
+ end
261
+ end
262
+
263
+ def construct_conditions(options, cond_strs, cond_syms)
264
+ conditions = [ cond_strs.join(" and "), cond_syms ]
265
+ preconditions = options[:conditions]
266
+ conditions[0].insert(0, "(#{preconditions}) and ") unless preconditions.nil?
267
+ options[:conditions] = conditions
268
+ end
269
+ end
270
+ end
@@ -6,7 +6,7 @@ module SearchableRecord
6
6
  dup_hash = { }
7
7
 
8
8
  preserved_keys.to_a.each do |key|
9
- if !hash[key.to_s].blank? # try to find first with 'key'; if that fails
9
+ if !hash[key.to_s].blank? # try to find first with "key"; if that fails
10
10
  dup_hash[key] = hash[key.to_s]
11
11
  elsif !hash[key].blank? # ...then with :key
12
12
  dup_hash[key] = hash[key]
@@ -0,0 +1,11 @@
1
+ module SearchableRecord
2
+ module Version #:nodoc:
3
+ MAJOR = 0
4
+ MINOR = 0
5
+ BUILD = 3
6
+
7
+ def self.to_s
8
+ [ MAJOR, MINOR, BUILD ].join('.')
9
+ end
10
+ end
11
+ end