merge 0.6.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,4 @@
1
+ = 0.1.0 / 2012-12-18
2
+
3
+ Forked from Piggyback
4
+
@@ -0,0 +1,7 @@
1
+ guard 'rspec' do
2
+ watch(%r{^spec/.+_spec\.rb$})
3
+ watch(%r{^lib/}) { "spec" }
4
+ watch('spec/spec_helper.rb') { "spec" }
5
+ watch('spec/models.rb') { "spec" }
6
+ end
7
+
@@ -0,0 +1,21 @@
1
+
2
+ Copyright (c) 2011 Andreas Korth
3
+
4
+ Permission is hereby granted, free of charge, to any person obtaining
5
+ a copy of this software and associated documentation files (the
6
+ "Software"), to deal in the Software without restriction, including
7
+ without limitation the rights to use, copy, modify, merge, publish,
8
+ distribute, sublicense, and/or sell copies of the Software, and to
9
+ permit persons to whom the Software is furnished to do so, subject to
10
+ the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be
13
+ included in all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
16
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
17
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
18
+ IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
19
+ CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
20
+ TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
21
+ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,14 @@
1
+ CHANGELOG
2
+ Guardfile
3
+ MIT-LICENSE
4
+ Manifest
5
+ README.markdown
6
+ Rakefile
7
+ lib/merge.rb
8
+ lib/merge/base.rb
9
+ lib/merge/relation.rb
10
+ merge.gemspec
11
+ spec/app.rb
12
+ spec/merge_spec.rb
13
+ spec/schema.rb
14
+ spec/spec_helper.rb
@@ -0,0 +1,82 @@
1
+ # Merge
2
+
3
+ An extension for merging of ActiveRecord™ models.
4
+
5
+ ### Piggybacking
6
+
7
+ Piggybacking refers to the technique of dynamically including attributes from an associated model. This is achieved by joining the associated model in a database query and selecting the attributes that should be included with the parent object.
8
+
9
+ This is best illustrated in an example. Consider these models:
10
+
11
+ class User < ActiveRecord::Base
12
+ has_many :posts
13
+ end
14
+
15
+ class Post < ActiveRecord::Base
16
+ belongs_to :user
17
+ end
18
+
19
+ ActiveRecord supports piggybacking simply by joining the associated table and selecting columns from it:
20
+
21
+ post = Post.select('posts.*, user.name AS user_name') \
22
+ .joins("JOIN users ON posts.user_id = users.id") \
23
+ .first
24
+
25
+ post.title # => "Why piggybacking in ActiveRecord is flawed"
26
+ post.user_name # => "Alec Smart"
27
+
28
+ As you can see, the `name` attribute from `User` is treated as if it were an attribute of `Post`. ActiveRecord dynamically determines a model's attributes from the result set returned by the database. Every column in the result set becomes an attribute of the instantiated ActiveRecord objects. Whether the columns originate from the model's own or from a foreign table doesn't make a difference.
29
+
30
+ Or so it seems. Actually there is a drawback which becomes obvious when we select non-string columns:
31
+
32
+ post = Post.select('posts.*, user.birthday AS user_birthday, user.rating AS user_rating') \
33
+ .joins("JOIN users ON posts.user_id = users.id") \
34
+ .first
35
+
36
+ post.user_birthday # => "2011-03-01"
37
+ post.user_rating # => "4.5"
38
+
39
+ Any attributes originating from the `users` table are treated as strings instead of being automatically type-casted as we would expect. The database returns result sets as plain text and ActiveRecord needs to obtain type information separately from the table schema in order to do its type-casting magic. Unfortunately, a model only knows about the columns types in its own table, so type-casting doesn't work with columns selected from foreign tables.
40
+
41
+ We could work around this by defining attribute reader methods in the `Post` model that implicitly convert the values:
42
+
43
+ class Post < ActiveRecord::Base
44
+ belongs_to :user
45
+
46
+ def user_birthday
47
+ Date.parse(read_attribute(:user_birthday))
48
+ end
49
+
50
+ def user_rating
51
+ read_attribute(:user_rating).to_f
52
+ end
53
+ end
54
+
55
+ However this is tedious, error-prone and repetitive if you do it in many models. The type-casting code shown above isn't solid and would quickly become more complex in a real-life application. In its current state it won't handle `nil` values properly, for example.
56
+
57
+
58
+ ### Merge to the rescue!
59
+
60
+ The `merge` declaration specifies the attributes we want to merge from associated models. Not only does it take care of the type-casting but also provides us with some additional benefits.
61
+
62
+ You simply declare which association and attributes you want to merge:
63
+
64
+ class Post < ActiveRecord::Base
65
+ belongs_to :user
66
+
67
+ merge :user, :only => [:name, :email, :rating, :confirmed_at]
68
+ end
69
+
70
+ Now you can do the following:
71
+
72
+ post = Post.first
73
+
74
+ post.name # => "John Doe"
75
+ post.rating # => 4.5
76
+ post.confirmed_at # => Tue, 01 Mar 2011
77
+
78
+ The type-casting works with any type of attribute, even with serialized ones.
79
+
80
+ Merge uses `OUTER JOIN` in order to include both records that have an associated record and ones that don't.
81
+
82
+ Of course, Merge plays nice with Arel and you can add additional `joins`, `select` and other statements as you like.
@@ -0,0 +1,37 @@
1
+ require 'rake'
2
+ require 'echoe'
3
+
4
+ Echoe.new('merge', '0.6.0') do |p|
5
+
6
+ p.description = "Merge attributes from associated models with ActiveRecord"
7
+ p.url = "http://github.com/juni0r/merge"
8
+ p.author = "Andreas Korth"
9
+ p.email = "andreas.korth@gmail.com"
10
+
11
+ p.retain_gemspec = true
12
+
13
+ p.ignore_pattern = %w{
14
+ Gemfile
15
+ Gemfile.lock
16
+ vendor/**/*
17
+ tmp/*
18
+ log/*
19
+ *.tmproj
20
+ }
21
+
22
+ p.runtime_dependencies = [ "activerecord >=3.1.0" ]
23
+ p.development_dependencies = [ "echoe", "rspec", "sqlite3" ]
24
+ end
25
+
26
+ require 'rspec/core'
27
+ require 'rspec/core/rake_task'
28
+ RSpec::Core::RakeTask.new(:spec) do |spec|
29
+ spec.pattern = FileList['spec/**/*_spec.rb']
30
+ end
31
+
32
+ RSpec::Core::RakeTask.new(:rcov) do |spec|
33
+ spec.pattern = 'spec/**/*_spec.rb'
34
+ spec.rcov = true
35
+ end
36
+
37
+ task default: :spec
@@ -0,0 +1,5 @@
1
+ require 'merge/relation'
2
+ require 'merge/base'
3
+
4
+ ActiveRecord::Base.send :include, Merge::Base
5
+ ActiveRecord::Relation.send :include, Merge::Relation
@@ -0,0 +1,109 @@
1
+ module Merge
2
+ module Base
3
+
4
+ extend ActiveSupport::Concern
5
+
6
+ included do
7
+ class_attribute :merges
8
+ self.merges = {}
9
+ end
10
+
11
+ module ClassMethods
12
+
13
+ def merge(name, options = nil)
14
+ reflection = reflect_on_association(name)
15
+
16
+ if reflection.nil?
17
+ raise ArgumentError, "#{self.name} has no association #{name.inspect}"
18
+ end
19
+
20
+ if reflection.collection?
21
+ raise ArgumentError, "Merge only supports belongs_to and has_one associations"
22
+ end
23
+
24
+ assoc = Association.new(reflection, options)
25
+
26
+ self.merges = merges.merge(name => assoc)
27
+
28
+ default_scope do
29
+ dep = ActiveRecord::Associations::JoinDependency.new(self, name, [])
30
+ joins(dep.join_associations.each{ |a| a.join_type = Arel::OuterJoin })
31
+ end
32
+ end
33
+
34
+ def default_selects
35
+ selects = [ arel_table[Arel::star] ]
36
+ merges.each_value do |assoc|
37
+ selects.concat(assoc.selects)
38
+ end
39
+ selects
40
+ end
41
+
42
+ def define_attribute_methods
43
+ @attribute_methods_mutex.synchronize do
44
+ return if attribute_methods_generated?
45
+
46
+ # This stunt is to call "super.super"
47
+ _super = ActiveModel::AttributeMethods::ClassMethods::instance_method(:define_attribute_methods).bind(self)
48
+
49
+ columns = merges.each_value.map do |assoc|
50
+ assoc.each_column do |name, column, serialized|
51
+ columns_hash[name] = column
52
+ serialized_attributes[name] = serialized if serialized
53
+ end
54
+ end
55
+
56
+ _super.call(columns.flatten)
57
+ end
58
+ super
59
+ end
60
+ end
61
+
62
+ class Association #:nodoc:
63
+
64
+ def initialize(reflection, options = nil)
65
+ @reflection = reflection
66
+
67
+ unless options.is_a? Hash
68
+ options = { only: options }
69
+ end
70
+
71
+ options.assert_valid_keys(:only, :except)
72
+
73
+ @only = Array.wrap(options[:only]).map(&:to_s)
74
+ @except = Array.wrap(options[:except]).map(&:to_s)
75
+ end
76
+
77
+ def method_missing(method, *args, &block)
78
+ if @reflection.respond_to? method
79
+ @reflection.send(method, *args, &block)
80
+ else
81
+ super
82
+ end
83
+ end
84
+
85
+ def attributes
86
+ unless @attributes
87
+ @attributes = klass.attribute_names
88
+ @attributes &= @only if @only.any?
89
+
90
+ @attributes -= active_record.attribute_names
91
+ @attributes -= @except if @except.any?
92
+
93
+ @attributes -= [foreign_key] unless belongs_to?
94
+ end
95
+ @attributes
96
+ end
97
+
98
+ def each_column
99
+ attributes.each do |name|
100
+ yield(name, klass.columns_hash[name], klass.serialized_attributes[name])
101
+ end
102
+ end
103
+
104
+ def selects
105
+ @select ||= attributes.map{ |name| klass.arel_table[name] }
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,15 @@
1
+ module Merge
2
+ module Relation
3
+ private
4
+
5
+ def build_select(arel, selects)
6
+ if @klass.respond_to?(:default_selects) && selects.empty?
7
+ arel.project(*@klass.default_selects)
8
+ else
9
+ super
10
+ end
11
+ end
12
+ end
13
+ end
14
+
15
+
@@ -0,0 +1,41 @@
1
+ # -*- encoding: utf-8 -*-
2
+
3
+ Gem::Specification.new do |s|
4
+ s.name = "merge"
5
+ s.version = "0.6.0"
6
+
7
+ s.required_rubygems_version = Gem::Requirement.new(">= 1.2") if s.respond_to? :required_rubygems_version=
8
+ s.authors = ["Andreas Korth"]
9
+ s.date = "2012-12-15"
10
+ s.description = "Merge attributes from associated models with ActiveRecord"
11
+ s.email = "andreas.korth@gmail.com"
12
+ s.extra_rdoc_files = ["CHANGELOG", "README.markdown", "lib/merge.rb", "lib/merge/base.rb", "lib/merge/relation.rb"]
13
+ s.files = ["CHANGELOG", "Guardfile", "MIT-LICENSE", "Manifest", "README.markdown", "Rakefile", "lib/merge.rb", "lib/merge/base.rb", "lib/merge/relation.rb", "merge.gemspec", "spec/app.rb", "spec/merge_spec.rb", "spec/schema.rb", "spec/spec_helper.rb"]
14
+ s.homepage = "http://github.com/juni0r/merge"
15
+ s.rdoc_options = ["--line-numbers", "--inline-source", "--title", "Merge", "--main", "README.markdown"]
16
+ s.require_paths = ["lib"]
17
+ s.rubyforge_project = "merge"
18
+ s.rubygems_version = "1.8.23"
19
+ s.summary = "Merge attributes from associated models with ActiveRecord"
20
+
21
+ if s.respond_to? :specification_version then
22
+ s.specification_version = 3
23
+
24
+ if Gem::Version.new(Gem::VERSION) >= Gem::Version.new('1.2.0') then
25
+ s.add_runtime_dependency(%q<activerecord>, [">= 3.1.0"])
26
+ s.add_development_dependency(%q<echoe>, [">= 0"])
27
+ s.add_development_dependency(%q<rspec>, [">= 0"])
28
+ s.add_development_dependency(%q<sqlite3>, [">= 0"])
29
+ else
30
+ s.add_dependency(%q<activerecord>, [">= 3.1.0"])
31
+ s.add_dependency(%q<echoe>, [">= 0"])
32
+ s.add_dependency(%q<rspec>, [">= 0"])
33
+ s.add_dependency(%q<sqlite3>, [">= 0"])
34
+ end
35
+ else
36
+ s.add_dependency(%q<activerecord>, [">= 3.1.0"])
37
+ s.add_dependency(%q<echoe>, [">= 0"])
38
+ s.add_dependency(%q<rspec>, [">= 0"])
39
+ s.add_dependency(%q<sqlite3>, [">= 0"])
40
+ end
41
+ end
@@ -0,0 +1,27 @@
1
+ require 'schema'
2
+
3
+ class Essential < ActiveRecord::Base
4
+ has_many :details
5
+ has_one :detail, inverse_of: :essential
6
+
7
+ has_many :features
8
+ has_one :feature, conditions:{ active:true }
9
+
10
+ merge :detail, [:first_name, :count, :checked, :birthday, :baggage]
11
+
12
+ merge :feature, except: :active
13
+ end
14
+
15
+
16
+ class Detail < ActiveRecord::Base
17
+ belongs_to :essential, inverse_of: :detail
18
+
19
+ merge :essential, :token
20
+
21
+ serialize :baggage
22
+ end
23
+
24
+
25
+ class Feature < ActiveRecord::Base
26
+ belongs_to :essential, inverse_of: :detail
27
+ end
@@ -0,0 +1,90 @@
1
+ require 'spec_helper'
2
+
3
+ describe Merge do
4
+
5
+ context "directive" do
6
+
7
+ def definition_should_raise(message, &block)
8
+ lambda{ Essential.class_eval(&block) }.should raise_error(ArgumentError, message)
9
+ end
10
+
11
+ it "should require the association to be defined" do
12
+ definition_should_raise "Essential has no association :bogus" do
13
+ merge :bogus
14
+ end
15
+ end
16
+
17
+ it "should not work with has_many associations" do
18
+ definition_should_raise "Merge only supports belongs_to and has_one associations" do
19
+ merge :details
20
+ end
21
+ end
22
+ end
23
+
24
+ it "should respect association options" do
25
+ essential = Essential.create!
26
+ Feature.create!(essential_id:essential.id, value:23, active:false)
27
+ Feature.create!(essential_id:essential.id, value:42, active:true)
28
+ Feature.create!(essential_id:essential.id, value:45, active:false)
29
+
30
+ essential = Essential.first
31
+ essential.value.should eql(42)
32
+ end
33
+
34
+ context "relation" do
35
+
36
+ before do
37
+ Essential.create!(:token => "ESSENTIAL").create_detail(
38
+ :first_name => "John",
39
+ :last_name => "Doe",
40
+ :count => 23,
41
+ :checked => false,
42
+ :birthday => "18.12.1974",
43
+ :baggage => {:key => "value"})
44
+ end
45
+
46
+ it "should work with belongs_to associations" do
47
+ Detail.first.token.should eql("ESSENTIAL")
48
+ Detail.create!
49
+ Detail.should have(2).items
50
+ end
51
+
52
+ it "should include records without an association" do
53
+ Essential.create!
54
+ Essential.should have(2).items
55
+ end
56
+
57
+ it "should include all attributes from the specified associations" do
58
+ essential = Essential.first
59
+ essential.attributes.keys.should =~
60
+ %w{ id token created_at updated_at first_name count checked birthday baggage value }
61
+ end
62
+
63
+ context "attribute methods" do
64
+
65
+ let :essential do
66
+ Essential.first
67
+ end
68
+
69
+ it "should read string attributes" do
70
+ essential.first_name.should eql("John")
71
+ end
72
+
73
+ it "should read integer attributes" do
74
+ essential.count.should eql(23)
75
+ end
76
+
77
+ it "should read boolean attributes" do
78
+ essential.checked.should eql(false)
79
+ end
80
+
81
+ it "should read date attributes" do
82
+ essential.birthday.should eql(Date.new(1974,12,18))
83
+ end
84
+
85
+ it "should read serialized attributes" do
86
+ essential.baggage.should eql({:key => "value"})
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,36 @@
1
+ require 'active_record'
2
+
3
+ ActiveRecord::Base.establish_connection(
4
+ :adapter => "sqlite3",
5
+ :database => ":memory:",
6
+ :verbosity => "silent"
7
+ )
8
+
9
+ silence_stream(STDOUT) do
10
+ ActiveRecord::Schema.define(:version => 1) do
11
+
12
+ create_table :essentials do |t|
13
+ t.string :token
14
+ t.timestamps
15
+ end
16
+
17
+ create_table :details do |t|
18
+ t.belongs_to :essential
19
+
20
+ t.string :first_name
21
+ t.string :last_name
22
+ t.integer :count
23
+ t.boolean :checked
24
+ t.date :birthday
25
+ t.text :baggage
26
+
27
+ t.timestamps
28
+ end
29
+
30
+ create_table :features do |t|
31
+ t.belongs_to :essential
32
+ t.integer :value
33
+ t.boolean :active
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,16 @@
1
+ GEM_ROOT = File.expand_path('../..',__FILE__)
2
+ $: << File.join(GEM_ROOT,'lib')
3
+
4
+ require 'active_record'
5
+ require 'merge'
6
+ require 'app'
7
+
8
+ ActiveRecord::Base.logger = ActiveSupport::BufferedLogger.new(File.join(GEM_ROOT,'log','test.log'))
9
+ ActiveRecord::Base::include_root_in_json = false
10
+
11
+ RSpec.configure do |config|
12
+ config.before do
13
+ ActiveRecord::Base::descendants.each{ |model| model.delete_all }
14
+ end
15
+ end
16
+
metadata ADDED
@@ -0,0 +1,133 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: merge
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.6.0
5
+ prerelease:
6
+ platform: ruby
7
+ authors:
8
+ - Andreas Korth
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2012-12-15 00:00:00.000000000 Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: activerecord
16
+ requirement: !ruby/object:Gem::Requirement
17
+ none: false
18
+ requirements:
19
+ - - ! '>='
20
+ - !ruby/object:Gem::Version
21
+ version: 3.1.0
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ none: false
26
+ requirements:
27
+ - - ! '>='
28
+ - !ruby/object:Gem::Version
29
+ version: 3.1.0
30
+ - !ruby/object:Gem::Dependency
31
+ name: echoe
32
+ requirement: !ruby/object:Gem::Requirement
33
+ none: false
34
+ requirements:
35
+ - - ! '>='
36
+ - !ruby/object:Gem::Version
37
+ version: '0'
38
+ type: :development
39
+ prerelease: false
40
+ version_requirements: !ruby/object:Gem::Requirement
41
+ none: false
42
+ requirements:
43
+ - - ! '>='
44
+ - !ruby/object:Gem::Version
45
+ version: '0'
46
+ - !ruby/object:Gem::Dependency
47
+ name: rspec
48
+ requirement: !ruby/object:Gem::Requirement
49
+ none: false
50
+ requirements:
51
+ - - ! '>='
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
54
+ type: :development
55
+ prerelease: false
56
+ version_requirements: !ruby/object:Gem::Requirement
57
+ none: false
58
+ requirements:
59
+ - - ! '>='
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ - !ruby/object:Gem::Dependency
63
+ name: sqlite3
64
+ requirement: !ruby/object:Gem::Requirement
65
+ none: false
66
+ requirements:
67
+ - - ! '>='
68
+ - !ruby/object:Gem::Version
69
+ version: '0'
70
+ type: :development
71
+ prerelease: false
72
+ version_requirements: !ruby/object:Gem::Requirement
73
+ none: false
74
+ requirements:
75
+ - - ! '>='
76
+ - !ruby/object:Gem::Version
77
+ version: '0'
78
+ description: Merge attributes from associated models with ActiveRecord
79
+ email: andreas.korth@gmail.com
80
+ executables: []
81
+ extensions: []
82
+ extra_rdoc_files:
83
+ - CHANGELOG
84
+ - README.markdown
85
+ - lib/merge.rb
86
+ - lib/merge/base.rb
87
+ - lib/merge/relation.rb
88
+ files:
89
+ - CHANGELOG
90
+ - Guardfile
91
+ - MIT-LICENSE
92
+ - Manifest
93
+ - README.markdown
94
+ - Rakefile
95
+ - lib/merge.rb
96
+ - lib/merge/base.rb
97
+ - lib/merge/relation.rb
98
+ - merge.gemspec
99
+ - spec/app.rb
100
+ - spec/merge_spec.rb
101
+ - spec/schema.rb
102
+ - spec/spec_helper.rb
103
+ homepage: http://github.com/juni0r/merge
104
+ licenses: []
105
+ post_install_message:
106
+ rdoc_options:
107
+ - --line-numbers
108
+ - --inline-source
109
+ - --title
110
+ - Merge
111
+ - --main
112
+ - README.markdown
113
+ require_paths:
114
+ - lib
115
+ required_ruby_version: !ruby/object:Gem::Requirement
116
+ none: false
117
+ requirements:
118
+ - - ! '>='
119
+ - !ruby/object:Gem::Version
120
+ version: '0'
121
+ required_rubygems_version: !ruby/object:Gem::Requirement
122
+ none: false
123
+ requirements:
124
+ - - ! '>='
125
+ - !ruby/object:Gem::Version
126
+ version: '1.2'
127
+ requirements: []
128
+ rubyforge_project: merge
129
+ rubygems_version: 1.8.23
130
+ signing_key:
131
+ specification_version: 3
132
+ summary: Merge attributes from associated models with ActiveRecord
133
+ test_files: []