chainlink 0.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/.gitignore +4 -0
- data/.travis.yml +11 -0
- data/Gemfile +3 -0
- data/LICENSE +22 -0
- data/README.md +3 -0
- data/Rakefile +11 -0
- data/chainlink.gemspec +23 -0
- data/lib/chainlink.rb +118 -0
- data/test/chainlink_test.rb +115 -0
- data/test/test_helper.rb +60 -0
- metadata +169 -0
data/.gitignore
ADDED
data/.travis.yml
ADDED
data/Gemfile
ADDED
data/LICENSE
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
Copyright (c) 2012 Norbert Crombach
|
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
data/Rakefile
ADDED
data/chainlink.gemspec
ADDED
@@ -0,0 +1,23 @@
|
|
1
|
+
Gem::Specification.new do |s|
|
2
|
+
s.name = "chainlink"
|
3
|
+
s.version = "0.1.0"
|
4
|
+
s.platform = Gem::Platform::RUBY
|
5
|
+
s.authors = ["Norbert Crombach"]
|
6
|
+
s.email = ["norbert.crombach@primetheory.org"]
|
7
|
+
s.homepage = "http://github.com/norbert/chainlink"
|
8
|
+
s.summary = %q{Simple ActiveRecord plugin for handling merges.}
|
9
|
+
|
10
|
+
s.rubyforge_project = "chainlink"
|
11
|
+
|
12
|
+
s.files = `git ls-files`.split("\n")
|
13
|
+
s.test_files = s.files.grep(/^test\//)
|
14
|
+
s.require_paths = ["lib"]
|
15
|
+
|
16
|
+
s.add_dependency 'activerecord', '~> 3.2.0'
|
17
|
+
s.add_development_dependency 'rake'
|
18
|
+
s.add_development_dependency 'mocha'
|
19
|
+
s.add_development_dependency 'database_cleaner'
|
20
|
+
s.add_development_dependency 'sqlite3'
|
21
|
+
s.add_development_dependency 'mysql2'
|
22
|
+
s.add_development_dependency 'pg'
|
23
|
+
end
|
data/lib/chainlink.rb
ADDED
@@ -0,0 +1,118 @@
|
|
1
|
+
require 'active_record'
|
2
|
+
|
3
|
+
module ChainLink
|
4
|
+
extend ActiveSupport::Concern
|
5
|
+
|
6
|
+
class DirectionError < StandardError
|
7
|
+
end
|
8
|
+
|
9
|
+
MERGE_TARGET_KEY = :merge_target_id
|
10
|
+
JOIN_TABLE_PREFIX = :merge_target_
|
11
|
+
|
12
|
+
included do
|
13
|
+
has_many :merge_sources, class_name: self.name, foreign_key: MERGE_TARGET_KEY
|
14
|
+
|
15
|
+
scope :merge_targets, lambda {
|
16
|
+
where(MERGE_TARGET_KEY => nil)
|
17
|
+
}
|
18
|
+
scope :merge_sources, lambda {
|
19
|
+
where("#{table_name}.#{MERGE_TARGET_KEY} IS NOT NULL")
|
20
|
+
}
|
21
|
+
|
22
|
+
class_attribute :merge_target_associations
|
23
|
+
self.merge_target_associations = []
|
24
|
+
end
|
25
|
+
|
26
|
+
module ClassMethods
|
27
|
+
def find_merge_target(*args)
|
28
|
+
as_merge_targets.find(*args)
|
29
|
+
end
|
30
|
+
|
31
|
+
def as_merge_targets
|
32
|
+
joins(merge_target_join_clause).select(merge_target_select_clause)
|
33
|
+
end
|
34
|
+
|
35
|
+
def merge!(target, source)
|
36
|
+
target.merge!(source)
|
37
|
+
end
|
38
|
+
|
39
|
+
private
|
40
|
+
def merge_target_join_clause
|
41
|
+
table_name = quoted_table_name
|
42
|
+
join_table_name = connection.quote_table_name(merge_target_join_table_name)
|
43
|
+
|
44
|
+
primary_key = quoted_primary_key
|
45
|
+
foreign_key = connection.quote_column_name(MERGE_TARGET_KEY.to_s)
|
46
|
+
|
47
|
+
equivalence_clause = merge_target_equivalence_clause(
|
48
|
+
"#{table_name}.#{foreign_key}", "#{table_name}.#{primary_key}"
|
49
|
+
)
|
50
|
+
|
51
|
+
"INNER JOIN #{table_name} AS #{join_table_name} ON #{join_table_name}.#{primary_key} = #{equivalence_clause}"
|
52
|
+
end
|
53
|
+
|
54
|
+
def merge_target_select_clause
|
55
|
+
"#{connection.quote_table_name(merge_target_join_table_name)}.*"
|
56
|
+
end
|
57
|
+
|
58
|
+
def merge_target_equivalence_clause(foreign_key, primary_key)
|
59
|
+
"COALESCE(#{foreign_key}, #{primary_key})"
|
60
|
+
end
|
61
|
+
|
62
|
+
def merge_target_join_table_name
|
63
|
+
"#{JOIN_TABLE_PREFIX}#{table_name}"
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
def merge!(source)
|
68
|
+
raise DirectionError, "source is not mergeable" unless source.mergeable?
|
69
|
+
raise DirectionError, "target is not mergeable" unless mergeable?(:target)
|
70
|
+
|
71
|
+
self.class.transaction do
|
72
|
+
yield if block_given?
|
73
|
+
source[MERGE_TARGET_KEY] ||= id
|
74
|
+
source.save!
|
75
|
+
save!
|
76
|
+
end
|
77
|
+
|
78
|
+
self
|
79
|
+
end
|
80
|
+
|
81
|
+
def merge_associations!(source, *associations)
|
82
|
+
Array(associations).each do |association_name|
|
83
|
+
reflection = self.class.reflect_on_association(association_name)
|
84
|
+
case reflection.macro
|
85
|
+
when :has_many
|
86
|
+
foreign_key = reflection.foreign_key
|
87
|
+
reflection.klass.where(foreign_key => source.id).update_all(foreign_key => id)
|
88
|
+
end
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|
92
|
+
def merge_target
|
93
|
+
if !merged?
|
94
|
+
self
|
95
|
+
elsif target_record = self.class.find_by_id(merge_target_id)
|
96
|
+
target_record.merge_target
|
97
|
+
end
|
98
|
+
end
|
99
|
+
|
100
|
+
def merged?
|
101
|
+
merge_target_id.present?
|
102
|
+
end
|
103
|
+
|
104
|
+
def mergeable?(as = :source)
|
105
|
+
case as
|
106
|
+
when :source
|
107
|
+
!merged? && !merge_sources.exists?
|
108
|
+
when :target
|
109
|
+
!merged?
|
110
|
+
else
|
111
|
+
raise ArgumentError, "unknown merge role"
|
112
|
+
end
|
113
|
+
end
|
114
|
+
|
115
|
+
def merge_target_id
|
116
|
+
read_attribute(MERGE_TARGET_KEY)
|
117
|
+
end
|
118
|
+
end
|
@@ -0,0 +1,115 @@
|
|
1
|
+
require 'test_helper'
|
2
|
+
|
3
|
+
class ChainLinkTest < ActiveSupport::TestCase
|
4
|
+
include ChainLinkTestHelper
|
5
|
+
|
6
|
+
setup do
|
7
|
+
@target = Artist.create!(name: 'Burial')
|
8
|
+
@source = Artist.create!(name: 'Burial', imported: true)
|
9
|
+
end
|
10
|
+
|
11
|
+
test "should merge record instances" do
|
12
|
+
@source.records.create!(title: 'Distant Lights')
|
13
|
+
|
14
|
+
assert @target.mergeable?
|
15
|
+
assert @target.mergeable?(:target)
|
16
|
+
assert @source.mergeable?
|
17
|
+
assert @source.mergeable?(:target)
|
18
|
+
|
19
|
+
assert_no_difference '@target.records.count' do
|
20
|
+
assert_equal @target, @target.merge!(@source)
|
21
|
+
end
|
22
|
+
assert_equal @target.id, @source.merge_target_id
|
23
|
+
|
24
|
+
assert @source.merged?
|
25
|
+
assert !@target.mergeable?
|
26
|
+
assert @target.mergeable?(:target)
|
27
|
+
assert !@source.mergeable?
|
28
|
+
assert !@source.mergeable?(:target)
|
29
|
+
end
|
30
|
+
|
31
|
+
test "should not merge instances with existing sources or targets" do
|
32
|
+
@target.merge!(@source)
|
33
|
+
@duplicate = Artist.create!(name: 'burial')
|
34
|
+
|
35
|
+
assert_raise ChainLink::DirectionError do
|
36
|
+
@duplicate.merge!(@source)
|
37
|
+
end
|
38
|
+
assert_raise ChainLink::DirectionError do
|
39
|
+
@duplicate.merge!(@target)
|
40
|
+
end
|
41
|
+
assert_raise ChainLink::DirectionError do
|
42
|
+
@source.merge!(@duplicate)
|
43
|
+
end
|
44
|
+
|
45
|
+
assert @target.merge!(@duplicate)
|
46
|
+
end
|
47
|
+
|
48
|
+
test "should find merge targets" do
|
49
|
+
@target.merge!(@source)
|
50
|
+
assert_equal @target, Artist.find_merge_target(@source)
|
51
|
+
end
|
52
|
+
|
53
|
+
test "should list merge sources and targets" do
|
54
|
+
@target.merge!(@source)
|
55
|
+
assert_equal [@target], Artist.merge_targets
|
56
|
+
assert_equal [@source], Artist.merge_sources
|
57
|
+
end
|
58
|
+
|
59
|
+
test "should resolve merge targets" do
|
60
|
+
@duplicate = Artist.create!(name: 'burial')
|
61
|
+
@target.merge!(@source)
|
62
|
+
@target.merge!(@duplicate)
|
63
|
+
assert_equal [@target, @target], Artist.as_merge_targets.where(id: [@source, @duplicate]).all
|
64
|
+
assert_equal @target, @source.merge_target
|
65
|
+
end
|
66
|
+
|
67
|
+
test "should yield during merge" do
|
68
|
+
# useful for temporary scripts and subclassing
|
69
|
+
@target.merge!(@source) do
|
70
|
+
@target.imported = @source.imported
|
71
|
+
end
|
72
|
+
|
73
|
+
assert @target.imported?
|
74
|
+
end
|
75
|
+
|
76
|
+
test "should merge collection associations" do
|
77
|
+
@target.records.create!(title: 'Ghost Hardware')
|
78
|
+
@source.records.create!(title: 'South London Borougs')
|
79
|
+
@source.records.create!(title: 'Distant Lights')
|
80
|
+
|
81
|
+
assert_difference '@target.records.count', 2 do
|
82
|
+
# this should not be a side effect of subclassing either
|
83
|
+
assert_no_difference 'Record.count' do
|
84
|
+
@target.merge_target_associations = [:records]
|
85
|
+
@target.merge_associations!(@source, :records)
|
86
|
+
end
|
87
|
+
end
|
88
|
+
|
89
|
+
assert_blank Artist.merge_target_associations
|
90
|
+
|
91
|
+
assert_equal Record.all, @target.records
|
92
|
+
end
|
93
|
+
|
94
|
+
test "should not merge singular associations" do
|
95
|
+
@associated_target = @target.records.create!(title: 'Ghost Hardware')
|
96
|
+
@associated_source = @source.records.create!(title: 'Kindred')
|
97
|
+
|
98
|
+
# reflects on merge_sources
|
99
|
+
assert @associated_source.mergeable?
|
100
|
+
|
101
|
+
# check fragile state of implementation
|
102
|
+
reflection = mock('reflection', macro: :belongs_to)
|
103
|
+
reflection.expects(:foreign_key).never
|
104
|
+
Record.expects(:reflect_on_association).with(:artist).returns(reflection)
|
105
|
+
|
106
|
+
@associated_target.merge_target_associations = [:artist]
|
107
|
+
@associated_target.merge_associations!(@associated_source, :artist)
|
108
|
+
end
|
109
|
+
|
110
|
+
test "should find merge targets recursively on instances" do
|
111
|
+
@target.merge!(@source)
|
112
|
+
@duplicate = Artist.create!(name: 'Burial', merge_target_id: @source.id)
|
113
|
+
assert_equal @target, @duplicate.merge_target
|
114
|
+
end
|
115
|
+
end
|
data/test/test_helper.rb
ADDED
@@ -0,0 +1,60 @@
|
|
1
|
+
require 'chainlink'
|
2
|
+
|
3
|
+
require 'test/unit'
|
4
|
+
require 'mocha/setup'
|
5
|
+
require 'database_cleaner'
|
6
|
+
require 'logger'
|
7
|
+
|
8
|
+
ActiveRecord::Base.logger = Logger.new(STDOUT)
|
9
|
+
ActiveRecord::Base.logger.level = Logger::UNKNOWN
|
10
|
+
|
11
|
+
case ENV['ADAPTER'] || 'sqlite3'
|
12
|
+
when 'sqlite3'
|
13
|
+
ActiveRecord::Base.establish_connection(adapter: 'sqlite3', database: ':memory:')
|
14
|
+
when 'mysql2'
|
15
|
+
ActiveRecord::Base.establish_connection(adapter: 'mysql2', database: 'chainlink_test')
|
16
|
+
when 'postgresql'
|
17
|
+
configuration = { adapter: 'postgresql', database: 'chainlink_test'}
|
18
|
+
configuration[:username] = 'postgres' if ENV['CI'] == 'true'
|
19
|
+
ActiveRecord::Base.establish_connection(configuration)
|
20
|
+
end
|
21
|
+
|
22
|
+
ActiveRecord::Schema.define(:version => 1) do
|
23
|
+
create_table :artists, force: true do |t|
|
24
|
+
t.string :name
|
25
|
+
t.boolean :imported
|
26
|
+
t.integer :merge_target_id
|
27
|
+
end
|
28
|
+
|
29
|
+
create_table :records, force: true do |t|
|
30
|
+
t.integer :artist_id
|
31
|
+
t.string :title
|
32
|
+
t.integer :merge_target_id
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
module ChainLinkTestHelper
|
37
|
+
extend ActiveSupport::Concern
|
38
|
+
|
39
|
+
DatabaseCleaner[:active_record].strategy = :transaction
|
40
|
+
|
41
|
+
included do
|
42
|
+
setup do
|
43
|
+
DatabaseCleaner.start
|
44
|
+
end
|
45
|
+
|
46
|
+
teardown do
|
47
|
+
DatabaseCleaner.clean
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
class Artist < ActiveRecord::Base
|
53
|
+
include ChainLink
|
54
|
+
has_many :records
|
55
|
+
end
|
56
|
+
|
57
|
+
class Record < ActiveRecord::Base
|
58
|
+
include ChainLink
|
59
|
+
belongs_to :artist
|
60
|
+
end
|
metadata
ADDED
@@ -0,0 +1,169 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: chainlink
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
prerelease:
|
6
|
+
platform: ruby
|
7
|
+
authors:
|
8
|
+
- Norbert Crombach
|
9
|
+
autorequire:
|
10
|
+
bindir: bin
|
11
|
+
cert_chain: []
|
12
|
+
date: 2013-02-28 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.2.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.2.0
|
30
|
+
- !ruby/object:Gem::Dependency
|
31
|
+
name: rake
|
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: mocha
|
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: database_cleaner
|
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
|
+
- !ruby/object:Gem::Dependency
|
79
|
+
name: sqlite3
|
80
|
+
requirement: !ruby/object:Gem::Requirement
|
81
|
+
none: false
|
82
|
+
requirements:
|
83
|
+
- - ! '>='
|
84
|
+
- !ruby/object:Gem::Version
|
85
|
+
version: '0'
|
86
|
+
type: :development
|
87
|
+
prerelease: false
|
88
|
+
version_requirements: !ruby/object:Gem::Requirement
|
89
|
+
none: false
|
90
|
+
requirements:
|
91
|
+
- - ! '>='
|
92
|
+
- !ruby/object:Gem::Version
|
93
|
+
version: '0'
|
94
|
+
- !ruby/object:Gem::Dependency
|
95
|
+
name: mysql2
|
96
|
+
requirement: !ruby/object:Gem::Requirement
|
97
|
+
none: false
|
98
|
+
requirements:
|
99
|
+
- - ! '>='
|
100
|
+
- !ruby/object:Gem::Version
|
101
|
+
version: '0'
|
102
|
+
type: :development
|
103
|
+
prerelease: false
|
104
|
+
version_requirements: !ruby/object:Gem::Requirement
|
105
|
+
none: false
|
106
|
+
requirements:
|
107
|
+
- - ! '>='
|
108
|
+
- !ruby/object:Gem::Version
|
109
|
+
version: '0'
|
110
|
+
- !ruby/object:Gem::Dependency
|
111
|
+
name: pg
|
112
|
+
requirement: !ruby/object:Gem::Requirement
|
113
|
+
none: false
|
114
|
+
requirements:
|
115
|
+
- - ! '>='
|
116
|
+
- !ruby/object:Gem::Version
|
117
|
+
version: '0'
|
118
|
+
type: :development
|
119
|
+
prerelease: false
|
120
|
+
version_requirements: !ruby/object:Gem::Requirement
|
121
|
+
none: false
|
122
|
+
requirements:
|
123
|
+
- - ! '>='
|
124
|
+
- !ruby/object:Gem::Version
|
125
|
+
version: '0'
|
126
|
+
description:
|
127
|
+
email:
|
128
|
+
- norbert.crombach@primetheory.org
|
129
|
+
executables: []
|
130
|
+
extensions: []
|
131
|
+
extra_rdoc_files: []
|
132
|
+
files:
|
133
|
+
- .gitignore
|
134
|
+
- .travis.yml
|
135
|
+
- Gemfile
|
136
|
+
- LICENSE
|
137
|
+
- README.md
|
138
|
+
- Rakefile
|
139
|
+
- chainlink.gemspec
|
140
|
+
- lib/chainlink.rb
|
141
|
+
- test/chainlink_test.rb
|
142
|
+
- test/test_helper.rb
|
143
|
+
homepage: http://github.com/norbert/chainlink
|
144
|
+
licenses: []
|
145
|
+
post_install_message:
|
146
|
+
rdoc_options: []
|
147
|
+
require_paths:
|
148
|
+
- lib
|
149
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
150
|
+
none: false
|
151
|
+
requirements:
|
152
|
+
- - ! '>='
|
153
|
+
- !ruby/object:Gem::Version
|
154
|
+
version: '0'
|
155
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
156
|
+
none: false
|
157
|
+
requirements:
|
158
|
+
- - ! '>='
|
159
|
+
- !ruby/object:Gem::Version
|
160
|
+
version: '0'
|
161
|
+
requirements: []
|
162
|
+
rubyforge_project: chainlink
|
163
|
+
rubygems_version: 1.8.23
|
164
|
+
signing_key:
|
165
|
+
specification_version: 3
|
166
|
+
summary: Simple ActiveRecord plugin for handling merges.
|
167
|
+
test_files:
|
168
|
+
- test/chainlink_test.rb
|
169
|
+
- test/test_helper.rb
|