searchable_record 0.0.2
Sign up to get free protection for your applications and to get access to all the features.
- data/History.txt +7 -0
- data/MIT-LICENSE.txt +19 -0
- data/Manifest.txt +10 -0
- data/README.txt +128 -0
- data/Rakefile +35 -0
- data/lib/searchable_record.rb +267 -0
- data/lib/util.rb +28 -0
- data/spec/searchable_record_spec.rb +301 -0
- data/spec/searchable_record_spec_helper.rb +8 -0
- data/spec/util_spec.rb +32 -0
- metadata +85 -0
data/History.txt
ADDED
data/MIT-LICENSE.txt
ADDED
@@ -0,0 +1,19 @@
|
|
1
|
+
Copyright (c) 2008 Tuomas Kareinen
|
2
|
+
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
4
|
+
of this software and associated documentation files (the "Software"), to
|
5
|
+
deal in the Software without restriction, including without limitation the
|
6
|
+
rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
|
7
|
+
sell copies of the Software, and to permit persons to whom the Software is
|
8
|
+
furnished to do so, subject to the following conditions:
|
9
|
+
|
10
|
+
The above copyright notice and this permission notice shall be included in
|
11
|
+
all copies or substantial portions of the Software.
|
12
|
+
|
13
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
14
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
15
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
16
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
17
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
18
|
+
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
|
19
|
+
IN THE SOFTWARE.
|
data/Manifest.txt
ADDED
data/README.txt
ADDED
@@ -0,0 +1,128 @@
|
|
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
|
+
ActiveRecord::Base#find: it parses query parameters against the given rules
|
13
|
+
and calls <tt>find</tt> accordingly, returning the results of <tt>find</tt>.
|
14
|
+
|
15
|
+
== A usage example
|
16
|
+
|
17
|
+
The following example, although a bit contrived, allows the client to
|
18
|
+
|
19
|
+
* limit the number of items as the result of the search
|
20
|
+
(<tt>limit</tt> parameter),
|
21
|
+
* set an offset for the items (<tt>offset</tt> parameter, intended to be
|
22
|
+
used together with <tt>limit</tt>),
|
23
|
+
* sort the items either in ascending (<tt>sort</tt> parameter) or
|
24
|
+
descending (<tt>rsort</tt> parameter) order by items' type and name,
|
25
|
+
* to limit the result by matching only items that were update before
|
26
|
+
(<tt>until</tt> parameter) or after (<tt>since</tt> parameter) a certain
|
27
|
+
date, and
|
28
|
+
* to limit the result by matching only items with certain kind of
|
29
|
+
types (<tt>type</tt> parameter) or names (<tt>name</tt> parameter), or
|
30
|
+
both (for a name, a conversion to the client supplied parameter must be
|
31
|
+
applied before matching the name in the database).
|
32
|
+
|
33
|
+
|
34
|
+
These requirements for the query parameters are expressed as the following
|
35
|
+
rules:
|
36
|
+
|
37
|
+
rules = {
|
38
|
+
:limit => nil, # key as a flag; the value for the key is not used
|
39
|
+
:offset => nil, # key as a flag
|
40
|
+
:sort => { 'name' => 'items.name', 'created' => 'items.created_at' },
|
41
|
+
:rsort => nil, # rsort is allowed according to rules in :sort (key as a flag)
|
42
|
+
:since => 'items.created_at', # cast parameter value as the default type
|
43
|
+
:until => 'items.created_at', # cast parameter value as the default type
|
44
|
+
:patterns => { :type => 'items.type', # match the pattern with the default operator and converter
|
45
|
+
:name => { :column => 'items.name',
|
46
|
+
:converter => lambda { |val| "%#{val.gsub('_', '.')}%" } } }
|
47
|
+
# match the pattern with the default operator
|
48
|
+
}
|
49
|
+
|
50
|
+
The client uses the URL
|
51
|
+
<tt>http://example-site.org/items?limit=5&offset=4&rsort=name&since=2008-02-28&name=foo_bar</tt>
|
52
|
+
to fetch a representation of the resource containing the items. The action
|
53
|
+
results to the following parameters:
|
54
|
+
|
55
|
+
# => query_params = {
|
56
|
+
# 'offset' => '4',
|
57
|
+
# 'limit' => '5',
|
58
|
+
# 'rsort' => 'name',
|
59
|
+
# 'until' => '2008-02-28',
|
60
|
+
# 'name' => 'foo_bar',
|
61
|
+
# ...
|
62
|
+
# # plus Rails-specific parameters, such as 'action' and 'controller'
|
63
|
+
# }
|
64
|
+
|
65
|
+
In addition, the application happens to require some options to be passed to
|
66
|
+
<tt>find</tt>:
|
67
|
+
|
68
|
+
options = {
|
69
|
+
:include => [ :owners ],
|
70
|
+
:conditions => "items.flag = 'f'"
|
71
|
+
}
|
72
|
+
|
73
|
+
When <tt>find_queried</tt> is called, with
|
74
|
+
|
75
|
+
Item.find_queried(:all, query_params, rules, options)
|
76
|
+
|
77
|
+
the result is the following call to <tt>find</tt>.
|
78
|
+
|
79
|
+
Item.find(:all,
|
80
|
+
:include => [ :owners ],
|
81
|
+
:order => 'items.name desc',
|
82
|
+
:offset => 4,
|
83
|
+
:limit => 5,
|
84
|
+
:conditions => [ "(items.flag = 'f') and (items.created_at <= cast(:until as datetime)) and (items.name like :name)",
|
85
|
+
{ :until => '2008-02-28', :name => '%foo.bar%' } ])
|
86
|
+
|
87
|
+
The search result for <tt>find</tt> contains at most 5 items that are
|
88
|
+
|
89
|
+
* from offset 4 (that is, items from positions 5 to 9),
|
90
|
+
* sorted in descending order by items' names,
|
91
|
+
* updated since 2008-02-28, and
|
92
|
+
* have <tt>foo.bar</tt> in their name.
|
93
|
+
|
94
|
+
See +find_queried+ method in SearchableRecord::ClassMethods for usage
|
95
|
+
documentation.
|
96
|
+
|
97
|
+
== Installation
|
98
|
+
|
99
|
+
In order to install the plugin as a Ruby gem for a Rails application,
|
100
|
+
edit the <tt>environment.rb</tt> file of the application to contain the
|
101
|
+
following line:
|
102
|
+
|
103
|
+
config.gem "searchable_record"
|
104
|
+
|
105
|
+
(This requires Rails version 2.1 or above.)
|
106
|
+
|
107
|
+
Then install the gem, either using the Rakefile of the Rails application:
|
108
|
+
|
109
|
+
rake gems:install
|
110
|
+
|
111
|
+
...or with the <tt>gem</tt> tool:
|
112
|
+
|
113
|
+
gem install searchable_record
|
114
|
+
|
115
|
+
Use git to get the source code for modifications and hacks:
|
116
|
+
|
117
|
+
git clone git://gitorious.org/searchable-rec/mainline.git
|
118
|
+
|
119
|
+
== Contacting
|
120
|
+
|
121
|
+
Please send comments, suggestions, bugs, or patches by email to Tuomas
|
122
|
+
Kareinen < tkareine (at) gmail (dot) com >.
|
123
|
+
|
124
|
+
== Legal note
|
125
|
+
|
126
|
+
Copyright (c) 2008 Tuomas Kareinen.
|
127
|
+
|
128
|
+
SearchableRecord plugin is licensed under the MIT license.
|
data/Rakefile
ADDED
@@ -0,0 +1,35 @@
|
|
1
|
+
require 'rubygems'
|
2
|
+
require 'hoe'
|
3
|
+
require 'spec/rake/spectask'
|
4
|
+
|
5
|
+
$LOAD_PATH.unshift(File.dirname(__FILE__) + "/lib")
|
6
|
+
|
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
|
12
|
+
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")
|
18
|
+
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']
|
23
|
+
end
|
24
|
+
|
25
|
+
desc "Run specs."
|
26
|
+
Spec::Rake::SpecTask.new('spec') do |t|
|
27
|
+
t.spec_files = FileList['spec/**/*.rb']
|
28
|
+
t.spec_opts = ["--format", "specdoc"]
|
29
|
+
#t.warning = true
|
30
|
+
end
|
31
|
+
|
32
|
+
desc "Search unfinished parts of source code."
|
33
|
+
task :todo do
|
34
|
+
FileList['**/*.rb'].egrep /#.*(TODO|FIXME)/
|
35
|
+
end
|
@@ -0,0 +1,267 @@
|
|
1
|
+
unless defined? ActiveSupport
|
2
|
+
require 'rubygems'
|
3
|
+
gem 'activesupport'
|
4
|
+
require 'active_support'
|
5
|
+
end
|
6
|
+
require 'util'
|
7
|
+
|
8
|
+
# See SearchableRecord::ClassMethods#find_queried for usage documentation.
|
9
|
+
module SearchableRecord
|
10
|
+
module Meta #:nodoc:
|
11
|
+
module VERSION #:nodoc:
|
12
|
+
MAJOR = 0
|
13
|
+
MINOR = 0
|
14
|
+
BUILD = 2
|
15
|
+
|
16
|
+
def self.to_s
|
17
|
+
[ MAJOR, MINOR, BUILD ].join('.')
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
def self.included(base_class) #:nodoc:
|
23
|
+
base_class.class_eval do
|
24
|
+
extend ClassMethods
|
25
|
+
|
26
|
+
@@searchable_record_settings = {
|
27
|
+
:cast_since_as => 'datetime',
|
28
|
+
:cast_until_as => 'datetime',
|
29
|
+
:pattern_operator => 'like',
|
30
|
+
:pattern_converter => lambda { |val| "%#{val}%" }
|
31
|
+
}
|
32
|
+
|
33
|
+
cattr_accessor :searchable_record_settings
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
private
|
38
|
+
|
39
|
+
module ClassMethods
|
40
|
+
# === Description
|
41
|
+
#
|
42
|
+
# Parses the query parameters the client has given in the URL of the HTTP
|
43
|
+
# request. With the query parameters, the client may set a limit, an
|
44
|
+
# offset, or an ordering to the items in the search result. In addition,
|
45
|
+
# the client may limit the output by allowing only certain records that
|
46
|
+
# match to specific patterns.
|
47
|
+
#
|
48
|
+
# What the client user is allowed to query is defined by specific rules
|
49
|
+
# passed to the method as a Hash argument. Query parameters that are not
|
50
|
+
# explicitly stated in the rules are silently discarded.
|
51
|
+
#
|
52
|
+
# Essentially, the method is a frontend for
|
53
|
+
# ActiveRecord::Base#find. The method
|
54
|
+
#
|
55
|
+
# 1. parses the query parameters the client has given in the URL for the
|
56
|
+
# HTTP request (+query_params+) against the rules (+rules+), and
|
57
|
+
# 2. calls <tt>find</tt> with the parsed options.
|
58
|
+
#
|
59
|
+
# ==== Parsing rules
|
60
|
+
#
|
61
|
+
# The parsing rules must be given as a Hash; the keys in the hash indicate
|
62
|
+
# the parameters that are allowed. The recognized keys are the following:
|
63
|
+
#
|
64
|
+
# * <tt>:limit</tt>, which uses nil as the value (the same effect as with
|
65
|
+
# <tt>find</tt>).
|
66
|
+
# * <tt>:offset</tt>, which uses nil as the value (the same effect as with
|
67
|
+
# <tt>find</tt>).
|
68
|
+
# * <tt>:sort</tt>, which determines the ordering. The value is a Hash of
|
69
|
+
# <tt>'parameter value' => 'internal table column'</tt> pairs (the same
|
70
|
+
# effect as with the <tt>:order</tt> option of
|
71
|
+
# <tt>find</tt>);
|
72
|
+
# * <tt>:rsort</tt>, for reverse sort. Uses the rules of +:sort+; thus,
|
73
|
+
# use <tt>nil</tt> as the value.
|
74
|
+
# * <tt>:since</tt>, which sets a lower timedate limit. The value is
|
75
|
+
# either a string naming the database table column that has timestamps
|
76
|
+
# (using the type from default settings' <tt>:cast_since_as</tt> entry)
|
77
|
+
# or a Hash that contains entries <tt>:column => 'table.column'</tt> and
|
78
|
+
# <tt>:cast_as => '<sql_timedate_type>'</tt>.
|
79
|
+
# * <tt>:until</tt>, which sets an upper timedate limit. Used like
|
80
|
+
# <tt>:since</tt>.
|
81
|
+
# * <tt>:patterns</tt>, where the value is a Hash containing patterns. The
|
82
|
+
# keys in the Hash correspond to additional query parameters and the
|
83
|
+
# corresponding values to database table columns. For each pattern,
|
84
|
+
# the value is either directly a string, or a Hash containing the
|
85
|
+
# entry <tt>:column => 'table.column'</tt>. In addition, the Hash may
|
86
|
+
# contain optional entries
|
87
|
+
# <tt>:converter => lambda { |val| <conversion_operation_for_val> }</tt>
|
88
|
+
# and <tt>:operator => '<sql_pattern_operator>'</tt>.
|
89
|
+
# <tt>:converter</tt> expects a block that modifies the input value; if
|
90
|
+
# the key is not used, the converter specified in
|
91
|
+
# <tt>:pattern_converter</tt> in the default settings is used.
|
92
|
+
# <tt>:pattern_operator</tt> specifies a custom match operator for the
|
93
|
+
# pattern; if the key is not used, the operator specified in
|
94
|
+
# <tt>:pattern_operator</tt> in default settings is used.
|
95
|
+
#
|
96
|
+
# If both +sort+ and +rsort+ parameters are given in the URL and both are
|
97
|
+
# allowed by the rules, +sort+ is favored over +rsort+. Unlike with +sort+
|
98
|
+
# and +rsort+ rules (+rsort+ uses the rules of +sort+), the rules for
|
99
|
+
# +since+ and +until+ are independent from each other.
|
100
|
+
#
|
101
|
+
# For usage examples, see the example in README and the unit tests that
|
102
|
+
# come with the plugin.
|
103
|
+
#
|
104
|
+
# ==== Default settings for rules
|
105
|
+
#
|
106
|
+
# The default settings for the rules are accessible and modifiable by
|
107
|
+
# calling the method +searchable_record_settings+. The settings are
|
108
|
+
# stored as a Hash; the following keys are recognized:
|
109
|
+
#
|
110
|
+
# * <tt>:cast_since_as</tt>,
|
111
|
+
# * <tt>:cast_until_as</tt>,
|
112
|
+
# * <tt>:pattern_operator</tt>, and
|
113
|
+
# * <tt>:pattern_converter</tt>.
|
114
|
+
#
|
115
|
+
# See the parsing rules above how the default settings are used.
|
116
|
+
#
|
117
|
+
# === Arguments
|
118
|
+
#
|
119
|
+
# +extend+:: The same as the first argument to <tt>find</tt> (such as <tt>:all</tt>).
|
120
|
+
# +query_params+:: The (unsafe) query parameters from the URL.
|
121
|
+
# +rules+:: The parsing rules as a Hash.
|
122
|
+
# +options+:: Additional options for <tt>find</tt>, such as <tt>:include => [ :an_association ]</tt>.
|
123
|
+
#
|
124
|
+
# === Return
|
125
|
+
#
|
126
|
+
# The same as with ActiveRecord::Base#find.
|
127
|
+
def find_queried(extend, query_params, rules, options = { })
|
128
|
+
query_params = preserve_allowed_query_params(query_params, rules)
|
129
|
+
|
130
|
+
unless query_params.empty?
|
131
|
+
parse_offset(options, query_params)
|
132
|
+
parse_limit(options, query_params)
|
133
|
+
parse_order(options, query_params, rules)
|
134
|
+
parse_conditions(options, query_params, rules)
|
135
|
+
end
|
136
|
+
|
137
|
+
logger.debug("find_queried: query_params=<<#{query_params.inspect}>>, resulted options=<<#{options.inspect}>>")
|
138
|
+
|
139
|
+
self.find(extend, options)
|
140
|
+
end
|
141
|
+
|
142
|
+
private
|
143
|
+
|
144
|
+
def preserve_allowed_query_params(query_params, rules)
|
145
|
+
allowed_keys = rules.keys
|
146
|
+
|
147
|
+
# Add pattern matching parameters to the list of allowed keys.
|
148
|
+
allowed_keys.delete(:patterns)
|
149
|
+
if rules[:patterns]
|
150
|
+
allowed_keys += rules[:patterns].keys
|
151
|
+
end
|
152
|
+
|
153
|
+
# Do not affect the passed query parameters.
|
154
|
+
Util.pruned_dup(query_params, allowed_keys)
|
155
|
+
end
|
156
|
+
|
157
|
+
def parse_offset(options, query_params)
|
158
|
+
if query_params[:offset]
|
159
|
+
value = Util.parse_positive_int(query_params[:offset])
|
160
|
+
options[:offset] = value unless value.nil?
|
161
|
+
end
|
162
|
+
end
|
163
|
+
|
164
|
+
def parse_limit(options, query_params)
|
165
|
+
if query_params[:limit]
|
166
|
+
value = Util.parse_positive_int(query_params[:limit])
|
167
|
+
options[:limit] = value unless value.nil?
|
168
|
+
end
|
169
|
+
end
|
170
|
+
|
171
|
+
def parse_order(options, query_params, rules)
|
172
|
+
# Sort is favored over rsort.
|
173
|
+
|
174
|
+
if query_params[:rsort]
|
175
|
+
raise ArgumentError, "No sort rule specified." if rules[:sort].nil?
|
176
|
+
|
177
|
+
sort_by = rules[:sort][query_params[:rsort]]
|
178
|
+
options[:order] = sort_by + ' desc' unless sort_by.nil?
|
179
|
+
end
|
180
|
+
|
181
|
+
if query_params[:sort]
|
182
|
+
sort_by = rules[:sort][query_params[:sort]]
|
183
|
+
options[:order] = sort_by unless sort_by.nil?
|
184
|
+
end
|
185
|
+
end
|
186
|
+
|
187
|
+
def parse_conditions(options, query_params, rules)
|
188
|
+
cond_strs = [ ]
|
189
|
+
cond_syms = { }
|
190
|
+
|
191
|
+
# The hash query_params is not empty, therefore, it contains at least
|
192
|
+
# some of the allowed query parameters (as Symbols) below. Those
|
193
|
+
# parameters that are not identified are ignored silently.
|
194
|
+
|
195
|
+
parse_since_until(cond_strs, cond_syms, query_params, rules)
|
196
|
+
parse_patterns(cond_strs, cond_syms, query_params, rules)
|
197
|
+
|
198
|
+
construct_conditions(options, cond_strs, cond_syms) unless cond_strs.empty?
|
199
|
+
end
|
200
|
+
|
201
|
+
def parse_since_until(cond_strs, cond_syms, query_params, rules)
|
202
|
+
if query_params[:since]
|
203
|
+
parse_datetime(cond_strs, cond_syms, query_params, rules, :since)
|
204
|
+
end
|
205
|
+
|
206
|
+
if query_params[:until]
|
207
|
+
parse_datetime(cond_strs, cond_syms, query_params, rules, :until)
|
208
|
+
end
|
209
|
+
end
|
210
|
+
|
211
|
+
def parse_patterns(cond_strs, cond_syms, query_params, rules)
|
212
|
+
if rules[:patterns]
|
213
|
+
rules[:patterns].each do |param, rule|
|
214
|
+
if query_params[param]
|
215
|
+
match_op = searchable_record_settings[:pattern_operator]
|
216
|
+
conversion_blk = searchable_record_settings[:pattern_converter]
|
217
|
+
|
218
|
+
if rule.respond_to?(:to_hash)
|
219
|
+
column = rule[:column]
|
220
|
+
|
221
|
+
# Use custom pattern match operator.
|
222
|
+
match_op = rule[:operator] unless rule[:operator].nil?
|
223
|
+
|
224
|
+
# Use custom converter.
|
225
|
+
conversion_blk = rule[:converter] unless rule[:converter].nil?
|
226
|
+
else
|
227
|
+
column = rule
|
228
|
+
end
|
229
|
+
|
230
|
+
cond_strs << "(#{column} #{match_op} :#{param})"
|
231
|
+
cond_syms[param] = conversion_blk.call(query_params[param])
|
232
|
+
end
|
233
|
+
end
|
234
|
+
end
|
235
|
+
end
|
236
|
+
|
237
|
+
def parse_datetime(cond_strs, cond_syms, query_params, rules, type)
|
238
|
+
rule = rules[type]
|
239
|
+
cast_type = searchable_record_settings["cast_#{type}_as".to_sym]
|
240
|
+
|
241
|
+
if rule.respond_to?(:to_hash)
|
242
|
+
column = rule[:column]
|
243
|
+
|
244
|
+
# Use custom cast type.
|
245
|
+
cast_type = rule[:cast_as] unless rule[:cast_as].nil?
|
246
|
+
else
|
247
|
+
column = rule
|
248
|
+
end
|
249
|
+
|
250
|
+
case type
|
251
|
+
when :since then op = '>='
|
252
|
+
when :until then op = '<='
|
253
|
+
else raise ArgumentError, "Could not determine comparison operator for datetime."
|
254
|
+
end
|
255
|
+
|
256
|
+
cond_strs << "(#{column} #{op} cast(:#{type} as #{cast_type}))"
|
257
|
+
cond_syms[type] = query_params[type]
|
258
|
+
end
|
259
|
+
|
260
|
+
def construct_conditions(options, cond_strs, cond_syms)
|
261
|
+
conditions = [ cond_strs.join(' and '), cond_syms ]
|
262
|
+
preconditions = options[:conditions]
|
263
|
+
conditions[0].insert(0, "(#{preconditions}) and ") unless preconditions.nil?
|
264
|
+
options[:conditions] = conditions
|
265
|
+
end
|
266
|
+
end
|
267
|
+
end
|
data/lib/util.rb
ADDED
@@ -0,0 +1,28 @@
|
|
1
|
+
module SearchableRecord
|
2
|
+
module Util #:nodoc:
|
3
|
+
def self.pruned_dup(hash, preserved_keys)
|
4
|
+
hash = hash.to_hash
|
5
|
+
|
6
|
+
dup_hash = { }
|
7
|
+
|
8
|
+
preserved_keys.to_a.each do |key|
|
9
|
+
if !hash[key.to_s].blank? # try to find first with 'key'; if that fails
|
10
|
+
dup_hash[key] = hash[key.to_s]
|
11
|
+
elsif !hash[key].blank? # ...then with :key
|
12
|
+
dup_hash[key] = hash[key]
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
return dup_hash
|
17
|
+
end
|
18
|
+
|
19
|
+
def self.parse_positive_int(str)
|
20
|
+
integer = str.to_i
|
21
|
+
if integer > 0 # nil.to_i == 0
|
22
|
+
return integer
|
23
|
+
else
|
24
|
+
return nil
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
@@ -0,0 +1,301 @@
|
|
1
|
+
require File.join(File.dirname(__FILE__) + '/searchable_record_spec_helper')
|
2
|
+
require 'logger'
|
3
|
+
require 'searchable_record'
|
4
|
+
|
5
|
+
class Record; include SearchableRecord; end
|
6
|
+
|
7
|
+
describe SearchableRecord, "for finding queried records" do
|
8
|
+
before(:each) do
|
9
|
+
Record.stubs(:logger).returns(stub(:debug))
|
10
|
+
#Record.stubs(:logger).returns(Logger.new(STDOUT))
|
11
|
+
end
|
12
|
+
|
13
|
+
it "should be able to modify default settings" do
|
14
|
+
new_settings = {
|
15
|
+
:cast_since_as => 'time',
|
16
|
+
:cast_until_as => 'date',
|
17
|
+
:pattern_operator => 'regexp',
|
18
|
+
:pattern_converter => lambda { |val| val }
|
19
|
+
}
|
20
|
+
|
21
|
+
org_settings = Record.searchable_record_settings.dup
|
22
|
+
|
23
|
+
Record.searchable_record_settings = new_settings
|
24
|
+
Record.searchable_record_settings.should == new_settings
|
25
|
+
|
26
|
+
Record.searchable_record_settings = org_settings
|
27
|
+
Record.searchable_record_settings.should == org_settings
|
28
|
+
end
|
29
|
+
|
30
|
+
it "should execute find with no parameters, discarding unrecognized parameters" do
|
31
|
+
Record.expects(:find).times(2).with(:all, { })
|
32
|
+
|
33
|
+
Record.find_queried(:all, { }, { })
|
34
|
+
Record.find_queried(:all, { :foo => 'bar' }, { })
|
35
|
+
end
|
36
|
+
|
37
|
+
it "should execute find with a positive offset parameter" do
|
38
|
+
Record.expects(:find).times(1).with(:all, { :offset => 1 })
|
39
|
+
|
40
|
+
Record.find_queried(:all, { :offset => '1' }, { :offset => nil })
|
41
|
+
|
42
|
+
Record.expects(:find).times(2).with(:all, { :offset => 4 })
|
43
|
+
|
44
|
+
Record.find_queried(:all, { :offset => '4' }, { :offset => nil })
|
45
|
+
Record.find_queried(:all, { :offset => '4', :limit => '3' }, { :offset => nil })
|
46
|
+
end
|
47
|
+
|
48
|
+
it "should discard other than positive offset parameters" do
|
49
|
+
Record.expects(:find).times(4).with(:all, { })
|
50
|
+
|
51
|
+
Record.find_queried(:all, { }, { :offset => nil })
|
52
|
+
Record.find_queried(:all, { :offset => 'aii' }, { :offset => nil })
|
53
|
+
Record.find_queried(:all, { :offset => '0' }, { :offset => nil })
|
54
|
+
Record.find_queried(:all, { :offset => '-1' }, { :offset => nil })
|
55
|
+
end
|
56
|
+
|
57
|
+
it "should execute find with a positive limit parameter" do
|
58
|
+
Record.expects(:find).times(1).with(:all, { :limit => 1 })
|
59
|
+
|
60
|
+
Record.find_queried(:all, { :limit => '1' }, { :limit => nil })
|
61
|
+
|
62
|
+
Record.expects(:find).times(2).with(:all, { :limit => 4 })
|
63
|
+
|
64
|
+
Record.find_queried(:all, { :limit => '4' }, { :limit => nil })
|
65
|
+
Record.find_queried(:all, { :limit => '4', :offset => '3' }, { :limit => nil })
|
66
|
+
end
|
67
|
+
|
68
|
+
it "should discard other than positive limit parameters" do
|
69
|
+
Record.expects(:find).times(4).with(:all, { })
|
70
|
+
|
71
|
+
Record.find_queried(:all, { }, { :limit => nil })
|
72
|
+
Record.find_queried(:all, { :limit => 'aii' }, { :limit => nil })
|
73
|
+
Record.find_queried(:all, { :limit => '0' }, { :limit => nil })
|
74
|
+
Record.find_queried(:all, { :limit => '-1' }, { :limit => nil })
|
75
|
+
end
|
76
|
+
|
77
|
+
it "should execute find with sort parameter" do
|
78
|
+
Record.expects(:find).times(1).with(:all, { :order => 'users.first_name' })
|
79
|
+
|
80
|
+
Record.find_queried(:all, { :sort => 'first_name' },
|
81
|
+
{ :sort => { 'first_name' => 'users.first_name' } })
|
82
|
+
end
|
83
|
+
|
84
|
+
it "should execute find with reverse sort parameter" do
|
85
|
+
Record.expects(:find).times(1).with(:all, { :order => 'users.first_name desc' })
|
86
|
+
|
87
|
+
Record.find_queried(:all, { :rsort => 'first_name' },
|
88
|
+
{ :sort => { 'first_name' => 'users.first_name' },
|
89
|
+
:rsort => nil })
|
90
|
+
end
|
91
|
+
|
92
|
+
it "should raise an exception if rsort is specified without sort rule" do
|
93
|
+
lambda { Record.find_queried(:all, { :rsort => 'first_name' },
|
94
|
+
{ :rsort => { 'first_name' => 'users.first_name' } }) }.should raise_error(ArgumentError)
|
95
|
+
end
|
96
|
+
|
97
|
+
it "should execute find with sort parameter, favoring 'sort' over 'rsort'" do
|
98
|
+
Record.expects(:find).times(1).with(:all, { :order => 'users.first_name' })
|
99
|
+
|
100
|
+
Record.find_queried(:all, { :sort => 'first_name', :rsort => 'last_name' },
|
101
|
+
{ :sort => { 'first_name' => 'users.first_name',
|
102
|
+
'last_name' => 'users.last_name' },
|
103
|
+
:rsort => nil })
|
104
|
+
end
|
105
|
+
|
106
|
+
it "should discard other than specified sort parameters" do
|
107
|
+
Record.expects(:find).times(1).with(:all, { })
|
108
|
+
|
109
|
+
Record.find_queried(:all, { :sort => 'first' },
|
110
|
+
{ :sort => { 'first_name' => 'users.first_name' } })
|
111
|
+
|
112
|
+
Record.expects(:find).times(1).with(:all, { :order => 'users.last_name desc' })
|
113
|
+
|
114
|
+
Record.find_queried(:all, { :sort => 'last', :rsort => 'last_name' },
|
115
|
+
{ :sort => { 'first_name' => 'users.first_name',
|
116
|
+
'last_name' => 'users.last_name' },
|
117
|
+
:rsort => nil })
|
118
|
+
end
|
119
|
+
|
120
|
+
it "should execute find with since parameter, with default settings" do
|
121
|
+
Record.expects(:find).times(2).with(:all, { :conditions => [ '(users.created_at >= cast(:since as datetime))',
|
122
|
+
{ :since => '2008-02-26' } ] })
|
123
|
+
|
124
|
+
Record.find_queried(:all, { :since => '2008-02-26' },
|
125
|
+
{ :since => 'users.created_at' })
|
126
|
+
Record.find_queried(:all, { :since => '2008-02-26' },
|
127
|
+
{ :since => { :column => 'users.created_at' } })
|
128
|
+
end
|
129
|
+
|
130
|
+
it "should execute find with since parameter, with custom settings" do
|
131
|
+
Record.expects(:find).times(3).with(:all, { :conditions => [ '(users.time >= cast(:since as time))',
|
132
|
+
{ :since => '11:04' } ] })
|
133
|
+
|
134
|
+
Record.find_queried(:all, { :since => '11:04' },
|
135
|
+
{ :since => { :column => 'users.time',
|
136
|
+
:cast_as => 'time' } })
|
137
|
+
|
138
|
+
org_settings = Record.searchable_record_settings.dup
|
139
|
+
Record.searchable_record_settings[:cast_since_as] = 'time'
|
140
|
+
|
141
|
+
Record.find_queried(:all, { :since => '11:04' },
|
142
|
+
{ :since => 'users.time'})
|
143
|
+
|
144
|
+
Record.find_queried(:all, { :since => '11:04' },
|
145
|
+
{ :since => { :column => 'users.time' } })
|
146
|
+
|
147
|
+
Record.searchable_record_settings = org_settings
|
148
|
+
end
|
149
|
+
|
150
|
+
it "should execute find with until parameter, with default settings" do
|
151
|
+
Record.expects(:find).times(2).with(:all, { :conditions => [ '(users.created_at <= cast(:until as datetime))',
|
152
|
+
{ :until => '2008-04-28' } ] })
|
153
|
+
|
154
|
+
Record.find_queried(:all, { :until => '2008-04-28' },
|
155
|
+
{ :until => 'users.created_at' })
|
156
|
+
Record.find_queried(:all, { :until => '2008-04-28' },
|
157
|
+
{ :until => { :column => 'users.created_at' } })
|
158
|
+
end
|
159
|
+
|
160
|
+
it "should execute find with until parameter, with custom settings" do
|
161
|
+
Record.expects(:find).times(3).with(:all, { :conditions => [ '(users.time <= cast(:until as time))',
|
162
|
+
{ :until => '13:06' } ] })
|
163
|
+
|
164
|
+
Record.find_queried(:all, { :until => '13:06' },
|
165
|
+
{ :until => { :column => 'users.time',
|
166
|
+
:cast_as => 'time' } })
|
167
|
+
|
168
|
+
org_settings = Record.searchable_record_settings.dup
|
169
|
+
Record.searchable_record_settings[:cast_until_as] = 'time'
|
170
|
+
|
171
|
+
Record.find_queried(:all, { :until => '13:06' },
|
172
|
+
{ :until => 'users.time'})
|
173
|
+
|
174
|
+
Record.find_queried(:all, { :until => '13:06' },
|
175
|
+
{ :until => { :column => 'users.time' } })
|
176
|
+
|
177
|
+
Record.searchable_record_settings = org_settings
|
178
|
+
end
|
179
|
+
|
180
|
+
it "should execute find with pattern matching parameters" do
|
181
|
+
Record.expects(:find).times(1).with(:all, :conditions => [ "(users.first_name like :name)",
|
182
|
+
{ :name => '%john%' } ])
|
183
|
+
|
184
|
+
Record.find_queried(:all, { :name => 'john' },
|
185
|
+
{ :patterns => { :name => 'users.first_name'} })
|
186
|
+
end
|
187
|
+
|
188
|
+
it "should execute find with a pattern matching parameter, with default settings" do
|
189
|
+
Record.expects(:find).times(1).with(:all, :conditions => [ "(sites.status like :status)",
|
190
|
+
{ :status => '%active%' } ])
|
191
|
+
|
192
|
+
Record.find_queried(:all, { :status => 'active' },
|
193
|
+
{ :patterns => { :status => 'sites.status' } })
|
194
|
+
end
|
195
|
+
|
196
|
+
it "should execute find with a pattern matching parameter, with custom settings" do
|
197
|
+
Record.expects(:find).times(1).with(:all, :conditions => [ "(sites.domain like :domain)",
|
198
|
+
{ :domain => '%www.example.fi%' } ])
|
199
|
+
|
200
|
+
Record.find_queried(:all, { :domain => 'www_example_fi' },
|
201
|
+
{ :patterns => { :domain => { :column => 'sites.domain',
|
202
|
+
:converter => lambda { |val| "%#{val.gsub('_', '.')}%" } } } })
|
203
|
+
|
204
|
+
Record.expects(:find).times(2).with(:all, :conditions => [ "(sites.domain regexp :domain)",
|
205
|
+
{ :domain => 'www.example.fi' } ])
|
206
|
+
|
207
|
+
Record.find_queried(:all, { :domain => 'www_example_fi' },
|
208
|
+
{ :patterns => { :domain => { :column => 'sites.domain',
|
209
|
+
:converter => lambda { |val| val.gsub('_', '.') },
|
210
|
+
:operator => 'regexp' } } })
|
211
|
+
|
212
|
+
org_settings = Record.searchable_record_settings.dup
|
213
|
+
Record.searchable_record_settings[:pattern_operator] = 'regexp'
|
214
|
+
Record.searchable_record_settings[:pattern_converter] = lambda { |val| val.gsub('_', '.') }
|
215
|
+
|
216
|
+
Record.find_queried(:all, { :domain => 'www_example_fi' },
|
217
|
+
{ :patterns => { :domain => 'sites.domain' } })
|
218
|
+
|
219
|
+
Record.searchable_record_settings = org_settings
|
220
|
+
end
|
221
|
+
|
222
|
+
it "should execute find with multiple pattern matching parameters" do
|
223
|
+
results = [
|
224
|
+
{ :conditions => [ "(sites.domain like :domain) and (sites.status like :status)",
|
225
|
+
{ :domain => '%www.another.example.fi%',
|
226
|
+
:status => '%active%' } ]},
|
227
|
+
{ :conditions => [ "(sites.status like :status) and (sites.domain like :domain)",
|
228
|
+
{ :domain => '%www.another.example.fi%',
|
229
|
+
:status => '%active%' } ]}
|
230
|
+
]
|
231
|
+
|
232
|
+
Record.expects(:find).times(1).with(:all, any_of(equals(results[0]), equals(results[1])))
|
233
|
+
|
234
|
+
Record.find_queried(:all, { :domain => 'www_another_example_fi',
|
235
|
+
:status => 'active' },
|
236
|
+
{ :patterns => { :domain => { :column => 'sites.domain',
|
237
|
+
:converter => lambda { |val| "%#{val.gsub('_', '.')}%" } },
|
238
|
+
:status => 'sites.status' } })
|
239
|
+
end
|
240
|
+
|
241
|
+
it "should preserve additional options" do
|
242
|
+
Record.expects(:find).times(1).with(:all, :include => [ :affiliates ],
|
243
|
+
:conditions => [ "(sites.flags = 'fo') and (sites.domain like :domain)",
|
244
|
+
{ :domain => '%www.still-works.com%' } ])
|
245
|
+
|
246
|
+
Record.find_queried(:all, { :domain => 'www_still-works_com' },
|
247
|
+
{ :patterns => { :domain => { :column => 'sites.domain',
|
248
|
+
:converter => lambda { |val| "%#{val.gsub('_', '.')}%" } } } },
|
249
|
+
{ :conditions => "sites.flags = 'fo'",
|
250
|
+
:include => [ :affiliates ] })
|
251
|
+
|
252
|
+
Record.expects(:find).times(1).with(:all, :include => [ :affiliates ],
|
253
|
+
:conditions => [ "(sites.flags = 'fo') and (users.time <= cast(:until as time))",
|
254
|
+
{ :until => '13:06' } ])
|
255
|
+
|
256
|
+
Record.find_queried(:all, { :until => '13:06' },
|
257
|
+
{ :until => { :column => 'users.time',
|
258
|
+
:cast_as => 'time' } },
|
259
|
+
{ :conditions => "sites.flags = 'fo'",
|
260
|
+
:include => [ :affiliates ] })
|
261
|
+
end
|
262
|
+
|
263
|
+
it "should work with all rules combined" do
|
264
|
+
Item = Record
|
265
|
+
|
266
|
+
Item.expects(:find).times(1).with(:all,
|
267
|
+
:include => [ :owners ],
|
268
|
+
:order => 'items.name desc',
|
269
|
+
:offset => 4,
|
270
|
+
:limit => 5,
|
271
|
+
:conditions => [ "(items.flag = 'f') and (items.created_at <= cast(:until as datetime)) and (items.name like :name)",
|
272
|
+
{ :until => '2008-02-28', :name => '%foo.bar%' } ])
|
273
|
+
|
274
|
+
query_params = {
|
275
|
+
:offset => '4',
|
276
|
+
:limit => '5',
|
277
|
+
:rsort => 'name',
|
278
|
+
:until => '2008-02-28',
|
279
|
+
:name => 'foo_bar'
|
280
|
+
}
|
281
|
+
|
282
|
+
rules = {
|
283
|
+
:limit => nil,
|
284
|
+
:offset => nil,
|
285
|
+
:sort => { 'name' => 'items.name', 'created' => 'items.created_at' },
|
286
|
+
:rsort => nil,
|
287
|
+
:since => 'items.created_at',
|
288
|
+
:until => 'items.created_at',
|
289
|
+
:patterns => { :type => 'items.type',
|
290
|
+
:name => { :column => 'items.name',
|
291
|
+
:converter => lambda { |val| "%#{val.gsub('_', '.')}%" } } }
|
292
|
+
}
|
293
|
+
|
294
|
+
options = {
|
295
|
+
:include => [ :owners ],
|
296
|
+
:conditions => "items.flag = 'f'"
|
297
|
+
}
|
298
|
+
|
299
|
+
Item.find_queried(:all, query_params, rules, options)
|
300
|
+
end
|
301
|
+
end
|
data/spec/util_spec.rb
ADDED
@@ -0,0 +1,32 @@
|
|
1
|
+
require File.join(File.dirname(__FILE__) + '/searchable_record_spec_helper')
|
2
|
+
require 'active_support/core_ext/blank'
|
3
|
+
require 'util'
|
4
|
+
|
5
|
+
include SearchableRecord
|
6
|
+
|
7
|
+
describe Util do
|
8
|
+
it "should parse positive integers" do
|
9
|
+
Util.parse_positive_int('1').should == 1
|
10
|
+
Util.parse_positive_int('1sdfgsdf').should == 1
|
11
|
+
Util.parse_positive_int(nil).should be_nil
|
12
|
+
Util.parse_positive_int('0').should be_nil
|
13
|
+
Util.parse_positive_int('-1').should be_nil
|
14
|
+
Util.parse_positive_int('sdfgdfg').should be_nil
|
15
|
+
end
|
16
|
+
|
17
|
+
it "should prune and duplicate hashes" do
|
18
|
+
str = "don't remove me"
|
19
|
+
|
20
|
+
Util.pruned_dup({ :excess => 'foobar', :preserve => str }, [ :preserve ]).should == { :preserve => str }
|
21
|
+
Util.pruned_dup({ :excess => 'foobar', 'preserve' => str }, [ :preserve ]).should == { :preserve => str }
|
22
|
+
|
23
|
+
# A contrived example of calling #to_a for the second argument.
|
24
|
+
Util.pruned_dup({:e => 'foobar', [ :p, str ] => str }, { :p => str }).should == { [:p, str ] => str }
|
25
|
+
|
26
|
+
Util.pruned_dup({ :excess => 'foobar' }, [ :preserve ]).should == { }
|
27
|
+
Util.pruned_dup({ }, [ ]).should == { }
|
28
|
+
Util.pruned_dup({ }, [ :preserve ]).should == { }
|
29
|
+
|
30
|
+
lambda { Util.pruned_dup(nil, [ :foo ]) }.should raise_error(NoMethodError)
|
31
|
+
end
|
32
|
+
end
|
metadata
ADDED
@@ -0,0 +1,85 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: searchable_record
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.0.2
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Tuomas Kareinen
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
|
12
|
+
date: 2008-08-27 00:00:00 +03:00
|
13
|
+
default_executable:
|
14
|
+
dependencies:
|
15
|
+
- !ruby/object:Gem::Dependency
|
16
|
+
name: activesupport
|
17
|
+
type: :runtime
|
18
|
+
version_requirement:
|
19
|
+
version_requirements: !ruby/object:Gem::Requirement
|
20
|
+
requirements:
|
21
|
+
- - ">="
|
22
|
+
- !ruby/object:Gem::Version
|
23
|
+
version: "0"
|
24
|
+
version:
|
25
|
+
- !ruby/object:Gem::Dependency
|
26
|
+
name: hoe
|
27
|
+
type: :development
|
28
|
+
version_requirement:
|
29
|
+
version_requirements: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - ">="
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: 1.7.0
|
34
|
+
version:
|
35
|
+
description: "SearchableRecord is a small Ruby on Rails plugin that makes the parsing of query parameters from URLs easy for resources, allowing the requester to control the items (records) shown in the resource's representation. The implementation is a helper module (a mixin) for ActiveRecord models. It is used by including SearchableRecord module in a model. The mixin provides a class method, <tt>SearchableRecord#find_queried</tt>, to the class that includes it. The method is a front-end to ActiveRecord::Base#find: it parses query parameters against the given rules and calls <tt>find</tt> accordingly, returning the results of <tt>find</tt>."
|
36
|
+
email: tkareine@gmail.com
|
37
|
+
executables: []
|
38
|
+
|
39
|
+
extensions: []
|
40
|
+
|
41
|
+
extra_rdoc_files:
|
42
|
+
- History.txt
|
43
|
+
- Manifest.txt
|
44
|
+
- MIT-LICENSE.txt
|
45
|
+
- README.txt
|
46
|
+
files:
|
47
|
+
- History.txt
|
48
|
+
- Manifest.txt
|
49
|
+
- MIT-LICENSE.txt
|
50
|
+
- Rakefile
|
51
|
+
- README.txt
|
52
|
+
- lib/searchable_record.rb
|
53
|
+
- lib/util.rb
|
54
|
+
- spec/searchable_record_spec_helper.rb
|
55
|
+
- spec/searchable_record_spec.rb
|
56
|
+
- spec/util_spec.rb
|
57
|
+
has_rdoc: true
|
58
|
+
homepage: http://searchable-rec.rubyforge.org
|
59
|
+
post_install_message:
|
60
|
+
rdoc_options:
|
61
|
+
- --main
|
62
|
+
- README.txt
|
63
|
+
require_paths:
|
64
|
+
- lib
|
65
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
66
|
+
requirements:
|
67
|
+
- - ">="
|
68
|
+
- !ruby/object:Gem::Version
|
69
|
+
version: "0"
|
70
|
+
version:
|
71
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
72
|
+
requirements:
|
73
|
+
- - ">="
|
74
|
+
- !ruby/object:Gem::Version
|
75
|
+
version: "0"
|
76
|
+
version:
|
77
|
+
requirements: []
|
78
|
+
|
79
|
+
rubyforge_project: searchable-rec
|
80
|
+
rubygems_version: 1.2.0
|
81
|
+
signing_key:
|
82
|
+
specification_version: 2
|
83
|
+
summary: SearchableRecord is a small Ruby on Rails plugin that makes the parsing of query parameters from URLs easy for resources, allowing the requester to control the items (records) shown in the resource's representation.
|
84
|
+
test_files: []
|
85
|
+
|