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
@@ -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
@@ -47,7 +49,7 @@ module Herringbone
47
49
 
48
50
  # Values each declared integer type can hold, whatever width it came in on the wire
49
51
  INT_RANGES = {
50
- i16: -2**15...2**15, i32: -2**31...2**31, i64: -2**63...2**63
52
+ byte: -2**7...2**7, i16: -2**15...2**15, i32: -2**31...2**31, i64: -2**63...2**63
51
53
  }.freeze
52
54
 
53
55
  # Deepest nesting of structs, lists, sets and maps read or skipped, as in Apache Thrift
@@ -94,7 +96,9 @@ module Herringbone
94
96
 
95
97
  # @param buf [String] the encoded data (binary)
96
98
  # @param pos [Integer] byte offset to start reading at
99
+ # @raise [Error] when +pos+ is negative
97
100
  def initialize(buf, pos = 0)
101
+ raise Error, "Negative offset #{pos}" if pos.negative?
98
102
  @buf = buf
99
103
  @pos = pos
100
104
  @depth = 0
@@ -197,8 +201,8 @@ module Herringbone
197
201
  # Reads one value of wire type +wire+ as declared +type+
198
202
  #
199
203
  # @param wire [Integer] wire type from the field or list header
200
- # @param type [Symbol, Array, Class] declared type; +:string+ makes binary values UTF-8,
201
- # 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
202
206
  # @return [Object] true/false, an Integer (bytes are signed), a Float, a String, an Array
203
207
  # (or nil, see #read_list) or a Struct
204
208
  # @raise [Error] on an unsupported wire type, truncated data or an integer out of the range
@@ -217,7 +221,7 @@ module Herringbone
217
221
  when T_DOUBLE then read_double
218
222
  when T_BINARY
219
223
  s = read_binary
220
- (type == :string) ? s.force_encoding(Encoding::UTF_8) : s
224
+ s.force_encoding((type == :string) ? Encoding::UTF_8 : Encoding::BINARY)
221
225
  when T_LIST, T_SET then read_list(type)
222
226
  when T_STRUCT then read_struct(type)
223
227
  else
@@ -407,12 +411,15 @@ module Herringbone
407
411
  # @param type [Symbol, Array, Class] declared type (see WIRE_TYPES)
408
412
  # @param value [Object] Integer, Float, String, Array or Struct matching +type+
409
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
410
416
  def write_value(type, value)
411
417
  case type
412
- when :byte then @buf << (value & 0xFF)
413
- 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))
414
420
  when :double then @buf << [value].pack("E")
415
- when :binary, :string then write_binary(value)
421
+ when :binary then write_binary(value)
422
+ when :string then write_binary(utf8(value))
416
423
  when Array then write_list(type[1], value)
417
424
  else write_struct(value)
418
425
  end
@@ -440,6 +447,32 @@ module Herringbone
440
447
  end
441
448
  end
442
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
443
476
  end
444
477
 
445
478
  # A field declared on a Thrift::Struct subclass
@@ -458,14 +491,14 @@ module Herringbone
458
491
  # field 1, :name, :i32
459
492
  class Struct
460
493
  class << self
461
- # @return [Array<Field>] declared fields, sorted by id
494
+ # @return [Array<Field>] declared fields, sorted by id, including the superclass's
462
495
  def fields
463
- @fields || []
496
+ @fields || ((superclass < Struct) ? superclass.fields : [])
464
497
  end
465
498
 
466
499
  # @return [Hash{Integer => Field}] declared fields by id, for decoding
467
500
  def fields_by_id
468
- @fields_by_id || {}
501
+ @fields_by_id || ((superclass < Struct) ? superclass.fields_by_id : {})
469
502
  end
470
503
 
471
504
  # Declares a field and defines its accessor
@@ -534,6 +567,12 @@ module Herringbone
534
567
  def ==(other)
535
568
  other.class == self.class && other.to_h == to_h
536
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
537
576
 
538
577
  # @return [String] short class name and the set fields
539
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
 
@@ -81,13 +83,15 @@ module Herringbone
81
83
  end
82
84
 
83
85
  # Physical attributes (type, logical type etc.) for a DSL type name
86
+ #
87
+ # +:time+ and +:timestamp+ hold values converted to UTC and are flagged isAdjustedToUTC;
88
+ # +:local_time+ and +:local_timestamp+ hold wall clock values, are not flagged and get no
89
+ # legacy converted type.
84
90
  # @param type [Symbol, String] column type name, e.g. +:string+, +:int64+, +:decimal+, +:timestamp+
85
91
  # @param opts [Hash{Symbol => Object}] type-specific options
86
92
  # @option opts [Boolean] :parquet_enum for +:enum+, annotate as ENUM instead of STRING
87
93
  # @option opts [Integer] :length byte length for +:fixed+ (required)
88
- # @option opts [Symbol] :unit (:micros) for +:time+ and +:timestamp+, see {time_unit}
89
- # @option opts [Boolean] :utc (true) for +:time+ and +:timestamp+, sets +is_adjusted_to_utc+;
90
- # the legacy converted type is only written when true
94
+ # @option opts [Symbol] :unit (:micros) for the time and timestamp types, see {time_unit}
91
95
  # @option opts [Integer] :precision number of decimal digits for +:decimal+ (required)
92
96
  # @option opts [Integer] :scale (0) digits after the decimal point for +:decimal+
93
97
  # @option opts [Symbol] :physical for +:decimal+, force +:int32+, +:int64+, +:binary+ or
@@ -125,16 +129,18 @@ module Herringbone
125
129
  when :int96 then {type: T::INT96}
126
130
  when :fixed
127
131
  {type: T::FIXED_LEN_BYTE_ARRAY, type_length: Integer(opts.fetch(:length))}
128
- when :time
132
+ when :time, :local_time
133
+ utc = type.to_sym == :time
129
134
  unit = time_unit(opts.fetch(:unit, :micros))
130
135
  converted = {millis: C::TIME_MILLIS, micros: C::TIME_MICROS}[unit.to_sym]
131
- {type: unit.millis ? T::INT32 : T::INT64, converted_type: opts.fetch(:utc, true) ? converted : nil,
132
- logical_type: lt(time: Format::TimeType.new(is_adjusted_to_utc: opts.fetch(:utc, true), unit: unit))}
133
- when :timestamp
136
+ {type: unit.millis ? T::INT32 : T::INT64, converted_type: utc ? converted : nil,
137
+ logical_type: lt(time: Format::TimeType.new(is_adjusted_to_utc: utc, unit: unit))}
138
+ when :timestamp, :local_timestamp
139
+ utc = type.to_sym == :timestamp
134
140
  unit = time_unit(opts.fetch(:unit, :micros))
135
141
  converted = {millis: C::TIMESTAMP_MILLIS, micros: C::TIMESTAMP_MICROS}[unit.to_sym]
136
- {type: T::INT64, converted_type: opts.fetch(:utc, true) ? converted : nil,
137
- logical_type: lt(timestamp: Format::TimestampType.new(is_adjusted_to_utc: opts.fetch(:utc, true), unit: unit))}
142
+ {type: T::INT64, converted_type: utc ? converted : nil,
143
+ logical_type: lt(timestamp: Format::TimestampType.new(is_adjusted_to_utc: utc, unit: unit))}
138
144
  when :decimal
139
145
  precision = Integer(opts.fetch(:precision))
140
146
  scale = Integer(opts.fetch(:scale, 0))
@@ -335,7 +341,8 @@ module Herringbone
335
341
  # date: Date, Time/DateTime (its calendar date), ISO-8601 String, Integer days since epoch
336
342
  # timestamp: Time, DateTime, ActiveSupport::TimeWithZone, Date (midnight UTC), ISO-8601 String,
337
343
  # Integer in the column's unit
338
- # time: Time (its time of day), "HH:MM[:SS[.fraction]]" String, Integer in the column's unit
344
+ # time: Time (its time of day, in UTC unless the column is local), "HH:MM[:SS[.fraction]]"
345
+ # String, Integer in the column's unit
339
346
  # json: String (used as-is) or any other object (serialized with JSON.generate)
340
347
  # string: String, Symbol or anything responding to to_s
341
348
  # boolean: true/false, 1/0, "true"/"false", "t"/"f", "1"/"0", "yes"/"no"
@@ -355,7 +362,7 @@ module Herringbone
355
362
  when :timestamp
356
363
  return timestamp_writer(a, b)
357
364
  when :time
358
- return time_writer(a) if type == T::INT32 || type == T::INT64
365
+ return time_writer(a, b) if type == T::INT32 || type == T::INT64
359
366
  when :json
360
367
  return ->(v) { v.is_a?(String) ? v : JSON.generate(v) }
361
368
  when :string, :enum
@@ -481,9 +488,11 @@ module Herringbone
481
488
  # anything responding to +hour+ and +nsec+ (Time, DateTime), and raises ArgumentError or
482
489
  # RangeError for anything else or a time past 23:59:59.
483
490
  # @param unit [Symbol] +:millis+, +:micros+ or +:nanos+
491
+ # @param utc [Boolean] true to convert a Time to UTC before taking its time of day, false to
492
+ # take the time of day in the Time's own zone
484
493
  # @return [Proc] one-argument lambda
485
494
  # @raise [KeyError] for an unknown unit
486
- def time_writer(unit)
495
+ def time_writer(unit, utc)
487
496
  mult = UNIT_DIVISORS.fetch(unit)
488
497
  lambda do |v|
489
498
  return v if v.is_a?(Integer)
@@ -494,6 +503,7 @@ module Herringbone
494
503
  secs = m[1].to_i * 3600 + m[2].to_i * 60 + m[3].to_i
495
504
  else
496
505
  raise ArgumentError, "expected a Time, got #{v.class}" unless v.respond_to?(:hour) && v.respond_to?(:nsec)
506
+ v = v.getutc if utc && v.respond_to?(:getutc)
497
507
  nanos = v.nsec
498
508
  secs = v.hour * 3600 + v.min * 60 + v.sec
499
509
  end
@@ -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.1"
5
+ VERSION = "0.8.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
data/lib/herringbone.rb CHANGED
@@ -40,6 +40,34 @@ module Herringbone
40
40
  # such as a codec whose library is unavailable
41
41
  class UnsupportedError < Error; end
42
42
 
43
+ # Schemas that do not fit together: two schemas that cannot be united or intersected
44
+ # (Schema#union, Schema#intersect), or inputs of Herringbone.combine that do not fit the output
45
+ # schema or each other. The message lists every field that does not fit, one per line, e.g.
46
+ #
47
+ # Cannot unite the schemas, 2 fields do not fit:
48
+ # price: int64 vs double (int64 does not fit a double exactly)
49
+ # address.zip: int32 vs string (no common type)
50
+ class IncompatibleSchema < Error
51
+ # A field that does not fit: its dotted path, how each side declares it, why the two do not
52
+ # fit, and for Herringbone.combine the position of the input it was found in
53
+ Conflict = Struct.new(:path, :left, :right, :reason, :input) do
54
+ # @return [String] e.g. "address.zip: int32 vs string (no common type)"
55
+ def to_s = "#{path.empty? ? "(top level)" : path}: #{left} vs #{right} (#{reason})"
56
+ end
57
+
58
+ # @return [Array<Conflict>] every field that does not fit, in schema (and input) order
59
+ attr_reader :conflicts
60
+
61
+ # @param conflicts [Array<Conflict>] the fields that do not fit
62
+ # @param operation [String, nil] what was attempted, "unite" or "intersect", for the message
63
+ # @param message [String, nil] the message, instead of one built from +operation+
64
+ def initialize(conflicts, operation: nil, message: nil)
65
+ @conflicts = conflicts
66
+ count = (conflicts.size == 1) ? "1 field does" : "#{conflicts.size} fields do"
67
+ super(message || "Cannot #{operation} the schemas, #{count} not fit:\n#{conflicts.map { |c| " #{c}" }.join("\n")}")
68
+ end
69
+ end
70
+
43
71
  # An encrypted file or column cannot be read: its key was not given, the key or AAD prefix is
44
72
  # wrong, or the encrypted bytes were changed
45
73
  class DecryptionError < Error; end
@@ -56,6 +84,7 @@ module Herringbone
56
84
 
57
85
  autoload :BloomFilter, "#{LIB}/bloom_filter"
58
86
  autoload :ByteValues, "#{LIB}/byte_values"
87
+ autoload :Combiner, "#{LIB}/combiner"
59
88
  autoload :Compression, "#{LIB}/compression"
60
89
  autoload :DecryptionConfiguration, "#{LIB}/encryption_configuration"
61
90
  autoload :Encryption, "#{LIB}/encryption"
@@ -68,6 +97,7 @@ module Herringbone
68
97
  autoload :MissingCodecError, "#{LIB}/compression"
69
98
  autoload :Reader, "#{LIB}/reader"
70
99
  autoload :Redaction, "#{LIB}/redaction"
100
+ autoload :RestrictedReadableIO, "#{LIB}/restricted_readable_io"
71
101
  autoload :Schema, "#{LIB}/schema"
72
102
  autoload :SimpleWriter, "#{LIB}/simple_writer"
73
103
  autoload :Thrift, "#{LIB}/thrift"
@@ -176,16 +206,17 @@ module Herringbone
176
206
  writer.rows_written
177
207
  end
178
208
 
179
- # Rewrites +input+ into +output+ with rows removed or values replaced, building the Redaction from
180
- # the block (or taking +redaction+). A shortcut for Redaction#apply.
209
+ # Rewrites +io_or_reader+ into +output_io+ with rows removed or values replaced, building the
210
+ # Redaction from the block (or taking +redaction+). A shortcut for Redaction#apply.
181
211
  #
182
- # Herringbone.redact(input, output) do |r|
212
+ # Herringbone.redact(io, output_io) do |r|
183
213
  # r.where(user_id: 42).delete
184
214
  # r.replace(:email) { |email| email && OpenSSL::HMAC.hexdigest("SHA256", KEY, email) }
185
215
  # end
186
216
  #
187
- # @param input [IO, StringIO] the Parquet file, read with #seek and #read; not closed
188
- # @param output [IO, #write] destination, written sequentially; not closed
217
+ # @param io_or_reader [IO, StringIO, Reader] the Parquet file, read with #seek and #read, or a
218
+ # Reader of it, whose IO and decryption are used; not closed
219
+ # @param output_io [IO, #write] destination, written sequentially; not closed
189
220
  # @param redaction [Redaction, nil] the redaction to apply, instead of a block
190
221
  # @param writer_options [Hash{Symbol => Object}] Writer options for re-encoded column chunks, and
191
222
  # +metadata:+ to replace the footer key/value metadata, see Redaction#apply
@@ -204,15 +235,71 @@ module Herringbone
204
235
  # @yieldreturn [void]
205
236
  # @return [Redaction::Report] what was done
206
237
  # @raise [ArgumentError] when given both or neither of +redaction+ and a block, a block that takes
207
- # no parameter, or when the redaction does not fit the file's schema
238
+ # no parameter, a Reader with +decryption:+, or when the redaction does not fit the file's schema
208
239
  # @raise [EncodeError] when a replacement value cannot be written to its column
209
- def redact(input, output, redaction = nil, **writer_options, &block)
240
+ def redact(io_or_reader, output_io, redaction = nil, **writer_options, &block)
210
241
  raise ArgumentError, "Herringbone.redact takes a Redaction or a block, not both" if redaction && block
211
242
  raise ArgumentError, "Herringbone.redact needs a Redaction or a block" unless redaction || block
212
243
  if block&.arity&.zero?
213
- raise ArgumentError, "The block receives the redaction: Herringbone.redact(input, output) { |r| r.where(user_id: 42).delete }"
244
+ raise ArgumentError, "The block receives the redaction: Herringbone.redact(io, output_io) { |r| r.where(user_id: 42).delete }"
214
245
  end
215
- (redaction || Redaction.new(&block)).apply(input, output, **writer_options)
246
+ (redaction || Redaction.new(&block)).apply(io_or_reader, output_io, **writer_options)
247
+ end
248
+
249
+ # Concatenates Parquet files into +output_io+: every row group of every input, in order. A
250
+ # shortcut for Combiner. Column chunks are copied byte for byte (only offsets are rebased)
251
+ # wherever the output stores a column's values as the input does, so files with the same schema
252
+ # are combined without decoding anything.
253
+ #
254
+ # Herringbone.combine([jan, feb, mar], output_io) # the same schema
255
+ # Herringbone.combine([old, new], output_io, schema: :union) # all fields, nulls in the gaps
256
+ # Herringbone.combine([a, b], output_io, schema: :intersect) # the fields all inputs have
257
+ #
258
+ # Without +schema:+ every input must have the schema of the first (see Schema#==). With
259
+ # +schema: :union+ the output has the fields of all inputs, widened as Schema#union widens them,
260
+ # and fields an input lacks are written as nulls. With +schema: :intersect+ it has the fields all
261
+ # inputs have, and the others are dropped. A Schema given as +schema:+ must hold every input: each
262
+ # must have the same fields or fewer, in the same or narrower types. Every input is checked
263
+ # before anything is written, and IncompatibleSchema lists all that do not fit.
264
+ #
265
+ # The output is encrypted like the encrypted inputs, if any (plaintext inputs included), unless
266
+ # +encryption:+ says otherwise; +encryption: false+ writes it in plaintext. Inputs encrypted
267
+ # differently need +encryption:+. Herringbone never opens files by path: pass open IOs, which
268
+ # are read twice (footers first, then chunks), so keep them open until +combine+ returns and
269
+ # mind the limit on open files when combining very many of them.
270
+ #
271
+ # @param ios_or_readers [Enumerable<IO, StringIO, Reader>] the Parquet files: IOs read with #seek
272
+ # and #read, or Readers, which bring their own decryption; enumerated once, none is closed
273
+ # @param output_io [IO, #write] destination, written sequentially; not closed
274
+ # @param schema [Schema, Symbol, nil] +:union+, +:intersect+, a Schema every input fits, or nil
275
+ # to require the inputs to have the same schema
276
+ # @param decryption [DecryptionConfiguration, Hash{Symbol => Object}, Array, #call, nil] keys of
277
+ # encrypted inputs given as IOs, see Reader.new
278
+ # @param writer_options [Hash{Symbol => Object}] Writer options for chunks encoded again (and
279
+ # written nulls), +metadata:+ and +encryption:+
280
+ # @option writer_options [Symbol] :compression (codec of each source chunk) codec for chunks
281
+ # encoded again
282
+ # @option writer_options [Integer, nil] :compression_level (nil) level for that codec, see Writer
283
+ # @option writer_options [Hash{String => String}] :metadata (the first input's) footer key/value metadata
284
+ # @option writer_options [EncryptionConfiguration, Hash{Symbol => Object}, Key, String, false] :encryption
285
+ # (encrypted like the encrypted inputs) see Writer; false for a plaintext output
286
+ # @option writer_options [Boolean, Array<String>, Hash{String => Boolean, Hash}] :bloom_filters (nil)
287
+ # columns whose encoded chunks get a bloom filter, besides those whose source chunk had one
288
+ # @option writer_options [Integer] :page_bytes (1MB) approximate uncompressed data page size
289
+ # @option writer_options [Integer] :page_rows (20_000) maximum rows per data page
290
+ # @option writer_options [Integer] :data_page_version (1) 1 or 2
291
+ # @option writer_options [Boolean, Array<String>] :dictionary (true) see Writer
292
+ # @option writer_options [Hash{String => Symbol}] :encodings ({}) see Writer
293
+ # @return [Combiner::Report] rows written, how many row groups were copied or rewritten, and per
294
+ # input which fields were filled with nulls, widened or dropped
295
+ # @raise [IncompatibleSchema] listing every input (and field) that does not fit the others or
296
+ # +schema:+
297
+ # @raise [ArgumentError] when there are no inputs, an input is neither an IO nor a Reader, the
298
+ # encrypted inputs are encrypted differently and +encryption:+ is not given, or for
299
+ # +row_group_bytes:+ / +row_group_rows:+
300
+ # @raise [DecryptionError] when an input is encrypted and a key it needs was not given
301
+ def combine(ios_or_readers, output_io, schema: nil, decryption: nil, **writer_options)
302
+ Combiner.new(ios_or_readers, schema: schema, decryption: decryption).apply(output_io, **writer_options)
216
303
  end
217
304
 
218
305
  # Compression codecs this process can read and write, e.g. [:none, :snappy, :gzip, :lz4, :lz4_hadoop, :zstd].
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: herringbone
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.1
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Julik Tarkhanov
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2026-10-04 00:00:00.000000000 Z
10
+ date: 2026-10-17 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: bigdecimal
@@ -45,6 +45,7 @@ files:
45
45
  - lib/herringbone/codecs/lz4.rb
46
46
  - lib/herringbone/codecs/lzo.rb
47
47
  - lib/herringbone/codecs/snappy.rb
48
+ - lib/herringbone/combiner.rb
48
49
  - lib/herringbone/compression.rb
49
50
  - lib/herringbone/encodings/delta.rb
50
51
  - lib/herringbone/encodings/plain.rb
@@ -58,6 +59,7 @@ files:
58
59
  - lib/herringbone/key.rb
59
60
  - lib/herringbone/reader.rb
60
61
  - lib/herringbone/reader/bloom_filters.rb
62
+ - lib/herringbone/reader/chunk_copier.rb
61
63
  - lib/herringbone/reader/column_chunk_reader.rb
62
64
  - lib/herringbone/reader/column_cursor.rb
63
65
  - lib/herringbone/reader/numo.rb
@@ -65,7 +67,9 @@ files:
65
67
  - lib/herringbone/reader/scan.rb
66
68
  - lib/herringbone/redaction.rb
67
69
  - lib/herringbone/redaction/rewriter.rb
70
+ - lib/herringbone/restricted_readable_io.rb
68
71
  - lib/herringbone/schema.rb
72
+ - lib/herringbone/schema/merge.rb
69
73
  - lib/herringbone/simple_writer.rb
70
74
  - lib/herringbone/thrift.rb
71
75
  - lib/herringbone/types.rb