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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b145ab9d9f312a9a043e91d5d4b9e7977bb5788e339aa4f88a571a5178d9c92f
4
- data.tar.gz: 8843f622d7b152fff80a2a465fddd55dcd4271f0256cd64b46a3898a38214d65
3
+ metadata.gz: af75f9e9eb115a889d53b10ed89d8355e3ef111868b69eec31a025a8f22db27e
4
+ data.tar.gz: 5da0d56267ac89f2ffc282e92275a91d3fb83d80fe969207329ec8ce1876472b
5
5
  SHA512:
6
- metadata.gz: f210607011225b932975ddd77343c1ff9468ae824a642b3c0b48033853a2fa0d68f4eed4fcfcda5c36a6d7cc1b99e491a6a0b62f147d232c8d517721b08a6590
7
- data.tar.gz: bb38c6291b40ad0188483889aa9696269a384456ed7425be61e624a26b797110dc363a44b9d0774fc4b2ad04122d6e8863d04b7bb1a89f986c0829ada8a2aea4
6
+ metadata.gz: 9f9d78aebf02f75a5fee9413b56bba68ed97334b3134cef5c28f4b5a8a15cbab45e752cb73dcd88028e2b3b2b0fefb20ef9bd4abe0c92d35998a520b0029e961
7
+ data.tar.gz: a2fdaee8ba32df378ed2538119817b7649fe134a33c0f6013d55d8e1cb76ae6b0a75d3dd10dff4872d0452bc2e41bb0024c5f276f16ed8f0d1c81b114574f757
data/CHANGELOG.md CHANGED
@@ -1,3 +1,10 @@
1
+ ## 0.5.2 (2020-03-27)
2
+
3
+ - Added `--fail-fast` option
4
+ - Automatically exclude tables when `--init` run inside Rails app
5
+ - Improved error message
6
+ - Fixed typo in error message
7
+
1
8
  ## 0.5.1 (2020-03-26)
2
9
 
3
10
  - Fixed Slop warning with Ruby 2.7
data/README.md CHANGED
@@ -1,9 +1,10 @@
1
1
  # pgsync
2
2
 
3
- Sync Postgres data between databases. Designed for:
3
+ Sync data from one Postgres database to another (like `pg_dump`/`pg_restore`). Designed for:
4
4
 
5
- - **speed** - up to 4x faster than traditional tools on a 4-core machine
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 already set up on your local machine. See the [schema section](#schema) if that’s not the case.
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.with_clean_env do
252
+ Bundler.with_unbundled_env do
250
253
  system "pgsync ..."
251
254
  end
252
255
  ```
data/config.yml CHANGED
@@ -13,10 +13,7 @@ from: $(some_command)?sslmode=require
13
13
  to: postgres://localhost:5432/myapp_development
14
14
 
15
15
  # exclude tables
16
- # exclude:
17
- # - schema_migrations
18
- # - ar_internal_metadata
19
-
16
+ %{exclude}
20
17
  # define groups
21
18
  # groups:
22
19
  # group1:
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
- begin
9
- require "pgsync"
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
- $stdout.sync = true
6
+ @args = args
5
7
  $stderr.sync = true
6
- @exit = false
7
- @arguments, @options = parse_args(args)
8
8
  end
9
9
 
10
- # TODO clean up this mess
11
- def perform
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[:init]
21
- setup(db_config_file(args[0]) || config_file || ".pgsync.yml")
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
- sync(args, opts)
21
+ Sync.new.perform(opts)
24
22
  end
25
-
26
- true
23
+ rescue Error, PG::ConnectionBad => e
24
+ raise e if testing
25
+ abort colorize(e.message, 31) # red
27
26
  end
28
27
 
29
- protected
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
- def sync_schema(source, destination, tables = nil)
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(args)
157
- opts = Slop.parse(args) do |o|
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.on "-v", "--version", "print the version" do
190
- log PgSync::VERSION
191
- @exit = true
192
- end
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 PgSync::Error, e.message
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
@@ -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 PgSync::Error, "Invalid connection string"
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
- psql_version = `psql --version`.lines[0].chomp.split(" ")[-1].split(/[^\d.]/)[0]
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 PgSync::Error, "Command exited with non-zero status:\n#{command}"
183
+ raise Error, "Command exited with non-zero status:\n#{command}"
181
184
  end
182
185
  result
183
186
  end
@@ -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
@@ -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
@@ -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 PgSync::Error, "Group not found: #{group}"
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 PgSync::Error, "Cannot use parameters with tables" if id
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 PgSync::Error, "Cannot use parameters with tables" if id
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
- tables[table][:sql] = boom.gsub("{id}", cast(id)).gsub("{1}", cast(id)) if boom
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
@@ -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 PgSync::Error, "Cannot use --overwrite with --in-batches" if opts[:overwrite]
61
- raise PgSync::Error, "No primary key" unless primary_key
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 PgSync::Error, "No primary key" unless primary_key
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 PgSync::Error => e
164
- {status: "error", message: e.message}
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 PgSync::Error, "Unknown rule #{rule.inspect} for column #{column}"
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 PgSync::Error, "Unknown rule #{rule} for column #{column}"
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
@@ -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
@@ -1,3 +1,3 @@
1
1
  module PgSync
2
- VERSION = "0.5.1"
2
+ VERSION = "0.5.2"
3
3
  end
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.1
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-27 00:00:00.000000000 Z
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: