herringbone 0.6.0 → 0.7.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 (40) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +40 -0
  3. data/MANUAL.md +161 -12
  4. data/README.md +19 -4
  5. data/bin/herringbone +56 -1
  6. data/lib/herringbone/active_record.rb +2 -0
  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 +53 -9
  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/inspector.rb +29 -5
  19. data/lib/herringbone/io_buffer_support.rb +2 -0
  20. data/lib/herringbone/reader/bloom_filters.rb +1 -0
  21. data/lib/herringbone/reader/chunk_copier.rb +177 -0
  22. data/lib/herringbone/reader/column_chunk_reader.rb +4 -2
  23. data/lib/herringbone/reader/column_cursor.rb +2 -0
  24. data/lib/herringbone/reader/numo.rb +4 -0
  25. data/lib/herringbone/reader/page_stream.rb +2 -0
  26. data/lib/herringbone/reader/scan.rb +2 -0
  27. data/lib/herringbone/reader.rb +19 -6
  28. data/lib/herringbone/redaction/rewriter.rb +51 -157
  29. data/lib/herringbone/redaction.rb +25 -15
  30. data/lib/herringbone/restricted_readable_io.rb +85 -0
  31. data/lib/herringbone/schema/merge.rb +355 -0
  32. data/lib/herringbone/schema.rb +80 -0
  33. data/lib/herringbone/thrift.rb +166 -68
  34. data/lib/herringbone/types.rb +2 -0
  35. data/lib/herringbone/version.rb +1 -1
  36. data/lib/herringbone/visualizer.rb +2 -0
  37. data/lib/herringbone/writer.rb +58 -22
  38. data/lib/herringbone/xxhash.rb +2 -0
  39. data/lib/herringbone.rb +96 -9
  40. metadata +6 -2
@@ -29,6 +29,8 @@ module Herringbone
29
29
  WINDOW = 64 * 1024
30
30
 
31
31
  # Just enough of parquet.thrift's BloomFilterHeader to learn the filter's size
32
+ #
33
+ # @api private
32
34
  class BloomFilterHeader < Thrift::Struct
33
35
  field 1, :num_bytes, :i32
34
36
  end
@@ -484,13 +486,13 @@ module Herringbone
484
486
  # @raise [FormatError] when the file is too small, lacks the magic bytes or has a corrupt footer
485
487
  # @raise [DecryptionError] when the footer is encrypted and cannot be decrypted
486
488
  def initialize(io, decryption: nil)
487
- @io = io
488
489
  @decryption = decryption.nil? ? nil : DecryptionConfiguration.from(decryption)
489
- unless @io.respond_to?(:read) && @io.respond_to?(:seek)
490
+ unless io.respond_to?(:read) && io.respond_to?(:seek)
490
491
  raise ArgumentError, "Herringbone::Inspector expects an IO that supports #seek and #read " \
491
492
  "(e.g. File.open(path, \"rb\")), got #{io.class}"
492
493
  end
493
- @name = (@io.respond_to?(:path) && @io.path) ? File.basename(@io.path.to_s) : nil
494
+ @io = RestrictedReadableIO.wrap(io)
495
+ @name = File.basename(@io.path) unless @io.path.equal?(RestrictedReadableIO::UNTITLED)
494
496
  read_footer
495
497
  @schema = Schema.from_elements(@metadata.schema)
496
498
  end
@@ -907,6 +909,7 @@ module Herringbone
907
909
  # Decryption of an encrypted chunk's modules
908
910
  # @param chunk [ColumnChunkInfo] an encrypted chunk
909
911
  # @return [Encryption::ModuleCrypto, nil] nil when the chunk's key was not given
912
+ # @api private
910
913
  def chunk_crypto(chunk)
911
914
  @decryptor&.chunk(chunk.row_group.index, chunk.row_group.row_group.ordinal, chunk.column, chunk.chunk)
912
915
  rescue DecryptionError
@@ -918,6 +921,7 @@ module Herringbone
918
921
  # @param chunk [ColumnChunkInfo] chunk whose pages to walk
919
922
  # @return [Array(Array<PageInfo>, String), Array(Array<PageInfo>, nil)] the pages found, and why
920
923
  # the walk stopped early (nil when every value was accounted for)
924
+ # @api private
921
925
  def walk_pages(chunk)
922
926
  return [[], "column chunk stored in external file #{chunk.external_file}"] if chunk.external_file
923
927
  if chunk.encrypted?
@@ -959,6 +963,7 @@ module Herringbone
959
963
  # Reads and decodes a chunk's ColumnIndex, decoding the per-page min/max with the column type
960
964
  # @param chunk [ColumnChunkInfo] chunk whose ColumnIndex to read
961
965
  # @return [ColumnIndexInfo, nil] nil when the chunk has none or it fails to decode
966
+ # @api private
962
967
  def read_column_index(chunk)
963
968
  offset, length = chunk.column_index_range
964
969
  return nil unless offset && length.positive?
@@ -985,6 +990,7 @@ module Herringbone
985
990
  # Reads and decodes a chunk's OffsetIndex
986
991
  # @param chunk [ColumnChunkInfo] chunk whose OffsetIndex to read
987
992
  # @return [OffsetIndexInfo, nil] nil when the chunk has none or it fails to decode
993
+ # @api private
988
994
  def read_offset_index(chunk)
989
995
  offset, length = chunk.offset_index_range
990
996
  return nil unless offset && length.positive?
@@ -1006,6 +1012,7 @@ module Herringbone
1006
1012
  # @param offset [Integer] file offset of the BloomFilterHeader
1007
1013
  # @param encrypted [Boolean] whether the filter is encrypted
1008
1014
  # @return [Integer, nil] nil when the header can't be decoded or has no num_bytes
1015
+ # @api private
1009
1016
  def bloom_filter_size(offset, encrypted = false)
1010
1017
  if encrypted
1011
1018
  header = read_at(offset, 4).unpack1("V") or return nil
@@ -1022,6 +1029,7 @@ module Herringbone
1022
1029
  # :ok, :mismatch or :absent for one page (reads its body). Sets PageInfo#actual_crc.
1023
1030
  # @param page [PageInfo] page to check
1024
1031
  # @return [Symbol]
1032
+ # @api private
1025
1033
  def page_checksum(page)
1026
1034
  return :absent unless page.crc
1027
1035
  page.actual_crc = page_crc(page)
@@ -1031,6 +1039,7 @@ module Herringbone
1031
1039
  # CRC32 of a page's body as stored
1032
1040
  # @param page [PageInfo] page whose compressed body to read
1033
1041
  # @return [Integer] unsigned 32-bit CRC
1042
+ # @api private
1034
1043
  def page_crc(page)
1035
1044
  Zlib.crc32(read_at(page.body_offset, page.compressed_size))
1036
1045
  end
@@ -1039,6 +1048,7 @@ module Herringbone
1039
1048
  # @param chunk [ColumnChunkInfo] chunk whose page headers to compare with its ColumnIndex
1040
1049
  # @return [Array<Hash{Symbol => Object}>] one entry per disagreement; a single :page_count
1041
1050
  # entry when the ColumnIndex and the data pages differ in number
1051
+ # @api private
1042
1052
  def compare_page_index(chunk)
1043
1053
  ci = chunk.column_index or return []
1044
1054
  data = chunk.data_pages
@@ -1072,6 +1082,7 @@ module Herringbone
1072
1082
  # @param st [Format::Statistics, nil] statistics from column metadata or a page header
1073
1083
  # @param column [Schema::Column] column the statistics describe
1074
1084
  # @return [Stats, nil] nil when +st+ is nil
1085
+ # @api private
1075
1086
  def decode_statistics(st, column)
1076
1087
  return nil unless st
1077
1088
  order = Inspector.sort_order(column)
@@ -1111,6 +1122,7 @@ module Herringbone
1111
1122
  # @param column [Schema::Column] column whose physical type and converter apply
1112
1123
  # @return [Object, nil] the converted value, a hex String when it could not be decoded, nil
1113
1124
  # for nil (or empty BOOLEAN) input
1125
+ # @api private
1114
1126
  def decode_value(bytes, column)
1115
1127
  return nil if bytes.nil?
1116
1128
  bytes = bytes.b
@@ -1158,6 +1170,8 @@ module Herringbone
1158
1170
 
1159
1171
  # A minimal flatbuffer reader: tables (through their vtables), scalars, strings, vectors of
1160
1172
  # scalars and tables, and unions. Every read is bounds-checked; malformed input raises Error.
1173
+ #
1174
+ # @api private
1161
1175
  class FlatBuffer
1162
1176
  # @param bytes [String] the flatbuffer (copied as binary)
1163
1177
  def initialize(bytes)
@@ -1241,6 +1255,8 @@ module Herringbone
1241
1255
  end
1242
1256
 
1243
1257
  # One flatbuffer table; fields are addressed by their slot (declaration order in the .fbs)
1258
+ #
1259
+ # @api private
1244
1260
  class Table
1245
1261
  # @param fb [FlatBuffer] buffer the table lives in
1246
1262
  # @param pos [Integer] absolute position of the table (where its vtable offset is stored)
@@ -1491,11 +1507,13 @@ module Herringbone
1491
1507
 
1492
1508
  # @param e [Integer] Parquet Encoding value
1493
1509
  # @return [String] its name, e.g. "RLE_DICTIONARY" (the number as a String when unknown)
1510
+ # @api private
1494
1511
  def self.encoding_name(e) = Format::Encoding::NAMES[e]&.to_s || e.to_s
1495
1512
 
1496
1513
  # @param column [Schema::Column] leaf column
1497
1514
  # @return [String] physical type with its logical annotation, e.g. "BYTE_ARRAY STRING" or
1498
1515
  # "FIXED_LEN_BYTE_ARRAY(16) UUID"
1516
+ # @api private
1499
1517
  def self.type_name(column)
1500
1518
  node = column.node
1501
1519
  phys = T::NAMES[node.type].to_s
@@ -1508,6 +1526,7 @@ module Herringbone
1508
1526
  # @param node [Schema::Node] schema node (leaf or group)
1509
1527
  # @return [String, nil] e.g. "INTEGER(8, unsigned)", "TIMESTAMP(MICROS, UTC)" or "DECIMAL(10, 2)";
1510
1528
  # nil when the node has no annotation
1529
+ # @api private
1511
1530
  def self.logical_type_name(node)
1512
1531
  if (kind = node.logical_type&.kind)
1513
1532
  name, payload = kind
@@ -1528,6 +1547,7 @@ module Herringbone
1528
1547
  # :signed, :unsigned or :unknown, per the Parquet sort order rules for the column's type
1529
1548
  # @param column [Schema::Column] leaf column
1530
1549
  # @return [Symbol]
1550
+ # @api private
1531
1551
  def self.sort_order(column)
1532
1552
  kind, _a, signed = Types.logical_of(column.node)
1533
1553
  case kind
@@ -1547,6 +1567,7 @@ module Herringbone
1547
1567
  # Recurses into Hashes, Arrays and Structs; Hash keys other than Symbols become Strings.
1548
1568
  # @param v [Object] value to convert
1549
1569
  # @return [Hash, Array, String, Integer, Float, Boolean, nil]
1570
+ # @api private
1550
1571
  def self.jsonable(v)
1551
1572
  case v
1552
1573
  when Hash then v.each_with_object({}) { |(k, x), h| h[k.is_a?(Symbol) ? k : k.to_s] = jsonable(x) }
@@ -1567,6 +1588,7 @@ module Herringbone
1567
1588
  # Strings already tagged as valid UTF-8 are returned as they are, control characters and all.
1568
1589
  # @param s [String] string in any encoding
1569
1590
  # @return [String]
1591
+ # @api private
1570
1592
  def self.text(s)
1571
1593
  return s if s.encoding == Encoding::UTF_8 && s.valid_encoding?
1572
1594
  u = s.dup.force_encoding(Encoding::UTF_8)
@@ -1577,6 +1599,7 @@ module Herringbone
1577
1599
  # @param bytes [String] bytes to show
1578
1600
  # @return [String] "0x" and lowercase hex digits; only the first 64 bytes, followed by the
1579
1601
  # total size, for longer input
1602
+ # @api private
1580
1603
  def self.hex(bytes)
1581
1604
  b = bytes.b
1582
1605
  (b.bytesize > 64) ? "0x#{b.byteslice(0, 64).unpack1("H*")}… (#{b.bytesize} bytes)" : "0x#{b.unpack1("H*")}"
@@ -1587,6 +1610,7 @@ module Herringbone
1587
1610
  # @param v [Object] decoded value
1588
1611
  # @param max [Integer] longest result, in characters; longer ones are cut and end in an ellipsis
1589
1612
  # @return [String]
1613
+ # @api private
1590
1614
  def self.display(v, max: 40)
1591
1615
  s = case v
1592
1616
  when String then (v.encoding == Encoding::BINARY) ? text(v) : v
@@ -1599,6 +1623,7 @@ module Herringbone
1599
1623
 
1600
1624
  # @param n [Integer, nil] byte count
1601
1625
  # @return [String] e.g. "512 B", "1.50 KB" or "12.3 MB" (binary units); "?" for nil
1626
+ # @api private
1602
1627
  def self.human_bytes(n)
1603
1628
  return "?" unless n
1604
1629
  units = %w[B KB MB GB TB]
@@ -1710,8 +1735,7 @@ module Herringbone
1710
1735
  # @raise [FormatError] when the file is too small, lacks the magic bytes or has a corrupt footer
1711
1736
  # @raise [DecryptionError] when the footer is encrypted and cannot be decrypted
1712
1737
  def read_footer
1713
- @io.seek(0, IO::SEEK_END)
1714
- @file_size = @io.pos
1738
+ @file_size = @io.size
1715
1739
  raise FormatError, "File too small to be Parquet (#{@file_size} bytes)" if @file_size < 12
1716
1740
  tail = read_at(@file_size - 8, 8)
1717
1741
  magic = tail.byteslice(4, 4)
@@ -4,6 +4,8 @@ module Herringbone
4
4
  # IO::Buffer lets the decompressors copy bytes around without allocating a String per copy.
5
5
  # It still carries an "experimental" warning, which is silenced once here: the gem only uses
6
6
  # new/for/copy/get_string/free, and falls back to String operations where it is missing.
7
+ #
8
+ # @api private
7
9
  module IOBufferSupport
8
10
  # true when IO::Buffer has the methods the decompressors use and HERRINGBONE_NO_IO_BUFFER is
9
11
  # not set in the environment (which forces the String fallback)
@@ -14,6 +14,7 @@ module Herringbone
14
14
  # @raise [FormatError] when the filter is truncated or its header cannot be decoded
15
15
  # @raise [DecryptionError] when the column is encrypted and its key was not given, or the
16
16
  # filter does not decrypt
17
+ # @api private
17
18
  def bloom_filter(row_group_index, column)
18
19
  col = bloom_filter_column(column)
19
20
  rg = row_groups.fetch(row_group_index) { raise IndexError, "No row group #{row_group_index}" }
@@ -0,0 +1,177 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Herringbone
4
+ class Reader
5
+ # Internal (used by Redaction and Herringbone.combine): takes plaintext column chunks out of a
6
+ # file as they are stored, for Writer#write_row_group to copy into another file, and tells how
7
+ # a chunk that has to be encoded again should be compressed.
8
+ #
9
+ # @api private
10
+ class ChunkCopier
11
+ # Bytes read at a time when a page header runs past what was read of a chunk
12
+ READ_MORE = 64 * 1024
13
+ # Footer metadata that describes the columns, and goes stale when the schema changes
14
+ COLUMN_METADATA_KEYS = %w[ARROW:schema pandas].freeze
15
+
16
+ # @param reader [Reader] the file, whose IO the chunks are read from
17
+ def initialize(reader)
18
+ @reader = reader
19
+ @io = reader.io
20
+ end
21
+
22
+ # @param i [Integer] row group index
23
+ # @param col [Schema::Column] the column, in the file's schema
24
+ # @return [Writer::CopiedChunk] the chunk's pages, page index and bloom filter
25
+ # @raise [UnsupportedError] for an encrypted chunk or one stored in another file
26
+ # @raise [FormatError] when a page header is corrupt or the chunk overruns the file
27
+ def copy(i, col)
28
+ chunk = @reader.row_groups[i].columns.fetch(col.index)
29
+ meta = chunk.meta_data or raise UnsupportedError, "Column chunk without metadata (encrypted?)"
30
+ raise UnsupportedError, "Column chunks in external files are not supported" if chunk.file_path
31
+ column_index, offset_index = @reader.page_index(i, col)
32
+ start, bytes = chunk_bytes(col, meta, offset_index)
33
+ column_index &&= read_at(chunk.column_index_offset, chunk.column_index_length)
34
+ Writer::CopiedChunk.new(chunk, start, bytes, column_index, offset_index, bloom_bytes(i, col, meta))
35
+ end
36
+
37
+ # How a chunk encoded again follows the source chunk: the same codec (LZO cannot be written
38
+ # and becomes Snappy), and a bloom filter when the source chunk had one
39
+ #
40
+ # @param i [Integer] row group index
41
+ # @param col [Schema::Column] the column, in the file's schema
42
+ # @return [Array(Integer, Boolean), nil] codec id and whether to write a bloom filter, nil when
43
+ # the chunk's metadata is not readable
44
+ def recode_settings(i, col)
45
+ meta = @reader.row_groups[i].columns.fetch(col.index).meta_data
46
+ return nil unless meta
47
+ [(meta.codec == Format::Codec::LZO) ? Format::Codec::SNAPPY : meta.codec, !meta.bloom_filter_offset.nil?]
48
+ end
49
+
50
+ # The encrypted columns of a file encrypted like this one, as the +columns:+ of its
51
+ # EncryptionConfiguration: those encrypted with the footer key as +:footer+, the others
52
+ # with their key and key metadata
53
+ #
54
+ # @param sources [Hash{String => Schema::Column}] the new file's column path => the column
55
+ # here that holds its values
56
+ # @return [Hash{String => Symbol, Hash}] the encrypted ones of +sources+
57
+ # @raise [DecryptionError] when the key of an encrypted column was not given
58
+ def encrypted_columns(sources)
59
+ decryptor = @reader.decryptor or return {}
60
+ chunks = @reader.row_groups.first&.columns || []
61
+ sources.filter_map do |path, col|
62
+ chunk = chunks[col.index]
63
+ crypto = chunk&.crypto_metadata or next
64
+ with_column_key = crypto.encryption_with_column_key
65
+ next [path, :footer] unless with_column_key
66
+ key = decryptor.chunk_key(chunk, col.dotted_path)
67
+ key or raise DecryptionError, "The output is encrypted like the input, which needs the key of #{col.dotted_path}: " \
68
+ "pass it in decryption:, or pass encryption: for the output"
69
+ [path, {key: key, key_metadata: with_column_key.key_metadata}]
70
+ end.to_h
71
+ end
72
+
73
+ # The row group's sorting columns, pointed at the new file's columns. A column missing from
74
+ # +output_index+ ends the list, since the columns after it were only sorted within its runs.
75
+ #
76
+ # @param i [Integer] row group index
77
+ # @param output_index [Hash{Integer => Integer}] column index here => column index in the new
78
+ # file, for the columns whose values the new file holds as they are
79
+ # @return [Array<Format::SortingColumn>, nil]
80
+ def sorting_columns(i, output_index)
81
+ out = (@reader.row_groups[i].sorting_columns || []).each_with_object([]) do |sc, acc|
82
+ j = output_index[sc.column_idx] or break acc
83
+ acc << Format::SortingColumn.new(column_idx: j, descending: sc.descending, nulls_first: sc.nulls_first)
84
+ end
85
+ out.empty? ? nil : out
86
+ end
87
+
88
+ # @param schema [Schema] the new file's schema
89
+ # @return [Hash{String => String, nil}] the file's key/value metadata, without the keys that
90
+ # describe the columns when +schema+ is another
91
+ def metadata(schema)
92
+ (@reader.schema == schema) ? @reader.metadata : @reader.metadata.except(*COLUMN_METADATA_KEYS)
93
+ end
94
+
95
+ private
96
+
97
+ # The chunk's pages. They are walked header by header rather than trusting
98
+ # total_compressed_size, which some writers get wrong: copying too little breaks the
99
+ # chunk, and copying too much could carry over bytes of a neighbouring chunk.
100
+ #
101
+ # @param col [Schema::Column] the column
102
+ # @param meta [Format::ColumnMetaData] the chunk's metadata
103
+ # @param offset_index [Format::OffsetIndex, nil] the chunk's OffsetIndex, whose pages are
104
+ # included even when num_values is reached before them
105
+ # @return [Array(Integer, String)] file offset of the first page, and the pages' bytes
106
+ # @raise [FormatError] when a page header is corrupt or the chunk overruns the file
107
+ def chunk_bytes(col, meta, offset_index)
108
+ start = meta.data_page_offset
109
+ dict = meta.dictionary_page_offset
110
+ start = dict if dict&.positive? && dict < start
111
+ last = offset_index&.page_locations&.last
112
+ min_end = last ? last.offset + last.compressed_page_size - start : 0
113
+ @io.seek(start)
114
+ buf = (@io.read(meta.total_compressed_size) || "").b
115
+ pos = 0
116
+ seen = 0
117
+ while seen < meta.num_values || pos < min_end
118
+ begin
119
+ header, body = Format::PageHeader.decode(buf, pos)
120
+ rescue Thrift::Error
121
+ raise FormatError, "Corrupt page header in #{col.dotted_path}" if read_more(buf, start, READ_MORE).zero?
122
+ retry
123
+ end
124
+ size = header.compressed_page_size
125
+ raise FormatError, "Negative page size in #{col.dotted_path}" if size.nil? || size.negative?
126
+ short = body + size - buf.bytesize
127
+ if short.positive? && read_more(buf, start, short) < short
128
+ raise FormatError, "Column #{col.dotted_path}: page overruns the file"
129
+ end
130
+ pos = body + size
131
+ seen += header.data_page_header&.num_values || header.data_page_header_v2&.num_values || 0
132
+ end
133
+ [start, (pos == buf.bytesize) ? buf : buf.byteslice(0, pos)]
134
+ end
135
+
136
+ # Appends up to +count+ bytes that follow +buf+ in the file
137
+ #
138
+ # @param buf [String] bytes read from +start+ on; appended to
139
+ # @param start [Integer] file offset of +buf+
140
+ # @param count [Integer] bytes wanted
141
+ # @return [Integer] bytes appended, 0 at the end of the file
142
+ def read_more(buf, start, count)
143
+ @io.seek(start + buf.bytesize)
144
+ more = @io.read(count)
145
+ return 0 if more.nil?
146
+ buf << more.b
147
+ more.bytesize
148
+ end
149
+
150
+ # @param offset [Integer, nil] file offset
151
+ # @param length [Integer, nil] byte count
152
+ # @return [String, nil] the bytes, nil when absent or cut off
153
+ def read_at(offset, length)
154
+ return nil unless offset && length&.positive?
155
+ @io.seek(offset)
156
+ bytes = @io.read(length)
157
+ (bytes&.bytesize == length) ? bytes.b : nil
158
+ end
159
+
160
+ # The chunk's bloom filter as stored. Filters written without a length (older writers) are
161
+ # decoded and encoded again. A filter that cannot be read is left out, which only costs
162
+ # pruning.
163
+ #
164
+ # @param i [Integer] row group index
165
+ # @param col [Schema::Column] the column
166
+ # @param meta [Format::ColumnMetaData] the chunk's metadata
167
+ # @return [String, nil] header and bitset
168
+ def bloom_bytes(i, col, meta)
169
+ return nil unless meta.bloom_filter_offset
170
+ return read_at(meta.bloom_filter_offset, meta.bloom_filter_length) if meta.bloom_filter_length
171
+ @reader.bloom_filter(i, col)&.encode
172
+ rescue FormatError
173
+ nil
174
+ end
175
+ end
176
+ end
177
+ end
@@ -13,6 +13,8 @@ module Herringbone
13
13
  #
14
14
  # The declared total_compressed_size of the chunk is not relied on (some old writers
15
15
  # under-report it): pages are read until the chunk's num_values have been seen.
16
+ #
17
+ # @api private
16
18
  class ColumnChunkReader
17
19
  # Shorthand for the physical type constants
18
20
  T = Format::Type
@@ -37,7 +39,7 @@ module Herringbone
37
39
  # @return [Proc, nil] converter of the last page returned by #next_page, nil when none
38
40
  attr_reader :page_converter
39
41
 
40
- # @param io [IO, StringIO] the file, read with #seek and #read
42
+ # @param io [IO, StringIO, RestrictedReadableIO] the file, read with #seek and #read
41
43
  # @param chunk [Format::ColumnChunk] the chunk's footer entry
42
44
  # @param column [Schema::Column] the leaf column the chunk stores
43
45
  # @param converter [Proc, nil] physical value => Ruby value, applied to values and dictionaries
@@ -46,7 +48,7 @@ module Herringbone
46
48
  # encrypted column
47
49
  # @raise [UnsupportedError] for chunks without metadata (encrypted) or stored in another file
48
50
  def initialize(io, chunk, column, converter: column.converter, lazy: false, crypto: nil)
49
- @io = io
51
+ @io = RestrictedReadableIO.wrap(io)
50
52
  @chunk = chunk
51
53
  @column = column
52
54
  @meta = chunk.meta_data or raise UnsupportedError, "Column chunk without metadata (encrypted?)"
@@ -7,6 +7,8 @@ module Herringbone
7
7
  # entries of the requested rows (plus a small lookahead of levels for repeated columns)
8
8
  # become Ruby objects. A row starts at an entry with repetition level 0 and may continue
9
9
  # over any number of following pages.
10
+ #
11
+ # @api private
10
12
  class ColumnCursor
11
13
  # Levels decoded ahead at a time when looking for row starts in repeated columns
12
14
  LOOKAHEAD = 4096
@@ -20,6 +20,8 @@ module Herringbone
20
20
  # list<number>, all rows of the 2-D [rows, length] of the element type
21
21
  # same length and no nulls
22
22
  # anything else RObject of the values as: :rows returns
23
+ #
24
+ # @api private
23
25
  module NumoColumns
24
26
  # Whether the host is little-endian, so PLAIN bytes (always little-endian) can be handed to
25
27
  # Numo's from_binary, which reads native byte order.
@@ -224,6 +226,8 @@ module Herringbone
224
226
  # Hands out the next +k+ rows of a flat (non-repeated) numeric or boolean column as a
225
227
  # [Numo values, validity] pair: values has one slot per row (zero where the row is null),
226
228
  # validity is a Numo::Bit or nil when all rows are present.
229
+ #
230
+ # @api private
227
231
  class NumoCursor
228
232
  # @param chunk_reader [ColumnChunkReader] reader for the column's chunk in one row group
229
233
  # @param spec [NumoColumns::Spec] a :fixed spec with a bin_klass
@@ -5,6 +5,8 @@ module Herringbone
5
5
  # Incremental decoders for the contents of one data page. The page bytes are decoded as they
6
6
  # are asked for, so a caller that takes a few hundred entries at a time never holds a whole
7
7
  # page's worth of levels or values as Ruby objects.
8
+ #
9
+ # @api private
8
10
  module PageStream
9
11
  # Values unpacked from a bit-packed run at a time (a multiple of 8)
10
12
  CHUNK = 1024
@@ -16,6 +16,8 @@ module Herringbone
16
16
  # bloom filters; within a row group, pages are ruled out with the page index (ColumnIndex +
17
17
  # OffsetIndex), leaving ranges of rows to read; and every row that is read is checked, so
18
18
  # results are exact.
19
+ #
20
+ # @api private
19
21
  class Filter
20
22
  # One where: entry, resolved against the schema
21
23
  #
@@ -44,9 +44,16 @@ module Herringbone
44
44
  # @return [Format::FileMetaData] the file's FileMetaData (the decoded Thrift footer)
45
45
  attr_reader :file_metadata
46
46
 
47
- # +io+ must support #seek and #read (a File opened with "rb", StringIO, Tempfile...)
47
+ # @return [RestrictedReadableIO] the IO the file is read from, as given to Reader.new, wrapped
48
+ attr_reader :io
49
+
50
+ # @return [DecryptionConfiguration, nil] the keys given as +decryption:+, nil without them
51
+ attr_reader :decryption
52
+
53
+ # +io+ must support #seek and #read (a File opened with "rb", StringIO, Tempfile...). It is
54
+ # read only through a RestrictedReadableIO wrapping it.
48
55
  #
49
- # @param io [IO, StringIO] random-access source of the Parquet bytes; the caller closes it
56
+ # @param io [IO, StringIO, RestrictedReadableIO] random-access source of the Parquet bytes; the caller closes it
50
57
  # @param keys [Symbol, String] +:string+ or +:symbol+, the key type of row and struct Hashes
51
58
  # @param time_zone [String, Integer, Object, nil] zone timestamps are returned in (see the
52
59
  # class docs); nil keeps them in UTC
@@ -74,7 +81,7 @@ module Herringbone
74
81
  @symbolize = keys == :symbol
75
82
  @zone_converter = zone_converter(time_zone)
76
83
  @decryption = decryption.nil? ? nil : DecryptionConfiguration.from(decryption)
77
- @io = io
84
+ @io = RestrictedReadableIO.wrap(io)
78
85
  @file_metadata = read_footer
79
86
  @schema = Schema.from_elements(@file_metadata.schema)
80
87
  end
@@ -301,6 +308,7 @@ module Herringbone
301
308
  # @return [Array(Format::ColumnIndex, Format::OffsetIndex)] either element may be nil
302
309
  # @raise [ArgumentError] when +column+ does not name a leaf column
303
310
  # @raise [IndexError] when the row group does not exist
311
+ # @api private
304
312
  def page_index(row_group_index, column)
305
313
  column = @schema.column(column) unless column.is_a?(Schema::Column)
306
314
  raise ArgumentError, "No such leaf column" unless column
@@ -328,9 +336,10 @@ module Herringbone
328
336
  # checked) and +:columns+
329
337
  def encryption = @decryptor&.describe(@schema, row_groups.first&.columns || [])
330
338
 
331
- # Internal (used by Redaction): the keys and settings of an encrypted file
339
+ # Internal (used by Redaction and Combiner): the keys and settings of an encrypted file
332
340
  #
333
341
  # @return [Encryption::FileDecryptor, nil] nil for a file that is not encrypted
342
+ # @api private
334
343
  attr_reader :decryptor
335
344
 
336
345
  # Internal: the decryption of one column chunk's modules
@@ -339,6 +348,7 @@ module Herringbone
339
348
  # @param column [Schema::Column] leaf column
340
349
  # @return [Encryption::ModuleCrypto, nil] nil when the chunk is not encrypted
341
350
  # @raise [DecryptionError] when the chunk is encrypted and its key was not given
351
+ # @api private
342
352
  def chunk_crypto(row_group_index, column)
343
353
  return nil unless @decryptor
344
354
  rg = row_groups.fetch(row_group_index)
@@ -354,6 +364,7 @@ module Herringbone
354
364
  # @option options [Boolean] :lazy leave non-dictionary values physical
355
365
  # @return [ColumnChunkReader]
356
366
  # @raise [DecryptionError] when the chunk is encrypted and its key was not given
367
+ # @api private
357
368
  def chunk_reader(row_group_index, column, **options)
358
369
  chunk = row_groups.fetch(row_group_index).columns.fetch(column.index)
359
370
  ColumnChunkReader.new(@io, chunk, column, crypto: chunk_crypto(row_group_index, column), **options)
@@ -656,8 +667,7 @@ module Herringbone
656
667
  # @raise [FormatError] when the file is too short, lacks the magic or the footer is corrupt
657
668
  # @raise [DecryptionError] when the footer is encrypted and cannot be decrypted
658
669
  def read_footer
659
- @io.seek(0, IO::SEEK_END)
660
- size = @io.pos
670
+ size = @io.size
661
671
  raise FormatError, "File too small to be Parquet (#{size} bytes)" if size < 12
662
672
  @io.seek(size - 8)
663
673
  tail = @io.read(8)
@@ -679,6 +689,8 @@ module Herringbone
679
689
 
680
690
  # Rebuilds nested values of one top-level field from the levels of its leaf columns
681
691
  # (the "record assembly" half of the Dremel algorithm).
692
+ #
693
+ # @api private
682
694
  class Assembler
683
695
  # @param field [Schema::Field] top-level field to assemble
684
696
  # @param symbolize [Boolean] whether struct Hashes are keyed by Symbol instead of String
@@ -860,6 +872,7 @@ require_relative "reader/bloom_filters"
860
872
 
861
873
  module Herringbone
862
874
  class Reader
875
+ autoload :ChunkCopier, File.expand_path("reader/chunk_copier", __dir__)
863
876
  autoload :Filter, File.expand_path("reader/scan", __dir__)
864
877
  autoload :NumoColumns, File.expand_path("reader/numo", __dir__)
865
878
  autoload :NumoCursor, File.expand_path("reader/numo", __dir__)