grouped_scope 0.6.1 → 3.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (34) hide show
  1. data/.travis.yml +5 -0
  2. data/CHANGELOG +13 -0
  3. data/Gemfile +4 -6
  4. data/README.md +173 -0
  5. data/lib/grouped_scope.rb +7 -7
  6. data/lib/grouped_scope/arish/associations/association_scope.rb +90 -0
  7. data/lib/grouped_scope/arish/associations/builder/grouped_association.rb +50 -0
  8. data/lib/grouped_scope/arish/associations/builder/grouped_collection_association.rb +32 -0
  9. data/lib/grouped_scope/arish/associations/collection_association.rb +25 -0
  10. data/lib/grouped_scope/arish/base.rb +24 -0
  11. data/lib/grouped_scope/arish/reflection.rb +18 -0
  12. data/lib/grouped_scope/arish/relation/predicate_builer.rb +27 -0
  13. data/lib/grouped_scope/errors.rb +2 -3
  14. data/lib/grouped_scope/self_grouping.rb +59 -23
  15. data/lib/grouped_scope/version.rb +2 -4
  16. data/test/cases/has_many_test.rb +155 -0
  17. data/test/cases/has_many_through_test.rb +51 -0
  18. data/test/cases/reflection_test.rb +62 -0
  19. data/test/cases/self_grouping_test.rb +201 -0
  20. data/test/helper.rb +48 -35
  21. metadata +27 -30
  22. data/README.rdoc +0 -98
  23. data/lib/grouped_scope/association_reflection.rb +0 -54
  24. data/lib/grouped_scope/class_methods.rb +0 -32
  25. data/lib/grouped_scope/core_ext.rb +0 -29
  26. data/lib/grouped_scope/grouping.rb +0 -9
  27. data/lib/grouped_scope/has_many_association.rb +0 -28
  28. data/lib/grouped_scope/has_many_through_association.rb +0 -28
  29. data/lib/grouped_scope/instance_methods.rb +0 -10
  30. data/test/grouped_scope/association_reflection_test.rb +0 -73
  31. data/test/grouped_scope/class_methods_test.rb +0 -51
  32. data/test/grouped_scope/has_many_association_test.rb +0 -156
  33. data/test/grouped_scope/has_many_through_association_test.rb +0 -51
  34. data/test/grouped_scope/self_grouping_test.rb +0 -146
@@ -0,0 +1,5 @@
1
+ rvm:
2
+ - 1.8.7
3
+ - 1.9.2
4
+ - 1.9.3
5
+ - ree
data/CHANGELOG CHANGED
@@ -1,6 +1,19 @@
1
1
 
2
2
  = master
3
3
 
4
+ = 3.1.0 (unreleased)
5
+
6
+ * Works with ActiveRecord 3.1
7
+
8
+ * The group object is now an ActiveRecord::Relation so you can further scope it.
9
+
10
+ * New group scoped #blank? and #present? which simply checks if the proxy owner has a group set.
11
+ This allows us to tune the SQL generated to IN statements only when needed, even if a grouped
12
+ scope is being used.
13
+
14
+ * New group.ids_sql which is an Arel SQL literal. Avoids large groups IDs and better query plans.
15
+
16
+
4
17
  = 0.6.0 (May 06, 2009)
5
18
 
6
19
  * ActiveRecord 2.3.14 compatibility.
data/Gemfile CHANGED
@@ -7,16 +7,14 @@ ar_version = spec.dependencies.detect{ |d|d.name == 'activerecord' }.requirement
7
7
 
8
8
  gem 'sqlite3', '1.3.4'
9
9
  gem 'activerecord', ar_version, :require => 'active_record'
10
- gem 'will_paginate', '2.3.16'
11
10
 
12
11
  group :development do
13
- gem 'rake', '0.8.7'
12
+ gem 'rake', '~> 0.9.2'
14
13
  end
15
14
 
16
15
  group :test do
17
- gem 'minitest', '2.5.1'
18
- gem 'mini_shoulda', '0.4.0'
19
- gem 'factory_girl', '2.1.0'
20
- gem 'mocha', '0.10.0'
16
+ gem 'minitest', '~> 2.8.1'
17
+ gem 'factory_girl', '~> 2.3.2'
18
+ gem 'mocha', '~> 0.10.0'
21
19
  end
22
20
 
@@ -0,0 +1,173 @@
1
+
2
+ # GroupedScope: Has Many Associations IN (GROUPS)
3
+
4
+ <img src="http://metaskills.net/assets/jack.png" alt="Jack Has Many Things" width="320" height="214" style="float:right; margin:0 0 15px 15px; background-color:#fff; padding:13px;">
5
+
6
+ GroupedScope provides an easy way to group objects and to allow those groups to share association collections via existing `has_many` relationships. You may enjoy my original article titled [*Jack has_many :things*](http://metaskills.net/2008/09/28/jack-has_many-things/).
7
+
8
+
9
+ ## Installation
10
+
11
+ Install the gem with bundler. We follow a semantic versioning format that tracks ActiveRecord's minor version. So this means to use the latest 3.1.x version of GroupedScope with any ActiveRecord 3.1 version.
12
+
13
+ ```ruby
14
+ gem 'grouped_scope', '~> 3.1.0'
15
+ ```
16
+
17
+
18
+ ## Setup
19
+
20
+ To use GroupedScope on a model it must have a `:group_id` column.
21
+
22
+ ```ruby
23
+ class AddGroupId < ActiveRecord::Migration
24
+ def up
25
+ add_column :employees, :group_id, :integer
26
+ end
27
+ def down
28
+ remove_column :employees, :group_id
29
+ end
30
+ end
31
+ ```
32
+
33
+
34
+ ## General Usage
35
+
36
+ Assume the following model.
37
+
38
+ ```ruby
39
+ class Employee < ActiveRecord::Base
40
+ has_many :reports
41
+ grouped_scope :reports
42
+ end
43
+ ```
44
+
45
+ By calling grouped_scope on any association you create a new group accessor for each
46
+ instance. The object returned will act just like an array and at least include the
47
+ current object that called it.
48
+
49
+ ```ruby
50
+ @employee_one.group # => [#<Employee id: 1, group_id: nil>]
51
+ ```
52
+
53
+ To group resources, just assign the same `:group_id` to each record in that group.
54
+
55
+ ```ruby
56
+ @employee_one.update_attribute :group_id, 1
57
+ @employee_two.update_attribute :group_id, 1
58
+ @employee_one.group # => [#<Employee id: 1, group_id: 1>, #<Employee id: 2, group_id: 1>]
59
+ ```
60
+
61
+ Calling grouped_scope on the :reports association leaves the existing association intact.
62
+
63
+ ```ruby
64
+ @employee_one.reports # => [#<Report id: 2, employee_id: 1>]
65
+ @employee_two.reports # => [#<Report id: 18, employee_id: 2>, #<Report id: 36, employee_id: 2>]
66
+ ```
67
+
68
+ Now the good part, all associations passed to the grouped_scope method can be called
69
+ on the group proxy. The collection will return resources shared by the group.
70
+
71
+ ```ruby
72
+ @employee_one.group.reports # => [#<Report id: 2, employee_id: 1>,
73
+ #<Report id: 18, employee_id: 2>,
74
+ #<Report id: 36, employee_id: 2>]
75
+ ```
76
+
77
+ You can even call scopes or association extensions defined on the objects in the collection
78
+ defined on the original association. For instance:
79
+
80
+ ```ruby
81
+ @employee.group.reports.urgent.assigned_to(user)
82
+ ```
83
+
84
+
85
+ ## Advanced Usage
86
+
87
+ The group scoped object can respond to either `blank?` or `present?` which checks the group's
88
+ target `group_id` presence or not. We use this internally so that grouped scopes only use grouping
89
+ SQL when absolutely needed.
90
+
91
+ ```ruby
92
+ @employee_one = Employee.create :group_id => nil
93
+ @employee_two = Employee.create :group_id => 38
94
+
95
+ @employee_one.group.blank? # => true
96
+ @employee_two.group.present? # => true
97
+ ```
98
+
99
+ The object returned by the `#group` method is an ActiveRecord relation on the targets class,
100
+ in this case `Employee`. Given this, you can further scope the grouped proxy if needed. Below,
101
+ we use the `:email_present` scope to refine the group down.
102
+
103
+ ```ruby
104
+ class Employee < ActiveRecord::Base
105
+ has_many :reports
106
+ grouped_scope :reports
107
+ scope :email_present, where("email IS NOT NULL")
108
+ end
109
+
110
+ @employee_one = Employee.create :group_id => 5, :name => 'Ken'
111
+ @employee_two = Employee.create :group_id => 5, :name => 'MetaSkills', :email => 'ken@metaskills.net'
112
+
113
+ # Only one employee is returned now.
114
+ @employee_one.group.email_present # => [#<Employee id: 1, group_id: 5, name: 'MetaSkills', email: 'ken@metaskills.net']
115
+ ```
116
+
117
+ We always use raw SQL to get the group ids vs. mapping them to an array and using those in scopes.
118
+ This means that large groups can avoid pushing down hundreds of keys in SQL form. So given an employee
119
+ with a `group_id` of `43` and calling `@employee.group.reports`, you would get something similar to
120
+ the following SQL.
121
+
122
+ ```sql
123
+ SELECT "reports".*
124
+ FROM "reports"
125
+ WHERE "reports"."employee_id" IN (
126
+ SELECT "employees"."id"
127
+ FROM "employees"
128
+ WHERE "employees"."group_id" = 43
129
+ )
130
+ ```
131
+
132
+ You can pass the group scoped object as a predicate to ActiveRecord's relation interface. In past
133
+ versions, this would have treated the group object as an array of IDs. The new behavior is to return
134
+ a SQL literal to be used with IN statements. So note, the following would generate SQL similar to
135
+ the one above.
136
+
137
+ ```ruby
138
+ Employee.where(:group_id => @employee.group).all
139
+ ```
140
+
141
+ If you need more control and you are working with the group at a lower level, you can always
142
+ use the `#ids` or `#ids_sql` methods on the group.
143
+
144
+ ```ruby
145
+ # Returns primary key array.
146
+ @employee.group.ids # => [33, 58, 240]
147
+
148
+ # Returns a Arel::Nodes::SqlLiteral object.
149
+ @employee.group.ids_sql # => 'SELECT "employees"."id" FROM "employees" WHERE "employees"."group_id" = 33'
150
+ ```
151
+
152
+
153
+ ## Todo List
154
+
155
+ * Raise errors for :finder_sql/:counter_sql.
156
+ * Add a user definable group_id schema.
157
+ * Remove SelfGrouping#with_relation, has not yet proved useful.
158
+
159
+
160
+
161
+ ## Testing
162
+
163
+ Simple! Just clone the repo, then run `bundle install` and `bundle exec rake`. The tests will begin to run. We also use Travis CI to run our tests too. Current build status is:
164
+
165
+ [![Build Status](https://secure.travis-ci.org/metaskills/grouped_scope.png)](http://travis-ci.org/metaskills/grouped_scope)
166
+
167
+
168
+
169
+ ## License
170
+
171
+ Released under the MIT license.
172
+ Copyright (c) 2011 Ken Collins
173
+
@@ -1,11 +1,11 @@
1
1
  require 'active_record/version'
2
2
  require 'grouped_scope/errors'
3
- require 'grouped_scope/grouping'
4
3
  require 'grouped_scope/self_grouping'
5
- require 'grouped_scope/association_reflection'
6
- require 'grouped_scope/class_methods'
7
- require 'grouped_scope/has_many_association'
8
- require 'grouped_scope/has_many_through_association'
9
- require 'grouped_scope/core_ext'
10
- require 'grouped_scope/version'
11
4
 
5
+ require 'grouped_scope/arish/reflection'
6
+ require 'grouped_scope/arish/associations/collection_association'
7
+ require 'grouped_scope/arish/associations/builder/grouped_association'
8
+ require 'grouped_scope/arish/associations/builder/grouped_collection_association'
9
+ require 'grouped_scope/arish/associations/association_scope'
10
+ require 'grouped_scope/arish/relation/predicate_builer'
11
+ require 'grouped_scope/arish/base'
@@ -0,0 +1,90 @@
1
+ module GroupedScope
2
+ module Arish
3
+ module Associations
4
+ class AssociationScope < ActiveRecord::Associations::AssociationScope
5
+
6
+
7
+ private
8
+
9
+ # A direct copy of of ActiveRecord's AssociationScope#add_constraints. If this was
10
+ # in chunks, it would be easier to hook into. This more elegant version which supers
11
+ # up will only work for the has_many. https://gist.github.com/1434980
12
+ #
13
+ # We will just have to monitor rails every now and then and update this. Thankfully this
14
+ # copy is only used in a group scope. FYI, our one line change is commented below.
15
+ def add_constraints(scope)
16
+ tables = construct_tables
17
+
18
+ chain.each_with_index do |reflection, i|
19
+ table, foreign_table = tables.shift, tables.first
20
+
21
+ if reflection.source_macro == :has_and_belongs_to_many
22
+ join_table = tables.shift
23
+
24
+ scope = scope.joins(join(
25
+ join_table,
26
+ table[reflection.association_primary_key].
27
+ in(join_table[reflection.association_foreign_key])
28
+ ))
29
+
30
+ table, foreign_table = join_table, tables.first
31
+ end
32
+
33
+ if reflection.source_macro == :belongs_to
34
+ if reflection.options[:polymorphic]
35
+ key = reflection.association_primary_key(klass)
36
+ else
37
+ key = reflection.association_primary_key
38
+ end
39
+
40
+ foreign_key = reflection.foreign_key
41
+ else
42
+ key = reflection.foreign_key
43
+ foreign_key = reflection.active_record_primary_key
44
+ end
45
+
46
+ conditions = self.conditions[i]
47
+
48
+ if reflection == chain.last
49
+ # GroupedScope changed this line.
50
+ # scope = scope.where(table[key].eq(owner[foreign_key]))
51
+ scope = if owner.group.present?
52
+ scope.where(table[key].in(owner.group.ids_sql))
53
+ else
54
+ scope.where(table[key].eq(owner[foreign_key]))
55
+ end
56
+
57
+ if reflection.type
58
+ scope = scope.where(table[reflection.type].eq(owner.class.base_class.name))
59
+ end
60
+
61
+ conditions.each do |condition|
62
+ if options[:through] && condition.is_a?(Hash)
63
+ condition = { table.name => condition }
64
+ end
65
+
66
+ scope = scope.where(interpolate(condition))
67
+ end
68
+ else
69
+ constraint = table[key].eq(foreign_table[foreign_key])
70
+
71
+ if reflection.type
72
+ type = chain[i + 1].klass.base_class.name
73
+ constraint = constraint.and(table[reflection.type].eq(type))
74
+ end
75
+
76
+ scope = scope.joins(join(foreign_table, constraint))
77
+
78
+ unless conditions.empty?
79
+ scope = scope.where(sanitize(conditions, table))
80
+ end
81
+ end
82
+ end
83
+
84
+ scope
85
+ end
86
+
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,50 @@
1
+ module GroupedScope
2
+ module Arish
3
+ module Associations
4
+ module Builder
5
+ class GroupedAssociation
6
+
7
+ attr_reader :model, :ungrouped_name, :ungrouped_reflection, :grouped_name, :grouped_options
8
+
9
+ def self.build(model, *association_names)
10
+ association_names.each { |ungrouped_name| new(model, ungrouped_name).build }
11
+ end
12
+
13
+ def initialize(model, ungrouped_name)
14
+ @model = model
15
+ @ungrouped_name = ungrouped_name
16
+ @ungrouped_reflection = find_ungrouped_reflection
17
+ @grouped_name = :"grouped_scope_#{ungrouped_name}"
18
+ @grouped_options = copy_ungrouped_reflection_options
19
+ end
20
+
21
+ def build
22
+ model.send(ungrouped_reflection.macro, grouped_name, grouped_options).tap do |grouped_reflection|
23
+ grouped_reflection.grouped_scope = true
24
+ model.grouped_reflections = model.grouped_reflections.merge(ungrouped_name => grouped_reflection)
25
+ define_grouped_scope_reader(model)
26
+ end
27
+ end
28
+
29
+
30
+ private
31
+
32
+ def define_grouped_scope_reader(model)
33
+ model.send(:define_method, :group) do
34
+ @group ||= GroupedScope::SelfGroupping.new(self)
35
+ end
36
+ end
37
+
38
+ def find_ungrouped_reflection
39
+ model.reflections[ungrouped_name]
40
+ end
41
+
42
+ def copy_ungrouped_reflection_options
43
+ ungrouped_reflection.options.dup
44
+ end
45
+
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,32 @@
1
+ module GroupedScope
2
+ module Arish
3
+ module Associations
4
+ module Builder
5
+ class GroupedCollectionAssociation < GroupedAssociation
6
+
7
+ private
8
+
9
+ def find_ungrouped_reflection
10
+ reflection = model.reflections[ungrouped_name]
11
+ if reflection.blank? || [:has_many, :has_and_belongs_to_many].exclude?(reflection.macro)
12
+ msg = "Cannot create a group scope for #{ungrouped_name.inspect}. Either the reflection is blank or not supported. " +
13
+ "Make sure to call grouped_scope after the association you are trying to extend has been defined."
14
+ raise ArgumentError, msg
15
+ end
16
+ reflection
17
+ end
18
+
19
+ def copy_ungrouped_reflection_options
20
+ ungrouped_reflection.options.dup.tap do |options|
21
+ options[:class_name] = ungrouped_reflection.class_name
22
+ if ungrouped_reflection.source_reflection && options[:source].blank?
23
+ options[:source] = ungrouped_reflection.source_reflection.name
24
+ end
25
+ end
26
+ end
27
+
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,25 @@
1
+ module GroupedScope
2
+ module Arish
3
+ module Associations
4
+ module CollectionAssociation
5
+
6
+ extend ActiveSupport::Concern
7
+
8
+ module InstanceMethods
9
+
10
+ def association_scope
11
+ if reflection.grouped_scope?
12
+ @association_scope ||= Associations::AssociationScope.new(self).scope if klass
13
+ else
14
+ super
15
+ end
16
+ end
17
+
18
+ end
19
+
20
+ end
21
+ end
22
+ end
23
+ end
24
+
25
+ ActiveRecord::Associations::CollectionAssociation.send :include, GroupedScope::Arish::Associations::CollectionAssociation
@@ -0,0 +1,24 @@
1
+ module GroupedScope
2
+ module Arish
3
+ module Base
4
+
5
+ extend ActiveSupport::Concern
6
+
7
+ included do
8
+ class_attribute :grouped_reflections, :instance_reader => false, :instance_writer => false
9
+ self.grouped_reflections = {}.freeze
10
+ end
11
+
12
+ module ClassMethods
13
+
14
+ def grouped_scope(*association_names)
15
+ Associations::Builder::GroupedCollectionAssociation.build(self, *association_names)
16
+ end
17
+
18
+ end
19
+
20
+ end
21
+ end
22
+ end
23
+
24
+ ActiveRecord::Base.send :include, GroupedScope::Arish::Base