ransack 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.
Files changed (51) hide show
  1. data/.gitignore +4 -0
  2. data/Gemfile +11 -0
  3. data/LICENSE +20 -0
  4. data/README.rdoc +5 -0
  5. data/Rakefile +19 -0
  6. data/lib/ransack.rb +24 -0
  7. data/lib/ransack/adapters/active_record.rb +2 -0
  8. data/lib/ransack/adapters/active_record/base.rb +17 -0
  9. data/lib/ransack/adapters/active_record/context.rb +153 -0
  10. data/lib/ransack/configuration.rb +39 -0
  11. data/lib/ransack/constants.rb +23 -0
  12. data/lib/ransack/context.rb +152 -0
  13. data/lib/ransack/helpers.rb +2 -0
  14. data/lib/ransack/helpers/form_builder.rb +172 -0
  15. data/lib/ransack/helpers/form_helper.rb +27 -0
  16. data/lib/ransack/locale/en.yml +67 -0
  17. data/lib/ransack/naming.rb +53 -0
  18. data/lib/ransack/nodes.rb +7 -0
  19. data/lib/ransack/nodes/and.rb +8 -0
  20. data/lib/ransack/nodes/attribute.rb +36 -0
  21. data/lib/ransack/nodes/condition.rb +209 -0
  22. data/lib/ransack/nodes/grouping.rb +207 -0
  23. data/lib/ransack/nodes/node.rb +34 -0
  24. data/lib/ransack/nodes/or.rb +8 -0
  25. data/lib/ransack/nodes/sort.rb +39 -0
  26. data/lib/ransack/nodes/value.rb +120 -0
  27. data/lib/ransack/predicate.rb +57 -0
  28. data/lib/ransack/search.rb +114 -0
  29. data/lib/ransack/translate.rb +92 -0
  30. data/lib/ransack/version.rb +3 -0
  31. data/ransack.gemspec +29 -0
  32. data/spec/blueprints/articles.rb +5 -0
  33. data/spec/blueprints/comments.rb +5 -0
  34. data/spec/blueprints/notes.rb +3 -0
  35. data/spec/blueprints/people.rb +4 -0
  36. data/spec/blueprints/tags.rb +3 -0
  37. data/spec/console.rb +22 -0
  38. data/spec/helpers/ransack_helper.rb +2 -0
  39. data/spec/playground.rb +37 -0
  40. data/spec/ransack/adapters/active_record/base_spec.rb +30 -0
  41. data/spec/ransack/adapters/active_record/context_spec.rb +29 -0
  42. data/spec/ransack/configuration_spec.rb +11 -0
  43. data/spec/ransack/helpers/form_builder_spec.rb +39 -0
  44. data/spec/ransack/nodes/compound_condition_spec.rb +0 -0
  45. data/spec/ransack/nodes/condition_spec.rb +0 -0
  46. data/spec/ransack/nodes/grouping_spec.rb +13 -0
  47. data/spec/ransack/predicate_spec.rb +25 -0
  48. data/spec/ransack/search_spec.rb +182 -0
  49. data/spec/spec_helper.rb +28 -0
  50. data/spec/support/schema.rb +102 -0
  51. metadata +200 -0
@@ -0,0 +1,4 @@
1
+ *.gem
2
+ .bundle
3
+ Gemfile.lock
4
+ pkg/*
data/Gemfile ADDED
@@ -0,0 +1,11 @@
1
+ source "http://rubygems.org"
2
+ gemspec
3
+
4
+ gem 'arel', :git => 'git://github.com/rails/arel.git'
5
+ gem 'rack', :git => 'git://github.com/rack/rack.git'
6
+
7
+ git 'git://github.com/rails/rails.git' do
8
+ gem 'activesupport'
9
+ gem 'activerecord'
10
+ gem 'actionpack'
11
+ end
data/LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright (c) 2010 Ernie Miller
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,5 @@
1
+ = Ransack
2
+
3
+ Don't use me.
4
+
5
+ Seriously, I'm not anywhere close to ready for public consumption, yet.
@@ -0,0 +1,19 @@
1
+ require 'bundler'
2
+ require 'rspec/core/rake_task'
3
+
4
+ Bundler::GemHelper.install_tasks
5
+
6
+ RSpec::Core::RakeTask.new(:spec) do |rspec|
7
+ rspec.rspec_opts = ['--backtrace']
8
+ end
9
+
10
+ task :default => :spec
11
+
12
+ desc "Open an irb session with Ransack and the sample data used in specs"
13
+ task :console do
14
+ require 'irb'
15
+ require 'irb/completion'
16
+ require 'console'
17
+ ARGV.clear
18
+ IRB.start
19
+ end
@@ -0,0 +1,24 @@
1
+ require 'ransack/configuration'
2
+
3
+ module Ransack
4
+ extend Configuration
5
+ end
6
+
7
+ Ransack.configure do |config|
8
+ Ransack::Constants::AREL_PREDICATES.each do |name|
9
+ config.add_predicate name, :arel_predicate => name
10
+ end
11
+
12
+ Ransack::Constants::DERIVED_PREDICATES.each do |args|
13
+ config.add_predicate *args
14
+ end
15
+ end
16
+
17
+ require 'ransack/translate'
18
+ require 'ransack/search'
19
+ require 'ransack/adapters/active_record'
20
+ require 'ransack/helpers'
21
+ require 'action_controller'
22
+
23
+ ActiveRecord::Base.extend Ransack::Adapters::ActiveRecord::Base
24
+ ActionController::Base.helper Ransack::Helpers::FormHelper
@@ -0,0 +1,2 @@
1
+ require 'ransack/adapters/active_record/base'
2
+ require 'ransack/adapters/active_record/context'
@@ -0,0 +1,17 @@
1
+ module Ransack
2
+ module Adapters
3
+ module ActiveRecord
4
+ module Base
5
+
6
+ def self.extended(base)
7
+ alias :search :ransack unless base.method_defined? :search
8
+ end
9
+
10
+ def ransack(params = {})
11
+ Search.new(self, params)
12
+ end
13
+
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,153 @@
1
+ require 'ransack/context'
2
+ require 'active_record'
3
+
4
+ module Ransack
5
+ module Adapters
6
+ module ActiveRecord
7
+ class Context < ::Ransack::Context
8
+ # Because the AR::Associations namespace is insane
9
+ JoinDependency = ::ActiveRecord::Associations::JoinDependency
10
+ JoinPart = JoinDependency::JoinPart
11
+ JoinAssociation = JoinDependency::JoinAssociation
12
+
13
+ def evaluate(search, opts = {})
14
+ relation = @object.where(accept(search.base)).order(accept(search.sorts))
15
+ opts[:distinct] ? relation.group(@klass.arel_table[@klass.primary_key]) : relation
16
+ end
17
+
18
+ def attribute_method?(str, klass = @klass)
19
+ exists = false
20
+
21
+ if column = get_column(str, klass)
22
+ exists = true
23
+ elsif (segments = str.split(/_/)).size > 1
24
+ remainder = []
25
+ found_assoc = nil
26
+ while !found_assoc && remainder.unshift(segments.pop) && segments.size > 0 do
27
+ if found_assoc = get_association(segments.join('_'), klass)
28
+ exists = attribute_method?(remainder.join('_'), found_assoc.klass)
29
+ end
30
+ end
31
+ end
32
+
33
+ exists
34
+ end
35
+
36
+ def type_for(attr)
37
+ return nil unless attr
38
+ name = attr.name.to_s
39
+ table = attr.relation.table_name
40
+
41
+ unless @engine.connection_pool.table_exists?(table)
42
+ raise "No table named #{table} exists"
43
+ end
44
+
45
+ @engine.connection_pool.columns_hash[table][name].type
46
+ end
47
+
48
+ private
49
+
50
+ def klassify(obj)
51
+ if Class === obj && ::ActiveRecord::Base > obj
52
+ obj
53
+ elsif obj.respond_to? :klass
54
+ obj.klass
55
+ elsif obj.respond_to? :active_record
56
+ obj.active_record
57
+ else
58
+ raise ArgumentError, "Don't know how to klassify #{obj}"
59
+ end
60
+ end
61
+
62
+ def get_attribute(str, parent = @base)
63
+ attribute = nil
64
+
65
+ if column = get_column(str, parent)
66
+ attribute = parent.table[str]
67
+ elsif (segments = str.split(/_/)).size > 1
68
+ remainder = []
69
+ found_assoc = nil
70
+ while remainder.unshift(segments.pop) && segments.size > 0 && !found_assoc do
71
+ if found_assoc = get_association(segments.join('_'), parent)
72
+ join = build_or_find_association(found_assoc.name, parent)
73
+ attribute = get_attribute(remainder.join('_'), join)
74
+ end
75
+ end
76
+ end
77
+
78
+ attribute
79
+ end
80
+
81
+ def get_column(str, parent = @base)
82
+ klassify(parent).columns_hash[str]
83
+ end
84
+
85
+ def get_association(str, parent = @base)
86
+ klassify(parent).reflect_on_all_associations.detect {|a| a.name.to_s == str}
87
+ end
88
+
89
+ def join_dependency(relation)
90
+ if relation.respond_to?(:join_dependency) # MetaWhere will enable this
91
+ relation.join_dependency
92
+ else
93
+ build_join_dependency(relation)
94
+ end
95
+ end
96
+
97
+ def build_join_dependency(relation)
98
+ buckets = relation.joins_values.group_by do |join|
99
+ case join
100
+ when String
101
+ 'string_join'
102
+ when Hash, Symbol, Array
103
+ 'association_join'
104
+ when ActiveRecord::Associations::JoinDependency::JoinAssociation
105
+ 'stashed_join'
106
+ when Arel::Nodes::Join
107
+ 'join_node'
108
+ else
109
+ raise 'unknown class: %s' % join.class.name
110
+ end
111
+ end
112
+
113
+ association_joins = buckets['association_join'] || []
114
+ stashed_association_joins = buckets['stashed_join'] || []
115
+ join_nodes = buckets['join_node'] || []
116
+ string_joins = (buckets['string_join'] || []).map { |x|
117
+ x.strip
118
+ }.uniq
119
+
120
+ join_list = relation.send :custom_join_ast, relation.table.from(relation.table), string_joins
121
+
122
+ join_dependency = JoinDependency.new(
123
+ relation.klass,
124
+ association_joins,
125
+ join_list
126
+ )
127
+
128
+ join_nodes.each do |join|
129
+ join_dependency.table_aliases[join.left.name.downcase] = 1
130
+ end
131
+
132
+ join_dependency.graft(*stashed_association_joins)
133
+ end
134
+
135
+ def build_or_find_association(name, parent = @base)
136
+ found_association = @join_dependency.join_associations.detect do |assoc|
137
+ assoc.reflection.name == name &&
138
+ assoc.parent == parent
139
+ end
140
+ unless found_association
141
+ @join_dependency.send(:build, name.to_sym, parent, Arel::Nodes::OuterJoin)
142
+ found_association = @join_dependency.join_associations.last
143
+ # Leverage the stashed association functionality in AR
144
+ @object = @object.joins(found_association)
145
+ end
146
+
147
+ found_association
148
+ end
149
+
150
+ end
151
+ end
152
+ end
153
+ end
@@ -0,0 +1,39 @@
1
+ require 'ransack/constants'
2
+ require 'ransack/predicate'
3
+
4
+ module Ransack
5
+ module Configuration
6
+
7
+ mattr_accessor :predicates
8
+ self.predicates = {}
9
+
10
+ def self.predicate_keys
11
+ predicates.keys.sort {|a,b| b.length <=> a.length}
12
+ end
13
+
14
+ def configure
15
+ yield self
16
+ end
17
+
18
+ def add_predicate(name, opts = {})
19
+ name = name.to_s
20
+ opts[:name] = name
21
+ compounds = opts.delete(:compounds)
22
+ compounds = true if compounds.nil?
23
+ opts[:arel_predicate] = opts[:arel_predicate].to_s
24
+
25
+ self.predicates[name] = Predicate.new(opts)
26
+
27
+ ['_any', '_all'].each do |suffix|
28
+ self.predicates[name + suffix] = Predicate.new(
29
+ opts.merge(
30
+ :name => name + suffix,
31
+ :arel_predicate => opts[:arel_predicate] + suffix,
32
+ :compound => true
33
+ )
34
+ )
35
+ end if compounds
36
+ end
37
+
38
+ end
39
+ end
@@ -0,0 +1,23 @@
1
+ module Ransack
2
+ module Constants
3
+ TRUE_VALUES = [true, 1, '1', 't', 'T', 'true', 'TRUE'].to_set
4
+ FALSE_VALUES = [false, 0, '0', 'f', 'F', 'false', 'FALSE'].to_set
5
+
6
+ AREL_PREDICATES = %w(eq not_eq matches does_not_match lt lteq gt gteq in not_in)
7
+
8
+ DERIVED_PREDICATES = [
9
+ ['cont', {:arel_predicate => 'matches', :formatter => proc {|v| "%#{v}%"}}],
10
+ ['not_cont', {:arel_predicate => 'does_not_match', :formatter => proc {|v| "%#{v}%"}}],
11
+ ['start', {:arel_predicate => 'matches', :formatter => proc {|v| "#{v}%"}}],
12
+ ['not_start', {:arel_predicate => 'does_not_match', :formatter => proc {|v| "#{v}%"}}],
13
+ ['end', {:arel_predicate => 'matches', :formatter => proc {|v| "%#{v}"}}],
14
+ ['not_end', {:arel_predicate => 'does_not_match', :formatter => proc {|v| "%#{v}"}}],
15
+ ['true', {:arel_predicate => 'eq', :compounds => false, :type => :boolean, :validator => proc {|v| TRUE_VALUES.include?(v)}}],
16
+ ['false', {:arel_predicate => 'eq', :compounds => false, :type => :boolean, :validator => proc {|v| TRUE_VALUES.include?(v)}, :formatter => proc {|v| !v}}],
17
+ ['present', {:arel_predicate => 'not_eq_all', :compounds => false, :type => :boolean, :validator => proc {|v| TRUE_VALUES.include?(v)}, :formatter => proc {|v| [nil, '']}}],
18
+ ['blank', {:arel_predicate => 'eq_any', :compounds => false, :type => :boolean, :validator => proc {|v| TRUE_VALUES.include?(v)}, :formatter => proc {|v| [nil, '']}}],
19
+ ['null', {:arel_predicate => 'eq', :compounds => false, :type => :boolean, :validator => proc {|v| TRUE_VALUES.include?(v)}, :formatter => proc {|v| nil}}],
20
+ ['not_null', {:arel_predicate => 'not_eq', :compounds => false, :type => :boolean, :validator => proc {|v| TRUE_VALUES.include?(v)}, :formatter => proc {|v| nil}}]
21
+ ]
22
+ end
23
+ end
@@ -0,0 +1,152 @@
1
+ module Ransack
2
+ class Context
3
+ attr_reader :search, :object, :klass, :base, :engine, :arel_visitor
4
+
5
+ class << self
6
+
7
+ def for(object)
8
+ context = Class === object ? for_class(object) : for_object(object)
9
+ context or raise ArgumentError, "Don't know what context to use for #{object}"
10
+ end
11
+
12
+ def for_class(klass)
13
+ if klass < ActiveRecord::Base
14
+ Adapters::ActiveRecord::Context.new(klass)
15
+ end
16
+ end
17
+
18
+ def for_object(object)
19
+ case object
20
+ when ActiveRecord::Relation
21
+ Adapters::ActiveRecord::Context.new(object.klass)
22
+ end
23
+ end
24
+
25
+ def can_accept?(object)
26
+ method_defined? DISPATCH[object.class]
27
+ end
28
+
29
+ end
30
+
31
+ def initialize(object)
32
+ @object = object.scoped
33
+ @klass = @object.klass
34
+ @join_dependency = join_dependency(@object)
35
+ @base = @join_dependency.join_base
36
+ @engine = @base.arel_engine
37
+ @arel_visitor = Arel::Visitors.visitor_for @engine
38
+ @default_table = Arel::Table.new(@base.table_name, :as => @base.aliased_table_name, :engine => @engine)
39
+ @attributes = Hash.new do |hash, key|
40
+ if attribute = get_attribute(key.to_s)
41
+ hash[key] = attribute
42
+ end
43
+ end
44
+ end
45
+
46
+ # Convert a string representing a chain of associations and an attribute
47
+ # into the attribute itself
48
+ def contextualize(str)
49
+ @attributes[str]
50
+ end
51
+
52
+ def traverse(str, base = @base)
53
+ str ||= ''
54
+
55
+ if (segments = str.split(/_/)).size > 0
56
+ association_parts = []
57
+ found_assoc = nil
58
+ while !found_assoc && segments.size > 0 && association_parts << segments.shift do
59
+ if found_assoc = get_association(association_parts.join('_'), base)
60
+ base = traverse(segments.join('_'), found_assoc.klass)
61
+ end
62
+ end
63
+ raise ArgumentError, "No association matches #{str}" unless found_assoc
64
+ end
65
+
66
+ klassify(base)
67
+ end
68
+
69
+ def association_path(str, base = @base)
70
+ base = klassify(base)
71
+ str ||= ''
72
+ path = []
73
+ segments = str.split(/_/)
74
+ association_parts = []
75
+ if (segments = str.split(/_/)).size > 0
76
+ while segments.size > 0 && !base.columns_hash[segments.join('_')] && association_parts << segments.shift do
77
+ if found_assoc = get_association(association_parts.join('_'), base)
78
+ path += association_parts
79
+ association_parts = []
80
+ base = klassify(found_assoc)
81
+ end
82
+ end
83
+ end
84
+
85
+ path.join('_')
86
+ end
87
+
88
+ def searchable_columns(str = '')
89
+ traverse(str).column_names
90
+ end
91
+
92
+ def accept(object)
93
+ visit(object)
94
+ end
95
+
96
+ def can_accept?(object)
97
+ respond_to? DISPATCH[object.class]
98
+ end
99
+
100
+ def visit_Array(object)
101
+ object.map {|o| accept(o)}.compact
102
+ end
103
+
104
+ def visit_Ransack_Nodes_Condition(object)
105
+ object.apply_predicate if object.valid?
106
+ end
107
+
108
+ def visit_Ransack_Nodes_And(object)
109
+ nodes = object.values.map {|o| accept(o)}.compact
110
+ return nil unless nodes.size > 0
111
+
112
+ if nodes.size > 1
113
+ Arel::Nodes::Grouping.new(Arel::Nodes::And.new(nodes))
114
+ else
115
+ nodes.first
116
+ end
117
+ end
118
+
119
+ def visit_Ransack_Nodes_Sort(object)
120
+ object.attr.send(object.dir) if object.valid?
121
+ end
122
+
123
+ def visit_Ransack_Nodes_Or(object)
124
+ nodes = object.values.map {|o| accept(o)}.compact
125
+ return nil unless nodes.size > 0
126
+
127
+ if nodes.size > 1
128
+ nodes.inject(&:or)
129
+ else
130
+ nodes.first
131
+ end
132
+ end
133
+
134
+ def quoted?(object)
135
+ case object
136
+ when Arel::Nodes::SqlLiteral, Bignum, Fixnum
137
+ false
138
+ else
139
+ true
140
+ end
141
+ end
142
+
143
+ def visit(object)
144
+ send(DISPATCH[object.class], object)
145
+ end
146
+
147
+ DISPATCH = Hash.new do |hash, klass|
148
+ hash[klass] = "visit_#{klass.name.gsub('::', '_')}"
149
+ end
150
+
151
+ end
152
+ end