herringbone 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +40 -0
  3. data/MANUAL.md +161 -12
  4. data/README.md +19 -4
  5. data/bin/herringbone +56 -1
  6. data/lib/herringbone/active_record.rb +2 -0
  7. data/lib/herringbone/bloom_filter.rb +2 -0
  8. data/lib/herringbone/byte_values.rb +2 -0
  9. data/lib/herringbone/codecs/lz4.rb +9 -4
  10. data/lib/herringbone/codecs/lzo.rb +2 -0
  11. data/lib/herringbone/codecs/snappy.rb +48 -6
  12. data/lib/herringbone/combiner.rb +426 -0
  13. data/lib/herringbone/compression.rb +53 -9
  14. data/lib/herringbone/encodings/delta.rb +4 -0
  15. data/lib/herringbone/encodings/plain.rb +2 -0
  16. data/lib/herringbone/encodings/rle.rb +2 -0
  17. data/lib/herringbone/encryption.rb +2 -0
  18. data/lib/herringbone/inspector.rb +29 -5
  19. data/lib/herringbone/io_buffer_support.rb +2 -0
  20. data/lib/herringbone/reader/bloom_filters.rb +1 -0
  21. data/lib/herringbone/reader/chunk_copier.rb +177 -0
  22. data/lib/herringbone/reader/column_chunk_reader.rb +4 -2
  23. data/lib/herringbone/reader/column_cursor.rb +2 -0
  24. data/lib/herringbone/reader/numo.rb +4 -0
  25. data/lib/herringbone/reader/page_stream.rb +2 -0
  26. data/lib/herringbone/reader/scan.rb +2 -0
  27. data/lib/herringbone/reader.rb +19 -6
  28. data/lib/herringbone/redaction/rewriter.rb +51 -157
  29. data/lib/herringbone/redaction.rb +25 -15
  30. data/lib/herringbone/restricted_readable_io.rb +85 -0
  31. data/lib/herringbone/schema/merge.rb +355 -0
  32. data/lib/herringbone/schema.rb +80 -0
  33. data/lib/herringbone/thrift.rb +166 -68
  34. data/lib/herringbone/types.rb +2 -0
  35. data/lib/herringbone/version.rb +1 -1
  36. data/lib/herringbone/visualizer.rb +2 -0
  37. data/lib/herringbone/writer.rb +58 -22
  38. data/lib/herringbone/xxhash.rb +2 -0
  39. data/lib/herringbone.rb +96 -9
  40. metadata +6 -2
@@ -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
@@ -25,6 +25,8 @@ module Herringbone
25
25
  # come from optional gems (zstd-ruby, brotli), which the application requires; if they are
26
26
  # not loaded, MissingCodecError says what to add.
27
27
  # (Snappy also uses the optional snappy gem when it is loaded, see Codecs::Snappy.)
28
+ #
29
+ # @api private
28
30
  module Compression
29
31
  module_function
30
32
 
@@ -129,7 +131,7 @@ module Herringbone
129
131
  def decompress(codec, data, uncompressed_size)
130
132
  return "".b if uncompressed_size.zero? && data.empty?
131
133
  out = case codec
132
- when Format::Codec::UNCOMPRESSED then data
134
+ when Format::Codec::UNCOMPRESSED then (data.encoding == Encoding::BINARY) ? data : data.b
133
135
  when Format::Codec::SNAPPY then Codecs::Snappy.decompress(data)
134
136
  when Format::Codec::GZIP then gunzip(data)
135
137
  when Format::Codec::LZ4_RAW then Codecs::LZ4.decompress_block(data, uncompressed_size)
@@ -140,33 +142,75 @@ module Herringbone
140
142
  else
141
143
  raise UnsupportedError, "Unsupported compression codec #{Format::Codec::NAMES[codec] || codec}"
142
144
  end
143
- out = out.b
145
+ out = binary(out)
144
146
  if out.bytesize != uncompressed_size
145
147
  raise FormatError, "Decompressed #{out.bytesize} bytes, expected #{uncompressed_size}"
146
148
  end
147
149
  out
148
150
  end
149
151
 
150
- # Compresses a page body
152
+ # Compresses a page body. Given several Strings, compresses their concatenation; GZIP, ZSTD
153
+ # and Snappy take them one by one, the other codecs join them first.
151
154
  #
152
155
  # @param codec [Integer] Format::Codec id
153
- # @param data [String] bytes to compress
156
+ # @param data [String, Array<String>] bytes to compress, or the parts of them
154
157
  # @param level [Integer, nil] compression level checked with check_level!, nil for the default
155
158
  # @return [String] compressed bytes (binary)
156
159
  # @raise [UnsupportedError] for a codec herringbone does not implement
157
160
  # @raise [MissingCodecError] when the codec's gem is not loaded
158
161
  def compress(codec, data, level = nil)
162
+ parts = data.is_a?(Array) ? data : [data]
159
163
  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)
164
+ when Format::Codec::GZIP then return binary(gzip(parts, level))
165
+ when Format::Codec::ZSTD then return binary(zstd(parts, level))
166
+ when Format::Codec::SNAPPY then return binary(Codecs::Snappy.compress(data))
167
+ end
168
+ data = parts.join if data.is_a?(Array)
169
+ out = case codec
170
+ when Format::Codec::UNCOMPRESSED then (data.encoding == Encoding::BINARY) ? data : data.b
163
171
  when Format::Codec::LZ4_RAW then Codecs::LZ4.compress_block(data)
164
172
  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
173
  when Format::Codec::BROTLI then level ? library(codec).deflate(data, quality: level) : library(codec).deflate(data)
167
174
  else
168
175
  raise UnsupportedError, "Unsupported compression codec #{Format::Codec::NAMES[codec] || codec}"
169
- end.b
176
+ end
177
+ binary(out)
178
+ end
179
+
180
+ # A gzip member with no file name and a zero mtime, like Zlib.gzip makes
181
+ #
182
+ # @param parts [Array<String>] bytes to compress, in order
183
+ # @param level [Integer, nil] zlib level, nil for the default
184
+ # @return [String] gzip data
185
+ def gzip(parts, level)
186
+ z = Zlib::Deflate.new(level || Zlib::DEFAULT_COMPRESSION, Zlib::MAX_WBITS + 16)
187
+ parts.each { |part| z << part }
188
+ z.finish
189
+ ensure
190
+ z&.close
191
+ end
192
+
193
+ # @param parts [Array<String>] bytes to compress, in order
194
+ # @param level [Integer, nil] zstd level, nil for the default
195
+ # @return [String] a single zstd frame
196
+ # @raise [MissingCodecError] when zstd-ruby is not loaded
197
+ def zstd(parts, level)
198
+ zstd = library(Format::Codec::ZSTD)
199
+ if parts.size == 1
200
+ return level ? zstd.compress(parts.first, level: level) : zstd.compress(parts.first)
201
+ end
202
+ stream = level ? zstd::StreamingCompress.new(level: level) : zstd::StreamingCompress.new
203
+ parts.each { |part| stream << part }
204
+ stream.finish
205
+ end
206
+
207
+ # Codec output relabelled as binary without copying it, unless it is frozen
208
+ #
209
+ # @param str [String] bytes
210
+ # @return [String] +str+ or a binary copy of it
211
+ def binary(str)
212
+ return str if str.encoding == Encoding::BINARY
213
+ str.frozen? ? str.b : str.force_encoding(Encoding::BINARY)
170
214
  end
171
215
 
172
216
  # Handles files whose gzip data consists of several concatenated members
@@ -3,6 +3,8 @@
3
3
  module Herringbone
4
4
  module Encodings
5
5
  # DELTA_BINARY_PACKED, DELTA_LENGTH_BYTE_ARRAY and DELTA_BYTE_ARRAY
6
+ #
7
+ # @api private
6
8
  module Delta
7
9
  module_function
8
10
 
@@ -175,6 +177,8 @@ module Herringbone
175
177
  end
176
178
 
177
179
  # BYTE_STREAM_SPLIT: byte k of every value is stored in stream k.
180
+ #
181
+ # @api private
178
182
  module ByteStreamSplit
179
183
  module_function
180
184
 
@@ -4,6 +4,8 @@ module Herringbone
4
4
  module Encodings
5
5
  # PLAIN encoding for all physical types. Values are returned in their physical
6
6
  # Ruby form (Integer, Float, true/false, binary String); logical conversion happens elsewhere.
7
+ #
8
+ # @api private
7
9
  module Plain
8
10
  module_function
9
11
 
@@ -7,6 +7,8 @@ module Herringbone
7
7
  module Encodings
8
8
  # Bit packing (LSB-first, as used by Parquet) and the RLE / bit-packed hybrid encoding
9
9
  # used for repetition/definition levels, dictionary indices and RLE booleans.
10
+ #
11
+ # @api private
10
12
  module RLE
11
13
  module_function
12
14
 
@@ -9,6 +9,8 @@ module Herringbone
9
9
  #
10
10
  # An encrypted module is stored as a 4-byte little-endian length, then a 12-byte nonce, the
11
11
  # ciphertext and (GCM only) a 16-byte tag.
12
+ #
13
+ # @api private
12
14
  module Encryption
13
15
  # Module type: the FileMetaData
14
16
  FOOTER = 0