replicator 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 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