multi_compress 0.5.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 +45 -0
- data/GET_STARTED.md +20 -1
- data/README.md +15 -2
- data/THIRD_PARTY_NOTICES.md +1 -1
- data/db_core/include/mcdb_format.h +67 -9
- data/db_core/src/mcdb_format.c +392 -139
- data/db_deployment/mysql/README.md +19 -0
- data/db_deployment/mysql/bin/common +70 -18
- data/db_deployment/mysql/bin/enable +5 -1
- data/db_deployment/mysql/bin/status +2 -0
- data/db_deployment/mysql/bin/upgrade +17 -5
- data/db_deployment/postgres/README.md +24 -0
- data/db_deployment/postgres/bin/enable +2 -0
- data/db_deployment/postgres/bin/install +4 -2
- data/docs/database-envelope-v2.md +147 -0
- data/docs/rfcs/0002-mcdb2-dictionaries.md +75 -0
- data/ext/multi_compress/multi_compress.c +261 -76
- 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 +29 -95
- 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 +13 -435
- 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
- data/mysql_udf/README.md +45 -0
- data/mysql_udf/sql/examples.sql +5 -0
- data/mysql_udf/sql/install.sql +25 -1
- data/mysql_udf/sql/uninstall.sql +7 -1
- data/mysql_udf/sql/views.sql +16 -4
- data/mysql_udf/src/multi_compress_mysql.c +383 -44
- data/mysql_udf/test/run_e2e.sh +119 -1
- data/postgres_extension/Makefile +2 -2
- data/postgres_extension/README.md +48 -0
- data/postgres_extension/multi_compress.control +2 -2
- data/postgres_extension/multi_compress_pg.exports +12 -0
- data/postgres_extension/sql/examples.sql +11 -0
- data/postgres_extension/sql/multi_compress--0.5.0--0.6.0.sql +59 -0
- data/postgres_extension/sql/multi_compress--0.6.0.sql +85 -0
- data/postgres_extension/src/multi_compress_pg.c +301 -16
- data/postgres_extension/test/run_e2e.sh +90 -2
- metadata +29 -6
|
@@ -14,443 +14,21 @@ require "zlib"
|
|
|
14
14
|
module MultiCompress
|
|
15
15
|
module DBDeployment
|
|
16
16
|
ROOT = File.expand_path("../..", __dir__)
|
|
17
|
-
FORMAT = "MCDB1"
|
|
18
|
-
BUNDLE_FORMAT_VERSION =
|
|
17
|
+
FORMAT = "MCDB1 + MCDB2"
|
|
18
|
+
BUNDLE_FORMAT_VERSION = 2
|
|
19
|
+
MAX_DICTIONARY_BYTES = 256 * 1024
|
|
19
20
|
|
|
20
21
|
class Error < StandardError; end
|
|
21
22
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
PackageCommand.new.run(argv)
|
|
33
|
-
when "view"
|
|
34
|
-
ViewCommand.new.run(argv)
|
|
35
|
-
when "help", "--help", "-h", nil
|
|
36
|
-
puts help
|
|
37
|
-
0
|
|
38
|
-
else
|
|
39
|
-
raise OptionParser::InvalidOption, "unknown db command: #{command.inspect}"
|
|
40
|
-
end
|
|
41
|
-
rescue OptionParser::ParseError, Error => e
|
|
42
|
-
warn "multi_compress db: #{e.message}"
|
|
43
|
-
2
|
|
44
|
-
rescue SystemCallError, IOError => e
|
|
45
|
-
warn "multi_compress db: #{e.message}"
|
|
46
|
-
1
|
|
47
|
-
end
|
|
48
|
-
|
|
49
|
-
private
|
|
50
|
-
|
|
51
|
-
def help
|
|
52
|
-
<<~TEXT
|
|
53
|
-
Usage:
|
|
54
|
-
multi_compress db package postgres [--output PATH] [--force]
|
|
55
|
-
multi_compress db package mysql [--output PATH] [--force]
|
|
56
|
-
|
|
57
|
-
multi_compress db view postgres --table SCHEMA.TABLE --column COLUMN \\
|
|
58
|
-
--view SCHEMA.VIEW --columns ID,CREATED_AT [--as PAYLOAD] \\
|
|
59
|
-
[--extension-schema multi_compress] [--output PATH]
|
|
60
|
-
|
|
61
|
-
multi_compress db view mysql --table SCHEMA.TABLE --column COLUMN \\
|
|
62
|
-
--view SCHEMA.VIEW --columns ID,CREATED_AT [--as PAYLOAD] [--output PATH]
|
|
63
|
-
|
|
64
|
-
`package` creates a self-contained source bundle for the DBA. The DBA
|
|
65
|
-
extracts it on the database host and runs `make doctor`, `sudo make
|
|
66
|
-
install`, then `make enable DB=...`.
|
|
67
|
-
|
|
68
|
-
`view` emits a safe readable-view definition so DBeaver users query
|
|
69
|
-
decoded text instead of the compressed bytea/BLOB column.
|
|
70
|
-
TEXT
|
|
71
|
-
end
|
|
72
|
-
end
|
|
73
|
-
|
|
74
|
-
class PackageCommand
|
|
75
|
-
TARGETS = %w[postgres mysql].freeze
|
|
76
|
-
|
|
77
|
-
def run(argv)
|
|
78
|
-
target = argv.shift
|
|
79
|
-
raise OptionParser::MissingArgument, "TARGET must be postgres or mysql" if target.nil?
|
|
80
|
-
raise OptionParser::InvalidArgument, "TARGET must be postgres or mysql" unless TARGETS.include?(target)
|
|
81
|
-
|
|
82
|
-
options = { output: nil, force: false }
|
|
83
|
-
parser = OptionParser.new do |opts|
|
|
84
|
-
opts.banner = "Usage: multi_compress db package #{target} [OPTIONS]"
|
|
85
|
-
opts.on("-o", "--output PATH", "write the deployment archive to PATH") { |value| options[:output] = value }
|
|
86
|
-
opts.on("-f", "--force", "overwrite an existing archive") { options[:force] = true }
|
|
87
|
-
opts.on("-h", "--help", "show this help") { puts opts; return 0 }
|
|
88
|
-
end
|
|
89
|
-
parser.parse!(argv)
|
|
90
|
-
raise OptionParser::InvalidOption, argv.join(" ") unless argv.empty?
|
|
91
|
-
|
|
92
|
-
output = options[:output] || "multi_compress-#{target}-#{MultiCompress::VERSION}.tar.gz"
|
|
93
|
-
DeploymentBundle.new(target, output, force: options[:force]).write!
|
|
94
|
-
puts File.expand_path(output)
|
|
95
|
-
0
|
|
96
|
-
end
|
|
97
|
-
end
|
|
98
|
-
|
|
99
|
-
class ViewCommand
|
|
100
|
-
TARGETS = %w[postgres mysql].freeze
|
|
101
|
-
IDENTIFIER = /\A[A-Za-z_][A-Za-z0-9_]*\z/.freeze
|
|
102
|
-
|
|
103
|
-
def run(argv)
|
|
104
|
-
target = argv.shift
|
|
105
|
-
raise OptionParser::MissingArgument, "TARGET must be postgres or mysql" if target.nil?
|
|
106
|
-
raise OptionParser::InvalidArgument, "TARGET must be postgres or mysql" unless TARGETS.include?(target)
|
|
107
|
-
|
|
108
|
-
options = { columns: [], as: "payload", output: nil, extension_schema: "multi_compress" }
|
|
109
|
-
parser = OptionParser.new do |opts|
|
|
110
|
-
opts.banner = "Usage: multi_compress db view #{target} --table SCHEMA.TABLE --column COLUMN --view SCHEMA.VIEW --columns ID,CREATED_AT [OPTIONS]"
|
|
111
|
-
opts.on("--table NAME", "source table, optionally schema-qualified") { |value| options[:table] = value }
|
|
112
|
-
opts.on("--column NAME", "compressed bytea/BLOB column") { |value| options[:column] = value }
|
|
113
|
-
opts.on("--view NAME", "destination view, optionally schema-qualified") { |value| options[:view] = value }
|
|
114
|
-
opts.on("--columns LIST", "comma-separated plain columns to expose") { |value| options[:columns] = value.split(",").map(&:strip) }
|
|
115
|
-
opts.on("--as NAME", "decoded column name (default: payload)") { |value| options[:as] = value }
|
|
116
|
-
if target == "postgres"
|
|
117
|
-
opts.on("--extension-schema NAME", "extension schema (default: multi_compress)") { |value| options[:extension_schema] = value }
|
|
118
|
-
end
|
|
119
|
-
opts.on("-o", "--output PATH", "write SQL to PATH instead of stdout") { |value| options[:output] = value }
|
|
120
|
-
opts.on("-h", "--help", "show this help") { puts opts; return 0 }
|
|
121
|
-
end
|
|
122
|
-
parser.parse!(argv)
|
|
123
|
-
raise OptionParser::InvalidOption, argv.join(" ") unless argv.empty?
|
|
124
|
-
|
|
125
|
-
sql = ReadableView.new(target, options).to_sql
|
|
126
|
-
if options[:output]
|
|
127
|
-
write_text_atomically(options[:output], sql)
|
|
128
|
-
puts File.expand_path(options[:output])
|
|
129
|
-
else
|
|
130
|
-
$stdout.write(sql)
|
|
131
|
-
end
|
|
132
|
-
0
|
|
133
|
-
end
|
|
134
|
-
|
|
135
|
-
private
|
|
136
|
-
|
|
137
|
-
def write_text_atomically(path, contents)
|
|
138
|
-
directory = File.dirname(File.expand_path(path))
|
|
139
|
-
FileUtils.mkdir_p(directory)
|
|
140
|
-
temporary = File.join(directory, ".multi-compress-view-#{Process.pid}-#{SecureRandom.hex(8)}")
|
|
141
|
-
File.open(temporary, "wb", 0o644) { |file| file.write(contents) }
|
|
142
|
-
File.rename(temporary, path)
|
|
143
|
-
ensure
|
|
144
|
-
File.delete(temporary) if defined?(temporary) && File.exist?(temporary)
|
|
145
|
-
end
|
|
146
|
-
end
|
|
147
|
-
|
|
148
|
-
class ReadableView
|
|
149
|
-
IDENTIFIER = /\A[A-Za-z_][A-Za-z0-9_]*\z/.freeze
|
|
150
|
-
|
|
151
|
-
def initialize(target, options)
|
|
152
|
-
@target = target
|
|
153
|
-
@table = qualified_identifier!(options.fetch(:table), "--table")
|
|
154
|
-
@view = qualified_identifier!(options.fetch(:view), "--view")
|
|
155
|
-
@column = identifier!(options.fetch(:column), "--column")
|
|
156
|
-
@columns = Array(options.fetch(:columns)).reject(&:empty?).map { |value| identifier!(value, "--columns") }
|
|
157
|
-
@decoded_name = identifier!(options.fetch(:as), "--as")
|
|
158
|
-
@extension_schema = identifier!(options.fetch(:extension_schema, "multi_compress"), "--extension-schema")
|
|
159
|
-
raise Error, "--columns must contain at least one plain column" if @columns.empty?
|
|
160
|
-
raise Error, "--columns must not contain duplicate names" unless @columns.uniq.length == @columns.length
|
|
161
|
-
raise Error, "--as must not duplicate a selected column" if @columns.include?(@decoded_name)
|
|
162
|
-
rescue KeyError
|
|
163
|
-
raise Error, "--table, --column, --view and --columns are required"
|
|
164
|
-
end
|
|
165
|
-
|
|
166
|
-
def to_sql
|
|
167
|
-
case @target
|
|
168
|
-
when "postgres" then postgres_sql
|
|
169
|
-
when "mysql" then mysql_sql
|
|
170
|
-
else raise Error, "unsupported database target: #{@target.inspect}"
|
|
171
|
-
end
|
|
172
|
-
end
|
|
173
|
-
|
|
174
|
-
private
|
|
175
|
-
|
|
176
|
-
def postgres_sql
|
|
177
|
-
selections = @columns.map { |column| " source.#{pg_quote(column)}" }
|
|
178
|
-
selections << " #{pg_quote(@extension_schema)}.multi_compress_db_decompress(source.#{pg_quote(@column)}) AS #{pg_quote(@decoded_name)}"
|
|
179
|
-
|
|
180
|
-
<<~SQL
|
|
181
|
-
-- Generated by multi_compress #{MultiCompress::VERSION}. Keep this view in your schema migrations.
|
|
182
|
-
-- Query it in DBeaver; do not expose the compressed column for ad-hoc reading.
|
|
183
|
-
CREATE OR REPLACE VIEW #{pg_qualified(@view)} AS
|
|
184
|
-
SELECT
|
|
185
|
-
#{selections.join(",\n")}
|
|
186
|
-
FROM #{pg_qualified(@table)} AS source;
|
|
187
|
-
SQL
|
|
188
|
-
end
|
|
189
|
-
|
|
190
|
-
def mysql_sql
|
|
191
|
-
selections = @columns.map { |column| " source.#{mysql_quote(column)}" }
|
|
192
|
-
selections << " CONVERT(multi_compress_db_decompress(source.#{mysql_quote(@column)}) USING utf8mb4) AS #{mysql_quote(@decoded_name)}"
|
|
193
|
-
|
|
194
|
-
<<~SQL
|
|
195
|
-
-- Generated by multi_compress #{MultiCompress::VERSION}. Keep this view in your schema migrations.
|
|
196
|
-
-- Query it in DBeaver; do not expose the compressed column for ad-hoc reading.
|
|
197
|
-
CREATE OR REPLACE VIEW #{mysql_qualified(@view)} AS
|
|
198
|
-
SELECT
|
|
199
|
-
#{selections.join(",\n")}
|
|
200
|
-
FROM #{mysql_qualified(@table)} AS source;
|
|
201
|
-
SQL
|
|
202
|
-
end
|
|
203
|
-
|
|
204
|
-
def qualified_identifier!(value, option)
|
|
205
|
-
pieces = value.to_s.split(".", -1)
|
|
206
|
-
unless pieces.length.between?(1, 2) && pieces.all? { |piece| IDENTIFIER.match?(piece) }
|
|
207
|
-
raise Error, "#{option} must be NAME or SCHEMA.NAME using letters, digits and underscores"
|
|
208
|
-
end
|
|
209
|
-
pieces
|
|
210
|
-
end
|
|
211
|
-
|
|
212
|
-
def identifier!(value, option)
|
|
213
|
-
string = value.to_s
|
|
214
|
-
raise Error, "#{option} must use letters, digits and underscores and cannot start with a digit" unless IDENTIFIER.match?(string)
|
|
215
|
-
|
|
216
|
-
string
|
|
217
|
-
end
|
|
218
|
-
|
|
219
|
-
def pg_quote(identifier)
|
|
220
|
-
%Q("#{identifier}")
|
|
221
|
-
end
|
|
222
|
-
|
|
223
|
-
def pg_qualified(pieces)
|
|
224
|
-
pieces.map { |piece| pg_quote(piece) }.join(".")
|
|
225
|
-
end
|
|
226
|
-
|
|
227
|
-
def mysql_quote(identifier)
|
|
228
|
-
"`#{identifier}`"
|
|
229
|
-
end
|
|
230
|
-
|
|
231
|
-
def mysql_qualified(pieces)
|
|
232
|
-
pieces.map { |piece| mysql_quote(piece) }.join(".")
|
|
233
|
-
end
|
|
234
|
-
end
|
|
235
|
-
|
|
236
|
-
class DeploymentBundle
|
|
237
|
-
DEFAULT_ARCHIVE_EPOCH = 315_619_200
|
|
238
|
-
|
|
239
|
-
class DeterministicTarWriter < Gem::Package::TarWriter
|
|
240
|
-
def self.new(io, epoch)
|
|
241
|
-
writer = allocate
|
|
242
|
-
writer.send(:initialize, io, epoch)
|
|
243
|
-
return writer unless block_given?
|
|
244
|
-
|
|
245
|
-
begin
|
|
246
|
-
yield writer
|
|
247
|
-
ensure
|
|
248
|
-
writer.close
|
|
249
|
-
end
|
|
250
|
-
|
|
251
|
-
nil
|
|
252
|
-
end
|
|
253
|
-
|
|
254
|
-
def initialize(io, epoch)
|
|
255
|
-
@archive_epoch = epoch
|
|
256
|
-
super(io)
|
|
257
|
-
end
|
|
258
|
-
|
|
259
|
-
def add_file_simple(name, mode, size)
|
|
260
|
-
check_closed
|
|
261
|
-
|
|
262
|
-
name, prefix = split_name(name)
|
|
263
|
-
header = Gem::Package::TarHeader.new(
|
|
264
|
-
name: name,
|
|
265
|
-
mode: mode,
|
|
266
|
-
size: size,
|
|
267
|
-
prefix: prefix,
|
|
268
|
-
mtime: @archive_epoch
|
|
269
|
-
).to_s
|
|
270
|
-
@io.write(header)
|
|
271
|
-
output = BoundedStream.new(@io, size)
|
|
272
|
-
yield output if block_given?
|
|
273
|
-
@io.write("\0" * (size - output.written))
|
|
274
|
-
@io.write("\0" * ((512 - (size % 512)) % 512))
|
|
275
|
-
self
|
|
276
|
-
end
|
|
277
|
-
|
|
278
|
-
def mkdir(name, mode)
|
|
279
|
-
check_closed
|
|
280
|
-
|
|
281
|
-
name, prefix = split_name(name)
|
|
282
|
-
header = Gem::Package::TarHeader.new(
|
|
283
|
-
name: name,
|
|
284
|
-
mode: mode,
|
|
285
|
-
typeflag: "5",
|
|
286
|
-
size: 0,
|
|
287
|
-
prefix: prefix,
|
|
288
|
-
mtime: @archive_epoch
|
|
289
|
-
)
|
|
290
|
-
@io.write(header)
|
|
291
|
-
self
|
|
292
|
-
end
|
|
293
|
-
end
|
|
294
|
-
|
|
295
|
-
TARGETS = {
|
|
296
|
-
"postgres" => {
|
|
297
|
-
source_paths: %w[
|
|
298
|
-
db_core
|
|
299
|
-
postgres_extension
|
|
300
|
-
ext/multi_compress/vendor/zstd
|
|
301
|
-
docs/database-envelope-v1.md
|
|
302
|
-
LICENSE.txt
|
|
303
|
-
THIRD_PARTY_NOTICES.md
|
|
304
|
-
],
|
|
305
|
-
template_path: "db_deployment/postgres"
|
|
306
|
-
},
|
|
307
|
-
"mysql" => {
|
|
308
|
-
source_paths: %w[
|
|
309
|
-
db_core
|
|
310
|
-
mysql_udf
|
|
311
|
-
ext/multi_compress/vendor/zstd
|
|
312
|
-
docs/database-envelope-v1.md
|
|
313
|
-
LICENSE.txt
|
|
314
|
-
THIRD_PARTY_NOTICES.md
|
|
315
|
-
],
|
|
316
|
-
template_path: "db_deployment/mysql"
|
|
317
|
-
}
|
|
318
|
-
}.freeze
|
|
319
|
-
|
|
320
|
-
def initialize(target, output, force:)
|
|
321
|
-
@target = target
|
|
322
|
-
@output = File.expand_path(output)
|
|
323
|
-
@force = force
|
|
324
|
-
@root_name = "multi_compress-#{target}-#{MultiCompress::VERSION}"
|
|
325
|
-
end
|
|
326
|
-
|
|
327
|
-
def write!
|
|
328
|
-
raise Error, "deployment sources are unavailable from #{ROOT}" unless File.directory?(ROOT)
|
|
329
|
-
raise Error, "#{@output} already exists (use --force to overwrite)" if File.exist?(@output) && !@force
|
|
330
|
-
|
|
331
|
-
Dir.mktmpdir("multi-compress-db-package") do |temporary_directory|
|
|
332
|
-
bundle_root = File.join(temporary_directory, @root_name)
|
|
333
|
-
FileUtils.mkdir_p(bundle_root)
|
|
334
|
-
stage_payload!(bundle_root)
|
|
335
|
-
write_metadata!(bundle_root)
|
|
336
|
-
write_tarball!(temporary_directory)
|
|
337
|
-
end
|
|
338
|
-
end
|
|
339
|
-
|
|
340
|
-
private
|
|
341
|
-
|
|
342
|
-
def stage_payload!(bundle_root)
|
|
343
|
-
target = TARGETS.fetch(@target)
|
|
344
|
-
target.fetch(:source_paths).each do |relative|
|
|
345
|
-
copy_path!(relative, bundle_root)
|
|
346
|
-
end
|
|
347
|
-
|
|
348
|
-
template_root = File.join(ROOT, target.fetch(:template_path))
|
|
349
|
-
Dir.children(template_root).sort.each do |entry|
|
|
350
|
-
next if ignored?(entry)
|
|
351
|
-
|
|
352
|
-
copy_path!(File.join(target.fetch(:template_path), entry), bundle_root, destination: entry)
|
|
353
|
-
end
|
|
354
|
-
end
|
|
355
|
-
|
|
356
|
-
def copy_path!(relative, bundle_root, destination: relative)
|
|
357
|
-
source = File.join(ROOT, relative)
|
|
358
|
-
raise Error, "package source is missing: #{relative}" unless File.exist?(source)
|
|
359
|
-
|
|
360
|
-
destination_path = File.join(bundle_root, destination)
|
|
361
|
-
if File.directory?(source)
|
|
362
|
-
FileUtils.mkdir_p(destination_path)
|
|
363
|
-
Dir.children(source).sort.each do |entry|
|
|
364
|
-
next if ignored?(entry)
|
|
365
|
-
|
|
366
|
-
copy_path!(File.join(relative, entry), bundle_root, destination: File.join(destination, entry))
|
|
367
|
-
end
|
|
368
|
-
else
|
|
369
|
-
FileUtils.mkdir_p(File.dirname(destination_path))
|
|
370
|
-
FileUtils.copy_file(source, destination_path)
|
|
371
|
-
File.chmod(File.stat(source).mode & 0o777, destination_path)
|
|
372
|
-
end
|
|
373
|
-
end
|
|
374
|
-
|
|
375
|
-
def ignored?(entry)
|
|
376
|
-
entry == ".DS_Store" || entry == ".git" || entry == "build" || entry == "test"
|
|
377
|
-
end
|
|
378
|
-
|
|
379
|
-
def write_metadata!(bundle_root)
|
|
380
|
-
payload = regular_files(bundle_root).reject do |path|
|
|
381
|
-
%w[manifest.json SHA256SUMS].include?(File.basename(path))
|
|
382
|
-
end
|
|
383
|
-
files = payload.sort.map do |path|
|
|
384
|
-
relative = relative_to(bundle_root, path)
|
|
385
|
-
{ "path" => relative, "sha256" => Digest::SHA256.file(path).hexdigest }
|
|
386
|
-
end
|
|
387
|
-
|
|
388
|
-
manifest = {
|
|
389
|
-
"bundle_format_version" => BUNDLE_FORMAT_VERSION,
|
|
390
|
-
"gem" => { "name" => "multi_compress", "version" => MultiCompress::VERSION },
|
|
391
|
-
"database_target" => @target,
|
|
392
|
-
"database_envelope" => FORMAT,
|
|
393
|
-
"files" => files
|
|
394
|
-
}
|
|
395
|
-
File.write(File.join(bundle_root, "manifest.json"), JSON.pretty_generate(manifest) + "\n")
|
|
396
|
-
|
|
397
|
-
checksum_paths = regular_files(bundle_root).reject { |path| File.basename(path) == "SHA256SUMS" }
|
|
398
|
-
checksums = checksum_paths.sort.map do |path|
|
|
399
|
-
"#{Digest::SHA256.file(path).hexdigest} #{relative_to(bundle_root, path)}"
|
|
400
|
-
end
|
|
401
|
-
File.write(File.join(bundle_root, "SHA256SUMS"), checksums.join("\n") + "\n")
|
|
402
|
-
end
|
|
403
|
-
|
|
404
|
-
def write_tarball!(temporary_directory)
|
|
405
|
-
FileUtils.mkdir_p(File.dirname(@output))
|
|
406
|
-
temporary_output = File.join(
|
|
407
|
-
File.dirname(@output),
|
|
408
|
-
".#{File.basename(@output)}.#{Process.pid}.#{SecureRandom.hex(8)}.tmp"
|
|
409
|
-
)
|
|
410
|
-
|
|
411
|
-
File.open(temporary_output, "wb", 0o644) do |file|
|
|
412
|
-
Zlib::GzipWriter.wrap(file) do |gzip|
|
|
413
|
-
gzip.mtime = archive_epoch
|
|
414
|
-
DeterministicTarWriter.new(gzip, archive_epoch) do |tar|
|
|
415
|
-
add_tree_to_tar!(tar, File.join(temporary_directory, @root_name), @root_name)
|
|
416
|
-
end
|
|
417
|
-
end
|
|
418
|
-
end
|
|
419
|
-
File.rename(temporary_output, @output)
|
|
420
|
-
ensure
|
|
421
|
-
File.delete(temporary_output) if defined?(temporary_output) && File.exist?(temporary_output)
|
|
422
|
-
end
|
|
423
|
-
|
|
424
|
-
def archive_epoch
|
|
425
|
-
value = ENV["SOURCE_DATE_EPOCH"]
|
|
426
|
-
return DEFAULT_ARCHIVE_EPOCH unless value&.match?(/\A\d+\z/)
|
|
427
|
-
|
|
428
|
-
value.to_i
|
|
429
|
-
end
|
|
430
|
-
|
|
431
|
-
def add_tree_to_tar!(tar, directory, archive_path)
|
|
432
|
-
tar.mkdir(archive_path, File.stat(directory).mode & 0o777)
|
|
433
|
-
Dir.children(directory).sort.each do |entry|
|
|
434
|
-
source = File.join(directory, entry)
|
|
435
|
-
destination = File.join(archive_path, entry)
|
|
436
|
-
if File.directory?(source)
|
|
437
|
-
add_tree_to_tar!(tar, source, destination)
|
|
438
|
-
else
|
|
439
|
-
mode = File.stat(source).mode & 0o777
|
|
440
|
-
tar.add_file_simple(destination, mode, File.size(source)) do |destination_io|
|
|
441
|
-
File.open(source, "rb") { |source_io| IO.copy_stream(source_io, destination_io) }
|
|
442
|
-
end
|
|
443
|
-
end
|
|
444
|
-
end
|
|
445
|
-
end
|
|
446
|
-
|
|
447
|
-
def regular_files(root)
|
|
448
|
-
Dir.glob(File.join(root, "**", "*"), File::FNM_DOTMATCH).select { |path| File.file?(path) }
|
|
449
|
-
end
|
|
450
|
-
|
|
451
|
-
def relative_to(root, path)
|
|
452
|
-
Pathname.new(path).relative_path_from(Pathname.new(root)).to_s
|
|
453
|
-
end
|
|
454
|
-
end
|
|
23
|
+
require "multi_compress/db_deployment/sql_identifiers"
|
|
24
|
+
require "multi_compress/db_deployment/commands"
|
|
25
|
+
require "multi_compress/db_deployment/dictionary_registry"
|
|
26
|
+
require "multi_compress/db_deployment/readable_view_options"
|
|
27
|
+
require "multi_compress/db_deployment/readable_view"
|
|
28
|
+
require "multi_compress/db_deployment/deterministic_tar_writer"
|
|
29
|
+
require "multi_compress/db_deployment/bundle_stager"
|
|
30
|
+
require "multi_compress/db_deployment/bundle_metadata"
|
|
31
|
+
require "multi_compress/db_deployment/bundle_archiver"
|
|
32
|
+
require "multi_compress/db_deployment/deployment_bundle"
|
|
455
33
|
end
|
|
456
34
|
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MultiCompress
|
|
4
|
+
class Reader
|
|
5
|
+
BUFFER_COMPACT_THRESHOLD = 64 * 1024
|
|
6
|
+
|
|
7
|
+
module BufferOperations
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
def buffer_size
|
|
11
|
+
@buffer.bytesize - @buffer_pos
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def buffer_empty?
|
|
15
|
+
@buffer_pos >= @buffer.bytesize
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def buffer_append(data)
|
|
19
|
+
compact_buffer_if_needed
|
|
20
|
+
@buffer << data
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def compact_buffer_if_needed
|
|
24
|
+
return if @buffer_pos.zero?
|
|
25
|
+
|
|
26
|
+
total = @buffer.bytesize
|
|
27
|
+
return unless @buffer_pos >= BUFFER_COMPACT_THRESHOLD && @buffer_pos * 2 >= total
|
|
28
|
+
|
|
29
|
+
@buffer = @buffer.byteslice(@buffer_pos, total - @buffer_pos)
|
|
30
|
+
@buffer_pos = 0
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def buffer_includes?(separator)
|
|
34
|
+
idx = @buffer.index(separator, @buffer_pos)
|
|
35
|
+
!idx.nil?
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def extract_line(separator)
|
|
39
|
+
idx = @buffer.index(separator, @buffer_pos)
|
|
40
|
+
end_pos = idx + separator.bytesize
|
|
41
|
+
result = @buffer.byteslice(@buffer_pos, end_pos - @buffer_pos)
|
|
42
|
+
@buffer_pos = end_pos
|
|
43
|
+
compact_buffer_if_needed
|
|
44
|
+
result
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def consume_buffer
|
|
48
|
+
result = @buffer.byteslice(@buffer_pos, @buffer.bytesize - @buffer_pos) || +"".b
|
|
49
|
+
@buffer.clear
|
|
50
|
+
@buffer_pos = 0
|
|
51
|
+
result
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private_constant :BufferOperations
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MultiCompress
|
|
4
|
+
class Reader
|
|
5
|
+
module ReadOperations
|
|
6
|
+
private
|
|
7
|
+
|
|
8
|
+
def read_all
|
|
9
|
+
return nil if @eof && buffer_empty?
|
|
10
|
+
|
|
11
|
+
result = consume_buffer
|
|
12
|
+
result = +"".b if result.empty?
|
|
13
|
+
append_remaining_stream(result)
|
|
14
|
+
result.empty? ? nil : result
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def append_remaining_stream(result)
|
|
18
|
+
until @eof
|
|
19
|
+
chunk = read_compressed_chunk
|
|
20
|
+
break unless chunk && !chunk.empty?
|
|
21
|
+
|
|
22
|
+
decompressed = @inflater.write(chunk)
|
|
23
|
+
result << decompressed if decompressed
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
final = @inflater.finish
|
|
27
|
+
result << final if final && !final.empty?
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def read_exactly(length)
|
|
31
|
+
return nil if @eof && buffer_empty?
|
|
32
|
+
|
|
33
|
+
fill_buffer_until { buffer_size >= length }
|
|
34
|
+
return read_buffer_slice(length) if buffer_size >= length
|
|
35
|
+
|
|
36
|
+
consume_buffer unless buffer_empty?
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def read_buffer_slice(length)
|
|
40
|
+
result = @buffer.byteslice(@buffer_pos, length)
|
|
41
|
+
@buffer_pos += length
|
|
42
|
+
compact_buffer_if_needed
|
|
43
|
+
result
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def fill_buffer_until
|
|
47
|
+
until yield || @eof
|
|
48
|
+
chunk = read_compressed_chunk
|
|
49
|
+
if chunk.nil? || chunk.empty?
|
|
50
|
+
@eof = true
|
|
51
|
+
break
|
|
52
|
+
end
|
|
53
|
+
decompressed = @inflater.write(chunk)
|
|
54
|
+
buffer_append(decompressed) if decompressed
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def read_compressed_chunk
|
|
59
|
+
@io.read(CHUNK_SIZE)
|
|
60
|
+
rescue EOFError
|
|
61
|
+
nil
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private_constant :ReadOperations
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MultiCompress
|
|
4
|
+
class Reader
|
|
5
|
+
CHUNK_SIZE = 8192
|
|
6
|
+
require "multi_compress/reader/buffer_operations"
|
|
7
|
+
require "multi_compress/reader/read_operations"
|
|
8
|
+
include BufferOperations
|
|
9
|
+
include ReadOperations
|
|
10
|
+
|
|
11
|
+
def self.open(path_or_io, algo: nil, dictionary: nil, **opts, &block)
|
|
12
|
+
io, algo, owned = StreamIO.resolve_io(path_or_io, algo, mode: "rb")
|
|
13
|
+
reader = new(io, algo: algo, dictionary: dictionary, **opts)
|
|
14
|
+
reader.instance_variable_set(:@owned_io, owned)
|
|
15
|
+
|
|
16
|
+
return reader unless block
|
|
17
|
+
|
|
18
|
+
begin
|
|
19
|
+
yield reader
|
|
20
|
+
ensure
|
|
21
|
+
reader.close
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def initialize(io, algo: nil, dictionary: nil, **opts)
|
|
26
|
+
@io = io
|
|
27
|
+
@inflater = Inflater.new(algo: algo, dictionary: dictionary, **opts)
|
|
28
|
+
@closed = false
|
|
29
|
+
@owned_io = false
|
|
30
|
+
@buffer = +"".b
|
|
31
|
+
@buffer_pos = 0
|
|
32
|
+
@eof = false
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def read(length = nil)
|
|
36
|
+
ensure_open!
|
|
37
|
+
return read_all if length.nil?
|
|
38
|
+
|
|
39
|
+
read_exactly(length)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def gets(separator = "\n")
|
|
43
|
+
ensure_open!
|
|
44
|
+
return nil if @eof && buffer_empty?
|
|
45
|
+
|
|
46
|
+
fill_buffer_until { buffer_includes?(separator) }
|
|
47
|
+
|
|
48
|
+
return extract_line(separator) if buffer_includes?(separator)
|
|
49
|
+
return consume_buffer unless buffer_empty?
|
|
50
|
+
|
|
51
|
+
nil
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def close
|
|
55
|
+
return if @closed
|
|
56
|
+
|
|
57
|
+
@inflater.close
|
|
58
|
+
@io.close if @owned_io && @io.respond_to?(:close)
|
|
59
|
+
@closed = true
|
|
60
|
+
nil
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def closed?
|
|
64
|
+
@closed
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def eof?
|
|
68
|
+
@eof && buffer_empty?
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def each_line
|
|
72
|
+
return enum_for(:each_line) unless block_given?
|
|
73
|
+
|
|
74
|
+
while (line = gets)
|
|
75
|
+
yield line
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def each_chunk(size)
|
|
80
|
+
return enum_for(:each_chunk, size) unless block_given?
|
|
81
|
+
|
|
82
|
+
while (chunk = read(size))
|
|
83
|
+
yield chunk
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def readline
|
|
88
|
+
gets || raise(EOFError, "end of file reached")
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
def ensure_open!
|
|
94
|
+
raise StreamError, "reader is closed" if @closed
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MultiCompress
|
|
4
|
+
module StreamIO
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def resolve_io(path_or_io, algo, mode:)
|
|
8
|
+
return [path_or_io, algo, false] unless path_or_io.is_a?(String)
|
|
9
|
+
|
|
10
|
+
[File.open(path_or_io, mode), algo || MultiCompress.algo_from_ext(path_or_io), true]
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|