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.
- data/.travis.yml +5 -0
- data/CHANGELOG +13 -0
- data/Gemfile +4 -6
- data/README.md +173 -0
- data/lib/grouped_scope.rb +7 -7
- data/lib/grouped_scope/arish/associations/association_scope.rb +90 -0
- data/lib/grouped_scope/arish/associations/builder/grouped_association.rb +50 -0
- data/lib/grouped_scope/arish/associations/builder/grouped_collection_association.rb +32 -0
- data/lib/grouped_scope/arish/associations/collection_association.rb +25 -0
- data/lib/grouped_scope/arish/base.rb +24 -0
- data/lib/grouped_scope/arish/reflection.rb +18 -0
- data/lib/grouped_scope/arish/relation/predicate_builer.rb +27 -0
- data/lib/grouped_scope/errors.rb +2 -3
- data/lib/grouped_scope/self_grouping.rb +59 -23
- data/lib/grouped_scope/version.rb +2 -4
- data/test/cases/has_many_test.rb +155 -0
- data/test/cases/has_many_through_test.rb +51 -0
- data/test/cases/reflection_test.rb +62 -0
- data/test/cases/self_grouping_test.rb +201 -0
- data/test/helper.rb +48 -35
- metadata +27 -30
- data/README.rdoc +0 -98
- data/lib/grouped_scope/association_reflection.rb +0 -54
- data/lib/grouped_scope/class_methods.rb +0 -32
- data/lib/grouped_scope/core_ext.rb +0 -29
- data/lib/grouped_scope/grouping.rb +0 -9
- data/lib/grouped_scope/has_many_association.rb +0 -28
- data/lib/grouped_scope/has_many_through_association.rb +0 -28
- data/lib/grouped_scope/instance_methods.rb +0 -10
- data/test/grouped_scope/association_reflection_test.rb +0 -73
- data/test/grouped_scope/class_methods_test.rb +0 -51
- data/test/grouped_scope/has_many_association_test.rb +0 -156
- data/test/grouped_scope/has_many_through_association_test.rb +0 -51
- data/test/grouped_scope/self_grouping_test.rb +0 -146
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.
|
12
|
+
gem 'rake', '~> 0.9.2'
|
14
13
|
end
|
15
14
|
|
16
15
|
group :test do
|
17
|
-
gem 'minitest',
|
18
|
-
gem '
|
19
|
-
gem '
|
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
|
|
data/README.md
ADDED
@@ -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
|
+
|
data/lib/grouped_scope.rb
CHANGED
@@ -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
|