herringbone 0.6.1 → 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 +34 -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 +49 -10
  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
@@ -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.7.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-09 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