herringbone 0.6.1 → 0.8.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 +4 -4
- data/CHANGELOG.md +45 -0
- data/MANUAL.md +196 -17
- data/README.md +22 -5
- data/bin/herringbone +56 -1
- data/lib/herringbone/active_record.rb +3 -1
- data/lib/herringbone/bloom_filter.rb +2 -0
- data/lib/herringbone/byte_values.rb +2 -0
- data/lib/herringbone/codecs/lz4.rb +9 -4
- data/lib/herringbone/codecs/lzo.rb +2 -0
- data/lib/herringbone/codecs/snappy.rb +48 -6
- data/lib/herringbone/combiner.rb +426 -0
- data/lib/herringbone/compression.rb +76 -13
- data/lib/herringbone/encodings/delta.rb +4 -0
- data/lib/herringbone/encodings/plain.rb +2 -0
- data/lib/herringbone/encodings/rle.rb +2 -0
- data/lib/herringbone/encryption.rb +2 -0
- data/lib/herringbone/inferring_writer.rb +1 -1
- data/lib/herringbone/inspector.rb +29 -5
- data/lib/herringbone/io_buffer_support.rb +2 -0
- data/lib/herringbone/reader/bloom_filters.rb +1 -0
- data/lib/herringbone/reader/chunk_copier.rb +177 -0
- data/lib/herringbone/reader/column_chunk_reader.rb +6 -2
- data/lib/herringbone/reader/column_cursor.rb +2 -0
- data/lib/herringbone/reader/numo.rb +4 -0
- data/lib/herringbone/reader/page_stream.rb +2 -0
- data/lib/herringbone/reader/scan.rb +2 -0
- data/lib/herringbone/reader.rb +19 -6
- data/lib/herringbone/redaction/rewriter.rb +51 -157
- data/lib/herringbone/redaction.rb +25 -15
- data/lib/herringbone/restricted_readable_io.rb +85 -0
- data/lib/herringbone/schema/merge.rb +355 -0
- data/lib/herringbone/schema.rb +175 -12
- data/lib/herringbone/thrift.rb +49 -10
- data/lib/herringbone/types.rb +22 -12
- data/lib/herringbone/version.rb +1 -1
- data/lib/herringbone/visualizer.rb +2 -0
- data/lib/herringbone/writer.rb +58 -22
- data/lib/herringbone/xxhash.rb +2 -0
- data/lib/herringbone.rb +96 -9
- metadata +6 -2
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Herringbone
|
|
4
|
+
class Schema
|
|
5
|
+
# The union or intersection of two schemas, behind Schema#union and Schema#intersect. Fields
|
|
6
|
+
# are matched by name at every level of nesting, and the type of a field both schemas have is
|
|
7
|
+
# widened to one that holds the values of both without loss. Fields that do not fit are
|
|
8
|
+
# collected rather than raised one by one, so a single IncompatibleSchema names them all.
|
|
9
|
+
#
|
|
10
|
+
# Leaf types are compared as Arrays like +[:int, 32, true]+ or +[:timestamp, :micros, false]+,
|
|
11
|
+
# so the same type spelled with a logical type in one file and a converted type in another
|
|
12
|
+
# still matches.
|
|
13
|
+
#
|
|
14
|
+
# @api private
|
|
15
|
+
class Merge
|
|
16
|
+
# Shorthand for Format::Type
|
|
17
|
+
T = Format::Type
|
|
18
|
+
# Shorthand for Format::ConvertedType
|
|
19
|
+
C = Format::ConvertedType
|
|
20
|
+
|
|
21
|
+
# Converted integer annotations => [bit width, signed]
|
|
22
|
+
INT_CONVERTED = {
|
|
23
|
+
C::INT_8 => [8, true], C::INT_16 => [16, true], C::INT_32 => [32, true], C::INT_64 => [64, true],
|
|
24
|
+
C::UINT_8 => [8, false], C::UINT_16 => [16, false], C::UINT_32 => [32, false], C::UINT_64 => [64, false]
|
|
25
|
+
}.freeze
|
|
26
|
+
|
|
27
|
+
# Converted annotations of time and timestamp columns => [kind, unit, adjusted to UTC]
|
|
28
|
+
TIME_CONVERTED = {
|
|
29
|
+
C::TIME_MILLIS => [:time, :millis, true], C::TIME_MICROS => [:time, :micros, true],
|
|
30
|
+
C::TIMESTAMP_MILLIS => [:timestamp, :millis, true], C::TIMESTAMP_MICROS => [:timestamp, :micros, true]
|
|
31
|
+
}.freeze
|
|
32
|
+
|
|
33
|
+
# Converted annotations of UTF-8 byte arrays => the text kind
|
|
34
|
+
TEXT_CONVERTED = {C::UTF8 => :string, C::ENUM => :enum, C::JSON => :json}.freeze
|
|
35
|
+
|
|
36
|
+
# Time units from coarsest to finest
|
|
37
|
+
TIME_UNITS = %i[millis micros nanos].freeze
|
|
38
|
+
|
|
39
|
+
# Float width => bits of its significand, which is how wide an Integer it holds exactly
|
|
40
|
+
FLOAT_SIGNIFICANDS = {16 => 11, 32 => 24, 64 => 53}.freeze
|
|
41
|
+
|
|
42
|
+
# @param mode [Symbol] +:union+ or +:intersect+
|
|
43
|
+
def initialize(mode)
|
|
44
|
+
@mode = mode
|
|
45
|
+
@conflicts = []
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# @param mine [Schema] the receiver, whose field order comes first
|
|
49
|
+
# @param theirs [Schema] the other schema
|
|
50
|
+
# @return [Schema] a new schema sharing no nodes with either
|
|
51
|
+
# @raise [IncompatibleSchema] listing every field that does not fit
|
|
52
|
+
def call(mine, theirs)
|
|
53
|
+
nodes = merge_members(mine.fields, theirs.fields, [])
|
|
54
|
+
unless @conflicts.empty?
|
|
55
|
+
raise IncompatibleSchema.new(@conflicts, operation: (@mode == :union) ? "unite" : "intersect")
|
|
56
|
+
end
|
|
57
|
+
Schema.new(Node.new(name: mine.root.name, repetition: :required, children: nodes))
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# @param field [Field] any field
|
|
61
|
+
# @return [String] its type as error messages name it: the leaf type's label ("uint16",
|
|
62
|
+
# "timestamp(millis, UTC)", "decimal(10, 2)"...), or "struct", "list" or "map"
|
|
63
|
+
def describe(field) = kind_label(field)
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
# Matches the members of two groups (or the top-level fields) by name: those of +mine+ in
|
|
68
|
+
# order, then, for a union, those only +theirs+ has.
|
|
69
|
+
#
|
|
70
|
+
# @param mine [Array<Field>] fields of the receiver
|
|
71
|
+
# @param theirs [Array<Field>] fields of the other schema
|
|
72
|
+
# @param path [Array<String>] path of the enclosing group, empty at the top level
|
|
73
|
+
# @return [Array<Node>] merged nodes; incomplete when a conflict was recorded
|
|
74
|
+
def merge_members(mine, theirs, path)
|
|
75
|
+
by_name = theirs.to_h { |f| [f.name, f] }
|
|
76
|
+
names = mine.map(&:name)
|
|
77
|
+
if @mode == :intersect && (names & by_name.keys).empty?
|
|
78
|
+
conflict(path, "fields #{names.join(", ")}", "fields #{by_name.keys.join(", ")}", "no fields in common")
|
|
79
|
+
return []
|
|
80
|
+
end
|
|
81
|
+
nodes = mine.map do |field|
|
|
82
|
+
if (twin = by_name[field.name])
|
|
83
|
+
merge(field, twin, path + [field.name])
|
|
84
|
+
elsif @mode == :union
|
|
85
|
+
nullable(field, path + [field.name], :left)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
if @mode == :union
|
|
89
|
+
nodes += theirs.reject { |f| names.include?(f.name) }.map { |f| nullable(f, path + [f.name], :right) }
|
|
90
|
+
end
|
|
91
|
+
nodes.compact
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# A field only one side of a union has: rows from the other side hold no value for it
|
|
95
|
+
#
|
|
96
|
+
# @param field [Field] the field only one schema has
|
|
97
|
+
# @param path [Array<String>] path of the field
|
|
98
|
+
# @param side [Symbol] +:left+ or +:right+, the schema that has it
|
|
99
|
+
# @return [Node, nil] an optional copy of the field's node; nil for a conflict
|
|
100
|
+
def nullable(field, path, side)
|
|
101
|
+
unless field.node.repeated?
|
|
102
|
+
return copy(field.node, repetition: :optional)
|
|
103
|
+
end
|
|
104
|
+
sides = ["repeated #{kind_label(field.element)}", "absent"]
|
|
105
|
+
conflict(path, *((side == :left) ? sides : sides.reverse), "a repeated field cannot be null, so both schemas need it")
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# @param a [Field] the field in the receiver
|
|
109
|
+
# @param b [Field] the field of the same name in the other schema
|
|
110
|
+
# @param path [Array<String>] path of the field
|
|
111
|
+
# @return [Node, nil] the merged node; nil when a conflict was recorded
|
|
112
|
+
def merge(a, b, path)
|
|
113
|
+
an = a.node
|
|
114
|
+
bn = b.node
|
|
115
|
+
a_id = field_id_of(a)
|
|
116
|
+
b_id = field_id_of(b)
|
|
117
|
+
if a_id && b_id && a_id != b_id
|
|
118
|
+
return conflict(path, "field_id #{a_id}", "field_id #{b_id}", "different field ids, so different columns")
|
|
119
|
+
end
|
|
120
|
+
if a.kind != b.kind
|
|
121
|
+
return conflict(path, kind_label(a), kind_label(b), "a #{a.kind} and a #{b.kind} do not mix")
|
|
122
|
+
end
|
|
123
|
+
both_bare = a.kind == :list && an.repeated? && bn.repeated?
|
|
124
|
+
repetition = if both_bare then :repeated
|
|
125
|
+
elsif a.optional || b.optional then :optional
|
|
126
|
+
else :required
|
|
127
|
+
end
|
|
128
|
+
field_id = a_id || b_id
|
|
129
|
+
if shape(an) == shape(bn)
|
|
130
|
+
enum_values = an.enum_values if an.enum_values == bn.enum_values
|
|
131
|
+
return copy(an, repetition: repetition, field_id: field_id, enum_values: enum_values)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
case a.kind
|
|
135
|
+
when :leaf
|
|
136
|
+
attrs = merge_leaf(an, bn, path) or return
|
|
137
|
+
enum_values = an.enum_values if an.enum_values == bn.enum_values
|
|
138
|
+
Node.new(name: an.name, repetition: repetition, field_id: field_id, enum_values: enum_values, **attrs)
|
|
139
|
+
when :struct
|
|
140
|
+
children = merge_members(a.children, b.children, path)
|
|
141
|
+
Node.new(name: an.name, repetition: repetition, field_id: field_id, children: children) unless children.empty?
|
|
142
|
+
when :list
|
|
143
|
+
element = merge(a.element, b.element, path + ["element"]) or return
|
|
144
|
+
if both_bare
|
|
145
|
+
element.name = an.name
|
|
146
|
+
element.repetition = :repeated
|
|
147
|
+
element.field_id = field_id
|
|
148
|
+
return element
|
|
149
|
+
end
|
|
150
|
+
element.name = "element"
|
|
151
|
+
Node.new(name: an.name, repetition: repetition, field_id: field_id,
|
|
152
|
+
children: [Node.new(name: "list", repetition: :repeated, children: [element])],
|
|
153
|
+
logical_type: Format::LogicalType.new(list: Format::ListType.new), converted_type: C::LIST)
|
|
154
|
+
when :map
|
|
155
|
+
key = merge(a.key, b.key, path + ["key"])
|
|
156
|
+
value = merge(a.value, b.value, path + ["value"])
|
|
157
|
+
return unless key && value
|
|
158
|
+
key.name = "key"
|
|
159
|
+
key.repetition = :required
|
|
160
|
+
value.name = "value"
|
|
161
|
+
Node.new(name: an.name, repetition: repetition, field_id: field_id,
|
|
162
|
+
children: [Node.new(name: "key_value", repetition: :repeated, children: [key, value])],
|
|
163
|
+
logical_type: Format::LogicalType.new(map: Format::MapType.new), converted_type: C::MAP)
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# @param an [Node] leaf in the receiver
|
|
168
|
+
# @param bn [Node] leaf of the same name in the other schema
|
|
169
|
+
# @param path [Array<String>] path of the field
|
|
170
|
+
# @return [Hash{Symbol => Object}, nil] physical attributes for Node.new; nil when a conflict
|
|
171
|
+
# was recorded
|
|
172
|
+
def merge_leaf(an, bn, path)
|
|
173
|
+
ta = leaf_type(an)
|
|
174
|
+
tb = leaf_type(bn)
|
|
175
|
+
return attributes_of(an) if ta == tb && ta.first == :other
|
|
176
|
+
type, reason = (ta == tb) ? [ta] : widen(ta, tb)
|
|
177
|
+
return conflict(path, label(ta), label(tb), reason) if reason
|
|
178
|
+
attributes(type)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# @param field [Field] any field
|
|
182
|
+
# @return [Integer, nil] the field id of its node, nil for the element of a bare repeated
|
|
183
|
+
# field, whose node (and field id) is that of the list
|
|
184
|
+
def field_id_of(field)
|
|
185
|
+
field.node.field_id unless field.node.repeated? && field.kind != :list
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# @param ta [Array] leaf type of the receiver's field (see #leaf_type)
|
|
189
|
+
# @param tb [Array] a different leaf type of the other schema's field
|
|
190
|
+
# @return [Array(Array, nil), Array(nil, String)] the widened type, or why there is none
|
|
191
|
+
def widen(ta, tb)
|
|
192
|
+
case [ta.first, tb.first]
|
|
193
|
+
when [:int, :int] then widen_ints(ta, tb)
|
|
194
|
+
when [:float, :float] then [[:float, [ta[1], tb[1]].max]]
|
|
195
|
+
when [:int, :float] then int_to_float(ta, tb)
|
|
196
|
+
when [:float, :int] then int_to_float(tb, ta)
|
|
197
|
+
when [:timestamp, :timestamp], [:time, :time]
|
|
198
|
+
return [nil, "one is adjusted to UTC and the other is not"] if ta[2] != tb[2]
|
|
199
|
+
[[ta[0], TIME_UNITS[[TIME_UNITS.index(ta[1]), TIME_UNITS.index(tb[1])].max], ta[2]]]
|
|
200
|
+
when [:text, :text] then [[:text, :string]]
|
|
201
|
+
when [:text, :binary], [:binary, :text] then [[:binary]]
|
|
202
|
+
when [:decimal, :decimal] then [nil, "decimals need the same precision and scale"]
|
|
203
|
+
else [nil, "no common type"]
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# @param ta [Array] an integer type
|
|
208
|
+
# @param tb [Array] another integer type
|
|
209
|
+
# @return [Array(Array, nil), Array(nil, String)] the narrowest integer holding both, or why
|
|
210
|
+
# there is none
|
|
211
|
+
def widen_ints(ta, tb)
|
|
212
|
+
_, a_bits, a_signed = ta
|
|
213
|
+
_, b_bits, b_signed = tb
|
|
214
|
+
return [[:int, [a_bits, b_bits].max, a_signed]] if a_signed == b_signed
|
|
215
|
+
signed_bits, unsigned_bits = a_signed ? [a_bits, b_bits] : [b_bits, a_bits]
|
|
216
|
+
return [nil, "uint64 does not fit any signed integer"] if unsigned_bits == 64
|
|
217
|
+
[[:int, [signed_bits, unsigned_bits * 2].max, true]]
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# @param int [Array] an integer type
|
|
221
|
+
# @param float [Array] a float type
|
|
222
|
+
# @return [Array(Array, nil), Array(nil, String)] the narrowest float at least as wide as
|
|
223
|
+
# +float+ that holds every value of +int+ exactly, or why there is none
|
|
224
|
+
def int_to_float(int, float)
|
|
225
|
+
_, bits, signed = int
|
|
226
|
+
magnitude = signed ? bits - 1 : bits
|
|
227
|
+
width = FLOAT_SIGNIFICANDS.find { |w, significand| w >= float[1] && significand >= magnitude }&.first
|
|
228
|
+
return [nil, "#{label(int)} does not fit a double exactly"] unless width
|
|
229
|
+
[[:float, width]]
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# @param node [Node] a leaf
|
|
233
|
+
# @return [Array] its type, independent of whether a logical or converted type spells it;
|
|
234
|
+
# +[:other, ...]+ for annotations nothing widens (INTERVAL, unknown logical types)
|
|
235
|
+
def leaf_type(node)
|
|
236
|
+
lt = node.logical_type
|
|
237
|
+
case node.logical_kind
|
|
238
|
+
when :integer then [:int, lt.integer.bit_width, lt.integer.is_signed]
|
|
239
|
+
when :decimal then [:decimal, lt.decimal.precision, lt.decimal.scale]
|
|
240
|
+
when :timestamp then [:timestamp, lt.timestamp.unit.to_sym, lt.timestamp.is_adjusted_to_utc]
|
|
241
|
+
when :time then [:time, lt.time.unit.to_sym, lt.time.is_adjusted_to_utc]
|
|
242
|
+
when :string, :enum, :json then [:text, node.logical_kind]
|
|
243
|
+
when :float16 then [:float, 16]
|
|
244
|
+
when :date, :uuid, :bson then [node.logical_kind]
|
|
245
|
+
when nil
|
|
246
|
+
# A logical type this version does not know decodes as an empty union
|
|
247
|
+
lt ? [:other, shape(node), "an unknown logical type"] : converted_type(node)
|
|
248
|
+
else [:other, shape(node), node.logical_kind.to_s]
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# @param node [Node] a leaf without a logical type
|
|
253
|
+
# @return [Array] its type, see #leaf_type
|
|
254
|
+
def converted_type(node)
|
|
255
|
+
ct = node.converted_type
|
|
256
|
+
if (int = INT_CONVERTED[ct]) then [:int, *int]
|
|
257
|
+
elsif (time = TIME_CONVERTED[ct]) then time
|
|
258
|
+
elsif (text = TEXT_CONVERTED[ct]) then [:text, text]
|
|
259
|
+
elsif ct == C::DECIMAL then [:decimal, node.precision, node.scale || 0]
|
|
260
|
+
elsif ct == C::DATE then [:date]
|
|
261
|
+
elsif ct == C::BSON then [:bson]
|
|
262
|
+
elsif ct then [:other, shape(node), C::NAMES[ct].to_s.downcase]
|
|
263
|
+
else
|
|
264
|
+
case node.type
|
|
265
|
+
when T::BOOLEAN then [:boolean]
|
|
266
|
+
when T::INT32 then [:int, 32, true]
|
|
267
|
+
when T::INT64 then [:int, 64, true]
|
|
268
|
+
when T::INT96 then [:int96]
|
|
269
|
+
when T::FLOAT then [:float, 32]
|
|
270
|
+
when T::DOUBLE then [:float, 64]
|
|
271
|
+
when T::BYTE_ARRAY then [:binary]
|
|
272
|
+
when T::FIXED_LEN_BYTE_ARRAY then [:fixed, node.type_length]
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# @param type [Array] a leaf type, see #leaf_type
|
|
278
|
+
# @return [Hash{Symbol => Object}] physical attributes for Node.new, as the Builder DSL
|
|
279
|
+
# declares that type
|
|
280
|
+
def attributes(type)
|
|
281
|
+
case type
|
|
282
|
+
in [:int, 32, true] then Types.physical_attributes(:int32)
|
|
283
|
+
in [:int, 64, true] then Types.physical_attributes(:int64)
|
|
284
|
+
in [:int, bits, signed] then Types.int_type(bits, signed)
|
|
285
|
+
in [:float, bits] then Types.physical_attributes({16 => :float16, 32 => :float, 64 => :double}.fetch(bits))
|
|
286
|
+
in [:timestamp | :time => kind, unit, utc] then Types.physical_attributes(utc ? kind : :"local_#{kind}", unit: unit)
|
|
287
|
+
in [:text, :enum] then Types.physical_attributes(:enum, parquet_enum: true)
|
|
288
|
+
in [:text, kind] then Types.physical_attributes(kind)
|
|
289
|
+
in [:decimal, precision, scale] then Types.physical_attributes(:decimal, precision: precision, scale: scale)
|
|
290
|
+
in [:fixed, length] then Types.physical_attributes(:fixed, length: length)
|
|
291
|
+
in [kind] then Types.physical_attributes(kind)
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# @param node [Node] a leaf
|
|
296
|
+
# @return [Hash{Symbol => Object}] its physical attributes as they are
|
|
297
|
+
def attributes_of(node)
|
|
298
|
+
{type: node.type, type_length: node.type_length, converted_type: node.converted_type,
|
|
299
|
+
logical_type: node.logical_type, scale: node.scale, precision: node.precision}
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
# @param type [Array] a leaf type, see #leaf_type
|
|
303
|
+
# @return [String] e.g. "uint16", "timestamp(millis, UTC)", "decimal(10, 2)"
|
|
304
|
+
def label(type)
|
|
305
|
+
case type
|
|
306
|
+
in [:int, bits, signed] then "#{"u" unless signed}int#{bits}"
|
|
307
|
+
in [:float, bits] then {16 => "float16", 32 => "float", 64 => "double"}.fetch(bits)
|
|
308
|
+
in [:timestamp | :time => kind, unit, utc] then "#{kind}(#{unit}, #{utc ? "UTC" : "local"})"
|
|
309
|
+
in [:text, kind] then kind.to_s
|
|
310
|
+
in [:decimal, precision, scale] then "decimal(#{precision}, #{scale})"
|
|
311
|
+
in [:fixed, length] then "fixed(#{length})"
|
|
312
|
+
in [:other, _, description] then description
|
|
313
|
+
in [kind] then kind.to_s
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
# @param field [Field] any field
|
|
318
|
+
# @return [String] the leaf type's label, or "struct", "list" or "map"
|
|
319
|
+
def kind_label(field)
|
|
320
|
+
field.leaf? ? label(leaf_type(field.node)) : field.kind.to_s
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
# What two nodes must share to be merged as they are: Node#signature without the name,
|
|
324
|
+
# repetition and field id of the node itself
|
|
325
|
+
#
|
|
326
|
+
# @param node [Node] any node
|
|
327
|
+
# @return [Array]
|
|
328
|
+
def shape(node)
|
|
329
|
+
node.signature.values_at(2, 3, 4, 6)
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
# @param node [Node] node to copy, with its children
|
|
333
|
+
# @param changes [Hash{Symbol => Object}] attributes to set on the copy (not on its children)
|
|
334
|
+
# @option changes [Symbol] :repetition repetition of the copy
|
|
335
|
+
# @option changes [Integer, nil] :field_id field id of the copy
|
|
336
|
+
# @option changes [Array<String>, Hash, nil] :enum_values enum values of the copy
|
|
337
|
+
# @return [Node] a copy sharing no nodes with the original
|
|
338
|
+
def copy(node, **changes)
|
|
339
|
+
attrs = {name: node.name, repetition: node.repetition, field_id: node.field_id,
|
|
340
|
+
enum_values: node.enum_values, children: node.children&.map { |c| copy(c) }}
|
|
341
|
+
Node.new(**attrs.merge(attributes_of(node)).merge(changes))
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
# @param path [Array<String>] path of the field
|
|
345
|
+
# @param left [String] the field in the receiver
|
|
346
|
+
# @param right [String] the field in the other schema
|
|
347
|
+
# @param reason [String] why they do not fit
|
|
348
|
+
# @return [nil]
|
|
349
|
+
def conflict(path, left, right, reason)
|
|
350
|
+
@conflicts << IncompatibleSchema::Conflict.new(path.join("."), left, right, reason)
|
|
351
|
+
nil
|
|
352
|
+
end
|
|
353
|
+
end
|
|
354
|
+
end
|
|
355
|
+
end
|
data/lib/herringbone/schema.rb
CHANGED
|
@@ -120,6 +120,17 @@ module Herringbone
|
|
|
120
120
|
field_id: field_id
|
|
121
121
|
)
|
|
122
122
|
end
|
|
123
|
+
|
|
124
|
+
# What Schema#== compares: name, repetition, physical type and width, annotation and field id,
|
|
125
|
+
# and the same of the children. A logical type supersedes the converted type (and the
|
|
126
|
+
# decimal scale and precision, which it holds), since writers differ in whether they also
|
|
127
|
+
# store the legacy annotation. +enum_values+ is not stored in the file and does not count.
|
|
128
|
+
#
|
|
129
|
+
# @return [Array] nested Arrays of plain values, comparable with == and usable as a Hash key
|
|
130
|
+
def signature
|
|
131
|
+
annotation = logical_type ? logical_type.to_h : [converted_type, scale, precision]
|
|
132
|
+
[name, repetition, type, type_length, annotation, field_id, children&.map(&:signature)]
|
|
133
|
+
end
|
|
123
134
|
end
|
|
124
135
|
|
|
125
136
|
# A leaf column (one column chunk per row group)
|
|
@@ -296,6 +307,8 @@ module Herringbone
|
|
|
296
307
|
end
|
|
297
308
|
|
|
298
309
|
# Type inference behind Schema.infer
|
|
310
|
+
#
|
|
311
|
+
# @api private
|
|
299
312
|
module Inference
|
|
300
313
|
module_function
|
|
301
314
|
|
|
@@ -401,6 +414,86 @@ module Herringbone
|
|
|
401
414
|
@columns.find { |c| c.path == path }
|
|
402
415
|
end
|
|
403
416
|
|
|
417
|
+
# Structural equality: the same fields in the same order, with the same names, repetition,
|
|
418
|
+
# physical types and widths, annotations, field ids and nesting (see Node#signature). The name
|
|
419
|
+
# of the root (+schema+, +spark_schema+...) does not count.
|
|
420
|
+
#
|
|
421
|
+
# @param other [Object] object to compare with
|
|
422
|
+
# @return [Boolean]
|
|
423
|
+
def ==(other)
|
|
424
|
+
other.is_a?(Schema) && signature == other.signature
|
|
425
|
+
end
|
|
426
|
+
alias_method :eql?, :==
|
|
427
|
+
|
|
428
|
+
# @return [Integer] hash consistent with #==
|
|
429
|
+
def hash = signature.hash
|
|
430
|
+
|
|
431
|
+
# The fields of this schema, then those only +other+ has, matched by name at every level of
|
|
432
|
+
# nesting (struct members, list elements, map keys and values). A field only one side has
|
|
433
|
+
# becomes optional, as does one that is optional on either side. The types of fields both
|
|
434
|
+
# have are widened without loss:
|
|
435
|
+
#
|
|
436
|
+
# * integers to the wider one: int32 + int64 -> int64, uint8 + uint32 -> uint32, and unsigned
|
|
437
|
+
# with signed to a signed integer twice as wide: uint32 + int8 -> int64
|
|
438
|
+
# * floats to the wider one, and integers with floats to a float holding every integer
|
|
439
|
+
# exactly: int16 + float -> float, int32 + float -> double
|
|
440
|
+
# * timestamps and times to the finer unit: millis + micros -> micros
|
|
441
|
+
# * string, enum and json to string; any of them with binary to binary
|
|
442
|
+
#
|
|
443
|
+
# (a.schema + b.schema).fields.map(&:name) # => ["id", "name", "email"]
|
|
444
|
+
#
|
|
445
|
+
# @param other [Schema] the schema to unite with
|
|
446
|
+
# @return [Schema] a new schema, sharing no nodes with either
|
|
447
|
+
# @raise [IncompatibleSchema] listing every field without a common type (int64 + double,
|
|
448
|
+
# uint64 + signed, decimals of another precision or scale, UTC + local timestamps, a struct and
|
|
449
|
+
# a list...), with different field ids, or repeated and only in one schema
|
|
450
|
+
# @raise [ArgumentError] when +other+ is not a Schema
|
|
451
|
+
def union(other)
|
|
452
|
+
raise ArgumentError, "Expected a Herringbone::Schema, got #{other.class}" unless other.is_a?(Schema)
|
|
453
|
+
Merge.new(:union).call(self, other)
|
|
454
|
+
end
|
|
455
|
+
alias_method :+, :union
|
|
456
|
+
|
|
457
|
+
# The fields both schemas have, in the order of this one, matched by name at every level of
|
|
458
|
+
# nesting. Nullability and types are widened as by #union.
|
|
459
|
+
#
|
|
460
|
+
# (a.schema & b.schema).fields.map(&:name) # => ["id", "name"]
|
|
461
|
+
#
|
|
462
|
+
# @param other [Schema] the schema to intersect with
|
|
463
|
+
# @return [Schema] a new schema, sharing no nodes with either
|
|
464
|
+
# @raise [IncompatibleSchema] listing every field without a common type or with different field
|
|
465
|
+
# ids, and the schemas or structs that have no fields in common
|
|
466
|
+
# @raise [ArgumentError] when +other+ is not a Schema
|
|
467
|
+
def intersect(other)
|
|
468
|
+
raise ArgumentError, "Expected a Herringbone::Schema, got #{other.class}" unless other.is_a?(Schema)
|
|
469
|
+
Merge.new(:intersect).call(self, other)
|
|
470
|
+
end
|
|
471
|
+
alias_method :&, :intersect
|
|
472
|
+
|
|
473
|
+
# The given top-level fields, in the order of this schema.
|
|
474
|
+
#
|
|
475
|
+
# schema.only(:id, :name).fields.map(&:name) # => ["id", "name"]
|
|
476
|
+
#
|
|
477
|
+
# @param names [Array<String, Symbol>] names of top-level fields
|
|
478
|
+
# @return [Schema] a new schema, sharing no nodes with this one
|
|
479
|
+
# @raise [ArgumentError] when a name is not a top-level field, or no name is given
|
|
480
|
+
def only(*names)
|
|
481
|
+
keep = field_names!(names, "only")
|
|
482
|
+
subset { |f| keep.include?(f.name) }
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
# Every top-level field but the given ones, in the order of this schema.
|
|
486
|
+
#
|
|
487
|
+
# schema.except(:ssn).fields.map(&:name) # => ["id", "name"]
|
|
488
|
+
#
|
|
489
|
+
# @param names [Array<String, Symbol>] names of top-level fields
|
|
490
|
+
# @return [Schema] a new schema, sharing no nodes with this one
|
|
491
|
+
# @raise [ArgumentError] when a name is not a top-level field, or all fields would be left out
|
|
492
|
+
def except(*names)
|
|
493
|
+
drop = field_names!(names, "except")
|
|
494
|
+
subset { |f| !drop.include?(f.name) }
|
|
495
|
+
end
|
|
496
|
+
|
|
404
497
|
# @return [String] one line per node, indented by depth: repetition, physical type, name and
|
|
405
498
|
# annotation
|
|
406
499
|
def inspect
|
|
@@ -425,8 +518,47 @@ module Herringbone
|
|
|
425
518
|
end
|
|
426
519
|
alias_method :to_s, :inspect
|
|
427
520
|
|
|
521
|
+
protected
|
|
522
|
+
|
|
523
|
+
# @return [Array] the signatures of the top-level nodes (Node#signature), for #== and #hash
|
|
524
|
+
def signature = @root.children.map(&:signature)
|
|
525
|
+
|
|
428
526
|
private
|
|
429
527
|
|
|
528
|
+
# @param names [Array<String, Symbol, Array>] names given to #only or #except
|
|
529
|
+
# @param method [String] the method, for the error message
|
|
530
|
+
# @return [Array<String>] the names as Strings
|
|
531
|
+
# @raise [ArgumentError] when a name is not a top-level field, or no name is given
|
|
532
|
+
def field_names!(names, method)
|
|
533
|
+
names = names.flatten.map(&:to_s)
|
|
534
|
+
raise ArgumentError, "Schema##{method} needs the names of top-level fields, e.g. schema.#{method}(:id, :name)" if names.empty?
|
|
535
|
+
missing = names - @fields.map(&:name)
|
|
536
|
+
unless missing.empty?
|
|
537
|
+
raise ArgumentError, "Schema##{method}: no top-level field #{missing.join(", ")}. " \
|
|
538
|
+
"The schema has #{@fields.map(&:name).join(", ")}"
|
|
539
|
+
end
|
|
540
|
+
names
|
|
541
|
+
end
|
|
542
|
+
|
|
543
|
+
# @yieldparam field [Field] a top-level field
|
|
544
|
+
# @yieldreturn [Boolean] whether to keep it
|
|
545
|
+
# @return [Schema] a schema of copies of the kept fields' nodes
|
|
546
|
+
# @raise [ArgumentError] when no field is kept
|
|
547
|
+
def subset
|
|
548
|
+
nodes = @fields.select { |f| yield f }.map { |f| copy_node(f.node) }
|
|
549
|
+
raise ArgumentError, "A schema needs at least one field, and this would leave out all of #{@fields.map(&:name).join(", ")}" if nodes.empty?
|
|
550
|
+
Schema.new(Node.new(name: @root.name, repetition: :required, children: nodes))
|
|
551
|
+
end
|
|
552
|
+
|
|
553
|
+
# @param node [Node] node to copy, with its children
|
|
554
|
+
# @return [Node] a copy sharing no nodes with the original
|
|
555
|
+
def copy_node(node)
|
|
556
|
+
Node.new(name: node.name, repetition: node.repetition, type: node.type, type_length: node.type_length,
|
|
557
|
+
converted_type: node.converted_type, logical_type: node.logical_type, scale: node.scale,
|
|
558
|
+
precision: node.precision, field_id: node.field_id, enum_values: node.enum_values,
|
|
559
|
+
children: node.children&.map { |c| copy_node(c) })
|
|
560
|
+
end
|
|
561
|
+
|
|
430
562
|
# Appends a Column for every leaf under +node+, depth-first. A non-required node adds one
|
|
431
563
|
# definition level and a repeated node one repetition level.
|
|
432
564
|
#
|
|
@@ -579,28 +711,50 @@ module Herringbone
|
|
|
579
711
|
define_method(t) { |name, **opts| column(name, t, **opts) }
|
|
580
712
|
end
|
|
581
713
|
|
|
582
|
-
# A TIME column
|
|
714
|
+
# A TIME column. A Time is converted to UTC when written, and its time of day in UTC is
|
|
715
|
+
# stored. Millis are stored as INT32, micros and nanos as INT64.
|
|
716
|
+
#
|
|
717
|
+
# @param name [String, Symbol] field name
|
|
718
|
+
# @param unit [Symbol] +:millis+, +:micros+ or +:nanos+
|
|
719
|
+
# @param opts [Hash{Symbol => Object}] options of #column
|
|
720
|
+
# @option opts [Boolean] :null (true) whether the field is nullable
|
|
721
|
+
# @option opts [Integer, nil] :field_id (nil) field id to store in the schema
|
|
722
|
+
# @return [Node] the added node
|
|
723
|
+
# @raise [ArgumentError] for an unknown unit or a duplicate name
|
|
724
|
+
def time(name, unit: :micros, **opts) = column(name, :time, unit: unit, **opts)
|
|
725
|
+
# A TIME column for wall clock times of day. A Time is written as its time of day in its
|
|
726
|
+
# own zone, without converting it to UTC. Millis are stored as INT32, micros and nanos as INT64.
|
|
583
727
|
#
|
|
584
728
|
# @param name [String, Symbol] field name
|
|
585
729
|
# @param unit [Symbol] +:millis+, +:micros+ or +:nanos+
|
|
586
|
-
# @param utc [Boolean] the isAdjustedToUTC flag of the logical type
|
|
587
730
|
# @param opts [Hash{Symbol => Object}] options of #column
|
|
588
731
|
# @option opts [Boolean] :null (true) whether the field is nullable
|
|
589
732
|
# @option opts [Integer, nil] :field_id (nil) field id to store in the schema
|
|
590
733
|
# @return [Node] the added node
|
|
591
734
|
# @raise [ArgumentError] for an unknown unit or a duplicate name
|
|
592
|
-
def
|
|
593
|
-
# A TIMESTAMP column, stored as INT64.
|
|
735
|
+
def local_time(name, unit: :micros, **opts) = column(name, :local_time, unit: unit, **opts)
|
|
736
|
+
# A TIMESTAMP column, stored as INT64. A Time in any zone is converted to UTC when written,
|
|
737
|
+
# so the column holds instants.
|
|
594
738
|
#
|
|
595
739
|
# @param name [String, Symbol] field name
|
|
596
740
|
# @param unit [Symbol] +:millis+, +:micros+ or +:nanos+
|
|
597
|
-
# @param utc [Boolean] the isAdjustedToUTC flag: true for instants, false for local date-times
|
|
598
741
|
# @param opts [Hash{Symbol => Object}] options of #column
|
|
599
742
|
# @option opts [Boolean] :null (true) whether the field is nullable
|
|
600
743
|
# @option opts [Integer, nil] :field_id (nil) field id to store in the schema
|
|
601
744
|
# @return [Node] the added node
|
|
602
745
|
# @raise [ArgumentError] for an unknown unit or a duplicate name
|
|
603
|
-
def timestamp(name, unit: :micros,
|
|
746
|
+
def timestamp(name, unit: :micros, **opts) = column(name, :timestamp, unit: unit, **opts)
|
|
747
|
+
# A TIMESTAMP column for wall clock date-times, stored as INT64. A Time is written as its
|
|
748
|
+
# date and time in its own zone, without converting it to UTC, and no zone is stored.
|
|
749
|
+
#
|
|
750
|
+
# @param name [String, Symbol] field name
|
|
751
|
+
# @param unit [Symbol] +:millis+, +:micros+ or +:nanos+
|
|
752
|
+
# @param opts [Hash{Symbol => Object}] options of #column
|
|
753
|
+
# @option opts [Boolean] :null (true) whether the field is nullable
|
|
754
|
+
# @option opts [Integer, nil] :field_id (nil) field id to store in the schema
|
|
755
|
+
# @return [Node] the added node
|
|
756
|
+
# @raise [ArgumentError] for an unknown unit or a duplicate name
|
|
757
|
+
def local_timestamp(name, unit: :micros, **opts) = column(name, :local_timestamp, unit: unit, **opts)
|
|
604
758
|
# A DECIMAL column. Up to 9 digits are stored as INT32, up to 18 as INT64, more as a
|
|
605
759
|
# FIXED_LEN_BYTE_ARRAY of the minimal width, unless +physical:+ says otherwise.
|
|
606
760
|
#
|
|
@@ -660,7 +814,6 @@ module Herringbone
|
|
|
660
814
|
# @option type_opts [Integer] :precision decimal precision
|
|
661
815
|
# @option type_opts [Integer] :scale decimal scale
|
|
662
816
|
# @option type_opts [Symbol] :unit time or timestamp unit
|
|
663
|
-
# @option type_opts [Boolean] :utc time or timestamp UTC adjustment
|
|
664
817
|
# @option type_opts [Integer] :length FIXED_LEN_BYTE_ARRAY width
|
|
665
818
|
# @yield [list] declares the struct's fields for a +:struct+ element, or exactly one field
|
|
666
819
|
# (renamed to "element") when +type+ is nil
|
|
@@ -699,7 +852,6 @@ module Herringbone
|
|
|
699
852
|
# @option type_opts [Integer] :precision decimal precision
|
|
700
853
|
# @option type_opts [Integer] :scale decimal scale
|
|
701
854
|
# @option type_opts [Symbol] :unit time or timestamp unit
|
|
702
|
-
# @option type_opts [Boolean] :utc time or timestamp UTC adjustment
|
|
703
855
|
# @option type_opts [Integer] :length FIXED_LEN_BYTE_ARRAY width
|
|
704
856
|
# @yield [map] declares the struct's fields for a +:struct+ value, or exactly one field
|
|
705
857
|
# (renamed to "value") when +value_type+ is nil
|
|
@@ -721,8 +873,8 @@ module Herringbone
|
|
|
721
873
|
# Generic column declaration: s.column :name, :int32, null: false
|
|
722
874
|
#
|
|
723
875
|
# @param name [String, Symbol] field name
|
|
724
|
-
# @param type [Symbol, String] DSL type: one of PRIMITIVES, or +:time+, +:
|
|
725
|
-
# +:decimal+, +:fixed+, +:enum+ with their options
|
|
876
|
+
# @param type [Symbol, String] DSL type: one of PRIMITIVES, or +:time+, +:local_time+,
|
|
877
|
+
# +:timestamp+, +:local_timestamp+, +:decimal+, +:fixed+, +:enum+ with their options
|
|
726
878
|
# @param null [Boolean] whether the field is nullable (optional rather than required)
|
|
727
879
|
# @param field_id [Integer, nil] field id to store in the schema
|
|
728
880
|
# @param opts [Hash{Symbol => Object}] type options
|
|
@@ -730,7 +882,6 @@ module Herringbone
|
|
|
730
882
|
# @option opts [Integer] :scale (0) decimal scale
|
|
731
883
|
# @option opts [Symbol] :physical decimal storage: +:int32+, +:int64+, +:binary+ or +:fixed+
|
|
732
884
|
# @option opts [Symbol] :unit (:micros) time or timestamp unit
|
|
733
|
-
# @option opts [Boolean] :utc (true) time or timestamp UTC adjustment
|
|
734
885
|
# @option opts [Integer] :length FIXED_LEN_BYTE_ARRAY width (required for +:fixed+)
|
|
735
886
|
# @option opts [Array<String>, Hash] :values allowed values, see #enum
|
|
736
887
|
# @option opts [Boolean] :parquet_enum (false) ENUM instead of STRING annotation, see #enum
|
|
@@ -834,8 +985,14 @@ module Herringbone
|
|
|
834
985
|
# and the rest go to Types.physical_attributes
|
|
835
986
|
# @option opts [Array<String>, Hash] :values allowed values of a string/enum column
|
|
836
987
|
# @return [Node] leaf node, not added to #nodes
|
|
837
|
-
# @raise [ArgumentError] for an unknown type or invalid type options
|
|
988
|
+
# @raise [ArgumentError] for an unknown type or invalid type options, or for +utc:+, which
|
|
989
|
+
# the local_time and local_timestamp types replace
|
|
838
990
|
def leaf_node(name, type, repetition, opts)
|
|
991
|
+
if opts.key?(:utc)
|
|
992
|
+
raise ArgumentError, "#{name}: the utc: option is gone. #{type} columns always convert Times to UTC when " \
|
|
993
|
+
"writing. To keep the wall clock time as given, without converting it, declare a local_#{type}, " \
|
|
994
|
+
"e.g. s.local_#{type} :#{name} or s.column :#{name}, :local_#{type}"
|
|
995
|
+
end
|
|
839
996
|
opts = opts.dup
|
|
840
997
|
values = opts.delete(:values)
|
|
841
998
|
attrs = Types.physical_attributes(type.to_sym, **opts)
|
|
@@ -846,3 +1003,9 @@ module Herringbone
|
|
|
846
1003
|
end
|
|
847
1004
|
|
|
848
1005
|
require_relative "active_record"
|
|
1006
|
+
|
|
1007
|
+
module Herringbone
|
|
1008
|
+
class Schema
|
|
1009
|
+
autoload :Merge, File.expand_path("schema/merge", __dir__)
|
|
1010
|
+
end
|
|
1011
|
+
end
|