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
@@ -4,6 +4,8 @@ module Herringbone
4
4
  # Minimal Thrift Compact Protocol implementation, just enough for Parquet metadata.
5
5
  # Structs are described declaratively (see Herringbone::Thrift::Struct) so that
6
6
  # both the reader and the writer are driven by the same field tables.
7
+ #
8
+ # @api private
7
9
  module Thrift
8
10
  # Raised on malformed or truncated Thrift data, and on values the writer cannot encode
9
11
  class Error < StandardError; end
@@ -45,6 +47,14 @@ module Herringbone
45
47
  double: T_DOUBLE, binary: T_BINARY, string: T_BINARY
46
48
  }.freeze
47
49
 
50
+ # Values each declared integer type can hold, whatever width it came in on the wire
51
+ INT_RANGES = {
52
+ byte: -2**7...2**7, i16: -2**15...2**15, i32: -2**31...2**31, i64: -2**63...2**63
53
+ }.freeze
54
+
55
+ # Deepest nesting of structs, lists, sets and maps read or skipped, as in Apache Thrift
56
+ MAX_DEPTH = 64
57
+
48
58
  # Wire type written for a declared field type
49
59
  #
50
60
  # @param type [Symbol, Array, Class] a scalar type from WIRE_TYPES, a +[:list, elem]+ Array,
@@ -86,9 +96,12 @@ module Herringbone
86
96
 
87
97
  # @param buf [String] the encoded data (binary)
88
98
  # @param pos [Integer] byte offset to start reading at
99
+ # @raise [Error] when +pos+ is negative
89
100
  def initialize(buf, pos = 0)
101
+ raise Error, "Negative offset #{pos}" if pos.negative?
90
102
  @buf = buf
91
103
  @pos = pos
104
+ @depth = 0
92
105
  end
93
106
 
94
107
  # Reads one raw byte
@@ -104,17 +117,21 @@ module Herringbone
104
117
 
105
118
  # Reads an unsigned LEB128 varint
106
119
  #
107
- # @return [Integer] the decoded non-negative value
108
- # @raise [Error] when the varint runs past the buffer or is longer than 64 bits allow
120
+ # @return [Integer] the decoded non-negative value, below 2**64
121
+ # @raise [Error] when the varint runs past the buffer, is longer than 10 bytes or does not
122
+ # fit in 64 bits
109
123
  def read_varint
110
124
  result = 0
111
125
  shift = 0
112
126
  while true
113
127
  b = read_byte
114
128
  result |= (b & 0x7F) << shift
115
- return result if b < 0x80
129
+ if b < 0x80
130
+ raise Error, "Varint exceeds 64 bits" if result >= 2**64
131
+ return result
132
+ end
116
133
  shift += 7
117
- raise Error, "Varint too long" if shift > 70
134
+ raise Error, "Varint too long" if shift > 63
118
135
  end
119
136
  end
120
137
 
@@ -141,8 +158,10 @@ module Herringbone
141
158
 
142
159
  # Reads an 8-byte little-endian double
143
160
  #
144
- # @return [Float, nil] the value (nil when fewer than 8 bytes are left: this is not checked)
161
+ # @return [Float] the value
162
+ # @raise [Error] when fewer than 8 bytes are left
145
163
  def read_double
164
+ raise Error, "Unexpected end of Thrift data at #{@pos}" if @pos + 8 > @buf.bytesize
146
165
  v = @buf.byteslice(@pos, 8).unpack1("E")
147
166
  @pos += 8
148
167
  v
@@ -155,36 +174,39 @@ module Herringbone
155
174
  #
156
175
  # @param klass [Class] a Thrift::Struct subclass
157
176
  # @return [Struct] an instance of +klass+ with the fields that were present set
158
- # @raise [Error] on truncated or malformed data
177
+ # @raise [Error] on truncated or malformed data, or nesting deeper than MAX_DEPTH
159
178
  def read_struct(klass)
160
- obj = klass.new
161
- fields = klass.fields_by_id
162
- last_id = 0
163
- while true
164
- header = read_byte
165
- wire = header & 0x0F
166
- break if wire == T_STOP
167
- delta = header >> 4
168
- fid = delta.zero? ? read_zigzag : last_id + delta
169
- last_id = fid
170
- field = fields[fid]
171
- if field && Thrift.compatible?(wire, field.type)
172
- obj.instance_variable_set(field.ivar, read_value(wire, field.type))
173
- else
174
- skip(wire)
179
+ nested do
180
+ obj = klass.new
181
+ fields = klass.fields_by_id
182
+ last_id = 0
183
+ while true
184
+ header = read_byte
185
+ wire = header & 0x0F
186
+ break if wire == T_STOP
187
+ delta = header >> 4
188
+ fid = delta.zero? ? read_zigzag : last_id + delta
189
+ last_id = fid
190
+ field = fields[fid]
191
+ if field && Thrift.compatible?(wire, field.type)
192
+ obj.instance_variable_set(field.ivar, read_value(wire, field.type))
193
+ else
194
+ skip(wire)
195
+ end
175
196
  end
197
+ obj
176
198
  end
177
- obj
178
199
  end
179
200
 
180
201
  # Reads one value of wire type +wire+ as declared +type+
181
202
  #
182
203
  # @param wire [Integer] wire type from the field or list header
183
- # @param type [Symbol, Array, Class] declared type; +:string+ makes binary values UTF-8,
184
- # an Array or Struct subclass gives the element type or struct to read
204
+ # @param type [Symbol, Array, Class] declared type; +:string+ makes binary values UTF-8 and
205
+ # +:binary+ makes them BINARY, an Array or Struct subclass gives the element type or struct to read
185
206
  # @return [Object] true/false, an Integer (bytes are signed), a Float, a String, an Array
186
207
  # (or nil, see #read_list) or a Struct
187
- # @raise [Error] on an unsupported wire type or truncated data
208
+ # @raise [Error] on an unsupported wire type, truncated data or an integer out of the range
209
+ # of +type+
188
210
  def read_value(wire, type)
189
211
  case wire
190
212
  when T_TRUE then true
@@ -192,11 +214,14 @@ module Herringbone
192
214
  when T_BYTE
193
215
  b = read_byte
194
216
  (b >= 0x80) ? b - 0x100 : b
195
- when T_I16, T_I32, T_I64 then read_zigzag
217
+ when T_I16, T_I32, T_I64
218
+ n = read_zigzag
219
+ raise Error, "Integer #{n} out of range for #{type}" unless INT_RANGES.fetch(type).cover?(n)
220
+ n
196
221
  when T_DOUBLE then read_double
197
222
  when T_BINARY
198
223
  s = read_binary
199
- (type == :string) ? s.force_encoding(Encoding::UTF_8) : s
224
+ s.force_encoding((type == :string) ? Encoding::UTF_8 : Encoding::BINARY)
200
225
  when T_LIST, T_SET then read_list(type)
201
226
  when T_STRUCT then read_struct(type)
202
227
  else
@@ -210,24 +235,26 @@ module Herringbone
210
235
  # @param type [Array] declared list type, +[:list, elem_type]+
211
236
  # @return [Array, nil] the elements, or nil (with the list skipped) when the element wire
212
237
  # type does not match +elem_type+
213
- # @raise [Error] on truncated or malformed data
238
+ # @raise [Error] on truncated or malformed data, or nesting deeper than MAX_DEPTH
214
239
  def read_list(type)
215
- header = read_byte
216
- size = header >> 4
217
- size = read_varint if size == 15
218
- elem_wire = header & 0x0F
219
- elem_type = type[1]
220
- bool_elems = elem_wire == T_TRUE || elem_wire == T_FALSE
221
- if size.positive? && !(bool_elems && elem_type == :bool) && !Thrift.compatible?(elem_wire, elem_type)
222
- size.times { bool_elems ? read_byte : skip(elem_wire) }
223
- return nil
224
- end
225
- Array.new(size) do
226
- if elem_wire == T_TRUE || elem_wire == T_FALSE
227
- # Booleans inside lists are encoded as full bytes
228
- read_byte == T_TRUE
229
- else
230
- read_value(elem_wire, elem_type)
240
+ nested do
241
+ header = read_byte
242
+ size = header >> 4
243
+ size = read_size if size == 15
244
+ elem_wire = header & 0x0F
245
+ elem_type = type[1]
246
+ bool_elems = elem_wire == T_TRUE || elem_wire == T_FALSE
247
+ if size.positive? && !(bool_elems && elem_type == :bool) && !Thrift.compatible?(elem_wire, elem_type)
248
+ size.times { bool_elems ? read_byte : skip(elem_wire) }
249
+ return nil
250
+ end
251
+ Array.new(size) do
252
+ if elem_wire == T_TRUE || elem_wire == T_FALSE
253
+ # Booleans inside lists are encoded as full bytes
254
+ read_byte == T_TRUE
255
+ else
256
+ read_value(elem_wire, elem_type)
257
+ end
231
258
  end
232
259
  end
233
260
  end
@@ -236,40 +263,76 @@ module Herringbone
236
263
  #
237
264
  # @param wire [Integer] wire type of the value to skip
238
265
  # @return [void]
239
- # @raise [Error] on an unknown wire type (including T_STOP) or truncated data
266
+ # @raise [Error] on an unknown wire type (including T_STOP), truncated data or nesting deeper
267
+ # than MAX_DEPTH
240
268
  def skip(wire)
241
269
  case wire
242
270
  when T_TRUE, T_FALSE then nil
243
271
  when T_BYTE then read_byte
244
272
  when T_I16, T_I32, T_I64 then read_varint
245
- when T_DOUBLE then @pos += 8
273
+ when T_DOUBLE then read_double
246
274
  when T_BINARY then read_binary
247
275
  when T_LIST, T_SET
248
- header = read_byte
249
- size = header >> 4
250
- size = read_varint if size == 15
251
- elem = header & 0x0F
252
- size.times { (elem == T_TRUE || elem == T_FALSE) ? read_byte : skip(elem) }
276
+ nested do
277
+ header = read_byte
278
+ size = header >> 4
279
+ size = read_size if size == 15
280
+ elem = header & 0x0F
281
+ size.times { (elem == T_TRUE || elem == T_FALSE) ? read_byte : skip(elem) }
282
+ end
253
283
  when T_MAP
254
- size = read_varint
255
- unless size.zero?
256
- kv = read_byte
257
- size.times do
258
- [kv >> 4, kv & 0x0F].each { |w| (w == T_TRUE || w == T_FALSE) ? read_byte : skip(w) }
284
+ nested do
285
+ size = read_size
286
+ unless size.zero?
287
+ kv = read_byte
288
+ size.times do
289
+ [kv >> 4, kv & 0x0F].each { |w| (w == T_TRUE || w == T_FALSE) ? read_byte : skip(w) }
290
+ end
259
291
  end
260
292
  end
261
293
  when T_STRUCT
262
- while true
263
- header = read_byte
264
- w = header & 0x0F
265
- break if w == T_STOP
266
- read_zigzag if (header >> 4).zero?
267
- skip(w)
294
+ nested do
295
+ while true
296
+ header = read_byte
297
+ w = header & 0x0F
298
+ break if w == T_STOP
299
+ read_zigzag if (header >> 4).zero?
300
+ skip(w)
301
+ end
268
302
  end
269
303
  else
270
304
  raise Error, "Cannot skip wire type #{wire}"
271
305
  end
272
306
  end
307
+
308
+ private
309
+
310
+ # Reads the element count of a list, set or map
311
+ #
312
+ # Every element takes at least one byte, so a count above the bytes left is corrupt. Checking
313
+ # that up front keeps a forged count from preallocating a huge Array or skipping for ever.
314
+ #
315
+ # @return [Integer] the count, at most the number of bytes left in the buffer
316
+ # @raise [Error] when the count exceeds the bytes left
317
+ def read_size
318
+ size = read_varint
319
+ left = @buf.bytesize - @pos
320
+ raise Error, "Collection of #{size} elements exceeds the #{left} bytes left" if size > left
321
+ size
322
+ end
323
+
324
+ # Runs the block one nesting level deeper
325
+ #
326
+ # @yieldreturn [Object] the value read inside the nested container
327
+ # @return [Object] the block's value
328
+ # @raise [Error] when the nesting gets deeper than MAX_DEPTH
329
+ def nested
330
+ @depth += 1
331
+ raise Error, "Thrift data nested deeper than #{MAX_DEPTH} levels" if @depth > MAX_DEPTH
332
+ yield
333
+ ensure
334
+ @depth -= 1
335
+ end
273
336
  end
274
337
 
275
338
  # Encodes compact protocol data by appending to a binary String
@@ -348,12 +411,15 @@ module Herringbone
348
411
  # @param type [Symbol, Array, Class] declared type (see WIRE_TYPES)
349
412
  # @param value [Object] Integer, Float, String, Array or Struct matching +type+
350
413
  # @return [void]
414
+ # @raise [Error] when an integer type gets a value that is not an Integer in its range, or a
415
+ # +:string+ gets a value that is not valid UTF-8 and cannot be converted to it
351
416
  def write_value(type, value)
352
417
  case type
353
- when :byte then @buf << (value & 0xFF)
354
- when :i16, :i32, :i64 then write_zigzag(value)
418
+ when :byte then @buf << (checked_int(type, value) & 0xFF)
419
+ when :i16, :i32, :i64 then write_zigzag(checked_int(type, value))
355
420
  when :double then @buf << [value].pack("E")
356
- when :binary, :string then write_binary(value)
421
+ when :binary then write_binary(value)
422
+ when :string then write_binary(utf8(value))
357
423
  when Array then write_list(type[1], value)
358
424
  else write_struct(value)
359
425
  end
@@ -381,6 +447,32 @@ module Herringbone
381
447
  end
382
448
  end
383
449
  end
450
+
451
+ private
452
+
453
+ # @param type [Symbol] declared integer type, a key of INT_RANGES
454
+ # @param value [Object] value to be written as +type+
455
+ # @return [Integer] +value+
456
+ # @raise [Error] when +value+ is not an Integer that +type+ can hold
457
+ def checked_int(type, value)
458
+ raise Error, "#{value.inspect} is not a valid #{type}" unless value.is_a?(Integer) && INT_RANGES.fetch(type).cover?(value)
459
+ value
460
+ end
461
+
462
+ # Parquet strings are UTF-8. Binary Strings are taken to hold UTF-8 bytes, Strings in other
463
+ # encodings are converted.
464
+ #
465
+ # @param s [String] value of a +:string+ field
466
+ # @return [String] +s+, or a UTF-8 copy of it
467
+ # @raise [Error] when +s+ is not valid UTF-8 and cannot be converted to it
468
+ def utf8(s)
469
+ return s if s.ascii_only? || (s.encoding == Encoding::UTF_8 && s.valid_encoding?)
470
+ u = (s.encoding == Encoding::BINARY) ? s.dup.force_encoding(Encoding::UTF_8) : s.encode(Encoding::UTF_8)
471
+ raise Error, "String in #{s.encoding} is not valid UTF-8" unless u.valid_encoding?
472
+ u
473
+ rescue EncodingError => e
474
+ raise Error, "String in #{s.encoding} cannot be converted to UTF-8: #{e.message}"
475
+ end
384
476
  end
385
477
 
386
478
  # A field declared on a Thrift::Struct subclass
@@ -399,14 +491,14 @@ module Herringbone
399
491
  # field 1, :name, :i32
400
492
  class Struct
401
493
  class << self
402
- # @return [Array<Field>] declared fields, sorted by id
494
+ # @return [Array<Field>] declared fields, sorted by id, including the superclass's
403
495
  def fields
404
- @fields || []
496
+ @fields || ((superclass < Struct) ? superclass.fields : [])
405
497
  end
406
498
 
407
499
  # @return [Hash{Integer => Field}] declared fields by id, for decoding
408
500
  def fields_by_id
409
- @fields_by_id || {}
501
+ @fields_by_id || ((superclass < Struct) ? superclass.fields_by_id : {})
410
502
  end
411
503
 
412
504
  # Declares a field and defines its accessor
@@ -475,6 +567,12 @@ module Herringbone
475
567
  def ==(other)
476
568
  other.class == self.class && other.to_h == to_h
477
569
  end
570
+ alias_method :eql?, :==
571
+
572
+ # @return [Integer] hash agreeing with #==, so equal structs work as Hash keys and in +uniq+
573
+ def hash
574
+ [self.class, to_h].hash
575
+ end
478
576
 
479
577
  # @return [String] short class name and the set fields
480
578
  def inspect
@@ -18,6 +18,8 @@ module Herringbone
18
18
  # DECIMAL <-> BigDecimal
19
19
  # UUID <-> String "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
20
20
  # FLOAT16 <-> Float
21
+ #
22
+ # @api private
21
23
  module Types
22
24
  module_function
23
25
 
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Herringbone
4
4
  # Gem version, also written to the +created_by+ field of every file's footer
5
- VERSION = "0.6.0"
5
+ VERSION = "0.7.0"
6
6
  end
@@ -15,6 +15,8 @@ module Herringbone
15
15
  # Used through Inspector#to_html:
16
16
  #
17
17
  # File.open("data.parquet", "rb") { |io| Herringbone::Inspector.new(io).to_html }
18
+ #
19
+ # @api private
18
20
  class Visualizer
19
21
  # highlight.js build loaded by the page to colour the footer JSON; optional.
20
22
  HIGHLIGHT_JS = "https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"
@@ -76,6 +76,8 @@ module Herringbone
76
76
  MAX_DICTIONARY_BYTES = 1024 * 1024
77
77
  # The row group byte size is first estimated after this many rows, then after every row group
78
78
  ESTIMATE_AFTER_ROWS = 1000
79
+ # Options .open_for_copies refuses, since the row groups it writes keep their boundaries
80
+ ROW_GROUP_OPTIONS = %i[row_group_bytes row_group_rows].freeze
79
81
 
80
82
  # @return [Schema] schema the rows are written with
81
83
  attr_reader :schema
@@ -121,6 +123,29 @@ module Herringbone
121
123
  result
122
124
  end
123
125
 
126
+ # Internal (used by Redaction and Combiner): .open for a file whose row groups are all written
127
+ # with #write_row_group, one for each row group of another file, so they keep their boundaries
128
+ #
129
+ # @param io [IO, #write] destination
130
+ # @param schema [Schema] schema of the rows
131
+ # @param refusal [String] why +row_group_bytes:+ and +row_group_rows:+ cannot be given, e.g.
132
+ # "a redaction keeps the row groups of the input"
133
+ # @param options [Hash{Symbol => Object}] see .open, without the row group options
134
+ # @option options [Symbol] :compression (:snappy) codec of the chunks encoded again
135
+ # @option options [Hash{String => String}] :metadata ({}) key/value metadata for the footer
136
+ # @option options [EncryptionConfiguration, Hash{Symbol => Object}, nil] :encryption (nil) see #initialize
137
+ # @yield [writer] the open writer
138
+ # @yieldparam writer [Writer] writer to write row groups to
139
+ # @yieldreturn [Object] returned by open_for_copies
140
+ # @return [Object] the block's value
141
+ # @raise [ArgumentError] for +row_group_bytes:+ / +row_group_rows:+, or an invalid option
142
+ # @api private
143
+ def self.open_for_copies(io, schema, refusal, **options, &block)
144
+ bad = options.keys & ROW_GROUP_OPTIONS
145
+ raise ArgumentError, "#{bad.join(", ")}: #{refusal}" unless bad.empty?
146
+ self.open(io, schema, row_group_bytes: 1 << 62, **options, &block)
147
+ end
148
+
124
149
  # Validates the options and writes the leading magic bytes to +io+.
125
150
  #
126
151
  # @param io [IO, #write] destination; switched to binmode when it supports that
@@ -269,11 +294,12 @@ module Herringbone
269
294
  # @return [Format::OffsetIndex, nil] the chunk's OffsetIndex, with source file offsets
270
295
  # @!attribute bloom_filter
271
296
  # @return [String, nil] the chunk's encoded bloom filter, header included
297
+ # @api private
272
298
  CopiedChunk = Struct.new(:chunk, :start, :bytes, :column_index, :offset_index, :bloom_filter)
273
299
 
274
- # Internal (used by Redaction): writes a row group of +num_rows+ rows from the buffered values,
275
- # except for the columns in +copies+, whose chunks are copied byte for byte from another file
276
- # with their offsets rebased. Starts a new row group afterwards.
300
+ # Internal (used by Redaction and Combiner): writes a row group of +num_rows+ rows from the
301
+ # buffered values, except for the columns in +copies+, whose chunks are copied byte for byte
302
+ # from another file with their offsets rebased. Starts a new row group afterwards.
277
303
  #
278
304
  # @param num_rows [Integer] rows in the row group
279
305
  # @param copies [Hash{Integer => CopiedChunk}] column index => chunk to copy instead of encoding
@@ -285,6 +311,7 @@ module Herringbone
285
311
  # @param sorting_columns [Array<Format::SortingColumn>, nil] stored on the RowGroup as given
286
312
  # @return [void]
287
313
  # @raise [Error] when the writer is closed
314
+ # @api private
288
315
  def write_row_group(num_rows, copies: {}, codecs: {}, bloom_filters: {}, sorting_columns: nil)
289
316
  raise Error, "Writer is closed" if @closed
290
317
  if @encryption && (encrypted = copies.keys.find { |i| @encryption.encrypted?(i) })
@@ -293,7 +320,7 @@ module Herringbone
293
320
  start = @pos
294
321
  chunks = @schema.columns.map do |col|
295
322
  if (copy = copies[col.index])
296
- copy_column_chunk(copy)
323
+ copy_column_chunk(col, copy)
297
324
  else
298
325
  bloom = @bloom_filters[col.dotted_path]
299
326
  bloom ||= bloom_filter_settings({}) if bloom_filters[col.index] && BloomFilter::TYPES.include?(col.type)
@@ -314,21 +341,23 @@ module Herringbone
314
341
  reset_buffers
315
342
  end
316
343
 
317
- # Internal (used by Redaction): whether the column is encrypted in this file
344
+ # Internal (used by Redaction and Combiner): whether the column is encrypted in this file
318
345
  #
319
346
  # @param index [Integer] leaf column index
320
347
  # @return [Boolean]
348
+ # @api private
321
349
  def encrypted_column?(index) = @encryption&.encrypted?(index) || false
322
350
 
323
- # Internal (used by Redaction): shreds one value per row of a top-level field into the buffers,
324
- # for #write_row_group. Other fields are left as they are, so the caller decides which columns
325
- # are encoded and which are copied.
351
+ # Internal (used by Redaction and Combiner): shreds one value per row of a top-level field into
352
+ # the buffers, for #write_row_group. Other fields are left as they are, so the caller decides
353
+ # which columns are encoded and which are copied.
326
354
  #
327
355
  # @param name [String] top-level field name
328
356
  # @param values [Array] the field's value for each row
329
357
  # @return [void]
330
358
  # @raise [ArgumentError] when the schema has no such field
331
359
  # @raise [EncodeError] when a value cannot be written to the field
360
+ # @api private
332
361
  def buffer_field(name, values)
333
362
  field = @schema.field(name) or raise ArgumentError, "No such field #{name.inspect}"
334
363
  values.each_with_index do |value, i|
@@ -414,6 +443,7 @@ module Herringbone
414
443
  # @return [String, Array<Integer>, nil] repetition levels, like +defs+; nil for non-repeated columns
415
444
  # @!attribute values
416
445
  # @return [Array, ByteValues] non-null physical values
446
+ # @api private
417
447
  ColumnBuffer = Struct.new(:defs, :reps, :values)
418
448
 
419
449
  # Starts a new row group: fresh buffers per column, and the per-field write plan
@@ -769,6 +799,7 @@ module Herringbone
769
799
  # @return [Integer] non-null values in the page
770
800
  # @!attribute range
771
801
  # @return [Array(Object, Object), nil] [min, max] physical values, nil when there are none
802
+ # @api private
772
803
  PageInfo = Struct.new(:offset, :size, :first_row, :nulls, :non_null, :range)
773
804
 
774
805
  # @param pages [Array<PageInfo>] pages of a column chunk
@@ -781,14 +812,17 @@ module Herringbone
781
812
 
782
813
  # Writes a chunk copied from another file. The pages, the ColumnIndex and the bloom filter
783
814
  # hold no file offsets and go out as they are; the ColumnMetaData and the OffsetIndex do, so
784
- # those are rebased onto where the chunk lands in this file.
815
+ # those are rebased onto where the chunk lands in this file. The path is this file's, which
816
+ # differs when the source file names list elements another way ("list.item", "array").
785
817
  #
818
+ # @param col [Schema::Column] the column the chunk is copied into
786
819
  # @param copy [CopiedChunk] the chunk to copy
787
820
  # @return [Format::ColumnChunk] chunk with its ColumnMetaData, for the row group
788
- def copy_column_chunk(copy)
821
+ def copy_column_chunk(col, copy)
789
822
  start = @pos
790
823
  shift = start - copy.start
791
824
  meta = Format::ColumnMetaData.decode(copy.chunk.meta_data.encode).first
825
+ meta.path_in_schema = col.path
792
826
  meta.data_page_offset += shift
793
827
  dict = meta.dictionary_page_offset
794
828
  # Some writers store 0 when there is no dictionary page
@@ -1070,7 +1104,7 @@ module Herringbone
1070
1104
  # keeps in their source codec use that codec's default
1071
1105
  #
1072
1106
  # @param codec [Integer] codec id
1073
- # @param data [String] bytes to compress
1107
+ # @param data [String, Array<String>] bytes to compress, or the parts of them
1074
1108
  # @return [String] compressed bytes
1075
1109
  def compress(codec, data)
1076
1110
  Compression.compress(codec, data, (codec == @codec) ? @compression_level : nil)
@@ -1080,28 +1114,30 @@ module Herringbone
1080
1114
  # stored, encrypted.
1081
1115
  #
1082
1116
  # @param header [Format::PageHeader] header; sizes and CRC32 are filled in here
1083
- # @param body [String] uncompressed page body
1084
- # @param compressed [String, nil] bytes to write as the page body, when already prepared (v2 data
1085
- # pages, whose levels stay uncompressed); +body+ is compressed otherwise
1117
+ # @param body [String, Array<String>] uncompressed page body, or its parts
1118
+ # @param compressed [String, Array<String>, nil] bytes to write as the page body, or their
1119
+ # parts, when already prepared (v2 data pages, whose levels stay uncompressed); +body+ is
1120
+ # compressed otherwise
1086
1121
  # @param codec [Integer] codec id to compress +body+ with
1087
1122
  # @param crypto [Encryption::ModuleCrypto, nil] encryption of the column's modules
1088
1123
  # @param ordinal [Integer, nil] data page ordinal within the chunk; nil for a dictionary page
1089
1124
  # @return [Integer]
1090
1125
  def write_page(header, body, compressed = nil, codec: @codec, crypto: nil, ordinal: nil)
1091
- compressed ||= compress(codec, body)
1092
- header.uncompressed_page_size ||= body.bytesize
1126
+ parts = Array(compressed || compress(codec, body))
1127
+ header.uncompressed_page_size ||= Array(body).sum(&:bytesize)
1093
1128
  dictionary = header.type == Format::PageType::DICTIONARY_PAGE
1094
1129
  if crypto
1095
- compressed = crypto.encrypt(dictionary ? Encryption::DICTIONARY_PAGE : Encryption::DATA_PAGE, compressed, ordinal)
1130
+ parts = [crypto.encrypt(dictionary ? Encryption::DICTIONARY_PAGE : Encryption::DATA_PAGE, parts.join, ordinal)]
1096
1131
  end
1097
- header.compressed_page_size = compressed.bytesize
1098
- header.crc = Zlib.crc32(compressed).then { |c| (c >= 0x8000_0000) ? c - 0x1_0000_0000 : c }
1132
+ header.compressed_page_size = parts.sum(&:bytesize)
1133
+ crc = parts.inject(0) { |c, part| Zlib.crc32(part, c) }
1134
+ header.crc = (crc >= 0x8000_0000) ? crc - 0x1_0000_0000 : crc
1099
1135
  encoded = header.encode
1100
1136
  if crypto
1101
1137
  encoded = crypto.encrypt(dictionary ? Encryption::DICTIONARY_PAGE_HEADER : Encryption::DATA_PAGE_HEADER, encoded, ordinal)
1102
1138
  end
1103
1139
  write_raw(encoded)
1104
- write_raw(compressed)
1140
+ parts.each { |part| write_raw(part) unless part.empty? }
1105
1141
  encoded.bytesize + header.uncompressed_page_size
1106
1142
  end
1107
1143
 
@@ -1117,7 +1153,7 @@ module Herringbone
1117
1153
  # modules and the page's ordinal, for an encrypted column
1118
1154
  # @return [Integer] uncompressed size including the header
1119
1155
  def write_data_page_v1(n, rep_bytes, def_bytes, encoded, encoding, codec, page_crypto)
1120
- body = String.new(encoding: Encoding::BINARY)
1156
+ body = []
1121
1157
  body << [rep_bytes.bytesize].pack("V") << rep_bytes unless rep_bytes.empty?
1122
1158
  body << [def_bytes.bytesize].pack("V") << def_bytes unless def_bytes.empty?
1123
1159
  body << encoded
@@ -1156,7 +1192,7 @@ module Herringbone
1156
1192
  is_compressed: codec != Format::Codec::UNCOMPRESSED
1157
1193
  )
1158
1194
  )
1159
- write_page(header, "".b, rep_bytes + def_bytes + compressed, codec: codec, crypto: page_crypto&.first,
1195
+ write_page(header, "".b, [rep_bytes, def_bytes, compressed], codec: codec, crypto: page_crypto&.first,
1160
1196
  ordinal: page_crypto&.last)
1161
1197
  end
1162
1198
 
@@ -14,6 +14,8 @@ module Herringbone
14
14
  # allocations trigger so many garbage collections that hashing becomes up to 10x slower. The
15
15
  # arithmetic is written once with the small code generators below, whose output is inlined into
16
16
  # the hashing methods: no method calls or allocations in the hot paths.
17
+ #
18
+ # @api private
17
19
  module XXHash
18
20
  # 64-bit mask
19
21
  M = 0xFFFF_FFFF_FFFF_FFFF