calculated_attributes 0.0.15

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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 9c877786aece3b27fdc6487e1f0fde18e972e509
4
+ data.tar.gz: 7aea77ccc13a0ac2ccc8177dfc69dc799c606100
5
+ SHA512:
6
+ metadata.gz: 8376dbbcbb9eb0590c813574dea24279282b6ba01062b647c395d545926128b5cf640fd40cd986b6edbf8a4a3d74756ecf1a4e6487c961ebdecafe8791367043
7
+ data.tar.gz: 3653cf42956ff9a77528981e6429d3e372747e2b1a219870090fa108a0b5fc9367718c0d1631a3550446ada3e8848370bd5a8fd04033119cce6c03148aac252f
data/.gitignore ADDED
@@ -0,0 +1,17 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /Gemfile.lock
4
+ /_yardoc/
5
+ /coverage/
6
+ /doc/
7
+ /pkg/
8
+ /spec/reports/
9
+ /tmp/
10
+ *.bundle
11
+ *.so
12
+ *.o
13
+ *.a
14
+ mkmf.log
15
+
16
+ # Ignore test database.
17
+ /spec/*.sqlite3
data/.rspec ADDED
@@ -0,0 +1,2 @@
1
+ --color
2
+ --require spec_helper
data/.rubocop.yml ADDED
@@ -0,0 +1,22 @@
1
+ AllCops:
2
+ Exclude:
3
+ - db/**/*
4
+
5
+ BlockNesting:
6
+ Max: 4
7
+ ClassLength:
8
+ Enabled: False
9
+ ClassVars:
10
+ Enabled: False
11
+ CyclomaticComplexity:
12
+ Enabled: False
13
+ Documentation:
14
+ Enabled: false
15
+ LineLength:
16
+ Enabled: false
17
+ MethodLength:
18
+ Enabled: false
19
+ Metrics/AbcSize:
20
+ Enabled: false
21
+ Metrics/PerceivedComplexity:
22
+ Enabled: false
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in calculated_attributes.gemspec
4
+ gemspec
data/LICENSE.txt ADDED
@@ -0,0 +1,22 @@
1
+ Copyright (c) 2015 Aha! Labs Inc
2
+
3
+ MIT License
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining
6
+ a copy of this software and associated documentation files (the
7
+ "Software"), to deal in the Software without restriction, including
8
+ without limitation the rights to use, copy, modify, merge, publish,
9
+ distribute, sublicense, and/or sell copies of the Software, and to
10
+ permit persons to whom the Software is furnished to do so, subject to
11
+ the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be
14
+ included in all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,83 @@
1
+ # CalculatedAttributes
2
+
3
+ Automatically add calculated attributes from accessory select queries to ActiveRecord models.
4
+
5
+ ## Installation
6
+
7
+ Add this line to your application's Gemfile:
8
+
9
+ ```ruby
10
+ gem 'calculated_attributes'
11
+ ```
12
+
13
+ And then execute:
14
+
15
+ $ bundle
16
+
17
+ Or install it yourself as:
18
+
19
+ $ gem install calculated_attributes
20
+
21
+ ## Usage
22
+
23
+ Add each calculated attribute to your model using the `calculated` keyword. It accepts two parameters: a symbol representing the name of the calculated attribute, and a lambda containing a string to calculate the attribute.
24
+
25
+ For example, if we have two models, `Post` and `Comment`, and `Comment` has a `post_id` attribute, we might write the following code to add a comments count to each `Post` record in a relation:
26
+
27
+ class Post < ActiveRecord::Base
28
+ ...
29
+ calculated :comments_count, -> { "select count(*) from comments where comments.post_id = posts.id" }
30
+ ...
31
+ end
32
+
33
+ Then, the comments count may be accessed as follows:
34
+
35
+ Post.scoped.calculated(:comments_count).first.comments_count
36
+ #=> 5
37
+
38
+ Multiple calculated attributes may be attached to each model. If we add a `Tag` model that also has a `post_id`, we can update the Post model as following:
39
+
40
+ class Post < ActiveRecord::Base
41
+ ...
42
+ calculated :comments_count, -> { "select count(*) from comments where comments.post_id = posts.id" }
43
+ calculated :tags_count, -> { "select count(*) from tags where tags.post_id = posts.id" }
44
+ ...
45
+ end
46
+
47
+ And then access both the `comments_count` and `tags_count` like so:
48
+
49
+ post = Post.scoped.calculated(:comments_count, :tags_count).first
50
+ post.comments_count
51
+ #=> 5
52
+ post.tags_count
53
+ #=> 2
54
+
55
+ You may also use the `calculated` method on a single model instance, like so:
56
+
57
+ Post.first.calculated(:comments_count).comments_count
58
+ #=> 5
59
+
60
+ If you have defined a `calculated` method, results of that method will be returned rather than throwing a method missing error even if you don't explicitly use the `calculated()` call on the instance:
61
+
62
+ Post.first.comments_count
63
+ #=> 5
64
+
65
+ If you like, you may define `calculated` lambdas using Arel syntax:
66
+
67
+ class Post < ActiveRecord::Base
68
+ ...
69
+ calculated :comments_count, -> { Comment.select(Arel::Nodes::NamedFunction.new("COUNT", [Comment.arel_table[:id]])).where(Comment.arel_table[:post_id].eq(Post.arel_table[:id])) }
70
+ ...
71
+ end
72
+
73
+ ## Contributing
74
+
75
+ 1. Fork it ( https://github.com/aha-app/calculated_attributes/fork )
76
+ 2. Create your feature branch (`git checkout -b my-new-feature`)
77
+ 3. Commit your changes (`git commit -am 'Add some feature'`)
78
+ 4. Push to the branch (`git push origin my-new-feature`)
79
+ 5. Create a new Pull Request
80
+
81
+ ## Credits
82
+
83
+ Written by Zach Schneider based on ideas from Chris Waters.
data/Rakefile ADDED
@@ -0,0 +1 @@
1
+ require 'bundler/gem_tasks'
@@ -0,0 +1,27 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'calculated_attributes/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = 'calculated_attributes'
8
+ spec.version = CalculatedAttributes::VERSION
9
+ spec.authors = ['Zach Schneider']
10
+ spec.email = ['zach@aha.io']
11
+ spec.summary = 'Automatically add calculated attributes to ActiveRecord models.'
12
+ spec.homepage = 'https://github.com/aha-app/calculated_attributes'
13
+ spec.license = 'MIT'
14
+
15
+ spec.files = `git ls-files -z`.split("\x0")
16
+ spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
17
+ spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
18
+ spec.require_paths = ['lib']
19
+
20
+ spec.add_development_dependency 'bundler', '~> 1.7'
21
+ spec.add_development_dependency 'rake', '~> 10.0'
22
+ spec.add_development_dependency 'rspec', '~> 3.1'
23
+ spec.add_development_dependency 'rubocop'
24
+ spec.add_development_dependency 'sqlite3'
25
+
26
+ spec.add_dependency 'activerecord', '3.2.21'
27
+ end
@@ -0,0 +1,121 @@
1
+ require 'calculated_attributes/version'
2
+ require 'active_record'
3
+
4
+ module CalculatedAttributes
5
+ def calculated(*args)
6
+ @config ||= CalculatedAttributes::Config.new
7
+ @config.calculated(args.first, args.last) if args.size == 2
8
+ @config
9
+ end
10
+
11
+ class CalculatedAttributes
12
+ class Config
13
+ def calculated(title = nil, lambda = nil)
14
+ @calculations ||= {}
15
+ @calculations[title] ||= lambda if title && lambda
16
+ @calculations
17
+ end
18
+ end
19
+ end
20
+ end
21
+ ActiveRecord::Base.extend CalculatedAttributes
22
+
23
+ ActiveRecord::Base.send(:include, Module.new do
24
+ def calculated(*args)
25
+ self.class.scoped.calculated(*args).find(id)
26
+ end
27
+
28
+ def method_missing(sym, *args, &block)
29
+ if !@attributes.include?(sym.to_s) && (self.class.calculated.calculated[sym] || self.class.base_class.calculated.calculated[sym])
30
+ Rails.logger.warn("Using calculated value without including it in the relation: #{sym}") if defined? Rails
31
+ class_with_attr =
32
+ if self.class.calculated.calculated[sym]
33
+ self.class
34
+ else
35
+ self.class.base_class
36
+ end
37
+ class_with_attr.scoped.calculated(sym).find(id).send(sym)
38
+ else
39
+ super(sym, *args, &block)
40
+ end
41
+ end
42
+
43
+ def respond_to?(method, include_private = false)
44
+ super || (!@attributes.include?(method.to_s) && (self.class.calculated.calculated[method] || self.class.base_class.calculated.calculated[method]))
45
+ end
46
+ end)
47
+
48
+ ActiveRecord::Relation.send(:include, Module.new do
49
+ def calculated(*args)
50
+ projections = arel.projections
51
+ args.each do |arg|
52
+ lam = klass.calculated.calculated[arg] || klass.base_class.calculated.calculated[arg]
53
+ sql = lam.call
54
+ if sql.is_a? String
55
+ new_projection = Arel.sql("(#{sql})").as(arg.to_s)
56
+ new_projection.calculated_attr!
57
+ projections.push new_projection
58
+ else
59
+ new_projection = sql.as(arg.to_s)
60
+ new_projection.calculated_attr!
61
+ projections.push new_projection
62
+ end
63
+ end
64
+ select(projections)
65
+ end
66
+ end)
67
+
68
+ Arel::SelectManager.send(:include, Module.new do
69
+ def projections
70
+ @ctx.projections
71
+ end
72
+ end)
73
+
74
+ module ActiveRecord
75
+ module FinderMethods
76
+ def construct_relation_for_association_find(join_dependency)
77
+ calculated_columns = arel.projections.select { |p| p.is_a?(Arel::Nodes::Node) && p.calculated_attr? }
78
+ relation = except(:includes, :eager_load, :preload, :select).select(join_dependency.columns.concat(calculated_columns))
79
+ join_dependency.calculated_columns = calculated_columns
80
+ apply_join_dependency(relation, join_dependency)
81
+ end
82
+ end
83
+ end
84
+
85
+ module ActiveRecord
86
+ module Associations
87
+ class JoinDependency
88
+ attr_writer :calculated_columns
89
+
90
+ def instantiate(rows)
91
+ primary_key = join_base.aliased_primary_key
92
+ parents = {}
93
+
94
+ records = rows.map do |model|
95
+ primary_id = model[primary_key]
96
+ parent = parents[primary_id] ||= join_base.instantiate(model)
97
+ construct(parent, @associations, join_associations, model)
98
+ @calculated_columns.each { |column| parent[column.right] = model[column.right] }
99
+ parent
100
+ end.uniq
101
+
102
+ remove_duplicate_results!(active_record, records, @associations)
103
+ records
104
+ end
105
+ end
106
+ end
107
+ end
108
+
109
+ module Arel
110
+ module Nodes
111
+ class Node
112
+ def calculated_attr!
113
+ @is_calculated_attr = true
114
+ end
115
+
116
+ def calculated_attr?
117
+ @is_calculated_attr
118
+ end
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,3 @@
1
+ module CalculatedAttributes
2
+ VERSION = '0.0.15'
3
+ end
@@ -0,0 +1,81 @@
1
+ require 'spec_helper'
2
+
3
+ describe 'calculated_attributes' do
4
+ it 'includes calculated attributes' do
5
+ expect(Post.scoped.calculated(:comments).first.comments).to eq(1)
6
+ end
7
+
8
+ it 'includes multiple calculated attributes' do
9
+ post = Post.scoped.calculated(:comments, :comments_two).first
10
+ expect(post.comments).to eq(1)
11
+ expect(post.comments_two).to eq(1)
12
+ end
13
+
14
+ it 'includes chained calculated attributes' do
15
+ post = Post.scoped.calculated(:comments).calculated(:comments_two).first
16
+ expect(post.comments).to eq(1)
17
+ expect(post.comments_two).to eq(1)
18
+ end
19
+
20
+ it 'nests with where query' do
21
+ expect(Post.where(id: 1).calculated(:comments).first.comments).to eq(1)
22
+ end
23
+
24
+ it 'nests with order query' do
25
+ expect(Post.order('id DESC').calculated(:comments).first.id).to eq(Post.count)
26
+ end
27
+
28
+ it 'allows access via model instance method' do
29
+ expect(Post.first.calculated(:comments).comments).to eq(1)
30
+ end
31
+
32
+ it 'allows anonymous access via model instance method' do
33
+ expect(Post.first.comments).to eq(1)
34
+ end
35
+
36
+ it 'allows anonymous access via model instance method with STI and lambda on base class' do
37
+ expect(Tutorial.first.comments).to eq(1)
38
+ end
39
+
40
+ it 'allows anonymous access via model instance method with STI and lambda on subclass' do
41
+ expect(Article.first.sub_comments).to eq(1)
42
+ end
43
+
44
+ it 'does not allow anonymous access for nonexisting calculated block' do
45
+ expect { Post.first.some_attr }.to raise_error(NoMethodError)
46
+ end
47
+
48
+ it 'does not allow anonymous access for nonexisting calculated block with STI' do
49
+ expect { Tutorial.first.some_attr }.to raise_error(NoMethodError)
50
+ end
51
+
52
+ it 'allows access to multiple calculated attributes via model instance method' do
53
+ post = Post.first.calculated(:comments, :comments_two)
54
+ expect(post.comments).to eq(1)
55
+ expect(post.comments_two).to eq(1)
56
+ end
57
+
58
+ it 'allows attributes to be defined using AREL' do
59
+ expect(Post.scoped.calculated(:comments_arel).first.comments_arel).to eq(1)
60
+ end
61
+
62
+ it 'maintains previous scope' do
63
+ expect(Post.where(text: 'First post!').calculated(:comments).count).to eq(1)
64
+ expect(Post.where(text: 'First post!').calculated(:comments).first.comments).to eq(1)
65
+ expect(Post.where("posts.text = 'First post!'").calculated(:comments).first.comments).to eq(1)
66
+ end
67
+
68
+ it 'maintains subsequent scope' do
69
+ expect(Post.scoped.calculated(:comments).where(text: 'First post!').count).to eq(1)
70
+ expect(Post.scoped.calculated(:comments).where(text: 'First post!').first.comments).to eq(1)
71
+ expect(Post.scoped.calculated(:comments).where("posts.text = 'First post!'").first.comments).to eq(1)
72
+ end
73
+
74
+ it 'includes calculated attributes with STI and lambda on base class' do
75
+ expect(Tutorial.scoped.calculated(:comments).first.comments).to eq(1)
76
+ end
77
+
78
+ it 'includes calculated attributes with STI and lambda on subclass' do
79
+ expect(Article.scoped.calculated(:sub_comments).first.sub_comments).to eq(1)
80
+ end
81
+ end
@@ -0,0 +1,8 @@
1
+ require 'calculated_attributes'
2
+
3
+ ActiveRecord::Base.establish_connection(adapter: 'sqlite3',
4
+ database: File.dirname(__FILE__) + '/calculated_attributes.sqlite3')
5
+
6
+ load File.dirname(__FILE__) + '/support/schema.rb'
7
+ load File.dirname(__FILE__) + '/support/models.rb'
8
+ load File.dirname(__FILE__) + '/support/data.rb'
@@ -0,0 +1,7 @@
1
+ p = Post.create(text: 'First post!')
2
+ Comment.create(post_id: p.id, text: 'First comment!')
3
+ Post.create(text: 'Second post!')
4
+ t = Tutorial.create(text: 'Tutorial!')
5
+ Comment.create(post_id: t.id, text: 'First comment!')
6
+ a = Article.create(text: 'Article!')
7
+ Comment.create(post_id: a.id, text: 'First comment!')
@@ -0,0 +1,15 @@
1
+ class Post < ActiveRecord::Base
2
+ calculated :comments, -> { 'select count(*) from comments where comments.post_id = posts.id' }
3
+ calculated :comments_two, -> { 'select count(*) from comments where comments.post_id = posts.id' }
4
+ calculated :comments_arel, -> { Comment.where(Comment.arel_table[:post_id].eq(Post.arel_table[:id])).select(Arel.sql('count(*)')) }
5
+ end
6
+
7
+ class Tutorial < Post
8
+ end
9
+
10
+ class Article < Post
11
+ calculated :sub_comments, -> { 'select count(*) from comments where comments.post_id = posts.id' }
12
+ end
13
+
14
+ class Comment < ActiveRecord::Base
15
+ end
@@ -0,0 +1,14 @@
1
+ ActiveRecord::Schema.define do
2
+ self.verbose = false
3
+
4
+ create_table :posts, force: true do |t|
5
+ t.string :text
6
+ t.timestamps
7
+ end
8
+
9
+ create_table :comments, force: true do |t|
10
+ t.integer :post_id
11
+ t.string :text
12
+ t.timestamps
13
+ end
14
+ end
metadata ADDED
@@ -0,0 +1,148 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: calculated_attributes
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.15
5
+ platform: ruby
6
+ authors:
7
+ - Zach Schneider
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2015-06-16 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: bundler
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '1.7'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '1.7'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rake
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '10.0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '10.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rspec
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '3.1'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '3.1'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rubocop
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: sqlite3
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: '0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: activerecord
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - '='
88
+ - !ruby/object:Gem::Version
89
+ version: 3.2.21
90
+ type: :runtime
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - '='
95
+ - !ruby/object:Gem::Version
96
+ version: 3.2.21
97
+ description:
98
+ email:
99
+ - zach@aha.io
100
+ executables: []
101
+ extensions: []
102
+ extra_rdoc_files: []
103
+ files:
104
+ - ".gitignore"
105
+ - ".rspec"
106
+ - ".rubocop.yml"
107
+ - Gemfile
108
+ - LICENSE.txt
109
+ - README.md
110
+ - Rakefile
111
+ - calculated_attributes.gemspec
112
+ - lib/calculated_attributes.rb
113
+ - lib/calculated_attributes/version.rb
114
+ - spec/lib/calculated_attributes_spec.rb
115
+ - spec/spec_helper.rb
116
+ - spec/support/data.rb
117
+ - spec/support/models.rb
118
+ - spec/support/schema.rb
119
+ homepage: https://github.com/aha-app/calculated_attributes
120
+ licenses:
121
+ - MIT
122
+ metadata: {}
123
+ post_install_message:
124
+ rdoc_options: []
125
+ require_paths:
126
+ - lib
127
+ required_ruby_version: !ruby/object:Gem::Requirement
128
+ requirements:
129
+ - - ">="
130
+ - !ruby/object:Gem::Version
131
+ version: '0'
132
+ required_rubygems_version: !ruby/object:Gem::Requirement
133
+ requirements:
134
+ - - ">="
135
+ - !ruby/object:Gem::Version
136
+ version: '0'
137
+ requirements: []
138
+ rubyforge_project:
139
+ rubygems_version: 2.4.6
140
+ signing_key:
141
+ specification_version: 4
142
+ summary: Automatically add calculated attributes to ActiveRecord models.
143
+ test_files:
144
+ - spec/lib/calculated_attributes_spec.rb
145
+ - spec/spec_helper.rb
146
+ - spec/support/data.rb
147
+ - spec/support/models.rb
148
+ - spec/support/schema.rb