herringbone 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +198 -74
- data/bin/herringbone +38 -26
- data/lib/herringbone/active_record.rb +48 -19
- data/lib/herringbone/bloom_filter.rb +370 -0
- data/lib/herringbone/byte_values.rb +29 -0
- data/lib/herringbone/codecs/lz4.rb +66 -3
- data/lib/herringbone/codecs/snappy.rb +132 -3
- data/lib/herringbone/compression.rb +121 -16
- data/lib/herringbone/encodings/delta.rb +76 -19
- data/lib/herringbone/encodings/plain.rb +29 -7
- data/lib/herringbone/encodings/rle.rb +55 -3
- data/lib/herringbone/format.rb +203 -0
- data/lib/herringbone/inspector.rb +1784 -0
- data/lib/herringbone/io_buffer_support.rb +3 -1
- data/lib/herringbone/reader/column_chunk_reader.rb +388 -0
- data/lib/herringbone/reader/column_cursor.rb +225 -0
- data/lib/herringbone/reader/numo.rb +365 -0
- data/lib/herringbone/reader/page_stream.rb +615 -0
- data/lib/herringbone/reader/scan.rb +350 -0
- data/lib/herringbone/reader.rb +585 -272
- data/lib/herringbone/schema.rb +326 -90
- data/lib/herringbone/thrift.rb +155 -4
- data/lib/herringbone/types.rb +177 -42
- data/lib/herringbone/version.rb +2 -1
- data/lib/herringbone/visualizer.rb +1136 -0
- data/lib/herringbone/writer.rb +550 -105
- data/lib/herringbone/xxhash.rb +425 -0
- data/lib/herringbone.rb +64 -9
- metadata +14 -33
data/lib/herringbone/schema.rb
CHANGED
|
@@ -8,6 +8,7 @@ module Herringbone
|
|
|
8
8
|
# * the logical tree of Schema::Field, which interprets LIST/MAP annotations and is
|
|
9
9
|
# what rows are assembled from (and shredded into) when reading and writing.
|
|
10
10
|
class Schema
|
|
11
|
+
# Thrift FieldRepetitionType value => the Symbol used on Node#repetition
|
|
11
12
|
REPETITIONS = {
|
|
12
13
|
Format::Repetition::REQUIRED => :required,
|
|
13
14
|
Format::Repetition::OPTIONAL => :optional,
|
|
@@ -16,11 +17,25 @@ module Herringbone
|
|
|
16
17
|
|
|
17
18
|
# A node of the physical schema tree.
|
|
18
19
|
class Node
|
|
20
|
+
# Fields of the SchemaElement this node maps to (+type+ and +type_length+ are nil for groups);
|
|
21
|
+
# +children+ is nil for leaves and +parent+ is nil for the root
|
|
19
22
|
attr_accessor :name, :repetition, :type, :type_length, :converted_type, :logical_type,
|
|
20
23
|
:scale, :precision, :field_id, :children, :parent
|
|
21
24
|
# Allowed values of a string/enum column (writer-side validation only, not stored in the file)
|
|
22
25
|
attr_accessor :enum_values
|
|
23
26
|
|
|
27
|
+
# @param name [String, Symbol] field name, stored as a String
|
|
28
|
+
# @param repetition [Symbol] +:required+, +:optional+ or +:repeated+
|
|
29
|
+
# @param type [Integer, nil] physical type (Format::Type), nil for groups
|
|
30
|
+
# @param type_length [Integer, nil] byte width of FIXED_LEN_BYTE_ARRAY columns
|
|
31
|
+
# @param converted_type [Integer, nil] legacy annotation (Format::ConvertedType)
|
|
32
|
+
# @param logical_type [Format::LogicalType, nil] logical type annotation
|
|
33
|
+
# @param scale [Integer, nil] decimal scale
|
|
34
|
+
# @param precision [Integer, nil] decimal precision
|
|
35
|
+
# @param field_id [Integer, nil] optional field id, as used by Iceberg
|
|
36
|
+
# @param children [Array<Node>, nil] child nodes of a group (their +parent+ is set to this node);
|
|
37
|
+
# nil for a leaf
|
|
38
|
+
# @param enum_values [Array<String>, Hash{String => Object}, nil] see #enum_values
|
|
24
39
|
def initialize(name:, repetition: :optional, type: nil, type_length: nil, converted_type: nil,
|
|
25
40
|
logical_type: nil, scale: nil, precision: nil, field_id: nil, children: nil, enum_values: nil)
|
|
26
41
|
@name = name.to_s
|
|
@@ -37,28 +52,44 @@ module Herringbone
|
|
|
37
52
|
@children&.each { |c| c.parent = self }
|
|
38
53
|
end
|
|
39
54
|
|
|
55
|
+
# @return [Boolean] true for a group (a node with children, possibly none)
|
|
40
56
|
def group? = !@children.nil?
|
|
57
|
+
# @return [Boolean] true for a primitive column node
|
|
41
58
|
def leaf? = @children.nil?
|
|
59
|
+
# @return [Boolean] true when the repetition is +:repeated+
|
|
42
60
|
def repeated? = @repetition == :repeated
|
|
61
|
+
# @return [Boolean] true when the repetition is +:optional+
|
|
43
62
|
def optional? = @repetition == :optional
|
|
44
63
|
|
|
64
|
+
# @return [Symbol, nil] the LogicalType union member that is set (+:string+, +:list+,
|
|
65
|
+
# +:decimal+...), or nil without a logical type
|
|
45
66
|
def logical_kind
|
|
46
67
|
@logical_type&.kind&.first
|
|
47
68
|
end
|
|
48
69
|
|
|
70
|
+
# @return [Boolean] whether the node carries a LIST logical or converted type
|
|
49
71
|
def list_annotated?
|
|
50
72
|
logical_kind == :list || @converted_type == Format::ConvertedType::LIST
|
|
51
73
|
end
|
|
52
74
|
|
|
75
|
+
# @return [Boolean] whether the node carries a MAP logical type, or a MAP / MAP_KEY_VALUE
|
|
76
|
+
# converted type
|
|
53
77
|
def map_annotated?
|
|
54
78
|
logical_kind == :map || @converted_type == Format::ConvertedType::MAP ||
|
|
55
79
|
@converted_type == Format::ConvertedType::MAP_KEY_VALUE
|
|
56
80
|
end
|
|
57
81
|
|
|
82
|
+
# @return [Array<String>] names from the top-level field down to this node, without the
|
|
83
|
+
# root's name (the +path_in_schema+ of a column)
|
|
58
84
|
def path
|
|
59
85
|
parent&.parent ? parent.path + [name] : [name]
|
|
60
86
|
end
|
|
61
87
|
|
|
88
|
+
# Builds a node without children from a footer SchemaElement; Schema.from_elements attaches them.
|
|
89
|
+
#
|
|
90
|
+
# @param el [Format::SchemaElement] element read from the footer
|
|
91
|
+
# @return [Node] node with +children+ nil; a missing repetition is taken as required
|
|
92
|
+
# @raise [KeyError] when the repetition type is not a known value
|
|
62
93
|
def self.from_element(el)
|
|
63
94
|
new(
|
|
64
95
|
name: el.name,
|
|
@@ -73,6 +104,8 @@ module Herringbone
|
|
|
73
104
|
)
|
|
74
105
|
end
|
|
75
106
|
|
|
107
|
+
# @param root [Boolean] true for the schema root, which is written without a repetition type
|
|
108
|
+
# @return [Format::SchemaElement] element for the footer's flattened schema list
|
|
76
109
|
def to_element(root: false)
|
|
77
110
|
Format::SchemaElement.new(
|
|
78
111
|
name: name,
|
|
@@ -91,8 +124,14 @@ module Herringbone
|
|
|
91
124
|
|
|
92
125
|
# A leaf column (one column chunk per row group)
|
|
93
126
|
class Column
|
|
127
|
+
# Position among the leaf columns (the column chunk order in a row group), the leaf Node,
|
|
128
|
+
# its path (Node#path), and the maximum definition / repetition levels of its values
|
|
94
129
|
attr_reader :index, :node, :path, :max_definition_level, :max_repetition_level
|
|
95
130
|
|
|
131
|
+
# @param index [Integer] position among the schema's leaf columns
|
|
132
|
+
# @param node [Node] leaf node of the physical tree
|
|
133
|
+
# @param max_def [Integer] maximum definition level (number of non-required nodes on the path)
|
|
134
|
+
# @param max_rep [Integer] maximum repetition level (number of repeated nodes on the path)
|
|
96
135
|
def initialize(index, node, max_def, max_rep)
|
|
97
136
|
@index = index
|
|
98
137
|
@node = node
|
|
@@ -101,14 +140,25 @@ module Herringbone
|
|
|
101
140
|
@max_repetition_level = max_rep
|
|
102
141
|
end
|
|
103
142
|
|
|
143
|
+
# @return [Integer] physical type (Format::Type)
|
|
104
144
|
def type = @node.type
|
|
145
|
+
# @return [Integer, nil] byte width for FIXED_LEN_BYTE_ARRAY columns
|
|
105
146
|
def type_length = @node.type_length
|
|
147
|
+
# @return [String] path joined with dots, as used for column names in options
|
|
106
148
|
def dotted_path = @path.join(".")
|
|
107
149
|
|
|
150
|
+
# Memoized Types.reader_for of the node.
|
|
151
|
+
#
|
|
152
|
+
# @return [Proc, Method, nil] converts a physical value into its Ruby value, or nil when the physical
|
|
153
|
+
# value is used as-is
|
|
108
154
|
def converter
|
|
109
155
|
@converter ||= Types.reader_for(@node)
|
|
110
156
|
end
|
|
111
157
|
|
|
158
|
+
# Memoized Types.writer_for of the node.
|
|
159
|
+
#
|
|
160
|
+
# @return [Proc, Method] converts a Ruby value into the physical value to store, raising
|
|
161
|
+
# ArgumentError / TypeError / RangeError for values that do not fit
|
|
112
162
|
def encoder
|
|
113
163
|
@encoder ||= Types.writer_for(@node)
|
|
114
164
|
end
|
|
@@ -123,9 +173,23 @@ module Herringbone
|
|
|
123
173
|
# item_def: definition level at which the repeated node has at least one entry
|
|
124
174
|
# element: element Field (lists); key/value Fields (maps, value may be nil)
|
|
125
175
|
class Field
|
|
176
|
+
# As described for the class; +children+ are the Fields of a :struct, +column+ is the Column of a :leaf,
|
|
177
|
+
# +leaves+ are all Columns under this field, and +node+ is the physical Node it was built from
|
|
126
178
|
attr_reader :kind, :name, :optional, :def_level, :rep_level, :item_def,
|
|
127
179
|
:children, :element, :key, :value, :column, :leaves, :node
|
|
128
180
|
|
|
181
|
+
# @param kind [Symbol] +:leaf+, +:struct+, +:list+ or +:map+
|
|
182
|
+
# @param name [String] field name
|
|
183
|
+
# @param optional [Boolean] whether this field itself may be null
|
|
184
|
+
# @param def_level [Integer] definition level at which this field counts as present
|
|
185
|
+
# @param node [Node] physical node the field was built from
|
|
186
|
+
# @param rep_level [Integer, nil] repetition level of the repeated node (lists and maps)
|
|
187
|
+
# @param item_def [Integer, nil] definition level at which a list or map has an entry
|
|
188
|
+
# @param children [Array<Field>, nil] fields of a struct
|
|
189
|
+
# @param element [Field, nil] element of a list
|
|
190
|
+
# @param key [Field, nil] key of a map
|
|
191
|
+
# @param value [Field, nil] value of a map
|
|
192
|
+
# @param column [Column, nil] column of a leaf
|
|
129
193
|
def initialize(kind:, name:, optional:, def_level:, node:, rep_level: nil, item_def: nil,
|
|
130
194
|
children: nil, element: nil, key: nil, value: nil, column: nil)
|
|
131
195
|
@kind = kind
|
|
@@ -148,16 +212,27 @@ module Herringbone
|
|
|
148
212
|
end
|
|
149
213
|
end
|
|
150
214
|
|
|
215
|
+
# @return [Column] first leaf column under this field
|
|
151
216
|
def first_leaf = @leaves.first
|
|
217
|
+
# @return [Boolean] true for a primitive (+:leaf+) field
|
|
152
218
|
def leaf? = @kind == :leaf
|
|
153
219
|
|
|
220
|
+
# Memoized lookup of a struct's children; only valid for +:struct+ fields.
|
|
221
|
+
#
|
|
222
|
+
# @return [Hash{String => Field}] child fields by name
|
|
154
223
|
def children_by_name
|
|
155
224
|
@children_by_name ||= @children.to_h { |c| [c.name, c] }
|
|
156
225
|
end
|
|
157
226
|
end
|
|
158
227
|
|
|
228
|
+
# The root Node, the leaf Columns in file order, and the top-level Fields of the logical tree
|
|
159
229
|
attr_reader :root, :columns, :fields
|
|
160
230
|
|
|
231
|
+
# Rebuilds the tree from the depth-first list of SchemaElements stored in the footer.
|
|
232
|
+
#
|
|
233
|
+
# @param elements [Array<Format::SchemaElement>] footer schema, root first
|
|
234
|
+
# @return [Schema]
|
|
235
|
+
# @raise [FormatError] when the list is empty or ends before all declared children are read
|
|
161
236
|
def self.from_elements(elements)
|
|
162
237
|
raise FormatError, "Empty schema" if elements.empty?
|
|
163
238
|
pos = 0
|
|
@@ -176,65 +251,71 @@ module Herringbone
|
|
|
176
251
|
new(root)
|
|
177
252
|
end
|
|
178
253
|
|
|
179
|
-
# Builds a schema with the DSL
|
|
180
|
-
#
|
|
181
|
-
# Schema.define(id: {type: :int64, null: false}, name: :string, tags: [:string],
|
|
182
|
-
# address: {city: :string, zip: :string}, price: {type: :decimal, precision: 10, scale: 2})
|
|
254
|
+
# Builds a schema with the DSL, see Schema::Builder
|
|
183
255
|
#
|
|
184
|
-
#
|
|
185
|
-
#
|
|
186
|
-
|
|
256
|
+
# @yield block evaluated with +instance_eval+ on a Builder, declaring the top-level fields
|
|
257
|
+
# @return [Schema]
|
|
258
|
+
# @raise [ArgumentError] when no field is declared, or a declaration is invalid
|
|
259
|
+
def self.define(&block)
|
|
187
260
|
builder = Builder.new
|
|
188
|
-
builder.fields(spec) if spec
|
|
189
261
|
builder.instance_eval(&block) if block
|
|
190
262
|
raise ArgumentError, "A schema needs at least one field" if builder.nodes.empty?
|
|
191
263
|
new(Node.new(name: "schema", repetition: :required, children: builder.nodes))
|
|
192
264
|
end
|
|
193
265
|
|
|
194
|
-
#
|
|
195
|
-
|
|
196
|
-
case schema
|
|
197
|
-
when Schema then schema
|
|
198
|
-
when Hash then define(schema)
|
|
199
|
-
else raise ArgumentError, "Expected a Herringbone::Schema or a Hash, got #{schema.class}"
|
|
200
|
-
end
|
|
201
|
-
end
|
|
266
|
+
# Number of leading rows Schema.infer looks at
|
|
267
|
+
INFER_SAMPLE = 1000
|
|
202
268
|
|
|
203
|
-
# Infers a schema from
|
|
204
|
-
# All fields are nullable. Integer -> int64, Integer mixed with Float -> double,
|
|
269
|
+
# Infers a schema from the first 1000 rows (Hashes, or objects responding to #attributes or
|
|
270
|
+
# #to_h). All fields are nullable. Integer -> int64, Integer mixed with Float -> double,
|
|
205
271
|
# String/Symbol -> string (binary if not valid UTF-8), true/false -> boolean,
|
|
206
272
|
# Time/DateTime -> timestamp(micros), Date -> date, BigDecimal -> decimal(38, max scale seen),
|
|
207
273
|
# Hash -> struct, Array -> list. Columns that are nil in every sampled row become strings.
|
|
208
|
-
#
|
|
209
|
-
# Schema.infer(rows
|
|
210
|
-
|
|
211
|
-
|
|
274
|
+
# Fields declared in the block (Builder DSL) replace the inferred ones of the same name:
|
|
275
|
+
# Schema.infer(rows) { json :payload }
|
|
276
|
+
#
|
|
277
|
+
# @param rows [Enumerable<Hash, Object>] rows to sample; only the first INFER_SAMPLE are read
|
|
278
|
+
# @yield optional block evaluated with +instance_eval+ on a Builder, declaring fields that
|
|
279
|
+
# replace inferred ones (or are added after them)
|
|
280
|
+
# @return [Schema]
|
|
281
|
+
# @raise [ArgumentError] when there are no rows, a row is not Hash-like, or a column mixes
|
|
282
|
+
# values that map to no single Parquet type
|
|
283
|
+
def self.infer(rows, &block)
|
|
284
|
+
sample_rows = rows.first(INFER_SAMPLE).map { |r| Inference.row_hash(r) }
|
|
212
285
|
raise ArgumentError, "Cannot infer a schema from zero rows" if sample_rows.empty?
|
|
213
|
-
overrides =
|
|
286
|
+
overrides = Builder.new
|
|
287
|
+
overrides.instance_eval(&block) if block
|
|
288
|
+
declared = overrides.nodes.to_h { |n| [n.name, n] }
|
|
214
289
|
names = sample_rows.flat_map { |r| r.keys.map(&:to_s) }.uniq
|
|
215
290
|
nodes = names.map do |name|
|
|
216
|
-
|
|
217
|
-
builder = Builder.new
|
|
218
|
-
builder.fields(name => overrides[name])
|
|
219
|
-
builder.nodes.first
|
|
220
|
-
else
|
|
221
|
-
values = sample_rows.map { |r| r.fetch(name) { r[name.to_sym] } }
|
|
222
|
-
Inference.node_for(name, values)
|
|
223
|
-
end
|
|
291
|
+
declared.delete(name) || Inference.node_for(name, sample_rows.map { |r| r.fetch(name) { r[name.to_sym] } })
|
|
224
292
|
end
|
|
225
|
-
new(Node.new(name: "schema", repetition: :required, children: nodes))
|
|
293
|
+
new(Node.new(name: "schema", repetition: :required, children: nodes + declared.values))
|
|
226
294
|
end
|
|
227
295
|
|
|
296
|
+
# Type inference behind Schema.infer
|
|
228
297
|
module Inference
|
|
229
298
|
module_function
|
|
230
299
|
|
|
300
|
+
# @param row [Hash, #attributes, #to_h] sampled row
|
|
301
|
+
# @return [Hash] the row as a Hash keyed by field name (String or Symbol keys)
|
|
302
|
+
# @raise [ArgumentError] when the row is positional (an Array), or neither a Hash nor converts
|
|
303
|
+
# to one
|
|
231
304
|
def row_hash(row)
|
|
232
305
|
return row if row.is_a?(Hash)
|
|
306
|
+
raise ArgumentError, "Cannot infer a schema from Array rows (values in schema order); pass a schema" if row.is_a?(Array)
|
|
233
307
|
return row.attributes if row.respond_to?(:attributes)
|
|
234
308
|
return row.to_h if row.respond_to?(:to_h)
|
|
235
309
|
raise ArgumentError, "Cannot infer a schema from a #{row.class}"
|
|
236
310
|
end
|
|
237
311
|
|
|
312
|
+
# Hashes become groups, Arrays become 3-level LIST groups, other values a primitive column;
|
|
313
|
+
# nil values are ignored and an all-nil column becomes a string.
|
|
314
|
+
#
|
|
315
|
+
# @param name [String] field name
|
|
316
|
+
# @param values [Array<Object>] the field's values across the sampled rows
|
|
317
|
+
# @return [Node] optional node for the field
|
|
318
|
+
# @raise [ArgumentError] when the values have no common Parquet type
|
|
238
319
|
def node_for(name, values)
|
|
239
320
|
present = values.compact
|
|
240
321
|
return Node.new(name: name, **Types.physical_attributes(:string)) if present.empty?
|
|
@@ -252,6 +333,11 @@ module Herringbone
|
|
|
252
333
|
end
|
|
253
334
|
end
|
|
254
335
|
|
|
336
|
+
# @param name [String] field name, for the error message
|
|
337
|
+
# @param values [Array<Object>] non-nil values of the field
|
|
338
|
+
# @return [Array(Symbol), Array(Symbol, Hash{Symbol => Object})] DSL type, plus its options
|
|
339
|
+
# for types that take some (decimal, timestamp)
|
|
340
|
+
# @raise [ArgumentError] when the values have no common Parquet type
|
|
255
341
|
def scalar_type(name, values)
|
|
256
342
|
all = ->(*classes) { values.all? { |v| classes.any? { |c| v.is_a?(c) } } }
|
|
257
343
|
if all.call(Integer)
|
|
@@ -260,25 +346,26 @@ module Herringbone
|
|
|
260
346
|
[:boolean]
|
|
261
347
|
elsif all.call(Integer, BigDecimal)
|
|
262
348
|
scale = values.map { |v| v.is_a?(BigDecimal) ? v.to_s("F").split(".")[1].to_s.sub(/0+\z/, "").size : 0 }.max
|
|
263
|
-
[:decimal, {
|
|
349
|
+
[:decimal, {precision: 38, scale: scale}]
|
|
264
350
|
elsif all.call(Numeric)
|
|
265
351
|
[:double]
|
|
266
352
|
elsif all.call(String)
|
|
267
|
-
values.all? { |v| v.encoding != Encoding::BINARY && v.valid_encoding? } ? [:string] : [:binary]
|
|
353
|
+
(values.all? { |v| v.encoding != Encoding::BINARY && v.valid_encoding? }) ? [:string] : [:binary]
|
|
268
354
|
elsif all.call(String, Symbol)
|
|
269
355
|
[:string]
|
|
270
356
|
elsif all.call(Time, DateTime)
|
|
271
|
-
[:timestamp, {
|
|
357
|
+
[:timestamp, {unit: :micros}]
|
|
272
358
|
elsif all.call(Date)
|
|
273
359
|
[:date]
|
|
274
360
|
else
|
|
275
361
|
classes = values.map(&:class).uniq
|
|
276
362
|
raise ArgumentError, "Cannot infer a Parquet type for #{name} from #{classes.map(&:name).join(", ")}; " \
|
|
277
|
-
"
|
|
363
|
+
"declare it in a block: Schema.infer(rows) { string :#{name} }"
|
|
278
364
|
end
|
|
279
365
|
end
|
|
280
366
|
end
|
|
281
367
|
|
|
368
|
+
# @param root [Node] root group of the physical tree, with +children+ set
|
|
282
369
|
def initialize(root)
|
|
283
370
|
@root = root
|
|
284
371
|
@columns = []
|
|
@@ -287,6 +374,8 @@ module Herringbone
|
|
|
287
374
|
@fields = root.children.map { |child| build_field(child, 0, 0) }
|
|
288
375
|
end
|
|
289
376
|
|
|
377
|
+
# @return [Array<Format::SchemaElement>] the tree flattened depth-first, root first, as stored
|
|
378
|
+
# in the footer
|
|
290
379
|
def to_elements
|
|
291
380
|
out = []
|
|
292
381
|
walk = lambda do |node, is_root|
|
|
@@ -297,15 +386,21 @@ module Herringbone
|
|
|
297
386
|
out
|
|
298
387
|
end
|
|
299
388
|
|
|
389
|
+
# @param name [String, Symbol] top-level field name
|
|
390
|
+
# @return [Field, nil] the top-level field, or nil when there is none by that name
|
|
300
391
|
def field(name)
|
|
301
392
|
@fields.find { |f| f.name == name.to_s }
|
|
302
393
|
end
|
|
303
394
|
|
|
395
|
+
# @param path [String, Array<String>] dotted path or path components of a leaf column
|
|
396
|
+
# @return [Column, nil] the leaf column, or nil when there is none at that path
|
|
304
397
|
def column(path)
|
|
305
398
|
path = path.split(".") if path.is_a?(String)
|
|
306
399
|
@columns.find { |c| c.path == path }
|
|
307
400
|
end
|
|
308
401
|
|
|
402
|
+
# @return [String] one line per node, indented by depth: repetition, physical type, name and
|
|
403
|
+
# annotation
|
|
309
404
|
def inspect
|
|
310
405
|
lines = []
|
|
311
406
|
walk = lambda do |node, depth|
|
|
@@ -314,8 +409,13 @@ module Herringbone
|
|
|
314
409
|
else
|
|
315
410
|
"group"
|
|
316
411
|
end
|
|
317
|
-
|
|
318
|
-
|
|
412
|
+
# A logical type this version does not know decodes as an empty union
|
|
413
|
+
kind = node.logical_type&.kind
|
|
414
|
+
ann = if kind then kind.first.to_s.upcase
|
|
415
|
+
elsif node.logical_type then "UNKNOWN LOGICAL TYPE"
|
|
416
|
+
else Format::ConvertedType::NAMES[node.converted_type]
|
|
417
|
+
end
|
|
418
|
+
lines << "#{" " * depth}#{node.repetition} #{desc} #{node.name}#{" (#{ann})" if ann}"
|
|
319
419
|
node.children&.each { |c| walk.call(c, depth + 1) }
|
|
320
420
|
end
|
|
321
421
|
@root.children.each { |c| walk.call(c, 0) }
|
|
@@ -325,9 +425,16 @@ module Herringbone
|
|
|
325
425
|
|
|
326
426
|
private
|
|
327
427
|
|
|
428
|
+
# Appends a Column for every leaf under +node+, depth-first. A non-required node adds one
|
|
429
|
+
# definition level and a repeated node one repetition level.
|
|
430
|
+
#
|
|
431
|
+
# @param node [Node] group whose descendants are walked
|
|
432
|
+
# @param max_def [Integer] definition level of +node+ itself
|
|
433
|
+
# @param max_rep [Integer] repetition level of +node+ itself
|
|
434
|
+
# @return [void]
|
|
328
435
|
def collect_columns(node, max_def, max_rep)
|
|
329
436
|
node.children.each do |child|
|
|
330
|
-
d = child.repetition == :required ? max_def : max_def + 1
|
|
437
|
+
d = (child.repetition == :required) ? max_def : max_def + 1
|
|
331
438
|
r = child.repeated? ? max_rep + 1 : max_rep
|
|
332
439
|
if child.leaf?
|
|
333
440
|
@columns << Column.new(@columns.size, child, d, r)
|
|
@@ -337,6 +444,15 @@ module Herringbone
|
|
|
337
444
|
end
|
|
338
445
|
end
|
|
339
446
|
|
|
447
|
+
# Builds the logical Field for +node+, recognizing LIST and MAP annotations (including the
|
|
448
|
+
# legacy 2-level forms) and bare repeated fields.
|
|
449
|
+
#
|
|
450
|
+
# @param node [Node] physical node to interpret
|
|
451
|
+
# @param parent_def [Integer] definition level of the enclosing field
|
|
452
|
+
# @param parent_rep [Integer] repetition level of the enclosing field
|
|
453
|
+
# @param as_element [Boolean] true when +node+ is itself the repeated node of a list, so its
|
|
454
|
+
# repetition is already accounted for and it is not optional
|
|
455
|
+
# @return [Field]
|
|
340
456
|
def build_field(node, parent_def, parent_rep, as_element: false)
|
|
341
457
|
if node.repeated? && !as_element
|
|
342
458
|
# A repeated field outside of a LIST/MAP annotation is a list of required elements
|
|
@@ -371,8 +487,10 @@ module Herringbone
|
|
|
371
487
|
rr = parent_rep + 1
|
|
372
488
|
key = build_field(kv.children[0], rd, rr)
|
|
373
489
|
# A map without values is read as a list of its keys, like Arrow does
|
|
374
|
-
|
|
375
|
-
|
|
490
|
+
unless kv.children[1]
|
|
491
|
+
return Field.new(kind: :list, name: node.name, optional: optional, def_level: d,
|
|
492
|
+
rep_level: rr, item_def: rd, element: key, node: node)
|
|
493
|
+
end
|
|
376
494
|
value = build_field(kv.children[1], rd, rr)
|
|
377
495
|
Field.new(kind: :map, name: node.name, optional: optional, def_level: d,
|
|
378
496
|
rep_level: rr, item_def: rd, key: key, value: value, node: node)
|
|
@@ -384,6 +502,11 @@ module Herringbone
|
|
|
384
502
|
end
|
|
385
503
|
|
|
386
504
|
# Backward-compatibility rules from the Parquet LogicalTypes spec
|
|
505
|
+
#
|
|
506
|
+
# @param list_node [Node] LIST-annotated group
|
|
507
|
+
# @param repeated [Node] its only (repeated) child
|
|
508
|
+
# @return [Boolean] true when +repeated+ is the element itself (2-level list), false when its
|
|
509
|
+
# single child is the element (standard 3-level list)
|
|
387
510
|
def list_element_is_repeated_node?(list_node, repeated)
|
|
388
511
|
return true if repeated.leaf?
|
|
389
512
|
return true if repeated.children.size > 1
|
|
@@ -407,12 +530,16 @@ module Herringbone
|
|
|
407
530
|
#
|
|
408
531
|
# Fields are nullable unless null: false is given.
|
|
409
532
|
class Builder
|
|
533
|
+
# @return [Array<Node>] fields declared so far, in declaration order
|
|
410
534
|
attr_reader :nodes
|
|
411
535
|
|
|
536
|
+
# Starts with no fields; Schema.define evaluates the block against this builder
|
|
412
537
|
def initialize
|
|
413
538
|
@nodes = []
|
|
414
539
|
end
|
|
415
540
|
|
|
541
|
+
# Types that need no options; each gets a DSL method taking a name and the options of #column,
|
|
542
|
+
# e.g. +int64 :id, null: false+
|
|
416
543
|
PRIMITIVES = %i[
|
|
417
544
|
boolean int8 int16 int32 int64 uint8 uint16 uint32 uint64 float double float16
|
|
418
545
|
string binary json bson uuid date int96
|
|
@@ -422,22 +549,90 @@ module Herringbone
|
|
|
422
549
|
define_method(t) { |name, **opts| column(name, t, **opts) }
|
|
423
550
|
end
|
|
424
551
|
|
|
552
|
+
# A TIME column; millis are stored as INT32, micros and nanos as INT64.
|
|
553
|
+
#
|
|
554
|
+
# @param name [String, Symbol] field name
|
|
555
|
+
# @param unit [Symbol] +:millis+, +:micros+ or +:nanos+
|
|
556
|
+
# @param utc [Boolean] the isAdjustedToUTC flag of the logical type
|
|
557
|
+
# @param opts [Hash{Symbol => Object}] options of #column
|
|
558
|
+
# @option opts [Boolean] :null (true) whether the field is nullable
|
|
559
|
+
# @option opts [Integer, nil] :field_id (nil) field id to store in the schema
|
|
560
|
+
# @return [Node] the added node
|
|
561
|
+
# @raise [ArgumentError] for an unknown unit or a duplicate name
|
|
425
562
|
def time(name, unit: :micros, utc: true, **opts) = column(name, :time, unit: unit, utc: utc, **opts)
|
|
563
|
+
# A TIMESTAMP column, stored as INT64.
|
|
564
|
+
#
|
|
565
|
+
# @param name [String, Symbol] field name
|
|
566
|
+
# @param unit [Symbol] +:millis+, +:micros+ or +:nanos+
|
|
567
|
+
# @param utc [Boolean] the isAdjustedToUTC flag: true for instants, false for local date-times
|
|
568
|
+
# @param opts [Hash{Symbol => Object}] options of #column
|
|
569
|
+
# @option opts [Boolean] :null (true) whether the field is nullable
|
|
570
|
+
# @option opts [Integer, nil] :field_id (nil) field id to store in the schema
|
|
571
|
+
# @return [Node] the added node
|
|
572
|
+
# @raise [ArgumentError] for an unknown unit or a duplicate name
|
|
426
573
|
def timestamp(name, unit: :micros, utc: true, **opts) = column(name, :timestamp, unit: unit, utc: utc, **opts)
|
|
574
|
+
# A DECIMAL column. Up to 9 digits are stored as INT32, up to 18 as INT64, more as a
|
|
575
|
+
# FIXED_LEN_BYTE_ARRAY of the minimal width, unless +physical:+ says otherwise.
|
|
576
|
+
#
|
|
577
|
+
# @param name [String, Symbol] field name
|
|
578
|
+
# @param precision [Integer] total number of digits
|
|
579
|
+
# @param scale [Integer] digits after the decimal point, between 0 and +precision+
|
|
580
|
+
# @param opts [Hash{Symbol => Object}] options of #column
|
|
581
|
+
# @option opts [Symbol] :physical storage: +:int32+, +:int64+, +:binary+ or +:fixed+
|
|
582
|
+
# @option opts [Boolean] :null (true) whether the field is nullable
|
|
583
|
+
# @option opts [Integer, nil] :field_id (nil) field id to store in the schema
|
|
584
|
+
# @return [Node] the added node
|
|
585
|
+
# @raise [ArgumentError] for an invalid precision or scale, or a duplicate name
|
|
427
586
|
def decimal(name, precision:, scale: 0, **opts) = column(name, :decimal, precision: precision, scale: scale, **opts)
|
|
587
|
+
# A FIXED_LEN_BYTE_ARRAY column without annotation.
|
|
588
|
+
#
|
|
589
|
+
# @param name [String, Symbol] field name
|
|
590
|
+
# @param length [Integer] byte width of every value
|
|
591
|
+
# @param opts [Hash{Symbol => Object}] options of #column
|
|
592
|
+
# @option opts [Boolean] :null (true) whether the field is nullable
|
|
593
|
+
# @option opts [Integer, nil] :field_id (nil) field id to store in the schema
|
|
594
|
+
# @return [Node] the added node
|
|
595
|
+
# @raise [ArgumentError] for a duplicate name
|
|
428
596
|
def fixed(name, length:, **opts) = column(name, :fixed, length: length, **opts)
|
|
429
597
|
|
|
598
|
+
# A group of named fields.
|
|
599
|
+
#
|
|
600
|
+
# @param name [String, Symbol] field name
|
|
601
|
+
# @param null [Boolean] whether the struct as a whole may be null
|
|
602
|
+
# @param field_id [Integer, nil] field id to store in the schema
|
|
603
|
+
# @yield block evaluated with +instance_eval+ on a new Builder, declaring the struct's fields
|
|
604
|
+
# @return [Node] the added group node
|
|
605
|
+
# @raise [ArgumentError] when the block declares no fields (or is missing), or for a duplicate name
|
|
430
606
|
def struct(name, null: true, field_id: nil, &block)
|
|
431
|
-
|
|
432
|
-
inner.instance_eval(&block)
|
|
433
|
-
raise ArgumentError, "struct #{name} has no fields" if inner.nodes.empty?
|
|
434
|
-
add Node.new(name: name, repetition: rep(null), children: inner.nodes, field_id: field_id)
|
|
607
|
+
add Node.new(name: name, repetition: rep(null), children: struct_fields(name, &block), field_id: field_id)
|
|
435
608
|
end
|
|
436
609
|
|
|
437
610
|
# list :tags, :string
|
|
438
611
|
# list :tags, :string, element_null: false
|
|
439
612
|
# list :points, :struct do double :x; double :y; end
|
|
440
613
|
# list :matrix do list :element, :double end (block declares the element)
|
|
614
|
+
#
|
|
615
|
+
# Written as the standard 3-level LIST: an optional (or required) group holding a repeated
|
|
616
|
+
# group "list" whose single child is "element".
|
|
617
|
+
#
|
|
618
|
+
# @param name [String, Symbol] field name
|
|
619
|
+
# @param type [Symbol, String, nil] element type (any #column type, or +:struct+ with a block);
|
|
620
|
+
# nil when the block declares the element
|
|
621
|
+
# @param null [Boolean] whether the list itself may be null
|
|
622
|
+
# @param element_null [Boolean] whether elements may be null; ignored when the block declares
|
|
623
|
+
# the element, which then keeps its own +null:+
|
|
624
|
+
# @param field_id [Integer, nil] field id to store in the schema
|
|
625
|
+
# @param type_opts [Hash{Symbol => Object}] options of the element type (+precision:+, +unit:+...)
|
|
626
|
+
# @option type_opts [Integer] :precision decimal precision
|
|
627
|
+
# @option type_opts [Integer] :scale decimal scale
|
|
628
|
+
# @option type_opts [Symbol] :unit time or timestamp unit
|
|
629
|
+
# @option type_opts [Boolean] :utc time or timestamp UTC adjustment
|
|
630
|
+
# @option type_opts [Integer] :length FIXED_LEN_BYTE_ARRAY width
|
|
631
|
+
# @yield block evaluated with +instance_eval+ on a new Builder: the struct's fields for a
|
|
632
|
+
# +:struct+ element, or exactly one field (renamed to "element") when +type+ is nil
|
|
633
|
+
# @return [Node] the added LIST group node
|
|
634
|
+
# @raise [ArgumentError] when neither a type nor a block is given, the block declares the wrong
|
|
635
|
+
# number of fields, or for a duplicate name
|
|
441
636
|
def list(name, type = nil, null: true, element_null: true, field_id: nil, **type_opts, &block)
|
|
442
637
|
element = element_node("element", type, element_null, type_opts, &block)
|
|
443
638
|
repeated = Node.new(name: "list", repetition: :repeated, children: [element])
|
|
@@ -448,6 +643,27 @@ module Herringbone
|
|
|
448
643
|
|
|
449
644
|
# map :scores, :string, :double
|
|
450
645
|
# map :things, :string, :struct do int32 :a end
|
|
646
|
+
#
|
|
647
|
+
# Written as the standard MAP: a group holding a repeated group "key_value" with a required
|
|
648
|
+
# "key" and a "value". Keys are never null.
|
|
649
|
+
#
|
|
650
|
+
# @param name [String, Symbol] field name
|
|
651
|
+
# @param key_type [Symbol, String] key type (a primitive #column type)
|
|
652
|
+
# @param value_type [Symbol, String, nil] value type (any #column type, or +:struct+ with a
|
|
653
|
+
# block); nil when the block declares the value
|
|
654
|
+
# @param null [Boolean] whether the map itself may be null
|
|
655
|
+
# @param value_null [Boolean] whether values may be null; ignored when the block declares the value
|
|
656
|
+
# @param field_id [Integer, nil] field id to store in the schema
|
|
657
|
+
# @param type_opts [Hash{Symbol => Object}] options of the value type (+precision:+, +unit:+...)
|
|
658
|
+
# @option type_opts [Integer] :precision decimal precision
|
|
659
|
+
# @option type_opts [Integer] :scale decimal scale
|
|
660
|
+
# @option type_opts [Symbol] :unit time or timestamp unit
|
|
661
|
+
# @option type_opts [Boolean] :utc time or timestamp UTC adjustment
|
|
662
|
+
# @option type_opts [Integer] :length FIXED_LEN_BYTE_ARRAY width
|
|
663
|
+
# @yield block evaluated with +instance_eval+ on a new Builder: the struct's fields for a
|
|
664
|
+
# +:struct+ value, or exactly one field (renamed to "value") when +value_type+ is nil
|
|
665
|
+
# @return [Node] the added MAP group node
|
|
666
|
+
# @raise [ArgumentError] for an invalid key or value declaration, or a duplicate name
|
|
451
667
|
def map(name, key_type, value_type = nil, null: true, value_null: true, field_id: nil, **type_opts, &block)
|
|
452
668
|
key = element_node("key", key_type, false, {})
|
|
453
669
|
value = element_node("value", value_type, value_null, type_opts, &block)
|
|
@@ -458,6 +674,23 @@ module Herringbone
|
|
|
458
674
|
end
|
|
459
675
|
|
|
460
676
|
# Generic column declaration: column :name, :int32, null: false
|
|
677
|
+
#
|
|
678
|
+
# @param name [String, Symbol] field name
|
|
679
|
+
# @param type [Symbol, String] DSL type: one of PRIMITIVES, or +:time+, +:timestamp+,
|
|
680
|
+
# +:decimal+, +:fixed+, +:enum+ with their options
|
|
681
|
+
# @param null [Boolean] whether the field is nullable (optional rather than required)
|
|
682
|
+
# @param field_id [Integer, nil] field id to store in the schema
|
|
683
|
+
# @param opts [Hash{Symbol => Object}] type options
|
|
684
|
+
# @option opts [Integer] :precision decimal precision (required for +:decimal+)
|
|
685
|
+
# @option opts [Integer] :scale (0) decimal scale
|
|
686
|
+
# @option opts [Symbol] :physical decimal storage: +:int32+, +:int64+, +:binary+ or +:fixed+
|
|
687
|
+
# @option opts [Symbol] :unit (:micros) time or timestamp unit
|
|
688
|
+
# @option opts [Boolean] :utc (true) time or timestamp UTC adjustment
|
|
689
|
+
# @option opts [Integer] :length FIXED_LEN_BYTE_ARRAY width (required for +:fixed+)
|
|
690
|
+
# @option opts [Array<String>, Hash] :values allowed values, see #enum
|
|
691
|
+
# @option opts [Boolean] :parquet_enum (false) ENUM instead of STRING annotation, see #enum
|
|
692
|
+
# @return [Node] the added leaf node
|
|
693
|
+
# @raise [ArgumentError] for an unknown type, invalid type options, or a duplicate name
|
|
461
694
|
def column(name, type, null: true, field_id: nil, **opts)
|
|
462
695
|
add leaf_node(name, type, rep(null), opts).tap { |n| n.field_id = field_id }
|
|
463
696
|
end
|
|
@@ -466,61 +699,45 @@ module Herringbone
|
|
|
466
699
|
# (note that pyarrow and pandas then read it as binary). values: restricts what can be
|
|
467
700
|
# written: an Array of labels, or a Hash like Rails' `Order.statuses` (label => stored value),
|
|
468
701
|
# in which case both labels and stored values are accepted and the label is written.
|
|
702
|
+
#
|
|
703
|
+
# @param name [String, Symbol] field name
|
|
704
|
+
# @param values [Array<String, Symbol>, Hash{String, Symbol => Object}, nil] allowed labels,
|
|
705
|
+
# or label => stored value; nil allows any string
|
|
706
|
+
# @param parquet_enum [Boolean] annotate as ENUM instead of STRING
|
|
707
|
+
# @param opts [Hash{Symbol => Object}] options of #column
|
|
708
|
+
# @option opts [Boolean] :null (true) whether the field is nullable
|
|
709
|
+
# @option opts [Integer, nil] :field_id (nil) field id to store in the schema
|
|
710
|
+
# @return [Node] the added leaf node
|
|
711
|
+
# @raise [ArgumentError] for a duplicate name
|
|
469
712
|
def enum(name, values: nil, parquet_enum: false, **opts)
|
|
470
713
|
column(name, :enum, values: values, parquet_enum: parquet_enum, **opts)
|
|
471
714
|
end
|
|
472
715
|
|
|
473
|
-
# Declares fields from a Hash spec, see Schema.define
|
|
474
|
-
def fields(spec)
|
|
475
|
-
spec.each { |name, desc| declare(name, desc) }
|
|
476
|
-
self
|
|
477
|
-
end
|
|
478
|
-
|
|
479
|
-
# Declares one field from a Hash spec value
|
|
480
|
-
def declare(name, desc, null: true)
|
|
481
|
-
case desc
|
|
482
|
-
when Symbol, String
|
|
483
|
-
column(name, desc.to_sym, null: null)
|
|
484
|
-
when Array
|
|
485
|
-
raise ArgumentError, "List spec for #{name} must have exactly one element type" unless desc.size == 1
|
|
486
|
-
element = desc.first
|
|
487
|
-
list(name, null: null) { declare(:element, element) }
|
|
488
|
-
when Hash
|
|
489
|
-
opts = desc.to_h { |k, v| [k.to_sym, v] }
|
|
490
|
-
type = opts.delete(:type)
|
|
491
|
-
null = opts.delete(:null) { null }
|
|
492
|
-
# A Hash without :type is a struct; its keys (other than null:) are the fields
|
|
493
|
-
return struct(name, null: null) { fields(opts) } if type.nil?
|
|
494
|
-
case type.to_sym
|
|
495
|
-
when :list
|
|
496
|
-
of = opts.delete(:of) or raise ArgumentError, "list #{name} needs of:"
|
|
497
|
-
element_null = opts.delete(:element_null) { true }
|
|
498
|
-
list(name, null: null, **opts) { declare(:element, of, null: element_null) }
|
|
499
|
-
when :map
|
|
500
|
-
key = opts.delete(:key) { :string }
|
|
501
|
-
value = opts.delete(:value) or raise ArgumentError, "map #{name} needs value:"
|
|
502
|
-
value_null = opts.delete(:value_null) { true }
|
|
503
|
-
map(name, key, null: null, **opts) { declare(:value, value, null: value_null) }
|
|
504
|
-
when :struct
|
|
505
|
-
struct(name, null: null) { fields(opts.fetch(:fields)) }
|
|
506
|
-
else
|
|
507
|
-
column(name, type.to_sym, null: null, **opts)
|
|
508
|
-
end
|
|
509
|
-
else
|
|
510
|
-
raise ArgumentError, "Cannot declare #{name} from #{desc.inspect}"
|
|
511
|
-
end
|
|
512
|
-
end
|
|
513
|
-
|
|
514
716
|
private
|
|
515
717
|
|
|
718
|
+
# @param node [Node] node to append to #nodes
|
|
719
|
+
# @return [Node] +node+
|
|
720
|
+
# @raise [ArgumentError] when a field of the same name was already declared
|
|
516
721
|
def add(node)
|
|
517
722
|
raise ArgumentError, "Duplicate field #{node.name}" if @nodes.any? { |n| n.name == node.name }
|
|
518
723
|
@nodes << node
|
|
519
724
|
node
|
|
520
725
|
end
|
|
521
726
|
|
|
727
|
+
# @param nullable [Boolean] whether the field may be null
|
|
728
|
+
# @return [Symbol] +:optional+ or +:required+
|
|
522
729
|
def rep(nullable) = nullable ? :optional : :required
|
|
523
730
|
|
|
731
|
+
# Builds the element of a list, or the key or value of a map.
|
|
732
|
+
#
|
|
733
|
+
# @param name [String] node name ("element", "key" or "value")
|
|
734
|
+
# @param type [Symbol, String, nil] DSL type, +:struct+, or nil to take the field the block declares
|
|
735
|
+
# @param nullable [Boolean] whether the node may be null (not applied to a block-declared field)
|
|
736
|
+
# @param type_opts [Hash{Symbol => Object}] type options passed to Types.physical_attributes
|
|
737
|
+
# @yield block evaluated with +instance_eval+ on a new Builder
|
|
738
|
+
# @return [Node] the element node, not added to #nodes
|
|
739
|
+
# @raise [ArgumentError] when the block is missing, declares the wrong number of fields, or
|
|
740
|
+
# declares no fields for a +:struct+
|
|
524
741
|
def element_node(name, type, nullable, type_opts, &block)
|
|
525
742
|
if type.nil?
|
|
526
743
|
raise ArgumentError, "Give an element type or a block declaring the element" unless block
|
|
@@ -531,15 +748,34 @@ module Herringbone
|
|
|
531
748
|
node.name = name
|
|
532
749
|
node
|
|
533
750
|
elsif type.to_sym == :struct
|
|
534
|
-
|
|
535
|
-
inner = Builder.new
|
|
536
|
-
inner.instance_eval(&block)
|
|
537
|
-
Node.new(name: name, repetition: rep(nullable), children: inner.nodes)
|
|
751
|
+
Node.new(name: name, repetition: rep(nullable), children: struct_fields(name, &block))
|
|
538
752
|
else
|
|
539
753
|
leaf_node(name, type, rep(nullable), type_opts)
|
|
540
754
|
end
|
|
541
755
|
end
|
|
542
756
|
|
|
757
|
+
# Evaluates a struct's block on a new Builder; Parquet groups need at least one child.
|
|
758
|
+
#
|
|
759
|
+
# @param name [String, Symbol] struct name, for the error message
|
|
760
|
+
# @yield block evaluated with +instance_eval+ on a new Builder, declaring the struct's fields
|
|
761
|
+
# @return [Array<Node>] the declared fields
|
|
762
|
+
# @raise [ArgumentError] when the block is missing or declares no fields
|
|
763
|
+
def struct_fields(name, &block)
|
|
764
|
+
raise ArgumentError, "struct #{name} needs a block declaring its fields" unless block
|
|
765
|
+
inner = Builder.new
|
|
766
|
+
inner.instance_eval(&block)
|
|
767
|
+
raise ArgumentError, "struct #{name} has no fields" if inner.nodes.empty?
|
|
768
|
+
inner.nodes
|
|
769
|
+
end
|
|
770
|
+
|
|
771
|
+
# @param name [String, Symbol] field name
|
|
772
|
+
# @param type [Symbol, String] DSL type
|
|
773
|
+
# @param repetition [Symbol] +:optional+ or +:required+
|
|
774
|
+
# @param opts [Hash{Symbol => Object}] type options; +:values+ is taken out as the enum values
|
|
775
|
+
# and the rest go to Types.physical_attributes
|
|
776
|
+
# @option opts [Array<String>, Hash] :values allowed values of a string/enum column
|
|
777
|
+
# @return [Node] leaf node, not added to #nodes
|
|
778
|
+
# @raise [ArgumentError] for an unknown type or invalid type options
|
|
543
779
|
def leaf_node(name, type, repetition, opts)
|
|
544
780
|
opts = opts.dup
|
|
545
781
|
values = opts.delete(:values)
|