aggregate_columns 0.9
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/README +91 -0
- data/Rakefile +50 -0
- data/lib/aggregate_columns.rb +104 -0
- data/test/aggregate_columns_test.rb +82 -0
- data/test/test_helper.rb +42 -0
- metadata +70 -0
data/README
ADDED
@@ -0,0 +1,91 @@
|
|
1
|
+
Introduction
|
2
|
+
============
|
3
|
+
|
4
|
+
This gem provides a nice API to create complicated SQL queries for associated
|
5
|
+
and aggregated tables.
|
6
|
+
|
7
|
+
Example
|
8
|
+
=======
|
9
|
+
|
10
|
+
Take a classic example:
|
11
|
+
|
12
|
+
class Post
|
13
|
+
has_many :comments
|
14
|
+
has_many :tags
|
15
|
+
end
|
16
|
+
|
17
|
+
Now let's say you want to display a table with some posts' attributes including
|
18
|
+
number of comments. Normally you would use post.comments.count, but that
|
19
|
+
generates separate query for each post. Alternatively you could use counter
|
20
|
+
cache, but it can get out of sync plus it only works on this simple example,
|
21
|
+
while you could also need the time of latest comment for each post.
|
22
|
+
|
23
|
+
|
24
|
+
AggregateColumns solution would look like this:
|
25
|
+
|
26
|
+
Post.all( Post.aggregate_columns_options( :association => :comments ))
|
27
|
+
|
28
|
+
This returns posts with additional column called "comment_count" containing (big
|
29
|
+
surprise here) number of comments for each post. I'm taking advantage of the
|
30
|
+
fact columns specified in :select option are reachable from fetched objects (but
|
31
|
+
not type-casted, so comment_count will be a String).
|
32
|
+
|
33
|
+
|
34
|
+
What if you did not want such a simple aggregation, but the one I already
|
35
|
+
mentioned: time of latest comment for each post:
|
36
|
+
|
37
|
+
Post.all( Post.aggregate_columns_options( :association => :comments, :function => :max, :column => :created_at ))
|
38
|
+
|
39
|
+
The name of aggregated column would be comments_created_at_max in this case, but
|
40
|
+
it can be changed using :result_column option.
|
41
|
+
|
42
|
+
|
43
|
+
Now something really complicated - sum of votes for comments whose authors are
|
44
|
+
active. Here's where :conditions and :joins options come in handy:
|
45
|
+
|
46
|
+
Post.all( Post.aggregate_columns_options(
|
47
|
+
:association => :comments,
|
48
|
+
:function => :sum,
|
49
|
+
:column => :votes,
|
50
|
+
:joins => "INNER JOIN authors ON comments.author_id = authors.id",
|
51
|
+
:conditions => ["authors.active = ?", true],
|
52
|
+
:result_column => :comment_vote_count
|
53
|
+
)
|
54
|
+
)
|
55
|
+
|
56
|
+
Important things to note here:
|
57
|
+
* aggregate_column_options return :select, :joins and :order options, so those
|
58
|
+
cannot be used for other purposes
|
59
|
+
* all options are used internally in aggregate subqueries, so they do not clash
|
60
|
+
with normal find options (other than aforementioned ones). This means you can
|
61
|
+
merge resulting options with eg. custom :conditions
|
62
|
+
|
63
|
+
|
64
|
+
You may also define multiple aggregate columns in one call:
|
65
|
+
|
66
|
+
Post.aggregate_columns_options(
|
67
|
+
{ :association => :comments, :function => :max, :column => :created_at },
|
68
|
+
{ :association => :tags, :result_column => :number_of_tags }
|
69
|
+
)
|
70
|
+
|
71
|
+
|
72
|
+
Yet another method to combine aggregate columns with other find options is to use scopes:
|
73
|
+
|
74
|
+
Post.aggregate_columns_scope( :association => :comments ).scoped( ...
|
75
|
+
|
76
|
+
Rails 3
|
77
|
+
=======
|
78
|
+
|
79
|
+
In Rails 3 you should use:
|
80
|
+
|
81
|
+
Post.aggregate_colums( :association => :comments )
|
82
|
+
|
83
|
+
which returns a full blown ActiveRecord::Relation object
|
84
|
+
|
85
|
+
Thanks
|
86
|
+
======
|
87
|
+
|
88
|
+
Many thanks go to Stefan Nothegger and Sharewise project
|
89
|
+
(http://www.sharewise.com), where the idea originates from.
|
90
|
+
|
91
|
+
2010 Marek Janukowicz/Starware. Released under MIT license.
|
data/Rakefile
ADDED
@@ -0,0 +1,50 @@
|
|
1
|
+
require 'rake'
|
2
|
+
require 'rake/testtask'
|
3
|
+
require 'rake/rdoctask'
|
4
|
+
require 'rake/gempackagetask'
|
5
|
+
|
6
|
+
PKG_FILES = FileList[
|
7
|
+
'[a-zA-Z]*',
|
8
|
+
'generators/**/*',
|
9
|
+
'lib/**/*',
|
10
|
+
'rails/**/*',
|
11
|
+
'tasks/**/*',
|
12
|
+
'test/**/*'
|
13
|
+
]
|
14
|
+
|
15
|
+
spec = Gem::Specification.new do |s|
|
16
|
+
s.name = "aggregate_columns"
|
17
|
+
s.version = "0.9"
|
18
|
+
s.author = "Marek Janukowicz"
|
19
|
+
s.email = "marek@janukowicz.net"
|
20
|
+
s.homepage = "http://www.bitbucket.org/starware/aggregate_columns"
|
21
|
+
s.platform = Gem::Platform::RUBY
|
22
|
+
s.summary = "Create and use aggregate columns in Rails applications"
|
23
|
+
s.files = PKG_FILES.to_a
|
24
|
+
s.require_path = "lib"
|
25
|
+
s.has_rdoc = false
|
26
|
+
s.extra_rdoc_files = ["README"]
|
27
|
+
end
|
28
|
+
|
29
|
+
desc 'Create gem'
|
30
|
+
Rake::GemPackageTask.new(spec) do |pkg|
|
31
|
+
pkg.gem_spec = spec
|
32
|
+
end
|
33
|
+
|
34
|
+
desc 'Run tests'
|
35
|
+
Rake::TestTask.new(:test) do |t|
|
36
|
+
t.libs << 'lib'
|
37
|
+
t.libs << 'test'
|
38
|
+
t.pattern = 'test/**/*_test.rb'
|
39
|
+
t.verbose = true
|
40
|
+
end
|
41
|
+
|
42
|
+
desc 'Generate RDoc documentation'
|
43
|
+
Rake::RDocTask.new(:rdoc) do |rdoc|
|
44
|
+
rdoc.rdoc_dir = 'rdoc'
|
45
|
+
rdoc.title = 'AggregateColumns'
|
46
|
+
rdoc.options << '--line-numbers' << '--inline-source'
|
47
|
+
rdoc.rdoc_files.include('README')
|
48
|
+
rdoc.rdoc_files.include('lib/**/*.rb')
|
49
|
+
end
|
50
|
+
|
@@ -0,0 +1,104 @@
|
|
1
|
+
module AggregateColumns
|
2
|
+
|
3
|
+
module MethodsRails2
|
4
|
+
|
5
|
+
def aggregate_columns_options( *aggregates )
|
6
|
+
all_joins = []
|
7
|
+
all_selects = ["#{table_name}.*"]
|
8
|
+
all_orders = []
|
9
|
+
aggregates.each do |options|
|
10
|
+
# Process options
|
11
|
+
options.assert_valid_keys( :association, :column, :function, :result_column, :join_type, :conditions, :joins, :order )
|
12
|
+
association = options[:association]
|
13
|
+
unless association
|
14
|
+
raise ArgumentError, "No association given"
|
15
|
+
end
|
16
|
+
field = options[:column] || '*'
|
17
|
+
function = options[:function] || :count
|
18
|
+
result_column = options[:result_column]
|
19
|
+
unless result_column
|
20
|
+
# Defaults:
|
21
|
+
# count(*) on comments => comment_count
|
22
|
+
# sum(votes) on comment => comments_votes_sum
|
23
|
+
result_column ||= field == '*' ? "#{association.to_s.singularize}_#{function}" : "#{association}_#{field}_#{function}"
|
24
|
+
end
|
25
|
+
assoc_reflection = reflect_on_association( association )
|
26
|
+
foreign_key = assoc_reflection.primary_key_name # As stupid as it looks, this is actually correct
|
27
|
+
table_name = assoc_reflection.table_name
|
28
|
+
klass = assoc_reflection.klass
|
29
|
+
join_type = options[:join_type] || "LEFT" # TODO: check if in range of allowed values
|
30
|
+
order = options[:order] || "DESC"
|
31
|
+
conditions = options[:conditions]
|
32
|
+
joins = options[:joins]
|
33
|
+
|
34
|
+
# Now gather params for aggregate
|
35
|
+
all_joins << "#{join_type.to_s.upcase} JOIN (SELECT #{foreign_key}, #{function}(#{field}) AS #{result_column} FROM #{table_name} #{joins}#{" WHERE " + klass.sanitize_sql( conditions ) if conditions } GROUP BY #{foreign_key}) #{result_column}_join ON #{table_name}.id = #{result_column}_join.#{foreign_key}"
|
36
|
+
all_selects << result_column.to_s
|
37
|
+
all_orders << "#{result_column} #{order.to_s.upcase}"
|
38
|
+
end
|
39
|
+
return { :joins => all_joins.join( "\n" ), :select => all_selects.join( ', ' ), :order => all_orders.join( ', ' ) }
|
40
|
+
end
|
41
|
+
|
42
|
+
def aggregate_columns_scope( *aggregates )
|
43
|
+
scoped( aggregate_columns_options( *aggregates ))
|
44
|
+
end
|
45
|
+
|
46
|
+
end
|
47
|
+
|
48
|
+
module MethodsRails3
|
49
|
+
|
50
|
+
def aggregate_columns( *aggregates )
|
51
|
+
rel = self.select( "#{table_name}.*" )
|
52
|
+
aggregates.each do |options|
|
53
|
+
# Process options
|
54
|
+
options.assert_valid_keys( :association, :column, :function, :result_column, :join_type, :conditions, :joins, :order )
|
55
|
+
association = options[:association]
|
56
|
+
unless association
|
57
|
+
raise ArgumentError, "No association given"
|
58
|
+
end
|
59
|
+
field = options[:column] || '*'
|
60
|
+
function = options[:function] || :count
|
61
|
+
result_column = options[:result_column]
|
62
|
+
unless result_column
|
63
|
+
# Defaults:
|
64
|
+
# count(*) on comments => comment_count
|
65
|
+
# sum(votes) on comment => comments_votes_sum
|
66
|
+
result_column ||= field == '*' ? "#{association.to_s.singularize}_#{function}" : "#{association}_#{field}_#{function}"
|
67
|
+
end
|
68
|
+
# TODO: this only works on ActiveRecord::Base classes - would be nice
|
69
|
+
# (and not that complicated) to make it work on ActiveRecord::Relation
|
70
|
+
# instances
|
71
|
+
assoc_reflection = reflect_on_association( association )
|
72
|
+
foreign_key = assoc_reflection.primary_key_name # As stupid as it looks, this is actually correct
|
73
|
+
aggregate_table_name = assoc_reflection.table_name
|
74
|
+
klass = assoc_reflection.klass
|
75
|
+
join_type = options[:join_type] || "LEFT" # TODO: check if in range of allowed values
|
76
|
+
order = options[:order] || "DESC"
|
77
|
+
conditions = options[:conditions]
|
78
|
+
joins = options[:joins]
|
79
|
+
|
80
|
+
# Now gather params for aggregate
|
81
|
+
rel = rel.
|
82
|
+
joins( "#{join_type.to_s.upcase} JOIN (SELECT #{foreign_key}, #{function}(#{field}) AS #{result_column} FROM #{aggregate_table_name} #{joins}#{" WHERE " + klass.sanitize_sql( conditions ) if conditions } GROUP BY #{foreign_key}) #{result_column}_join ON #{table_name}.id = #{result_column}_join.#{foreign_key}" ).
|
83
|
+
select( result_column.to_s ).
|
84
|
+
order( "#{result_column} #{order.to_s.upcase}" )
|
85
|
+
end
|
86
|
+
return rel
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
end
|
91
|
+
|
92
|
+
# All versions
|
93
|
+
ActiveRecord::Base.send( :extend, AggregateColumns::MethodsRails2 )
|
94
|
+
if defined?( ActiveRecord::Relation )
|
95
|
+
ActiveRecord::Base.send( :extend, AggregateColumns::MethodsRails3 )
|
96
|
+
end
|
97
|
+
|
98
|
+
## Rails 2
|
99
|
+
#if defined?( ActiveRecord::NamedScope )
|
100
|
+
# ActiveRecord::Associations::AssociationCollection.send( :include, AggregateColumns::MethodsRails2 )
|
101
|
+
# ActiveRecord::NamedScope::Scope.send( :include, AggregateColumns::MethodsRails2 )
|
102
|
+
## Rails 3
|
103
|
+
#ActiveRecord::Associations::CollectionProxy.send( :include, AggregateColumns::Methods ) if defined?( ActiveRecord::Associations::CollectionProxy )
|
104
|
+
#ActiveRecord::Relation.send( :include, AggregateColumns::Methods ) if defined?( ActiveRecord::Relation )
|
@@ -0,0 +1,82 @@
|
|
1
|
+
require 'test_helper'
|
2
|
+
|
3
|
+
class AggregateColumnsTest < ActiveSupport::TestCase
|
4
|
+
|
5
|
+
class Post < FakeModel
|
6
|
+
end
|
7
|
+
|
8
|
+
class User < FakeModel
|
9
|
+
has_many :posts
|
10
|
+
end
|
11
|
+
|
12
|
+
def setup
|
13
|
+
@user = User.new
|
14
|
+
end
|
15
|
+
|
16
|
+
test "empty options" do
|
17
|
+
options = User.aggregate_columns_options
|
18
|
+
assert_equal "users.*", options[:select]
|
19
|
+
assert options[:order].blank?
|
20
|
+
assert options[:joins].blank?
|
21
|
+
end
|
22
|
+
|
23
|
+
test "empty hash as options should fail" do
|
24
|
+
assert_raise( ArgumentError ) do
|
25
|
+
options = User.aggregate_columns_options( {} )
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
test "only association" do
|
30
|
+
options = User.aggregate_columns_options( :association => :posts )
|
31
|
+
assert_equal "users.*, post_count", options[:select]
|
32
|
+
assert_equal "post_count DESC", options[:order]
|
33
|
+
assert_equal "LEFT JOIN (SELECT user_id, count(*) AS post_count FROM posts GROUP BY user_id) post_count_join ON posts.id = post_count_join.user_id", options[:joins]
|
34
|
+
end
|
35
|
+
|
36
|
+
test "column function" do
|
37
|
+
options = User.aggregate_columns_options( :association => :posts, :column => :created_at, :function => :max )
|
38
|
+
assert_equal "users.*, posts_created_at_max", options[:select]
|
39
|
+
assert_equal "posts_created_at_max DESC", options[:order]
|
40
|
+
assert_equal "LEFT JOIN (SELECT user_id, max(created_at) AS posts_created_at_max FROM posts GROUP BY user_id) posts_created_at_max_join ON posts.id = posts_created_at_max_join.user_id", options[:joins]
|
41
|
+
end
|
42
|
+
|
43
|
+
test "result_column" do
|
44
|
+
options = User.aggregate_columns_options( :association => :posts, :column => :created_at, :function => :max, :result_column => "last_post_time" )
|
45
|
+
assert_equal "users.*, last_post_time", options[:select]
|
46
|
+
assert_equal "last_post_time DESC", options[:order]
|
47
|
+
assert_equal "LEFT JOIN (SELECT user_id, max(created_at) AS last_post_time FROM posts GROUP BY user_id) last_post_time_join ON posts.id = last_post_time_join.user_id", options[:joins]
|
48
|
+
end
|
49
|
+
|
50
|
+
test "join_type" do
|
51
|
+
options = User.aggregate_columns_options( :association => :posts, :column => :created_at, :function => :max, :join_type => "RIGHT" )
|
52
|
+
assert_equal "users.*, posts_created_at_max", options[:select]
|
53
|
+
assert_equal "posts_created_at_max DESC", options[:order]
|
54
|
+
assert_equal "RIGHT JOIN (SELECT user_id, max(created_at) AS posts_created_at_max FROM posts GROUP BY user_id) posts_created_at_max_join ON posts.id = posts_created_at_max_join.user_id", options[:joins]
|
55
|
+
end
|
56
|
+
|
57
|
+
test "conditions" do
|
58
|
+
options = User.aggregate_columns_options( :association => :posts, :column => :created_at, :function => :max, :conditions => { :active => true } )
|
59
|
+
assert_equal "users.*, posts_created_at_max", options[:select]
|
60
|
+
assert_equal "posts_created_at_max DESC", options[:order]
|
61
|
+
assert_equal "LEFT JOIN (SELECT user_id, max(created_at) AS posts_created_at_max FROM posts WHERE posts.active = true GROUP BY user_id) posts_created_at_max_join ON posts.id = posts_created_at_max_join.user_id", options[:joins]
|
62
|
+
end
|
63
|
+
|
64
|
+
test "joins" do
|
65
|
+
time = 2.days.ago
|
66
|
+
options = User.aggregate_columns_options( :association => :posts, :column => :created_at, :function => :max, :joins => "INNER JOIN comments ON comments.post_id = posts.id", :conditions => ["comments.updated_at > ?", time] )
|
67
|
+
assert_equal "users.*, posts_created_at_max", options[:select]
|
68
|
+
assert_equal "posts_created_at_max DESC", options[:order]
|
69
|
+
assert_equal "LEFT JOIN (SELECT user_id, max(created_at) AS posts_created_at_max FROM posts INNER JOIN comments ON comments.post_id = posts.id WHERE comments.updated_at > #{time} GROUP BY user_id) posts_created_at_max_join ON posts.id = posts_created_at_max_join.user_id", options[:joins]
|
70
|
+
end
|
71
|
+
|
72
|
+
test "order" do
|
73
|
+
options = User.aggregate_columns_options( :association => :posts, :order => :asc )
|
74
|
+
assert_equal "users.*, post_count", options[:select]
|
75
|
+
assert_equal "post_count ASC", options[:order]
|
76
|
+
assert_equal "LEFT JOIN (SELECT user_id, count(*) AS post_count FROM posts GROUP BY user_id) post_count_join ON posts.id = post_count_join.user_id", options[:joins]
|
77
|
+
end
|
78
|
+
|
79
|
+
# TODO:
|
80
|
+
# test multiple aggregates
|
81
|
+
# test with real database
|
82
|
+
end
|
data/test/test_helper.rb
ADDED
@@ -0,0 +1,42 @@
|
|
1
|
+
require 'rubygems'
|
2
|
+
require 'test/unit'
|
3
|
+
gem 'activesupport', "< 3.0.0"
|
4
|
+
require 'active_support/test_case'
|
5
|
+
require 'active_record'
|
6
|
+
require 'active_record/base'
|
7
|
+
require 'aggregate_columns'
|
8
|
+
|
9
|
+
# Class faking AR model behavior - needed to test without the database
|
10
|
+
class FakeModel < ActiveRecord::Base
|
11
|
+
|
12
|
+
class FakeConnection
|
13
|
+
|
14
|
+
def quote_column_name( column )
|
15
|
+
column
|
16
|
+
end
|
17
|
+
|
18
|
+
def quote( str )
|
19
|
+
str
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
self.abstract_class = true
|
24
|
+
|
25
|
+
def self.columns
|
26
|
+
@columns ||= [];
|
27
|
+
end
|
28
|
+
|
29
|
+
def self.column(name, sql_type = nil, default = nil, null = true)
|
30
|
+
columns << ActiveRecord::ConnectionAdapters::Column.new(name.to_s, default, sql_type.to_s, null)
|
31
|
+
end
|
32
|
+
|
33
|
+
def self.quoted_table_name
|
34
|
+
table_name
|
35
|
+
end
|
36
|
+
|
37
|
+
def self.connection
|
38
|
+
@@connection ||= FakeConnection.new
|
39
|
+
end
|
40
|
+
|
41
|
+
end
|
42
|
+
|
metadata
ADDED
@@ -0,0 +1,70 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: aggregate_columns
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
hash: 25
|
5
|
+
prerelease:
|
6
|
+
segments:
|
7
|
+
- 0
|
8
|
+
- 9
|
9
|
+
version: "0.9"
|
10
|
+
platform: ruby
|
11
|
+
authors:
|
12
|
+
- Marek Janukowicz
|
13
|
+
autorequire:
|
14
|
+
bindir: bin
|
15
|
+
cert_chain: []
|
16
|
+
|
17
|
+
date: 2011-11-23 00:00:00 +01:00
|
18
|
+
default_executable:
|
19
|
+
dependencies: []
|
20
|
+
|
21
|
+
description:
|
22
|
+
email: marek@janukowicz.net
|
23
|
+
executables: []
|
24
|
+
|
25
|
+
extensions: []
|
26
|
+
|
27
|
+
extra_rdoc_files:
|
28
|
+
- README
|
29
|
+
files:
|
30
|
+
- Rakefile
|
31
|
+
- README
|
32
|
+
- lib/aggregate_columns.rb
|
33
|
+
- test/aggregate_columns_test.rb
|
34
|
+
- test/test_helper.rb
|
35
|
+
has_rdoc: true
|
36
|
+
homepage: http://www.bitbucket.org/starware/aggregate_columns
|
37
|
+
licenses: []
|
38
|
+
|
39
|
+
post_install_message:
|
40
|
+
rdoc_options: []
|
41
|
+
|
42
|
+
require_paths:
|
43
|
+
- lib
|
44
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
45
|
+
none: false
|
46
|
+
requirements:
|
47
|
+
- - ">="
|
48
|
+
- !ruby/object:Gem::Version
|
49
|
+
hash: 3
|
50
|
+
segments:
|
51
|
+
- 0
|
52
|
+
version: "0"
|
53
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
54
|
+
none: false
|
55
|
+
requirements:
|
56
|
+
- - ">="
|
57
|
+
- !ruby/object:Gem::Version
|
58
|
+
hash: 3
|
59
|
+
segments:
|
60
|
+
- 0
|
61
|
+
version: "0"
|
62
|
+
requirements: []
|
63
|
+
|
64
|
+
rubyforge_project:
|
65
|
+
rubygems_version: 1.6.2
|
66
|
+
signing_key:
|
67
|
+
specification_version: 3
|
68
|
+
summary: Create and use aggregate columns in Rails applications
|
69
|
+
test_files: []
|
70
|
+
|