pgsync 0.5.4 → 0.6.3

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.

@@ -1,10 +1,11 @@
1
1
  module PgSync
2
2
  class DataSource
3
+ include Utils
4
+
3
5
  attr_reader :url
4
6
 
5
- def initialize(source, timeout: 3)
6
- @url = resolve_url(source)
7
- @timeout = timeout
7
+ def initialize(url)
8
+ @url = url
8
9
  end
9
10
 
10
11
  def exists?
@@ -30,8 +31,18 @@ module PgSync
30
31
  # gets visible tables
31
32
  def tables
32
33
  @tables ||= begin
33
- query = "SELECT table_schema, table_name FROM information_schema.tables WHERE table_type = 'BASE TABLE' AND table_schema NOT IN ('information_schema', 'pg_catalog') ORDER BY 1, 2"
34
- execute(query).map { |row| "#{row["table_schema"]}.#{row["table_name"]}" }
34
+ query = <<~SQL
35
+ SELECT
36
+ table_schema AS schema,
37
+ table_name AS table
38
+ FROM
39
+ information_schema.tables
40
+ WHERE
41
+ table_type = 'BASE TABLE' AND
42
+ table_schema NOT IN ('information_schema', 'pg_catalog')
43
+ ORDER BY 1, 2
44
+ SQL
45
+ execute(query).map { |row| Table.new(row["schema"], row["table"]) }
35
46
  end
36
47
  end
37
48
 
@@ -39,25 +50,21 @@ module PgSync
39
50
  table_set.include?(table)
40
51
  end
41
52
 
42
- def columns(table)
43
- query = "SELECT column_name FROM information_schema.columns WHERE table_schema = $1 AND table_name = $2"
44
- execute(query, table.split(".", 2)).map { |row| row["column_name"] }
45
- end
46
-
47
53
  def sequences(table, columns)
48
- execute("SELECT #{columns.map { |f| "pg_get_serial_sequence(#{escape("#{quote_ident_full(table)}")}, #{escape(f)}) AS #{quote_ident(f)}" }.join(", ")}")[0].values.compact
54
+ execute("SELECT #{columns.map { |f| "pg_get_serial_sequence(#{escape("#{quote_ident_full(table)}")}, #{escape(f)}) AS #{quote_ident(f)}" }.join(", ")}").first.values.compact
49
55
  end
50
56
 
51
57
  def max_id(table, primary_key, sql_clause = nil)
52
- execute("SELECT MAX(#{quote_ident(primary_key)}) FROM #{quote_ident_full(table)}#{sql_clause}")[0]["max"].to_i
58
+ execute("SELECT MAX(#{quote_ident(primary_key)}) FROM #{quote_ident_full(table)}#{sql_clause}").first["max"].to_i
53
59
  end
54
60
 
55
61
  def min_id(table, primary_key, sql_clause = nil)
56
- execute("SELECT MIN(#{quote_ident(primary_key)}) FROM #{quote_ident_full(table)}#{sql_clause}")[0]["min"].to_i
62
+ execute("SELECT MIN(#{quote_ident(primary_key)}) FROM #{quote_ident_full(table)}#{sql_clause}").first["min"].to_i
57
63
  end
58
64
 
65
+ # this value comes from pg_get_serial_sequence which is already quoted
59
66
  def last_value(seq)
60
- execute("select last_value from #{seq}")[0]["last_value"]
67
+ execute("SELECT last_value FROM #{seq}").first["last_value"]
61
68
  end
62
69
 
63
70
  def truncate(table)
@@ -65,38 +72,57 @@ module PgSync
65
72
  end
66
73
 
67
74
  # https://stackoverflow.com/a/20537829
75
+ # TODO can simplify with array_position in Postgres 9.5+
68
76
  def primary_key(table)
69
- query = <<-SQL
77
+ query = <<~SQL
70
78
  SELECT
71
79
  pg_attribute.attname,
72
- format_type(pg_attribute.atttypid, pg_attribute.atttypmod)
80
+ format_type(pg_attribute.atttypid, pg_attribute.atttypmod),
81
+ pg_attribute.attnum,
82
+ pg_index.indkey
73
83
  FROM
74
84
  pg_index, pg_class, pg_attribute, pg_namespace
75
85
  WHERE
76
- pg_class.oid = $2::regclass AND
77
- indrelid = pg_class.oid AND
78
86
  nspname = $1 AND
87
+ relname = $2 AND
88
+ indrelid = pg_class.oid AND
79
89
  pg_class.relnamespace = pg_namespace.oid AND
80
90
  pg_attribute.attrelid = pg_class.oid AND
81
91
  pg_attribute.attnum = any(pg_index.indkey) AND
82
92
  indisprimary
83
93
  SQL
84
- row = execute(query, [table.split(".", 2)[0], quote_ident_full(table)])[0]
85
- row && row["attname"]
94
+ rows = execute(query, [table.schema, table.name])
95
+ rows.sort_by { |r| r["indkey"].split(" ").index(r["attnum"]) }.map { |r| r["attname"] }
96
+ end
97
+
98
+ def triggers(table)
99
+ query = <<~SQL
100
+ SELECT
101
+ tgname AS name,
102
+ tgisinternal AS internal,
103
+ tgenabled != 'D' AS enabled,
104
+ tgconstraint != 0 AS integrity
105
+ FROM
106
+ pg_trigger
107
+ WHERE
108
+ pg_trigger.tgrelid = $1::regclass
109
+ SQL
110
+ execute(query, [quote_ident_full(table)])
86
111
  end
87
112
 
88
113
  def conn
89
114
  @conn ||= begin
90
115
  begin
91
- ENV["PGCONNECT_TIMEOUT"] ||= @timeout.to_s
116
+ ENV["PGCONNECT_TIMEOUT"] ||= "3"
92
117
  if @url =~ /\Apostgres(ql)?:\/\//
93
118
  config = @url
94
119
  else
95
120
  config = {dbname: @url}
96
121
  end
122
+ @concurrent_id = concurrent_id
97
123
  PG::Connection.new(config)
98
124
  rescue URI::InvalidURIError
99
- raise Error, "Invalid connection string"
125
+ raise Error, "Invalid connection string. Make sure it works with `psql`"
100
126
  end
101
127
  end
102
128
  end
@@ -108,84 +134,56 @@ module PgSync
108
134
  end
109
135
  end
110
136
 
111
- def dump_command(tables)
112
- tables = tables ? tables.keys.map { |t| "-t #{Shellwords.escape(quote_ident_full(t))}" }.join(" ") : ""
113
- "pg_dump -Fc --verbose --schema-only --no-owner --no-acl #{tables} -d #{@url}"
114
- end
115
-
116
- def restore_command
117
- if_exists = Gem::Version.new(pg_restore_version) >= Gem::Version.new("9.4.0")
118
- "pg_restore --verbose --no-owner --no-acl --clean #{if_exists ? "--if-exists" : nil} -d #{@url}"
119
- end
120
-
121
- def fully_resolve_tables(tables)
122
- no_schema_tables = {}
123
- search_path_index = Hash[search_path.map.with_index.to_a]
124
- self.tables.group_by { |t| t.split(".", 2)[-1] }.each do |group, t2|
125
- no_schema_tables[group] = t2.sort_by { |t| [search_path_index[t.split(".", 2)[0]] || 1000000, t] }[0]
126
- end
127
-
128
- Hash[tables.map { |k, v| [no_schema_tables[k] || k, v] }]
137
+ # reconnect for new thread or process
138
+ def reconnect_if_needed
139
+ reconnect if @concurrent_id != concurrent_id
129
140
  end
130
141
 
131
142
  def search_path
132
- @search_path ||= execute("SELECT current_schemas(true)")[0]["current_schemas"][1..-2].split(",")
143
+ @search_path ||= execute("SELECT unnest(current_schemas(true)) AS schema").map { |r| r["schema"] }
133
144
  end
134
145
 
135
- private
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"
146
+ def server_version_num
147
+ @server_version_num ||= execute("SHOW server_version_num").first["server_version_num"].to_i
141
148
  end
142
149
 
143
- def table_set
144
- @table_set ||= Set.new(tables)
145
- end
146
-
147
- def conninfo
148
- @conninfo ||= conn.conninfo_hash
150
+ def execute(query, params = [])
151
+ conn.exec_params(query, params).to_a
149
152
  end
150
153
 
151
- def quote_ident_full(ident)
152
- ident.split(".", 2).map { |v| quote_ident(v) }.join(".")
154
+ def transaction
155
+ if conn.transaction_status == 0
156
+ # not currently in transaction
157
+ conn.transaction do
158
+ yield
159
+ end
160
+ else
161
+ yield
162
+ end
153
163
  end
154
164
 
155
- def execute(query, params = [])
156
- conn.exec_params(query, params).to_a
157
- end
165
+ private
158
166
 
159
- def quote_ident(value)
160
- PG::Connection.quote_ident(value)
167
+ def concurrent_id
168
+ [Process.pid, Thread.current.object_id]
161
169
  end
162
170
 
163
- def escape(value)
164
- if value.is_a?(String)
165
- "'#{quote_string(value)}'"
166
- else
167
- value
168
- end
171
+ def reconnect
172
+ @conn.reset
173
+ @concurrent_id = concurrent_id
169
174
  end
170
175
 
171
- # activerecord
172
- def quote_string(s)
173
- s.gsub(/\\/, '\&\&').gsub(/'/, "''")
176
+ def table_set
177
+ @table_set ||= Set.new(tables)
174
178
  end
175
179
 
176
- def resolve_url(source)
177
- if source
178
- source = source.dup
179
- source.gsub!(/\$\([^)]+\)/) do |m|
180
- command = m[2..-2]
181
- result = `#{command}`.chomp
182
- unless $?.success?
183
- raise Error, "Command exited with non-zero status:\n#{command}"
184
- end
185
- result
180
+ def conninfo
181
+ @conninfo ||= begin
182
+ unless conn.respond_to?(:conninfo_hash)
183
+ raise Error, "libpq is too old. Upgrade it and run `gem install pg`"
186
184
  end
185
+ conn.conninfo_hash
187
186
  end
188
- source
189
187
  end
190
188
  end
191
189
  end
@@ -2,11 +2,26 @@ module PgSync
2
2
  class Init
3
3
  include Utils
4
4
 
5
- def perform(opts)
6
- # needed for config_file method
7
- @options = opts.to_hash
5
+ def initialize(arguments, options)
6
+ @arguments = arguments
7
+ @options = options
8
+ end
9
+
10
+ def perform
11
+ if @arguments.size > 1
12
+ raise Error, "Usage:\n pgsync --init [db]"
13
+ end
8
14
 
9
- file = db_config_file(opts.arguments[0]) || config_file || ".pgsync.yml"
15
+ file =
16
+ if @options[:config]
17
+ @options[:config]
18
+ elsif @arguments.any?
19
+ db_config_file(@arguments.first)
20
+ elsif @options[:db]
21
+ db_config_file(@options[:db])
22
+ else
23
+ ".pgsync.yml"
24
+ end
10
25
 
11
26
  if File.exist?(file)
12
27
  raise Error, "#{file} exists."
@@ -15,8 +30,19 @@ module PgSync
15
30
  if rails?
16
31
  <<~EOS
17
32
  exclude:
18
- - schema_migrations
19
33
  - ar_internal_metadata
34
+ - schema_migrations
35
+ EOS
36
+ elsif django?
37
+ # TODO exclude other tables?
38
+ <<~EOS
39
+ exclude:
40
+ - django_migrations
41
+ EOS
42
+ elsif laravel?
43
+ <<~EOS
44
+ exclude:
45
+ - migrations
20
46
  EOS
21
47
  else
22
48
  <<~EOS
@@ -35,12 +61,30 @@ module PgSync
35
61
  end
36
62
  end
37
63
 
64
+ def django?
65
+ file_exists?("manage.py", /django/i)
66
+ end
67
+
38
68
  def heroku?
39
69
  `git remote -v 2>&1`.include?("git.heroku.com") rescue false
40
70
  end
41
71
 
72
+ def laravel?
73
+ file_exists?("artisan")
74
+ end
75
+
42
76
  def rails?
43
- File.exist?("bin/rails")
77
+ file_exists?("bin/rails")
78
+ end
79
+
80
+ def file_exists?(path, contents = nil)
81
+ if contents
82
+ File.read(path).match(contents)
83
+ else
84
+ File.exist?(path)
85
+ end
86
+ rescue
87
+ false
44
88
  end
45
89
  end
46
90
  end
@@ -0,0 +1,83 @@
1
+ module PgSync
2
+ class SchemaSync
3
+ include Utils
4
+
5
+ attr_reader :args, :opts
6
+
7
+ def initialize(source:, destination:, tasks:, args:, opts:)
8
+ @source = source
9
+ @destination = destination
10
+ @tasks = tasks
11
+ @args = args
12
+ @opts = opts
13
+ end
14
+
15
+ def perform
16
+ if opts[:preserve]
17
+ raise Error, "Cannot use --preserve with --schema-first or --schema-only"
18
+ end
19
+
20
+ show_spinner = output.tty? && !opts[:debug]
21
+
22
+ if show_spinner
23
+ spinner = TTY::Spinner.new(":spinner Syncing schema", format: :dots)
24
+ spinner.auto_spin
25
+ end
26
+
27
+ # if spinner, capture lines to show on error
28
+ lines = []
29
+ success =
30
+ run_command("#{dump_command} | #{restore_command}") do |line|
31
+ if show_spinner
32
+ lines << line
33
+ else
34
+ log line
35
+ end
36
+ end
37
+
38
+ if show_spinner
39
+ if success
40
+ spinner.success
41
+ else
42
+ spinner.error
43
+ log lines.join
44
+ end
45
+ end
46
+
47
+ raise Error, "Schema sync returned non-zero exit code" unless success
48
+ end
49
+
50
+ private
51
+
52
+ def run_command(command)
53
+ Open3.popen2e(command) do |stdin, stdout, wait_thr|
54
+ stdout.each do |line|
55
+ yield line
56
+ end
57
+ wait_thr.value.success?
58
+ end
59
+ end
60
+
61
+ def pg_restore_version
62
+ `pg_restore --version`.lines[0].chomp.split(" ")[-1].split(/[^\d.]/)[0]
63
+ rescue Errno::ENOENT
64
+ raise Error, "pg_restore not found"
65
+ end
66
+
67
+ def dump_command
68
+ tables =
69
+ if !opts[:all_schemas] || opts[:tables] || opts[:groups] || args[0] || opts[:exclude] || opts[:schemas]
70
+ @tasks.map { |task| "-t #{Shellwords.escape(task.quoted_table)}" }
71
+ else
72
+ []
73
+ end
74
+
75
+ "pg_dump -Fc --verbose --schema-only --no-owner --no-acl #{tables.join(" ")} -d #{@source.url}"
76
+ end
77
+
78
+ def restore_command
79
+ if_exists = Gem::Version.new(pg_restore_version) >= Gem::Version.new("9.4.0")
80
+ "pg_restore --verbose --no-owner --no-acl --clean #{if_exists ? "--if-exists" : nil} -d #{@destination.url}"
81
+ end
82
+ end
83
+ end
@@ -2,139 +2,76 @@ module PgSync
2
2
  class Sync
3
3
  include Utils
4
4
 
5
- def perform(options)
6
- args = options.arguments
7
- opts = options.to_hash
8
- @options = opts
5
+ def initialize(arguments, options)
6
+ @arguments = arguments
7
+ @options = options
8
+ end
9
9
 
10
- # merge config
11
- [:to, :from, :to_safe, :exclude, :schemas].each do |opt|
12
- opts[opt] ||= config[opt.to_s]
13
- end
10
+ def perform
11
+ started_at = Time.now
14
12
 
15
- # TODO remove deprecations
16
- map_deprecations(args, opts)
13
+ args = @arguments
14
+ opts = @options
17
15
 
18
- # start
19
- start_time = Time.now
16
+ # only resolve commands from config, not CLI arguments
17
+ [:to, :from].each do |opt|
18
+ opts[opt] ||= resolve_source(config[opt.to_s])
19
+ end
20
+
21
+ # merge other config
22
+ [:to_safe, :exclude, :schemas].each do |opt|
23
+ opts[opt] ||= config[opt.to_s]
24
+ end
20
25
 
21
26
  if args.size > 2
22
27
  raise Error, "Usage:\n pgsync [options]"
23
28
  end
24
29
 
25
- source = DataSource.new(opts[:from])
26
30
  raise Error, "No source" unless source.exists?
27
-
28
- destination = DataSource.new(opts[:to])
29
31
  raise Error, "No destination" unless destination.exists?
30
32
 
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
33
+ unless opts[:to_safe] || destination.local?
34
+ raise Error, "Danger! Add `to_safe: true` to `.pgsync.yml` if the destination is not localhost or 127.0.0.1"
45
35
  end
46
36
 
47
- tables = nil
48
- begin
49
- tables = TableList.new(args, opts, source, config).tables
50
- ensure
51
- source.close
37
+ if (opts[:preserve] || opts[:overwrite]) && destination.server_version_num < 90500
38
+ raise Error, "Postgres 9.5+ is required for --preserve and --overwrite"
52
39
  end
53
40
 
54
- confirm_tables_exist(source, tables, "source")
41
+ print_description("From", source)
42
+ print_description("To", destination)
55
43
 
56
- if opts[:list]
57
- confirm_tables_exist(destination, tables, "destination")
44
+ resolver = TaskResolver.new(args: args, opts: opts, source: source, destination: destination, config: config, first_schema: first_schema)
45
+ tasks =
46
+ resolver.tasks.map do |task|
47
+ Task.new(source: source, destination: destination, config: config, table: task[:table], opts: opts.merge(sql: task[:sql]))
48
+ end
58
49
 
59
- list_items =
60
- if args[0] == "groups"
61
- (config["groups"] || {}).keys
62
- else
63
- tables.keys
64
- end
50
+ if opts[:in_batches] && tasks.size > 1
51
+ raise Error, "Cannot use --in-batches with multiple tables"
52
+ end
65
53
 
66
- pretty_list list_items
54
+ confirm_tables_exist(source, tasks, "source")
55
+
56
+ if opts[:list]
57
+ confirm_tables_exist(destination, tasks, "destination")
58
+ tasks.each do |task|
59
+ log task_name(task)
60
+ end
67
61
  else
68
62
  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)
63
+ SchemaSync.new(source: source, destination: destination, tasks: tasks, args: args, opts: opts).perform
76
64
  end
77
65
 
78
66
  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
67
+ TableSync.new(source: source, destination: destination, tasks: tasks, opts: opts, resolver: resolver).perform
84
68
  end
85
69
 
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
70
+ log_completed(started_at)
95
71
  end
96
- ensure
97
- data_source.close
98
72
  end
99
73
 
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
74
+ private
138
75
 
139
76
  def config
140
77
  @config ||= begin
@@ -144,6 +81,8 @@ module PgSync
144
81
  YAML.load_file(file) || {}
145
82
  rescue Psych::SyntaxError => e
146
83
  raise Error, e.message
84
+ rescue Errno::ENOENT
85
+ raise Error, "Config file not found: #{file}"
147
86
  end
148
87
  else
149
88
  {}
@@ -151,95 +90,74 @@ module PgSync
151
90
  end
152
91
  end
153
92
 
154
- def print_description(prefix, source)
155
- location = " on #{source.host}:#{source.port}" if source.host
156
- log "#{prefix}: #{source.dbname}#{location}"
157
- end
158
-
159
- def in_parallel(tables, first_schema:, &block)
160
- spinners = TTY::Spinner::Multi.new(format: :dots, output: output)
161
- item_spinners = {}
162
-
163
- start = lambda do |item, i|
164
- table, opts = item
165
- message = String.new(":spinner ")
166
- message << table.sub("#{first_schema}.", "")
167
- message << " #{opts[:sql]}" if opts[:sql]
168
- spinner = spinners.register(message)
169
- if @options[:in_batches]
170
- # log instead of spin for non-tty
171
- log message.sub(":spinner", "⠋")
172
- else
173
- spinner.auto_spin
174
- end
175
- item_spinners[item] = spinner
93
+ def config_file
94
+ if @options[:config]
95
+ @options[:config]
96
+ elsif @options[:db]
97
+ file = db_config_file(@options[:db])
98
+ search_tree(file) || file
99
+ else
100
+ search_tree(".pgsync.yml")
176
101
  end
102
+ end
177
103
 
178
- failed_tables = []
179
-
180
- finish = lambda do |item, i, result|
181
- spinner = item_spinners[item]
182
- table_name = item.first.sub("#{first_schema}.", "")
183
-
184
- if result[:status] == "success"
185
- spinner.success(display_message(result))
186
- else
187
- # TODO add option to fail fast
188
- spinner.error(display_message(result))
189
- failed_tables << table_name
190
- fail_sync(failed_tables) if @options[:fail_fast]
191
- end
192
-
193
- unless spinner.send(:tty?)
194
- status = result[:status] == "success" ? "✔" : "✖"
195
- log [status, table_name, item.last[:sql], display_message(result)].compact.join(" ")
196
- end
197
- end
104
+ def search_tree(file)
105
+ return file if File.exist?(file)
198
106
 
199
- options = {start: start, finish: finish}
200
- if @options[:debug] || @options[:in_batches]
201
- options[:in_processes] = 0
202
- else
203
- options[:in_threads] = 4 if windows?
107
+ path = Dir.pwd
108
+ # prevent infinite loop
109
+ 20.times do
110
+ absolute_file = File.join(path, file)
111
+ break absolute_file if File.exist?(absolute_file)
112
+ path = File.dirname(path)
113
+ break if path == "/"
204
114
  end
115
+ end
205
116
 
206
- # could try to use `raise Parallel::Kill` to fail faster with --fail-fast
207
- # see `fast_faster` branch
208
- # however, need to make sure connections are cleaned up properly
209
- Parallel.each(tables, **options, &block)
210
-
211
- fail_sync(failed_tables) if failed_tables.any?
117
+ def print_description(prefix, source)
118
+ location = " on #{source.host}:#{source.port}" if source.host
119
+ log "#{prefix}: #{source.dbname}#{location}"
212
120
  end
213
121
 
214
- def fail_sync(failed_tables)
215
- raise Error, "Sync failed for #{failed_tables.size} table#{failed_tables.size == 1 ? nil : "s"}: #{failed_tables.join(", ")}"
122
+ def log_completed(started_at)
123
+ time = Time.now - started_at
124
+ message = "Completed in #{time.round(1)}s"
125
+ log colorize(message, :green)
216
126
  end
217
127
 
218
- def display_message(result)
219
- messages = []
220
- messages << "- #{result[:time]}s" if result[:time]
221
- messages << "(#{result[:message].lines.first.to_s.strip})" if result[:message]
222
- messages.join(" ")
128
+ def source
129
+ @source ||= data_source(@options[:from])
223
130
  end
224
131
 
225
- def pretty_list(items)
226
- items.each do |item|
227
- log item
228
- end
132
+ def destination
133
+ @destination ||= data_source(@options[:to])
229
134
  end
230
135
 
231
- def deprecated(message)
232
- log colorize("[DEPRECATED] #{message}", :yellow)
136
+ def data_source(url)
137
+ ds = DataSource.new(url)
138
+ ObjectSpace.define_finalizer(self, self.class.finalize(ds))
139
+ ds
233
140
  end
234
141
 
235
- def log_completed(start_time)
236
- time = Time.now - start_time
237
- message = "Completed in #{time.round(1)}s"
238
- log colorize(message, :green)
142
+ # ideally aliases would work, but haven't found a nice way to do this
143
+ def resolve_source(source)
144
+ if source
145
+ source = source.dup
146
+ source.gsub!(/\$\([^)]+\)/) do |m|
147
+ command = m[2..-2]
148
+ result = `#{command}`.chomp
149
+ unless $?.success?
150
+ raise Error, "Command exited with non-zero status:\n#{command}"
151
+ end
152
+ result
153
+ end
154
+ end
155
+ source
239
156
  end
240
157
 
241
- def windows?
242
- Gem.win_platform?
158
+ def self.finalize(ds)
159
+ # must use proc instead of stabby lambda
160
+ proc { ds.close }
243
161
  end
244
162
  end
245
163
  end