pgsync 0.5.1 → 0.5.2
Sign up to get free protection for your applications and to get access to all the features.
Potentially problematic release.
This version of pgsync might be problematic. Click here for more details.
- checksums.yaml +4 -4
- data/CHANGELOG.md +7 -0
- data/README.md +7 -4
- data/config.yml +1 -4
- data/exe/pgsync +4 -8
- data/lib/pgsync.rb +3 -4
- data/lib/pgsync/client.rb +26 -300
- data/lib/pgsync/data_source.rb +9 -6
- data/lib/pgsync/init.rb +42 -0
- data/lib/pgsync/sync.rb +240 -0
- data/lib/pgsync/table_list.rb +40 -4
- data/lib/pgsync/table_sync.rb +23 -11
- data/lib/pgsync/utils.rb +46 -0
- data/lib/pgsync/version.rb +1 -1
- metadata +5 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: af75f9e9eb115a889d53b10ed89d8355e3ef111868b69eec31a025a8f22db27e
|
4
|
+
data.tar.gz: 5da0d56267ac89f2ffc282e92275a91d3fb83d80fe969207329ec8ce1876472b
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 9f9d78aebf02f75a5fee9413b56bba68ed97334b3134cef5c28f4b5a8a15cbab45e752cb73dcd88028e2b3b2b0fefb20ef9bd4abe0c92d35998a520b0029e961
|
7
|
+
data.tar.gz: a2fdaee8ba32df378ed2538119817b7649fe134a33c0f6013d55d8e1cb76ae6b0a75d3dd10dff4872d0452bc2e41bb0024c5f276f16ed8f0d1c81b114574f757
|
data/CHANGELOG.md
CHANGED
data/README.md
CHANGED
@@ -1,9 +1,10 @@
|
|
1
1
|
# pgsync
|
2
2
|
|
3
|
-
Sync Postgres
|
3
|
+
Sync data from one Postgres database to another (like `pg_dump`/`pg_restore`). Designed for:
|
4
4
|
|
5
|
-
- **speed** -
|
5
|
+
- **speed** - tables are transferred in parallel
|
6
6
|
- **security** - built-in methods to prevent sensitive data from ever leaving the server
|
7
|
+
- **flexibility** - gracefully handles schema differences, like missing columns and extra columns
|
7
8
|
- **convenience** - sync partial tables, groups of tables, and related records
|
8
9
|
|
9
10
|
:tangerine: Battle-tested at [Instacart](https://www.instacart.com/opensource)
|
@@ -20,6 +21,8 @@ gem install pgsync
|
|
20
21
|
|
21
22
|
This will give you the `pgsync` command. If installation fails, you may need to install [dependencies](#dependencies).
|
22
23
|
|
24
|
+
## Setup
|
25
|
+
|
23
26
|
In your project directory, run:
|
24
27
|
|
25
28
|
```sh
|
@@ -36,7 +39,7 @@ Sync all tables
|
|
36
39
|
pgsync
|
37
40
|
```
|
38
41
|
|
39
|
-
**Note:** pgsync assumes your schema is
|
42
|
+
**Note:** pgsync assumes your schema is setup in your `to` database. See the [schema section](#schema) if that’s not the case.
|
40
43
|
|
41
44
|
Sync specific tables
|
42
45
|
|
@@ -246,7 +249,7 @@ Use groups when possible to take advantage of parallelism.
|
|
246
249
|
For Ruby scripts, you may need to do:
|
247
250
|
|
248
251
|
```rb
|
249
|
-
Bundler.
|
252
|
+
Bundler.with_unbundled_env do
|
250
253
|
system "pgsync ..."
|
251
254
|
end
|
252
255
|
```
|
data/config.yml
CHANGED
data/exe/pgsync
CHANGED
@@ -1,15 +1,11 @@
|
|
1
1
|
#!/usr/bin/env ruby
|
2
2
|
|
3
3
|
# disable Slop warning with Ruby 2.7
|
4
|
+
# TODO remove when Slop > 4.7.0 is released
|
4
5
|
$VERBOSE = nil
|
5
6
|
|
7
|
+
# handle interrupts
|
6
8
|
trap("SIGINT") { abort }
|
7
9
|
|
8
|
-
|
9
|
-
|
10
|
-
PgSync::Client.new(ARGV).perform
|
11
|
-
rescue PgSync::Error => e
|
12
|
-
abort PgSync::Client.colorize(e.message, 31) # red
|
13
|
-
rescue Interrupt
|
14
|
-
abort
|
15
|
-
end
|
10
|
+
require "pgsync"
|
11
|
+
PgSync::Client.start
|
data/lib/pgsync.rb
CHANGED
@@ -5,19 +5,18 @@ require "slop"
|
|
5
5
|
require "tty-spinner"
|
6
6
|
|
7
7
|
# stdlib
|
8
|
-
require "cgi"
|
9
|
-
require "erb"
|
10
|
-
require "fileutils"
|
11
8
|
require "set"
|
12
9
|
require "shellwords"
|
13
10
|
require "tempfile"
|
14
|
-
require "thread" # windows only
|
15
11
|
require "uri"
|
16
12
|
require "yaml"
|
17
13
|
|
18
14
|
# modules
|
15
|
+
require "pgsync/utils"
|
19
16
|
require "pgsync/client"
|
20
17
|
require "pgsync/data_source"
|
18
|
+
require "pgsync/init"
|
19
|
+
require "pgsync/sync"
|
21
20
|
require "pgsync/table_list"
|
22
21
|
require "pgsync/table_sync"
|
23
22
|
require "pgsync/version"
|
data/lib/pgsync/client.rb
CHANGED
@@ -1,160 +1,38 @@
|
|
1
1
|
module PgSync
|
2
2
|
class Client
|
3
|
+
include Utils
|
4
|
+
|
3
5
|
def initialize(args)
|
4
|
-
|
6
|
+
@args = args
|
5
7
|
$stderr.sync = true
|
6
|
-
@exit = false
|
7
|
-
@arguments, @options = parse_args(args)
|
8
8
|
end
|
9
9
|
|
10
|
-
|
11
|
-
|
12
|
-
return if @exit
|
13
|
-
|
14
|
-
args, opts = @arguments, @options
|
15
|
-
[:to, :from, :to_safe, :exclude, :schemas].each do |opt|
|
16
|
-
opts[opt] ||= config[opt.to_s]
|
17
|
-
end
|
18
|
-
map_deprecations(args, opts)
|
10
|
+
def perform(testing: true)
|
11
|
+
opts = parse_args
|
19
12
|
|
20
|
-
if opts
|
21
|
-
|
13
|
+
if opts.version?
|
14
|
+
log VERSION
|
15
|
+
elsif opts.help?
|
16
|
+
log opts
|
17
|
+
# TODO remove deprecated conditions (last two)
|
18
|
+
elsif opts.init? || opts.setup? || opts.arguments[0] == "setup"
|
19
|
+
Init.new.perform(opts)
|
22
20
|
else
|
23
|
-
|
21
|
+
Sync.new.perform(opts)
|
24
22
|
end
|
25
|
-
|
26
|
-
|
23
|
+
rescue Error, PG::ConnectionBad => e
|
24
|
+
raise e if testing
|
25
|
+
abort colorize(e.message, 31) # red
|
27
26
|
end
|
28
27
|
|
29
|
-
|
30
|
-
|
31
|
-
def sync(args, opts)
|
32
|
-
start_time = Time.now
|
33
|
-
|
34
|
-
if args.size > 2
|
35
|
-
raise PgSync::Error, "Usage:\n pgsync [options]"
|
36
|
-
end
|
37
|
-
|
38
|
-
source = DataSource.new(opts[:from])
|
39
|
-
raise PgSync::Error, "No source" unless source.exists?
|
40
|
-
|
41
|
-
destination = DataSource.new(opts[:to])
|
42
|
-
raise PgSync::Error, "No destination" unless destination.exists?
|
43
|
-
|
44
|
-
begin
|
45
|
-
# start connections
|
46
|
-
source.host
|
47
|
-
destination.host
|
48
|
-
|
49
|
-
unless opts[:to_safe] || destination.local?
|
50
|
-
raise PgSync::Error, "Danger! Add `to_safe: true` to `.pgsync.yml` if the destination is not localhost or 127.0.0.1"
|
51
|
-
end
|
52
|
-
|
53
|
-
print_description("From", source)
|
54
|
-
print_description("To", destination)
|
55
|
-
ensure
|
56
|
-
source.close
|
57
|
-
destination.close
|
58
|
-
end
|
59
|
-
|
60
|
-
tables = nil
|
61
|
-
begin
|
62
|
-
tables = TableList.new(args, opts, source, config).tables
|
63
|
-
ensure
|
64
|
-
source.close
|
65
|
-
end
|
66
|
-
|
67
|
-
confirm_tables_exist(source, tables, "source")
|
68
|
-
|
69
|
-
if opts[:list]
|
70
|
-
confirm_tables_exist(destination, tables, "destination")
|
71
|
-
|
72
|
-
list_items =
|
73
|
-
if args[0] == "groups"
|
74
|
-
(config["groups"] || {}).keys
|
75
|
-
else
|
76
|
-
tables.keys
|
77
|
-
end
|
78
|
-
|
79
|
-
pretty_list list_items
|
80
|
-
else
|
81
|
-
if opts[:schema_first] || opts[:schema_only]
|
82
|
-
if opts[:preserve]
|
83
|
-
raise PgSync::Error, "Cannot use --preserve with --schema-first or --schema-only"
|
84
|
-
end
|
85
|
-
|
86
|
-
log "* Dumping schema"
|
87
|
-
schema_tables = tables if !opts[:all_schemas] || opts[:tables] || opts[:groups] || args[0] || opts[:exclude]
|
88
|
-
sync_schema(source, destination, schema_tables)
|
89
|
-
end
|
90
|
-
|
91
|
-
unless opts[:schema_only]
|
92
|
-
confirm_tables_exist(destination, tables, "destination")
|
93
|
-
|
94
|
-
in_parallel(tables, first_schema: source.search_path.find { |sp| sp != "pg_catalog" }) do |table, table_opts|
|
95
|
-
TableSync.new.sync(config, table, opts.merge(table_opts), source.url, destination.url)
|
96
|
-
end
|
97
|
-
end
|
98
|
-
|
99
|
-
log_completed(start_time)
|
100
|
-
end
|
101
|
-
end
|
102
|
-
|
103
|
-
def confirm_tables_exist(data_source, tables, description)
|
104
|
-
tables.keys.each do |table|
|
105
|
-
unless data_source.table_exists?(table)
|
106
|
-
raise PgSync::Error, "Table does not exist in #{description}: #{table}"
|
107
|
-
end
|
108
|
-
end
|
109
|
-
ensure
|
110
|
-
data_source.close
|
111
|
-
end
|
112
|
-
|
113
|
-
def map_deprecations(args, opts)
|
114
|
-
command = args[0]
|
115
|
-
|
116
|
-
case command
|
117
|
-
when "setup"
|
118
|
-
args.shift
|
119
|
-
opts[:init] = true
|
120
|
-
deprecated "Use `psync --init` instead"
|
121
|
-
when "schema"
|
122
|
-
args.shift
|
123
|
-
opts[:schema_only] = true
|
124
|
-
deprecated "Use `psync --schema-only` instead"
|
125
|
-
when "tables"
|
126
|
-
args.shift
|
127
|
-
opts[:tables] = args.shift
|
128
|
-
deprecated "Use `pgsync #{opts[:tables]}` instead"
|
129
|
-
when "groups"
|
130
|
-
args.shift
|
131
|
-
opts[:groups] = args.shift
|
132
|
-
deprecated "Use `pgsync #{opts[:groups]}` instead"
|
133
|
-
end
|
134
|
-
|
135
|
-
if opts[:where]
|
136
|
-
opts[:sql] ||= String.new
|
137
|
-
opts[:sql] << " WHERE #{opts[:where]}"
|
138
|
-
deprecated "Use `\"WHERE #{opts[:where]}\"` instead"
|
139
|
-
end
|
140
|
-
|
141
|
-
if opts[:limit]
|
142
|
-
opts[:sql] ||= String.new
|
143
|
-
opts[:sql] << " LIMIT #{opts[:limit]}"
|
144
|
-
deprecated "Use `\"LIMIT #{opts[:limit]}\"` instead"
|
145
|
-
end
|
28
|
+
def self.start
|
29
|
+
new(ARGV).perform(testing: false)
|
146
30
|
end
|
147
31
|
|
148
|
-
|
149
|
-
dump_command = source.dump_command(tables)
|
150
|
-
restore_command = destination.restore_command
|
151
|
-
unless system("#{dump_command} | #{restore_command}")
|
152
|
-
raise PgSync::Error, "Schema sync returned non-zero exit code"
|
153
|
-
end
|
154
|
-
end
|
32
|
+
protected
|
155
33
|
|
156
|
-
def parse_args
|
157
|
-
|
34
|
+
def parse_args
|
35
|
+
Slop.parse(@args) do |o|
|
158
36
|
o.banner = %{Usage:
|
159
37
|
pgsync [options]
|
160
38
|
|
@@ -186,165 +64,13 @@ Options:}
|
|
186
64
|
o.boolean "--in-batches", "in batches", default: false, help: false
|
187
65
|
o.integer "--batch-size", "batch size", default: 10000, help: false
|
188
66
|
o.float "--sleep", "sleep", default: 0, help: false
|
189
|
-
o.
|
190
|
-
|
191
|
-
|
192
|
-
|
193
|
-
o.on "-h", "--help", "prints help" do
|
194
|
-
log o
|
195
|
-
@exit = true
|
196
|
-
end
|
67
|
+
o.boolean "--fail-fast", "stop on the first failed table", default: false
|
68
|
+
# o.array "--var", "pass a variable"
|
69
|
+
o.boolean "-v", "--version", "print the version"
|
70
|
+
o.boolean "-h", "--help", "prints help"
|
197
71
|
end
|
198
|
-
|
199
|
-
opts_hash = opts.to_hash
|
200
|
-
opts_hash[:init] = opts_hash[:setup] if opts_hash[:setup]
|
201
|
-
|
202
|
-
[opts.arguments, opts_hash]
|
203
72
|
rescue Slop::Error => e
|
204
|
-
raise
|
205
|
-
end
|
206
|
-
|
207
|
-
def config
|
208
|
-
@config ||= begin
|
209
|
-
if config_file
|
210
|
-
begin
|
211
|
-
YAML.load_file(config_file) || {}
|
212
|
-
rescue Psych::SyntaxError => e
|
213
|
-
raise PgSync::Error, e.message
|
214
|
-
end
|
215
|
-
else
|
216
|
-
{}
|
217
|
-
end
|
218
|
-
end
|
219
|
-
end
|
220
|
-
|
221
|
-
def setup(config_file)
|
222
|
-
if File.exist?(config_file)
|
223
|
-
raise PgSync::Error, "#{config_file} exists."
|
224
|
-
else
|
225
|
-
FileUtils.cp(File.dirname(__FILE__) + "/../../config.yml", config_file)
|
226
|
-
log "#{config_file} created. Add your database credentials."
|
227
|
-
end
|
228
|
-
end
|
229
|
-
|
230
|
-
def db_config_file(db)
|
231
|
-
return unless db
|
232
|
-
".pgsync-#{db}.yml"
|
233
|
-
end
|
234
|
-
|
235
|
-
def print_description(prefix, source)
|
236
|
-
location = " on #{source.host}:#{source.port}" if source.host
|
237
|
-
log "#{prefix}: #{source.dbname}#{location}"
|
238
|
-
end
|
239
|
-
|
240
|
-
def search_tree(file)
|
241
|
-
path = Dir.pwd
|
242
|
-
# prevent infinite loop
|
243
|
-
20.times do
|
244
|
-
absolute_file = File.join(path, file)
|
245
|
-
if File.exist?(absolute_file)
|
246
|
-
break absolute_file
|
247
|
-
end
|
248
|
-
path = File.dirname(path)
|
249
|
-
break if path == "/"
|
250
|
-
end
|
251
|
-
end
|
252
|
-
|
253
|
-
def config_file
|
254
|
-
return @config_file if instance_variable_defined?(:@config_file)
|
255
|
-
|
256
|
-
@config_file =
|
257
|
-
search_tree(
|
258
|
-
if @options[:db]
|
259
|
-
db_config_file(@options[:db])
|
260
|
-
else
|
261
|
-
@options[:config] || ".pgsync.yml"
|
262
|
-
end
|
263
|
-
)
|
264
|
-
end
|
265
|
-
|
266
|
-
def log(message = nil)
|
267
|
-
$stderr.puts message
|
268
|
-
end
|
269
|
-
|
270
|
-
def in_parallel(tables, first_schema:, &block)
|
271
|
-
spinners = TTY::Spinner::Multi.new(format: :dots)
|
272
|
-
item_spinners = {}
|
273
|
-
|
274
|
-
start = lambda do |item, i|
|
275
|
-
table, opts = item
|
276
|
-
message = String.new(":spinner ")
|
277
|
-
message << table.sub("#{first_schema}.", "")
|
278
|
-
# maybe output later
|
279
|
-
# message << " #{opts[:sql]}" if opts[:sql]
|
280
|
-
spinner = spinners.register(message)
|
281
|
-
spinner.auto_spin
|
282
|
-
item_spinners[item] = spinner
|
283
|
-
end
|
284
|
-
|
285
|
-
errors = 0
|
286
|
-
|
287
|
-
finish = lambda do |item, i, result|
|
288
|
-
spinner = item_spinners[item]
|
289
|
-
if result[:status] == "success"
|
290
|
-
spinner.success(display_message(result))
|
291
|
-
else
|
292
|
-
# TODO add option to fail fast
|
293
|
-
spinner.error(display_message(result))
|
294
|
-
errors += 1
|
295
|
-
end
|
296
|
-
|
297
|
-
unless spinner.send(:tty?)
|
298
|
-
status = result[:status] == "success" ? "✔" : "✖"
|
299
|
-
log [status, item.first.sub("#{first_schema}.", ""), display_message(result)].compact.join(" ")
|
300
|
-
end
|
301
|
-
end
|
302
|
-
|
303
|
-
options = {start: start, finish: finish}
|
304
|
-
if @options[:debug] || @options[:in_batches]
|
305
|
-
options[:in_processes] = 0
|
306
|
-
else
|
307
|
-
options[:in_threads] = 4 if windows?
|
308
|
-
end
|
309
|
-
|
310
|
-
Parallel.each(tables, **options, &block)
|
311
|
-
|
312
|
-
raise PgSync::Error, "Synced failed for #{errors} table#{errors == 1 ? nil : "s"}" if errors > 0
|
313
|
-
end
|
314
|
-
|
315
|
-
def display_message(result)
|
316
|
-
message = String.new("")
|
317
|
-
message << "- #{result[:time]}s" if result[:time]
|
318
|
-
message << "(#{result[:message].gsub("\n", " ").strip})" if result[:message]
|
319
|
-
message
|
320
|
-
end
|
321
|
-
|
322
|
-
def pretty_list(items)
|
323
|
-
items.each do |item|
|
324
|
-
log item
|
325
|
-
end
|
326
|
-
end
|
327
|
-
|
328
|
-
def deprecated(message)
|
329
|
-
log "[DEPRECATED] #{message}"
|
330
|
-
end
|
331
|
-
|
332
|
-
def log_completed(start_time)
|
333
|
-
time = Time.now - start_time
|
334
|
-
message = "Completed in #{time.round(1)}s"
|
335
|
-
log self.class.colorize(message, 32) # green
|
336
|
-
end
|
337
|
-
|
338
|
-
def windows?
|
339
|
-
Gem.win_platform?
|
340
|
-
end
|
341
|
-
|
342
|
-
def self.colorize(message, color_code)
|
343
|
-
if $stderr.tty?
|
344
|
-
"\e[#{color_code}m#{message}\e[0m"
|
345
|
-
else
|
346
|
-
message
|
347
|
-
end
|
73
|
+
raise Error, e.message
|
348
74
|
end
|
349
75
|
end
|
350
76
|
end
|
data/lib/pgsync/data_source.rb
CHANGED
@@ -95,10 +95,8 @@ module PgSync
|
|
95
95
|
config = {dbname: @url}
|
96
96
|
end
|
97
97
|
PG::Connection.new(config)
|
98
|
-
rescue PG::ConnectionBad => e
|
99
|
-
raise PgSync::Error, e.message
|
100
98
|
rescue URI::InvalidURIError
|
101
|
-
raise
|
99
|
+
raise Error, "Invalid connection string"
|
102
100
|
end
|
103
101
|
end
|
104
102
|
end
|
@@ -116,8 +114,7 @@ module PgSync
|
|
116
114
|
end
|
117
115
|
|
118
116
|
def restore_command
|
119
|
-
|
120
|
-
if_exists = Gem::Version.new(psql_version) >= Gem::Version.new("9.4.0")
|
117
|
+
if_exists = Gem::Version.new(pg_restore_version) >= Gem::Version.new("9.4.0")
|
121
118
|
"pg_restore --verbose --no-owner --no-acl --clean #{if_exists ? "--if-exists" : nil} -d #{@url}"
|
122
119
|
end
|
123
120
|
|
@@ -137,6 +134,12 @@ module PgSync
|
|
137
134
|
|
138
135
|
private
|
139
136
|
|
137
|
+
def pg_restore_version
|
138
|
+
`pg_restore --version`.lines[0].chomp.split(" ")[-1].split(/[^\d.]/)[0]
|
139
|
+
rescue Errno::ENOENT
|
140
|
+
raise Error, "pg_restore not found"
|
141
|
+
end
|
142
|
+
|
140
143
|
def table_set
|
141
144
|
@table_set ||= Set.new(tables)
|
142
145
|
end
|
@@ -177,7 +180,7 @@ module PgSync
|
|
177
180
|
command = m[2..-2]
|
178
181
|
result = `#{command}`.chomp
|
179
182
|
unless $?.success?
|
180
|
-
raise
|
183
|
+
raise Error, "Command exited with non-zero status:\n#{command}"
|
181
184
|
end
|
182
185
|
result
|
183
186
|
end
|
data/lib/pgsync/init.rb
ADDED
@@ -0,0 +1,42 @@
|
|
1
|
+
module PgSync
|
2
|
+
class Init
|
3
|
+
include Utils
|
4
|
+
|
5
|
+
def perform(opts)
|
6
|
+
# needed for config_file method
|
7
|
+
@options = opts.to_hash
|
8
|
+
|
9
|
+
file = db_config_file(opts.arguments[0]) || config_file || ".pgsync.yml"
|
10
|
+
|
11
|
+
if File.exist?(file)
|
12
|
+
raise Error, "#{file} exists."
|
13
|
+
else
|
14
|
+
exclude =
|
15
|
+
if rails_app?
|
16
|
+
<<~EOS
|
17
|
+
exclude:
|
18
|
+
- schema_migrations
|
19
|
+
- ar_internal_metadata
|
20
|
+
EOS
|
21
|
+
else
|
22
|
+
<<~EOS
|
23
|
+
# exclude:
|
24
|
+
# - schema_migrations
|
25
|
+
# - ar_internal_metadata
|
26
|
+
EOS
|
27
|
+
end
|
28
|
+
|
29
|
+
# create file
|
30
|
+
contents = File.read(__dir__ + "/../../config.yml")
|
31
|
+
File.write(file, contents % {exclude: exclude})
|
32
|
+
|
33
|
+
log "#{file} created. Add your database credentials."
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
# TODO maybe check parent directories
|
38
|
+
def rails_app?
|
39
|
+
File.exist?("bin/rails")
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
data/lib/pgsync/sync.rb
ADDED
@@ -0,0 +1,240 @@
|
|
1
|
+
module PgSync
|
2
|
+
class Sync
|
3
|
+
include Utils
|
4
|
+
|
5
|
+
def perform(options)
|
6
|
+
args = options.arguments
|
7
|
+
opts = options.to_hash
|
8
|
+
@options = opts
|
9
|
+
|
10
|
+
# merge config
|
11
|
+
[:to, :from, :to_safe, :exclude, :schemas].each do |opt|
|
12
|
+
opts[opt] ||= config[opt.to_s]
|
13
|
+
end
|
14
|
+
|
15
|
+
# TODO remove deprecations
|
16
|
+
map_deprecations(args, opts)
|
17
|
+
|
18
|
+
# start
|
19
|
+
start_time = Time.now
|
20
|
+
|
21
|
+
if args.size > 2
|
22
|
+
raise Error, "Usage:\n pgsync [options]"
|
23
|
+
end
|
24
|
+
|
25
|
+
source = DataSource.new(opts[:from])
|
26
|
+
raise Error, "No source" unless source.exists?
|
27
|
+
|
28
|
+
destination = DataSource.new(opts[:to])
|
29
|
+
raise Error, "No destination" unless destination.exists?
|
30
|
+
|
31
|
+
begin
|
32
|
+
# start connections
|
33
|
+
source.host
|
34
|
+
destination.host
|
35
|
+
|
36
|
+
unless opts[:to_safe] || destination.local?
|
37
|
+
raise Error, "Danger! Add `to_safe: true` to `.pgsync.yml` if the destination is not localhost or 127.0.0.1"
|
38
|
+
end
|
39
|
+
|
40
|
+
print_description("From", source)
|
41
|
+
print_description("To", destination)
|
42
|
+
ensure
|
43
|
+
source.close
|
44
|
+
destination.close
|
45
|
+
end
|
46
|
+
|
47
|
+
tables = nil
|
48
|
+
begin
|
49
|
+
tables = TableList.new(args, opts, source, config).tables
|
50
|
+
ensure
|
51
|
+
source.close
|
52
|
+
end
|
53
|
+
|
54
|
+
confirm_tables_exist(source, tables, "source")
|
55
|
+
|
56
|
+
if opts[:list]
|
57
|
+
confirm_tables_exist(destination, tables, "destination")
|
58
|
+
|
59
|
+
list_items =
|
60
|
+
if args[0] == "groups"
|
61
|
+
(config["groups"] || {}).keys
|
62
|
+
else
|
63
|
+
tables.keys
|
64
|
+
end
|
65
|
+
|
66
|
+
pretty_list list_items
|
67
|
+
else
|
68
|
+
if opts[:schema_first] || opts[:schema_only]
|
69
|
+
if opts[:preserve]
|
70
|
+
raise Error, "Cannot use --preserve with --schema-first or --schema-only"
|
71
|
+
end
|
72
|
+
|
73
|
+
log "* Dumping schema"
|
74
|
+
schema_tables = tables if !opts[:all_schemas] || opts[:tables] || opts[:groups] || args[0] || opts[:exclude]
|
75
|
+
sync_schema(source, destination, schema_tables)
|
76
|
+
end
|
77
|
+
|
78
|
+
unless opts[:schema_only]
|
79
|
+
confirm_tables_exist(destination, tables, "destination")
|
80
|
+
|
81
|
+
in_parallel(tables, first_schema: source.search_path.find { |sp| sp != "pg_catalog" }) do |table, table_opts|
|
82
|
+
TableSync.new.sync(config, table, opts.merge(table_opts), source.url, destination.url)
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
log_completed(start_time)
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
def confirm_tables_exist(data_source, tables, description)
|
91
|
+
tables.keys.each do |table|
|
92
|
+
unless data_source.table_exists?(table)
|
93
|
+
raise Error, "Table does not exist in #{description}: #{table}"
|
94
|
+
end
|
95
|
+
end
|
96
|
+
ensure
|
97
|
+
data_source.close
|
98
|
+
end
|
99
|
+
|
100
|
+
def map_deprecations(args, opts)
|
101
|
+
command = args[0]
|
102
|
+
|
103
|
+
case command
|
104
|
+
when "schema"
|
105
|
+
args.shift
|
106
|
+
opts[:schema_only] = true
|
107
|
+
deprecated "Use `psync --schema-only` instead"
|
108
|
+
when "tables"
|
109
|
+
args.shift
|
110
|
+
opts[:tables] = args.shift
|
111
|
+
deprecated "Use `pgsync #{opts[:tables]}` instead"
|
112
|
+
when "groups"
|
113
|
+
args.shift
|
114
|
+
opts[:groups] = args.shift
|
115
|
+
deprecated "Use `pgsync #{opts[:groups]}` instead"
|
116
|
+
end
|
117
|
+
|
118
|
+
if opts[:where]
|
119
|
+
opts[:sql] ||= String.new
|
120
|
+
opts[:sql] << " WHERE #{opts[:where]}"
|
121
|
+
deprecated "Use `\"WHERE #{opts[:where]}\"` instead"
|
122
|
+
end
|
123
|
+
|
124
|
+
if opts[:limit]
|
125
|
+
opts[:sql] ||= String.new
|
126
|
+
opts[:sql] << " LIMIT #{opts[:limit]}"
|
127
|
+
deprecated "Use `\"LIMIT #{opts[:limit]}\"` instead"
|
128
|
+
end
|
129
|
+
end
|
130
|
+
|
131
|
+
def sync_schema(source, destination, tables = nil)
|
132
|
+
dump_command = source.dump_command(tables)
|
133
|
+
restore_command = destination.restore_command
|
134
|
+
unless system("#{dump_command} | #{restore_command}")
|
135
|
+
raise Error, "Schema sync returned non-zero exit code"
|
136
|
+
end
|
137
|
+
end
|
138
|
+
|
139
|
+
def config
|
140
|
+
@config ||= begin
|
141
|
+
if config_file
|
142
|
+
begin
|
143
|
+
YAML.load_file(config_file) || {}
|
144
|
+
rescue Psych::SyntaxError => e
|
145
|
+
raise Error, e.message
|
146
|
+
end
|
147
|
+
else
|
148
|
+
{}
|
149
|
+
end
|
150
|
+
end
|
151
|
+
end
|
152
|
+
|
153
|
+
def print_description(prefix, source)
|
154
|
+
location = " on #{source.host}:#{source.port}" if source.host
|
155
|
+
log "#{prefix}: #{source.dbname}#{location}"
|
156
|
+
end
|
157
|
+
|
158
|
+
def in_parallel(tables, first_schema:, &block)
|
159
|
+
spinners = TTY::Spinner::Multi.new(format: :dots)
|
160
|
+
item_spinners = {}
|
161
|
+
|
162
|
+
start = lambda do |item, i|
|
163
|
+
table, opts = item
|
164
|
+
message = String.new(":spinner ")
|
165
|
+
message << table.sub("#{first_schema}.", "")
|
166
|
+
# maybe output later
|
167
|
+
# message << " #{opts[:sql]}" if opts[:sql]
|
168
|
+
spinner = spinners.register(message)
|
169
|
+
spinner.auto_spin
|
170
|
+
item_spinners[item] = spinner
|
171
|
+
end
|
172
|
+
|
173
|
+
failed_tables = []
|
174
|
+
|
175
|
+
finish = lambda do |item, i, result|
|
176
|
+
spinner = item_spinners[item]
|
177
|
+
table_name = item.first.sub("#{first_schema}.", "")
|
178
|
+
|
179
|
+
if result[:status] == "success"
|
180
|
+
spinner.success(display_message(result))
|
181
|
+
else
|
182
|
+
# TODO add option to fail fast
|
183
|
+
spinner.error(display_message(result))
|
184
|
+
failed_tables << table_name
|
185
|
+
fail_sync(failed_tables) if @options[:fail_fast]
|
186
|
+
end
|
187
|
+
|
188
|
+
unless spinner.send(:tty?)
|
189
|
+
status = result[:status] == "success" ? "✔" : "✖"
|
190
|
+
log [status, table_name, display_message(result)].compact.join(" ")
|
191
|
+
end
|
192
|
+
end
|
193
|
+
|
194
|
+
options = {start: start, finish: finish}
|
195
|
+
if @options[:debug] || @options[:in_batches]
|
196
|
+
options[:in_processes] = 0
|
197
|
+
else
|
198
|
+
options[:in_threads] = 4 if windows?
|
199
|
+
end
|
200
|
+
|
201
|
+
# could try to use `raise Parallel::Kill` to fail faster with --fail-fast
|
202
|
+
# see `fast_faster` branch
|
203
|
+
# however, need to make sure connections are cleaned up properly
|
204
|
+
Parallel.each(tables, **options, &block)
|
205
|
+
|
206
|
+
fail_sync(failed_tables) if failed_tables.any?
|
207
|
+
end
|
208
|
+
|
209
|
+
def fail_sync(failed_tables)
|
210
|
+
raise Error, "Sync failed for #{failed_tables.size} table#{failed_tables.size == 1 ? nil : "s"}: #{failed_tables.join(", ")}"
|
211
|
+
end
|
212
|
+
|
213
|
+
def display_message(result)
|
214
|
+
messages = []
|
215
|
+
messages << "- #{result[:time]}s" if result[:time]
|
216
|
+
messages << "(#{result[:message].gsub("\n", " ").strip})" if result[:message]
|
217
|
+
messages.join(" ")
|
218
|
+
end
|
219
|
+
|
220
|
+
def pretty_list(items)
|
221
|
+
items.each do |item|
|
222
|
+
log item
|
223
|
+
end
|
224
|
+
end
|
225
|
+
|
226
|
+
def deprecated(message)
|
227
|
+
log colorize("[DEPRECATED] #{message}", 33) # yellow
|
228
|
+
end
|
229
|
+
|
230
|
+
def log_completed(start_time)
|
231
|
+
time = Time.now - start_time
|
232
|
+
message = "Completed in #{time.round(1)}s"
|
233
|
+
log colorize(message, 32) # green
|
234
|
+
end
|
235
|
+
|
236
|
+
def windows?
|
237
|
+
Gem.win_platform?
|
238
|
+
end
|
239
|
+
end
|
240
|
+
end
|
data/lib/pgsync/table_list.rb
CHANGED
@@ -1,5 +1,7 @@
|
|
1
1
|
module PgSync
|
2
2
|
class TableList
|
3
|
+
include Utils
|
4
|
+
|
3
5
|
attr_reader :args, :opts, :source, :config
|
4
6
|
|
5
7
|
def initialize(args, options, source, config)
|
@@ -20,7 +22,7 @@ module PgSync
|
|
20
22
|
if (t = (config["groups"] || {})[group])
|
21
23
|
add_tables(tables, t, id, args[1])
|
22
24
|
else
|
23
|
-
raise
|
25
|
+
raise Error, "Group not found: #{group}"
|
24
26
|
end
|
25
27
|
end
|
26
28
|
end
|
@@ -29,7 +31,7 @@ module PgSync
|
|
29
31
|
tables ||= Hash.new { |hash, key| hash[key] = {} }
|
30
32
|
to_arr(opts[:tables]).each do |tag|
|
31
33
|
table, id = tag.split(":", 2)
|
32
|
-
raise
|
34
|
+
raise Error, "Cannot use parameters with tables" if id
|
33
35
|
add_table(tables, table, id, args[1])
|
34
36
|
end
|
35
37
|
end
|
@@ -43,7 +45,7 @@ module PgSync
|
|
43
45
|
if (t = (config["groups"] || {})[group])
|
44
46
|
add_tables(tables, t, id, args[1])
|
45
47
|
else
|
46
|
-
raise
|
48
|
+
raise Error, "Cannot use parameters with tables" if id
|
47
49
|
add_table(tables, group, id, args[1])
|
48
50
|
end
|
49
51
|
end
|
@@ -78,6 +80,12 @@ module PgSync
|
|
78
80
|
end
|
79
81
|
|
80
82
|
def add_tables(tables, t, id, boom)
|
83
|
+
# if id
|
84
|
+
# # TODO show group name and value
|
85
|
+
# log colorize("`pgsync group:value` is deprecated and will have a different function in 0.6.0.", 33) # yellow
|
86
|
+
# log colorize("Use `pgsync group --var 1=value` instead.", 33) # yellow
|
87
|
+
# end
|
88
|
+
|
81
89
|
t.each do |table|
|
82
90
|
sql = nil
|
83
91
|
if table.is_a?(Array)
|
@@ -96,10 +104,38 @@ module PgSync
|
|
96
104
|
end
|
97
105
|
else
|
98
106
|
tables[table] = {}
|
99
|
-
|
107
|
+
if boom
|
108
|
+
sql = boom.dup
|
109
|
+
# vars must match \w
|
110
|
+
missing_vars = sql.scan(/{\w+}/).map { |v| v[1..-2] }
|
111
|
+
|
112
|
+
vars = {}
|
113
|
+
|
114
|
+
# legacy
|
115
|
+
if id
|
116
|
+
vars["id"] = cast(id)
|
117
|
+
vars["1"] = cast(id)
|
118
|
+
end
|
119
|
+
|
120
|
+
# opts[:var].each do |value|
|
121
|
+
# k, v = value.split("=", 2)
|
122
|
+
# vars[k] = v
|
123
|
+
# end
|
124
|
+
|
125
|
+
sql = boom.dup
|
126
|
+
vars.each do |k, v|
|
127
|
+
# only sub if in var list
|
128
|
+
sql.gsub!("{#{k}}", cast(v)) if missing_vars.delete(k)
|
129
|
+
end
|
130
|
+
|
131
|
+
raise Error, "Missing variables: #{missing_vars.uniq.join(", ")}" if missing_vars.any?
|
132
|
+
|
133
|
+
tables[table][:sql] = sql
|
134
|
+
end
|
100
135
|
end
|
101
136
|
end
|
102
137
|
|
138
|
+
# TODO quote vars in next major version
|
103
139
|
def cast(value)
|
104
140
|
value.to_s.gsub(/\A\"|\"\z/, '')
|
105
141
|
end
|
data/lib/pgsync/table_sync.rb
CHANGED
@@ -1,5 +1,7 @@
|
|
1
1
|
module PgSync
|
2
2
|
class TableSync
|
3
|
+
include Utils
|
4
|
+
|
3
5
|
def sync(config, table, opts, source_url, destination_url)
|
4
6
|
start_time = Time.now
|
5
7
|
source = DataSource.new(source_url, timeout: 0)
|
@@ -57,8 +59,8 @@ module PgSync
|
|
57
59
|
|
58
60
|
copy_to_command = "COPY (SELECT #{copy_fields} FROM #{quote_ident_full(table)}#{sql_clause}) TO STDOUT"
|
59
61
|
if opts[:in_batches]
|
60
|
-
raise
|
61
|
-
raise
|
62
|
+
raise Error, "Cannot use --overwrite with --in-batches" if opts[:overwrite]
|
63
|
+
raise Error, "No primary key" unless primary_key
|
62
64
|
|
63
65
|
destination.truncate(table) if opts[:truncate]
|
64
66
|
|
@@ -99,8 +101,10 @@ module PgSync
|
|
99
101
|
sleep(opts[:sleep])
|
100
102
|
end
|
101
103
|
end
|
104
|
+
|
105
|
+
log # add extra line for spinner
|
102
106
|
elsif !opts[:truncate] && (opts[:overwrite] || opts[:preserve] || !sql_clause.empty?)
|
103
|
-
raise
|
107
|
+
raise Error, "No primary key" unless primary_key
|
104
108
|
|
105
109
|
temp_table = "pgsync_#{rand(1_000_000_000)}"
|
106
110
|
file = Tempfile.new(temp_table)
|
@@ -160,8 +164,20 @@ module PgSync
|
|
160
164
|
source.close
|
161
165
|
destination.close
|
162
166
|
end
|
163
|
-
rescue
|
164
|
-
|
167
|
+
rescue => e
|
168
|
+
message =
|
169
|
+
case e
|
170
|
+
when PG::Error
|
171
|
+
# likely fine to show simplified message here
|
172
|
+
# the full message will be shown when first trying to connect
|
173
|
+
"Connection failed"
|
174
|
+
when Error
|
175
|
+
e.message
|
176
|
+
else
|
177
|
+
"#{e.class.name}: #{e.message}"
|
178
|
+
end
|
179
|
+
|
180
|
+
{status: "error", message: message}
|
165
181
|
end
|
166
182
|
|
167
183
|
private
|
@@ -180,7 +196,7 @@ module PgSync
|
|
180
196
|
elsif rule.key?("statement")
|
181
197
|
rule["statement"]
|
182
198
|
else
|
183
|
-
raise
|
199
|
+
raise Error, "Unknown rule #{rule.inspect} for column #{column}"
|
184
200
|
end
|
185
201
|
else
|
186
202
|
case rule
|
@@ -207,7 +223,7 @@ module PgSync
|
|
207
223
|
when "null", nil
|
208
224
|
"NULL"
|
209
225
|
else
|
210
|
-
raise
|
226
|
+
raise Error, "Unknown rule #{rule} for column #{column}"
|
211
227
|
end
|
212
228
|
end
|
213
229
|
end
|
@@ -217,10 +233,6 @@ module PgSync
|
|
217
233
|
"#{quote_ident_full(table)}.#{quote_ident(primary_key)}"
|
218
234
|
end
|
219
235
|
|
220
|
-
def log(message = nil)
|
221
|
-
$stderr.puts message
|
222
|
-
end
|
223
|
-
|
224
236
|
def quote_ident_full(ident)
|
225
237
|
ident.split(".").map { |v| quote_ident(v) }.join(".")
|
226
238
|
end
|
data/lib/pgsync/utils.rb
ADDED
@@ -0,0 +1,46 @@
|
|
1
|
+
module PgSync
|
2
|
+
module Utils
|
3
|
+
def log(message = nil)
|
4
|
+
$stderr.puts message
|
5
|
+
end
|
6
|
+
|
7
|
+
def colorize(message, color_code)
|
8
|
+
if $stderr.tty?
|
9
|
+
"\e[#{color_code}m#{message}\e[0m"
|
10
|
+
else
|
11
|
+
message
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
def config_file
|
16
|
+
return @config_file if instance_variable_defined?(:@config_file)
|
17
|
+
|
18
|
+
@config_file =
|
19
|
+
search_tree(
|
20
|
+
if @options[:db]
|
21
|
+
db_config_file(@options[:db])
|
22
|
+
else
|
23
|
+
@options[:config] || ".pgsync.yml"
|
24
|
+
end
|
25
|
+
)
|
26
|
+
end
|
27
|
+
|
28
|
+
def db_config_file(db)
|
29
|
+
return unless db
|
30
|
+
".pgsync-#{db}.yml"
|
31
|
+
end
|
32
|
+
|
33
|
+
def search_tree(file)
|
34
|
+
path = Dir.pwd
|
35
|
+
# prevent infinite loop
|
36
|
+
20.times do
|
37
|
+
absolute_file = File.join(path, file)
|
38
|
+
if File.exist?(absolute_file)
|
39
|
+
break absolute_file
|
40
|
+
end
|
41
|
+
path = File.dirname(path)
|
42
|
+
break if path == "/"
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
data/lib/pgsync/version.rb
CHANGED
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: pgsync
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.5.
|
4
|
+
version: 0.5.2
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Andrew Kane
|
8
8
|
autorequire:
|
9
9
|
bindir: exe
|
10
10
|
cert_chain: []
|
11
|
-
date: 2020-03-
|
11
|
+
date: 2020-03-28 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: parallel
|
@@ -123,8 +123,11 @@ files:
|
|
123
123
|
- lib/pgsync.rb
|
124
124
|
- lib/pgsync/client.rb
|
125
125
|
- lib/pgsync/data_source.rb
|
126
|
+
- lib/pgsync/init.rb
|
127
|
+
- lib/pgsync/sync.rb
|
126
128
|
- lib/pgsync/table_list.rb
|
127
129
|
- lib/pgsync/table_sync.rb
|
130
|
+
- lib/pgsync/utils.rb
|
128
131
|
- lib/pgsync/version.rb
|
129
132
|
homepage: https://github.com/ankane/pgsync
|
130
133
|
licenses:
|