pgsync 0.5.1 → 0.6.0
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 +40 -0
- data/LICENSE.txt +1 -1
- data/README.md +84 -41
- data/config.yml +5 -4
- data/exe/pgsync +3 -11
- data/lib/pgsync.rb +8 -5
- data/lib/pgsync/client.rb +60 -332
- data/lib/pgsync/data_source.rb +78 -77
- data/lib/pgsync/init.rb +61 -0
- data/lib/pgsync/schema_sync.rb +83 -0
- data/lib/pgsync/sync.rb +162 -0
- data/lib/pgsync/table.rb +28 -0
- data/lib/pgsync/table_sync.rb +168 -208
- data/lib/pgsync/task.rb +315 -0
- data/lib/pgsync/task_resolver.rb +235 -0
- data/lib/pgsync/utils.rb +86 -0
- data/lib/pgsync/version.rb +1 -1
- metadata +11 -5
- data/lib/pgsync/table_list.rb +0 -107
@@ -0,0 +1,235 @@
|
|
1
|
+
module PgSync
|
2
|
+
class TaskResolver
|
3
|
+
include Utils
|
4
|
+
|
5
|
+
attr_reader :args, :opts, :source, :destination, :config, :first_schema, :notes
|
6
|
+
|
7
|
+
def initialize(args:, opts:, source:, destination:, config:, first_schema:)
|
8
|
+
@args = args
|
9
|
+
@opts = opts
|
10
|
+
@source = source
|
11
|
+
@destination = destination
|
12
|
+
@config = config
|
13
|
+
@groups = config["groups"] || {}
|
14
|
+
@first_schema = first_schema
|
15
|
+
@notes = []
|
16
|
+
end
|
17
|
+
|
18
|
+
def tasks
|
19
|
+
tasks = []
|
20
|
+
|
21
|
+
# get lists from args
|
22
|
+
groups, tables = process_args
|
23
|
+
|
24
|
+
# expand groups into tasks
|
25
|
+
groups.each do |group|
|
26
|
+
tasks.concat(group_to_tasks(group))
|
27
|
+
end
|
28
|
+
|
29
|
+
# expand tables into tasks
|
30
|
+
tables.each do |table|
|
31
|
+
tasks.concat(table_to_tasks(table))
|
32
|
+
end
|
33
|
+
|
34
|
+
# get default if none given
|
35
|
+
if !opts[:groups] && !opts[:tables] && args.size == 0
|
36
|
+
tasks.concat(default_tasks)
|
37
|
+
end
|
38
|
+
|
39
|
+
# resolve any tables that need it
|
40
|
+
tasks.each do |task|
|
41
|
+
task[:table] = fully_resolve(task[:table])
|
42
|
+
end
|
43
|
+
|
44
|
+
tasks
|
45
|
+
end
|
46
|
+
|
47
|
+
def group?(group)
|
48
|
+
@groups.key?(group)
|
49
|
+
end
|
50
|
+
|
51
|
+
private
|
52
|
+
|
53
|
+
def group_to_tasks(value)
|
54
|
+
group, param = value.split(":", 2)
|
55
|
+
raise Error, "Group not found: #{group}" unless group?(group)
|
56
|
+
|
57
|
+
@groups[group].map do |table|
|
58
|
+
table_sql = nil
|
59
|
+
if table.is_a?(Array)
|
60
|
+
table, table_sql = table
|
61
|
+
end
|
62
|
+
|
63
|
+
{
|
64
|
+
table: to_table(table),
|
65
|
+
sql: expand_sql(table_sql, param)
|
66
|
+
}
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
def table_to_tasks(value)
|
71
|
+
raise Error, "Cannot use parameters with tables" if value.include?(":")
|
72
|
+
|
73
|
+
tables =
|
74
|
+
if value.include?("*")
|
75
|
+
regex = Regexp.new('\A' + Regexp.escape(value).gsub('\*','[^\.]*') + '\z')
|
76
|
+
shared_tables.select { |t| regex.match(t.full_name) || regex.match(t.name) }
|
77
|
+
else
|
78
|
+
[to_table(value)]
|
79
|
+
end
|
80
|
+
|
81
|
+
tables.map do |table|
|
82
|
+
{
|
83
|
+
table: table,
|
84
|
+
sql: sql_arg # doesn't support params
|
85
|
+
}
|
86
|
+
end
|
87
|
+
end
|
88
|
+
|
89
|
+
# treats identifiers as if they were quoted (Users == "Users")
|
90
|
+
# this is different from Postgres (Users == "users")
|
91
|
+
#
|
92
|
+
# TODO add support for quoted identifiers like "my.schema"."my.table"
|
93
|
+
# so it's possible to specify identifiers with "." in them
|
94
|
+
def to_table(value)
|
95
|
+
parts = value.split(".")
|
96
|
+
case parts.size
|
97
|
+
when 1
|
98
|
+
# unknown schema
|
99
|
+
Table.new(nil, parts[0])
|
100
|
+
when 2
|
101
|
+
Table.new(*parts)
|
102
|
+
else
|
103
|
+
raise Error, "Cannot resolve table: #{value}"
|
104
|
+
end
|
105
|
+
end
|
106
|
+
|
107
|
+
def default_tasks
|
108
|
+
shared_tables.map do |table|
|
109
|
+
{
|
110
|
+
table: table
|
111
|
+
}
|
112
|
+
end
|
113
|
+
end
|
114
|
+
|
115
|
+
# tables that exists in both source and destination
|
116
|
+
# used when no tables specified, or a wildcard
|
117
|
+
# removes excluded tables and filters by schema
|
118
|
+
def shared_tables
|
119
|
+
tables = filter_tables(source.tables)
|
120
|
+
|
121
|
+
unless opts[:schema_only] || opts[:schema_first]
|
122
|
+
from_tables = tables
|
123
|
+
to_tables = filter_tables(destination.tables)
|
124
|
+
|
125
|
+
extra_tables = to_tables - from_tables
|
126
|
+
notes << "Extra tables: #{extra_tables.map { |t| friendly_name(t) }.join(", ")}" if extra_tables.any?
|
127
|
+
|
128
|
+
missing_tables = from_tables - to_tables
|
129
|
+
notes << "Missing tables: #{missing_tables.map { |t| friendly_name(t) }.join(", ")}" if missing_tables.any?
|
130
|
+
|
131
|
+
tables &= to_tables
|
132
|
+
end
|
133
|
+
|
134
|
+
tables
|
135
|
+
end
|
136
|
+
|
137
|
+
def filter_tables(tables)
|
138
|
+
tables = tables.dup
|
139
|
+
|
140
|
+
unless opts[:all_schemas]
|
141
|
+
# could support wildcard schemas as well
|
142
|
+
schemas = Set.new(opts[:schemas] ? to_arr(opts[:schemas]) : source.search_path)
|
143
|
+
tables.select! { |t| schemas.include?(t.schema) }
|
144
|
+
end
|
145
|
+
|
146
|
+
to_arr(opts[:exclude]).each do |value|
|
147
|
+
if value.include?("*")
|
148
|
+
regex = Regexp.new('\A' + Regexp.escape(value).gsub('\*','[^\.]*') + '\z')
|
149
|
+
tables.reject! { |t| regex.match(t.full_name) || regex.match(t.name) }
|
150
|
+
else
|
151
|
+
tables -= [fully_resolve(to_table(value))]
|
152
|
+
end
|
153
|
+
end
|
154
|
+
|
155
|
+
tables
|
156
|
+
end
|
157
|
+
|
158
|
+
def process_args
|
159
|
+
groups = to_arr(opts[:groups])
|
160
|
+
tables = to_arr(opts[:tables])
|
161
|
+
if args[0]
|
162
|
+
# could be a group, table, or mix
|
163
|
+
to_arr(args[0]).each do |value|
|
164
|
+
if group?(value.split(":", 2)[0])
|
165
|
+
groups << value
|
166
|
+
else
|
167
|
+
tables << value
|
168
|
+
end
|
169
|
+
end
|
170
|
+
end
|
171
|
+
[groups, tables]
|
172
|
+
end
|
173
|
+
|
174
|
+
def no_schema_tables
|
175
|
+
@no_schema_tables ||= begin
|
176
|
+
search_path_index = source.search_path.map.with_index.to_h
|
177
|
+
source.tables.group_by(&:name).map do |group, t2|
|
178
|
+
[group, t2.select { |t| search_path_index[t.schema] }.sort_by { |t| search_path_index[t.schema] }.first]
|
179
|
+
end.to_h
|
180
|
+
end
|
181
|
+
end
|
182
|
+
|
183
|
+
# for tables without a schema, find the table in the search path
|
184
|
+
def fully_resolve(table)
|
185
|
+
return table if table.schema
|
186
|
+
no_schema_tables[table.name] || (raise Error, "Table not found in source: #{table.name}")
|
187
|
+
end
|
188
|
+
|
189
|
+
# parse command line arguments and YAML
|
190
|
+
def to_arr(value)
|
191
|
+
if value.is_a?(Array)
|
192
|
+
value
|
193
|
+
else
|
194
|
+
# Split by commas, but don't use commas inside double quotes
|
195
|
+
# https://stackoverflow.com/questions/21105360/regex-find-comma-not-inside-quotes
|
196
|
+
value.to_s.split(/(?!\B"[^"]*),(?![^"]*"\B)/)
|
197
|
+
end
|
198
|
+
end
|
199
|
+
|
200
|
+
def sql_arg
|
201
|
+
args[1]
|
202
|
+
end
|
203
|
+
|
204
|
+
def expand_sql(sql, param)
|
205
|
+
# command line option takes precedence over group option
|
206
|
+
sql = sql_arg if sql_arg
|
207
|
+
|
208
|
+
return unless sql
|
209
|
+
|
210
|
+
# vars must match \w
|
211
|
+
missing_vars = sql.scan(/{\w+}/).map { |v| v[1..-2] }
|
212
|
+
|
213
|
+
vars = {}
|
214
|
+
if param
|
215
|
+
vars["id"] = cast(param)
|
216
|
+
vars["1"] = cast(param)
|
217
|
+
end
|
218
|
+
|
219
|
+
sql = sql.dup
|
220
|
+
vars.each do |k, v|
|
221
|
+
# only sub if in var list
|
222
|
+
sql.gsub!("{#{k}}", cast(v)) if missing_vars.delete(k)
|
223
|
+
end
|
224
|
+
|
225
|
+
raise Error, "Missing variables: #{missing_vars.uniq.join(", ")}" if missing_vars.any?
|
226
|
+
|
227
|
+
sql
|
228
|
+
end
|
229
|
+
|
230
|
+
# TODO quote vars in next major version
|
231
|
+
def cast(value)
|
232
|
+
value.to_s.gsub(/\A\"|\"\z/, '')
|
233
|
+
end
|
234
|
+
end
|
235
|
+
end
|
data/lib/pgsync/utils.rb
ADDED
@@ -0,0 +1,86 @@
|
|
1
|
+
module PgSync
|
2
|
+
module Utils
|
3
|
+
COLOR_CODES = {
|
4
|
+
red: 31,
|
5
|
+
green: 32,
|
6
|
+
yellow: 33
|
7
|
+
}
|
8
|
+
|
9
|
+
def log(message = nil)
|
10
|
+
output.puts message
|
11
|
+
end
|
12
|
+
|
13
|
+
def colorize(message, color)
|
14
|
+
if output.tty?
|
15
|
+
"\e[#{COLOR_CODES[color]}m#{message}\e[0m"
|
16
|
+
else
|
17
|
+
message
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
def warning(message)
|
22
|
+
log colorize(message, :yellow)
|
23
|
+
end
|
24
|
+
|
25
|
+
def deprecated(message)
|
26
|
+
warning "[DEPRECATED] #{message}"
|
27
|
+
end
|
28
|
+
|
29
|
+
def output
|
30
|
+
$stderr
|
31
|
+
end
|
32
|
+
|
33
|
+
def db_config_file(db)
|
34
|
+
".pgsync-#{db}.yml"
|
35
|
+
end
|
36
|
+
|
37
|
+
def confirm_tables_exist(data_source, tasks, description)
|
38
|
+
tasks.map(&:table).each do |table|
|
39
|
+
unless data_source.table_exists?(table)
|
40
|
+
raise Error, "Table not found in #{description}: #{table}"
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
def first_schema
|
46
|
+
@first_schema ||= source.search_path.find { |sp| sp != "pg_catalog" }
|
47
|
+
end
|
48
|
+
|
49
|
+
def task_name(task)
|
50
|
+
friendly_name(task.table)
|
51
|
+
end
|
52
|
+
|
53
|
+
def friendly_name(table)
|
54
|
+
if table.schema == first_schema
|
55
|
+
table.name
|
56
|
+
else
|
57
|
+
table.full_name
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
def quote_ident_full(ident)
|
62
|
+
if ident.is_a?(Table)
|
63
|
+
[quote_ident(ident.schema), quote_ident(ident.name)].join(".")
|
64
|
+
else # temp table names are strings
|
65
|
+
quote_ident(ident)
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
def quote_ident(value)
|
70
|
+
PG::Connection.quote_ident(value)
|
71
|
+
end
|
72
|
+
|
73
|
+
def escape(value)
|
74
|
+
if value.is_a?(String)
|
75
|
+
"'#{quote_string(value)}'"
|
76
|
+
else
|
77
|
+
value
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
# activerecord
|
82
|
+
def quote_string(s)
|
83
|
+
s.gsub(/\\/, '\&\&').gsub(/'/, "''")
|
84
|
+
end
|
85
|
+
end
|
86
|
+
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.
|
4
|
+
version: 0.6.0
|
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-
|
11
|
+
date: 2020-06-07 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: parallel
|
@@ -44,14 +44,14 @@ dependencies:
|
|
44
44
|
requirements:
|
45
45
|
- - ">="
|
46
46
|
- !ruby/object:Gem::Version
|
47
|
-
version: 4.
|
47
|
+
version: 4.8.1
|
48
48
|
type: :runtime
|
49
49
|
prerelease: false
|
50
50
|
version_requirements: !ruby/object:Gem::Requirement
|
51
51
|
requirements:
|
52
52
|
- - ">="
|
53
53
|
- !ruby/object:Gem::Version
|
54
|
-
version: 4.
|
54
|
+
version: 4.8.1
|
55
55
|
- !ruby/object:Gem::Dependency
|
56
56
|
name: tty-spinner
|
57
57
|
requirement: !ruby/object:Gem::Requirement
|
@@ -123,8 +123,14 @@ files:
|
|
123
123
|
- lib/pgsync.rb
|
124
124
|
- lib/pgsync/client.rb
|
125
125
|
- lib/pgsync/data_source.rb
|
126
|
-
- lib/pgsync/
|
126
|
+
- lib/pgsync/init.rb
|
127
|
+
- lib/pgsync/schema_sync.rb
|
128
|
+
- lib/pgsync/sync.rb
|
129
|
+
- lib/pgsync/table.rb
|
127
130
|
- lib/pgsync/table_sync.rb
|
131
|
+
- lib/pgsync/task.rb
|
132
|
+
- lib/pgsync/task_resolver.rb
|
133
|
+
- lib/pgsync/utils.rb
|
128
134
|
- lib/pgsync/version.rb
|
129
135
|
homepage: https://github.com/ankane/pgsync
|
130
136
|
licenses:
|
data/lib/pgsync/table_list.rb
DELETED
@@ -1,107 +0,0 @@
|
|
1
|
-
module PgSync
|
2
|
-
class TableList
|
3
|
-
attr_reader :args, :opts, :source, :config
|
4
|
-
|
5
|
-
def initialize(args, options, source, config)
|
6
|
-
@args = args
|
7
|
-
@opts = options
|
8
|
-
@source = source
|
9
|
-
@config = config
|
10
|
-
end
|
11
|
-
|
12
|
-
def tables
|
13
|
-
tables = nil
|
14
|
-
|
15
|
-
if opts[:groups]
|
16
|
-
tables ||= Hash.new { |hash, key| hash[key] = {} }
|
17
|
-
specified_groups = to_arr(opts[:groups])
|
18
|
-
specified_groups.map do |tag|
|
19
|
-
group, id = tag.split(":", 2)
|
20
|
-
if (t = (config["groups"] || {})[group])
|
21
|
-
add_tables(tables, t, id, args[1])
|
22
|
-
else
|
23
|
-
raise PgSync::Error, "Group not found: #{group}"
|
24
|
-
end
|
25
|
-
end
|
26
|
-
end
|
27
|
-
|
28
|
-
if opts[:tables]
|
29
|
-
tables ||= Hash.new { |hash, key| hash[key] = {} }
|
30
|
-
to_arr(opts[:tables]).each do |tag|
|
31
|
-
table, id = tag.split(":", 2)
|
32
|
-
raise PgSync::Error, "Cannot use parameters with tables" if id
|
33
|
-
add_table(tables, table, id, args[1])
|
34
|
-
end
|
35
|
-
end
|
36
|
-
|
37
|
-
if args[0]
|
38
|
-
# could be a group, table, or mix
|
39
|
-
tables ||= Hash.new { |hash, key| hash[key] = {} }
|
40
|
-
specified_groups = to_arr(args[0])
|
41
|
-
specified_groups.map do |tag|
|
42
|
-
group, id = tag.split(":", 2)
|
43
|
-
if (t = (config["groups"] || {})[group])
|
44
|
-
add_tables(tables, t, id, args[1])
|
45
|
-
else
|
46
|
-
raise PgSync::Error, "Cannot use parameters with tables" if id
|
47
|
-
add_table(tables, group, id, args[1])
|
48
|
-
end
|
49
|
-
end
|
50
|
-
end
|
51
|
-
|
52
|
-
tables ||= begin
|
53
|
-
exclude = to_arr(opts[:exclude])
|
54
|
-
exclude = source.fully_resolve_tables(exclude).keys if exclude.any?
|
55
|
-
|
56
|
-
tabs = source.tables
|
57
|
-
unless opts[:all_schemas]
|
58
|
-
schemas = Set.new(opts[:schemas] ? to_arr(opts[:schemas]) : source.search_path)
|
59
|
-
tabs.select! { |t| schemas.include?(t.split(".", 2)[0]) }
|
60
|
-
end
|
61
|
-
|
62
|
-
Hash[(tabs - exclude).map { |k| [k, {}] }]
|
63
|
-
end
|
64
|
-
|
65
|
-
source.fully_resolve_tables(tables)
|
66
|
-
end
|
67
|
-
|
68
|
-
private
|
69
|
-
|
70
|
-
def to_arr(value)
|
71
|
-
if value.is_a?(Array)
|
72
|
-
value
|
73
|
-
else
|
74
|
-
# Split by commas, but don't use commas inside double quotes
|
75
|
-
# https://stackoverflow.com/questions/21105360/regex-find-comma-not-inside-quotes
|
76
|
-
value.to_s.split(/(?!\B"[^"]*),(?![^"]*"\B)/)
|
77
|
-
end
|
78
|
-
end
|
79
|
-
|
80
|
-
def add_tables(tables, t, id, boom)
|
81
|
-
t.each do |table|
|
82
|
-
sql = nil
|
83
|
-
if table.is_a?(Array)
|
84
|
-
table, sql = table
|
85
|
-
end
|
86
|
-
add_table(tables, table, id, boom || sql)
|
87
|
-
end
|
88
|
-
end
|
89
|
-
|
90
|
-
def add_table(tables, table, id, boom, wildcard = false)
|
91
|
-
if table.include?("*") && !wildcard
|
92
|
-
regex = Regexp.new('\A' + Regexp.escape(table).gsub('\*','[^\.]*') + '\z')
|
93
|
-
t2 = source.tables.select { |t| regex.match(t) }
|
94
|
-
t2.each do |tab|
|
95
|
-
add_table(tables, tab, id, boom, true)
|
96
|
-
end
|
97
|
-
else
|
98
|
-
tables[table] = {}
|
99
|
-
tables[table][:sql] = boom.gsub("{id}", cast(id)).gsub("{1}", cast(id)) if boom
|
100
|
-
end
|
101
|
-
end
|
102
|
-
|
103
|
-
def cast(value)
|
104
|
-
value.to_s.gsub(/\A\"|\"\z/, '')
|
105
|
-
end
|
106
|
-
end
|
107
|
-
end
|