searchable_record 0.0.2 → 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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