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.
Files changed (67) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +45 -0
  3. data/GET_STARTED.md +20 -1
  4. data/README.md +15 -2
  5. data/THIRD_PARTY_NOTICES.md +1 -1
  6. data/db_core/include/mcdb_format.h +67 -9
  7. data/db_core/src/mcdb_format.c +392 -139
  8. data/db_deployment/mysql/README.md +19 -0
  9. data/db_deployment/mysql/bin/common +70 -18
  10. data/db_deployment/mysql/bin/enable +5 -1
  11. data/db_deployment/mysql/bin/status +2 -0
  12. data/db_deployment/mysql/bin/upgrade +17 -5
  13. data/db_deployment/postgres/README.md +24 -0
  14. data/db_deployment/postgres/bin/enable +2 -0
  15. data/db_deployment/postgres/bin/install +4 -2
  16. data/docs/database-envelope-v2.md +147 -0
  17. data/docs/rfcs/0002-mcdb2-dictionaries.md +75 -0
  18. data/ext/multi_compress/multi_compress.c +261 -76
  19. data/ext/multi_compress/vendor/.vendored +1 -1
  20. data/ext/multi_compress/vendor/brotli/c/common/version.h +2 -2
  21. data/ext/multi_compress/vendor/brotli/c/dec/decode.c +59 -30
  22. data/ext/multi_compress/vendor/brotli/c/include/brotli/decode.h +1 -3
  23. data/lib/multi_compress/active_record.rb +11 -5
  24. data/lib/multi_compress/codec.rb +164 -94
  25. data/lib/multi_compress/database/api.rb +42 -0
  26. data/lib/multi_compress/database/decoding.rb +42 -0
  27. data/lib/multi_compress/database/dictionary.rb +72 -0
  28. data/lib/multi_compress/database/encoding.rb +31 -0
  29. data/lib/multi_compress/database/header.rb +71 -0
  30. data/lib/multi_compress/database/operations.rb +73 -0
  31. data/lib/multi_compress/database/validation.rb +95 -0
  32. data/lib/multi_compress/database.rb +29 -95
  33. data/lib/multi_compress/db_deployment/bundle_archiver.rb +70 -0
  34. data/lib/multi_compress/db_deployment/bundle_metadata.rb +56 -0
  35. data/lib/multi_compress/db_deployment/bundle_stager.rb +62 -0
  36. data/lib/multi_compress/db_deployment/commands.rb +309 -0
  37. data/lib/multi_compress/db_deployment/deployment_bundle.rb +80 -0
  38. data/lib/multi_compress/db_deployment/deterministic_tar_writer.rb +76 -0
  39. data/lib/multi_compress/db_deployment/dictionary_registry.rb +244 -0
  40. data/lib/multi_compress/db_deployment/readable_view.rb +112 -0
  41. data/lib/multi_compress/db_deployment/readable_view_options.rb +51 -0
  42. data/lib/multi_compress/db_deployment/sql_identifiers.rb +45 -0
  43. data/lib/multi_compress/db_deployment.rb +13 -435
  44. data/lib/multi_compress/reader/buffer_operations.rb +57 -0
  45. data/lib/multi_compress/reader/read_operations.rb +67 -0
  46. data/lib/multi_compress/reader.rb +97 -0
  47. data/lib/multi_compress/stream_io.rb +13 -0
  48. data/lib/multi_compress/version.rb +1 -1
  49. data/lib/multi_compress/writer.rb +85 -0
  50. data/lib/multi_compress.rb +42 -333
  51. data/mysql_udf/README.md +45 -0
  52. data/mysql_udf/sql/examples.sql +5 -0
  53. data/mysql_udf/sql/install.sql +25 -1
  54. data/mysql_udf/sql/uninstall.sql +7 -1
  55. data/mysql_udf/sql/views.sql +16 -4
  56. data/mysql_udf/src/multi_compress_mysql.c +383 -44
  57. data/mysql_udf/test/run_e2e.sh +119 -1
  58. data/postgres_extension/Makefile +2 -2
  59. data/postgres_extension/README.md +48 -0
  60. data/postgres_extension/multi_compress.control +2 -2
  61. data/postgres_extension/multi_compress_pg.exports +12 -0
  62. data/postgres_extension/sql/examples.sql +11 -0
  63. data/postgres_extension/sql/multi_compress--0.5.0--0.6.0.sql +59 -0
  64. data/postgres_extension/sql/multi_compress--0.6.0.sql +85 -0
  65. data/postgres_extension/src/multi_compress_pg.c +301 -16
  66. data/postgres_extension/test/run_e2e.sh +90 -2
  67. 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 = 1
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
- class CLI
23
- def self.run(argv)
24
- new.run(argv)
25
- end
26
-
27
- def run(argv)
28
- command = argv.shift
29
-
30
- case command
31
- when "package"
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module MultiCompress
4
- VERSION = "0.5.0"
4
+ VERSION = "0.6.1"
5
5
  end