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 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
@@ -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
+