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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +209 -0
- data/bin/herringbone +54 -0
- data/lib/herringbone/active_record.rb +131 -0
- data/lib/herringbone/byte_values.rb +144 -0
- data/lib/herringbone/codecs/lz4.rb +305 -0
- data/lib/herringbone/codecs/snappy.rb +375 -0
- data/lib/herringbone/compression.rb +75 -0
- data/lib/herringbone/encodings/delta.rb +153 -0
- data/lib/herringbone/encodings/plain.rb +87 -0
- data/lib/herringbone/encodings/rle.rb +203 -0
- data/lib/herringbone/format.rb +309 -0
- data/lib/herringbone/io_buffer_support.rb +25 -0
- data/lib/herringbone/reader.rb +469 -0
- data/lib/herringbone/schema.rb +551 -0
- data/lib/herringbone/thrift.rb +334 -0
- data/lib/herringbone/types.rb +454 -0
- data/lib/herringbone/version.rb +5 -0
- data/lib/herringbone/writer.rb +634 -0
- data/lib/herringbone.rb +45 -0
- metadata +104 -0
|
@@ -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
|