herringbone 0.3.0 → 0.5.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.
@@ -3,9 +3,9 @@
3
3
  module Herringbone
4
4
  # XXH64 (seed 0), the hash Parquet bloom filters use.
5
5
  #
6
- # When the optional "xxhash" gem (a C extension) can be loaded it is used, which is 20-40x
7
- # faster; otherwise hashing is pure Ruby. The gem is only a speedup, so nothing fails without
8
- # it. +XXHash.backend = :ruby+ forces pure Ruby (for tests and benchmarks).
6
+ # When the optional "xxhash" gem (a C extension) is loaded (+require "xxhash"+) it is used,
7
+ # which is 20-40x faster; otherwise hashing is pure Ruby. The gem is only a speedup, so nothing
8
+ # fails without it. +XXHash.backend = :ruby+ forces pure Ruby (for tests and benchmarks).
9
9
  #
10
10
  # The pure-Ruby version keeps every 64-bit value as two 32-bit halves, and multiplies by the
11
11
  # XXH64 primes split into 16-bit pieces, so that no intermediate result leaves the Fixnum range.
@@ -286,8 +286,7 @@ module Herringbone
286
286
 
287
287
  module_eval(Generator.source, __FILE__, __LINE__)
288
288
 
289
- @native = nil # nil: not resolved yet, false: pure Ruby, else the native module
290
- @native_lib = nil
289
+ @native = nil # nil: native when loaded, false: pure Ruby, else the native module
291
290
 
292
291
  class << self
293
292
  # XXH64 of a String's bytes, as an unsigned 64-bit Integer
@@ -296,7 +295,7 @@ module Herringbone
296
295
  # @return [Integer] the hash, 0...2^64
297
296
  def xxh64(bytes)
298
297
  native = @native
299
- native = resolve_backend if native.nil?
298
+ native = native_library if native.nil?
300
299
  native ? native.xxh64(bytes, 0) : ruby_xxh64(bytes)
301
300
  end
302
301
 
@@ -307,7 +306,7 @@ module Herringbone
307
306
  # @return [Integer] the hash, 0...2^64
308
307
  def xxh64_u64(lane)
309
308
  native = @native
310
- native = resolve_backend if native.nil?
309
+ native = native_library if native.nil?
311
310
  return native.xxh64([lane].pack("Q<"), 0) if native
312
311
  ruby_xxh64_lane((lane >> 32) & M32, lane & M32)
313
312
  end
@@ -318,7 +317,7 @@ module Herringbone
318
317
  # @return [Integer] the hash, 0...2^64
319
318
  def xxh64_u32(word)
320
319
  native = @native
321
- native = resolve_backend if native.nil?
320
+ native = native_library if native.nil?
322
321
  return native.xxh64([word].pack("L<"), 0) if native
323
322
  ruby_xxh64_u32(word & M32)
324
323
  end
@@ -329,7 +328,7 @@ module Herringbone
329
328
  # @return [Array<Integer>] the hashes, in the same order
330
329
  def xxh64_u64_all(lanes)
331
330
  native = @native
332
- native = resolve_backend if native.nil?
331
+ native = native_library if native.nil?
333
332
  if native
334
333
  packed = lanes.pack("Q<*")
335
334
  Array.new(lanes.size) { |i| native.xxh64(packed.byteslice(i << 3, 8), 0) }
@@ -344,7 +343,7 @@ module Herringbone
344
343
  # @return [Array<Integer>] the hashes, in the same order
345
344
  def xxh64_u32_all(words)
346
345
  native = @native
347
- native = resolve_backend if native.nil?
346
+ native = native_library if native.nil?
348
347
  if native
349
348
  packed = words.pack("L<*")
350
349
  Array.new(words.size) { |i| native.xxh64(packed.byteslice(i << 2, 4), 0) }
@@ -359,7 +358,7 @@ module Herringbone
359
358
  # @return [Array<Integer>] the hashes, in the same order
360
359
  def xxh64_all(strings)
361
360
  native = @native
362
- native = resolve_backend if native.nil?
361
+ native = native_library if native.nil?
363
362
  native ? strings.map { |s| native.xxh64(s, 0) } : strings.map { |s| ruby_xxh64(s) }
364
363
  end
365
364
 
@@ -368,57 +367,43 @@ module Herringbone
368
367
  # @return [Symbol] +:native+ or +:ruby+
369
368
  def backend
370
369
  native = @native
371
- native = resolve_backend if native.nil?
370
+ native = native_library if native.nil?
372
371
  native ? :native : :ruby
373
372
  end
374
373
 
375
- # :ruby forces pure Ruby, :native requires the xxhash gem (UnsupportedError if it cannot be
376
- # loaded), nil goes back to the default: native when available
374
+ # :ruby forces pure Ruby, :native the xxhash gem (UnsupportedError if it is not loaded), nil
375
+ # goes back to the default: native when loaded
377
376
  #
378
377
  # @param name [Symbol, nil] +:ruby+, +:native+ or nil
379
378
  # @return [void]
380
- # @raise [UnsupportedError] for +:native+ when the gem cannot be loaded
379
+ # @raise [UnsupportedError] for +:native+ when the gem is not loaded
381
380
  # @raise [ArgumentError] for any other name
382
381
  def backend=(name)
383
382
  @native = case name
384
383
  when :ruby then false
385
384
  when :native
386
- native_library || raise(UnsupportedError, "The \"#{NATIVE_GEM}\" gem could not be loaded")
385
+ native_library || raise(UnsupportedError, "The \"#{NATIVE_GEM}\" gem is not loaded (require \"#{NATIVE_GEM}\")")
387
386
  when nil then nil
388
387
  else raise ArgumentError, "Unknown XXHash backend #{name.inspect} (expected :ruby, :native or nil)"
389
388
  end
390
389
  end
391
390
 
392
- # Whether the native xxhash gem can be loaded (whatever the selected backend)
391
+ # Whether the native xxhash gem is loaded (whatever the selected backend)
393
392
  #
394
- # @return [Boolean] true when the gem is loadable
393
+ # @return [Boolean] true when the gem is loaded
395
394
  def native_available?
396
395
  !!native_library
397
396
  end
398
397
 
399
398
  private
400
399
 
401
- # Picks the default backend on first use: native when the gem loads, pure Ruby otherwise
402
- #
403
- # @return [Module, false] the native module, or false for pure Ruby
404
- def resolve_backend
405
- @native = native_library || false
406
- end
407
-
408
- # Requires the xxhash gem once and memoizes the result (a failed require is not retried)
400
+ # Not memoized, so that it works the same in every Ractor and once the gem is required later
409
401
  #
410
402
  # @return [Module, false] the gem's module to call +xxh64(data, seed)+ on, or false when it
411
- # cannot be loaded
403
+ # is not loaded
412
404
  def native_library
413
- if @native_lib.nil?
414
- @native_lib = begin
415
- require NATIVE_GEM
416
- defined?(::XXhash::XXhashInternal) ? ::XXhash::XXhashInternal : ::XXhash
417
- rescue LoadError
418
- false
419
- end
420
- end
421
- @native_lib
405
+ return ::XXhash::XXhashInternal if defined?(::XXhash::XXhashInternal)
406
+ defined?(::XXhash.xxh64) ? ::XXhash : false
422
407
  end
423
408
  end
424
409
  end
data/lib/herringbone.rb CHANGED
@@ -10,8 +10,33 @@ module Herringbone
10
10
  class Error < StandardError; end
11
11
  # The file is not valid Parquet (bad metadata, corrupt pages...)
12
12
  class FormatError < Error; end
13
+
13
14
  # A value cannot be written to its column
14
- class EncodeError < Error; end
15
+ class EncodeError < Error
16
+ # @return [Integer, nil] index of the row being written (0 for the first), when known
17
+ attr_reader :row
18
+ # @return [String, nil] dotted path of the column the value was meant for, when known
19
+ attr_reader :column
20
+ # @return [Object] the value that could not be written (nil when it was a missing required value)
21
+ attr_reader :value
22
+
23
+ # @param message [String, nil] the error message
24
+ # @param row [Integer, nil] index of the row being written
25
+ # @param column [String, nil] dotted path of the column
26
+ # @param value [Object] the value that could not be written
27
+ def initialize(message = nil, row: nil, column: nil, value: nil)
28
+ super(message)
29
+ @row = row
30
+ @column = column
31
+ @value = value
32
+ end
33
+ end
34
+
35
+ # A row does not fit a schema that was inferred from earlier rows (Herringbone.write without
36
+ # +schema:+, SimpleWriter). The message says what was inferred, from how many rows, and how to
37
+ # declare the column instead.
38
+ class SchemaMismatch < EncodeError; end
39
+
15
40
  # The file (or the writer configuration) uses a Parquet feature Herringbone does not implement,
16
41
  # such as encryption or a codec whose library is unavailable
17
42
  class UnsupportedError < Error; end
@@ -20,6 +45,7 @@ end
20
45
  require_relative "herringbone/io_buffer_support"
21
46
  require_relative "herringbone/codecs/snappy"
22
47
  require_relative "herringbone/codecs/lz4"
48
+ require_relative "herringbone/codecs/lzo"
23
49
  require_relative "herringbone/thrift"
24
50
  require_relative "herringbone/format"
25
51
  require_relative "herringbone/encodings/rle"
@@ -32,10 +58,13 @@ require_relative "herringbone/active_record"
32
58
  require_relative "herringbone/reader"
33
59
  require_relative "herringbone/byte_values"
34
60
  require_relative "herringbone/writer"
61
+ require_relative "herringbone/inferring_writer"
62
+ require_relative "herringbone/simple_writer"
35
63
  require_relative "herringbone/xxhash"
36
64
  require_relative "herringbone/bloom_filter"
37
65
  require_relative "herringbone/inspector"
38
66
  require_relative "herringbone/visualizer"
67
+ require_relative "herringbone/redaction"
39
68
 
40
69
  module Herringbone
41
70
  module_function
@@ -43,10 +72,13 @@ module Herringbone
43
72
  # Writes +records+ to +io+ (any IO responding to #write; Herringbone never opens files by path)
44
73
  # and returns the number of rows written. +records+ is an Enumerable of rows, or an ActiveRecord
45
74
  # model or relation, which is read with find_each. Without +schema+, the schema comes from the
46
- # model's columns (Schema.from_active_record) or is inferred from the first rows (Schema.infer).
47
- # Other options go to Writer.
75
+ # model's columns (Schema.from_active_record) or is inferred from the first rows (Schema.infer);
76
+ # fields declared in the block replace inferred ones. +records+ is iterated once, so a source
77
+ # that can only be read once (a cursor, a lazy Enumerator over an IO) works too. Other options go
78
+ # to Writer.
48
79
  #
49
80
  # File.open("orders.parquet", "wb") { |f| Herringbone.write(f, Order.where(created_at: 1.year.ago..)) }
81
+ # Herringbone.write(io, events.lazy.map(&:to_h)) { |s| s.json :payload }
50
82
  #
51
83
  # @param io [IO, #write] destination; written sequentially, never closed
52
84
  # @param records [Enumerable<Hash, Array, Object>, Class, #find_each] rows (Hashes, Arrays in schema
@@ -66,27 +98,81 @@ module Herringbone
66
98
  # @option options [Hash{String => String}] :metadata ({}) key/value metadata for the footer
67
99
  # @option options [Boolean, Array<String>, Hash{String => Boolean, Hash}] :bloom_filters (nil)
68
100
  # columns to write split block bloom filters for, see Writer
101
+ # @yield [s] optional, declares fields that replace inferred ones (see Schema.infer); ignored when
102
+ # the schema is not inferred
103
+ # @yieldparam s [Schema::Builder] the builder to declare fields on
104
+ # @yieldreturn [void]
69
105
  # @return [Integer] number of rows written
70
106
  # @raise [ArgumentError] when the schema has to be inferred and +records+ is empty or holds Array
71
- # rows, or an option is invalid
107
+ # rows, an option is invalid, or the block takes no parameter
72
108
  # @raise [EncodeError] when a row does not fit the schema
73
- def write(io, records, schema: nil, **options)
109
+ # @raise [SchemaMismatch] when a row does not fit the inferred schema; the file is left unfinished
110
+ def write(io, records, schema: nil, **options, &overrides)
111
+ Schema::Builder.check_block!(overrides, "Herringbone.write(io, rows) { |s| s.json :payload }")
74
112
  model = if records.respond_to?(:klass) then records.klass
75
113
  elsif records.respond_to?(:columns) && records.respond_to?(:find_each) then records
76
114
  end
77
- schema ||= model ? Schema.from_active_record(model) : Schema.infer(records)
78
- Writer.open(io, schema, **options) do |writer|
115
+ schema ||= Schema.from_active_record(model) if model
116
+ writer = if schema
117
+ Writer.new(io, schema, **options)
118
+ else
119
+ fix = "Herringbone.write(io, rows) { |s| s.%s }"
120
+ InferringWriter.new(io, fix: fix, **options) { |sample| Schema.infer(sample, &overrides) }
121
+ end
122
+ begin
79
123
  if records.respond_to?(:find_each)
80
124
  records.find_each { |record| writer << record }
81
125
  else
82
126
  records.each { |record| writer << record }
83
127
  end
84
- writer.rows_written
128
+ rescue Exception # rubocop:disable Lint/RescueException -- also abort on Interrupt
129
+ writer.abort
130
+ raise
131
+ end
132
+ writer.close
133
+ writer.rows_written
134
+ end
135
+
136
+ # Rewrites +input+ into +output+ with rows removed or values replaced, building the Redaction from
137
+ # the block (or taking +redaction+). A shortcut for Redaction#apply.
138
+ #
139
+ # Herringbone.redact(input, output) do |r|
140
+ # r.where(user_id: 42).delete
141
+ # r.replace(:email) { |email| email && OpenSSL::HMAC.hexdigest("SHA256", KEY, email) }
142
+ # end
143
+ #
144
+ # @param input [IO, StringIO] the Parquet file, read with #seek and #read; not closed
145
+ # @param output [IO, #write] destination, written sequentially; not closed
146
+ # @param redaction [Redaction, nil] the redaction to apply, instead of a block
147
+ # @param writer_options [Hash{Symbol => Object}] Writer options for re-encoded column chunks, and
148
+ # +metadata:+ to replace the footer key/value metadata, see Redaction#apply
149
+ # @option writer_options [Symbol] :compression (codec of each source chunk) codec for re-encoded chunks
150
+ # @option writer_options [Boolean, Array<String>, Hash{String => Boolean, Hash}] :bloom_filters (nil)
151
+ # columns whose re-encoded chunks get a bloom filter, besides those whose source chunk had one
152
+ # @option writer_options [Hash{String => String}] :metadata (the input's) footer key/value metadata
153
+ # @option writer_options [Integer] :page_bytes (1MB) approximate uncompressed data page size
154
+ # @option writer_options [Integer] :page_rows (20_000) maximum rows per data page
155
+ # @option writer_options [Integer] :data_page_version (1) 1 or 2
156
+ # @option writer_options [Boolean, Array<String>] :dictionary (true) see Writer
157
+ # @option writer_options [Hash{String => Symbol}] :encodings ({}) see Writer
158
+ # @yield [r] declares the statements on a new Redaction (see Redaction.new)
159
+ # @yieldparam r [Redaction] the redaction being built
160
+ # @yieldreturn [void]
161
+ # @return [Redaction::Report] what was done
162
+ # @raise [ArgumentError] when given both or neither of +redaction+ and a block, a block that takes
163
+ # no parameter, or when the redaction does not fit the file's schema
164
+ # @raise [EncodeError] when a replacement value cannot be written to its column
165
+ def redact(input, output, redaction = nil, **writer_options, &block)
166
+ raise ArgumentError, "Herringbone.redact takes a Redaction or a block, not both" if redaction && block
167
+ raise ArgumentError, "Herringbone.redact needs a Redaction or a block" unless redaction || block
168
+ if block&.arity&.zero?
169
+ raise ArgumentError, "The block receives the redaction: Herringbone.redact(input, output) { |r| r.where(user_id: 42).delete }"
85
170
  end
171
+ (redaction || Redaction.new(&block)).apply(input, output, **writer_options)
86
172
  end
87
173
 
88
174
  # Compression codecs this process can read and write, e.g. [:none, :snappy, :gzip, :lz4, :lz4_hadoop, :zstd].
89
- # :zstd and :brotli are listed when the zstd-ruby / brotli gems can be loaded.
175
+ # :zstd and :brotli are listed when the zstd-ruby / brotli gems are loaded.
90
176
  #
91
177
  # @return [Array<Symbol>] codec names accepted by the writer's +compression:+ option
92
178
  def codecs
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: herringbone
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Julik Tarkhanov
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2026-10-01 00:00:00.000000000 Z
10
+ date: 2026-10-02 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: bigdecimal
@@ -33,6 +33,7 @@ executables:
33
33
  extensions: []
34
34
  extra_rdoc_files: []
35
35
  files:
36
+ - CHANGELOG.md
36
37
  - LICENSE
37
38
  - README.md
38
39
  - bin/herringbone
@@ -41,12 +42,14 @@ files:
41
42
  - lib/herringbone/bloom_filter.rb
42
43
  - lib/herringbone/byte_values.rb
43
44
  - lib/herringbone/codecs/lz4.rb
45
+ - lib/herringbone/codecs/lzo.rb
44
46
  - lib/herringbone/codecs/snappy.rb
45
47
  - lib/herringbone/compression.rb
46
48
  - lib/herringbone/encodings/delta.rb
47
49
  - lib/herringbone/encodings/plain.rb
48
50
  - lib/herringbone/encodings/rle.rb
49
51
  - lib/herringbone/format.rb
52
+ - lib/herringbone/inferring_writer.rb
50
53
  - lib/herringbone/inspector.rb
51
54
  - lib/herringbone/io_buffer_support.rb
52
55
  - lib/herringbone/reader.rb
@@ -55,7 +58,10 @@ files:
55
58
  - lib/herringbone/reader/numo.rb
56
59
  - lib/herringbone/reader/page_stream.rb
57
60
  - lib/herringbone/reader/scan.rb
61
+ - lib/herringbone/redaction.rb
62
+ - lib/herringbone/redaction/rewriter.rb
58
63
  - lib/herringbone/schema.rb
64
+ - lib/herringbone/simple_writer.rb
59
65
  - lib/herringbone/thrift.rb
60
66
  - lib/herringbone/types.rb
61
67
  - lib/herringbone/version.rb