herringbone 0.1.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.
@@ -0,0 +1,469 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Herringbone
4
+ # Reads Parquet files.
5
+ #
6
+ # Herringbone::Reader.open("data.parquet") do |reader|
7
+ # reader.each_row { |row| p row } # rows as Hashes with String keys
8
+ # reader.column("name") # all values of a top-level field
9
+ # reader.each_row(columns: ["id"]) { ... } # projection
10
+ # end
11
+ class Reader
12
+ include Enumerable
13
+
14
+ MAGIC = "PAR1"
15
+
16
+ attr_reader :metadata, :schema
17
+
18
+ def self.open(path)
19
+ io = File.open(path, "rb")
20
+ reader = new(io)
21
+ return reader unless block_given?
22
+ begin
23
+ yield reader
24
+ ensure
25
+ io.close
26
+ end
27
+ end
28
+
29
+ # +source+ is an IO (opened in binary mode), a path, or a String of Parquet bytes when
30
+ # +string: true+ is given.
31
+ def initialize(source)
32
+ @io = case source
33
+ when String then File.open(source, "rb")
34
+ when Pathname then File.open(source.to_s, "rb")
35
+ else source
36
+ end
37
+ @metadata = read_footer
38
+ @schema = Schema.from_elements(@metadata.schema)
39
+ end
40
+
41
+ def self.from_string(bytes)
42
+ new(StringIO.new(bytes.b))
43
+ end
44
+
45
+ def close
46
+ @io.close
47
+ end
48
+
49
+ def num_rows = @metadata.num_rows
50
+ def row_groups = @metadata.row_groups
51
+ def num_row_groups = @metadata.row_groups.size
52
+ def created_by = @metadata.created_by
53
+
54
+ def key_value_metadata
55
+ (@metadata.key_value_metadata || []).to_h { |kv| [kv.key, kv.value] }
56
+ end
57
+
58
+ # Yields each row as a Hash of top-level field name => value
59
+ def each_row(columns: nil, &block)
60
+ return enum_for(:each_row, columns: columns) unless block
61
+ fields = select_fields(columns)
62
+ row_groups.each_index do |rg|
63
+ data = read_row_group_fields(rg, fields)
64
+ n = row_groups[rg].num_rows
65
+ names = fields.map(&:name)
66
+ n.times do |i|
67
+ row = {}
68
+ names.each_with_index { |name, j| row[name] = data[j][i] }
69
+ yield row
70
+ end
71
+ end
72
+ self
73
+ end
74
+ alias_method :each, :each_row
75
+
76
+ # Rows as an Array of Hashes
77
+ def rows(columns: nil) = each_row(columns: columns).to_a
78
+
79
+ # All values of a single top-level field, across all row groups
80
+ def column(name)
81
+ field = @schema.field(name) or raise ArgumentError, "No such column #{name.inspect}"
82
+ row_groups.each_index.flat_map { |rg| read_row_group_fields(rg, [field]).first }
83
+ end
84
+
85
+ # Hash of field name => Array of values, for the given row group
86
+ def read_row_group(index, columns: nil)
87
+ fields = select_fields(columns)
88
+ fields.map(&:name).zip(read_row_group_fields(index, fields)).to_h
89
+ end
90
+
91
+ # Raw column data for a leaf column: [definition_levels, repetition_levels, values].
92
+ # Levels are nil when the column's max level is 0.
93
+ def read_column_chunk(row_group_index, column)
94
+ column = @schema.column(column) unless column.is_a?(Schema::Column)
95
+ raise ArgumentError, "No such leaf column" unless column
96
+ chunk = row_groups.fetch(row_group_index).columns.fetch(column.index)
97
+ ColumnChunkReader.new(@io, chunk, column).read
98
+ end
99
+
100
+ def inspect
101
+ "#<#{self.class.name} rows=#{num_rows} row_groups=#{num_row_groups} created_by=#{created_by.inspect}>"
102
+ end
103
+
104
+ private
105
+
106
+ def select_fields(columns)
107
+ return @schema.fields unless columns
108
+ Array(columns).map { |c| @schema.field(c) or raise ArgumentError, "No such column #{c.inspect}" }
109
+ end
110
+
111
+ def read_row_group_fields(rg, fields)
112
+ rg_meta = row_groups.fetch(rg)
113
+ n = rg_meta.num_rows
114
+ fields.map do |field|
115
+ chunks = {}
116
+ field.leaves.each do |col|
117
+ chunks[col.index] = ColumnChunkReader.new(@io, rg_meta.columns.fetch(col.index), col).read
118
+ end
119
+ Assembler.new(field, chunks).read_rows(n)
120
+ end
121
+ end
122
+
123
+ def read_footer
124
+ @io.seek(0, IO::SEEK_END)
125
+ size = @io.pos
126
+ raise FormatError, "File too small to be Parquet (#{size} bytes)" if size < 12
127
+ @io.seek(size - 8)
128
+ tail = @io.read(8)
129
+ raise FormatError, "Missing PAR1 footer magic" unless tail.byteslice(4, 4) == MAGIC
130
+ raise UnsupportedError, "Encrypted Parquet files are not supported" if tail.byteslice(4, 4) == "PARE"
131
+ footer_len = tail.unpack1("V")
132
+ raise FormatError, "Footer length #{footer_len} exceeds file size" if footer_len + 12 > size
133
+ @io.seek(size - 8 - footer_len)
134
+ footer = @io.read(footer_len)
135
+ Format::FileMetaData.decode(footer).first
136
+ rescue Thrift::Error => e
137
+ raise FormatError, "Corrupt file metadata: #{e.message}"
138
+ end
139
+
140
+ # Decodes all pages of one column chunk into levels and values
141
+ class ColumnChunkReader
142
+ T = Format::Type
143
+ E = Format::Encoding
144
+
145
+ def initialize(io, chunk, column)
146
+ @io = io
147
+ @chunk = chunk
148
+ @column = column
149
+ @meta = chunk.meta_data or raise UnsupportedError, "Column chunk without metadata (encrypted?)"
150
+ raise UnsupportedError, "Column chunks in external files are not supported" if chunk.file_path
151
+ @max_def = column.max_definition_level
152
+ @max_rep = column.max_repetition_level
153
+ @converter = column.converter
154
+ end
155
+
156
+ def read
157
+ buf = read_bytes
158
+ defs = @max_def.positive? ? [] : nil
159
+ reps = @max_rep.positive? ? [] : nil
160
+ values = []
161
+ pos = 0
162
+ total = @meta.num_values
163
+ seen = 0
164
+ while seen < total && pos < buf.bytesize
165
+ header, pos = decode_page_header(buf, pos)
166
+ size = header.compressed_page_size
167
+ # Some old writers under-report total_compressed_size; read on past the declared end
168
+ extend_buffer(buf, pos + size - buf.bytesize) if pos + size > buf.bytesize
169
+ raise FormatError, "Page overruns column chunk" if pos + size > buf.bytesize
170
+ body = buf.byteslice(pos, size)
171
+ pos += size
172
+ case header.type
173
+ when Format::PageType::DICTIONARY_PAGE
174
+ read_dictionary(header, body)
175
+ when Format::PageType::DATA_PAGE
176
+ seen += read_data_page_v1(header, body, defs, reps, values)
177
+ when Format::PageType::DATA_PAGE_V2
178
+ seen += read_data_page_v2(header, body, defs, reps, values)
179
+ end
180
+ end
181
+ raise FormatError, "Column #{@column.dotted_path}: read #{seen} of #{total} values" if seen < total
182
+ [defs, reps, values]
183
+ rescue Thrift::Error => e
184
+ raise FormatError, "Corrupt page header in #{@column.dotted_path}: #{e.message}"
185
+ end
186
+
187
+ private
188
+
189
+ def read_bytes
190
+ start = @meta.data_page_offset
191
+ dict = @meta.dictionary_page_offset
192
+ # Some writers store 0 when there is no dictionary page
193
+ start = dict if dict && dict.positive? && dict < start
194
+ @start = start
195
+ @io.seek(start)
196
+ len = @meta.total_compressed_size
197
+ (@io.read(len) || "".b).b
198
+ end
199
+
200
+ def extend_buffer(buf, nbytes)
201
+ @io.seek(@start + buf.bytesize)
202
+ more = @io.read(nbytes)
203
+ buf << more.b if more
204
+ end
205
+
206
+ def decode_page_header(buf, pos)
207
+ Format::PageHeader.decode(buf, pos)
208
+ rescue Thrift::Error
209
+ # The header may straddle the declared end of the chunk
210
+ before = buf.bytesize
211
+ extend_buffer(buf, 1024)
212
+ raise if buf.bytesize == before
213
+ Format::PageHeader.decode(buf, pos)
214
+ end
215
+
216
+ def decompress(body, size)
217
+ Compression.decompress(@meta.codec, body, size)
218
+ end
219
+
220
+ def read_dictionary(header, body)
221
+ dh = header.dictionary_page_header
222
+ data = decompress(body, header.uncompressed_page_size)
223
+ vals, = Encodings::Plain.decode(data, 0, dh.num_values, @column.type, @column.type_length)
224
+ vals.map!(&@converter) if @converter
225
+ @dictionary = vals
226
+ end
227
+
228
+ def read_data_page_v1(header, body, defs, reps, values)
229
+ dh = header.data_page_header
230
+ n = dh.num_values
231
+ data = decompress(body, header.uncompressed_page_size)
232
+ pos = 0
233
+ if @max_rep.positive?
234
+ levels, pos = read_levels(data, pos, dh.repetition_level_encoding, @max_rep, n)
235
+ reps.concat(levels)
236
+ end
237
+ non_null = n
238
+ if @max_def.positive?
239
+ levels, pos = read_levels(data, pos, dh.definition_level_encoding, @max_def, n)
240
+ non_null = levels.count(@max_def)
241
+ defs.concat(levels)
242
+ end
243
+ values.concat(decode_values(data, pos, non_null, dh.encoding))
244
+ n
245
+ end
246
+
247
+ def read_data_page_v2(header, body, defs, reps, values)
248
+ dh = header.data_page_header_v2
249
+ n = dh.num_values
250
+ rep_len = dh.repetition_levels_byte_length
251
+ def_len = dh.definition_levels_byte_length
252
+ if @max_rep.positive?
253
+ reps.concat(Encodings::RLE.decode_hybrid(body, 0, rep_len, RLE_WIDTH[@max_rep], n))
254
+ end
255
+ non_null = n
256
+ if @max_def.positive?
257
+ levels = Encodings::RLE.decode_hybrid(body, rep_len, rep_len + def_len, RLE_WIDTH[@max_def], n)
258
+ non_null = levels.count(@max_def)
259
+ defs.concat(levels)
260
+ end
261
+ data = body.byteslice(rep_len + def_len, body.bytesize - rep_len - def_len)
262
+ if dh.is_compressed != false
263
+ data = decompress(data, header.uncompressed_page_size - rep_len - def_len)
264
+ end
265
+ values.concat(decode_values(data, 0, non_null, dh.encoding))
266
+ n
267
+ end
268
+
269
+ RLE_WIDTH = Hash.new { |h, k| h[k] = k.bit_length }
270
+
271
+ def read_levels(data, pos, encoding, max, n)
272
+ width = RLE_WIDTH[max]
273
+ case encoding
274
+ when E::RLE
275
+ len = data.byteslice(pos, 4).unpack1("V")
276
+ start = pos + 4
277
+ [Encodings::RLE.decode_hybrid(data, start, start + len, width, n), start + len]
278
+ when E::BIT_PACKED
279
+ [Encodings::RLE.decode_legacy_bit_packed(data, pos, width, n), pos + (n * width + 7) / 8]
280
+ else
281
+ raise UnsupportedError, "Unsupported level encoding #{E::NAMES[encoding] || encoding}"
282
+ end
283
+ end
284
+
285
+ def decode_values(data, pos, count, encoding)
286
+ type = @column.type
287
+ vals = case encoding
288
+ when E::PLAIN
289
+ Encodings::Plain.decode(data, pos, count, type, @column.type_length).first
290
+ when E::PLAIN_DICTIONARY, E::RLE_DICTIONARY
291
+ raise FormatError, "Dictionary-encoded page without a dictionary in #{@column.dotted_path}" unless @dictionary
292
+ return [] if count.zero?
293
+ width = data.getbyte(pos)
294
+ indices = Encodings::RLE.decode_hybrid(data, pos + 1, data.bytesize, width, count)
295
+ dict = @dictionary
296
+ raise FormatError, "Dictionary index out of range in #{@column.dotted_path}" if indices.max >= dict.size
297
+ return indices.map! { |i| dict[i] }
298
+ when E::RLE
299
+ raise UnsupportedError, "RLE value encoding is only supported for BOOLEAN" unless type == T::BOOLEAN
300
+ len = data.byteslice(pos, 4).unpack1("V")
301
+ Encodings::RLE.decode_hybrid(data, pos + 4, pos + 4 + len, 1, count).map! { |v| v == 1 }
302
+ when E::DELTA_BINARY_PACKED
303
+ bits = type == T::INT32 ? 32 : 64
304
+ vals, = Encodings::Delta.decode_binary_packed(data, pos, bits, count)
305
+ raise FormatError, "DELTA_BINARY_PACKED page has #{vals.size} values, need #{count}" if vals.size < count
306
+ vals
307
+ when E::DELTA_LENGTH_BYTE_ARRAY
308
+ Encodings::Delta.decode_length_byte_array(data, pos, count).first
309
+ when E::DELTA_BYTE_ARRAY
310
+ Encodings::Delta.decode_byte_array(data, pos, count).first
311
+ when E::BYTE_STREAM_SPLIT
312
+ width = case type
313
+ when T::INT32, T::FLOAT then 4
314
+ when T::INT64, T::DOUBLE then 8
315
+ when T::FIXED_LEN_BYTE_ARRAY then @column.type_length
316
+ else raise UnsupportedError, "BYTE_STREAM_SPLIT is not valid for #{T::NAMES[type]}"
317
+ end
318
+ plain, = Encodings::ByteStreamSplit.decode(data, pos, count, width)
319
+ Encodings::Plain.decode(plain, 0, count, type, @column.type_length).first
320
+ else
321
+ raise UnsupportedError, "Unsupported encoding #{E::NAMES[encoding] || encoding}"
322
+ end
323
+ vals.map!(&@converter) if @converter
324
+ vals
325
+ end
326
+ end
327
+
328
+ # Rebuilds nested values of one top-level field from the levels of its leaf columns
329
+ # (the "record assembly" half of the Dremel algorithm).
330
+ class Assembler
331
+ def initialize(field, chunks)
332
+ @field = field
333
+ @defs = {}
334
+ @reps = {}
335
+ @vals = {}
336
+ chunks.each do |idx, (d, r, v)|
337
+ @defs[idx] = d
338
+ @reps[idx] = r
339
+ @vals[idx] = v
340
+ end
341
+ @ei = Hash.new(0) # entry cursor per leaf column
342
+ @vi = Hash.new(0) # value cursor per leaf column
343
+ end
344
+
345
+ def read_rows(n)
346
+ f = @field
347
+ if f.leaf? && f.column.max_repetition_level.zero?
348
+ idx = f.column.index
349
+ defs = @defs[idx]
350
+ vals = @vals[idx]
351
+ return vals unless defs
352
+ max = f.column.max_definition_level
353
+ return vals if vals.size == defs.size
354
+ vi = -1
355
+ return defs.map { |d| d == max ? vals[vi += 1] : nil }
356
+ end
357
+ return read_simple_list(f) if f.kind == :list && f.element.leaf? && f.element.column.max_repetition_level == 1
358
+
359
+ out = Array.new(n) { read(f) }
360
+ f.leaves.each do |col|
361
+ if @ei[col.index] != (@defs[col.index] || @vals[col.index]).size
362
+ raise FormatError, "Column #{col.dotted_path} has leftover entries after assembling #{n} rows"
363
+ end
364
+ end
365
+ out
366
+ end
367
+
368
+ private
369
+
370
+ # Fast path for a top-level list of primitives (the most common nested shape)
371
+ def read_simple_list(field)
372
+ col = field.element.column
373
+ defs = @defs[col.index]
374
+ reps = @reps[col.index]
375
+ vals = @vals[col.index]
376
+ max_def = col.max_definition_level
377
+ list_def = field.def_level
378
+ item_def = field.item_def
379
+ out = []
380
+ cur = nil
381
+ vi = 0
382
+ i = 0
383
+ n = defs.size
384
+ while i < n
385
+ d = defs[i]
386
+ if reps[i].zero?
387
+ if d < list_def
388
+ out << nil
389
+ i += 1
390
+ next
391
+ end
392
+ cur = []
393
+ out << cur
394
+ if d < item_def
395
+ i += 1
396
+ next
397
+ end
398
+ end
399
+ if d == max_def
400
+ cur << vals[vi]
401
+ vi += 1
402
+ else
403
+ cur << nil
404
+ end
405
+ i += 1
406
+ end
407
+ out
408
+ end
409
+
410
+ def read(field)
411
+ c = field.first_leaf.index
412
+ kind = field.kind
413
+ if field.optional || kind == :list || kind == :map
414
+ d = @defs[c][@ei[c]]
415
+ raise FormatError, "Ran out of levels while assembling #{field.name}" if d.nil?
416
+ if d < field.def_level
417
+ skip(field)
418
+ return nil
419
+ end
420
+ end
421
+
422
+ case kind
423
+ when :leaf
424
+ @ei[c] += 1
425
+ v = @vi[c]
426
+ @vi[c] = v + 1
427
+ @vals[c][v]
428
+ when :struct
429
+ h = {}
430
+ field.children.each { |ch| h[ch.name] = read(ch) }
431
+ h
432
+ when :list
433
+ if d < field.item_def
434
+ skip(field)
435
+ return []
436
+ end
437
+ out = []
438
+ reps = @reps[c]
439
+ rl = field.rep_level
440
+ while true
441
+ out << read(field.element)
442
+ r = reps[@ei[c]]
443
+ break if r.nil? || r < rl
444
+ end
445
+ out
446
+ when :map
447
+ if d < field.item_def
448
+ skip(field)
449
+ return {}
450
+ end
451
+ out = {}
452
+ reps = @reps[c]
453
+ rl = field.rep_level
454
+ while true
455
+ k = read(field.key)
456
+ out[k] = field.value ? read(field.value) : nil
457
+ r = reps[@ei[c]]
458
+ break if r.nil? || r < rl
459
+ end
460
+ out
461
+ end
462
+ end
463
+
464
+ def skip(field)
465
+ field.leaves.each { |col| @ei[col.index] += 1 }
466
+ end
467
+ end
468
+ end
469
+ end