replicator 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
data/.gitignore ADDED
@@ -0,0 +1,3 @@
1
+ pkg
2
+ *~
3
+ Gemfile.lock
data/.rbenv-gemsets ADDED
@@ -0,0 +1 @@
1
+ replicator
data/.rbenv-version ADDED
@@ -0,0 +1 @@
1
+ ree-1.8.7-2012.02
data/Gemfile ADDED
@@ -0,0 +1,3 @@
1
+ source "http://www.rubygems.org"
2
+
3
+ gemspec
data/LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright (c) 2008 Justin Balthrop
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.rdoc ADDED
@@ -0,0 +1,47 @@
1
+ = Replicator
2
+
3
+ Easy Postgres replication for Rails. Replicates specific columns from one table to
4
+ another using auto-generated triggers. Just add simple directives in your migrations.
5
+
6
+ == Usage:
7
+
8
+ ActiveRecord::Migration.extend(Replicator)
9
+
10
+ class AddReplication < ActiveRecord::Migration
11
+ def self.up
12
+ replicate :names,
13
+ :to => :users,
14
+ :fields => [:first_name, :last_name]
15
+
16
+ replicate :data,
17
+ :to => :users,
18
+ :fields => [:eye_color, :height]
19
+ :key => 'user_id',
20
+ :prefix => 'data'
21
+
22
+ replicate :events,
23
+ :to => :users,
24
+ :fields => {:start_year => :year},
25
+ :key => 'user_id',
26
+ :prefix => 'type',
27
+ :prefix_map => {'BirthEvent' => 'birth', 'GraduationEvent' => 'grad'}
28
+
29
+ replicate :locations,
30
+ :to => :users,
31
+ :fields => [:latitude, :longitude, {[:city, :state, :country] => :location}],
32
+ :through => 'events.address_id',
33
+ :key => 'user_id',
34
+ :prefix => 'events.type',
35
+ :prefix_map => {'BirthEvent' => 'birth', 'GraduationEvent' => 'grad'}
36
+ end
37
+ end
38
+
39
+ There are a lot of options. The code is mostly self explanatory.
40
+
41
+ == Install:
42
+
43
+ gem install replicator
44
+
45
+ == License:
46
+
47
+ Copyright (c) 2009 Justin Balthrop, Geni.com; Published under The MIT License, see License.txt
data/Rakefile ADDED
@@ -0,0 +1,10 @@
1
+ require 'rake/testtask'
2
+ require 'bundler/gem_tasks'
3
+
4
+ Rake::TestTask.new do |t|
5
+ t.libs = ['lib']
6
+ t.pattern = 'test/**/*_test.rb'
7
+ t.verbose = false
8
+ end
9
+
10
+ task :default => :test
data/VERSION ADDED
@@ -0,0 +1 @@
1
+ 0.1.0
data/lib/replicator.rb ADDED
@@ -0,0 +1,299 @@
1
+ module Replicator
2
+ def replicate(table, opts)
3
+ action = opts.delete(:action) || :create
4
+ trigger = Trigger.new(table, opts)
5
+
6
+ case action
7
+ when :create
8
+ execute(trigger.create_sql)
9
+ when :drop
10
+ execute(trigger.drop_sql)
11
+ when :initialize
12
+ sql_by_slice = trigger.initialize_sql
13
+ sql_by_slice.each do |slice, sql|
14
+ execute("DROP TABLE IF EXISTS #{slice[:name]}")
15
+ execute(sql)
16
+ end
17
+ replicated_table_slices(trigger.to).concat(sql_by_slice.keys)
18
+ else
19
+ raise "invalid action: #{action}"
20
+ end
21
+ end
22
+
23
+ def create_replicated_table(table_name)
24
+ table = nil
25
+ fields = []
26
+ joins = []
27
+
28
+ replicated_table_slices(table_name).each do |slice|
29
+ if table
30
+ joins << "LEFT OUTER JOIN #{slice[:name]} ON #{table}.id = #{slice[:name]}.id"
31
+ else
32
+ table = slice[:name]
33
+ fields << "#{table}.id"
34
+ end
35
+ fields << slice[:fields].collect {|f| "#{slice[:name]}.#{f}"}
36
+ end
37
+
38
+ execute %{
39
+ CREATE TABLE #{table_name} AS
40
+ SELECT #{fields.flatten.join(', ')}
41
+ FROM #{table}
42
+ #{joins.join(' ')}
43
+ }
44
+ end
45
+
46
+ def replicated_table_slices(table_name = nil)
47
+ if table_name
48
+ replicated_table_slices[table_name.to_sym] ||= []
49
+ else
50
+ @replicated_table_slices ||= {}
51
+ end
52
+ end
53
+
54
+ class Trigger
55
+ attr_reader :from, :to, :key, :through_table, :through_key, :condition, :prefix, :prefix_map, :dependent
56
+
57
+ def initialize(table, opts)
58
+ @from = table
59
+ @to = opts[:to]
60
+ @name = opts[:name]
61
+ @key = opts[:key] || 'id'
62
+ @condition = opts[:condition] || opts[:if]
63
+ @prefix = opts[:prefix]
64
+ @prefix_map = opts[:prefix_map]
65
+ @timestamps = opts[:timestamps]
66
+ @dependent = opts[:dependent]
67
+
68
+ if opts[:through]
69
+ @through_table, @through_key = opts[:through].split('.')
70
+ raise "through must be of the form 'table.field'" unless @through_table and @through_key
71
+ end
72
+
73
+ # Use opts[:prefixes] to specify valid prefixes and use the identity mapping.
74
+ if @prefix_map.nil? and opts[:prefixes]
75
+ @prefix_map = {}
76
+ opts[:prefixes].each do |prefix|
77
+ @prefix_map[prefix] = prefix
78
+ end
79
+ end
80
+
81
+ if @prefix_map
82
+ @prefix, prefix_table = @prefix.split('.').reverse
83
+
84
+ if prefix_table.nil? or prefix_table == from
85
+ @prefix_through = false
86
+ elsif prefix_table == through_table
87
+ @prefix_through = true
88
+ else
89
+ "unknown prefix table: #{prefix_table}"
90
+ end
91
+ end
92
+
93
+ @fields = {}
94
+ opts[:fields] = [opts[:fields]] unless opts[:fields].kind_of?(Array)
95
+ opts[:fields].each do |field|
96
+ if field.kind_of?(Hash)
97
+ @fields.merge!(field)
98
+ else
99
+ @fields[field] = field
100
+ end
101
+ end
102
+ end
103
+
104
+ def fields(opts = nil)
105
+ if opts
106
+ opts[:row] ||= 'ROW'
107
+ # Add the prefixes and return an array of arrays.
108
+ @fields.collect do |from_field, to_field|
109
+ if from_field.kind_of?(Array)
110
+ from_field = from_field.collect {|f| "COALESCE(#{opts[:row]}.#{f}, '')"}.join(" || ' ' || ")
111
+ else
112
+ from_field = "#{opts[:row]}.#{from_field}"
113
+ end
114
+ to_field = [opts[:prefix], to_field].compact.join('_')
115
+ [from_field, to_field]
116
+ end
117
+ else
118
+ # Just return the hash.
119
+ @fields
120
+ end
121
+ end
122
+
123
+ def create_sql
124
+ %{
125
+ CREATE OR REPLACE FUNCTION #{name}() RETURNS TRIGGER AS $$
126
+ DECLARE
127
+ ROW RECORD;
128
+ THROUGH RECORD;
129
+ BEGIN
130
+ IF (TG_OP = 'DELETE') THEN
131
+ ROW := OLD;
132
+ ELSE
133
+ ROW := NEW;
134
+ END IF;
135
+ #{loop_sql}
136
+ IF #{conditions_sql} THEN
137
+ IF (TG_OP = 'DELETE') THEN
138
+ #{delete_sql(:indent => 18)}
139
+ ELSE
140
+ IF COUNT(*) = 0 FROM #{to} WHERE id = #{primary_key} THEN
141
+ #{insert_sql}
142
+ END IF;
143
+ #{update_all_sql(:indent => 18)}
144
+ END IF;
145
+ END IF;
146
+ #{end_loop_sql}
147
+ RETURN NULL;
148
+ END;
149
+ $$ LANGUAGE plpgsql;
150
+ CREATE TRIGGER #{name} AFTER INSERT OR UPDATE OR DELETE ON #{from}
151
+ FOR EACH ROW EXECUTE PROCEDURE #{name}();
152
+ }
153
+ end
154
+
155
+ def initialize_sql
156
+ sql_by_slice = {}
157
+
158
+ if through?
159
+ tables = "#{from}, #{through_table}"
160
+ join = "#{from}.id = #{through_table}.#{through_key}"
161
+ else
162
+ tables = from
163
+ end
164
+
165
+ if prefix_map
166
+ prefix_map.each do |prefix_value, mapping|
167
+ slice_fields = []
168
+ field_sql = fields(:prefix => mapping, :row => from).collect do |from_field, to_field|
169
+ slice_fields << to_field
170
+ "#{from_field} AS #{to_field}"
171
+ end.join(', ')
172
+ where_sql = "WHERE " << [join, "#{prefix_field(true)} = '#{prefix_value}'"].compact.join(' AND ')
173
+ table_name = "#{name}_#{mapping}"
174
+
175
+ slice = {:name => table_name, :fields => slice_fields}
176
+ sql_by_slice[slice] = %{
177
+ CREATE TABLE #{table_name} AS
178
+ SELECT #{primary_key(true)} AS id, #{field_sql} FROM #{tables} #{where_sql}
179
+ }
180
+ end
181
+ else
182
+ slice_fields = []
183
+ field_sql = fields(:prefix => prefix, :row => from).collect do |from_field, to_field|
184
+ slice_fields << to_field
185
+ "#{from_field} AS #{to_field}"
186
+ end.join(', ')
187
+ where_sql = "WHERE #{join}" if join
188
+
189
+ slice = {:name => name, :fields => slice_fields}
190
+ sql_by_slice[slice] = %{
191
+ CREATE TABLE #{name} AS
192
+ SELECT #{primary_key(true)} AS id, #{field_sql} FROM #{tables} #{where_sql}
193
+ }
194
+ end
195
+ sql_by_slice
196
+ end
197
+
198
+ def drop_sql
199
+ "DROP FUNCTION IF EXISTS #{name}() CASCADE"
200
+ end
201
+
202
+ def timestamps?
203
+ @timestamps
204
+ end
205
+
206
+ def through?
207
+ not through_table.nil?
208
+ end
209
+
210
+ def prefix_through?
211
+ @prefix_through
212
+ end
213
+
214
+ private
215
+
216
+ def prefix_field(initialize = false)
217
+ if initialize
218
+ "#{prefix_through? ? through_table : from}.#{prefix}"
219
+ else
220
+ "#{prefix_through? ? 'THROUGH' : 'ROW'}.#{prefix}"
221
+ end
222
+ end
223
+
224
+ def primary_key(initialize = false)
225
+ if initialize
226
+ "#{through? ? through_table : from}.#{key}"
227
+ else
228
+ @primary_key ||= "#{through? ? 'THROUGH' : 'ROW'}.#{key}"
229
+ end
230
+ end
231
+
232
+ def name
233
+ @name ||= "replicate_#{from}_to_#{to}"
234
+ end
235
+
236
+ def loop_sql
237
+ "FOR THROUGH IN SELECT * FROM #{through_table} WHERE #{through_key} = ROW.id LOOP" if through?
238
+ end
239
+
240
+ def end_loop_sql
241
+ "END LOOP;" if through?
242
+ end
243
+
244
+ def conditions_sql
245
+ conditions = []
246
+ conditions << "#{primary_key} IS NOT NULL"
247
+ conditions << "#{prefix_field} IN (#{prefixes_sql})" if prefix_map
248
+ conditions << condition if condition
249
+ conditions.join(' AND ')
250
+ end
251
+
252
+ def prefixes_sql
253
+ prefix_map.keys.collect {|p| "'#{p}'"}.join(',')
254
+ end
255
+
256
+ def insert_sql
257
+ if timestamps?
258
+ "INSERT INTO #{to} (id, created_at) VALUES (#{primary_key}, NOW());"
259
+ else
260
+ "INSERT INTO #{to} (id) VALUES (#{primary_key});"
261
+ end
262
+ end
263
+
264
+ def update_sql(opts = {})
265
+ updates = fields(:prefix => opts[:prefix]).collect do |from_field, to_field|
266
+ from_field = 'NULL' if opts[:clear]
267
+ "#{to_field} = #{from_field}"
268
+ end
269
+ updates << "updated_at = NOW()" if timestamps?
270
+ sql = "SET #{updates.join(', ')}"
271
+ sql = "UPDATE #{to} #{sql} WHERE #{to}.id = #{primary_key};"
272
+ sql
273
+ end
274
+
275
+ def update_all_sql(opts = {})
276
+ return update_sql(opts.merge(:prefix => prefix)) unless prefix_map
277
+
278
+ sql = ''
279
+ opts[:indent] ||= 0
280
+ newline = "\n#{' ' * opts[:indent]}"
281
+ cond = 'IF'
282
+ prefix_map.each do |prefix_value, mapping|
283
+ sql << "#{cond} #{prefix_field} = '#{prefix_value}' THEN" + newline
284
+ sql << " #{update_sql(opts.merge(:prefix => mapping))}" + newline
285
+ cond = 'ELSIF'
286
+ end
287
+ sql << "END IF;"
288
+ sql
289
+ end
290
+
291
+ def delete_sql(opts = {})
292
+ if dependent == :destroy
293
+ "DELETE FROM #{to} WHERE id = #{primary_key};"
294
+ elsif dependent == :nullify
295
+ update_all_sql(:indent => opts[:indent], :clear => true)
296
+ end
297
+ end
298
+ end
299
+ end
data/replicate.gemspec ADDED
@@ -0,0 +1,24 @@
1
+ lib = File.expand_path('../lib', __FILE__)
2
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
3
+
4
+ Gem::Specification.new do |gem|
5
+ gem.name = "replicator"
6
+ gem.version = IO.read('VERSION')
7
+ gem.authors = ["Justin Balthrop"]
8
+ gem.email = ["git@justinbalthrop.com"]
9
+ gem.description = %q{Easy Postgres replication for Rails}
10
+ gem.summary = gem.description
11
+ gem.homepage = "https://github.com/ninjudd/replicator"
12
+
13
+ gem.add_development_dependency 'shoulda', '3.0.1'
14
+ gem.add_development_dependency 'mocha'
15
+ gem.add_development_dependency 'rake'
16
+ gem.add_development_dependency 'activerecord-postgresql-adapter'
17
+
18
+ gem.add_dependency 'activerecord', '~> 2.3.9'
19
+
20
+ gem.files = `git ls-files`.split($/)
21
+ gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) }
22
+ gem.test_files = gem.files.grep(%r{^(test|spec|features)/})
23
+ gem.require_paths = ["lib"]
24
+ end
@@ -0,0 +1,24 @@
1
+ require File.dirname(__FILE__) + '/test_helper'
2
+
3
+ class ReplicatorTest < Test::Unit::TestCase
4
+ def test_sql_update_all
5
+ t = Replicator::Trigger.new :locations,
6
+ :to => :users,
7
+ :fields => [:latitude, :longitude, {[:city, :state, :country] => :location}],
8
+ :through => 'events.address_id',
9
+ :key => 'user_id',
10
+ :prefix => 'events.type',
11
+ :prefix_map => {'BirthEvent' => 'birth', 'GraduationEvent' => 'grad'}
12
+
13
+ sql = t.create_sql
14
+ # This is not a good to test this. I should actually add the triggers and check that they work.
15
+ assert_match 'FOR THROUGH IN SELECT * FROM events WHERE address_id = ROW.id LOOP', sql
16
+ assert_match 'INSERT INTO users (id) VALUES (THROUGH.user_id)', sql
17
+ ['birth', 'grad'].each do |prefix|
18
+ city_state_country = ['city', 'state', 'country'].map {|f| "COALESCE(ROW.#{f}, '')"}.join(" || ' ' || ")
19
+ assert_match "#{prefix}_location = " + city_state_country, sql
20
+ assert_match "#{prefix}_latitude = ROW.latitude", sql
21
+ assert_match "#{prefix}_longitude = ROW.longitude", sql
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,6 @@
1
+ require 'rubygems'
2
+ require 'test/unit'
3
+ require 'shoulda'
4
+ require 'mocha/setup'
5
+
6
+ require 'replicator'
metadata ADDED
@@ -0,0 +1,153 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: replicator
3
+ version: !ruby/object:Gem::Version
4
+ hash: 27
5
+ prerelease:
6
+ segments:
7
+ - 0
8
+ - 1
9
+ - 0
10
+ version: 0.1.0
11
+ platform: ruby
12
+ authors:
13
+ - Justin Balthrop
14
+ autorequire:
15
+ bindir: bin
16
+ cert_chain: []
17
+
18
+ date: 2013-06-03 00:00:00 -07:00
19
+ default_executable:
20
+ dependencies:
21
+ - !ruby/object:Gem::Dependency
22
+ name: shoulda
23
+ prerelease: false
24
+ requirement: &id001 !ruby/object:Gem::Requirement
25
+ none: false
26
+ requirements:
27
+ - - "="
28
+ - !ruby/object:Gem::Version
29
+ hash: 5
30
+ segments:
31
+ - 3
32
+ - 0
33
+ - 1
34
+ version: 3.0.1
35
+ type: :development
36
+ version_requirements: *id001
37
+ - !ruby/object:Gem::Dependency
38
+ name: mocha
39
+ prerelease: false
40
+ requirement: &id002 !ruby/object:Gem::Requirement
41
+ none: false
42
+ requirements:
43
+ - - ">="
44
+ - !ruby/object:Gem::Version
45
+ hash: 3
46
+ segments:
47
+ - 0
48
+ version: "0"
49
+ type: :development
50
+ version_requirements: *id002
51
+ - !ruby/object:Gem::Dependency
52
+ name: rake
53
+ prerelease: false
54
+ requirement: &id003 !ruby/object:Gem::Requirement
55
+ none: false
56
+ requirements:
57
+ - - ">="
58
+ - !ruby/object:Gem::Version
59
+ hash: 3
60
+ segments:
61
+ - 0
62
+ version: "0"
63
+ type: :development
64
+ version_requirements: *id003
65
+ - !ruby/object:Gem::Dependency
66
+ name: activerecord-postgresql-adapter
67
+ prerelease: false
68
+ requirement: &id004 !ruby/object:Gem::Requirement
69
+ none: false
70
+ requirements:
71
+ - - ">="
72
+ - !ruby/object:Gem::Version
73
+ hash: 3
74
+ segments:
75
+ - 0
76
+ version: "0"
77
+ type: :development
78
+ version_requirements: *id004
79
+ - !ruby/object:Gem::Dependency
80
+ name: activerecord
81
+ prerelease: false
82
+ requirement: &id005 !ruby/object:Gem::Requirement
83
+ none: false
84
+ requirements:
85
+ - - ~>
86
+ - !ruby/object:Gem::Version
87
+ hash: 17
88
+ segments:
89
+ - 2
90
+ - 3
91
+ - 9
92
+ version: 2.3.9
93
+ type: :runtime
94
+ version_requirements: *id005
95
+ description: Easy Postgres replication for Rails
96
+ email:
97
+ - git@justinbalthrop.com
98
+ executables: []
99
+
100
+ extensions: []
101
+
102
+ extra_rdoc_files: []
103
+
104
+ files:
105
+ - .gitignore
106
+ - .rbenv-gemsets
107
+ - .rbenv-version
108
+ - Gemfile
109
+ - LICENSE
110
+ - README.rdoc
111
+ - Rakefile
112
+ - VERSION
113
+ - lib/replicator.rb
114
+ - replicate.gemspec
115
+ - test/replicator_test.rb
116
+ - test/test_helper.rb
117
+ has_rdoc: true
118
+ homepage: https://github.com/ninjudd/replicator
119
+ licenses: []
120
+
121
+ post_install_message:
122
+ rdoc_options: []
123
+
124
+ require_paths:
125
+ - lib
126
+ required_ruby_version: !ruby/object:Gem::Requirement
127
+ none: false
128
+ requirements:
129
+ - - ">="
130
+ - !ruby/object:Gem::Version
131
+ hash: 3
132
+ segments:
133
+ - 0
134
+ version: "0"
135
+ required_rubygems_version: !ruby/object:Gem::Requirement
136
+ none: false
137
+ requirements:
138
+ - - ">="
139
+ - !ruby/object:Gem::Version
140
+ hash: 3
141
+ segments:
142
+ - 0
143
+ version: "0"
144
+ requirements: []
145
+
146
+ rubyforge_project:
147
+ rubygems_version: 1.5.2
148
+ signing_key:
149
+ specification_version: 3
150
+ summary: Easy Postgres replication for Rails
151
+ test_files:
152
+ - test/replicator_test.rb
153
+ - test/test_helper.rb