herringbone 0.1.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,454 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+ require "time"
5
+ require "json"
6
+ require "bigdecimal"
7
+
8
+ module Herringbone
9
+ # Conversion between physical Parquet values and Ruby objects, driven by the
10
+ # logical type (or legacy converted type) of a column.
11
+ #
12
+ # STRING/ENUM/JSON <-> String (UTF-8)
13
+ # BYTE_ARRAY/FLBA/BSON <-> String (binary)
14
+ # INTEGER (signed/uns.) <-> Integer
15
+ # DATE <-> Date
16
+ # TIMESTAMP, INT96 <-> Time (UTC)
17
+ # TIME <-> Integer in the column's unit since midnight
18
+ # DECIMAL <-> BigDecimal
19
+ # UUID <-> String "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
20
+ # FLOAT16 <-> Float
21
+ module Types
22
+ module_function
23
+
24
+ T = Format::Type
25
+ C = Format::ConvertedType
26
+
27
+ EPOCH_JD = Date.new(1970, 1, 1).jd
28
+ JULIAN_EPOCH_DAY = 2_440_588 # Julian day number of 1970-01-01, used by INT96
29
+ NANOS_PER_DAY = 86_400 * 1_000_000_000
30
+
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
+
35
+ def lt(**kw) = Format::LogicalType.new(**kw)
36
+
37
+ def time_unit(unit)
38
+ case unit.to_sym
39
+ when :millis, :ms then Format::TimeUnit.millis
40
+ when :micros, :us then Format::TimeUnit.micros
41
+ when :nanos, :ns then Format::TimeUnit.nanos
42
+ else raise ArgumentError, "Unknown time unit #{unit.inspect}"
43
+ end
44
+ end
45
+
46
+ def int_type(bits, signed)
47
+ physical = bits == 64 ? T::INT64 : T::INT32
48
+ 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)) }
51
+ end
52
+
53
+ # Physical attributes (type, logical type etc.) for a DSL type name
54
+ def physical_attributes(type, **opts)
55
+ case type.to_sym
56
+ when :boolean, :bool then { type: T::BOOLEAN }
57
+ when :int8 then int_type(8, true)
58
+ when :int16 then int_type(16, true)
59
+ when :int32 then { type: T::INT32 }
60
+ when :int64 then { type: T::INT64 }
61
+ when :uint8 then int_type(8, false)
62
+ when :uint16 then int_type(16, false)
63
+ when :uint32 then int_type(32, false)
64
+ 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, :utf8 then { type: T::BYTE_ARRAY, converted_type: C::UTF8, logical_type: lt(string: Format::StringType.new) }
69
+ when :binary, :bytes 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) }
72
+ when :enum
73
+ if opts[:parquet_enum]
74
+ { type: T::BYTE_ARRAY, converted_type: C::ENUM, logical_type: lt(enum: Format::EnumType.new) }
75
+ else
76
+ physical_attributes(:string)
77
+ 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 }
81
+ when :fixed
82
+ { type: T::FIXED_LEN_BYTE_ARRAY, type_length: Integer(opts.fetch(:length)) }
83
+ when :time
84
+ 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)) }
88
+ when :timestamp
89
+ 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)) }
93
+ when :decimal
94
+ precision = Integer(opts.fetch(:precision))
95
+ scale = Integer(opts.fetch(:scale, 0))
96
+ raise ArgumentError, "Decimal precision must be positive" unless precision.positive?
97
+ raise ArgumentError, "Decimal scale must be between 0 and precision" unless scale.between?(0, precision)
98
+ 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) }
104
+ end
105
+ physical.merge(converted_type: C::DECIMAL, scale: scale, precision: precision,
106
+ logical_type: lt(decimal: Format::DecimalType.new(scale: scale, precision: precision)))
107
+ else
108
+ raise ArgumentError, "Unknown column type #{type.inspect}"
109
+ end
110
+ end
111
+
112
+ # Minimal number of bytes to hold a signed integer of +precision+ decimal digits
113
+ def decimal_bytes(precision)
114
+ max = 10**precision
115
+ n = 1
116
+ n += 1 while (1 << (8 * n - 1)) <= max
117
+ n
118
+ end
119
+
120
+ # Normalized logical kind of a node: [symbol, details]
121
+ def logical_of(node)
122
+ if (kind = node.logical_type&.kind)
123
+ name, payload = kind
124
+ case name
125
+ when :integer then return [:integer, payload.bit_width, payload.is_signed]
126
+ when :decimal then return [:decimal, payload.scale, payload.precision]
127
+ when :timestamp then return [:timestamp, payload.unit.to_sym, payload.is_adjusted_to_utc]
128
+ when :time then return [:time, payload.unit.to_sym, payload.is_adjusted_to_utc]
129
+ else return [name]
130
+ end
131
+ end
132
+ case node.converted_type
133
+ when C::UTF8 then [:string]
134
+ when C::ENUM then [:enum]
135
+ when C::JSON then [:json]
136
+ when C::BSON then [:bson]
137
+ when C::DECIMAL then [:decimal, node.scale || 0, node.precision]
138
+ when C::DATE then [:date]
139
+ when C::TIME_MILLIS then [:time, :millis, true]
140
+ when C::TIME_MICROS then [:time, :micros, true]
141
+ when C::TIMESTAMP_MILLIS then [:timestamp, :millis, true]
142
+ when C::TIMESTAMP_MICROS then [:timestamp, :micros, true]
143
+ when C::UINT_8 then [:integer, 8, false]
144
+ when C::UINT_16 then [:integer, 16, false]
145
+ when C::UINT_32 then [:integer, 32, false]
146
+ when C::UINT_64 then [:integer, 64, false]
147
+ when C::INT_8 then [:integer, 8, true]
148
+ when C::INT_16 then [:integer, 16, true]
149
+ when C::INT_32 then [:integer, 32, true]
150
+ when C::INT_64 then [:integer, 64, true]
151
+ else [nil]
152
+ end
153
+ end
154
+
155
+ # Returns a lambda converting a physical value into a Ruby value, or nil when no conversion is needed.
156
+ def reader_for(node)
157
+ kind, a, b = logical_of(node)
158
+ type = node.type
159
+ case kind
160
+ when :string, :enum, :json
161
+ return ->(v) { v.force_encoding(Encoding::UTF_8) } if type == T::BYTE_ARRAY || type == T::FIXED_LEN_BYTE_ARRAY
162
+ when :integer
163
+ if !b && (type == T::INT32 || type == T::INT64)
164
+ mask = type == T::INT32 ? 0xFFFF_FFFF : 0xFFFF_FFFF_FFFF_FFFF
165
+ return ->(v) { v & mask }
166
+ end
167
+ when :date
168
+ return ->(v) { Date.jd(EPOCH_JD + v, Date::GREGORIAN) } if type == T::INT32
169
+ when :timestamp
170
+ return timestamp_reader(a) if type == T::INT64
171
+ when :decimal
172
+ return decimal_reader(type, a)
173
+ when :uuid
174
+ return ->(v) { v.unpack1("H*").insert(20, "-").insert(16, "-").insert(12, "-").insert(8, "-") } if type == T::FIXED_LEN_BYTE_ARRAY
175
+ when :float16
176
+ return ->(v) { half_to_float(v.unpack1("v")) } if type == T::FIXED_LEN_BYTE_ARRAY
177
+ end
178
+ return int96_reader if type == T::INT96
179
+ nil
180
+ end
181
+
182
+ def timestamp_reader(unit)
183
+ div = UNIT_DIVISORS.fetch(unit)
184
+ name = UNIT_NAMES.fetch(unit)
185
+ ->(v) { Time.at(v / div, v % div, name).utc }
186
+ end
187
+
188
+ def int96_reader
189
+ lambda do |(nanos, day)|
190
+ secs = (day - JULIAN_EPOCH_DAY) * 86_400
191
+ Time.at(secs + nanos / 1_000_000_000, nanos % 1_000_000_000, :nanosecond).utc
192
+ end
193
+ end
194
+
195
+ def decimal_reader(type, scale)
196
+ to_decimal = scale.zero? ? ->(i) { BigDecimal(i) } : ->(i) { BigDecimal("#{i}e-#{scale}") }
197
+ case type
198
+ when T::INT32, T::INT64 then to_decimal
199
+ when T::BYTE_ARRAY, T::FIXED_LEN_BYTE_ARRAY
200
+ ->(v) { to_decimal.call(be_to_int(v)) }
201
+ end
202
+ end
203
+
204
+ # Big-endian two's complement bytes to Integer
205
+ def be_to_int(bytes)
206
+ return 0 if bytes.empty?
207
+ i = bytes.unpack1("H*").to_i(16)
208
+ bits = bytes.bytesize * 8
209
+ i >= (1 << (bits - 1)) ? i - (1 << bits) : i
210
+ end
211
+
212
+ def int_to_be(i, nbytes)
213
+ bits = nbytes * 8
214
+ raise EncodeError, "Decimal value #{i} does not fit in #{nbytes} bytes" unless i.bit_length < bits
215
+ i += 1 << bits if i.negative?
216
+ [i.to_s(16).rjust(nbytes * 2, "0")].pack("H*")
217
+ end
218
+
219
+ def half_to_float(h)
220
+ sign = (h >> 15).zero? ? 1.0 : -1.0
221
+ exp = (h >> 10) & 0x1F
222
+ frac = h & 0x3FF
223
+ if exp.zero?
224
+ sign * frac * 2.0**-24
225
+ elsif exp == 31
226
+ frac.zero? ? sign * Float::INFINITY : Float::NAN
227
+ else
228
+ sign * (1 + frac / 1024.0) * 2.0**(exp - 15)
229
+ end
230
+ end
231
+
232
+ # Rounds a Float to the nearest half-precision value (ties to even). Every step is exact
233
+ # in double arithmetic, so there is no double rounding.
234
+ def float_to_half(f)
235
+ return 0x7E00 if f.nan?
236
+ sign = f.negative? || (f.zero? && (1.0 / f).negative?) ? 0x8000 : 0
237
+ a = f.abs
238
+ return sign | 0x7C00 if a >= 65_520.0
239
+ return sign | (a * 2.0**24).round(half: :even) if a < 2.0**-14
240
+ e = Math.frexp(a)[1] - 1
241
+ m = ((a / 2.0**e - 1) * 1024).round(half: :even)
242
+ if m == 1024
243
+ m = 0
244
+ e += 1
245
+ end
246
+ sign | ((e + 15) << 10) | m
247
+ end
248
+
249
+ # Returns a lambda converting a Ruby value into the physical value for +node+.
250
+ # Besides the canonical Ruby type of each column (see the table at the top), columns accept
251
+ # the values Rails and plain Ruby code commonly hand over:
252
+ # date: Date, Time/DateTime (its calendar date), ISO-8601 String, Integer days since epoch
253
+ # timestamp: Time, DateTime, ActiveSupport::TimeWithZone, Date (midnight UTC), ISO-8601 String,
254
+ # Integer in the column's unit
255
+ # time: Time (its time of day), "HH:MM:SS[.fraction]" String, Integer in the column's unit
256
+ # json: String (used as-is) or any other object (serialized with JSON.generate)
257
+ # string: String, Symbol or anything responding to to_s
258
+ # boolean: true/false, 1/0, "true"/"false", "t"/"f", "1"/"0", "yes"/"no"
259
+ # integers: Integer, or a Float/BigDecimal/Rational/String holding a whole number
260
+ # decimal: BigDecimal, Integer, Rational, Float or numeric String
261
+ # uuid: String with or without dashes, or 16 raw bytes
262
+ def writer_for(node)
263
+ kind, a, b = logical_of(node)
264
+ type = node.type
265
+ case kind
266
+ when :date
267
+ return method(:date_to_days)
268
+ when :timestamp
269
+ return timestamp_writer(a, b)
270
+ when :time
271
+ return time_writer(a) if type == T::INT32 || type == T::INT64
272
+ when :json
273
+ return ->(v) { v.is_a?(String) ? v : JSON.generate(v) }
274
+ when :string, :enum
275
+ values = node.enum_values
276
+ return enum_writer(values) if values
277
+ return ->(v) { v.is_a?(String) ? v : v.to_s }
278
+ when :decimal
279
+ return decimal_writer(node, a)
280
+ when :uuid
281
+ return fixed_checker(16) do |v|
282
+ v.bytesize == 16 && v.encoding == Encoding::BINARY ? v : [v.to_s.delete("-")].pack("H*")
283
+ end
284
+ when :float16
285
+ return ->(v) { [float_to_half(Float(v))].pack("v") }
286
+ when :integer
287
+ if !b && (type == T::INT32 || type == T::INT64)
288
+ bits = type == T::INT32 ? 32 : 64
289
+ check = int_checker(0, (1 << a) - 1)
290
+ return ->(v) { Delta.wrap(check.call(v), bits) }
291
+ elsif type == T::INT32 || type == T::INT64
292
+ return int_checker(-(1 << (a - 1)), (1 << (a - 1)) - 1)
293
+ end
294
+ end
295
+
296
+ case type
297
+ when T::BOOLEAN then method(:to_boolean)
298
+ when T::INT32 then int_checker(-(1 << 31), (1 << 31) - 1)
299
+ when T::INT64 then int_checker(-(1 << 63), (1 << 63) - 1)
300
+ when T::FLOAT, T::DOUBLE then ->(v) { v.is_a?(Float) ? v : Float(v) }
301
+ when T::BYTE_ARRAY then ->(v) { v.is_a?(String) ? v : v.to_s }
302
+ when T::FIXED_LEN_BYTE_ARRAY then fixed_checker(node.type_length) { |v| v.is_a?(String) ? v : v.to_s }
303
+ when T::INT96
304
+ lambda do |v|
305
+ return v if v.is_a?(Array)
306
+ v = to_time(v)
307
+ nanos = v.to_i * 1_000_000_000 + v.nsec
308
+ day, nanos_of_day = nanos.divmod(NANOS_PER_DAY)
309
+ [nanos_of_day, day + JULIAN_EPOCH_DAY]
310
+ end
311
+ end
312
+ end
313
+
314
+ BOOLEANS = {
315
+ true => true, false => false, 1 => true, 0 => false,
316
+ "true" => true, "false" => false, "t" => true, "f" => false, "1" => true, "0" => false,
317
+ "yes" => true, "no" => false, "TRUE" => true, "FALSE" => false, "T" => true, "F" => false
318
+ }.freeze
319
+
320
+ def to_boolean(v)
321
+ BOOLEANS.fetch(v) do
322
+ s = v.is_a?(String) || v.is_a?(Symbol) ? v.to_s.downcase : nil
323
+ BOOLEANS.fetch(s) { raise ArgumentError, "expected a boolean, got #{v.inspect}" }
324
+ end
325
+ end
326
+
327
+ def date_to_days(v)
328
+ return v if v.is_a?(Integer)
329
+ d = case v
330
+ when Date then v
331
+ when String then Date.iso8601(v)
332
+ else
333
+ raise ArgumentError, "expected a Date, got #{v.class}" unless v.respond_to?(:to_date)
334
+ v.to_date
335
+ end
336
+ # Use the civil date, so dates before 1582 in Ruby's default calendar are proleptic Gregorian
337
+ Date.civil(d.year, d.mon, d.mday, Date::GREGORIAN).jd - EPOCH_JD
338
+ end
339
+
340
+ # Converts the values a timestamp column accepts into a Time (or Time-like) object
341
+ def to_time(v)
342
+ case v
343
+ when Time then v
344
+ when DateTime then v.to_time
345
+ when Date then Time.utc(v.year, v.month, v.day)
346
+ when String
347
+ begin
348
+ Time.iso8601(v)
349
+ rescue ArgumentError
350
+ Time.parse(v)
351
+ end
352
+ else
353
+ # ActiveSupport::TimeWithZone and friends
354
+ raise ArgumentError, "expected a Time, got #{v.class}" unless v.respond_to?(:to_i) && v.respond_to?(:nsec)
355
+ v
356
+ end
357
+ end
358
+
359
+ def timestamp_writer(unit, utc)
360
+ mult = UNIT_DIVISORS.fetch(unit)
361
+ lambda do |v|
362
+ return v if v.is_a?(Integer)
363
+ t = to_time(v)
364
+ secs = t.to_i
365
+ # Local (not UTC-adjusted) timestamps store the wall clock time
366
+ secs += t.utc_offset unless utc
367
+ secs * mult + t.nsec * mult / 1_000_000_000
368
+ end
369
+ end
370
+
371
+ def time_writer(unit)
372
+ mult = UNIT_DIVISORS.fetch(unit)
373
+ lambda do |v|
374
+ return v if v.is_a?(Integer)
375
+ if v.is_a?(String)
376
+ m = /\A(\d{1,2}):(\d{2})(?::(\d{2})(?:\.(\d{1,9}))?)?\z/.match(v) or
377
+ raise ArgumentError, "expected HH:MM[:SS[.fraction]], got #{v.inspect}"
378
+ nanos = (m[4] || "").ljust(9, "0").to_i
379
+ secs = m[1].to_i * 3600 + m[2].to_i * 60 + m[3].to_i
380
+ else
381
+ raise ArgumentError, "expected a Time, got #{v.class}" unless v.respond_to?(:hour) && v.respond_to?(:nsec)
382
+ nanos = v.nsec
383
+ secs = v.hour * 3600 + v.min * 60 + v.sec
384
+ end
385
+ raise RangeError, "time of day out of range: #{v.inspect}" unless secs < 86_400
386
+ secs * mult + nanos * mult / 1_000_000_000
387
+ end
388
+ end
389
+
390
+ # String column restricted to a set of values. +values+ is an Array of labels, or a Hash of
391
+ # label => stored value like Rails' `Model.statuses`, in which case either is accepted.
392
+ def enum_writer(values)
393
+ labels = {}
394
+ if values.is_a?(Hash)
395
+ values.each do |label, stored|
396
+ labels[label.to_s] = label.to_s
397
+ labels[stored] = label.to_s unless stored.is_a?(String) || stored.is_a?(Symbol)
398
+ end
399
+ else
400
+ values.each { |label| labels[label.to_s] = label.to_s }
401
+ end
402
+ lambda do |v|
403
+ labels.fetch(v.is_a?(Symbol) ? v.to_s : v) do
404
+ raise ArgumentError, "#{v.inspect} is not one of #{labels.values.uniq.join(", ")}"
405
+ end
406
+ end
407
+ end
408
+
409
+ # Converts to Integer, rejecting fractional numbers and values outside min..max
410
+ def int_checker(min, max)
411
+ lambda do |v|
412
+ i = Integer(v)
413
+ 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
415
+ i
416
+ end
417
+ end
418
+
419
+ def fixed_checker(length, &convert)
420
+ lambda do |v|
421
+ s = convert.call(v)
422
+ raise ArgumentError, "expected #{length} bytes, got #{s.bytesize}" unless s.bytesize == length
423
+ s
424
+ end
425
+ end
426
+
427
+ def decimal_writer(node, scale)
428
+ mult = 10**scale
429
+ limit = node.precision ? 10**node.precision : nil
430
+ to_unscaled = lambda do |v|
431
+ r = case v
432
+ when Integer then v * mult
433
+ when Float, String then Rational(v.to_s) * mult
434
+ else v.to_r * mult
435
+ end
436
+ r = r.round if r.is_a?(Rational)
437
+ i = r.to_i
438
+ raise RangeError, "#{v} does not fit DECIMAL(#{node.precision}, #{scale})" if limit && i.abs >= limit
439
+ i
440
+ end
441
+ case node.type
442
+ when T::INT32, T::INT64 then to_unscaled
443
+ when T::FIXED_LEN_BYTE_ARRAY
444
+ len = node.type_length
445
+ ->(v) { int_to_be(to_unscaled.call(v), len) }
446
+ when T::BYTE_ARRAY
447
+ lambda do |v|
448
+ i = to_unscaled.call(v)
449
+ int_to_be(i, [(i.bit_length + 8) / 8, 1].max)
450
+ end
451
+ end
452
+ end
453
+ end
454
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Herringbone
4
+ VERSION = "0.1.0"
5
+ end