grouped_scope 0.6.1 → 3.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/.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
|
+
[](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
|