outoftime-record_filter 0.2.0 → 0.6.0
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/README.rdoc +205 -0
- data/VERSION.yml +1 -1
- data/lib/record_filter/active_record.rb +30 -2
- data/lib/record_filter/conjunctions.rb +10 -0
- data/lib/record_filter/dsl/conjunction.rb +4 -0
- data/lib/record_filter/dsl/conjunction_dsl.rb +20 -0
- data/lib/record_filter/dsl/dsl.rb +1 -1
- data/lib/record_filter/dsl/named_filter.rb +12 -0
- data/lib/record_filter/dsl/restriction.rb +5 -0
- data/lib/record_filter/dsl.rb +1 -1
- data/lib/record_filter/filter.rb +5 -26
- data/lib/record_filter/query.rb +41 -19
- data/lib/record_filter/restrictions.rb +5 -0
- data/lib/record_filter/table.rb +11 -8
- data/lib/record_filter.rb +5 -3
- data/spec/active_record_spec.rb +163 -0
- data/spec/exception_spec.rb +80 -0
- data/spec/explicit_join_spec.rb +5 -4
- data/spec/limits_and_ordering_spec.rb +6 -47
- data/spec/models.rb +19 -0
- data/spec/named_filter_spec.rb +78 -56
- data/spec/proxying_spec.rb +26 -12
- data/spec/restrictions_spec.rb +33 -0
- data/spec/test.db +0 -0
- metadata +8 -4
data/README.rdoc
ADDED
@@ -0,0 +1,205 @@
|
|
1
|
+
= record_filter
|
2
|
+
|
3
|
+
record_filter is a DSL for specifying criteria for ActiveRecord queries in pure Ruby.
|
4
|
+
It has support for filters created on the fly and for named filters that are associated with object types.
|
5
|
+
record_filter has the following top-level features:
|
6
|
+
|
7
|
+
* Pure ruby API eliminates the need for hard-coded SQL in most cases.
|
8
|
+
* Works seamlessly with existing ActiveRecord APIs, including named scopes.
|
9
|
+
* Supports creation of ad-hoc filters as well as named filters that can be associated with object types.
|
10
|
+
* Allows chaining of filters with each other and with named scopes to create complex queries.
|
11
|
+
* Takes advantage of the associations in your ActiveRecord objects for a clean implicit join API.
|
12
|
+
|
13
|
+
== Installation
|
14
|
+
|
15
|
+
gem install outoftime-record_filter --source=http://gems.github.com
|
16
|
+
|
17
|
+
== Usage
|
18
|
+
|
19
|
+
=== Ad-hoc filters
|
20
|
+
|
21
|
+
Post.filter do
|
22
|
+
with(:permalink, 'blog-post')
|
23
|
+
having(:blog).with(:name, 'Blog')
|
24
|
+
end
|
25
|
+
|
26
|
+
This could be expressed in ActiveRecord as:
|
27
|
+
|
28
|
+
Post.find(:all, :joins => :blog, :conditions => ['posts.permalink = ? AND blogs.name = ?', 'blog-post', 'Blog')
|
29
|
+
|
30
|
+
and it returns the same result, a list of Post objects that are returned from the query.
|
31
|
+
|
32
|
+
=== Named filters
|
33
|
+
|
34
|
+
class Post < ActiveRecord::Base
|
35
|
+
named_filter(:with_title) do |title|
|
36
|
+
with(:title, title)
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
Post.with_title('posted')
|
41
|
+
|
42
|
+
This is the same as the following code using named scopes, and returns the same result:
|
43
|
+
|
44
|
+
class Post < ActiveRecord::Base
|
45
|
+
named_scope :with_title, lambda { |title| { :conditions => ['title = ?', title] }}
|
46
|
+
end
|
47
|
+
|
48
|
+
Post.with_title('scoped')
|
49
|
+
|
50
|
+
=== Restrictions
|
51
|
+
|
52
|
+
Restrictions are specified through the API using the 'with' function. The first argument to 'with' should be the
|
53
|
+
name of the field that the restriction applies to. All restriction types can be negated by chaining the 'with'
|
54
|
+
method with a call to 'not', as seen in some examples below.
|
55
|
+
|
56
|
+
==== Equality
|
57
|
+
|
58
|
+
If a second argument is supplied, it is assumed that you are
|
59
|
+
expressing an equality condition and that argument is used as the value.
|
60
|
+
|
61
|
+
with(:title, 'abc') # :conditions => ['title = ?', 'abc']
|
62
|
+
|
63
|
+
Which can be negated with:
|
64
|
+
|
65
|
+
with(:title, 'abc').not # :conditions => ['title <> ?', 'abc']
|
66
|
+
|
67
|
+
For the more verbose among us, this can also be specified as:
|
68
|
+
|
69
|
+
with(:title).equal_to('abc') # :conditions => ['title = ?', 'abc']
|
70
|
+
|
71
|
+
Other types of restrictions are specified by omitting the second argument to 'with' and chaining it with one of the
|
72
|
+
restriction methods.
|
73
|
+
|
74
|
+
==== Comparison operators
|
75
|
+
|
76
|
+
with(:price).greater_than(10) # :conditions => ['price > ?', 10]
|
77
|
+
|
78
|
+
with(:created_at).less_than(2.days.ago) # :conditions => ['created_at < ?', 2.days.ago]
|
79
|
+
|
80
|
+
These methods can also have _or_equal_to tagged onto the end, to obvious affect, and all of the comparison operators are aliased to
|
81
|
+
their standard single-character variants:
|
82
|
+
|
83
|
+
gt, gte, lt and lte
|
84
|
+
|
85
|
+
==== IS NULL
|
86
|
+
|
87
|
+
with(:price, nil) # :conditions => ['price IS NULL']
|
88
|
+
|
89
|
+
This short form can also be made explicit by using the is_null, null, or nil functions on with:
|
90
|
+
|
91
|
+
with(:price).is_null # :conditions => ['price IS NULL']
|
92
|
+
|
93
|
+
It can be negated either by chaining with the 'not' function or by using is_not_null:
|
94
|
+
|
95
|
+
with(:price).is_not_null # :conditions => ['price IS NOT NULL']
|
96
|
+
with(:price).is_null.not # "
|
97
|
+
|
98
|
+
==== IN
|
99
|
+
|
100
|
+
with(:id).in([1, 2, 3]) # :conditions => ['id IN (?)', [1, 2, 3]]
|
101
|
+
|
102
|
+
==== BETWEEN
|
103
|
+
|
104
|
+
with(:id).between(1, 5) # :conditions => ['id BETWEEN ? AND ?', 1, 5]
|
105
|
+
|
106
|
+
The argument to between can also be either a tuple or a range
|
107
|
+
|
108
|
+
with(:created_at).between([Time.now, 3.days.ago]) # :conditions => ['created_at BETWEEN ? AND ?', Time.now, 3.days.ago]
|
109
|
+
|
110
|
+
with(:price).between(1..5) # :conditions => ['price BETWEEN ? AND ?', 1, 5]
|
111
|
+
|
112
|
+
==== LIKE
|
113
|
+
|
114
|
+
with(:title).like('%help%') # :conditions => ['title LIKE ?', '%help%']
|
115
|
+
|
116
|
+
|
117
|
+
=== Implicit Joins
|
118
|
+
|
119
|
+
Implicit joins are specified using the 'having' function, which takes as its argument the name of an association to join on.
|
120
|
+
|
121
|
+
having(:comments) # :joins => :comments
|
122
|
+
|
123
|
+
This function can be chained with calls to 'with' in order to specify conditions on the joined table:
|
124
|
+
|
125
|
+
having(:comments).with(:created_at).gt(2.days.ago) # :joins => :comments, :conditions => ['comments.created_at > ?', 2.days.ago]
|
126
|
+
|
127
|
+
It can also take a block that can have any number of conditions or other clauses (including other joins) in it:
|
128
|
+
|
129
|
+
having(:comments) do
|
130
|
+
with(:created_at).gt(2.days.ago)
|
131
|
+
having(:author).with(:name, 'Bubba')
|
132
|
+
end
|
133
|
+
|
134
|
+
The 'having' function can also take :inner, :left or :right as its second argument in order to specify that a particular join type
|
135
|
+
should be used.
|
136
|
+
|
137
|
+
=== Explicit joins
|
138
|
+
|
139
|
+
In cases where there is no ActiveRecord association that can be used to specify an implicit join, explicit joins are also
|
140
|
+
supported, using the 'join' function. Its arguments are the class to be joined against, the join type (:inner, left or :right) and
|
141
|
+
an optional alias for the join table. A block should also be supplied in order to specify the columns to use for the join using the
|
142
|
+
'on' method.
|
143
|
+
|
144
|
+
Post.filter do
|
145
|
+
join(Comment, :inner, :posts__comments_alias) do
|
146
|
+
on(:id => :commentable_id)
|
147
|
+
on(:commentable_type, 'Post')
|
148
|
+
end
|
149
|
+
end
|
150
|
+
|
151
|
+
=== Conjunctions
|
152
|
+
|
153
|
+
The following conjunction types are supported:
|
154
|
+
|
155
|
+
* any_of
|
156
|
+
* all_of
|
157
|
+
* none_of
|
158
|
+
* not_all_of
|
159
|
+
|
160
|
+
Each takes a block that can contain conditions or joins and will apply the expected boolean logic to the combination.
|
161
|
+
|
162
|
+
any_of
|
163
|
+
with(:price, 2)
|
164
|
+
with(:price).gt(100)
|
165
|
+
end
|
166
|
+
|
167
|
+
# :conditions => ['price = ? OR price > ?', 2, 100]
|
168
|
+
|
169
|
+
=== Limits and ordering
|
170
|
+
|
171
|
+
limit(20)
|
172
|
+
|
173
|
+
order(:id, :desc)
|
174
|
+
|
175
|
+
Multiple order clauses can be specified, and they will be applied in the order in which they are specified.
|
176
|
+
When joins are used, order can also take a hash as the first argument that leads to the column to order on through the joins:
|
177
|
+
|
178
|
+
having(:comments).with(:offensive, true)
|
179
|
+
order(:comments => :id, :desc)
|
180
|
+
|
181
|
+
|
182
|
+
== LICENSE:
|
183
|
+
|
184
|
+
(The MIT License)
|
185
|
+
|
186
|
+
Copyright (c) 2008 Mat Brown
|
187
|
+
|
188
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
189
|
+
a copy of this software and associated documentation files (the
|
190
|
+
'Software'), to deal in the Software without restriction, including
|
191
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
192
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
193
|
+
permit persons to whom the Software is furnished to do so, subject to
|
194
|
+
the following conditions:
|
195
|
+
|
196
|
+
The above copyright notice and this permission notice shall be
|
197
|
+
included in all copies or substantial portions of the Software.
|
198
|
+
|
199
|
+
THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
|
200
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
201
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
202
|
+
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
203
|
+
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
|
204
|
+
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
205
|
+
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/VERSION.yml
CHANGED
@@ -1,14 +1,33 @@
|
|
1
1
|
module RecordFilter
|
2
|
+
# The ActiveRecordExtension module is mixed in to ActiveRecord::Base to form the
|
3
|
+
# top-level API for interacting with record_filter. It adds public methods for
|
4
|
+
# executing ad-hoc filters as well as for creating and querying named filters.
|
2
5
|
module ActiveRecordExtension
|
3
6
|
module ClassMethods
|
4
7
|
|
8
|
+
# Execute an ad-hoc filter
|
9
|
+
#
|
10
|
+
# ==== Parameters
|
11
|
+
# block::
|
12
|
+
# A block that specifies the contents of the filter.
|
13
|
+
#
|
14
|
+
# ==== Returns
|
15
|
+
# Filter:: The Filter object resulting from the query, which can be
|
16
|
+
# treated as an array of the results.
|
17
|
+
#
|
18
|
+
# ==== Example
|
19
|
+
# Blog.filter do
|
20
|
+
# having(:posts).with(:name, nil)
|
21
|
+
# end
|
22
|
+
#—
|
23
|
+
# @public
|
5
24
|
def filter(&block)
|
6
25
|
Filter.new(self, nil, &block)
|
7
26
|
end
|
8
27
|
|
9
28
|
def named_filter(name, &block)
|
10
29
|
return if named_filters.include?(name.to_sym)
|
11
|
-
|
30
|
+
local_named_filters << name.to_sym
|
12
31
|
DSL::DSL::subclass(self).module_eval do
|
13
32
|
define_method(name, &block)
|
14
33
|
end
|
@@ -21,10 +40,19 @@ module RecordFilter
|
|
21
40
|
end
|
22
41
|
|
23
42
|
def named_filters
|
24
|
-
|
43
|
+
result = local_named_filters.dup
|
44
|
+
result.concat(superclass.named_filters) if (superclass && superclass.respond_to?(:named_filters))
|
45
|
+
result
|
46
|
+
end
|
47
|
+
|
48
|
+
protected
|
49
|
+
|
50
|
+
def local_named_filters
|
51
|
+
@local_named_filters ||= []
|
25
52
|
end
|
26
53
|
end
|
27
54
|
end
|
28
55
|
end
|
29
56
|
|
30
57
|
ActiveRecord::Base.send(:extend, RecordFilter::ActiveRecordExtension::ClassMethods)
|
58
|
+
|
@@ -30,6 +30,8 @@ module RecordFilter
|
|
30
30
|
result.add_order(step.column, step.direction)
|
31
31
|
when DSL::GroupBy
|
32
32
|
result.add_group_by(step.column)
|
33
|
+
when DSL::NamedFilter
|
34
|
+
result.add_named_filter(step.name, step.args)
|
33
35
|
end
|
34
36
|
end
|
35
37
|
result
|
@@ -81,6 +83,14 @@ module RecordFilter
|
|
81
83
|
@limit, @offset = limit, offset
|
82
84
|
end
|
83
85
|
|
86
|
+
def add_named_filter(name, args)
|
87
|
+
unless @table.model_class.named_filters.include?(name.to_sym)
|
88
|
+
raise NamedFilterNotFoundException.new("The named filter #{name} was not found in #{@table.model_class}")
|
89
|
+
end
|
90
|
+
query = Query.new(@table.model_class, name, *args)
|
91
|
+
self << self.class.create_from(query.dsl_conjunction, @table)
|
92
|
+
end
|
93
|
+
|
84
94
|
def <<(restriction)
|
85
95
|
@restrictions << restriction
|
86
96
|
end
|
@@ -53,6 +53,26 @@ module RecordFilter
|
|
53
53
|
def filter_class
|
54
54
|
@model_class
|
55
55
|
end
|
56
|
+
|
57
|
+
def limit(offset_or_limit, limit=nil)
|
58
|
+
raise InvalidFilterException.new('Calls to limit can only be made in the outer block of a filter.')
|
59
|
+
end
|
60
|
+
|
61
|
+
def order(column, direction=:asc)
|
62
|
+
raise InvalidFilterException.new('Calls to order can only be made in the outer block of a filter.')
|
63
|
+
end
|
64
|
+
|
65
|
+
def group_by(column)
|
66
|
+
raise InvalidFilterException.new('Calls to group_by can only be made in the outer block of a filter.')
|
67
|
+
end
|
68
|
+
|
69
|
+
def on(column, value=Restriction::DEFAULT_VALUE)
|
70
|
+
raise InvalidFilterException.new('Calls to on can only be made in the block of a call to join.')
|
71
|
+
end
|
72
|
+
|
73
|
+
def method_missing(method, *args)
|
74
|
+
@conjunction.add_named_filter(method, *args)
|
75
|
+
end
|
56
76
|
end
|
57
77
|
end
|
58
78
|
end
|
data/lib/record_filter/dsl.rb
CHANGED
@@ -1,4 +1,4 @@
|
|
1
|
-
%w(class_join conjunction conjunction_dsl dsl group_by join join_dsl join_condition limit order restriction).each { |file| require File.join(File.dirname(__FILE__), 'dsl', file) }
|
1
|
+
%w(class_join conjunction conjunction_dsl dsl group_by join join_dsl join_condition limit named_filter order restriction).each { |file| require File.join(File.dirname(__FILE__), 'dsl', file) }
|
2
2
|
|
3
3
|
module RecordFilter
|
4
4
|
module DSL
|
data/lib/record_filter/filter.rb
CHANGED
@@ -12,29 +12,18 @@ module RecordFilter
|
|
12
12
|
@current_scoped_methods = clazz.send(:current_scoped_methods)
|
13
13
|
@clazz = clazz
|
14
14
|
|
15
|
-
@
|
16
|
-
@dsl.instance_eval(&block) if block
|
17
|
-
@dsl.send(named_filter, *args) if named_filter && @dsl.respond_to?(named_filter)
|
18
|
-
@query = Query.new(@clazz, @dsl.conjunction)
|
15
|
+
@query = Query.new(@clazz, named_filter, *args, &block)
|
19
16
|
end
|
20
17
|
|
21
18
|
def first(*args)
|
22
|
-
|
23
|
-
|
24
|
-
else
|
25
|
-
do_with_scope do
|
26
|
-
@clazz.find(:first, *args)
|
27
|
-
end
|
19
|
+
do_with_scope do
|
20
|
+
@clazz.find(:first, *args)
|
28
21
|
end
|
29
22
|
end
|
30
23
|
|
31
24
|
def last(*args)
|
32
|
-
|
33
|
-
|
34
|
-
else
|
35
|
-
do_with_scope do
|
36
|
-
@clazz.find(:last, *args)
|
37
|
-
end
|
25
|
+
do_with_scope do
|
26
|
+
@clazz.find(:last, *args)
|
38
27
|
end
|
39
28
|
end
|
40
29
|
|
@@ -90,16 +79,6 @@ module RecordFilter
|
|
90
79
|
end
|
91
80
|
end
|
92
81
|
|
93
|
-
def dsl_for_named_filter(clazz, named_filter)
|
94
|
-
return DSL::DSL.create(clazz) if named_filter.blank?
|
95
|
-
while (clazz)
|
96
|
-
dsl = DSL::DSL::SUBCLASSES.has_key?(clazz.name.to_sym) ? DSL::DSL::SUBCLASSES[clazz.name.to_sym] : nil
|
97
|
-
return DSL::DSL.create(clazz) if dsl && dsl.instance_methods(false).include?(named_filter.to_s)
|
98
|
-
clazz = clazz.superclass
|
99
|
-
end
|
100
|
-
nil
|
101
|
-
end
|
102
|
-
|
103
82
|
def loaded_data
|
104
83
|
@loaded_data ||= do_with_scope do
|
105
84
|
@clazz.find(:all)
|
data/lib/record_filter/query.rb
CHANGED
@@ -1,31 +1,53 @@
|
|
1
1
|
module RecordFilter
|
2
2
|
class Query
|
3
3
|
|
4
|
-
|
4
|
+
attr_reader :dsl_conjunction
|
5
|
+
attr_reader :conjunction
|
6
|
+
|
7
|
+
def initialize(clazz, named_filter, *args, &block)
|
8
|
+
dsl = dsl_for_named_filter(clazz, named_filter)
|
9
|
+
dsl.instance_eval(&block) if block
|
10
|
+
dsl.send(named_filter, *args) if named_filter && dsl.respond_to?(named_filter)
|
11
|
+
|
5
12
|
@table = RecordFilter::Table.new(clazz)
|
6
|
-
@
|
13
|
+
@dsl_conjunction = dsl.conjunction
|
14
|
+
@conjunction = RecordFilter::Conjunctions::Base.create_from(@dsl_conjunction, @table)
|
7
15
|
end
|
8
16
|
|
9
17
|
def to_find_params(count_query=false)
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
18
|
+
@params_cache ||= {}
|
19
|
+
@params_cache[count_query] ||= begin
|
20
|
+
params = {}
|
21
|
+
conditions = @conjunction.to_conditions
|
22
|
+
params = { :conditions => conditions } if conditions
|
23
|
+
joins = @table.all_joins
|
24
|
+
params[:joins] = joins.map { |join| join.to_sql } * ' ' unless joins.empty?
|
25
|
+
if (joins.any? { |j| j.requires_distinct_select? })
|
26
|
+
if count_query
|
27
|
+
params[:select] = "DISTINCT #{@table.model_class.quoted_table_name}.#{@table.model_class.primary_key}"
|
28
|
+
else
|
29
|
+
params[:select] = "DISTINCT #{@table.model_class.quoted_table_name}.*"
|
30
|
+
end
|
20
31
|
end
|
32
|
+
orders = @table.orders
|
33
|
+
params[:order] = orders.map { |order| order.to_sql } * ', ' unless orders.empty?
|
34
|
+
group_bys = @table.group_bys
|
35
|
+
params[:group] = group_bys.map { |group_by| group_by.to_sql } * ', ' unless group_bys.empty?
|
36
|
+
params[:limit] = @conjunction.limit if @conjunction.limit
|
37
|
+
params[:offset] = @conjunction.offset if @conjunction.offset
|
38
|
+
params
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
protected
|
43
|
+
|
44
|
+
def dsl_for_named_filter(clazz, named_filter)
|
45
|
+
return DSL::DSL.create(clazz) if named_filter.blank?
|
46
|
+
while (clazz)
|
47
|
+
dsl = DSL::DSL.subclass(clazz)
|
48
|
+
return DSL::DSL.create(clazz) if dsl && dsl.instance_methods(false).include?(named_filter.to_s)
|
49
|
+
clazz = clazz.superclass
|
21
50
|
end
|
22
|
-
orders = @table.orders
|
23
|
-
params[:order] = orders.map { |order| order.to_sql } * ', ' unless orders.empty?
|
24
|
-
group_bys = @table.group_bys
|
25
|
-
params[:group] = group_bys.map { |group_by| group_by.to_sql } * ', ' unless group_bys.empty?
|
26
|
-
params[:limit] = @conjunction.limit if @conjunction.limit
|
27
|
-
params[:offset] = @conjunction.offset if @conjunction.offset
|
28
|
-
params
|
29
51
|
end
|
30
52
|
end
|
31
53
|
end
|