herringbone 0.2.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.
@@ -21,19 +21,44 @@ module Herringbone
21
21
  module Types
22
22
  module_function
23
23
 
24
+ # Shorthand for Format::Type
24
25
  T = Format::Type
26
+ # Shorthand for Format::ConvertedType
25
27
  C = Format::ConvertedType
26
28
 
29
+ # Julian day number of 1970-01-01, the origin of DATE columns
27
30
  EPOCH_JD = Date.new(1970, 1, 1).jd
31
+ # Julian day number of 1970-01-01, the origin of the day half of an INT96 timestamp
28
32
  JULIAN_EPOCH_DAY = 2_440_588 # Julian day number of 1970-01-01, used by INT96
33
+ # Nanoseconds in a day, used to split INT96 timestamps into day and nanoseconds of day
29
34
  NANOS_PER_DAY = 86_400 * 1_000_000_000
30
35
 
31
- UNIT_DIVISORS = { millis: 1_000, micros: 1_000_000, nanos: 1_000_000_000 }.freeze
32
- UNIT_NAMES = { millis: :millisecond, micros: :microsecond, nanos: :nanosecond }.freeze
33
-
34
-
36
+ # Ticks per second for each TimeUnit
37
+ UNIT_DIVISORS = {millis: 1_000, micros: 1_000_000, nanos: 1_000_000_000}.freeze
38
+ # Sub-second unit names understood by +Time.at+, for each TimeUnit
39
+ UNIT_NAMES = {millis: :millisecond, micros: :microsecond, nanos: :nanosecond}.freeze
40
+
41
+ # Shorthand for building a LogicalType union.
42
+ # @param kw [Hash{Symbol => Thrift::Struct}] the single union member to set; any LogicalType
43
+ # field is accepted, those used in this module are listed below
44
+ # @option kw [Format::StringType] :string
45
+ # @option kw [Format::EnumType] :enum
46
+ # @option kw [Format::JsonType] :json
47
+ # @option kw [Format::BsonType] :bson
48
+ # @option kw [Format::UUIDType] :uuid
49
+ # @option kw [Format::DateType] :date
50
+ # @option kw [Format::Float16Type] :float16
51
+ # @option kw [Format::IntType] :integer bit width and signedness
52
+ # @option kw [Format::DecimalType] :decimal scale and precision
53
+ # @option kw [Format::TimeType] :time unit and UTC adjustment
54
+ # @option kw [Format::TimestampType] :timestamp unit and UTC adjustment
55
+ # @return [Format::LogicalType]
35
56
  def lt(**kw) = Format::LogicalType.new(**kw)
36
57
 
58
+ # TimeUnit union for a unit name.
59
+ # @param unit [Symbol, String] +:millis+/+:ms+, +:micros+/+:us+ or +:nanos+/+:ns+
60
+ # @return [Format::TimeUnit]
61
+ # @raise [ArgumentError] for any other unit
37
62
  def time_unit(unit)
38
63
  case unit.to_sym
39
64
  when :millis, :ms then Format::TimeUnit.millis
@@ -43,64 +68,84 @@ module Herringbone
43
68
  end
44
69
  end
45
70
 
71
+ # Physical attributes of an annotated integer column, with both the INTEGER logical type
72
+ # and the matching INT_n/UINT_n converted type.
73
+ # @param bits [Integer] bit width: 8, 16, 32 or 64 (64 is stored as INT64, the rest as INT32)
74
+ # @param signed [Boolean] whether the integers are signed
75
+ # @return [Hash{Symbol => Object}] +:type+, +:converted_type+ and +:logical_type+
46
76
  def int_type(bits, signed)
47
- physical = bits == 64 ? T::INT64 : T::INT32
77
+ physical = (bits == 64) ? T::INT64 : T::INT32
48
78
  converted = signed ? C.const_get("INT_#{bits}") : C.const_get("UINT_#{bits}")
49
- { type: physical, converted_type: converted,
50
- logical_type: lt(integer: Format::IntType.new(bit_width: bits, is_signed: signed)) }
79
+ {type: physical, converted_type: converted,
80
+ logical_type: lt(integer: Format::IntType.new(bit_width: bits, is_signed: signed))}
51
81
  end
52
82
 
53
83
  # Physical attributes (type, logical type etc.) for a DSL type name
84
+ # @param type [Symbol, String] column type name, e.g. +:string+, +:int64+, +:decimal+, +:timestamp+
85
+ # @param opts [Hash{Symbol => Object}] type-specific options
86
+ # @option opts [Boolean] :parquet_enum for +:enum+, annotate as ENUM instead of STRING
87
+ # @option opts [Integer] :length byte length for +:fixed+ (required)
88
+ # @option opts [Symbol] :unit (:micros) for +:time+ and +:timestamp+, see {time_unit}
89
+ # @option opts [Boolean] :utc (true) for +:time+ and +:timestamp+, sets +is_adjusted_to_utc+;
90
+ # the legacy converted type is only written when true
91
+ # @option opts [Integer] :precision number of decimal digits for +:decimal+ (required)
92
+ # @option opts [Integer] :scale (0) digits after the decimal point for +:decimal+
93
+ # @option opts [Symbol] :physical for +:decimal+, force +:int32+, +:int64+, +:binary+ or
94
+ # +:fixed+ storage instead of picking the smallest by precision
95
+ # @return [Hash{Symbol => Object}] keyword arguments for Schema::Node.new: +:type+ and,
96
+ # where applicable, +:type_length+, +:converted_type+, +:logical_type+, +:scale+, +:precision+
97
+ # @raise [ArgumentError] for an unknown type, unit, or invalid decimal precision/scale
98
+ # @raise [KeyError] when a required option is missing or +:physical+ is not recognized
54
99
  def physical_attributes(type, **opts)
55
100
  case type.to_sym
56
- when :boolean then { type: T::BOOLEAN }
101
+ when :boolean then {type: T::BOOLEAN}
57
102
  when :int8 then int_type(8, true)
58
103
  when :int16 then int_type(16, true)
59
- when :int32 then { type: T::INT32 }
60
- when :int64 then { type: T::INT64 }
104
+ when :int32 then {type: T::INT32}
105
+ when :int64 then {type: T::INT64}
61
106
  when :uint8 then int_type(8, false)
62
107
  when :uint16 then int_type(16, false)
63
108
  when :uint32 then int_type(32, false)
64
109
  when :uint64 then int_type(64, false)
65
- when :float then { type: T::FLOAT }
66
- when :double then { type: T::DOUBLE }
67
- when :float16 then { type: T::FIXED_LEN_BYTE_ARRAY, type_length: 2, logical_type: lt(float16: Format::Float16Type.new) }
68
- when :string then { type: T::BYTE_ARRAY, converted_type: C::UTF8, logical_type: lt(string: Format::StringType.new) }
69
- when :binary then { type: T::BYTE_ARRAY }
70
- when :json then { type: T::BYTE_ARRAY, converted_type: C::JSON, logical_type: lt(json: Format::JsonType.new) }
71
- when :bson then { type: T::BYTE_ARRAY, converted_type: C::BSON, logical_type: lt(bson: Format::BsonType.new) }
110
+ when :float then {type: T::FLOAT}
111
+ when :double then {type: T::DOUBLE}
112
+ when :float16 then {type: T::FIXED_LEN_BYTE_ARRAY, type_length: 2, logical_type: lt(float16: Format::Float16Type.new)}
113
+ when :string then {type: T::BYTE_ARRAY, converted_type: C::UTF8, logical_type: lt(string: Format::StringType.new)}
114
+ when :binary then {type: T::BYTE_ARRAY}
115
+ when :json then {type: T::BYTE_ARRAY, converted_type: C::JSON, logical_type: lt(json: Format::JsonType.new)}
116
+ when :bson then {type: T::BYTE_ARRAY, converted_type: C::BSON, logical_type: lt(bson: Format::BsonType.new)}
72
117
  when :enum
73
118
  if opts[:parquet_enum]
74
- { type: T::BYTE_ARRAY, converted_type: C::ENUM, logical_type: lt(enum: Format::EnumType.new) }
119
+ {type: T::BYTE_ARRAY, converted_type: C::ENUM, logical_type: lt(enum: Format::EnumType.new)}
75
120
  else
76
121
  physical_attributes(:string)
77
122
  end
78
- when :uuid then { type: T::FIXED_LEN_BYTE_ARRAY, type_length: 16, logical_type: lt(uuid: Format::UUIDType.new) }
79
- when :date then { type: T::INT32, converted_type: C::DATE, logical_type: lt(date: Format::DateType.new) }
80
- when :int96 then { type: T::INT96 }
123
+ when :uuid then {type: T::FIXED_LEN_BYTE_ARRAY, type_length: 16, logical_type: lt(uuid: Format::UUIDType.new)}
124
+ when :date then {type: T::INT32, converted_type: C::DATE, logical_type: lt(date: Format::DateType.new)}
125
+ when :int96 then {type: T::INT96}
81
126
  when :fixed
82
- { type: T::FIXED_LEN_BYTE_ARRAY, type_length: Integer(opts.fetch(:length)) }
127
+ {type: T::FIXED_LEN_BYTE_ARRAY, type_length: Integer(opts.fetch(:length))}
83
128
  when :time
84
129
  unit = time_unit(opts.fetch(:unit, :micros))
85
- converted = { millis: C::TIME_MILLIS, micros: C::TIME_MICROS }[unit.to_sym]
86
- { type: unit.millis ? T::INT32 : T::INT64, converted_type: opts.fetch(:utc, true) ? converted : nil,
87
- logical_type: lt(time: Format::TimeType.new(is_adjusted_to_utc: opts.fetch(:utc, true), unit: unit)) }
130
+ converted = {millis: C::TIME_MILLIS, micros: C::TIME_MICROS}[unit.to_sym]
131
+ {type: unit.millis ? T::INT32 : T::INT64, converted_type: opts.fetch(:utc, true) ? converted : nil,
132
+ logical_type: lt(time: Format::TimeType.new(is_adjusted_to_utc: opts.fetch(:utc, true), unit: unit))}
88
133
  when :timestamp
89
134
  unit = time_unit(opts.fetch(:unit, :micros))
90
- converted = { millis: C::TIMESTAMP_MILLIS, micros: C::TIMESTAMP_MICROS }[unit.to_sym]
91
- { type: T::INT64, converted_type: opts.fetch(:utc, true) ? converted : nil,
92
- logical_type: lt(timestamp: Format::TimestampType.new(is_adjusted_to_utc: opts.fetch(:utc, true), unit: unit)) }
135
+ converted = {millis: C::TIMESTAMP_MILLIS, micros: C::TIMESTAMP_MICROS}[unit.to_sym]
136
+ {type: T::INT64, converted_type: opts.fetch(:utc, true) ? converted : nil,
137
+ logical_type: lt(timestamp: Format::TimestampType.new(is_adjusted_to_utc: opts.fetch(:utc, true), unit: unit))}
93
138
  when :decimal
94
139
  precision = Integer(opts.fetch(:precision))
95
140
  scale = Integer(opts.fetch(:scale, 0))
96
141
  raise ArgumentError, "Decimal precision must be positive" unless precision.positive?
97
142
  raise ArgumentError, "Decimal scale must be between 0 and precision" unless scale.between?(0, precision)
98
143
  physical = if opts[:physical]
99
- { int32: { type: T::INT32 }, int64: { type: T::INT64 }, binary: { type: T::BYTE_ARRAY },
100
- fixed: { type: T::FIXED_LEN_BYTE_ARRAY, type_length: decimal_bytes(precision) } }.fetch(opts[:physical])
101
- elsif precision <= 9 then { type: T::INT32 }
102
- elsif precision <= 18 then { type: T::INT64 }
103
- else { type: T::FIXED_LEN_BYTE_ARRAY, type_length: decimal_bytes(precision) }
144
+ {int32: {type: T::INT32}, int64: {type: T::INT64}, binary: {type: T::BYTE_ARRAY},
145
+ fixed: {type: T::FIXED_LEN_BYTE_ARRAY, type_length: decimal_bytes(precision)}}.fetch(opts[:physical])
146
+ elsif precision <= 9 then {type: T::INT32}
147
+ elsif precision <= 18 then {type: T::INT64}
148
+ else {type: T::FIXED_LEN_BYTE_ARRAY, type_length: decimal_bytes(precision)}
104
149
  end
105
150
  physical.merge(converted_type: C::DECIMAL, scale: scale, precision: precision,
106
151
  logical_type: lt(decimal: Format::DecimalType.new(scale: scale, precision: precision)))
@@ -110,6 +155,8 @@ module Herringbone
110
155
  end
111
156
 
112
157
  # Minimal number of bytes to hold a signed integer of +precision+ decimal digits
158
+ # @param precision [Integer] number of decimal digits
159
+ # @return [Integer] byte length for a FIXED_LEN_BYTE_ARRAY decimal
113
160
  def decimal_bytes(precision)
114
161
  max = 10**precision
115
162
  n = 1
@@ -118,6 +165,17 @@ module Herringbone
118
165
  end
119
166
 
120
167
  # Normalized logical kind of a node: [symbol, details]
168
+ #
169
+ # The LogicalType wins when present; otherwise the legacy converted type is mapped onto
170
+ # the same shapes. Details depend on the kind:
171
+ # [:integer, bit_width, signed]
172
+ # [:decimal, scale, precision]
173
+ # [:timestamp, unit, adjusted_to_utc] (unit is :millis, :micros or :nanos)
174
+ # [:time, unit, adjusted_to_utc]
175
+ # [kind] for other annotations (:string, :date, :uuid ...)
176
+ # [nil] for unannotated columns
177
+ # @param node [Schema::Node] leaf node of the physical schema
178
+ # @return [Array] kind Symbol (or nil) followed by its details
121
179
  def logical_of(node)
122
180
  if (kind = node.logical_type&.kind)
123
181
  name, payload = kind
@@ -153,6 +211,8 @@ module Herringbone
153
211
  end
154
212
 
155
213
  # Returns a lambda converting a physical value into a Ruby value, or nil when no conversion is needed.
214
+ # @param node [Schema::Node] leaf node of the physical schema
215
+ # @return [Proc, nil] one-argument converter, or nil when decoded values are used as-is
156
216
  def reader_for(node)
157
217
  kind, a, b = logical_of(node)
158
218
  type = node.type
@@ -161,7 +221,7 @@ module Herringbone
161
221
  return ->(v) { v.force_encoding(Encoding::UTF_8) } if type == T::BYTE_ARRAY || type == T::FIXED_LEN_BYTE_ARRAY
162
222
  when :integer
163
223
  if !b && (type == T::INT32 || type == T::INT64)
164
- mask = type == T::INT32 ? 0xFFFF_FFFF : 0xFFFF_FFFF_FFFF_FFFF
224
+ mask = (type == T::INT32) ? 0xFFFF_FFFF : 0xFFFF_FFFF_FFFF_FFFF
165
225
  return ->(v) { v & mask }
166
226
  end
167
227
  when :date
@@ -179,12 +239,18 @@ module Herringbone
179
239
  nil
180
240
  end
181
241
 
242
+ # Converter from an INT64 timestamp to a UTC Time.
243
+ # @param unit [Symbol] +:millis+, +:micros+ or +:nanos+
244
+ # @return [Proc] lambda taking an Integer count of +unit+ since the epoch
245
+ # @raise [KeyError] for an unknown unit
182
246
  def timestamp_reader(unit)
183
247
  div = UNIT_DIVISORS.fetch(unit)
184
248
  name = UNIT_NAMES.fetch(unit)
185
249
  ->(v) { Time.at(v / div, v % div, name).utc }
186
250
  end
187
251
 
252
+ # Converter from a decoded INT96 value to a UTC Time.
253
+ # @return [Proc] lambda taking a +[nanoseconds_of_day, julian_day]+ pair
188
254
  def int96_reader
189
255
  lambda do |(nanos, day)|
190
256
  secs = (day - JULIAN_EPOCH_DAY) * 86_400
@@ -192,6 +258,11 @@ module Herringbone
192
258
  end
193
259
  end
194
260
 
261
+ # Converter from a stored unscaled decimal to a BigDecimal.
262
+ # @param type [Integer] physical type (Format::Type) of the column
263
+ # @param scale [Integer] digits after the decimal point
264
+ # @return [Proc, nil] lambda taking an Integer (INT32/INT64) or big-endian two's complement
265
+ # bytes (BYTE_ARRAY/FIXED_LEN_BYTE_ARRAY), or nil for any other physical type
195
266
  def decimal_reader(type, scale)
196
267
  to_decimal = scale.zero? ? ->(i) { BigDecimal(i) } : ->(i) { BigDecimal("#{i}e-#{scale}") }
197
268
  case type
@@ -202,13 +273,20 @@ module Herringbone
202
273
  end
203
274
 
204
275
  # Big-endian two's complement bytes to Integer
276
+ # @param bytes [String] binary string; empty decodes as 0
277
+ # @return [Integer]
205
278
  def be_to_int(bytes)
206
279
  return 0 if bytes.empty?
207
280
  i = bytes.unpack1("H*").to_i(16)
208
281
  bits = bytes.bytesize * 8
209
- i >= (1 << (bits - 1)) ? i - (1 << bits) : i
282
+ (i >= (1 << (bits - 1))) ? i - (1 << bits) : i
210
283
  end
211
284
 
285
+ # Integer to big-endian two's complement bytes of a fixed width
286
+ # @param i [Integer] value to encode
287
+ # @param nbytes [Integer] output width in bytes
288
+ # @return [String] binary string of +nbytes+ bytes
289
+ # @raise [EncodeError] when +i+ does not fit in +nbytes+ bytes
212
290
  def int_to_be(i, nbytes)
213
291
  bits = nbytes * 8
214
292
  raise EncodeError, "Decimal value #{i} does not fit in #{nbytes} bytes" unless i.bit_length < bits
@@ -216,6 +294,9 @@ module Herringbone
216
294
  [i.to_s(16).rjust(nbytes * 2, "0")].pack("H*")
217
295
  end
218
296
 
297
+ # Decodes an IEEE 754 half-precision value, including subnormals, infinities and NaN.
298
+ # @param h [Integer] 16-bit pattern
299
+ # @return [Float]
219
300
  def half_to_float(h)
220
301
  sign = (h >> 15).zero? ? 1.0 : -1.0
221
302
  exp = (h >> 10) & 0x1F
@@ -231,9 +312,11 @@ module Herringbone
231
312
 
232
313
  # Rounds a Float to the nearest half-precision value (ties to even). Every step is exact
233
314
  # in double arithmetic, so there is no double rounding.
315
+ # @param f [Float] value to round; overflows to infinity, NaN becomes the canonical quiet NaN
316
+ # @return [Integer] 16-bit pattern
234
317
  def float_to_half(f)
235
318
  return 0x7E00 if f.nan?
236
- sign = f.negative? || (f.zero? && (1.0 / f).negative?) ? 0x8000 : 0
319
+ sign = (f.negative? || (f.zero? && (1.0 / f).negative?)) ? 0x8000 : 0
237
320
  a = f.abs
238
321
  return sign | 0x7C00 if a >= 65_520.0
239
322
  return sign | (a * 2.0**24).round(half: :even) if a < 2.0**-14
@@ -252,13 +335,17 @@ module Herringbone
252
335
  # date: Date, Time/DateTime (its calendar date), ISO-8601 String, Integer days since epoch
253
336
  # timestamp: Time, DateTime, ActiveSupport::TimeWithZone, Date (midnight UTC), ISO-8601 String,
254
337
  # Integer in the column's unit
255
- # time: Time (its time of day), "HH:MM:SS[.fraction]" String, Integer in the column's unit
338
+ # time: Time (its time of day), "HH:MM[:SS[.fraction]]" String, Integer in the column's unit
256
339
  # json: String (used as-is) or any other object (serialized with JSON.generate)
257
340
  # string: String, Symbol or anything responding to to_s
258
341
  # boolean: true/false, 1/0, "true"/"false", "t"/"f", "1"/"0", "yes"/"no"
259
342
  # integers: Integer, or a Float/BigDecimal/Rational/String holding a whole number
260
343
  # decimal: BigDecimal, Integer, Rational, Float or numeric String
261
344
  # uuid: String with or without dashes, or 16 raw bytes
345
+ # The returned lambda raises ArgumentError or RangeError for values it cannot convert.
346
+ # @param node [Schema::Node] leaf node of the physical schema
347
+ # @return [Proc, nil] one-argument converter; nil only for a column with no recognized
348
+ # physical type
262
349
  def writer_for(node)
263
350
  kind, a, b = logical_of(node)
264
351
  type = node.type
@@ -279,13 +366,13 @@ module Herringbone
279
366
  return decimal_writer(node, a)
280
367
  when :uuid
281
368
  return fixed_checker(16) do |v|
282
- v.bytesize == 16 && v.encoding == Encoding::BINARY ? v : [v.to_s.delete("-")].pack("H*")
369
+ (v.bytesize == 16 && v.encoding == Encoding::BINARY) ? v : [v.to_s.delete("-")].pack("H*")
283
370
  end
284
371
  when :float16
285
372
  return ->(v) { [float_to_half(Float(v))].pack("v") }
286
373
  when :integer
287
374
  if !b && (type == T::INT32 || type == T::INT64)
288
- bits = type == T::INT32 ? 32 : 64
375
+ bits = (type == T::INT32) ? 32 : 64
289
376
  check = int_checker(0, (1 << a) - 1)
290
377
  return ->(v) { Encodings::Delta.wrap(check.call(v), bits) }
291
378
  elsif type == T::INT32 || type == T::INT64
@@ -311,19 +398,29 @@ module Herringbone
311
398
  end
312
399
  end
313
400
 
401
+ # Values accepted for a boolean column (strings are also matched case-insensitively)
314
402
  BOOLEANS = {
315
403
  true => true, false => false, 1 => true, 0 => false,
316
404
  "true" => true, "false" => false, "t" => true, "f" => false, "1" => true, "0" => false,
317
405
  "yes" => true, "no" => false, "TRUE" => true, "FALSE" => false, "T" => true, "F" => false
318
406
  }.freeze
319
407
 
408
+ # Coerces a value to true/false using BOOLEANS.
409
+ # @param v [Object] true/false, 1/0, or a String/Symbol such as "yes" or :false
410
+ # @return [Boolean]
411
+ # @raise [ArgumentError] when +v+ is not recognized as a boolean
320
412
  def to_boolean(v)
321
413
  BOOLEANS.fetch(v) do
322
- s = v.is_a?(String) || v.is_a?(Symbol) ? v.to_s.downcase : nil
414
+ s = (v.is_a?(String) || v.is_a?(Symbol)) ? v.to_s.downcase : nil
323
415
  BOOLEANS.fetch(s) { raise ArgumentError, "expected a boolean, got #{v.inspect}" }
324
416
  end
325
417
  end
326
418
 
419
+ # Days since the Unix epoch in the proleptic Gregorian calendar, as stored in DATE columns.
420
+ # @param v [Date, String, Integer, #to_date] a Date, an ISO-8601 date String, an Integer
421
+ # (returned as-is) or anything responding to +to_date+ (Time, DateTime)
422
+ # @return [Integer]
423
+ # @raise [ArgumentError] when +v+ cannot be turned into a Date
327
424
  def date_to_days(v)
328
425
  return v if v.is_a?(Integer)
329
426
  d = case v
@@ -338,6 +435,10 @@ module Herringbone
338
435
  end
339
436
 
340
437
  # Converts the values a timestamp column accepts into a Time (or Time-like) object
438
+ # @param v [Time, DateTime, Date, String, #to_i] a Time, DateTime, Date (taken as midnight UTC),
439
+ # ISO-8601 (or +Time.parse+-able) String, or a Time-like object responding to +to_i+ and +nsec+
440
+ # @return [Time, Object] a Time, or +v+ itself when it is Time-like
441
+ # @raise [ArgumentError] when +v+ is none of the above or the String cannot be parsed
341
442
  def to_time(v)
342
443
  case v
343
444
  when Time then v
@@ -356,6 +457,13 @@ module Herringbone
356
457
  end
357
458
  end
358
459
 
460
+ # Converter from a timestamp-like value to an INT64 count of +unit+ since the epoch.
461
+ # Integers pass through unchanged; sub-unit precision is truncated.
462
+ # @param unit [Symbol] +:millis+, +:micros+ or +:nanos+
463
+ # @param utc [Boolean] whether the column is adjusted to UTC; when false the local wall
464
+ # clock time of the value (its UTC offset added) is stored
465
+ # @return [Proc] one-argument lambda, see {to_time} for accepted values
466
+ # @raise [KeyError] for an unknown unit
359
467
  def timestamp_writer(unit, utc)
360
468
  mult = UNIT_DIVISORS.fetch(unit)
361
469
  lambda do |v|
@@ -368,6 +476,13 @@ module Herringbone
368
476
  end
369
477
  end
370
478
 
479
+ # Converter from a time of day to a count of +unit+ since midnight.
480
+ # The lambda accepts an Integer (returned as-is), an "HH:MM[:SS[.fraction]]" String or
481
+ # anything responding to +hour+ and +nsec+ (Time, DateTime), and raises ArgumentError or
482
+ # RangeError for anything else or a time past 23:59:59.
483
+ # @param unit [Symbol] +:millis+, +:micros+ or +:nanos+
484
+ # @return [Proc] one-argument lambda
485
+ # @raise [KeyError] for an unknown unit
371
486
  def time_writer(unit)
372
487
  mult = UNIT_DIVISORS.fetch(unit)
373
488
  lambda do |v|
@@ -389,6 +504,9 @@ module Herringbone
389
504
 
390
505
  # String column restricted to a set of values. +values+ is an Array of labels, or a Hash of
391
506
  # label => stored value like Rails' `Model.statuses`, in which case either is accepted.
507
+ # Symbols are accepted in place of String labels.
508
+ # @param values [Array<String, Symbol>, Hash{String, Symbol => Object}] allowed labels
509
+ # @return [Proc] lambda returning the String label, raising ArgumentError for any other value
392
510
  def enum_writer(values)
393
511
  labels = {}
394
512
  if values.is_a?(Hash)
@@ -407,15 +525,24 @@ module Herringbone
407
525
  end
408
526
 
409
527
  # Converts to Integer, rejecting fractional numbers and values outside min..max
528
+ # @param min [Integer] smallest accepted value
529
+ # @param max [Integer] largest accepted value
530
+ # @return [Proc] lambda raising ArgumentError for non-integers and RangeError when out of range
410
531
  def int_checker(min, max)
411
532
  lambda do |v|
412
533
  i = Integer(v)
413
534
  raise ArgumentError, "#{v.inspect} is not an integer" unless v.is_a?(Integer) || !v.is_a?(Numeric) || v == i
414
- raise RangeError, "#{i} is outside #{min}..#{max}" unless i >= min && i <= max
535
+ raise RangeError, "#{i} is outside #{min}..#{max}" unless i.between?(min, max)
415
536
  i
416
537
  end
417
538
  end
418
539
 
540
+ # Wraps a conversion to a String and checks the result has exactly +length+ bytes.
541
+ # @param length [Integer] required byte length
542
+ # @yield [v] converts the incoming value
543
+ # @yieldparam v [Object] value handed to the writer
544
+ # @yieldreturn [String] bytes to store
545
+ # @return [Proc] lambda raising ArgumentError when the converted String has another length
419
546
  def fixed_checker(length, &convert)
420
547
  lambda do |v|
421
548
  s = convert.call(v)
@@ -424,6 +551,14 @@ module Herringbone
424
551
  end
425
552
  end
426
553
 
554
+ # Converter from a numeric value to the stored unscaled decimal. Values are rounded
555
+ # (half away from zero) to +scale+ digits; the lambda raises RangeError when the result
556
+ # exceeds the column's precision.
557
+ # @param node [Schema::Node] DECIMAL leaf node, for its physical type, precision and type length
558
+ # @param scale [Integer] digits after the decimal point
559
+ # @return [Proc, nil] lambda returning an Integer (INT32/INT64) or big-endian two's complement
560
+ # bytes (FIXED_LEN_BYTE_ARRAY, BYTE_ARRAY with minimal length), or nil for any other
561
+ # physical type
427
562
  def decimal_writer(node, scale)
428
563
  mult = 10**scale
429
564
  limit = node.precision ? 10**node.precision : nil
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Herringbone
4
- VERSION = "0.2.0"
4
+ # Gem version, also written to the +created_by+ field of every file's footer
5
+ VERSION = "0.3.0"
5
6
  end
@@ -16,6 +16,7 @@ module Herringbone
16
16
  #
17
17
  # File.open("data.parquet", "rb") { |io| Herringbone::Inspector.new(io).to_html }
18
18
  class Visualizer
19
+ # highlight.js build loaded by the page to colour the footer JSON; optional.
19
20
  HIGHLIGHT_JS = "https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"
20
21
  # The design and idea of this page come from Parquet X-ray; credited at the top of every page
21
22
  CREDIT_URL = "https://huggingface.co/spaces/cfahlgren1/parquet-xray"
@@ -26,9 +27,15 @@ module Herringbone
26
27
  # Footer JSON is embedded only when the footer is smaller than this
27
28
  MAX_FOOTER_JSON = 512 * 1024
28
29
 
29
- PAGE_TYPES = { DATA_PAGE: 0, INDEX_PAGE: 1, DICTIONARY_PAGE: 2, DATA_PAGE_V2: 3 }.freeze
30
+ # PageType names to their Thrift ids, the page type code the embedded JS expects in page rows
31
+ # (unknown page types are passed on as their raw Integer).
32
+ PAGE_TYPES = {DATA_PAGE: 0, INDEX_PAGE: 1, DICTIONARY_PAGE: 2, DATA_PAGE_V2: 3}.freeze
30
33
 
31
34
  # +inspector+ is an Inspector. +title+ defaults to the file's name.
35
+ # @param inspector [Inspector] inspector over the file to render
36
+ # @param title [String, nil] page title; defaults to the file's name
37
+ # @param max_pages [Integer] page budget for per-page detail (see MAX_PAGES)
38
+ # @raise [ArgumentError] if +inspector+ is not an Inspector
32
39
  def initialize(inspector, title: nil, max_pages: MAX_PAGES)
33
40
  raise ArgumentError, "Expected a Herringbone::Inspector, got #{inspector.class}" unless inspector.is_a?(Inspector)
34
41
  @inspector = inspector
@@ -36,11 +43,13 @@ module Herringbone
36
43
  @max_pages = max_pages
37
44
  end
38
45
 
46
+ # Builds the page. Walks every page header of the file (and reads the page indexes).
47
+ # @return [String] a complete, self-contained HTML document
39
48
  def to_html
40
49
  json = JSON.generate(payload).gsub("<", "\\u003c").gsub("\u2028", "\\u2028").gsub("\u2029", "\\u2029")
41
50
  name = @title || @inspector.name || "Parquet file"
42
- values = { "TITLE" => escape_html("#{name} · Parquet layout"), "HIGHLIGHT_JS" => HIGHLIGHT_JS,
43
- "CREDIT_URL" => CREDIT_URL, "DATA" => json }
51
+ values = {"TITLE" => escape_html("#{name} · Parquet layout"), "HIGHLIGHT_JS" => HIGHLIGHT_JS,
52
+ "CREDIT_URL" => CREDIT_URL, "DATA" => json}
44
53
  # One pass, so placeholder-like text in the data is never substituted
45
54
  TEMPLATE.gsub(/%%(TITLE|HIGHLIGHT_JS|CREDIT_URL|DATA)%%/) { values.fetch(Regexp.last_match(1)) }
46
55
  end
@@ -48,6 +57,8 @@ module Herringbone
48
57
  private
49
58
 
50
59
  # The data embedded in the page
60
+ # @return [Hash{Symbol => Object}] file, schema, columns, row groups (with chunks and pages),
61
+ # key/value metadata and footer JSON, ready for JSON.generate
51
62
  def payload
52
63
  i = @inspector
53
64
  budget = @max_pages
@@ -75,6 +86,8 @@ module Herringbone
75
86
  }
76
87
  end
77
88
 
89
+ # Inspector#summary plus the display name and whether per-page detail was truncated.
90
+ # @return [Hash{Symbol => Object}] file-level facts for the page header
78
91
  def file_info
79
92
  s = @inspector.summary
80
93
  name = @title || @inspector.name || "(IO)"
@@ -82,6 +95,10 @@ module Herringbone
82
95
  index_mismatches: @inspector.column_chunks.sum { |c| c.index_mismatches.size })
83
96
  end
84
97
 
98
+ # One leaf column with its totals over all row groups.
99
+ # @param col [Schema::Column] leaf column
100
+ # @param t [Hash{Symbol => Object}] the column's entry from Inspector#column_totals
101
+ # @return [Hash{Symbol => Object}] column row, keyed with the short names the JS uses
85
102
  def column_info(col, t)
86
103
  node = col.node
87
104
  {
@@ -96,6 +113,11 @@ module Herringbone
96
113
  }
97
114
  end
98
115
 
116
+ # One column chunk: its metadata, statistics and index ranges and, when +with_pages+, its page
117
+ # rows, ColumnIndex and OffsetIndex.
118
+ # @param c [Inspector::ColumnChunkInfo] chunk to describe
119
+ # @param with_pages [Boolean] whether to include per-page detail (false once over the page budget)
120
+ # @return [Hash{Symbol => Object}] JSON-safe chunk entry, nil members omitted
99
121
  def chunk_info(c, with_pages)
100
122
  st = c.statistics
101
123
  h = {
@@ -106,8 +128,8 @@ module Herringbone
106
128
  stats: st && stats_info(st),
107
129
  bloom: c.bloom_filter_offset && [c.bloom_filter_offset, c.bloom_filter_length],
108
130
  ci: c.column_index_range, oi: c.offset_index_range,
109
- dict: c.dictionary_page && { n: c.dictionary_page.num_values, cs: c.dictionary_page.compressed_size,
110
- us: c.dictionary_page.uncompressed_size, sorted: c.dictionary_page.is_sorted },
131
+ dict: c.dictionary_page && {n: c.dictionary_page.num_values, cs: c.dictionary_page.compressed_size,
132
+ us: c.dictionary_page.uncompressed_size, sorted: c.dictionary_page.is_sorted},
111
133
  np: c.pages.size, ndp: c.data_pages.size,
112
134
  size_stats: c.size_statistics,
113
135
  kv: c.key_value_metadata.empty? ? nil : c.key_value_metadata,
@@ -133,6 +155,8 @@ module Herringbone
133
155
  Inspector.jsonable(h.compact)
134
156
  end
135
157
 
158
+ # @param st [Inspector::Stats] chunk statistics
159
+ # @return [Hash{Symbol => Object}] statistics with min/max in display form, nil members omitted
136
160
  def stats_info(st)
137
161
  {
138
162
  min: disp(st.min), max: disp(st.max), nulls: st.null_count, distinct: st.distinct_count,
@@ -142,24 +166,30 @@ module Herringbone
142
166
 
143
167
  # [type, offset, header_size, compressed, uncompressed, values, nulls, rows, first_row, encoding,
144
168
  # min, max, crc, extra]; crc is 0 (none), 1 (present, not verified), 2 (verified ok) or 3 (mismatch)
169
+ # @param p [Inspector::PageInfo] page header to describe
170
+ # @return [Array] the page row, compact so pages of large files stay small
145
171
  def page_row(p)
146
172
  st = p.statistics
147
173
  extra = if p.type == :DATA_PAGE_V2
148
174
  "levels #{p.repetition_levels_byte_length}+#{p.definition_levels_byte_length} B" \
149
- "#{p.is_compressed ? "" : ", not compressed"}"
175
+ "#{", not compressed" unless p.is_compressed}"
150
176
  elsif p.type == :DATA_PAGE
151
177
  [p.definition_level_encoding && "def #{p.definition_level_encoding}",
152
178
  p.repetition_level_encoding && "rep #{p.repetition_level_encoding}"].compact.join(", ")
153
179
  elsif p.dictionary?
154
180
  p.is_sorted ? "sorted" : nil
155
181
  end
156
- [PAGE_TYPES.fetch(p.type, 1), p.offset, p.header_size, p.compressed_size, p.uncompressed_size, p.num_values,
182
+ [PAGE_TYPES.fetch(p.type) { p.type }, p.offset, p.header_size, p.compressed_size, p.uncompressed_size, p.num_values,
157
183
  p.num_nulls, p.num_rows, p.first_row_index, p.encoding, st && disp(st.min), st && disp(st.max),
158
184
  CRC_STATES.fetch(p.checksum) { p.crc.nil? ? 0 : 1 }, extra]
159
185
  end
160
186
 
161
- CRC_STATES = { absent: 0, ok: 2, mismatch: 3 }.freeze
187
+ # PageInfo#checksum states to the crc codes of page_row (1, present but unverified, is the fallback).
188
+ CRC_STATES = {absent: 0, ok: 2, mismatch: 3}.freeze
162
189
 
190
+ # A page statistics vs ColumnIndex disagreement as a compact row.
191
+ # @param m [Hash{Symbol => Object}] entry from Inspector::ColumnChunkInfo#index_mismatches
192
+ # @return [Array] [page, field, value in page header, value in column index]; min/max in display form
163
193
  def mismatch_row(m)
164
194
  values = [m[:page_value], m[:index_value]]
165
195
  values = values.map { |v| disp(v) } if m[:field] == :min || m[:field] == :max
@@ -167,12 +197,14 @@ module Herringbone
167
197
  end
168
198
 
169
199
  # Display form of a decoded statistics value: strings quoted, the rest as text
200
+ # @param v [Object, nil] decoded value (String, numeric, Time, Date, Array, ...)
201
+ # @return [String, nil] at most 160 characters, nil for nil
170
202
  def disp(v)
171
203
  s = case v
172
204
  when nil then return nil
173
205
  when String
174
206
  t = Inspector.text(v)
175
- v.encoding == Encoding::BINARY && t.start_with?("0x") ? t : JSON.generate(t)
207
+ (v.encoding == Encoding::BINARY && t.start_with?("0x")) ? t : JSON.generate(t)
176
208
  when Float then v.nan? ? "NaN" : v.to_s
177
209
  when BigDecimal then v.to_s("F")
178
210
  when Time then Inspector.jsonable(v)
@@ -180,30 +212,38 @@ module Herringbone
180
212
  when Array then JSON.generate(Inspector.jsonable(v))
181
213
  else v.to_s
182
214
  end
183
- s.size > 160 ? "#{s[0, 159]}…" : s
215
+ (s.size > 160) ? "#{s[0, 159]}…" : s
184
216
  end
185
217
 
218
+ # The footer FileMetaData pretty-printed, or nil when it exceeds MAX_FOOTER_JSON bytes.
219
+ # @return [String, nil] JSON text
186
220
  def footer_json
187
221
  return nil if @inspector.footer_size > MAX_FOOTER_JSON
188
222
  JSON.pretty_generate(Inspector.jsonable(raw(@inspector.metadata.to_h)))
189
223
  end
190
224
 
191
225
  # Footer structs as plain data; binary statistics as hex, long strings shortened
226
+ # @param v [Object] a value from FileMetaData#to_h (Hash, Array, String or scalar)
227
+ # @return [Object] +v+ with every String passed through Inspector.text and cut at 300 characters
192
228
  def raw(v)
193
229
  case v
194
230
  when Hash then v.to_h { |k, x| [k, raw(x)] }
195
231
  when Array then v.map { |x| raw(x) }
196
232
  when String
197
233
  t = Inspector.text(v)
198
- t.size > 300 ? "#{t[0, 300]}… (#{v.bytesize} bytes)" : t
234
+ (t.size > 300) ? "#{t[0, 300]}… (#{v.bytesize} bytes)" : t
199
235
  else v
200
236
  end
201
237
  end
202
238
 
239
+ # @param s [Object] text to put into HTML (converted with to_s)
240
+ # @return [String] +s+ with &, <, > and double quotes escaped
203
241
  def escape_html(s)
204
242
  s.to_s.gsub("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;").gsub('"', "&quot;")
205
243
  end
206
244
 
245
+ # The page: HTML, CSS and JS, with %%TITLE%%, %%HIGHLIGHT_JS%%, %%CREDIT_URL%% and %%DATA%%
246
+ # placeholders filled in by to_html.
207
247
  TEMPLATE = <<~'HTML'
208
248
  <!doctype html>
209
249
  <html lang="en">