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 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,4 +1,4 @@
1
1
  ---
2
2
  :patch: 0
3
3
  :major: 0
4
- :minor: 2
4
+ :minor: 6
@@ -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
- named_filters << name.to_sym
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
- read_inheritable_attribute(:named_filters) || write_inheritable_attribute(:named_filters, [])
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
@@ -44,6 +44,10 @@ module RecordFilter
44
44
  def add_group_by(column)
45
45
  @steps << GroupBy.new(column)
46
46
  end
47
+
48
+ def add_named_filter(method, *args)
49
+ @steps << NamedFilter.new(method, *args)
50
+ end
47
51
  end
48
52
  end
49
53
  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
@@ -12,7 +12,7 @@ module RecordFilter
12
12
  end
13
13
 
14
14
  def subclass(clazz)
15
- SUBCLASSES[clazz.name.to_sym]
15
+ SUBCLASSES[clazz.object_id]
16
16
  end
17
17
  end
18
18
 
@@ -0,0 +1,12 @@
1
+ module RecordFilter
2
+ module DSL
3
+ class NamedFilter
4
+
5
+ attr_reader :name, :args
6
+
7
+ def initialize(name, *args)
8
+ @name, @args = name, args
9
+ end
10
+ end
11
+ end
12
+ end
@@ -35,6 +35,11 @@ module RecordFilter
35
35
  end
36
36
  end
37
37
 
38
+ def is_not_null
39
+ @operator = :is_null
40
+ @negated = true
41
+ end
42
+
38
43
  alias_method :gt, :greater_than
39
44
  alias_method :gte, :greater_than_or_equal_to
40
45
  alias_method :lt, :less_than
@@ -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
@@ -12,29 +12,18 @@ module RecordFilter
12
12
  @current_scoped_methods = clazz.send(:current_scoped_methods)
13
13
  @clazz = clazz
14
14
 
15
- @dsl = dsl_for_named_filter(@clazz, named_filter)
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
- if args.first.kind_of?(Integer)
23
- loaded_data.first(*args)
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
- if args.first.kind_of?(Integer)
33
- loaded_data.last(*args)
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)
@@ -1,31 +1,53 @@
1
1
  module RecordFilter
2
2
  class Query
3
3
 
4
- def initialize(clazz, dsl_conjunction)
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
- @conjunction = RecordFilter::Conjunctions::Base.create_from(dsl_conjunction, @table)
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
- params = {}
11
- conditions = @conjunction.to_conditions
12
- params = { :conditions => conditions } if conditions
13
- joins = @table.all_joins
14
- params[:joins] = joins.map { |join| join.to_sql } * ' ' unless joins.empty?
15
- if (joins.any? { |j| j.requires_distinct_select? })
16
- if count_query
17
- params[:select] = "DISTINCT #{@table.model_class.quoted_table_name}.#{@table.model_class.primary_key}"
18
- else
19
- params[:select] = "DISTINCT #{@table.model_class.quoted_table_name}.*"
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