outoftime-record_filter 0.1.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/Rakefile ADDED
@@ -0,0 +1,24 @@
1
+ ENV['RUBYOPT'] = '-W1'
2
+
3
+ require 'rubygems'
4
+ require 'rake'
5
+ require 'rake/testtask'
6
+
7
+ FileList['tasks/**/*.rake'].each { |file| load file }
8
+
9
+ task :default => :spec
10
+
11
+ begin
12
+ require 'jeweler'
13
+ Jeweler::Tasks.new do |gemspec|
14
+ gemspec.name = 'record_filter'
15
+ gemspec.summary = 'Pure-ruby criteria API for building complex queries in ActiveRecord'
16
+ gemspec.email = 'mat@patch.com'
17
+ gemspec.homepage = 'http://github.com/outoftime/record_filter/tree/master'
18
+ gemspec.description = 'Pure-ruby criteria API for building complex queries in ActiveRecord'
19
+ gemspec.authors = ['Mat Brown', 'Aubrey Holland']
20
+ end
21
+ rescue LoadError
22
+ puts "Jeweler not available. Install it with: sudo gem install technicalpickles-jeweler -s http://gems.github.com"
23
+ end
24
+
data/VERSION.yml ADDED
@@ -0,0 +1,4 @@
1
+ ---
2
+ :major: 0
3
+ :minor: 1
4
+ :patch: 0
@@ -0,0 +1,24 @@
1
+ module RecordFilter
2
+ module ActiveRecordExtension
3
+ module ClassMethods
4
+
5
+ def filter(&block)
6
+ Filter.new(self, nil, nil, &block)
7
+ end
8
+
9
+ def named_filter(name, &block)
10
+ DSL::DSL::subclass(self).module_eval do
11
+ define_method(name, &block)
12
+ end
13
+
14
+ (class << self; self; end).instance_eval do
15
+ define_method(name.to_s) do |*args|
16
+ Filter.new(self, name, nil, *args)
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
23
+
24
+ ActiveRecord::Base.send(:extend, RecordFilter::ActiveRecordExtension::ClassMethods)
@@ -0,0 +1,96 @@
1
+ module RecordFilter
2
+ module Conjunctions
3
+ class Base
4
+ attr_reader :table_name, :limit, :offset
5
+
6
+ def self.create_from(dsl_conjunction, table)
7
+ result = case dsl_conjunction.type
8
+ when :any_of: AnyOf.new(table)
9
+ when :all_of: AllOf.new(table)
10
+ end
11
+
12
+ dsl_conjunction.steps.each do |step|
13
+ case step
14
+ when DSL::Restriction
15
+ result.add_restriction(step.column, step.operator, step.value, :negated => step.negated)
16
+ when DSL::Conjunction
17
+ result.add_conjunction(create_from(step, table))
18
+ when DSL::Join
19
+ join = result.add_join_on_association(step.association)
20
+ result.add_conjunction(create_from(step.conjunction, join.right_table))
21
+ when DSL::Limit
22
+ result.add_limit_and_offset(step.limit, step.offset)
23
+ when DSL::Order
24
+ result.add_order(step.column, step.direction)
25
+ end
26
+ end
27
+ result
28
+ end
29
+
30
+ def initialize(table, restrictions = nil, joins = nil)
31
+ @table = table
32
+ @table_name = table.table_alias
33
+ @restrictions = restrictions || []
34
+ @joins = joins || []
35
+ end
36
+
37
+ def add_restriction(column_name, operator, value, options={})
38
+ restriction_class = "RecordFilter::Restrictions::#{operator.to_s.camelize}".constantize
39
+ restriction = restriction_class.new("#{@table_name}.#{column_name}", value, options)
40
+ self << restriction
41
+ restriction
42
+ end
43
+
44
+ def add_conjunction(conjunction)
45
+ self << conjunction
46
+ conjunction
47
+ end
48
+
49
+ def add_join_on_association(association_name)
50
+ @table.join_association(association_name)
51
+ end
52
+
53
+ def add_order(column, direction)
54
+ @table.order_column(column, direction)
55
+ end
56
+
57
+ def add_limit_and_offset(limit, offset)
58
+ @limit, @offset = limit, offset
59
+ end
60
+
61
+ def <<(restriction)
62
+ @restrictions << restriction
63
+ end
64
+
65
+ def to_conditions
66
+ if @restrictions.empty?
67
+ nil
68
+ elsif @restrictions.length == 1
69
+ @restrictions.first.to_conditions
70
+ else
71
+ @restrictions.map do |restriction|
72
+ conditions = restriction.to_conditions
73
+ conditions[0] = "(#{conditions[0]})"
74
+ conditions
75
+ end.inject do |conditions, new_conditions|
76
+ conditions.first << " #{conjunctor} #{new_conditions.shift}"
77
+ conditions.concat(new_conditions)
78
+ conditions
79
+ end
80
+ end
81
+ end
82
+ end
83
+
84
+ class AnyOf < Base
85
+ def conjunctor
86
+ 'OR'
87
+ end
88
+ end
89
+
90
+ class AllOf < Base
91
+ def conjunctor
92
+ 'AND'
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,47 @@
1
+ module RecordFilter
2
+ module DSL
3
+ class Conjunction
4
+
5
+ attr_reader :type, :steps
6
+
7
+ DEFAULT_VALUE = Object.new
8
+
9
+ def initialize(type=:all_of)
10
+ @type, @steps = type, []
11
+ end
12
+
13
+ def add_restriction(column, value, negated)
14
+ @steps << (restriction = Restriction.new(column, negated))
15
+ if value == DEFAULT_VALUE
16
+ return restriction
17
+ elsif value.nil?
18
+ restriction.is_null
19
+ else
20
+ restriction.equal_to(value)
21
+ end
22
+ nil
23
+ end
24
+
25
+ def add_conjunction(type, &block)
26
+ dsl = ConjunctionDSL.new(Conjunction.new(type))
27
+ dsl.instance_eval(&block) if block
28
+ @steps << dsl.conjunction
29
+ end
30
+
31
+ def add_join(association, &block)
32
+ dsl = ConjunctionDSL.new
33
+ dsl.instance_eval(&block) if block
34
+ @steps << (join = Join.new(association, dsl.conjunction))
35
+ join
36
+ end
37
+
38
+ def add_limit(limit, offset)
39
+ @steps << Limit.new(limit, offset)
40
+ end
41
+
42
+ def add_order(column, direction)
43
+ @steps << Order.new(column, direction)
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,40 @@
1
+ module RecordFilter
2
+ module DSL
3
+ class ConjunctionDSL
4
+
5
+ attr_reader :conjunction
6
+
7
+ def initialize(conjunction=Conjunction.new(:all_of))
8
+ @conjunction = conjunction
9
+ end
10
+
11
+ # restriction
12
+ def with(column, value=Conjunction::DEFAULT_VALUE)
13
+ return @conjunction.add_restriction(column, value, false) # using return just to make it explicit
14
+ end
15
+
16
+ # restriction
17
+ def without(column, value=Conjunction::DEFAULT_VALUE)
18
+ return @conjunction.add_restriction(column, value, true) # using return just to make it explicit
19
+ end
20
+
21
+ # conjunction
22
+ def any_of(&block)
23
+ @conjunction.add_conjunction(:any_of, &block)
24
+ nil
25
+ end
26
+
27
+ # conjunction
28
+ def all_of(&block)
29
+ @conjunction.add_conjunction(:all_of, &block)
30
+ nil
31
+ end
32
+
33
+ # join
34
+ def having(association, &block)
35
+ join = @conjunction.add_join(association, &block)
36
+ ConjunctionDSL.new(join.conjunction)
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,42 @@
1
+ module RecordFilter
2
+ module DSL
3
+ class DSL < ConjunctionDSL
4
+
5
+ SUBCLASSES = Hash.new do |h, k|
6
+ h[k] = Class.new(DSL)
7
+ end
8
+
9
+ class << self
10
+ def create(clazz)
11
+ subclass(clazz).new
12
+ end
13
+
14
+ def subclass(clazz)
15
+ SUBCLASSES[clazz.name.to_sym]
16
+ end
17
+ end
18
+
19
+ # This method can take two forms:
20
+ # limit(offset, limit), or
21
+ # limit(limit)
22
+ def limit(offset_or_limit, limit=nil)
23
+ if limit
24
+ @conjunction.add_limit(limit, offset_or_limit)
25
+ else
26
+ @conjunction.add_limit(offset_or_limit, nil)
27
+ end
28
+ nil
29
+ end
30
+
31
+ # This method can take two forms, as shown below.
32
+ # order :permalink
33
+ # order :permalink, :desc
34
+ # order :photo => :path, :desc
35
+ # order :photo => { :comment => :id }, :asc
36
+ def order(column, direction=:asc)
37
+ @conjunction.add_order(column, direction)
38
+ nil
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,12 @@
1
+ module RecordFilter
2
+ module DSL
3
+ class Join
4
+
5
+ attr_reader :association, :conjunction
6
+
7
+ def initialize(association, conjunction)
8
+ @association, @conjunction = association, conjunction
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,12 @@
1
+ module RecordFilter
2
+ module DSL
3
+ class Limit
4
+
5
+ attr_reader :limit, :offset
6
+
7
+ def initialize(limit, offset)
8
+ @limit, @offset = limit, offset
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,12 @@
1
+ module RecordFilter
2
+ module DSL
3
+ class Order
4
+
5
+ attr_reader :column, :direction
6
+
7
+ def initialize(column, direction)
8
+ @column, @direction = column, direction
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,20 @@
1
+ module RecordFilter
2
+ module DSL
3
+ class Restriction
4
+
5
+ attr_reader :column, :negated, :operator, :value
6
+
7
+ def initialize(column, negated)
8
+ @column, @negated, @operator = column, negated, nil
9
+ end
10
+
11
+ [:equal_to, :is_null, :less_than, :greater_than, :in, :between].each do |operator|
12
+ define_method(operator) do |*args|
13
+ @value = args[0]
14
+ @operator = operator
15
+ self
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,6 @@
1
+ %w(conjunction conjunction_dsl dsl join limit order restriction).each { |file| require File.join(File.dirname(__FILE__), 'dsl', file) }
2
+
3
+ module RecordFilter
4
+ module DSL
5
+ end
6
+ end
@@ -0,0 +1,46 @@
1
+ module RecordFilter
2
+ class Filter
3
+
4
+ delegate :inspect, :to => :loaded_data
5
+
6
+ def initialize(clazz, named_filter, combine_conjunction, *args, &block)
7
+ @clazz = clazz
8
+
9
+ @dsl = dsl_for_named_filter(@clazz, named_filter)
10
+ @dsl.instance_eval(&block) if block
11
+ @dsl.send(named_filter, *args) if named_filter && @dsl.respond_to?(named_filter)
12
+ @dsl.conjunction.steps.unshift(combine_conjunction.steps).flatten! if combine_conjunction
13
+ end
14
+
15
+ def filter(&block)
16
+ Filter.new(@clazz, nil, @dsl.conjunction, &block)
17
+ end
18
+
19
+ def method_missing(method, *args, &block)
20
+ if @clazz.respond_to?(method) # UGLY, we need to only pass through things that are named filters.
21
+ Filter.new(@clazz, method, @dsl.conjunction, *args)
22
+ else
23
+ loaded_data.send(method, *args, &block)
24
+ end
25
+ end
26
+
27
+ protected
28
+
29
+ def loaded_data
30
+ @loaded_data ||= begin
31
+ query = Query.new(@clazz, @dsl.conjunction)
32
+ @clazz.scoped(query.to_find_params)
33
+ end
34
+ end
35
+
36
+ def dsl_for_named_filter(clazz, named_filter)
37
+ return DSL::DSL.create(clazz) if named_filter.blank?
38
+ while (clazz)
39
+ dsl = DSL::DSL::SUBCLASSES.has_key?(clazz.name.to_sym) ? DSL::DSL::SUBCLASSES[clazz.name.to_sym] : nil
40
+ return DSL::DSL.create(clazz) if dsl && dsl.instance_methods(false).include?(named_filter.to_s)
41
+ clazz = clazz.superclass
42
+ end
43
+ nil
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,17 @@
1
+ module RecordFilter
2
+ class Join
3
+ attr_reader :left_table, :right_table
4
+
5
+ def initialize(left_table, right_table, join_predicate)
6
+ @left_table, @right_table, @join_predicate =
7
+ left_table, right_table, join_predicate
8
+ end
9
+
10
+ def to_sql
11
+ predicate_sql = @join_predicate.map do |left_column, right_column|
12
+ "#{@left_table.table_alias}.#{left_column} = #{@right_table.table_alias}.#{right_column}"
13
+ end * ' AND '
14
+ "INNER JOIN #{@right_table.table_name} AS #{@right_table.table_alias} ON #{predicate_sql}"
15
+ end
16
+ end
17
+ end
File without changes
@@ -0,0 +1,24 @@
1
+ module RecordFilter
2
+ class Order
3
+ attr_reader :column, :direction, :table
4
+
5
+ def initialize(column, direction, table)
6
+ @column, @direction, @table = column, direction, table
7
+ end
8
+
9
+ def to_sql
10
+ dir = case @direction
11
+ when :asc then 'ASC'
12
+ when :desc then 'DESC'
13
+ end
14
+
15
+ table, column = @table, @column
16
+ while column.is_a?(Hash)
17
+ table = table.join_association(column.keys[0]).right_table
18
+ column = column.values[0]
19
+ end
20
+
21
+ "#{table.table_name}.#{column} #{dir}"
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,20 @@
1
+ module RecordFilter
2
+ class Query
3
+
4
+ def initialize(clazz, dsl_conjunction)
5
+ @table = RecordFilter::Table.new(clazz)
6
+ @conjunction = RecordFilter::Conjunctions::Base.create_from(dsl_conjunction, @table)
7
+ end
8
+
9
+ def to_find_params
10
+ params = { :conditions => @conjunction.to_conditions }
11
+ joins = @table.all_joins
12
+ params[:joins] = joins.map { |join| join.to_sql } * ' ' unless joins.empty?
13
+ orders = @table.all_orders
14
+ params[:order] = orders.map { |order| order.to_sql } * ', ' unless orders.empty?
15
+ params[:limit] = @conjunction.limit if @conjunction.limit
16
+ params[:offset] = @conjunction.offset if @conjunction.offset
17
+ params
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,69 @@
1
+ module RecordFilter
2
+ module Restrictions
3
+ class Base
4
+ def initialize(column_name, value, options={})
5
+ @column_name, @value, @negated = column_name, value, !!options.delete(:negated)
6
+ end
7
+
8
+ def to_conditions
9
+ @value.nil? ? [to_sql] : [to_sql, @value]
10
+ end
11
+
12
+ def to_sql
13
+ @negated ? to_negative_sql : to_positive_sql
14
+ end
15
+
16
+ def to_negative_sql
17
+ "!(#{to_positive_sql})"
18
+ end
19
+ end
20
+
21
+ class EqualTo < Base
22
+ def to_positive_sql
23
+ "#{@column_name} = ?"
24
+ end
25
+
26
+ def to_negative_sql
27
+ "#{@column_name} != ?"
28
+ end
29
+ end
30
+
31
+ class IsNull < Base
32
+ def to_positive_sql
33
+ "#{@column_name} IS NULL"
34
+ end
35
+
36
+ def to_negative_sql
37
+ "#{@column_name} IS NOT NULL"
38
+ end
39
+ end
40
+
41
+ class LessThan < Base
42
+ def to_positive_sql
43
+ "#{@column_name} < ?"
44
+ end
45
+ end
46
+
47
+ class GreaterThan < Base
48
+ def to_positive_sql
49
+ "#{@column_name} > ?"
50
+ end
51
+ end
52
+
53
+ class In < Base
54
+ def to_positive_sql
55
+ "#{@column_name} IN (?)"
56
+ end
57
+
58
+ def to_negative_sql
59
+ "#{@column_name} NOT IN (?)"
60
+ end
61
+ end
62
+
63
+ class Between < Base
64
+ def to_conditions
65
+ ["#{@column_name} #{'NOT ' if @negated}BETWEEN ? AND ?", @value.first, @value.last]
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,93 @@
1
+ module RecordFilter
2
+ class Table
3
+ attr_reader :table_alias
4
+
5
+ def initialize(model_class, table_alias = nil)
6
+ @model_class = model_class
7
+ @aliased = !table_alias.nil?
8
+ @table_alias = table_alias || model_class.quoted_table_name
9
+ @joins_cache = {}
10
+ @joins = []
11
+ @orders = []
12
+ end
13
+
14
+ def table_name
15
+ @model_class.quoted_table_name
16
+ end
17
+
18
+ def join_association(association_name)
19
+ @joins_cache[association_name] ||=
20
+ begin
21
+ association = @model_class.reflect_on_association(association_name)
22
+ case association.macro
23
+ when :belongs_to, :has_many, :has_one
24
+ simple_join(association)
25
+ when :has_and_belongs_to_many
26
+ compound_join(association)
27
+ end
28
+ end
29
+ end
30
+
31
+ def all_joins
32
+ @joins + @joins.inject([]) do |child_joins, join|
33
+ child_joins.concat(join.right_table.all_joins)
34
+ child_joins
35
+ end
36
+ end
37
+
38
+ def order_column(column, direction)
39
+ @orders << Order.new(column, direction, self)
40
+ end
41
+
42
+ def all_orders
43
+ @orders + @joins.inject([]) do |child_orders, join|
44
+ child_orders.concat(join.right_table.all_orders)
45
+ child_orders
46
+ end
47
+ end
48
+
49
+ private
50
+
51
+ def simple_join(association)
52
+ join_predicate =
53
+ case association.macro
54
+ when :belongs_to
55
+ { association.primary_key_name.to_sym => :id }
56
+ when :has_many, :has_one
57
+ { :id => association.primary_key_name.to_sym }
58
+ end
59
+ join_table = Table.new(association.klass, alias_for_association(association))
60
+ @joins << join = Join.new(self, join_table, join_predicate)
61
+ join
62
+ end
63
+
64
+ def compound_join(association)
65
+ pivot_join_predicate = { :id => association.primary_key_name.to_sym }
66
+ table_name = @model_class.connection.quote_table_name(association.options[:join_table])
67
+ pivot_table = PivotTable.new(table_name, "__#{alias_for_association(association)}")
68
+ pivot_join = Join.new(self, pivot_table, pivot_join_predicate)
69
+ join_predicate = { association.association_foreign_key => :id }
70
+ join_table = Table.new(association.klass, alias_for_association(association))
71
+ pivot_table.joins << join = Join.new(pivot_table, join_table, join_predicate)
72
+ @joins << pivot_join
73
+ join
74
+ end
75
+
76
+ protected
77
+
78
+ def alias_for_association(association)
79
+ "#{@aliased ? @table_alias.to_s : @model_class.table_name}__#{association.name}"
80
+ end
81
+ end
82
+
83
+ class PivotTable < Table
84
+ attr_reader :table_name, :joins
85
+
86
+ def initialize(table_name, table_alias = table_name)
87
+ @table_name, @table_alias = table_name, table_alias
88
+ @joins_cache = {}
89
+ @joins = []
90
+ @orders = []
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,10 @@
1
+ require 'rubygems'
2
+ gem 'activerecord', '~> 2.2'
3
+ require 'active_record'
4
+
5
+ %w(active_record query table conjunctions restrictions filter join order dsl).each do |file|
6
+ require File.join(File.dirname(__FILE__), 'record_filter', file)
7
+ end
8
+
9
+ module RecordFilter
10
+ end