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
@@ -14,6 +14,8 @@ module Herringbone
14
14
  #
15
15
  # 32-bit loads are done with four getbyte calls rather than unpack1(offset:) to stay
16
16
  # compatible with Ruby 3.0 - the speed difference on MRI is marginal.
17
+ #
18
+ # @api private
17
19
  module Snappy
18
20
  # Raised for corrupt or truncated Snappy input, and for input too large to compress.
19
21
  class Error < StandardError; end
@@ -227,32 +229,72 @@ module Herringbone
227
229
  out
228
230
  end
229
231
 
230
- # @param input [String] bytes to compress
232
+ # Several Strings are compressed as their concatenation without joining them: fragments are
233
+ # compressed straight out of the part holding them, and only a fragment straddling two parts
234
+ # is copied out. The output is the same as for the joined input.
235
+ #
236
+ # @param input [String, Array<String>] bytes to compress, or the parts of them
231
237
  # @return [String] raw snappy block in ASCII-8BIT
232
238
  # @raise [Error] if the input is larger than MAX_UNCOMPRESSED
233
239
  def compress(input)
234
- src = (input.encoding == Encoding::BINARY) ? input : input.b
235
- n = src.bytesize
240
+ parts = input.is_a?(Array) ? input.map { |part| binary(part) } : [binary(input)]
241
+ n = parts.sum(&:bytesize)
236
242
  # Checked for libsnappy too: it would truncate the 32-bit length preamble without complaint
237
243
  raise Error, "input too large for snappy" if n > MAX_UNCOMPRESSED
238
244
  if (lib = native)
239
- return lib.deflate(src)
245
+ return lib.deflate((parts.size == 1) ? parts.first : parts.join)
240
246
  end
241
247
 
242
248
  out = String.new(capacity: 32 + n + n / 6, encoding: Encoding::BINARY)
243
249
  write_varint(out, n)
244
250
  table = Array.new(1 << HASH_BITS, 0)
251
+ part_index = 0
252
+ offset = 0
245
253
  start = 0
246
254
  while start < n
247
255
  len = n - start
248
256
  len = BLOCK_SIZE if len > BLOCK_SIZE
249
257
  table.fill(0)
250
- compress_block(src, start, len, out, table)
258
+ part_index, offset = next_part(parts, part_index, offset)
259
+ part = parts[part_index]
260
+ if offset + len <= part.bytesize
261
+ compress_block(part, offset, len, out, table)
262
+ offset += len
263
+ else
264
+ fragment = String.new(capacity: len, encoding: Encoding::BINARY)
265
+ while fragment.bytesize < len
266
+ part_index, offset = next_part(parts, part_index, offset)
267
+ take = [len - fragment.bytesize, parts[part_index].bytesize - offset].min
268
+ fragment << parts[part_index].byteslice(offset, take)
269
+ offset += take
270
+ end
271
+ compress_block(fragment, 0, len, out, table)
272
+ end
251
273
  start += len
252
274
  end
253
275
  out
254
276
  end
255
277
 
278
+ # @param str [String] bytes in any encoding
279
+ # @return [String] +str+, or a binary copy when it is in another encoding
280
+ def binary(str)
281
+ (str.encoding == Encoding::BINARY) ? str : str.b
282
+ end
283
+
284
+ # Skips past parts that are used up
285
+ #
286
+ # @param parts [Array<String>] the input parts
287
+ # @param index [Integer] index of the current part
288
+ # @param offset [Integer] offset in the current part
289
+ # @return [Array(Integer, Integer)] index of a part with bytes left at the offset, and that offset
290
+ def next_part(parts, index, offset)
291
+ while offset >= parts[index].bytesize
292
+ index += 1
293
+ offset = 0
294
+ end
295
+ [index, offset]
296
+ end
297
+
256
298
  # Reads the uncompressed-length preamble (a little-endian base-128 varint) at the start of +src+.
257
299
  # @param src [String] raw snappy block
258
300
  # @param n [Integer] byte size of +src+
@@ -490,7 +532,7 @@ module Herringbone
490
532
  end
491
533
  end
492
534
 
493
- private_class_method :native, :native_library, :decompress_io_buffer, :decompress_string, :read_varint, :write_varint, :compress_block, :block_words, :match_length_words,
535
+ private_class_method :native, :native_library, :binary, :next_part, :decompress_io_buffer, :decompress_string, :read_varint, :write_varint, :compress_block, :block_words, :match_length_words,
494
536
  :match_length, :emit_literal, :emit_copy, :emit_copy_upto64
495
537
  end
496
538
  end
@@ -0,0 +1,426 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Herringbone
4
+ # Concatenates Parquet files into one, behind Herringbone.combine. Building it reads the footer of
5
+ # every input and checks every input against the output schema, so all mismatches surface at
6
+ # once, before anything is written.
7
+ #
8
+ # Every row group of every input becomes a row group of the output, in order. Column chunks are
9
+ # copied byte for byte wherever the output stores a column's values as the input does, and only
10
+ # their offsets (in the column metadata, the page index and the bloom filter references) are
11
+ # rebased. That holds per leaf column, so a narrower input still has most of its chunks copied.
12
+ # Columns that cannot be copied are encoded again from their values: those the output widens in
13
+ # physical type, time unit or nullability, and those encrypted in the input or to be encrypted in
14
+ # the output. Fields an input lacks are written as nulls.
15
+ #
16
+ # @api private
17
+ class Combiner
18
+ # What #apply did
19
+ #
20
+ # @!attribute rows
21
+ # @return [Integer] rows written
22
+ # @!attribute row_groups
23
+ # @return [Hash{Symbol => Integer}] +{copied:, rewritten:}+ row group counts: a row group is
24
+ # rewritten when at least one of its column chunks had to be encoded again
25
+ # @!attribute inputs
26
+ # @return [Array<InputReport>] how each input was fitted to the output schema, in input order
27
+ # @api public
28
+ Report = Struct.new(:rows, :row_groups, :inputs, keyword_init: true)
29
+
30
+ # How one input was fitted to the output schema. Paths are dotted field paths ("address.zip").
31
+ #
32
+ # @!attribute name
33
+ # @return [String] "input 2", with the IO's path when it has one: "input 2 (2026-03.parquet)"
34
+ # @!attribute filled
35
+ # @return [Array<String>] fields the input lacks, written as nulls
36
+ # @!attribute widened
37
+ # @return [Array<String>] fields the output holds in a wider type, or nullable
38
+ # @!attribute dropped
39
+ # @return [Array<String>] fields of the input the output does not have (+schema: :intersect+)
40
+ # @api public
41
+ InputReport = Struct.new(:name, :filled, :widened, :dropped, keyword_init: true)
42
+
43
+ # One input file: its Reader (with the combiner's own options), its ChunkCopier, output column
44
+ # index => the input's column holding its values (output columns the input lacks are not in
45
+ # it), and its InputReport
46
+ Input = Struct.new(:reader, :copier, :sources, :report)
47
+
48
+ # Closing paragraph of the message when inputs do not fit the output schema
49
+ FIT_ADVICE = <<~ADVICE.chomp
50
+ Pass a schema every input fits (schema: :union makes one), or schema: :intersect to keep only
51
+ the fields all inputs have.
52
+ ADVICE
53
+
54
+ # Encryption settings the inherited encryption takes from the inputs => how messages name them
55
+ ENCRYPTION_SETTINGS = {
56
+ footer_key: "footer key", footer_key_metadata: "footer key metadata", plaintext_footer: "footer mode",
57
+ algorithm: "algorithm", aad_prefix: "AAD prefix", store_aad_prefix: "AAD prefix storage"
58
+ }.freeze
59
+ # Settings whose values messages leave out
60
+ SECRET_SETTINGS = %i[footer_key aad_prefix].freeze
61
+
62
+ # Converted annotations of time and timestamp columns => their unit
63
+ CONVERTED_UNITS = {
64
+ Format::ConvertedType::TIME_MILLIS => :millis, Format::ConvertedType::TIME_MICROS => :micros,
65
+ Format::ConvertedType::TIMESTAMP_MILLIS => :millis, Format::ConvertedType::TIMESTAMP_MICROS => :micros
66
+ }.freeze
67
+
68
+ # @return [Schema] the schema of the output
69
+ attr_reader :schema
70
+
71
+ # @param ios_or_readers [Enumerable<IO, StringIO, Reader>] the input files: IOs read with #seek
72
+ # and #read, or Readers (which bring their own +decryption:+); enumerated once, and none is
73
+ # closed
74
+ # @param schema [Schema, Symbol, nil] the output schema: nil when every input has the same one,
75
+ # +:union+ for the union of the inputs' schemas, +:intersect+ for their intersection, or a
76
+ # Schema each input must fit
77
+ # @param decryption [DecryptionConfiguration, Hash{Symbol => Object}, Array, #call, nil] keys of
78
+ # encrypted inputs given as IOs, see Reader.new
79
+ # @raise [ArgumentError] when there are no inputs, an input is neither an IO nor a Reader, or
80
+ # +schema:+ is not one of the above
81
+ # @raise [IncompatibleSchema] listing every input (and every field of it) that does not fit
82
+ # @raise [FormatError] when an input's footer cannot be read
83
+ # @raise [DecryptionError] when an input's footer is encrypted and cannot be decrypted
84
+ def initialize(ios_or_readers, schema: nil, decryption: nil)
85
+ unless schema.is_a?(Schema) || [nil, :union, :intersect].include?(schema)
86
+ raise ArgumentError, "schema: takes :union, :intersect or a Herringbone::Schema, got #{schema.inspect}"
87
+ end
88
+ readers = open_readers(ios_or_readers, decryption)
89
+ @names = readers.each_with_index.map do |reader, k|
90
+ path = reader.io.path
91
+ path.equal?(RestrictedReadableIO::UNTITLED) ? "input #{k}" : "input #{k} (#{path})"
92
+ end
93
+ @conflicts = []
94
+ @schema = output_schema(schema, readers)
95
+ @inputs = readers.each_with_index.map { |reader, k| fit(reader, k, schema == :intersect) }
96
+ return if @conflicts.empty?
97
+ what = {union: "the union of the inputs", intersect: "the intersection of the inputs"}.fetch(schema, "given as schema:")
98
+ raise_conflicts("#{inputs_count("does", "do")} not fit the schema (#{what}):", "the schema", advice: FIT_ADVICE)
99
+ end
100
+
101
+ # @param output_io [IO, #write] destination, written sequentially; not closed
102
+ # @param options [Hash{Symbol => Object}] Writer options for chunks encoded again, and
103
+ # +metadata:+ / +encryption:+ for the output
104
+ # @option options [Symbol] :compression (codec of each source chunk) codec for chunks encoded
105
+ # again; written nulls use the Writer's default
106
+ # @option options [Hash{String => String}] :metadata (the first input's) footer key/value metadata
107
+ # @option options [EncryptionConfiguration, Hash{Symbol => Object}, Key, String, false] :encryption
108
+ # (encrypted like the encrypted inputs, if any) see Writer; false for a plaintext output
109
+ # @option options [Integer, nil] :compression_level (nil) level for that codec, see Writer
110
+ # @option options [Boolean, Array<String>, Hash{String => Boolean, Hash}] :bloom_filters (nil)
111
+ # columns whose encoded chunks get a bloom filter, besides those whose source chunk had one
112
+ # @option options [Integer] :page_bytes (1MB) approximate uncompressed data page size
113
+ # @option options [Integer] :page_rows (20_000) maximum rows per data page
114
+ # @option options [Integer] :data_page_version (1) 1 or 2
115
+ # @option options [Boolean, Array<String>] :dictionary (true) see Writer
116
+ # @option options [Hash{String => Symbol}] :encodings ({}) see Writer
117
+ # @return [Report]
118
+ # @raise [ArgumentError] for +row_group_bytes:+ / +row_group_rows:+, an invalid writer option,
119
+ # or encrypted inputs that are encrypted differently while +encryption:+ is not given
120
+ # @raise [DecryptionError] when a column is encrypted and its key was not given
121
+ def apply(output_io, **options)
122
+ options = {metadata: @inputs.first.copier.metadata(@schema)}.merge(options)
123
+ options[:encryption] = inherited_encryption unless options.key?(:encryption)
124
+ @keep_codecs = !options.key?(:compression)
125
+ report = Report.new(rows: 0, row_groups: {copied: 0, rewritten: 0}, inputs: @inputs.map(&:report))
126
+ Writer.open_for_copies(output_io, @schema, "combining keeps the row groups of the inputs", **options) do |writer|
127
+ @inputs.each do |input|
128
+ from = 0
129
+ input.reader.row_groups.each_with_index do |rg, i|
130
+ report.row_groups[write_row_group(input, i, from, writer)] += 1 unless rg.num_rows.zero?
131
+ from += rg.num_rows
132
+ end
133
+ end
134
+ report.rows = writer.rows_written
135
+ end
136
+ report
137
+ end
138
+
139
+ private
140
+
141
+ # Readers with the combiner's own options. A Reader given as input lends its IO and its
142
+ # decryption, but not its +keys:+ or +time_zone:+, which would change the values encoded again.
143
+ #
144
+ # @param ios_or_readers [Enumerable<IO, StringIO, Reader>] the inputs
145
+ # @param decryption [Object, nil] keys for the inputs given as IOs
146
+ # @return [Array<Reader>]
147
+ # @raise [ArgumentError] when there are no inputs, or one is neither an IO nor a Reader
148
+ def open_readers(ios_or_readers, decryption)
149
+ # A single IO is Enumerable too (over its lines), hence the check for #read
150
+ if ios_or_readers.respond_to?(:read) || !ios_or_readers.respond_to?(:each)
151
+ raise ArgumentError, "Herringbone.combine takes an Enumerable of IOs or Herringbone::Readers, " \
152
+ "got #{ios_or_readers.class}: Herringbone.combine([a, b], output_io)"
153
+ end
154
+ readers = ios_or_readers.each_with_index.map do |item, k|
155
+ next Reader.new(item.io, decryption: item.decryption) if item.is_a?(Reader)
156
+ next Reader.new(item, decryption: decryption) if item.respond_to?(:seek) && item.respond_to?(:read)
157
+ hint = "; Herringbone does not open files by path, pass File.open(path, \"rb\")" if item.is_a?(String) || item.respond_to?(:to_path)
158
+ raise ArgumentError, "Input #{k} is a #{item.class}, not an IO or a Herringbone::Reader#{hint}"
159
+ end
160
+ raise ArgumentError, "Herringbone.combine needs at least one input" if readers.empty?
161
+ readers
162
+ end
163
+
164
+ # @param schema [Schema, Symbol, nil] the +schema:+ option
165
+ # @param readers [Array<Reader>] the inputs
166
+ # @return [Schema]
167
+ # @raise [IncompatibleSchema] when the inputs' schemas differ (nil), or do not unite or intersect
168
+ def output_schema(schema, readers)
169
+ return schema if schema.is_a?(Schema)
170
+ first = readers.first.schema
171
+ readers.each_with_index.drop(1).each do |reader, k|
172
+ if schema.nil?
173
+ next if reader.schema == first
174
+ before = @conflicts.size
175
+ differences(first, reader.schema, k, "input 0")
176
+ if @conflicts.size == before
177
+ reordered = reader.schema.fields.map(&:name) != first.fields.map(&:name)
178
+ conflict([], nil, nil, reordered ? "the fields are in another order than in input 0" :
179
+ "the same fields as input 0, annotated or laid out another way", k)
180
+ end
181
+ else
182
+ first = (schema == :union) ? first + reader.schema : first & reader.schema
183
+ end
184
+ rescue IncompatibleSchema => e
185
+ e.conflicts.each { |c| conflict(c.path, c.left, c.right, c.reason, k) }
186
+ end
187
+ return first if @conflicts.empty?
188
+ if schema.nil?
189
+ raise_conflicts("#{inputs_count("has", "have")} another schema than #{@names[0]}:", "input 0", advice: <<~ADVICE.chomp)
190
+ Combine files with the same schema, or pass schema: :union to fill the fields an input lacks
191
+ with nulls and widen the others, or schema: :intersect to keep only the fields all inputs have.
192
+ ADVICE
193
+ end
194
+ raise_conflicts("Cannot #{(schema == :union) ? "unite" : "intersect"} the schemas of the inputs, " \
195
+ "#{inputs_count("does", "do")} not fit those before it:", "the inputs before it")
196
+ end
197
+
198
+ # Each field of +mine+ with the field of the same name in +theirs+ (nil when there is none),
199
+ # then the same for their members down through structs, lists and maps
200
+ #
201
+ # @param mine [Schema] the schema walked
202
+ # @param theirs [Schema] the schema its fields are looked up in
203
+ # @return [Array<Array(Schema::Field, Schema::Field, String)>] field, twin and dotted path
204
+ def pairs(mine, theirs)
205
+ walk = lambda do |a, b, path|
206
+ [[a, b, path.join(".")]] + case (b && a.kind == b.kind) ? a.kind : nil
207
+ when :struct
208
+ by_name = b.children_by_name
209
+ a.children.flat_map { |c| walk.call(c, by_name[c.name], path + [c.name]) }
210
+ when :list then walk.call(a.element, b.element, path + ["element"])
211
+ when :map then walk.call(a.key, b.key, path + ["key"]) + ((a.value && b.value) ? walk.call(a.value, b.value, path + ["value"]) : [])
212
+ else []
213
+ end
214
+ end
215
+ mine.fields.flat_map { |f| walk.call(f, theirs.field(f.name), [f.name]) }
216
+ end
217
+
218
+ # Where the fields of +theirs+ differ from those of +mine+, recorded as conflicts
219
+ #
220
+ # @param mine [Schema] the schema compared with
221
+ # @param theirs [Schema] the input's schema
222
+ # @param k [Integer] the input's position
223
+ # @param other [String] what to call +mine+, e.g. "input 0"
224
+ # @param skip [Array<String>] paths not to compare
225
+ # @return [void]
226
+ def differences(mine, theirs, k, other, skip: [])
227
+ pairs(mine, theirs).each do |a, b, path|
228
+ next if skip.include?(path)
229
+ if b.nil? then conflict(path, nil, nil, "missing here, #{other} has it", k)
230
+ elsif (da = describe(a)) != (db = describe(b)) then conflict(path, da, db, nil, k)
231
+ elsif a.optional != b.optional
232
+ conflict(path, a.optional ? "nullable" : "required", b.optional ? "nullable" : "required", nil, k)
233
+ elsif a.node.field_id != b.node.field_id
234
+ conflict(path, "field_id #{a.node.field_id.inspect}", "field_id #{b.node.field_id.inspect}", nil, k)
235
+ end
236
+ end
237
+ pairs(theirs, mine).each { |_, a, path| conflict(path, nil, nil, "only here, #{other} lacks it", k) unless a }
238
+ end
239
+
240
+ # Fits an input to the output schema, recording the conflicts when it does not fit. The input
241
+ # must be the same as the output or narrower: uniting it with the output (Schema#+) must leave
242
+ # the output as it is. Each output leaf is found in the input through the logical tree, by
243
+ # name, so the element of a pyarrow list ("list.item") or of a legacy 2-level list is still the
244
+ # same column.
245
+ #
246
+ # @param reader [Reader] the input
247
+ # @param k [Integer] its position
248
+ # @param drop [Boolean] whether fields the output lacks are dropped (+schema: :intersect+)
249
+ # rather than refused
250
+ # @return [Input]
251
+ def fit(reader, k, drop)
252
+ theirs = reader.schema
253
+ pairs = pairs(@schema, theirs)
254
+ filled = pairs.filter_map { |_, b, path| path unless b }
255
+ dropped = pairs(theirs, @schema).filter_map { |_, a, path| path unless a }
256
+ if pairs.all? { |_, b, _| b.nil? }
257
+ conflict([], @schema.fields.map(&:name).join(", "), theirs.fields.map(&:name).join(", "), "no fields in common with the schema", k)
258
+ else
259
+ pairs.each { |a, b, path| conflict(path, nil, nil, "missing here, and the schema requires it", k) if b.nil? && !a.optional }
260
+ begin
261
+ fitted = @schema + (drop ? theirs & @schema : theirs)
262
+ differences(@schema, fitted, k, "the schema", skip: filled)
263
+ rescue IncompatibleSchema => e
264
+ e.conflicts.each { |c| conflict(c.path, c.left, c.right, c.reason, k) }
265
+ end
266
+ end
267
+ sources = pairs.filter_map { |a, b, _| [a.column.index, b.column] if b && a.leaf? && b.leaf? }.to_h
268
+ widened = pairs.filter_map { |a, b, path| path if b && (a.optional != b.optional || (a.leaf? && describe(a) != describe(b))) }
269
+ report = InputReport.new(name: @names[k], filled: filled, widened: widened, dropped: dropped)
270
+ Input.new(reader, Reader::ChunkCopier.new(reader), sources, report)
271
+ end
272
+
273
+ # Whether a chunk of +theirs+ holds the values of +mine+ byte for byte. For a column of an
274
+ # input that fits the output, the widenings that keep the bytes are those of the annotation
275
+ # alone: int8 into int32, uint16 into int32, string into binary... Equal levels mean that no
276
+ # field on the way became nullable, since nullability only ever widens here.
277
+ #
278
+ # @param mine [Schema::Column] a column of the output schema
279
+ # @param theirs [Schema::Column] the input's column for it
280
+ # @return [Boolean]
281
+ def same_bytes?(mine, theirs)
282
+ mine.type == theirs.type && mine.type_length == theirs.type_length &&
283
+ mine.max_definition_level == theirs.max_definition_level &&
284
+ mine.max_repetition_level == theirs.max_repetition_level &&
285
+ time_unit(mine.node) == time_unit(theirs.node)
286
+ end
287
+
288
+ # @param node [Schema::Node] a leaf
289
+ # @return [Symbol, nil] the unit of a time or timestamp column, nil for other columns
290
+ def time_unit(node)
291
+ lt = node.logical_type
292
+ (lt&.timestamp || lt&.time)&.unit&.to_sym || CONVERTED_UNITS[node.converted_type]
293
+ end
294
+
295
+ # @param field [Schema::Field] any field
296
+ # @return [String] its type as messages name it, see Schema::Merge#describe
297
+ def describe(field) = (@describer ||= Schema::Merge.new(:union)).describe(field)
298
+
299
+ # @param path [String, Array<String>] path of the field, empty for the input as a whole
300
+ # @param left [String, nil] the field as the schema compared with has it
301
+ # @param right [String, nil] the field as the input has it
302
+ # @param reason [String, nil] why they do not fit
303
+ # @param k [Integer] the input's position
304
+ # @return [void]
305
+ def conflict(path, left, right, reason, k)
306
+ @conflicts << IncompatibleSchema::Conflict.new(Array(path).join("."), left, right, reason, k)
307
+ end
308
+
309
+ # @param singular [String] the verb for one input, e.g. "does"
310
+ # @param plural [String] the verb for several, e.g. "do"
311
+ # @return [String] the inputs with conflicts and the verb: "2 of 5 inputs do", "1 of 5 inputs
312
+ # does", or "The input does" when there is only one
313
+ def inputs_count(singular, plural)
314
+ n = @conflicts.map(&:input).uniq.size
315
+ return "The input #{singular}" if @names.size == 1
316
+ "#{n} of #{@names.size} inputs #{(n == 1) ? singular : plural}"
317
+ end
318
+
319
+ # Raises the conflicts, grouped by input, as one IncompatibleSchema:
320
+ #
321
+ # 2 of 3 inputs do not fit the schema (schema:):
322
+ #
323
+ # input 1 (2026-02.parquet)
324
+ # price double in the schema, string here (no common type)
325
+ # address.zip required in the schema, nullable here
326
+ #
327
+ # @param header [String] the first line
328
+ # @param other [String] what to call the schema the inputs are compared with, e.g. "the schema"
329
+ # @param advice [String, nil] a closing paragraph
330
+ # @return [void]
331
+ # @raise [IncompatibleSchema] always
332
+ def raise_conflicts(header, other, advice: nil)
333
+ groups = @conflicts.group_by(&:input).map do |k, conflicts|
334
+ width = conflicts.map { |c| c.path.size }.max
335
+ lines = conflicts.map do |c|
336
+ if c.path.empty? && c.left
337
+ "#{c.reason}\n #{other}: #{c.left.delete_prefix("fields ")}\n here: #{c.right.delete_prefix("fields ")}"
338
+ elsif c.path.empty?
339
+ c.reason
340
+ else
341
+ detail = [("#{c.left} in #{other}, #{c.right} here" if c.left), ("(#{c.reason})" if c.reason && c.left), (c.reason unless c.left)]
342
+ "#{c.path.ljust(width)} #{detail.compact.join(" ")}"
343
+ end
344
+ end
345
+ " #{@names[k]}\n#{lines.map { |l| " #{l}" }.join("\n")}"
346
+ end
347
+ message = [header, groups.join("\n\n"), advice].compact.join("\n\n")
348
+ raise IncompatibleSchema.new(@conflicts, message: message)
349
+ end
350
+
351
+ # The +encryption:+ option that encrypts the output like the encrypted inputs: the same
352
+ # algorithm, footer mode, footer key and key metadata, AAD prefix, and the same key for each
353
+ # column. Plaintext inputs get that encryption too.
354
+ #
355
+ # @return [EncryptionConfiguration, nil] nil when no input is encrypted
356
+ # @raise [ArgumentError] listing how the encrypted inputs differ, when they do
357
+ # @raise [DecryptionError] when a key the output needs was not given
358
+ def inherited_encryption
359
+ encrypted = @inputs.select { |input| input.reader.decryptor }
360
+ return nil if encrypted.empty?
361
+ settings = encrypted.map { |input| input.reader.decryptor.writer_settings(nil).to_h.except(:columns) }
362
+ problems = settings.first.keys.filter_map do |setting|
363
+ groups = encrypted.zip(settings).group_by { |_, h| h[setting] }
364
+ next if groups.size == 1
365
+ "#{ENCRYPTION_SETTINGS.fetch(setting)} differs: " + groups.map { |value, members|
366
+ "#{members.map { |input, _| input.report.name }.join(", ")}#{" (#{value.inspect})" unless SECRET_SETTINGS.include?(setting)}"
367
+ }.join(" / ")
368
+ end
369
+ columns = {}
370
+ owners = {}
371
+ layouts = encrypted.map { |input| input.copier.encrypted_columns(input.sources.to_h { |j, col| [@schema.columns[j].dotted_path, col] }) }
372
+ encrypted.zip(layouts).each do |input, layout|
373
+ layout.each do |path, setting|
374
+ owner = owners[path] ||= input.report.name
375
+ columns[path] ||= setting
376
+ problems << "#{path} is encrypted with another key in #{input.report.name} than in #{owner}" if columns[path] != setting
377
+ end
378
+ end
379
+ unless problems.empty?
380
+ raise ArgumentError, <<~MESSAGE.chomp
381
+ The encrypted inputs are encrypted differently, so there is no "encrypted like the inputs":
382
+
383
+ #{problems.map { |p| " #{p}" }.join("\n")}
384
+
385
+ Pass encryption: for the output (a Herringbone::Key or an EncryptionConfiguration), or
386
+ encryption: false to write it in plaintext.
387
+ MESSAGE
388
+ end
389
+ uniform = encrypted.zip(layouts).all? { |input, layout| layout.size == input.sources.size && layout.values.all?(:footer) }
390
+ EncryptionConfiguration.new(**settings.first, columns: uniform ? nil : columns)
391
+ end
392
+
393
+ # Writes row group +i+ of +input+, copying the column chunks it can. A field with a column that
394
+ # cannot be copied is read as a whole, since Reader and Writer handle top-level fields; the
395
+ # columns of it that can be copied still are.
396
+ #
397
+ # @param input [Input] the input file
398
+ # @param i [Integer] row group index in the input
399
+ # @param from [Integer] index of the row group's first row in the input
400
+ # @param writer [Writer] the output
401
+ # @return [Symbol] +:copied+ or +:rewritten+
402
+ def write_row_group(input, i, from, writer)
403
+ chunks = input.reader.row_groups[i].columns
404
+ n = input.reader.row_groups[i].num_rows
405
+ copies = {}
406
+ codecs = {}
407
+ blooms = {}
408
+ input.sources.each do |j, source|
409
+ if same_bytes?(@schema.columns[j], source) && !writer.encrypted_column?(j) && !chunks.fetch(source.index).crypto_metadata
410
+ copies[j] = input.copier.copy(i, source)
411
+ else
412
+ codec, bloom = input.copier.recode_settings(i, source)
413
+ codecs[j] = codec if codec && @keep_codecs
414
+ blooms[j] = true if bloom
415
+ end
416
+ end
417
+ encode = @schema.fields.reject { |field| field.leaves.all? { |col| copies.key?(col.index) } }
418
+ present = encode.map(&:name) & input.reader.schema.fields.map(&:name)
419
+ data = present.empty? ? {} : input.reader.read(as: :columns, columns: present, from: from, limit: n)
420
+ encode.each { |field| writer.buffer_field(field.name, data.fetch(field.name) { Array.new(n) }) }
421
+ sorting = input.copier.sorting_columns(i, input.sources.to_h { |j, source| [source.index, j] })
422
+ writer.write_row_group(n, copies: copies, codecs: codecs, bloom_filters: blooms, sorting_columns: sorting)
423
+ (copies.size == @schema.columns.size) ? :copied : :rewritten
424
+ end
425
+ end
426
+ end
@@ -4,7 +4,8 @@ require "zlib"
4
4
  require "stringio"
5
5
 
6
6
  module Herringbone
7
- # Raised when a file uses (or a writer asks for) a codec whose library is not loaded
7
+ # Raised when a file uses (or a writer asks for) a codec whose library is not loaded. The message
8
+ # says which gem to install, and that it has to be required before the read or write.
8
9
  class MissingCodecError < UnsupportedError
9
10
  # @return [String] codec name (+"ZSTD"+) and name of the gem providing it (+"zstd-ruby"+)
10
11
  attr_reader :codec, :gem_name
@@ -12,11 +13,29 @@ module Herringbone
12
13
  # @param codec [String] codec name, as shown in the message
13
14
  # @param gem_name [String] gem to add to the Gemfile
14
15
  # @param path [String] what to require to load the gem
15
- def initialize(codec, gem_name, path)
16
+ # @param column [String, nil] dotted path of the column being read, when there is one
17
+ def initialize(codec, gem_name, path, column: nil)
16
18
  @codec = codec
17
19
  @gem_name = gem_name
18
- super("#{codec} compression needs the \"#{gem_name}\" gem, which is not loaded. Add " \
19
- "`gem \"#{gem_name}\"` to your Gemfile and `require \"#{path}\"` to use #{codec}.")
20
+ @path = path
21
+ where = column ? " to decompress column #{column}" : ""
22
+ super(<<~MESSAGE.chomp)
23
+ #{codec} compression needs the "#{gem_name}" gem#{where}, but it is not loaded.
24
+ Herringbone does not load optional codec gems by itself. To use #{codec}:
25
+
26
+ 1. Install the gem: add `gem "#{gem_name}"` to your Gemfile, or run `gem install #{gem_name}`.
27
+ 2. Load it with `require "#{path}"` before the read or write that needs #{codec},
28
+ for instance at the top of your script or in an initializer.
29
+ MESSAGE
30
+ end
31
+
32
+ # The same error, naming the column that could not be decompressed
33
+ #
34
+ # @param column [String] dotted path of the column
35
+ # @return [MissingCodecError]
36
+ # @api private
37
+ def for_column(column)
38
+ self.class.new(@codec, @gem_name, @path, column: column).tap { |e| e.set_backtrace(backtrace) if backtrace }
20
39
  end
21
40
  end
22
41
 
@@ -25,6 +44,8 @@ module Herringbone
25
44
  # come from optional gems (zstd-ruby, brotli), which the application requires; if they are
26
45
  # not loaded, MissingCodecError says what to add.
27
46
  # (Snappy also uses the optional snappy gem when it is loaded, see Codecs::Snappy.)
47
+ #
48
+ # @api private
28
49
  module Compression
29
50
  module_function
30
51
 
@@ -129,7 +150,7 @@ module Herringbone
129
150
  def decompress(codec, data, uncompressed_size)
130
151
  return "".b if uncompressed_size.zero? && data.empty?
131
152
  out = case codec
132
- when Format::Codec::UNCOMPRESSED then data
153
+ when Format::Codec::UNCOMPRESSED then (data.encoding == Encoding::BINARY) ? data : data.b
133
154
  when Format::Codec::SNAPPY then Codecs::Snappy.decompress(data)
134
155
  when Format::Codec::GZIP then gunzip(data)
135
156
  when Format::Codec::LZ4_RAW then Codecs::LZ4.decompress_block(data, uncompressed_size)
@@ -140,33 +161,75 @@ module Herringbone
140
161
  else
141
162
  raise UnsupportedError, "Unsupported compression codec #{Format::Codec::NAMES[codec] || codec}"
142
163
  end
143
- out = out.b
164
+ out = binary(out)
144
165
  if out.bytesize != uncompressed_size
145
166
  raise FormatError, "Decompressed #{out.bytesize} bytes, expected #{uncompressed_size}"
146
167
  end
147
168
  out
148
169
  end
149
170
 
150
- # Compresses a page body
171
+ # Compresses a page body. Given several Strings, compresses their concatenation; GZIP, ZSTD
172
+ # and Snappy take them one by one, the other codecs join them first.
151
173
  #
152
174
  # @param codec [Integer] Format::Codec id
153
- # @param data [String] bytes to compress
175
+ # @param data [String, Array<String>] bytes to compress, or the parts of them
154
176
  # @param level [Integer, nil] compression level checked with check_level!, nil for the default
155
177
  # @return [String] compressed bytes (binary)
156
178
  # @raise [UnsupportedError] for a codec herringbone does not implement
157
179
  # @raise [MissingCodecError] when the codec's gem is not loaded
158
180
  def compress(codec, data, level = nil)
181
+ parts = data.is_a?(Array) ? data : [data]
159
182
  case codec
160
- when Format::Codec::UNCOMPRESSED then data
161
- when Format::Codec::SNAPPY then Codecs::Snappy.compress(data)
162
- when Format::Codec::GZIP then level ? Zlib.gzip(data, level: level) : Zlib.gzip(data)
183
+ when Format::Codec::GZIP then return binary(gzip(parts, level))
184
+ when Format::Codec::ZSTD then return binary(zstd(parts, level))
185
+ when Format::Codec::SNAPPY then return binary(Codecs::Snappy.compress(data))
186
+ end
187
+ data = parts.join if data.is_a?(Array)
188
+ out = case codec
189
+ when Format::Codec::UNCOMPRESSED then (data.encoding == Encoding::BINARY) ? data : data.b
163
190
  when Format::Codec::LZ4_RAW then Codecs::LZ4.compress_block(data)
164
191
  when Format::Codec::LZ4 then Codecs::LZ4.compress_hadoop(data)
165
- when Format::Codec::ZSTD then level ? library(codec).compress(data, level: level) : library(codec).compress(data)
166
192
  when Format::Codec::BROTLI then level ? library(codec).deflate(data, quality: level) : library(codec).deflate(data)
167
193
  else
168
194
  raise UnsupportedError, "Unsupported compression codec #{Format::Codec::NAMES[codec] || codec}"
169
- end.b
195
+ end
196
+ binary(out)
197
+ end
198
+
199
+ # A gzip member with no file name and a zero mtime, like Zlib.gzip makes
200
+ #
201
+ # @param parts [Array<String>] bytes to compress, in order
202
+ # @param level [Integer, nil] zlib level, nil for the default
203
+ # @return [String] gzip data
204
+ def gzip(parts, level)
205
+ z = Zlib::Deflate.new(level || Zlib::DEFAULT_COMPRESSION, Zlib::MAX_WBITS + 16)
206
+ parts.each { |part| z << part }
207
+ z.finish
208
+ ensure
209
+ z&.close
210
+ end
211
+
212
+ # @param parts [Array<String>] bytes to compress, in order
213
+ # @param level [Integer, nil] zstd level, nil for the default
214
+ # @return [String] a single zstd frame
215
+ # @raise [MissingCodecError] when zstd-ruby is not loaded
216
+ def zstd(parts, level)
217
+ zstd = library(Format::Codec::ZSTD)
218
+ if parts.size == 1
219
+ return level ? zstd.compress(parts.first, level: level) : zstd.compress(parts.first)
220
+ end
221
+ stream = level ? zstd::StreamingCompress.new(level: level) : zstd::StreamingCompress.new
222
+ parts.each { |part| stream << part }
223
+ stream.finish
224
+ end
225
+
226
+ # Codec output relabelled as binary without copying it, unless it is frozen
227
+ #
228
+ # @param str [String] bytes
229
+ # @return [String] +str+ or a binary copy of it
230
+ def binary(str)
231
+ return str if str.encoding == Encoding::BINARY
232
+ str.frozen? ? str.b : str.force_encoding(Encoding::BINARY)
170
233
  end
171
234
 
172
235
  # Handles files whose gzip data consists of several concatenated members