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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +45 -0
- data/MANUAL.md +196 -17
- data/README.md +22 -5
- data/bin/herringbone +56 -1
- data/lib/herringbone/active_record.rb +3 -1
- data/lib/herringbone/bloom_filter.rb +2 -0
- data/lib/herringbone/byte_values.rb +2 -0
- data/lib/herringbone/codecs/lz4.rb +9 -4
- data/lib/herringbone/codecs/lzo.rb +2 -0
- data/lib/herringbone/codecs/snappy.rb +48 -6
- data/lib/herringbone/combiner.rb +426 -0
- data/lib/herringbone/compression.rb +76 -13
- data/lib/herringbone/encodings/delta.rb +4 -0
- data/lib/herringbone/encodings/plain.rb +2 -0
- data/lib/herringbone/encodings/rle.rb +2 -0
- data/lib/herringbone/encryption.rb +2 -0
- data/lib/herringbone/inferring_writer.rb +1 -1
- data/lib/herringbone/inspector.rb +29 -5
- data/lib/herringbone/io_buffer_support.rb +2 -0
- data/lib/herringbone/reader/bloom_filters.rb +1 -0
- data/lib/herringbone/reader/chunk_copier.rb +177 -0
- data/lib/herringbone/reader/column_chunk_reader.rb +6 -2
- data/lib/herringbone/reader/column_cursor.rb +2 -0
- data/lib/herringbone/reader/numo.rb +4 -0
- data/lib/herringbone/reader/page_stream.rb +2 -0
- data/lib/herringbone/reader/scan.rb +2 -0
- data/lib/herringbone/reader.rb +19 -6
- data/lib/herringbone/redaction/rewriter.rb +51 -157
- data/lib/herringbone/redaction.rb +25 -15
- data/lib/herringbone/restricted_readable_io.rb +85 -0
- data/lib/herringbone/schema/merge.rb +355 -0
- data/lib/herringbone/schema.rb +175 -12
- data/lib/herringbone/thrift.rb +49 -10
- data/lib/herringbone/types.rb +22 -12
- data/lib/herringbone/version.rb +1 -1
- data/lib/herringbone/visualizer.rb +2 -0
- data/lib/herringbone/writer.rb +58 -22
- data/lib/herringbone/xxhash.rb +2 -0
- data/lib/herringbone.rb +96 -9
- 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
|
|
37
|
-
#
|
|
38
|
-
# @
|
|
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,
|
|
42
|
-
@
|
|
43
|
-
@
|
|
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
|
|
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(
|
|
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:
|
|
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
|
-
|
|
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,
|
|
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] =
|
|
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
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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
|
|
347
|
-
#
|
|
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
|
-
|
|
358
|
-
|
|
359
|
-
|
|
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
|
-
|
|
371
|
-
|
|
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 |
|
|
15
|
-
# File.open("out.parquet", "wb") { |
|
|
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 +
|
|
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
|
|
201
|
-
#
|
|
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?(
|
|
209
|
+
def affects?(io_or_reader, decryption: nil)
|
|
206
210
|
check_scopes!
|
|
207
|
-
Rewriter.new(self,
|
|
211
|
+
Rewriter.new(self, io_or_reader, decryption: decryption).affects?
|
|
208
212
|
end
|
|
209
213
|
|
|
210
|
-
# Writes the redacted copy of +
|
|
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
|
|
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
|
|
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,
|
|
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(
|
|
252
|
+
def apply(io_or_reader, output_io, decryption: nil, **writer_options)
|
|
243
253
|
check_scopes!
|
|
244
|
-
Rewriter.new(self,
|
|
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
|