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.
@@ -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 installed
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 load_error [LoadError] the error from requiring the gem, whose message is included
15
- def initialize(codec, gem_name, load_error)
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 could not be loaded " \
19
- "(#{load_error.message}). Add `gem \"#{gem_name}\"` to your Gemfile to use #{codec}.")
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. ZSTD and Brotli come from optional gems (zstd-ruby, brotli),
25
- # which are required on first use; if they are missing, MissingCodecError says what to add.
26
- # (Snappy also uses the optional snappy gem when it is installed, see Codecs::Snappy.)
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
- }.freeze
35
-
36
- @libraries = {}
37
- @library_mutex = Mutex.new
35
+ })
38
36
 
39
- # The library module for a codec backed by an optional gem, requiring it on first use
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 cannot be loaded
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
- @libraries.fetch(codec) do
47
- @library_mutex.synchronize do
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
- # Requires a codec gem; a separate method so tests can stub it to simulate a missing gem
48
+ # A separate method so tests can stub it to simulate a missing gem
62
49
  #
63
- # @param path [String] require path of the gem
64
- # @return [Boolean] whether the library was newly loaded
65
- # @raise [LoadError] when the gem is not installed
66
- def require_library(path)
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 cannot be loaded
75
- # @raise [UnsupportedError] for a codec herringbone does not implement (LZO)
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 cannot be loaded
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 cannot be loaded
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
- }.freeze
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, RLE_WIDTH[@max_rep]) if @max_rep.positive?
296
- defs = PageStream::HybridDecoder.new(body, rep_len, rep_len + def_len, RLE_WIDTH[@max_def]) if @max_def.positive?
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 = RLE_WIDTH[max]
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
- }.freeze
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 neither numo-narray-alt nor numo-narray can be loaded
47
+ # @raise [UnsupportedError] if Numo is not loaded
52
48
  def load!
53
- return if @loaded
54
- @mutex.synchronize do
55
- next if @loaded
56
- begin
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
- # Separate from load! so tests can stub it to simulate a missing gem.
68
- # @return [Boolean] true if the library was loaded by this call
69
- # @raise [LoadError] if "numo/narray" is not installed
70
- def require_library
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
- # Width => Numo vector of 1 << j for each bit j, built lazily; dotting a bit matrix with it
204
- # turns rows of bits into integers. Int64 above 30 bits so the sums cannot overflow.
205
- POWERS = Hash.new { |h, w| h[w] = ((w > 30) ? Numo::Int64 : Numo::Int32).cast(Array.new(w) { |j| 1 << j }) }
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 = POWERS[width]
219
+ pow = powers(width)
228
220
  pow.class.cast(bits).dot(pow)
229
221
  end
230
222
  end
@@ -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 no Numo gem can be loaded
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 no Numo gem can be loaded
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 no Numo gem can be loaded
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
@@ -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
- # Memoized Types.reader_for of the node.
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
- # Memoized Types.writer_for of the node.
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
- # Memoized lookup of a struct's children; only valid for +:struct+ fields.
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