herringbone 0.3.0 → 0.4.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 +52 -0
- data/README.md +88 -24
- data/bin/herringbone +40 -10
- data/lib/herringbone/codecs/lzo.rb +224 -0
- data/lib/herringbone/codecs/snappy.rb +13 -20
- data/lib/herringbone/compression.rb +26 -38
- data/lib/herringbone/encodings/plain.rb +2 -2
- data/lib/herringbone/inferring_writer.rb +161 -0
- data/lib/herringbone/reader/column_chunk_reader.rb +5 -8
- data/lib/herringbone/reader/numo.rb +18 -26
- data/lib/herringbone/reader.rb +3 -3
- data/lib/herringbone/schema.rb +7 -12
- data/lib/herringbone/simple_writer.rb +152 -0
- data/lib/herringbone/thrift.rb +5 -5
- data/lib/herringbone/version.rb +1 -1
- data/lib/herringbone/writer.rb +15 -13
- data/lib/herringbone/xxhash.rb +21 -36
- data/lib/herringbone.rb +52 -8
- metadata +6 -2
|
@@ -4,80 +4,67 @@ 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
|
|
7
|
+
# Raised when a file uses (or a writer asks for) a codec whose library is not loaded
|
|
8
8
|
class MissingCodecError < UnsupportedError
|
|
9
9
|
# @return [String] codec name (+"ZSTD"+) and name of the gem providing it (+"zstd-ruby"+)
|
|
10
10
|
attr_reader :codec, :gem_name
|
|
11
11
|
|
|
12
12
|
# @param codec [String] codec name, as shown in the message
|
|
13
13
|
# @param gem_name [String] gem to add to the Gemfile
|
|
14
|
-
# @param
|
|
15
|
-
def initialize(codec, gem_name,
|
|
14
|
+
# @param path [String] what to require to load the gem
|
|
15
|
+
def initialize(codec, gem_name, path)
|
|
16
16
|
@codec = codec
|
|
17
17
|
@gem_name = gem_name
|
|
18
|
-
super("#{codec} compression needs the \"#{gem_name}\" gem, which
|
|
19
|
-
"
|
|
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
20
|
end
|
|
21
21
|
end
|
|
22
22
|
|
|
23
23
|
# Dispatches page (de)compression by Parquet codec id. Snappy and LZ4 are pure Ruby and GZIP
|
|
24
|
-
# uses zlib, so those always work.
|
|
25
|
-
#
|
|
26
|
-
#
|
|
24
|
+
# uses zlib, so those always work. LZO is pure Ruby too, but can only be read. ZSTD and Brotli
|
|
25
|
+
# come from optional gems (zstd-ruby, brotli), which the application requires; if they are
|
|
26
|
+
# not loaded, MissingCodecError says what to add.
|
|
27
|
+
# (Snappy also uses the optional snappy gem when it is loaded, see Codecs::Snappy.)
|
|
27
28
|
module Compression
|
|
28
29
|
module_function
|
|
29
30
|
|
|
30
31
|
# Codecs backed by optional native gems: codec id => [name, gem, require path, constant]
|
|
31
|
-
LIBRARIES = {
|
|
32
|
+
LIBRARIES = Ractor.make_shareable({
|
|
32
33
|
Format::Codec::ZSTD => ["ZSTD", "zstd-ruby", "zstd-ruby", :Zstd],
|
|
33
34
|
Format::Codec::BROTLI => ["Brotli", "brotli", "brotli", :Brotli]
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
@libraries = {}
|
|
37
|
-
@library_mutex = Mutex.new
|
|
35
|
+
})
|
|
38
36
|
|
|
39
|
-
# The library module for a codec backed by an optional gem
|
|
37
|
+
# The library module for a codec backed by an optional gem
|
|
40
38
|
#
|
|
41
39
|
# @param codec [Integer] a codec id that is a key of LIBRARIES
|
|
42
40
|
# @return [Module] the gem's module (+Zstd+ or +Brotli+)
|
|
43
|
-
# @raise [MissingCodecError] when the gem
|
|
41
|
+
# @raise [MissingCodecError] when the gem is not loaded
|
|
44
42
|
# @raise [KeyError] for a codec id not in LIBRARIES
|
|
45
43
|
def library(codec)
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
@libraries.fetch(codec) do
|
|
49
|
-
name, gem_name, path, const = LIBRARIES.fetch(codec)
|
|
50
|
-
begin
|
|
51
|
-
require_library(path)
|
|
52
|
-
rescue LoadError => e
|
|
53
|
-
raise MissingCodecError.new(name, gem_name, e)
|
|
54
|
-
end
|
|
55
|
-
@libraries[codec] = Object.const_get(const)
|
|
56
|
-
end
|
|
57
|
-
end
|
|
58
|
-
end
|
|
44
|
+
name, gem_name, path, const = LIBRARIES.fetch(codec)
|
|
45
|
+
loaded_library(const) || raise(MissingCodecError.new(name, gem_name, path))
|
|
59
46
|
end
|
|
60
47
|
|
|
61
|
-
#
|
|
48
|
+
# A separate method so tests can stub it to simulate a missing gem
|
|
62
49
|
#
|
|
63
|
-
# @param
|
|
64
|
-
# @return [
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
require path
|
|
50
|
+
# @param const [Symbol] top-level constant the gem defines
|
|
51
|
+
# @return [Module, nil] the gem's module, nil when it is not loaded
|
|
52
|
+
def loaded_library(const)
|
|
53
|
+
Object.const_get(const) if Object.const_defined?(const)
|
|
68
54
|
end
|
|
69
55
|
|
|
70
56
|
# Raises MissingCodecError (or UnsupportedError) unless +codec+ can be used
|
|
71
57
|
#
|
|
72
58
|
# @param codec [Integer, Symbol, String] codec id or name (see NAMES)
|
|
73
59
|
# @return [Module, nil] the gem's module for a gem-backed codec, nil for a built-in one
|
|
74
|
-
# @raise [MissingCodecError] when the codec's gem
|
|
75
|
-
# @raise [UnsupportedError] for a codec herringbone
|
|
60
|
+
# @raise [MissingCodecError] when the codec's gem is not loaded
|
|
61
|
+
# @raise [UnsupportedError] for a codec herringbone cannot write (LZO)
|
|
76
62
|
# @raise [ArgumentError] for an unknown codec name
|
|
77
63
|
def ensure_available!(codec)
|
|
78
64
|
codec = codec_id(codec)
|
|
79
65
|
return library(codec) if LIBRARIES.key?(codec)
|
|
80
66
|
return if SUPPORTED.include?(codec)
|
|
67
|
+
raise UnsupportedError, "LZO compression is only supported for reading" if codec == Format::Codec::LZO
|
|
81
68
|
raise UnsupportedError, "#{Format::Codec::NAMES[codec] || codec} compression is not supported"
|
|
82
69
|
end
|
|
83
70
|
|
|
@@ -115,7 +102,7 @@ module Herringbone
|
|
|
115
102
|
# @param uncompressed_size [Integer] expected decompressed size in bytes
|
|
116
103
|
# @return [String] decompressed bytes (binary)
|
|
117
104
|
# @raise [UnsupportedError] for a codec herringbone does not implement
|
|
118
|
-
# @raise [MissingCodecError] when the codec's gem
|
|
105
|
+
# @raise [MissingCodecError] when the codec's gem is not loaded
|
|
119
106
|
# @raise [FormatError] when the decompressed size does not match +uncompressed_size+
|
|
120
107
|
def decompress(codec, data, uncompressed_size)
|
|
121
108
|
return "".b if uncompressed_size.zero? && data.empty?
|
|
@@ -125,6 +112,7 @@ module Herringbone
|
|
|
125
112
|
when Format::Codec::GZIP then gunzip(data)
|
|
126
113
|
when Format::Codec::LZ4_RAW then Codecs::LZ4.decompress_block(data, uncompressed_size)
|
|
127
114
|
when Format::Codec::LZ4 then Codecs::LZ4.decompress_hadoop(data, uncompressed_size)
|
|
115
|
+
when Format::Codec::LZO then Codecs::LZO.decompress_hadoop(data, uncompressed_size)
|
|
128
116
|
when Format::Codec::ZSTD then library(codec).decompress(data)
|
|
129
117
|
when Format::Codec::BROTLI then library(codec).inflate(data)
|
|
130
118
|
else
|
|
@@ -143,7 +131,7 @@ module Herringbone
|
|
|
143
131
|
# @param data [String] bytes to compress
|
|
144
132
|
# @return [String] compressed bytes (binary)
|
|
145
133
|
# @raise [UnsupportedError] for a codec herringbone does not implement
|
|
146
|
-
# @raise [MissingCodecError] when the codec's gem
|
|
134
|
+
# @raise [MissingCodecError] when the codec's gem is not loaded
|
|
147
135
|
def compress(codec, data)
|
|
148
136
|
case codec
|
|
149
137
|
when Format::Codec::UNCOMPRESSED then data
|
|
@@ -8,12 +8,12 @@ module Herringbone
|
|
|
8
8
|
module_function
|
|
9
9
|
|
|
10
10
|
# Fixed-width numeric types: pack/unpack directive and byte width (all little-endian).
|
|
11
|
-
FORMATS = {
|
|
11
|
+
FORMATS = Ractor.make_shareable({
|
|
12
12
|
Format::Type::INT32 => ["l<", 4],
|
|
13
13
|
Format::Type::INT64 => ["q<", 8],
|
|
14
14
|
Format::Type::FLOAT => ["e", 4],
|
|
15
15
|
Format::Type::DOUBLE => ["E", 8]
|
|
16
|
-
}
|
|
16
|
+
})
|
|
17
17
|
|
|
18
18
|
# Decodes +count+ values of +type+ from +data+ starting at +pos+.
|
|
19
19
|
# Returns [values, new_pos].
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Herringbone
|
|
4
|
+
# Writes rows whose schema is inferred from the rows themselves, reading them only once: the
|
|
5
|
+
# first Schema::INFER_SAMPLE rows are held back, the schema is built from them, and then they and
|
|
6
|
+
# every later row go straight to a Writer. Memory is bounded by the sample, however many rows
|
|
7
|
+
# follow it. Nothing is written to the IO before the schema is known, so a source that can only
|
|
8
|
+
# be iterated once (a cursor, a queue, a lazy Enumerator over an IO) loses no rows.
|
|
9
|
+
#
|
|
10
|
+
# A row that does not fit the inferred schema raises SchemaMismatch and stops the writer: the
|
|
11
|
+
# file is left unfinished (no footer) rather than written with rows missing.
|
|
12
|
+
#
|
|
13
|
+
# Used by Herringbone.write and SimpleWriter.
|
|
14
|
+
#
|
|
15
|
+
# @api private
|
|
16
|
+
class InferringWriter
|
|
17
|
+
# @param io [IO, #write] destination, passed to Writer.new
|
|
18
|
+
# @param fix [String] how a caller declares a column, for SchemaMismatch messages; +%s+ is
|
|
19
|
+
# replaced by a declaration such as +string :age+
|
|
20
|
+
# @param options [Hash{Symbol => Object}] Writer options
|
|
21
|
+
# @option options [Symbol] :compression (:snappy) codec, checked before any row is read
|
|
22
|
+
# (the other Writer options are passed on as well)
|
|
23
|
+
# @yield [sample] builds the schema once the sample is complete (or at #close)
|
|
24
|
+
# @yieldparam sample [Array] the rows held back, at most Schema::INFER_SAMPLE
|
|
25
|
+
# @yieldreturn [Schema]
|
|
26
|
+
# @raise [MissingCodecError] when the codec's optional gem is not loaded
|
|
27
|
+
def initialize(io, fix:, **options, &schema_for)
|
|
28
|
+
# Fail before reading any rows if the codec's library is missing
|
|
29
|
+
Compression.ensure_available!(Compression.codec_id(options.fetch(:compression, :snappy)))
|
|
30
|
+
@io = io
|
|
31
|
+
@fix = fix
|
|
32
|
+
@options = options
|
|
33
|
+
@schema_for = schema_for
|
|
34
|
+
@sample = []
|
|
35
|
+
@sample_size = nil
|
|
36
|
+
@writer = nil
|
|
37
|
+
@mismatch = nil
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# @param row [Object] row to write, in any form the Writer accepts
|
|
41
|
+
# @return [self]
|
|
42
|
+
# @raise [SchemaMismatch] when the row does not fit the inferred schema
|
|
43
|
+
# @raise [Error] after an earlier SchemaMismatch
|
|
44
|
+
def <<(row)
|
|
45
|
+
check_usable!
|
|
46
|
+
if @writer
|
|
47
|
+
write(row)
|
|
48
|
+
else
|
|
49
|
+
@sample << row
|
|
50
|
+
start if @sample.size >= Schema::INFER_SAMPLE
|
|
51
|
+
end
|
|
52
|
+
self
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# @return [Integer] rows accepted so far, including the ones held back
|
|
56
|
+
def rows_written = @writer ? @writer.rows_written : @sample.size
|
|
57
|
+
|
|
58
|
+
# Writes the held-back rows if the sample never filled up, then finishes the file
|
|
59
|
+
#
|
|
60
|
+
# @return [void]
|
|
61
|
+
# @raise [SchemaMismatch] when a held-back row does not fit the inferred schema
|
|
62
|
+
# @raise [Error] after an earlier SchemaMismatch
|
|
63
|
+
def close
|
|
64
|
+
check_usable!
|
|
65
|
+
start unless @writer
|
|
66
|
+
@writer.close
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Stops without finishing the file; when the schema was never built nothing was written at all
|
|
70
|
+
#
|
|
71
|
+
# @return [void]
|
|
72
|
+
def abort
|
|
73
|
+
@writer&.abort
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
# Builds the schema from the held-back rows, opens the Writer and writes them
|
|
79
|
+
#
|
|
80
|
+
# @return [void]
|
|
81
|
+
# @raise [SchemaMismatch] when a held-back row does not fit
|
|
82
|
+
def start
|
|
83
|
+
@sample_size = @sample.size
|
|
84
|
+
@writer = Writer.new(@io, @schema_for.call(@sample), **@options)
|
|
85
|
+
sample, @sample = @sample, nil
|
|
86
|
+
sample.each { |row| write(row) }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# @param row [Object] row for the Writer
|
|
90
|
+
# @return [void]
|
|
91
|
+
# @raise [SchemaMismatch] when the row does not fit; the Writer is aborted
|
|
92
|
+
def write(row)
|
|
93
|
+
@writer << row
|
|
94
|
+
rescue EncodeError => e
|
|
95
|
+
@writer.abort
|
|
96
|
+
@mismatch = SchemaMismatch.new(explain(e), row: e.row, column: e.column, value: e.value)
|
|
97
|
+
raise @mismatch
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# @return [void]
|
|
101
|
+
# @raise [Error] after a SchemaMismatch
|
|
102
|
+
def check_usable!
|
|
103
|
+
return unless @mismatch
|
|
104
|
+
raise Error, "This writer stopped at a schema mismatch and cannot continue " \
|
|
105
|
+
"(the file is unfinished):\n#{@mismatch.message}"
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# A multi-line explanation of an EncodeError against the inferred schema
|
|
109
|
+
#
|
|
110
|
+
# @param error [EncodeError] error raised by the Writer
|
|
111
|
+
# @return [String]
|
|
112
|
+
def explain(error)
|
|
113
|
+
column = error.column && @writer.schema.column(error.column)
|
|
114
|
+
column ||= error.column && @writer.schema.columns.find { |c| c.dotted_path.start_with?("#{error.column}.") }
|
|
115
|
+
top = error.column&.split(".")&.first
|
|
116
|
+
lines = ["Row #{error.row} does not fit the schema inferred from the first #{@sample_size} rows."]
|
|
117
|
+
lines << " column: #{error.column}" if error.column
|
|
118
|
+
lines << " inferred: #{describe(column)}" if column
|
|
119
|
+
lines << " got: #{error.value.inspect[0, 200]} (#{error.value.class})" unless error.value.nil?
|
|
120
|
+
lines << " error: #{error.message.sub(/\ARow \d+: /, "")}"
|
|
121
|
+
lines << ""
|
|
122
|
+
lines << "Column types are decided from the first #{Schema::INFER_SAMPLE} rows (or all of them, when there are"
|
|
123
|
+
lines << "fewer), so a later value of a different type cannot be written to the same column."
|
|
124
|
+
if top
|
|
125
|
+
lines << "Declare the column with a type that holds every value, for example:"
|
|
126
|
+
lines << " #{format(@fix, declaration_for(top, error.value))}"
|
|
127
|
+
end
|
|
128
|
+
lines << "or pass a complete schema. The file was left unfinished (no footer)."
|
|
129
|
+
lines.join("\n")
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# @param column [Schema::Column] leaf column
|
|
133
|
+
# @return [String] its type as the Builder DSL names it, e.g. "int64" or "timestamp (micros)"
|
|
134
|
+
def describe(column)
|
|
135
|
+
kind, *details = Types.logical_of(column.node)
|
|
136
|
+
type = case kind
|
|
137
|
+
when nil then Format::Type::NAMES[column.type].downcase
|
|
138
|
+
when :integer then "#{details[1] ? "int" : "uint"}#{details[0]}"
|
|
139
|
+
when :timestamp, :time then "#{kind} (#{details[0]})"
|
|
140
|
+
when :decimal then "decimal(#{details[1]}, #{details[0]})"
|
|
141
|
+
else kind.to_s
|
|
142
|
+
end
|
|
143
|
+
(column.path.size > 1) ? "#{type} (at #{column.dotted_path})" : type
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# A Builder DSL declaration whose type can hold +value+, to suggest in the explanation
|
|
147
|
+
#
|
|
148
|
+
# @param name [String] top-level field name
|
|
149
|
+
# @param value [Object] the value that did not fit
|
|
150
|
+
# @return [String] e.g. "string :age"
|
|
151
|
+
def declaration_for(name, value)
|
|
152
|
+
type = case value
|
|
153
|
+
when Float, BigDecimal, Rational then "double"
|
|
154
|
+
when Time, DateTime then "timestamp"
|
|
155
|
+
when Hash, Array then "json"
|
|
156
|
+
else "string"
|
|
157
|
+
end
|
|
158
|
+
"#{type} :#{name}"
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
@@ -292,8 +292,8 @@ module Herringbone
|
|
|
292
292
|
rep_len = dh.repetition_levels_byte_length
|
|
293
293
|
def_len = dh.definition_levels_byte_length
|
|
294
294
|
reps = defs = nil
|
|
295
|
-
reps = PageStream::HybridDecoder.new(body, 0, rep_len,
|
|
296
|
-
defs = PageStream::HybridDecoder.new(body, rep_len, rep_len + def_len,
|
|
295
|
+
reps = PageStream::HybridDecoder.new(body, 0, rep_len, @max_rep.bit_length) if @max_rep.positive?
|
|
296
|
+
defs = PageStream::HybridDecoder.new(body, rep_len, rep_len + def_len, @max_def.bit_length) if @max_def.positive?
|
|
297
297
|
data = body.byteslice(rep_len + def_len, body.bytesize - rep_len - def_len)
|
|
298
298
|
if dh.is_compressed != false
|
|
299
299
|
data = decompress(data, header.uncompressed_page_size - rep_len - def_len)
|
|
@@ -302,9 +302,6 @@ module Herringbone
|
|
|
302
302
|
PageStream::Page.new(n, defs, reps, values, conv)
|
|
303
303
|
end
|
|
304
304
|
|
|
305
|
-
# Bit width of levels up to a max level (memoized Integer#bit_length)
|
|
306
|
-
RLE_WIDTH = Hash.new { |h, k| h[k] = k.bit_length }
|
|
307
|
-
|
|
308
305
|
# [decoder, position after the levels]
|
|
309
306
|
#
|
|
310
307
|
# @param data [String] the uncompressed page
|
|
@@ -317,7 +314,7 @@ module Herringbone
|
|
|
317
314
|
# @raise [FormatError] when the RLE length prefix is cut off
|
|
318
315
|
# @raise [UnsupportedError] for any other level encoding
|
|
319
316
|
def level_decoder(data, pos, encoding, max, n)
|
|
320
|
-
width =
|
|
317
|
+
width = max.bit_length
|
|
321
318
|
case encoding
|
|
322
319
|
when E::RLE
|
|
323
320
|
len = data.byteslice(pos, 4)&.unpack1("V") or raise FormatError, "Truncated levels"
|
|
@@ -332,9 +329,9 @@ module Herringbone
|
|
|
332
329
|
end
|
|
333
330
|
|
|
334
331
|
# Physical type => [String#unpack directive, byte width] for PLAIN fixed-width values
|
|
335
|
-
FIXED_FORMATS = {
|
|
332
|
+
FIXED_FORMATS = Ractor.make_shareable({
|
|
336
333
|
T::INT32 => ["l<", 4], T::INT64 => ["q<", 8], T::FLOAT => ["e", 4], T::DOUBLE => ["E", 8]
|
|
337
|
-
}
|
|
334
|
+
})
|
|
338
335
|
|
|
339
336
|
# [value decoder, converter still to apply to its values (nil for dictionary pages)]
|
|
340
337
|
#
|
|
@@ -27,9 +27,6 @@ module Herringbone
|
|
|
27
27
|
# Shorthand for Format::Type.
|
|
28
28
|
T = Format::Type
|
|
29
29
|
|
|
30
|
-
@loaded = false
|
|
31
|
-
@mutex = Mutex.new
|
|
32
|
-
|
|
33
30
|
# How a top-level field becomes a Numo array.
|
|
34
31
|
# kind: :fixed (a numeric or boolean leaf), :list (list of numbers) or :object
|
|
35
32
|
# klass: the Numo class of the result without nulls
|
|
@@ -46,29 +43,19 @@ module Herringbone
|
|
|
46
43
|
|
|
47
44
|
module_function
|
|
48
45
|
|
|
49
|
-
# Requires Numo once per process (thread-safe); a no-op after the first success.
|
|
50
46
|
# @return [void]
|
|
51
|
-
# @raise [UnsupportedError] if
|
|
47
|
+
# @raise [UnsupportedError] if Numo is not loaded
|
|
52
48
|
def load!
|
|
53
|
-
return if
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
require_library
|
|
58
|
-
rescue LoadError => e
|
|
59
|
-
raise UnsupportedError, "as: :numo needs the \"numo-narray-alt\" gem (or \"numo-narray\"), " \
|
|
60
|
-
"which could not be loaded (#{e.message}). Add `gem \"numo-narray-alt\"` to your Gemfile " \
|
|
61
|
-
"to read columns into Numo arrays."
|
|
62
|
-
end
|
|
63
|
-
@loaded = true
|
|
64
|
-
end
|
|
49
|
+
return if loaded?
|
|
50
|
+
raise UnsupportedError, "as: :numo needs the \"numo-narray-alt\" gem (or \"numo-narray\"), which " \
|
|
51
|
+
"is not loaded. Add `gem \"numo-narray-alt\"` to your Gemfile and `require \"numo/narray\"` " \
|
|
52
|
+
"to read columns into Numo arrays."
|
|
65
53
|
end
|
|
66
54
|
|
|
67
|
-
#
|
|
68
|
-
# @return [Boolean]
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
require "numo/narray"
|
|
55
|
+
# A separate method so tests can stub it to simulate a missing gem.
|
|
56
|
+
# @return [Boolean] whether "numo/narray" is loaded
|
|
57
|
+
def loaded?
|
|
58
|
+
defined?(::Numo::NArray) ? true : false
|
|
72
59
|
end
|
|
73
60
|
|
|
74
61
|
# Picks how +field+ is converted: a numeric or boolean leaf, a list of numbers with a single
|
|
@@ -200,9 +187,14 @@ module Herringbone
|
|
|
200
187
|
out
|
|
201
188
|
end
|
|
202
189
|
|
|
203
|
-
#
|
|
204
|
-
# turns rows of bits into integers. Int64 above 30 bits so the sums cannot overflow.
|
|
205
|
-
|
|
190
|
+
# Numo vector of 1 << j for each bit j of +width+, memoized per Ractor; dotting a bit matrix
|
|
191
|
+
# with it turns rows of bits into integers. Int64 above 30 bits so the sums cannot overflow.
|
|
192
|
+
# @param width [Integer] bits per value
|
|
193
|
+
# @return [Numo::Int32, Numo::Int64] the powers of two
|
|
194
|
+
def powers(width)
|
|
195
|
+
cache = Ractor.current[:herringbone_numo_powers] ||= {}
|
|
196
|
+
cache[width] ||= ((width > 30) ? Numo::Int64 : Numo::Int32).cast(Array.new(width) { |j| 1 << j })
|
|
197
|
+
end
|
|
206
198
|
|
|
207
199
|
# +count+ bit-packed values of +width+ bits (LSB first) from +data+ at +pos+ as a Numo
|
|
208
200
|
# array, without a Ruby object per value
|
|
@@ -224,7 +216,7 @@ module Herringbone
|
|
|
224
216
|
end
|
|
225
217
|
# Row i of the [count, width] bit matrix holds value i's bits, least significant first
|
|
226
218
|
bits = Numo::Bit.from_binary(bytes, [count * width]).reshape(count, width)
|
|
227
|
-
pow =
|
|
219
|
+
pow = powers(width)
|
|
228
220
|
pow.class.cast(bits).dot(pow)
|
|
229
221
|
end
|
|
230
222
|
end
|
data/lib/herringbone/reader.rb
CHANGED
|
@@ -107,7 +107,7 @@ module Herringbone
|
|
|
107
107
|
# @return [Reader, Enumerator] self, or an Enumerator of batches when no block is given
|
|
108
108
|
# @raise [ArgumentError] for a non-positive +size+, unknown +as+, negative +from+ / +limit+,
|
|
109
109
|
# or an unknown column in +columns+ or +where+
|
|
110
|
-
# @raise [UnsupportedError] with +as: :numo+ when
|
|
110
|
+
# @raise [UnsupportedError] with +as: :numo+ when Numo is not loaded
|
|
111
111
|
def each_batch(size = DEFAULT_BATCH_SIZE, columns: nil, as: :rows, where: nil, from: nil, limit: nil)
|
|
112
112
|
return enum_for(:each_batch, size, columns: columns, as: as, where: where, from: from, limit: limit) unless block_given?
|
|
113
113
|
size = Integer(size)
|
|
@@ -249,7 +249,7 @@ module Herringbone
|
|
|
249
249
|
# @return [Array<Hash>, Hash{String, Symbol => Array}, Hash{String, Symbol => Numo::NArray}]
|
|
250
250
|
# row Hashes (+:rows+), or field name => all values (+:columns+, +:numo+)
|
|
251
251
|
# @raise [ArgumentError] for an unknown +as+, negative +from+ / +limit+ or an unknown column
|
|
252
|
-
# @raise [UnsupportedError] with +as: :numo+ when
|
|
252
|
+
# @raise [UnsupportedError] with +as: :numo+ when Numo is not loaded
|
|
253
253
|
def read(columns: nil, as: :rows, where: nil, from: nil, limit: nil)
|
|
254
254
|
if as == :numo
|
|
255
255
|
raise ArgumentError, "limit: must not be negative" if limit&.negative?
|
|
@@ -315,7 +315,7 @@ module Herringbone
|
|
|
315
315
|
# @yieldparam batch [Hash{String, Symbol => Numo::NArray}] field name => values of the batch
|
|
316
316
|
# @yieldreturn [void]
|
|
317
317
|
# @return [void]
|
|
318
|
-
# @raise [UnsupportedError] when
|
|
318
|
+
# @raise [UnsupportedError] when Numo is not loaded
|
|
319
319
|
def each_numo_batch(size, columns, where, from, limit)
|
|
320
320
|
NumoColumns.load!
|
|
321
321
|
symbolize = @symbolize
|
data/lib/herringbone/schema.rb
CHANGED
|
@@ -147,21 +147,18 @@ module Herringbone
|
|
|
147
147
|
# @return [String] path joined with dots, as used for column names in options
|
|
148
148
|
def dotted_path = @path.join(".")
|
|
149
149
|
|
|
150
|
-
#
|
|
150
|
+
# Types.reader_for of the node. Not memoized: a Schema holding Procs could not be made
|
|
151
|
+
# Ractor-shareable.
|
|
151
152
|
#
|
|
152
153
|
# @return [Proc, Method, nil] converts a physical value into its Ruby value, or nil when the physical
|
|
153
154
|
# value is used as-is
|
|
154
|
-
def converter
|
|
155
|
-
@converter ||= Types.reader_for(@node)
|
|
156
|
-
end
|
|
155
|
+
def converter = Types.reader_for(@node)
|
|
157
156
|
|
|
158
|
-
#
|
|
157
|
+
# Types.writer_for of the node. Not memoized, like #converter.
|
|
159
158
|
#
|
|
160
159
|
# @return [Proc, Method] converts a Ruby value into the physical value to store, raising
|
|
161
160
|
# ArgumentError / TypeError / RangeError for values that do not fit
|
|
162
|
-
def encoder
|
|
163
|
-
@encoder ||= Types.writer_for(@node)
|
|
164
|
-
end
|
|
161
|
+
def encoder = Types.writer_for(@node)
|
|
165
162
|
end
|
|
166
163
|
|
|
167
164
|
# A node of the logical tree.
|
|
@@ -217,12 +214,10 @@ module Herringbone
|
|
|
217
214
|
# @return [Boolean] true for a primitive (+:leaf+) field
|
|
218
215
|
def leaf? = @kind == :leaf
|
|
219
216
|
|
|
220
|
-
#
|
|
217
|
+
# Lookup of a struct's children; only valid for +:struct+ fields.
|
|
221
218
|
#
|
|
222
219
|
# @return [Hash{String => Field}] child fields by name
|
|
223
|
-
def children_by_name
|
|
224
|
-
@children_by_name ||= @children.to_h { |c| [c.name, c] }
|
|
225
|
-
end
|
|
220
|
+
def children_by_name = @children.to_h { |c| [c.name, c] }
|
|
226
221
|
end
|
|
227
222
|
|
|
228
223
|
# The root Node, the leaf Columns in file order, and the top-level Fields of the logical tree
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Herringbone
|
|
4
|
+
# Writes Parquet the way the CSV gem writes CSV: name the columns, then append rows as Arrays.
|
|
5
|
+
# The column types are inferred from the first Schema::INFER_SAMPLE rows (see Schema.infer), so
|
|
6
|
+
# no schema has to be declared.
|
|
7
|
+
#
|
|
8
|
+
# File.open("people.parquet", "wb") do |file|
|
|
9
|
+
# Herringbone::SimpleWriter.open(file) do |sw|
|
|
10
|
+
# sw.headers!(:id, :name, :age)
|
|
11
|
+
# sw << [123, "John", 12]
|
|
12
|
+
# sw << { id: 124, name: "Jane" } # Hashes work too; missing columns are nulls
|
|
13
|
+
# end
|
|
14
|
+
# end
|
|
15
|
+
#
|
|
16
|
+
# Every column is nullable. A column that is nil in every sampled row becomes a string, and a file
|
|
17
|
+
# with headers but no rows has only string columns. Only the sample is held in memory; later rows
|
|
18
|
+
# are written as they come. A row that does not fit the inferred types raises SchemaMismatch,
|
|
19
|
+
# explaining what was inferred and how to declare the column, and the writer stops there (the
|
|
20
|
+
# file is left without a footer). Unlike CSV, the headers are mandatory: a Parquet file always
|
|
21
|
+
# has named columns.
|
|
22
|
+
#
|
|
23
|
+
# Columns declared in a block (Schema::Builder DSL) replace inferred ones, e.g. for a column that
|
|
24
|
+
# holds both numbers and text:
|
|
25
|
+
#
|
|
26
|
+
# Herringbone::SimpleWriter.new(io) { string :code }
|
|
27
|
+
class SimpleWriter
|
|
28
|
+
# Opens a writer, yields it and closes it, finishing the file. If the block raises, the file is
|
|
29
|
+
# left unfinished (no footer), as with Writer.open. To declare columns, use #initialize and #close.
|
|
30
|
+
#
|
|
31
|
+
# @param io [IO, #write] destination; written sequentially, never closed
|
|
32
|
+
# @param options [Hash{Symbol => Object}] Writer options (compression:, row_group_bytes:...)
|
|
33
|
+
# @option options [Symbol] :compression (:snappy) codec, see Herringbone.codecs
|
|
34
|
+
# @option options [Integer] :row_group_bytes (16MB) approximate buffered size that triggers a row group
|
|
35
|
+
# @option options [Integer, nil] :row_group_rows (nil) also flush a row group after this many rows
|
|
36
|
+
# (other Writer options are passed on as well)
|
|
37
|
+
# @yield [writer] the open writer
|
|
38
|
+
# @yieldparam writer [SimpleWriter]
|
|
39
|
+
# @yieldreturn [Object] returned by open
|
|
40
|
+
# @return [SimpleWriter, Object] the writer without a block, the block's value with one
|
|
41
|
+
def self.open(io, **options)
|
|
42
|
+
writer = new(io, **options)
|
|
43
|
+
return writer unless block_given?
|
|
44
|
+
begin
|
|
45
|
+
result = yield writer
|
|
46
|
+
rescue Exception # rubocop:disable Lint/RescueException -- also abort on Interrupt
|
|
47
|
+
writer.abort
|
|
48
|
+
raise
|
|
49
|
+
end
|
|
50
|
+
writer.close
|
|
51
|
+
result
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# @return [Array<String>, nil] the column names, nil until #headers! is called
|
|
55
|
+
attr_reader :headers
|
|
56
|
+
|
|
57
|
+
# @param io [IO, #write] destination; nothing is written to it until the column types are known
|
|
58
|
+
# @param options [Hash{Symbol => Object}] Writer options (compression:, row_group_bytes:...)
|
|
59
|
+
# @option options [Symbol] :compression (:snappy) codec, see Herringbone.codecs
|
|
60
|
+
# @option options [Integer] :row_group_bytes (16MB) approximate buffered size that triggers a row group
|
|
61
|
+
# @option options [Integer, nil] :row_group_rows (nil) also flush a row group after this many rows
|
|
62
|
+
# (other Writer options are passed on as well)
|
|
63
|
+
# @yield optional block evaluated with +instance_eval+ on a Schema::Builder, declaring columns
|
|
64
|
+
# (named in #headers!) that replace inferred ones
|
|
65
|
+
# @raise [MissingCodecError] when the codec's optional gem is not loaded
|
|
66
|
+
def initialize(io, **options, &overrides)
|
|
67
|
+
@headers = nil
|
|
68
|
+
@overrides = overrides
|
|
69
|
+
fix = "Herringbone::SimpleWriter.new(io) { %s }"
|
|
70
|
+
@writer = InferringWriter.new(io, fix: fix, **options) { |sample| schema_for(sample) }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Names the columns, in the order row Arrays list their values. Must be called once, before
|
|
74
|
+
# the first row.
|
|
75
|
+
#
|
|
76
|
+
# @param names [Array<String, Symbol>] column names
|
|
77
|
+
# @return [self]
|
|
78
|
+
# @raise [ArgumentError] when called twice, with no names, or with a name given twice
|
|
79
|
+
def headers!(*names)
|
|
80
|
+
raise ArgumentError, "headers! was already called" if @headers
|
|
81
|
+
names = names.flatten.map(&:to_s)
|
|
82
|
+
raise ArgumentError, "headers! needs at least one column name" if names.empty?
|
|
83
|
+
duplicates = names.tally.select { |_, n| n > 1 }.keys
|
|
84
|
+
raise ArgumentError, "Duplicate column names: #{duplicates.join(", ")}" unless duplicates.empty?
|
|
85
|
+
@headers = names.freeze
|
|
86
|
+
self
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Appends a row: an Array with one value per header, in header order, or a Hash keyed by
|
|
90
|
+
# header (String or Symbol keys; missing columns are nulls).
|
|
91
|
+
#
|
|
92
|
+
# @param row [Array, Hash]
|
|
93
|
+
# @return [self]
|
|
94
|
+
# @raise [ArgumentError] when #headers! was not called, an Array has the wrong length or a Hash
|
|
95
|
+
# has keys that are not headers
|
|
96
|
+
# @raise [SchemaMismatch] when a value does not fit its column's inferred type
|
|
97
|
+
def <<(row)
|
|
98
|
+
raise ArgumentError, "Call headers! with the column names before writing rows" unless @headers
|
|
99
|
+
@writer << values_of(row)
|
|
100
|
+
self
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# @return [Integer] rows written so far, including those held back to infer the column types
|
|
104
|
+
def rows_written = @writer.rows_written
|
|
105
|
+
|
|
106
|
+
# Writes any rows still held back and finishes the file. The IO is not closed.
|
|
107
|
+
#
|
|
108
|
+
# @return [void]
|
|
109
|
+
# @raise [ArgumentError] when #headers! was never called
|
|
110
|
+
def close
|
|
111
|
+
raise ArgumentError, "Call headers! with the column names before closing" unless @headers
|
|
112
|
+
@writer.close
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Stops without finishing the file. What was already written stays in the IO.
|
|
116
|
+
#
|
|
117
|
+
# @return [void]
|
|
118
|
+
def abort = @writer.abort
|
|
119
|
+
|
|
120
|
+
private
|
|
121
|
+
|
|
122
|
+
# @param row [Array, Hash] row given to #<<
|
|
123
|
+
# @return [Array] its values in header order
|
|
124
|
+
# @raise [ArgumentError] when it does not match the headers
|
|
125
|
+
def values_of(row)
|
|
126
|
+
case row
|
|
127
|
+
when Array
|
|
128
|
+
unless row.size == @headers.size
|
|
129
|
+
raise ArgumentError, "Expected #{@headers.size} values (#{@headers.join(", ")}), got #{row.size}"
|
|
130
|
+
end
|
|
131
|
+
row
|
|
132
|
+
when Hash
|
|
133
|
+
unknown = row.keys.map(&:to_s) - @headers
|
|
134
|
+
raise ArgumentError, "Not among the headers (#{@headers.join(", ")}): #{unknown.join(", ")}" unless unknown.empty?
|
|
135
|
+
@headers.map { |name| row.fetch(name) { row[name.to_sym] } }
|
|
136
|
+
else
|
|
137
|
+
raise ArgumentError, "Rows must be Arrays or Hashes, got #{row.class}"
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# @param sample [Array<Array>] held-back rows, values in header order
|
|
142
|
+
# @return [Schema] inferred from the sample; string columns when it is empty
|
|
143
|
+
# @raise [ArgumentError] when a column cannot be inferred or a declared column is not a header
|
|
144
|
+
def schema_for(sample)
|
|
145
|
+
rows = sample.empty? ? [@headers.to_h { |name| [name, nil] }] : sample.map { |values| @headers.zip(values).to_h }
|
|
146
|
+
schema = Schema.infer(rows, &@overrides)
|
|
147
|
+
extra = schema.fields.map(&:name) - @headers
|
|
148
|
+
raise ArgumentError, "Declared columns not among the headers: #{extra.join(", ")}" unless extra.empty?
|
|
149
|
+
schema
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|