herringbone 0.1.0 → 0.3.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,425 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Herringbone
4
+ # XXH64 (seed 0), the hash Parquet bloom filters use.
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).
9
+ #
10
+ # The pure-Ruby version keeps every 64-bit value as two 32-bit halves, and multiplies by the
11
+ # XXH64 primes split into 16-bit pieces, so that no intermediate result leaves the Fixnum range.
12
+ # Masked 64-bit Integer arithmetic is shorter, but allocates a Bignum for almost every operation
13
+ # (about 25 per hash), and with a large live heap (a writer holding a row group) those
14
+ # allocations trigger so many garbage collections that hashing becomes up to 10x slower. The
15
+ # arithmetic is written once with the small code generators below, whose output is inlined into
16
+ # the hashing methods: no method calls or allocations in the hot paths.
17
+ module XXHash
18
+ # 64-bit mask
19
+ M = 0xFFFF_FFFF_FFFF_FFFF
20
+ # XXH64 PRIME64_1
21
+ P1 = 11_400_714_785_074_694_791
22
+ # XXH64 PRIME64_2
23
+ P2 = 14_029_467_366_897_019_727
24
+ # XXH64 PRIME64_3
25
+ P3 = 1_609_587_929_392_839_161
26
+ # XXH64 PRIME64_4
27
+ P4 = 9_650_029_242_287_828_579
28
+ # XXH64 PRIME64_5
29
+ P5 = 2_870_177_450_012_600_261
30
+ # 32-bit mask
31
+ M32 = 0xFFFF_FFFF
32
+ # unpack format splitting the input into little-endian 32-bit words
33
+ WORDS = "V*" # frozen, unlike a literal in the generated code
34
+
35
+ # Gem providing the native implementation
36
+ NATIVE_GEM = "xxhash"
37
+
38
+ # Code generators for the pure-Ruby hash: each returns Ruby source operating on a 64-bit
39
+ # value held in two local variables (+hi+ and +lo+, 32 bits each), using t0/t1 as scratch
40
+ module Generator
41
+ module_function
42
+
43
+ # Shifts are written as multiplications and divisions by powers of two, which YARV has
44
+ # specialized instructions for (<< and >> on Integers are method calls).
45
+ #
46
+ # (hi:lo) = (hi:lo) * c mod 2^64. Products are at most 48 bits wide: the low halves are
47
+ # multiplied by 16-bit pieces of c, and only the low 32 bits of the cross terms are kept.
48
+ #
49
+ # @param hi [String] variable holding the high 32 bits
50
+ # @param lo [String] variable holding the low 32 bits
51
+ # @param c [Integer] unsigned 64-bit constant multiplier
52
+ # @param hi_zero [Boolean] whether +hi+ is known to be 0, which drops its cross terms
53
+ # @return [String] Ruby source (uses t0 and t1 as scratch)
54
+ def mul(hi, lo, c, hi_zero: false)
55
+ ch = c >> 32
56
+ cl = c & M32
57
+ cross = "#{lo} * #{ch & 0xFFFF} + (#{lo} * #{ch >> 16} & 0xFFFF) * 65536"
58
+ cross += " + #{hi} * #{cl & 0xFFFF} + (#{hi} * #{cl >> 16} & 0xFFFF) * 65536" unless hi_zero
59
+ <<~RUBY
60
+ t1 = #{lo} * #{cl >> 16}
61
+ t0 = #{lo} * #{cl & 0xFFFF} + (t1 & 0xFFFF) * 65536
62
+ #{hi} = (t0 / 4294967296 + t1 / 65536 + #{cross}) & 0xFFFFFFFF
63
+ #{lo} = t0 & 0xFFFFFFFF
64
+ RUBY
65
+ end
66
+
67
+ # Rotate (hi:lo) left by r bits (0 < r < 32)
68
+ #
69
+ # @param hi [String] variable holding the high 32 bits
70
+ # @param lo [String] variable holding the low 32 bits
71
+ # @param r [Integer] rotation in bits, 1..31
72
+ # @return [String] Ruby source (uses t0 as scratch)
73
+ def rotl(hi, lo, r)
74
+ mask = (1 << (32 - r)) - 1
75
+ <<~RUBY
76
+ t0 = #{hi}
77
+ #{hi} = (#{hi} & #{mask}) * #{1 << r} | #{lo} / #{1 << (32 - r)}
78
+ #{lo} = (#{lo} & #{mask}) * #{1 << r} | t0 / #{1 << (32 - r)}
79
+ RUBY
80
+ end
81
+
82
+ # (hi:lo) += (a_hi:a_lo), where the addend is two expressions (constants or variables)
83
+ #
84
+ # @param hi [String] variable holding the high 32 bits
85
+ # @param lo [String] variable holding the low 32 bits
86
+ # @param a_hi [String, Integer] expression for the addend's high 32 bits
87
+ # @param a_lo [String, Integer] expression for the addend's low 32 bits
88
+ # @return [String] Ruby source
89
+ def add(hi, lo, a_hi, a_lo)
90
+ <<~RUBY
91
+ #{lo} += #{a_lo}
92
+ #{hi} = (#{hi} + #{a_hi} + #{lo} / 4294967296) & 0xFFFFFFFF
93
+ #{lo} &= 0xFFFFFFFF
94
+ RUBY
95
+ end
96
+
97
+ # (hi:lo) += c, mod 2^64
98
+ #
99
+ # @param hi [String] variable holding the high 32 bits
100
+ # @param lo [String] variable holding the low 32 bits
101
+ # @param c [Integer] unsigned 64-bit constant addend
102
+ # @return [String] Ruby source
103
+ def add_const(hi, lo, c)
104
+ add(hi, lo, c >> 32, c & M32)
105
+ end
106
+
107
+ # Assigns the 64-bit constant c to (hi:lo)
108
+ #
109
+ # @param hi [String] variable for the high 32 bits
110
+ # @param lo [String] variable for the low 32 bits
111
+ # @param c [Integer] unsigned 64-bit constant
112
+ # @return [String] Ruby source
113
+ def set(hi, lo, c)
114
+ "#{hi} = #{c >> 32}\n#{lo} = #{c & M32}\n"
115
+ end
116
+
117
+ # XXH64 round with a zero accumulator: (hi:lo) = rotl(lane * P2, 31) * P1
118
+ #
119
+ # @param hi [String] variable holding the lane's high 32 bits, replaced by the result's
120
+ # @param lo [String] variable holding the lane's low 32 bits, replaced by the result's
121
+ # @return [String] Ruby source
122
+ def round0(hi, lo)
123
+ mul(hi, lo, P2) + rotl(hi, lo, 31) + mul(hi, lo, P1)
124
+ end
125
+
126
+ # Stripe round: acc = rotl(acc + lane * P2, 31) * P1, with the lane in (xh:xl)
127
+ #
128
+ # @param hi [String] variable holding the accumulator's high 32 bits
129
+ # @param lo [String] variable holding the accumulator's low 32 bits
130
+ # @param xh [String] variable holding the lane's high 32 bits (clobbered)
131
+ # @param xl [String] variable holding the lane's low 32 bits (clobbered)
132
+ # @return [String] Ruby source
133
+ def round(hi, lo, xh, xl)
134
+ mul(xh, xl, P2) + add(hi, lo, xh, xl) + rotl(hi, lo, 31) + mul(hi, lo, P1)
135
+ end
136
+
137
+ # h ^= rotl(v * P2, 31) * P1; h = h * P1 + P4, with v in (vh:vl) (left unchanged)
138
+ #
139
+ # @param hi [String] variable holding h's high 32 bits
140
+ # @param lo [String] variable holding h's low 32 bits
141
+ # @param vh [String] variable holding the accumulator v's high 32 bits
142
+ # @param vl [String] variable holding the accumulator v's low 32 bits
143
+ # @return [String] Ruby source (uses xh and xl as scratch)
144
+ def merge_round(hi, lo, vh, vl)
145
+ "xh = #{vh}\nxl = #{vl}\n" + round0("xh", "xl") +
146
+ "#{hi} ^= xh\n#{lo} ^= xl\n" + mul(hi, lo, P1) + add_const(hi, lo, P4)
147
+ end
148
+
149
+ # Consumes an 8-byte lane in (xh:xl)
150
+ #
151
+ # @param hi [String] variable holding the hash's high 32 bits
152
+ # @param lo [String] variable holding the hash's low 32 bits
153
+ # @return [String] Ruby source
154
+ def lane8(hi, lo)
155
+ round0("xh", "xl") + "#{hi} ^= xh\n#{lo} ^= xl\n" + rotl(hi, lo, 27) + mul(hi, lo, P1) + add_const(hi, lo, P4)
156
+ end
157
+
158
+ # Consumes a 4-byte word in xl
159
+ #
160
+ # @param hi [String] variable holding the hash's high 32 bits
161
+ # @param lo [String] variable holding the hash's low 32 bits
162
+ # @return [String] Ruby source (sets xh)
163
+ def lane4(hi, lo)
164
+ "xh = 0\n" + mul("xh", "xl", P1, hi_zero: true) + "#{hi} ^= xh\n#{lo} ^= xl\n" +
165
+ rotl(hi, lo, 23) + mul(hi, lo, P2) + add_const(hi, lo, P3)
166
+ end
167
+
168
+ # Consumes one byte in xl (as it is below 2^16, byte * P5 needs no splitting)
169
+ #
170
+ # @param hi [String] variable holding the hash's high 32 bits
171
+ # @param lo [String] variable holding the hash's low 32 bits
172
+ # @return [String] Ruby source
173
+ def lane1(hi, lo)
174
+ <<~RUBY + rotl(hi, lo, 11) + mul(hi, lo, P1)
175
+ t0 = xl * #{P5 & M32}
176
+ #{hi} ^= (t0 / 4294967296 + xl * #{P5 >> 32}) & 0xFFFFFFFF
177
+ #{lo} ^= t0 & 0xFFFFFFFF
178
+ RUBY
179
+ end
180
+
181
+ # Final mix; evaluates to the hash as one Integer
182
+ #
183
+ # @param hi [String] variable holding the hash's high 32 bits
184
+ # @param lo [String] variable holding the hash's low 32 bits
185
+ # @return [String] Ruby source whose last expression is the 64-bit hash
186
+ def avalanche(hi, lo)
187
+ "#{lo} ^= #{hi} / 2\n" + mul(hi, lo, P2) +
188
+ "#{lo} ^= (#{hi} & 0x1FFFFFFF) * 8 | #{lo} / 536870912\n#{hi} ^= #{hi} / 536870912\n" +
189
+ mul(hi, lo, P3) + "#{lo} ^= #{hi}\n(#{hi} << 32) | #{lo}\n"
190
+ end
191
+
192
+ # Source of the pure-Ruby hashing methods, evaluated into XXHash: +ruby_xxh64(bytes)+,
193
+ # +ruby_xxh64_lane(xh, xl)+ (8 bytes as two 32-bit halves) and +ruby_xxh64_u32(xl)+ (4 bytes)
194
+ #
195
+ # @return [String] Ruby source defining the three singleton methods
196
+ def source
197
+ g = self
198
+ <<~RUBY
199
+ # XXH64 of 8 bytes given as two little-endian 32-bit halves
200
+ def self.ruby_xxh64_lane(xh, xl)
201
+ #{g.set("h", "l", P5 + 8)}
202
+ #{g.lane8("h", "l")}
203
+ #{g.avalanche("h", "l")}
204
+ end
205
+
206
+ # XXH64 of 4 bytes given as a little-endian 32-bit word
207
+ def self.ruby_xxh64_u32(xl)
208
+ #{g.set("h", "l", P5 + 4)}
209
+ #{g.lane4("h", "l")}
210
+ #{g.avalanche("h", "l")}
211
+ end
212
+
213
+ def self.ruby_xxh64(bytes)
214
+ len = bytes.bytesize
215
+ words = bytes.unpack(WORDS)
216
+ i = 0
217
+ if len >= 32
218
+ #{g.set("ah", "al", (P1 + P2) & M)}
219
+ #{g.set("bh", "bl", P2)}
220
+ ch = 0
221
+ cl = 0
222
+ #{g.set("dh", "dl", (-P1) & M)}
223
+ limit = (len >> 5) << 3
224
+ while i < limit
225
+ xl = words[i]
226
+ xh = words[i + 1]
227
+ #{g.round("ah", "al", "xh", "xl")}
228
+ xl = words[i + 2]
229
+ xh = words[i + 3]
230
+ #{g.round("bh", "bl", "xh", "xl")}
231
+ xl = words[i + 4]
232
+ xh = words[i + 5]
233
+ #{g.round("ch", "cl", "xh", "xl")}
234
+ xl = words[i + 6]
235
+ xh = words[i + 7]
236
+ #{g.round("dh", "dl", "xh", "xl")}
237
+ i += 8
238
+ end
239
+ h = ah
240
+ l = al
241
+ #{g.rotl("h", "l", 1)}
242
+ yh = bh
243
+ yl = bl
244
+ #{g.rotl("yh", "yl", 7)}
245
+ #{g.add("h", "l", "yh", "yl")}
246
+ yh = ch
247
+ yl = cl
248
+ #{g.rotl("yh", "yl", 12)}
249
+ #{g.add("h", "l", "yh", "yl")}
250
+ yh = dh
251
+ yl = dl
252
+ #{g.rotl("yh", "yl", 18)}
253
+ #{g.add("h", "l", "yh", "yl")}
254
+ #{g.merge_round("h", "l", "ah", "al")}
255
+ #{g.merge_round("h", "l", "bh", "bl")}
256
+ #{g.merge_round("h", "l", "ch", "cl")}
257
+ #{g.merge_round("h", "l", "dh", "dl")}
258
+ #{g.add("h", "l", 0, "len")}
259
+ else
260
+ #{g.set("h", "l", P5)}
261
+ #{g.add("h", "l", 0, "len")}
262
+ end
263
+ nwords = words.size
264
+ while i + 2 <= nwords
265
+ xl = words[i]
266
+ xh = words[i + 1]
267
+ #{g.lane8("h", "l")}
268
+ i += 2
269
+ end
270
+ if i < nwords
271
+ xl = words[i]
272
+ #{g.lane4("h", "l")}
273
+ i += 1
274
+ end
275
+ pos = i << 2
276
+ while pos < len
277
+ xl = bytes.getbyte(pos)
278
+ #{g.lane1("h", "l")}
279
+ pos += 1
280
+ end
281
+ #{g.avalanche("h", "l")}
282
+ end
283
+ RUBY
284
+ end
285
+ end
286
+
287
+ module_eval(Generator.source, __FILE__, __LINE__)
288
+
289
+ @native = nil # nil: not resolved yet, false: pure Ruby, else the native module
290
+ @native_lib = nil
291
+
292
+ class << self
293
+ # XXH64 of a String's bytes, as an unsigned 64-bit Integer
294
+ #
295
+ # @param bytes [String] data to hash (its encoding is ignored)
296
+ # @return [Integer] the hash, 0...2^64
297
+ def xxh64(bytes)
298
+ native = @native
299
+ native = resolve_backend if native.nil?
300
+ native ? native.xxh64(bytes, 0) : ruby_xxh64(bytes)
301
+ end
302
+
303
+ # XXH64 of 8 bytes given as a little-endian 64-bit Integer (an INT64 or DOUBLE's PLAIN
304
+ # encoding), signed or unsigned: only its low 64 bits are used
305
+ #
306
+ # @param lane [Integer] value whose low 64 bits are hashed
307
+ # @return [Integer] the hash, 0...2^64
308
+ def xxh64_u64(lane)
309
+ native = @native
310
+ native = resolve_backend if native.nil?
311
+ return native.xxh64([lane].pack("Q<"), 0) if native
312
+ ruby_xxh64_lane((lane >> 32) & M32, lane & M32)
313
+ end
314
+
315
+ # XXH64 of 4 bytes given as a little-endian 32-bit Integer (INT32, FLOAT), signed or unsigned
316
+ #
317
+ # @param word [Integer] value whose low 32 bits are hashed
318
+ # @return [Integer] the hash, 0...2^64
319
+ def xxh64_u32(word)
320
+ native = @native
321
+ native = resolve_backend if native.nil?
322
+ return native.xxh64([word].pack("L<"), 0) if native
323
+ ruby_xxh64_u32(word & M32)
324
+ end
325
+
326
+ # Hashes of many 64-bit Integers (low 64 bits of each)
327
+ #
328
+ # @param lanes [Array<Integer>] values to hash, signed or unsigned
329
+ # @return [Array<Integer>] the hashes, in the same order
330
+ def xxh64_u64_all(lanes)
331
+ native = @native
332
+ native = resolve_backend if native.nil?
333
+ if native
334
+ packed = lanes.pack("Q<*")
335
+ Array.new(lanes.size) { |i| native.xxh64(packed.byteslice(i << 3, 8), 0) }
336
+ else
337
+ lanes.map { |v| ruby_xxh64_lane(v / 4_294_967_296 & M32, v & M32) }
338
+ end
339
+ end
340
+
341
+ # Hashes of many 32-bit Integers (low 32 bits of each)
342
+ #
343
+ # @param words [Array<Integer>] values to hash, signed or unsigned
344
+ # @return [Array<Integer>] the hashes, in the same order
345
+ def xxh64_u32_all(words)
346
+ native = @native
347
+ native = resolve_backend if native.nil?
348
+ if native
349
+ packed = words.pack("L<*")
350
+ Array.new(words.size) { |i| native.xxh64(packed.byteslice(i << 2, 4), 0) }
351
+ else
352
+ words.map { |v| ruby_xxh64_u32(v & M32) }
353
+ end
354
+ end
355
+
356
+ # Hashes of many Strings
357
+ #
358
+ # @param strings [Array<String>] values whose bytes are hashed
359
+ # @return [Array<Integer>] the hashes, in the same order
360
+ def xxh64_all(strings)
361
+ native = @native
362
+ native = resolve_backend if native.nil?
363
+ native ? strings.map { |s| native.xxh64(s, 0) } : strings.map { |s| ruby_xxh64(s) }
364
+ end
365
+
366
+ # :native when the xxhash gem is used, :ruby otherwise
367
+ #
368
+ # @return [Symbol] +:native+ or +:ruby+
369
+ def backend
370
+ native = @native
371
+ native = resolve_backend if native.nil?
372
+ native ? :native : :ruby
373
+ end
374
+
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
377
+ #
378
+ # @param name [Symbol, nil] +:ruby+, +:native+ or nil
379
+ # @return [void]
380
+ # @raise [UnsupportedError] for +:native+ when the gem cannot be loaded
381
+ # @raise [ArgumentError] for any other name
382
+ def backend=(name)
383
+ @native = case name
384
+ when :ruby then false
385
+ when :native
386
+ native_library || raise(UnsupportedError, "The \"#{NATIVE_GEM}\" gem could not be loaded")
387
+ when nil then nil
388
+ else raise ArgumentError, "Unknown XXHash backend #{name.inspect} (expected :ruby, :native or nil)"
389
+ end
390
+ end
391
+
392
+ # Whether the native xxhash gem can be loaded (whatever the selected backend)
393
+ #
394
+ # @return [Boolean] true when the gem is loadable
395
+ def native_available?
396
+ !!native_library
397
+ end
398
+
399
+ private
400
+
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)
409
+ #
410
+ # @return [Module, false] the gem's module to call +xxh64(data, seed)+ on, or false when it
411
+ # cannot be loaded
412
+ 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
422
+ end
423
+ end
424
+ end
425
+ end
data/lib/herringbone.rb CHANGED
@@ -6,10 +6,14 @@ require_relative "herringbone/version"
6
6
 
7
7
  # Pure-Ruby reader and writer for Apache Parquet files
8
8
  module Herringbone
9
+ # Base class of every error Herringbone raises on purpose
9
10
  class Error < StandardError; end
11
+ # The file is not valid Parquet (bad metadata, corrupt pages...)
10
12
  class FormatError < Error; end
11
- class DecodeError < FormatError; end
13
+ # A value cannot be written to its column
12
14
  class EncodeError < Error; end
15
+ # The file (or the writer configuration) uses a Parquet feature Herringbone does not implement,
16
+ # such as encryption or a codec whose library is unavailable
13
17
  class UnsupportedError < Error; end
14
18
  end
15
19
 
@@ -28,18 +32,69 @@ require_relative "herringbone/active_record"
28
32
  require_relative "herringbone/reader"
29
33
  require_relative "herringbone/byte_values"
30
34
  require_relative "herringbone/writer"
35
+ require_relative "herringbone/xxhash"
36
+ require_relative "herringbone/bloom_filter"
37
+ require_relative "herringbone/inspector"
38
+ require_relative "herringbone/visualizer"
31
39
 
32
40
  module Herringbone
33
- Delta = Encodings::Delta
34
-
35
41
  module_function
36
42
 
37
- def open(path, &block) = Reader.open(path, &block)
38
- def read(path, columns: nil) = Reader.open(path) { |r| r.rows(columns: columns) }
43
+ # Writes +records+ to +io+ (any IO responding to #write; Herringbone never opens files by path)
44
+ # and returns the number of rows written. +records+ is an Enumerable of rows, or an ActiveRecord
45
+ # 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.
48
+ #
49
+ # File.open("orders.parquet", "wb") { |f| Herringbone.write(f, Order.where(created_at: 1.year.ago..)) }
50
+ #
51
+ # @param io [IO, #write] destination; written sequentially, never closed
52
+ # @param records [Enumerable<Hash, Array, Object>, Class, #find_each] rows (Hashes, Arrays in schema
53
+ # order, or objects responding to #attributes or #to_h), or an ActiveRecord model or relation
54
+ # @param schema [Schema, nil] schema to write with; derived from +records+ when nil
55
+ # @param options [Hash{Symbol => Object}] passed to Writer.new
56
+ # @option options [Symbol] :compression (:snappy) codec, see Herringbone.codecs
57
+ # @option options [Integer] :row_group_bytes (16MB) approximate buffered size that triggers a row group
58
+ # @option options [Integer, nil] :row_group_rows (nil) also flush a row group after this many rows
59
+ # @option options [Integer] :page_bytes (1MB) approximate uncompressed data page size
60
+ # @option options [Integer] :page_rows (20_000) maximum rows per data page
61
+ # @option options [Integer] :data_page_version (1) 1 or 2
62
+ # @option options [Boolean, Array<String>] :dictionary (true) dictionary-encode all eligible columns,
63
+ # none, or only the listed dotted column paths
64
+ # @option options [Hash{String => Symbol}] :encodings ({}) dotted column path => value encoding
65
+ # for non-dictionary pages
66
+ # @option options [Hash{String => String}] :metadata ({}) key/value metadata for the footer
67
+ # @option options [Boolean, Array<String>, Hash{String => Boolean, Hash}] :bloom_filters (nil)
68
+ # columns to write split block bloom filters for, see Writer
69
+ # @return [Integer] number of rows written
70
+ # @raise [ArgumentError] when the schema has to be inferred and +records+ is empty or holds Array
71
+ # rows, or an option is invalid
72
+ # @raise [EncodeError] when a row does not fit the schema
73
+ def write(io, records, schema: nil, **options)
74
+ model = if records.respond_to?(:klass) then records.klass
75
+ elsif records.respond_to?(:columns) && records.respond_to?(:find_each) then records
76
+ end
77
+ schema ||= model ? Schema.from_active_record(model) : Schema.infer(records)
78
+ Writer.open(io, schema, **options) do |writer|
79
+ if records.respond_to?(:find_each)
80
+ records.find_each { |record| writer << record }
81
+ else
82
+ records.each { |record| writer << record }
83
+ end
84
+ writer.rows_written
85
+ end
86
+ end
39
87
 
40
- # Writes an Enumerable of row Hashes. Without a schema, one is inferred from the first rows.
41
- def write(path, rows, schema: nil, **options)
42
- schema ||= Schema.infer(rows)
43
- Writer.open(path, schema, **options) { |w| w.write_rows(rows) }
88
+ # 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.
90
+ #
91
+ # @return [Array<Symbol>] codec names accepted by the writer's +compression:+ option
92
+ def codecs
93
+ Compression::NAMES.values.select do |name|
94
+ Compression.ensure_available!(name)
95
+ true
96
+ rescue UnsupportedError
97
+ false
98
+ end
44
99
  end
45
100
  end
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.1.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Julik Tarkhanov
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2026-09-30 00:00:00.000000000 Z
10
+ date: 2026-10-01 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: bigdecimal
@@ -23,37 +23,9 @@ dependencies:
23
23
  - - ">="
24
24
  - !ruby/object:Gem::Version
25
25
  version: '0'
26
- - !ruby/object:Gem::Dependency
27
- name: brotli
28
- requirement: !ruby/object:Gem::Requirement
29
- requirements:
30
- - - ">="
31
- - !ruby/object:Gem::Version
32
- version: '0'
33
- type: :runtime
34
- prerelease: false
35
- version_requirements: !ruby/object:Gem::Requirement
36
- requirements:
37
- - - ">="
38
- - !ruby/object:Gem::Version
39
- version: '0'
40
- - !ruby/object:Gem::Dependency
41
- name: zstd-ruby
42
- requirement: !ruby/object:Gem::Requirement
43
- requirements:
44
- - - ">="
45
- - !ruby/object:Gem::Version
46
- version: '0'
47
- type: :runtime
48
- prerelease: false
49
- version_requirements: !ruby/object:Gem::Requirement
50
- requirements:
51
- - - ">="
52
- - !ruby/object:Gem::Version
53
- version: '0'
54
- description: Reads and writes Apache Parquet files without native extensions or Thrift.
55
- Snappy and LZ4 are implemented in Ruby; ZSTD and Brotli are used when their gems
56
- are present.
26
+ description: Reads and writes Apache Parquet files in Ruby, without Thrift or native
27
+ extensions of its own. Snappy and LZ4 are implemented in Ruby and GZIP uses zlib;
28
+ ZSTD and Brotli are available when the zstd-ruby / brotli gems are installed.
57
29
  email:
58
30
  - me@julik.nl
59
31
  executables:
@@ -66,6 +38,7 @@ files:
66
38
  - bin/herringbone
67
39
  - lib/herringbone.rb
68
40
  - lib/herringbone/active_record.rb
41
+ - lib/herringbone/bloom_filter.rb
69
42
  - lib/herringbone/byte_values.rb
70
43
  - lib/herringbone/codecs/lz4.rb
71
44
  - lib/herringbone/codecs/snappy.rb
@@ -74,13 +47,21 @@ files:
74
47
  - lib/herringbone/encodings/plain.rb
75
48
  - lib/herringbone/encodings/rle.rb
76
49
  - lib/herringbone/format.rb
50
+ - lib/herringbone/inspector.rb
77
51
  - lib/herringbone/io_buffer_support.rb
78
52
  - lib/herringbone/reader.rb
53
+ - lib/herringbone/reader/column_chunk_reader.rb
54
+ - lib/herringbone/reader/column_cursor.rb
55
+ - lib/herringbone/reader/numo.rb
56
+ - lib/herringbone/reader/page_stream.rb
57
+ - lib/herringbone/reader/scan.rb
79
58
  - lib/herringbone/schema.rb
80
59
  - lib/herringbone/thrift.rb
81
60
  - lib/herringbone/types.rb
82
61
  - lib/herringbone/version.rb
62
+ - lib/herringbone/visualizer.rb
83
63
  - lib/herringbone/writer.rb
64
+ - lib/herringbone/xxhash.rb
84
65
  licenses:
85
66
  - MIT
86
67
  metadata: {}