multi_compress 0.6.0 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +10 -0
- data/GET_STARTED.md +1 -1
- data/README.md +1 -1
- data/THIRD_PARTY_NOTICES.md +1 -1
- data/ext/multi_compress/multi_compress.c +230 -77
- data/ext/multi_compress/vendor/.vendored +1 -1
- data/ext/multi_compress/vendor/brotli/c/common/version.h +2 -2
- data/ext/multi_compress/vendor/brotli/c/dec/decode.c +59 -30
- data/ext/multi_compress/vendor/brotli/c/include/brotli/decode.h +1 -3
- data/lib/multi_compress/active_record.rb +11 -5
- data/lib/multi_compress/codec.rb +164 -94
- data/lib/multi_compress/database/api.rb +42 -0
- data/lib/multi_compress/database/decoding.rb +42 -0
- data/lib/multi_compress/database/dictionary.rb +72 -0
- data/lib/multi_compress/database/encoding.rb +31 -0
- data/lib/multi_compress/database/header.rb +71 -0
- data/lib/multi_compress/database/operations.rb +73 -0
- data/lib/multi_compress/database/validation.rb +95 -0
- data/lib/multi_compress/database.rb +10 -282
- data/lib/multi_compress/db_deployment/bundle_archiver.rb +70 -0
- data/lib/multi_compress/db_deployment/bundle_metadata.rb +56 -0
- data/lib/multi_compress/db_deployment/bundle_stager.rb +62 -0
- data/lib/multi_compress/db_deployment/commands.rb +309 -0
- data/lib/multi_compress/db_deployment/deployment_bundle.rb +80 -0
- data/lib/multi_compress/db_deployment/deterministic_tar_writer.rb +76 -0
- data/lib/multi_compress/db_deployment/dictionary_registry.rb +244 -0
- data/lib/multi_compress/db_deployment/readable_view.rb +112 -0
- data/lib/multi_compress/db_deployment/readable_view_options.rb +51 -0
- data/lib/multi_compress/db_deployment/sql_identifiers.rb +45 -0
- data/lib/multi_compress/db_deployment.rb +10 -844
- data/lib/multi_compress/reader/buffer_operations.rb +57 -0
- data/lib/multi_compress/reader/read_operations.rb +67 -0
- data/lib/multi_compress/reader.rb +97 -0
- data/lib/multi_compress/stream_io.rb +13 -0
- data/lib/multi_compress/version.rb +1 -1
- data/lib/multi_compress/writer.rb +85 -0
- data/lib/multi_compress.rb +42 -333
- metadata +25 -6
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MultiCompress
|
|
4
|
+
module DBDeployment
|
|
5
|
+
class BundleArchiver
|
|
6
|
+
DEFAULT_EPOCH = 315_619_200
|
|
7
|
+
attr_reader :output, :root_name
|
|
8
|
+
private :output, :root_name
|
|
9
|
+
|
|
10
|
+
def initialize(output, root_name)
|
|
11
|
+
@output = output
|
|
12
|
+
@root_name = root_name
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def write!(temporary_directory)
|
|
16
|
+
temporary_output = temporary_path
|
|
17
|
+
FileUtils.mkdir_p(File.dirname(output))
|
|
18
|
+
write_archive(temporary_output, temporary_directory)
|
|
19
|
+
File.rename(temporary_output, output)
|
|
20
|
+
ensure
|
|
21
|
+
File.delete(temporary_output) if defined?(temporary_output) && File.exist?(temporary_output)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def temporary_path
|
|
27
|
+
File.join(File.dirname(output), ".#{File.basename(output)}.#{Process.pid}.#{SecureRandom.hex(8)}.tmp")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def write_archive(path, temporary_directory)
|
|
31
|
+
File.open(path, "wb", 0o644) do |file|
|
|
32
|
+
Zlib::GzipWriter.wrap(file) do |gzip|
|
|
33
|
+
gzip.mtime = archive_epoch
|
|
34
|
+
DeterministicTarWriter.new(gzip, archive_epoch) do |tar|
|
|
35
|
+
add_tree(tar, File.join(temporary_directory, root_name), root_name)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def archive_epoch
|
|
42
|
+
value = ENV.fetch("SOURCE_DATE_EPOCH", nil)
|
|
43
|
+
value&.match?(/\A\d+\z/) ? value.to_i : DEFAULT_EPOCH
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def add_tree(tar, directory, archive_path)
|
|
47
|
+
tar.mkdir(archive_path, mode(directory))
|
|
48
|
+
Dir.children(directory).sort.each { |entry| add_entry(tar, directory, archive_path, entry) }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def add_entry(tar, directory, archive_path, entry)
|
|
52
|
+
source = File.join(directory, entry)
|
|
53
|
+
destination = File.join(archive_path, entry)
|
|
54
|
+
return add_tree(tar, source, destination) if File.directory?(source)
|
|
55
|
+
|
|
56
|
+
add_file(tar, source, destination)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def add_file(tar, source, destination)
|
|
60
|
+
tar.add_file_simple(destination, mode(source), File.size(source)) do |destination_io|
|
|
61
|
+
File.open(source, "rb") { |source_io| IO.copy_stream(source_io, destination_io) }
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def mode(path)
|
|
66
|
+
File.stat(path).mode & 0o777
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MultiCompress
|
|
4
|
+
module DBDeployment
|
|
5
|
+
class BundleMetadata
|
|
6
|
+
attr_reader :target
|
|
7
|
+
private :target
|
|
8
|
+
|
|
9
|
+
def initialize(target)
|
|
10
|
+
@target = target
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def write!(bundle_root)
|
|
14
|
+
write_manifest(bundle_root, manifest_files(bundle_root))
|
|
15
|
+
write_checksums(bundle_root)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def manifest_files(root)
|
|
21
|
+
regular_files(root).reject { |path| %w[manifest.json SHA256SUMS].include?(File.basename(path)) }
|
|
22
|
+
.sort.map { |path| file_record(root, path) }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def file_record(root, path)
|
|
26
|
+
{ "path" => relative_to(root, path), "sha256" => Digest::SHA256.file(path).hexdigest }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def write_manifest(root, files)
|
|
30
|
+
manifest = {
|
|
31
|
+
"bundle_format_version" => BUNDLE_FORMAT_VERSION,
|
|
32
|
+
"gem" => { "name" => "multi_compress", "version" => MultiCompress::VERSION },
|
|
33
|
+
"database_target" => target,
|
|
34
|
+
"database_envelope" => FORMAT,
|
|
35
|
+
"files" => files
|
|
36
|
+
}
|
|
37
|
+
File.write(File.join(root, "manifest.json"), "#{JSON.pretty_generate(manifest)}\n")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def write_checksums(root)
|
|
41
|
+
checksums = regular_files(root).reject { |path| File.basename(path) == "SHA256SUMS" }.sort.map do |path|
|
|
42
|
+
"#{Digest::SHA256.file(path).hexdigest} #{relative_to(root, path)}"
|
|
43
|
+
end
|
|
44
|
+
File.write(File.join(root, "SHA256SUMS"), "#{checksums.join("\n")}\n")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def regular_files(root)
|
|
48
|
+
Dir.glob(File.join(root, "**", "*"), File::FNM_DOTMATCH).select { |path| File.file?(path) }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def relative_to(root, path)
|
|
52
|
+
Pathname.new(path).relative_path_from(Pathname.new(root)).to_s
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MultiCompress
|
|
4
|
+
module DBDeployment
|
|
5
|
+
class BundleStager
|
|
6
|
+
attr_reader :root, :target
|
|
7
|
+
private :root, :target
|
|
8
|
+
|
|
9
|
+
IGNORED_ENTRIES = [".DS_Store", ".git", "build", "test"].freeze
|
|
10
|
+
|
|
11
|
+
def initialize(root, target)
|
|
12
|
+
@root = root
|
|
13
|
+
@target = target
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def stage!(bundle_root)
|
|
17
|
+
target.fetch(:source_paths).each { |relative| copy_path!(relative, bundle_root) }
|
|
18
|
+
stage_template(bundle_root)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def stage_template(bundle_root)
|
|
24
|
+
template = target.fetch(:template_path)
|
|
25
|
+
Dir.children(File.join(root, template)).sort.each do |entry|
|
|
26
|
+
next if ignored?(entry)
|
|
27
|
+
|
|
28
|
+
copy_path!(File.join(template, entry), bundle_root, destination: entry)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def copy_path!(relative, bundle_root, destination: relative)
|
|
33
|
+
source = File.join(root, relative)
|
|
34
|
+
raise Error, "package source is missing: #{relative}" unless File.exist?(source)
|
|
35
|
+
|
|
36
|
+
destination_path = File.join(bundle_root, destination)
|
|
37
|
+
return copy_directory(source, relative, destination_path, destination, bundle_root) if File.directory?(source)
|
|
38
|
+
|
|
39
|
+
copy_file(source, destination_path)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def copy_directory(source, relative, destination_path, destination, bundle_root)
|
|
43
|
+
FileUtils.mkdir_p(destination_path)
|
|
44
|
+
Dir.children(source).sort.each do |entry|
|
|
45
|
+
next if ignored?(entry)
|
|
46
|
+
|
|
47
|
+
copy_path!(File.join(relative, entry), bundle_root, destination: File.join(destination, entry))
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def copy_file(source, destination)
|
|
52
|
+
FileUtils.mkdir_p(File.dirname(destination))
|
|
53
|
+
FileUtils.copy_file(source, destination)
|
|
54
|
+
File.chmod(File.stat(source).mode & 0o777, destination)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def ignored?(entry)
|
|
58
|
+
IGNORED_ENTRIES.include?(entry)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MultiCompress
|
|
4
|
+
module DBDeployment
|
|
5
|
+
module CommandHelpers
|
|
6
|
+
private
|
|
7
|
+
|
|
8
|
+
def validated_target!(argv, targets)
|
|
9
|
+
target = argv.shift
|
|
10
|
+
raise OptionParser::MissingArgument, "TARGET must be postgres or mysql" if target.nil?
|
|
11
|
+
raise OptionParser::InvalidArgument, "TARGET must be postgres or mysql" unless targets.include?(target)
|
|
12
|
+
|
|
13
|
+
target
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def show_help(parser)
|
|
17
|
+
puts parser
|
|
18
|
+
0
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def write_sql(sql, output)
|
|
22
|
+
if output
|
|
23
|
+
write_text_atomically(output, sql)
|
|
24
|
+
puts File.expand_path(output)
|
|
25
|
+
else
|
|
26
|
+
$stdout.write(sql)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def write_text_atomically(path, contents)
|
|
31
|
+
directory = File.dirname(File.expand_path(path))
|
|
32
|
+
FileUtils.mkdir_p(directory)
|
|
33
|
+
temporary = File.join(directory, ".multi-compress-view-#{Process.pid}-#{SecureRandom.hex(8)}")
|
|
34
|
+
File.open(temporary, "wb", 0o644) { |file| file.write(contents) }
|
|
35
|
+
File.rename(temporary, path)
|
|
36
|
+
ensure
|
|
37
|
+
File.delete(temporary) if defined?(temporary) && File.exist?(temporary)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def add_value_options(parser, options, definitions)
|
|
41
|
+
definitions.each do |key, flag, description|
|
|
42
|
+
parser.on("--#{flag} NAME", description) { |value| options[key] = value }
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
class CLI
|
|
48
|
+
COMMANDS = {
|
|
49
|
+
"package" => "PackageCommand",
|
|
50
|
+
"view" => "ViewCommand",
|
|
51
|
+
"registry" => "RegistryCommand"
|
|
52
|
+
}.freeze
|
|
53
|
+
HELP_COMMANDS = ["help", "--help", "-h", nil].freeze
|
|
54
|
+
|
|
55
|
+
def self.run(argv)
|
|
56
|
+
new.run(argv)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def run(argv)
|
|
60
|
+
dispatch(argv)
|
|
61
|
+
rescue OptionParser::ParseError, Error => e
|
|
62
|
+
report_error(e, 2)
|
|
63
|
+
rescue SystemCallError, IOError => e
|
|
64
|
+
report_error(e, 1)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def dispatch(argv)
|
|
70
|
+
command = argv.shift
|
|
71
|
+
return show_help if HELP_COMMANDS.include?(command)
|
|
72
|
+
|
|
73
|
+
command_class = DBDeployment.const_get(
|
|
74
|
+
COMMANDS.fetch(command) { raise OptionParser::InvalidOption, "unknown db command: #{command.inspect}" },
|
|
75
|
+
false
|
|
76
|
+
)
|
|
77
|
+
command_class.new.run(argv)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def report_error(error, status)
|
|
81
|
+
warn "multi_compress db: #{error.message}"
|
|
82
|
+
status
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def show_help
|
|
86
|
+
puts help
|
|
87
|
+
0
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def help
|
|
91
|
+
<<~TEXT
|
|
92
|
+
Usage:
|
|
93
|
+
multi_compress db package postgres [--output PATH] [--force]
|
|
94
|
+
multi_compress db package mysql [--output PATH] [--force]
|
|
95
|
+
|
|
96
|
+
multi_compress db view postgres --table SCHEMA.TABLE --column COLUMN \\
|
|
97
|
+
--view SCHEMA.VIEW --columns ID,CREATED_AT [--as PAYLOAD] \\
|
|
98
|
+
[--extension-schema multi_compress] [--output PATH]
|
|
99
|
+
|
|
100
|
+
multi_compress db view mysql --table SCHEMA.TABLE --column COLUMN \\
|
|
101
|
+
--view SCHEMA.VIEW --columns ID,CREATED_AT [--as PAYLOAD] [--output PATH]
|
|
102
|
+
|
|
103
|
+
`package` creates a self-contained source bundle for the DBA. The DBA
|
|
104
|
+
extracts it on the database host and runs `make doctor`, `sudo make
|
|
105
|
+
install`, then `make enable DB=...`.
|
|
106
|
+
|
|
107
|
+
`view` emits a safe readable-view definition so DBeaver users query
|
|
108
|
+
decoded text instead of the compressed bytea/BLOB column. Supply
|
|
109
|
+
--dictionary-table and --dictionary-id-column for MCDB2.
|
|
110
|
+
|
|
111
|
+
`registry` emits append-only MCDB2 dictionary-registry DDL. Dictionary
|
|
112
|
+
bytes are application data and are never placed in a DBA reader bundle.
|
|
113
|
+
Supplying --payload-table, --payload-column and
|
|
114
|
+
--payload-dictionary-id-column also emits the MCDB2 header/FK
|
|
115
|
+
consistency trigger for that source table.
|
|
116
|
+
TEXT
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
class PackageCommand
|
|
121
|
+
include CommandHelpers
|
|
122
|
+
|
|
123
|
+
TARGETS = %w[postgres mysql].freeze
|
|
124
|
+
|
|
125
|
+
def run(argv)
|
|
126
|
+
target = validated_target!(argv, TARGETS)
|
|
127
|
+
options = { output: nil, force: false }
|
|
128
|
+
parser = option_parser(target, options)
|
|
129
|
+
parser.parse!(argv)
|
|
130
|
+
return show_help(options[:help]) if options[:help]
|
|
131
|
+
|
|
132
|
+
raise OptionParser::InvalidOption, argv.join(" ") unless argv.empty?
|
|
133
|
+
|
|
134
|
+
write_bundle(target, options)
|
|
135
|
+
0
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
private
|
|
139
|
+
|
|
140
|
+
def option_parser(target, options)
|
|
141
|
+
OptionParser.new do |parser|
|
|
142
|
+
parser.banner = "Usage: multi_compress db package #{target} [OPTIONS]"
|
|
143
|
+
parser.on("-o", "--output PATH", "write the deployment archive to PATH") do |value|
|
|
144
|
+
options[:output] = value
|
|
145
|
+
end
|
|
146
|
+
parser.on("-f", "--force", "overwrite an existing archive") { options[:force] = true }
|
|
147
|
+
parser.on("-h", "--help", "show this help") { options[:help] = parser }
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def write_bundle(target, options)
|
|
152
|
+
output = options[:output] || "multi_compress-#{target}-#{MultiCompress::VERSION}.tar.gz"
|
|
153
|
+
DeploymentBundle.new(target, output, force: options[:force]).write!
|
|
154
|
+
puts File.expand_path(output)
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
class SqlCommand
|
|
159
|
+
include CommandHelpers
|
|
160
|
+
|
|
161
|
+
def run(argv)
|
|
162
|
+
target = validated_target!(argv, self.class::TARGETS)
|
|
163
|
+
options = default_options
|
|
164
|
+
parser = option_parser(target, options)
|
|
165
|
+
parser.parse!(argv)
|
|
166
|
+
return show_help(options[:help]) if options[:help]
|
|
167
|
+
|
|
168
|
+
raise OptionParser::InvalidOption, argv.join(" ") unless argv.empty?
|
|
169
|
+
|
|
170
|
+
write_sql(build_sql(target, options), options[:output])
|
|
171
|
+
0
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
private
|
|
175
|
+
|
|
176
|
+
def build_sql(_target, _options)
|
|
177
|
+
raise NotImplementedError
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
class ViewCommand < SqlCommand
|
|
182
|
+
TARGETS = %w[postgres mysql].freeze
|
|
183
|
+
IDENTIFIER = /\A[A-Za-z_][A-Za-z0-9_]*\z/.freeze
|
|
184
|
+
DICTIONARY_OPTION_DEFINITIONS = [
|
|
185
|
+
[:dictionary_table, "dictionary-table", "MCDB2 immutable dictionary registry table"],
|
|
186
|
+
[:dictionary_id_column, "dictionary-id-column", "MCDB2 FK column on source table"],
|
|
187
|
+
[:registry_id_column, "registry-id-column", "registry PK (default: id)"],
|
|
188
|
+
[:dictionary_sha256_column, "dictionary-sha256-column", "registry SHA-256 column (default: sha256)"],
|
|
189
|
+
[:dictionary_bytes_column, "dictionary-bytes-column", "registry dictionary bytes column (default: bytes)"]
|
|
190
|
+
].map(&:freeze).freeze
|
|
191
|
+
|
|
192
|
+
private_constant :DICTIONARY_OPTION_DEFINITIONS
|
|
193
|
+
|
|
194
|
+
private
|
|
195
|
+
|
|
196
|
+
def build_sql(target, options)
|
|
197
|
+
ReadableView.new(target, options).to_sql
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def default_options
|
|
201
|
+
{
|
|
202
|
+
columns: [], as: "payload", output: nil, extension_schema: "multi_compress",
|
|
203
|
+
dictionary_table: nil, dictionary_id_column: nil, registry_id_column: "id",
|
|
204
|
+
dictionary_sha256_column: "sha256", dictionary_bytes_column: "bytes"
|
|
205
|
+
}
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def option_parser(target, options)
|
|
209
|
+
OptionParser.new do |parser|
|
|
210
|
+
parser.banner = view_usage(target)
|
|
211
|
+
add_source_options(parser, options)
|
|
212
|
+
add_dictionary_options(parser, options)
|
|
213
|
+
add_view_output_options(parser, target, options)
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def view_usage(target)
|
|
218
|
+
"Usage: multi_compress db view #{target} --table SCHEMA.TABLE --column COLUMN " \
|
|
219
|
+
"--view SCHEMA.VIEW --columns ID,CREATED_AT [OPTIONS]"
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def add_source_options(parser, options)
|
|
223
|
+
parser.on("--table NAME", "source table, optionally schema-qualified") { |value| options[:table] = value }
|
|
224
|
+
parser.on("--column NAME", "compressed bytea/BLOB column") { |value| options[:column] = value }
|
|
225
|
+
parser.on("--view NAME", "destination view, optionally schema-qualified") { |value| options[:view] = value }
|
|
226
|
+
parser.on("--columns LIST", "comma-separated plain columns to expose") do |value|
|
|
227
|
+
options[:columns] = value.split(",").map(&:strip)
|
|
228
|
+
end
|
|
229
|
+
parser.on("--as NAME", "decoded column name (default: payload)") { |value| options[:as] = value }
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def add_dictionary_options(parser, options)
|
|
233
|
+
add_value_options(parser, options, DICTIONARY_OPTION_DEFINITIONS)
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def add_view_output_options(parser, target, options)
|
|
237
|
+
if target == "postgres"
|
|
238
|
+
parser.on("--extension-schema NAME", "extension schema (default: multi_compress)") do |value|
|
|
239
|
+
options[:extension_schema] = value
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
parser.on("-o", "--output PATH", "write SQL to PATH instead of stdout") { |value| options[:output] = value }
|
|
243
|
+
parser.on("-h", "--help", "show this help") { options[:help] = parser }
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
class RegistryCommand < SqlCommand
|
|
248
|
+
TARGETS = %w[postgres mysql].freeze
|
|
249
|
+
TABLE_OPTION_DEFINITIONS = [
|
|
250
|
+
[:table, "table", "versions table (default: mcdb_dictionary_versions)"],
|
|
251
|
+
[:heads_table, "heads-table", "heads table (default: mcdb_dictionary_heads)"],
|
|
252
|
+
[:payload_table, "payload-table", "MCDB2 source table in this schema/database"],
|
|
253
|
+
[:payload_column, "payload-column", "MCDB2 compressed envelope column on --payload-table"],
|
|
254
|
+
[:payload_dictionary_id_column, "payload-dictionary-id-column",
|
|
255
|
+
"MCDB2 dictionary FK column on --payload-table"]
|
|
256
|
+
].map(&:freeze).freeze
|
|
257
|
+
|
|
258
|
+
private_constant :TABLE_OPTION_DEFINITIONS
|
|
259
|
+
|
|
260
|
+
private
|
|
261
|
+
|
|
262
|
+
def build_sql(target, options)
|
|
263
|
+
DictionaryRegistry.new(target, options).to_sql
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def default_options
|
|
267
|
+
{ output: nil, extension_schema: "multi_compress", table: "mcdb_dictionary_versions",
|
|
268
|
+
heads_table: "mcdb_dictionary_heads" }
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def option_parser(target, options)
|
|
272
|
+
OptionParser.new do |parser|
|
|
273
|
+
parser.banner = "Usage: multi_compress db registry #{target} [OPTIONS]"
|
|
274
|
+
add_target_options(parser, target, options)
|
|
275
|
+
add_registry_table_options(parser, options)
|
|
276
|
+
add_registry_output_options(parser, options)
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def add_target_options(parser, target, options)
|
|
281
|
+
return add_postgres_options(parser, options) if target == "postgres"
|
|
282
|
+
|
|
283
|
+
parser.on("--database NAME", "application database") { |value| options[:schema] = value }
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def add_postgres_options(parser, options)
|
|
287
|
+
parser.on("--schema NAME", "application schema") { |value| options[:schema] = value }
|
|
288
|
+
parser.on("--owner ROLE", "NOLOGIN owner for append-only dictionary versions") do |value|
|
|
289
|
+
options[:owner] = value
|
|
290
|
+
end
|
|
291
|
+
parser.on("--migration-role ROLE", "role allowed to insert dictionary versions") do |value|
|
|
292
|
+
options[:migration_role] = value
|
|
293
|
+
end
|
|
294
|
+
parser.on("--extension-schema NAME", "extension schema (default: multi_compress)") do |value|
|
|
295
|
+
options[:extension_schema] = value
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def add_registry_table_options(parser, options)
|
|
300
|
+
add_value_options(parser, options, TABLE_OPTION_DEFINITIONS)
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def add_registry_output_options(parser, options)
|
|
304
|
+
parser.on("-o", "--output PATH", "write SQL to PATH instead of stdout") { |value| options[:output] = value }
|
|
305
|
+
parser.on("-h", "--help", "show this help") { options[:help] = parser }
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "multi_compress/db_deployment/bundle_stager"
|
|
4
|
+
require "multi_compress/db_deployment/bundle_metadata"
|
|
5
|
+
require "multi_compress/db_deployment/bundle_archiver"
|
|
6
|
+
|
|
7
|
+
module MultiCompress
|
|
8
|
+
module DBDeployment
|
|
9
|
+
class DeploymentBundle
|
|
10
|
+
attr_reader :target, :output, :force, :root_name
|
|
11
|
+
private :target, :output, :force, :root_name
|
|
12
|
+
|
|
13
|
+
DEFAULT_ARCHIVE_EPOCH = 315_619_200
|
|
14
|
+
|
|
15
|
+
TARGETS = {
|
|
16
|
+
"postgres" => {
|
|
17
|
+
source_paths: %w[
|
|
18
|
+
db_core
|
|
19
|
+
postgres_extension
|
|
20
|
+
ext/multi_compress/vendor/zstd
|
|
21
|
+
docs/database-envelope-v1.md
|
|
22
|
+
docs/database-envelope-v2.md
|
|
23
|
+
docs/rfcs/0002-mcdb2-dictionaries.md
|
|
24
|
+
LICENSE.txt
|
|
25
|
+
THIRD_PARTY_NOTICES.md
|
|
26
|
+
],
|
|
27
|
+
template_path: "db_deployment/postgres"
|
|
28
|
+
},
|
|
29
|
+
"mysql" => {
|
|
30
|
+
source_paths: %w[
|
|
31
|
+
db_core
|
|
32
|
+
mysql_udf
|
|
33
|
+
ext/multi_compress/vendor/zstd
|
|
34
|
+
docs/database-envelope-v1.md
|
|
35
|
+
docs/database-envelope-v2.md
|
|
36
|
+
docs/rfcs/0002-mcdb2-dictionaries.md
|
|
37
|
+
LICENSE.txt
|
|
38
|
+
THIRD_PARTY_NOTICES.md
|
|
39
|
+
],
|
|
40
|
+
template_path: "db_deployment/mysql"
|
|
41
|
+
}
|
|
42
|
+
}.freeze
|
|
43
|
+
|
|
44
|
+
def initialize(target, output, force:)
|
|
45
|
+
@target = target
|
|
46
|
+
@output = File.expand_path(output)
|
|
47
|
+
@force = force
|
|
48
|
+
@root_name = "multi_compress-#{target}-#{MultiCompress::VERSION}"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def write!
|
|
52
|
+
validate_sources!
|
|
53
|
+
validate_output!
|
|
54
|
+
Dir.mktmpdir("multi-compress-db-package") { |directory| build_bundle(directory) }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def validate_sources!
|
|
60
|
+
return if File.directory?(ROOT)
|
|
61
|
+
|
|
62
|
+
raise Error, "deployment sources are unavailable from #{ROOT}"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def validate_output!
|
|
66
|
+
return unless File.exist?(output) && !force
|
|
67
|
+
|
|
68
|
+
raise Error, "#{output} already exists (use --force to overwrite)"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def build_bundle(directory)
|
|
72
|
+
bundle_root = File.join(directory, root_name)
|
|
73
|
+
FileUtils.mkdir_p(bundle_root)
|
|
74
|
+
BundleStager.new(ROOT, TARGETS.fetch(target)).stage!(bundle_root)
|
|
75
|
+
BundleMetadata.new(target).write!(bundle_root)
|
|
76
|
+
BundleArchiver.new(output, root_name).write!(directory)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MultiCompress
|
|
4
|
+
module DBDeployment
|
|
5
|
+
class DeterministicTarWriter < Gem::Package::TarWriter
|
|
6
|
+
attr_reader :archive_epoch
|
|
7
|
+
private :archive_epoch
|
|
8
|
+
|
|
9
|
+
def self.new(io, epoch)
|
|
10
|
+
writer = allocate
|
|
11
|
+
writer.send(:initialize, io, epoch)
|
|
12
|
+
return writer unless block_given?
|
|
13
|
+
|
|
14
|
+
begin
|
|
15
|
+
yield writer
|
|
16
|
+
ensure
|
|
17
|
+
writer.close
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
nil
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def initialize(io, epoch)
|
|
24
|
+
@archive_epoch = epoch
|
|
25
|
+
super(io)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def add_file_simple(name, mode, size)
|
|
29
|
+
check_closed
|
|
30
|
+
name, prefix = split_name(name)
|
|
31
|
+
write_file_header(name, prefix, mode, size)
|
|
32
|
+
write_file_body(size) { |output| yield output if block_given? }
|
|
33
|
+
self
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def mkdir(name, mode)
|
|
37
|
+
check_closed
|
|
38
|
+
name, prefix = split_name(name)
|
|
39
|
+
@io.write(directory_header(name, prefix, mode))
|
|
40
|
+
self
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def write_file_header(name, prefix, mode, size)
|
|
46
|
+
header = Gem::Package::TarHeader.new(
|
|
47
|
+
name: name,
|
|
48
|
+
mode: mode,
|
|
49
|
+
size: size,
|
|
50
|
+
prefix: prefix,
|
|
51
|
+
mtime: archive_epoch
|
|
52
|
+
).to_s
|
|
53
|
+
@io.write(header)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def write_file_body(size)
|
|
57
|
+
output = BoundedStream.new(@io, size)
|
|
58
|
+
yield output
|
|
59
|
+
@io.write("\0" * (size - output.written))
|
|
60
|
+
padding = (512 - (size % 512)) % 512
|
|
61
|
+
@io.write("\0" * padding)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def directory_header(name, prefix, mode)
|
|
65
|
+
Gem::Package::TarHeader.new(
|
|
66
|
+
name: name,
|
|
67
|
+
mode: mode,
|
|
68
|
+
typeflag: "5",
|
|
69
|
+
size: 0,
|
|
70
|
+
prefix: prefix,
|
|
71
|
+
mtime: archive_epoch
|
|
72
|
+
)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|