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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +40 -0
- data/MANUAL.md +161 -12
- data/README.md +19 -4
- data/bin/herringbone +56 -1
- data/lib/herringbone/active_record.rb +2 -0
- data/lib/herringbone/bloom_filter.rb +2 -0
- data/lib/herringbone/byte_values.rb +2 -0
- data/lib/herringbone/codecs/lz4.rb +9 -4
- data/lib/herringbone/codecs/lzo.rb +2 -0
- data/lib/herringbone/codecs/snappy.rb +48 -6
- data/lib/herringbone/combiner.rb +426 -0
- data/lib/herringbone/compression.rb +53 -9
- data/lib/herringbone/encodings/delta.rb +4 -0
- data/lib/herringbone/encodings/plain.rb +2 -0
- data/lib/herringbone/encodings/rle.rb +2 -0
- data/lib/herringbone/encryption.rb +2 -0
- data/lib/herringbone/inspector.rb +29 -5
- data/lib/herringbone/io_buffer_support.rb +2 -0
- data/lib/herringbone/reader/bloom_filters.rb +1 -0
- data/lib/herringbone/reader/chunk_copier.rb +177 -0
- data/lib/herringbone/reader/column_chunk_reader.rb +4 -2
- data/lib/herringbone/reader/column_cursor.rb +2 -0
- data/lib/herringbone/reader/numo.rb +4 -0
- data/lib/herringbone/reader/page_stream.rb +2 -0
- data/lib/herringbone/reader/scan.rb +2 -0
- data/lib/herringbone/reader.rb +19 -6
- data/lib/herringbone/redaction/rewriter.rb +51 -157
- data/lib/herringbone/redaction.rb +25 -15
- data/lib/herringbone/restricted_readable_io.rb +85 -0
- data/lib/herringbone/schema/merge.rb +355 -0
- data/lib/herringbone/schema.rb +80 -0
- data/lib/herringbone/thrift.rb +166 -68
- data/lib/herringbone/types.rb +2 -0
- data/lib/herringbone/version.rb +1 -1
- data/lib/herringbone/visualizer.rb +2 -0
- data/lib/herringbone/writer.rb +58 -22
- data/lib/herringbone/xxhash.rb +2 -0
- data/lib/herringbone.rb +96 -9
- 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
|
|
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::
|
|
161
|
-
when Format::Codec::
|
|
162
|
-
when Format::Codec::
|
|
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
|
|
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
|
|