herringbone 0.6.1 → 0.8.0

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 (41) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +45 -0
  3. data/MANUAL.md +196 -17
  4. data/README.md +22 -5
  5. data/bin/herringbone +56 -1
  6. data/lib/herringbone/active_record.rb +3 -1
  7. data/lib/herringbone/bloom_filter.rb +2 -0
  8. data/lib/herringbone/byte_values.rb +2 -0
  9. data/lib/herringbone/codecs/lz4.rb +9 -4
  10. data/lib/herringbone/codecs/lzo.rb +2 -0
  11. data/lib/herringbone/codecs/snappy.rb +48 -6
  12. data/lib/herringbone/combiner.rb +426 -0
  13. data/lib/herringbone/compression.rb +76 -13
  14. data/lib/herringbone/encodings/delta.rb +4 -0
  15. data/lib/herringbone/encodings/plain.rb +2 -0
  16. data/lib/herringbone/encodings/rle.rb +2 -0
  17. data/lib/herringbone/encryption.rb +2 -0
  18. data/lib/herringbone/inferring_writer.rb +1 -1
  19. data/lib/herringbone/inspector.rb +29 -5
  20. data/lib/herringbone/io_buffer_support.rb +2 -0
  21. data/lib/herringbone/reader/bloom_filters.rb +1 -0
  22. data/lib/herringbone/reader/chunk_copier.rb +177 -0
  23. data/lib/herringbone/reader/column_chunk_reader.rb +6 -2
  24. data/lib/herringbone/reader/column_cursor.rb +2 -0
  25. data/lib/herringbone/reader/numo.rb +4 -0
  26. data/lib/herringbone/reader/page_stream.rb +2 -0
  27. data/lib/herringbone/reader/scan.rb +2 -0
  28. data/lib/herringbone/reader.rb +19 -6
  29. data/lib/herringbone/redaction/rewriter.rb +51 -157
  30. data/lib/herringbone/redaction.rb +25 -15
  31. data/lib/herringbone/restricted_readable_io.rb +85 -0
  32. data/lib/herringbone/schema/merge.rb +355 -0
  33. data/lib/herringbone/schema.rb +175 -12
  34. data/lib/herringbone/thrift.rb +49 -10
  35. data/lib/herringbone/types.rb +22 -12
  36. data/lib/herringbone/version.rb +1 -1
  37. data/lib/herringbone/visualizer.rb +2 -0
  38. data/lib/herringbone/writer.rb +58 -22
  39. data/lib/herringbone/xxhash.rb +2 -0
  40. data/lib/herringbone.rb +96 -9
  41. metadata +6 -2
@@ -10,6 +10,8 @@ module Herringbone
10
10
  # are copied byte for byte. When rows match but none is deleted and only leaf columns change,
11
11
  # the changed chunks are re-encoded and the others copied. Otherwise the whole row group is
12
12
  # rewritten, keeping its boundary: one row group in, one (or none) out.
13
+ #
14
+ # @api private
13
15
  class Rewriter
14
16
  # A replaced column, resolved against the file's schema
15
17
  #
@@ -23,24 +25,21 @@ module Herringbone
23
25
  # @return [Schema::Column, nil] the leaf column, nil when the field is nested
24
26
  Target = Struct.new(:name, :path, :field, :column)
25
27
 
26
- # Writer options that make no sense here, since row groups keep their boundaries
27
- ROW_GROUP_OPTIONS = %i[row_group_bytes row_group_rows].freeze
28
- # Footer metadata that describes the columns, and goes stale when some are dropped
29
- COLUMN_METADATA_KEYS = %w[ARROW:schema pandas].freeze
30
- # Bytes read at a time when a page header runs past what was read of a chunk
31
- READ_MORE = 64 * 1024
32
28
  # What #current returns for a struct member of a null struct
33
29
  ABSENT = Object.new.freeze
34
30
 
35
31
  # @param redaction [Redaction] the statements and drops to apply
36
- # @param io [IO, StringIO] the input file, read with #seek and #read
37
- # @param decryption [DecryptionConfiguration, Hash{Symbol => Object}, nil] keys of an encrypted input, see Reader.new
38
- # @raise [ArgumentError] when a statement or drop does not fit the file's schema
32
+ # @param io_or_reader [IO, StringIO, Reader] the Parquet file, read with #seek and #read, or a
33
+ # Reader of it (whose IO and decryption are used)
34
+ # @param decryption [DecryptionConfiguration, Hash{Symbol => Object}, nil] keys of an encrypted
35
+ # file, see Reader.new; not with a Reader
36
+ # @raise [ArgumentError] when +io_or_reader+ is neither an IO nor a Reader, when a Reader comes
37
+ # with +decryption:+, or when a statement or drop does not fit the file's schema
39
38
  # @raise [FormatError] when the footer cannot be read
40
39
  # @raise [DecryptionError] when the footer is encrypted and cannot be decrypted
41
- def initialize(redaction, io, decryption: nil)
42
- @io = io
43
- @reader = Reader.new(io, decryption: decryption)
40
+ def initialize(redaction, io_or_reader, decryption: nil)
41
+ @reader = fixed_options_reader(io_or_reader, decryption)
42
+ @copier = Reader::ChunkCopier.new(@reader)
44
43
  @schema = @reader.schema
45
44
  @statements = redaction.statements
46
45
  @filters = @statements.map { |s| s.where && Reader::Filter.new(@schema, s.where) }
@@ -66,7 +65,7 @@ module Herringbone
66
65
  end
67
66
  end
68
67
 
69
- # @param output [IO, #write] destination
68
+ # @param output_io [IO, #write] destination
70
69
  # @param options [Hash{Symbol => Object}] Writer options for re-encoded chunks, and +metadata:+
71
70
  # @option options [Symbol] :compression (codec of each source chunk) codec for re-encoded chunks
72
71
  # @option options [Integer, nil] :compression_level (nil) level for that codec, see Writer
@@ -83,27 +82,43 @@ module Herringbone
83
82
  # @raise [ArgumentError] for +row_group_bytes:+ / +row_group_rows:+ or an invalid writer option
84
83
  # @raise [EncodeError] when a replacement value cannot be written
85
84
  # @raise [DecryptionError] when the output is to be encrypted like the input but a key is missing
86
- def apply(output, **options)
87
- bad = options.keys & ROW_GROUP_OPTIONS
88
- raise ArgumentError, "#{bad.join(", ")}: a redaction keeps the row groups of the input" unless bad.empty?
85
+ def apply(output_io, **options)
89
86
  @keep_codecs = !options.key?(:compression)
90
- options = {metadata: copied_metadata}.merge(options)
87
+ options = {metadata: @copier.metadata(@output_schema)}.merge(options)
91
88
  options[:encryption] = input_encryption unless options.key?(:encryption)
92
- writer = Writer.new(output, @output_schema, row_group_bytes: 1 << 62, **options)
93
89
  @report = Report.new(rows_read: 0, rows_deleted: 0, rows_changed: 0, row_groups: {copied: 0, rewritten: 0})
94
- begin
90
+ Writer.open_for_copies(output_io, @output_schema, "a redaction keeps the row groups of the input", **options) do |writer|
95
91
  @reader.row_groups.each_index { |i| redact_row_group(i, writer) }
96
- rescue Exception # rubocop:disable Lint/RescueException -- also abort on Interrupt
97
- writer.abort
98
- raise
99
92
  end
100
- writer.close
101
93
  @report.rows_read = @rows_read
102
94
  @report
103
95
  end
104
96
 
105
97
  private
106
98
 
99
+ # The caller's Reader may have been built with +keys:+ or +time_zone:+, which change what
100
+ # #read returns, so values are always read through a Reader with the default options
101
+ #
102
+ # @param io_or_reader [IO, StringIO, Reader] the Parquet file, or a Reader of it
103
+ # @param decryption [DecryptionConfiguration, Hash{Symbol => Object}, nil] keys, for an IO only
104
+ # @return [Reader]
105
+ # @raise [ArgumentError] when +io_or_reader+ is neither an IO nor a Reader, or a Reader comes
106
+ # with +decryption:+
107
+ def fixed_options_reader(io_or_reader, decryption)
108
+ if io_or_reader.is_a?(Reader)
109
+ if decryption
110
+ raise ArgumentError, "decryption: cannot be given with a Herringbone::Reader, which " \
111
+ "already has its decryption (pass decryption: to Reader.new instead)"
112
+ end
113
+ return Reader.new(io_or_reader.io, decryption: io_or_reader.decryption)
114
+ end
115
+ unless io_or_reader.respond_to?(:seek) && io_or_reader.respond_to?(:read)
116
+ raise ArgumentError, "io_or_reader must be an IO that supports #seek and #read " \
117
+ "(e.g. File.open(path, \"rb\")) or a Herringbone::Reader, got #{io_or_reader.class}"
118
+ end
119
+ Reader.new(io_or_reader, decryption: decryption)
120
+ end
121
+
107
122
  # @param i [Integer] row group index
108
123
  # @param writer [Writer] the output
109
124
  # @return [void]
@@ -250,7 +265,7 @@ module Herringbone
250
265
  @report.row_groups[:copied] += 1
251
266
  return
252
267
  end
253
- copies = @output_columns.each_with_index.to_h { |col, j| [j, copied_chunk(i, col)] }
268
+ copies = @output_columns.each_with_index.to_h { |col, j| [j, @copier.copy(i, col)] }
254
269
  writer.write_row_group(rows(i), copies: copies, sorting_columns: sorting_columns(i, []))
255
270
  @report.row_groups[:copied] += 1
256
271
  end
@@ -275,7 +290,7 @@ module Herringbone
275
290
  if encoded.include?(col.index)
276
291
  chunk_settings(i, col, j, codecs, blooms)
277
292
  else
278
- copies[j] = copied_chunk(i, col)
293
+ copies[j] = @copier.copy(i, col)
279
294
  end
280
295
  end
281
296
  writer.write_row_group(rows(i), copies: copies, codecs: codecs, bloom_filters: blooms,
@@ -303,20 +318,9 @@ module Herringbone
303
318
  # @raise [DecryptionError] when a key of the input was not given
304
319
  def input_encryption
305
320
  decryptor = @reader.decryptor or return nil
306
- chunks = @reader.row_groups.first&.columns || []
307
- columns = @output_columns.each_with_index.filter_map do |col, j|
308
- chunk = chunks[col.index]
309
- crypto = chunk&.crypto_metadata or next
310
- path = @output_schema.columns[j].dotted_path
311
- with_column_key = crypto.encryption_with_column_key
312
- next [path, :footer] unless with_column_key
313
- key = decryptor.chunk_key(chunk, col.dotted_path)
314
- key or raise DecryptionError, "The output is encrypted like the input, which needs the key of #{col.dotted_path}: " \
315
- "pass it in decryption:, or pass encryption: for the output"
316
- [path, {key: key, key_metadata: with_column_key.key_metadata}]
317
- end
318
- uniform = !columns.empty? && columns.size == @output_columns.size && columns.all? { |_, v| v == :footer }
319
- decryptor.writer_settings(uniform ? nil : columns.to_h)
321
+ columns = @copier.encrypted_columns(@output_columns.each_with_index.to_h { |col, j| [@output_schema.columns[j].dotted_path, col] })
322
+ uniform = !columns.empty? && columns.size == @output_columns.size && columns.values.all?(:footer)
323
+ decryptor.writer_settings(uniform ? nil : columns)
320
324
  end
321
325
 
322
326
  # Tier 3: the remaining rows are encoded again, every column of them
@@ -343,9 +347,8 @@ module Herringbone
343
347
  writer.write_row_group(kept, codecs: codecs, bloom_filters: blooms, sorting_columns: sorting_columns(i, rewritten))
344
348
  end
345
349
 
346
- # Codec and bloom filter of a re-encoded chunk follow the source chunk: the same codec
347
- # (unless +compression:+ was given; LZO cannot be written and becomes Snappy), and a bloom
348
- # filter when the source chunk had one
350
+ # Codec and bloom filter of a re-encoded chunk follow the source chunk (see
351
+ # Reader::ChunkCopier#recode_settings), unless +compression:+ was given
349
352
  #
350
353
  # @param i [Integer] row group index
351
354
  # @param col [Schema::Column] the input column
@@ -354,121 +357,19 @@ module Herringbone
354
357
  # @param blooms [Hash{Integer => Boolean}] collects output column index => true
355
358
  # @return [void]
356
359
  def chunk_settings(i, col, j, codecs, blooms)
357
- meta = @reader.row_groups[i].columns.fetch(col.index).meta_data
358
- return unless meta
359
- codecs[j] = (meta.codec == Format::Codec::LZO) ? Format::Codec::SNAPPY : meta.codec if @keep_codecs
360
- blooms[j] = true if meta.bloom_filter_offset
360
+ codec, bloom = @copier.recode_settings(i, col)
361
+ codecs[j] = codec if codec && @keep_codecs
362
+ blooms[j] = true if bloom
361
363
  end
362
364
 
363
- # The row group's sorting columns that still hold: dropped or changed columns end the list,
364
- # since the columns after them were only sorted within their runs
365
+ # The row group's sorting columns that still hold: dropped or changed columns end the list
365
366
  #
366
367
  # @param i [Integer] row group index
367
368
  # @param rewritten [Array<Integer>] indexes of input columns whose values changed
368
369
  # @return [Array<Format::SortingColumn>, nil]
369
370
  def sorting_columns(i, rewritten)
370
- out = []
371
- (@reader.row_groups[i].sorting_columns || []).each do |sc|
372
- col = @schema.columns[sc.column_idx]
373
- j = col && !rewritten.include?(col.index) && @output_columns.index(col)
374
- break unless j
375
- out << Format::SortingColumn.new(column_idx: j, descending: sc.descending, nulls_first: sc.nulls_first)
376
- end
377
- out.empty? ? nil : out
378
- end
379
-
380
- # @param i [Integer] row group index
381
- # @param col [Schema::Column] the input column
382
- # @return [Writer::CopiedChunk] the chunk's pages, page index and bloom filter
383
- # @raise [UnsupportedError] for an encrypted chunk or one stored in another file
384
- # @raise [FormatError] when a page header is corrupt or the chunk overruns the file
385
- def copied_chunk(i, col)
386
- chunk = @reader.row_groups[i].columns.fetch(col.index)
387
- meta = chunk.meta_data or raise UnsupportedError, "Column chunk without metadata (encrypted?)"
388
- raise UnsupportedError, "Column chunks in external files are not supported" if chunk.file_path
389
- column_index, offset_index = @reader.page_index(i, col)
390
- start, bytes = chunk_bytes(col, meta, offset_index)
391
- column_index &&= read_at(chunk.column_index_offset, chunk.column_index_length)
392
- Writer::CopiedChunk.new(chunk, start, bytes, column_index, offset_index, bloom_bytes(i, col, meta))
393
- end
394
-
395
- # The chunk's pages. They are walked header by header rather than trusting
396
- # total_compressed_size, which some writers get wrong: copying too little breaks the
397
- # chunk, and copying too much could carry over bytes of a neighbouring chunk.
398
- #
399
- # @param col [Schema::Column] the input column
400
- # @param meta [Format::ColumnMetaData] the chunk's metadata
401
- # @param offset_index [Format::OffsetIndex, nil] the chunk's OffsetIndex, whose pages are
402
- # included even when num_values is reached before them
403
- # @return [Array(Integer, String)] file offset of the first page, and the pages' bytes
404
- # @raise [FormatError] when a page header is corrupt or the chunk overruns the file
405
- def chunk_bytes(col, meta, offset_index)
406
- start = meta.data_page_offset
407
- dict = meta.dictionary_page_offset
408
- start = dict if dict&.positive? && dict < start
409
- last = offset_index&.page_locations&.last
410
- min_end = last ? last.offset + last.compressed_page_size - start : 0
411
- @io.seek(start)
412
- buf = (@io.read(meta.total_compressed_size) || "").b
413
- pos = 0
414
- seen = 0
415
- while seen < meta.num_values || pos < min_end
416
- begin
417
- header, body = Format::PageHeader.decode(buf, pos)
418
- rescue Thrift::Error
419
- raise FormatError, "Corrupt page header in #{col.dotted_path}" unless read_more(buf, start, READ_MORE)
420
- retry
421
- end
422
- size = header.compressed_page_size
423
- raise FormatError, "Negative page size in #{col.dotted_path}" if size.nil? || size.negative?
424
- short = body + size - buf.bytesize
425
- if short.positive? && read_more(buf, start, short) < short
426
- raise FormatError, "Column #{col.dotted_path}: page overruns the file"
427
- end
428
- pos = body + size
429
- seen += header.data_page_header&.num_values || header.data_page_header_v2&.num_values || 0
430
- end
431
- [start, (pos == buf.bytesize) ? buf : buf.byteslice(0, pos)]
432
- end
433
-
434
- # Appends up to +count+ bytes that follow +buf+ in the file
435
- #
436
- # @param buf [String] bytes read from +start+ on; appended to
437
- # @param start [Integer] file offset of +buf+
438
- # @param count [Integer] bytes wanted
439
- # @return [Integer] bytes appended, 0 at the end of the file
440
- def read_more(buf, start, count)
441
- @io.seek(start + buf.bytesize)
442
- more = @io.read(count)
443
- return 0 if more.nil?
444
- buf << more.b
445
- more.bytesize
446
- end
447
-
448
- # @param offset [Integer, nil] file offset
449
- # @param length [Integer, nil] byte count
450
- # @return [String, nil] the bytes, nil when absent or cut off
451
- def read_at(offset, length)
452
- return nil unless offset && length&.positive?
453
- @io.seek(offset)
454
- bytes = @io.read(length)
455
- (bytes&.bytesize == length) ? bytes.b : nil
456
- end
457
-
458
- # The chunk's bloom filter as stored. Filters written without a length (older writers) are
459
- # decoded and encoded again. A filter that cannot be read is left out, which only costs
460
- # pruning.
461
- #
462
- # @param i [Integer] row group index
463
- # @param col [Schema::Column] the input column
464
- # @param meta [Format::ColumnMetaData] the chunk's metadata
465
- # @return [String, nil] header and bitset
466
- def bloom_bytes(i, col, meta)
467
- return nil unless meta.bloom_filter_offset
468
- return read_at(meta.bloom_filter_offset, meta.bloom_filter_length) if meta.bloom_filter_length
469
- @reader.bloom_filter(i, col)&.encode
470
- rescue FormatError
471
- nil
371
+ kept = @output_columns.each_with_index.to_h { |col, j| [col.index, j] }
372
+ @copier.sorting_columns(i, kept.except(*rewritten))
472
373
  end
473
374
 
474
375
  # Reads top-level fields of row group +i+ into @data, unless they are there already
@@ -488,13 +389,6 @@ module Herringbone
488
389
  # @return [Integer] rows in the row group
489
390
  def rows(i) = @reader.row_groups[i].num_rows
490
391
 
491
- # @return [Hash{String => String, nil}] the input's key/value metadata, without the keys that
492
- # describe the columns when some are dropped
493
- def copied_metadata
494
- meta = @reader.metadata
495
- @drops.empty? ? meta : meta.except(*COLUMN_METADATA_KEYS)
496
- end
497
-
498
392
  # @param block [Proc] a replace block
499
393
  # @return [Boolean] whether the block takes the row as a second parameter
500
394
  def wants_row?(block)
@@ -11,8 +11,8 @@ module Herringbone
11
11
  # r.replace(:phone) { |phone| phone&.gsub(/\d(?=\d{3})/, "#") }
12
12
  # r.drop :ssn
13
13
  # end
14
- # File.open("in.parquet", "rb") do |input|
15
- # File.open("out.parquet", "wb") { |output| forget.apply(input, output) }
14
+ # File.open("in.parquet", "rb") do |io|
15
+ # File.open("out.parquet", "wb") { |output_io| forget.apply(io, output_io) }
16
16
  # end
17
17
  #
18
18
  # Statements apply in declared order, per row: a deleted row is gone for later statements, and
@@ -187,39 +187,48 @@ module Herringbone
187
187
  #
188
188
  # @param statement [Statement] the statement to add
189
189
  # @return [Redaction] self
190
+ # @api private
190
191
  def add_statement(statement)
191
192
  @statements << statement
192
193
  self
193
194
  end
194
195
 
195
- # Whether #apply would change +input_io+: true when a column is dropped, when a replace without
196
+ # Whether #apply would change +io_or_reader+: true when a column is dropped, when a replace without
196
197
  # #where meets a non-empty file, or when some row matches a #where. Reads as little as it can:
197
198
  # row groups are ruled out with statistics, bloom filters and the page index first, and only
198
199
  # the columns the conditions name are read for the rest.
199
200
  #
200
- # @param input_io [IO, StringIO] the Parquet file, read with #seek and #read
201
- # @param decryption [DecryptionConfiguration, Hash{Symbol => Object}, nil] keys of an encrypted file, see Reader.new
201
+ # @param io_or_reader [IO, StringIO, Reader] the Parquet file, read with #seek and #read, or a
202
+ # Reader of it, whose IO and decryption are used
203
+ # @param decryption [DecryptionConfiguration, Hash{Symbol => Object}, nil] keys of an encrypted
204
+ # file, see Reader.new; not with a Reader, which has its own
202
205
  # @return [Boolean]
203
- # @raise [ArgumentError] when the redaction does not fit the file's schema
206
+ # @raise [ArgumentError] when the redaction does not fit the file's schema, or a Reader comes
207
+ # with +decryption:+
204
208
  # @raise [DecryptionError] when the file is encrypted and a key it needs was not given
205
- def affects?(input_io, decryption: nil)
209
+ def affects?(io_or_reader, decryption: nil)
206
210
  check_scopes!
207
- Rewriter.new(self, input_io, decryption: decryption).affects?
211
+ Rewriter.new(self, io_or_reader, decryption: decryption).affects?
208
212
  end
209
213
 
210
- # Writes the redacted copy of +input_io+ to +output_io+. The redaction is checked against the
214
+ # Writes the redacted copy of +io_or_reader+ to +output_io+. The redaction is checked against the
211
215
  # file's schema first, so a missing column, a nil for a required column or a #where without a
212
216
  # verb raise before anything is written. The output is left unfinished (no footer) when an
213
217
  # error happens later, for instance a block returning a value its column cannot store.
214
218
  #
219
+ # A Reader is read with its own IO and decryption, but always with the default +keys:+ and
220
+ # +time_zone:+, whatever it was built with.
221
+ #
215
222
  # An encrypted input (opened with +decryption:+) is written encrypted the same way: same
216
223
  # algorithm, footer mode, AAD prefix, keys and key metadata. +encryption:+ (see Writer) writes
217
224
  # it with other settings, and +encryption: false+ writes a plaintext file. Column chunks that
218
225
  # are encrypted in either file are re-encoded rather than copied.
219
226
  #
220
- # @param input_io [IO, StringIO] the Parquet file, read with #seek and #read; not closed
227
+ # @param io_or_reader [IO, StringIO, Reader] the Parquet file, read with #seek and #read, or a
228
+ # Reader of it; not closed
221
229
  # @param output_io [IO, #write] destination, written sequentially; not closed
222
- # @param decryption [DecryptionConfiguration, Hash{Symbol => Object}, nil] keys of an encrypted input, see Reader.new
230
+ # @param decryption [DecryptionConfiguration, Hash{Symbol => Object}, nil] keys of an encrypted
231
+ # input, see Reader.new; not with a Reader, which has its own
223
232
  # @param writer_options [Hash{Symbol => Object}] Writer options for the re-encoded column chunks
224
233
  # (+compression:+, +bloom_filters:+, +page_rows:+, +dictionary:+...), and +metadata:+ to
225
234
  # replace the footer key/value metadata instead of copying it
@@ -235,13 +244,14 @@ module Herringbone
235
244
  # @option writer_options [EncryptionConfiguration, Hash{Symbol => Object}, false] :encryption (as the input) see Writer;
236
245
  # false for a plaintext output
237
246
  # @return [Report] what was done
238
- # @raise [ArgumentError] when the redaction does not fit the file's schema, or for a writer
239
- # option that does not apply (+row_group_bytes:+, +row_group_rows:+)
247
+ # @raise [ArgumentError] when the redaction does not fit the file's schema, for a writer
248
+ # option that does not apply (+row_group_bytes:+, +row_group_rows:+), or when a Reader comes
249
+ # with +decryption:+
240
250
  # @raise [EncodeError] when a replacement value cannot be written to its column
241
251
  # @raise [DecryptionError] when the input is encrypted and a key it needs was not given
242
- def apply(input_io, output_io, decryption: nil, **writer_options)
252
+ def apply(io_or_reader, output_io, decryption: nil, **writer_options)
243
253
  check_scopes!
244
- Rewriter.new(self, input_io, decryption: decryption).apply(output_io, **writer_options)
254
+ Rewriter.new(self, io_or_reader, decryption: decryption).apply(output_io, **writer_options)
245
255
  end
246
256
 
247
257
  # Short summary for the console: one clause per statement, naming the columns but not the
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Herringbone
4
+ # Wraps every IO Herringbone reads a Parquet file from, and lets the reading code use only
5
+ # #read, #seek, #pos and #size. Any object implementing #read and #seek can then be read from
6
+ # (a File, a StringIO, an object fetching ranges from S3), and the reading code cannot start
7
+ # depending on #readpartial, #pread, #eof? or anything else only some IOs have: what it needs
8
+ # beyond the four methods it has to build on top of them.
9
+ #
10
+ # #path is there only to name the file in messages and in Inspector output.
11
+ #
12
+ # reader = Herringbone::Reader.new(File.open("data.parquet", "rb"))
13
+ # reader.io # => #<Herringbone::RestrictedReadableIO data.parquet>
14
+ # reader.io.path # => "data.parquet"
15
+ class RestrictedReadableIO
16
+ # What #path returns when the IO has no path of its own (a StringIO, a socket-backed IO...)
17
+ UNTITLED = "untitled.parquet"
18
+
19
+ # Wraps +io+, unless it is already wrapped
20
+ #
21
+ # @param io [IO, StringIO, RestrictedReadableIO, #read] anything responding to #read and #seek
22
+ # @return [RestrictedReadableIO] +io+ itself when it is one, a new wrapper of it otherwise
23
+ # @raise [ArgumentError] when +io+ does not respond to #read and #seek
24
+ def self.wrap(io)
25
+ io.is_a?(self) ? io : new(io)
26
+ end
27
+
28
+ # @param io [IO, StringIO, #read] anything responding to #read and #seek
29
+ # @raise [ArgumentError] when +io+ does not respond to #read and #seek
30
+ def initialize(io)
31
+ unless io.respond_to?(:read) && io.respond_to?(:seek)
32
+ raise ArgumentError, "#{self.class} wraps an IO that supports #seek and #read, got #{io.class}"
33
+ end
34
+ @io = io
35
+ end
36
+
37
+ # Reads up to +n_bytes+ from the current position
38
+ #
39
+ # @param n_bytes [Integer] bytes wanted
40
+ # @return [String, nil] at most +n_bytes+, fewer when the end of the IO comes first; nil at the end
41
+ def read(n_bytes)
42
+ @io.read(n_bytes)
43
+ end
44
+
45
+ # Moves to +offset+ bytes from the start of the IO
46
+ #
47
+ # @param offset [Integer] absolute position
48
+ # @return [Integer] 0, as IO#seek returns
49
+ def seek(offset)
50
+ @io.seek(offset)
51
+ 0
52
+ end
53
+
54
+ # @return [Integer] the current position, in bytes from the start of the IO
55
+ def pos
56
+ @io.pos
57
+ end
58
+
59
+ # Size of the IO in bytes, asked of the IO when it can tell (File, StringIO, Tempfile...) and
60
+ # found by seeking to its end otherwise
61
+ #
62
+ # @return [Integer] the size in bytes
63
+ def size
64
+ return @io.size if @io.respond_to?(:size)
65
+ at = @io.pos
66
+ begin
67
+ @io.seek(0, IO::SEEK_END)
68
+ @io.pos
69
+ ensure
70
+ @io.seek(at)
71
+ end
72
+ end
73
+
74
+ # @return [String] the path of the IO (File#path, Tempfile#path), or UNTITLED when it has none
75
+ def path
76
+ path = @io.path if @io.respond_to?(:path)
77
+ path ? path.to_s : UNTITLED
78
+ end
79
+
80
+ # @return [String] e.g. "#<Herringbone::RestrictedReadableIO data.parquet>"
81
+ def inspect
82
+ "#<#{self.class.name} #{path}>"
83
+ end
84
+ end
85
+ end