herringbone 0.2.0 → 0.4.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.
@@ -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,17 +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
 
108
- def converter
109
- @converter ||= Types.reader_for(@node)
110
- end
111
-
112
- def encoder
113
- @encoder ||= Types.writer_for(@node)
114
- end
150
+ # Types.reader_for of the node. Not memoized: a Schema holding Procs could not be made
151
+ # Ractor-shareable.
152
+ #
153
+ # @return [Proc, Method, nil] converts a physical value into its Ruby value, or nil when the physical
154
+ # value is used as-is
155
+ def converter = Types.reader_for(@node)
156
+
157
+ # Types.writer_for of the node. Not memoized, like #converter.
158
+ #
159
+ # @return [Proc, Method] converts a Ruby value into the physical value to store, raising
160
+ # ArgumentError / TypeError / RangeError for values that do not fit
161
+ def encoder = Types.writer_for(@node)
115
162
  end
116
163
 
117
164
  # A node of the logical tree.
@@ -123,9 +170,23 @@ module Herringbone
123
170
  # item_def: definition level at which the repeated node has at least one entry
124
171
  # element: element Field (lists); key/value Fields (maps, value may be nil)
125
172
  class Field
173
+ # As described for the class; +children+ are the Fields of a :struct, +column+ is the Column of a :leaf,
174
+ # +leaves+ are all Columns under this field, and +node+ is the physical Node it was built from
126
175
  attr_reader :kind, :name, :optional, :def_level, :rep_level, :item_def,
127
176
  :children, :element, :key, :value, :column, :leaves, :node
128
177
 
178
+ # @param kind [Symbol] +:leaf+, +:struct+, +:list+ or +:map+
179
+ # @param name [String] field name
180
+ # @param optional [Boolean] whether this field itself may be null
181
+ # @param def_level [Integer] definition level at which this field counts as present
182
+ # @param node [Node] physical node the field was built from
183
+ # @param rep_level [Integer, nil] repetition level of the repeated node (lists and maps)
184
+ # @param item_def [Integer, nil] definition level at which a list or map has an entry
185
+ # @param children [Array<Field>, nil] fields of a struct
186
+ # @param element [Field, nil] element of a list
187
+ # @param key [Field, nil] key of a map
188
+ # @param value [Field, nil] value of a map
189
+ # @param column [Column, nil] column of a leaf
129
190
  def initialize(kind:, name:, optional:, def_level:, node:, rep_level: nil, item_def: nil,
130
191
  children: nil, element: nil, key: nil, value: nil, column: nil)
131
192
  @kind = kind
@@ -148,16 +209,25 @@ module Herringbone
148
209
  end
149
210
  end
150
211
 
212
+ # @return [Column] first leaf column under this field
151
213
  def first_leaf = @leaves.first
214
+ # @return [Boolean] true for a primitive (+:leaf+) field
152
215
  def leaf? = @kind == :leaf
153
216
 
154
- def children_by_name
155
- @children_by_name ||= @children.to_h { |c| [c.name, c] }
156
- end
217
+ # Lookup of a struct's children; only valid for +:struct+ fields.
218
+ #
219
+ # @return [Hash{String => Field}] child fields by name
220
+ def children_by_name = @children.to_h { |c| [c.name, c] }
157
221
  end
158
222
 
223
+ # The root Node, the leaf Columns in file order, and the top-level Fields of the logical tree
159
224
  attr_reader :root, :columns, :fields
160
225
 
226
+ # Rebuilds the tree from the depth-first list of SchemaElements stored in the footer.
227
+ #
228
+ # @param elements [Array<Format::SchemaElement>] footer schema, root first
229
+ # @return [Schema]
230
+ # @raise [FormatError] when the list is empty or ends before all declared children are read
161
231
  def self.from_elements(elements)
162
232
  raise FormatError, "Empty schema" if elements.empty?
163
233
  pos = 0
@@ -177,6 +247,10 @@ module Herringbone
177
247
  end
178
248
 
179
249
  # Builds a schema with the DSL, see Schema::Builder
250
+ #
251
+ # @yield block evaluated with +instance_eval+ on a Builder, declaring the top-level fields
252
+ # @return [Schema]
253
+ # @raise [ArgumentError] when no field is declared, or a declaration is invalid
180
254
  def self.define(&block)
181
255
  builder = Builder.new
182
256
  builder.instance_eval(&block) if block
@@ -184,6 +258,7 @@ module Herringbone
184
258
  new(Node.new(name: "schema", repetition: :required, children: builder.nodes))
185
259
  end
186
260
 
261
+ # Number of leading rows Schema.infer looks at
187
262
  INFER_SAMPLE = 1000
188
263
 
189
264
  # Infers a schema from the first 1000 rows (Hashes, or objects responding to #attributes or
@@ -193,6 +268,13 @@ module Herringbone
193
268
  # Hash -> struct, Array -> list. Columns that are nil in every sampled row become strings.
194
269
  # Fields declared in the block (Builder DSL) replace the inferred ones of the same name:
195
270
  # Schema.infer(rows) { json :payload }
271
+ #
272
+ # @param rows [Enumerable<Hash, Object>] rows to sample; only the first INFER_SAMPLE are read
273
+ # @yield optional block evaluated with +instance_eval+ on a Builder, declaring fields that
274
+ # replace inferred ones (or are added after them)
275
+ # @return [Schema]
276
+ # @raise [ArgumentError] when there are no rows, a row is not Hash-like, or a column mixes
277
+ # values that map to no single Parquet type
196
278
  def self.infer(rows, &block)
197
279
  sample_rows = rows.first(INFER_SAMPLE).map { |r| Inference.row_hash(r) }
198
280
  raise ArgumentError, "Cannot infer a schema from zero rows" if sample_rows.empty?
@@ -206,16 +288,29 @@ module Herringbone
206
288
  new(Node.new(name: "schema", repetition: :required, children: nodes + declared.values))
207
289
  end
208
290
 
291
+ # Type inference behind Schema.infer
209
292
  module Inference
210
293
  module_function
211
294
 
295
+ # @param row [Hash, #attributes, #to_h] sampled row
296
+ # @return [Hash] the row as a Hash keyed by field name (String or Symbol keys)
297
+ # @raise [ArgumentError] when the row is positional (an Array), or neither a Hash nor converts
298
+ # to one
212
299
  def row_hash(row)
213
300
  return row if row.is_a?(Hash)
301
+ raise ArgumentError, "Cannot infer a schema from Array rows (values in schema order); pass a schema" if row.is_a?(Array)
214
302
  return row.attributes if row.respond_to?(:attributes)
215
303
  return row.to_h if row.respond_to?(:to_h)
216
304
  raise ArgumentError, "Cannot infer a schema from a #{row.class}"
217
305
  end
218
306
 
307
+ # Hashes become groups, Arrays become 3-level LIST groups, other values a primitive column;
308
+ # nil values are ignored and an all-nil column becomes a string.
309
+ #
310
+ # @param name [String] field name
311
+ # @param values [Array<Object>] the field's values across the sampled rows
312
+ # @return [Node] optional node for the field
313
+ # @raise [ArgumentError] when the values have no common Parquet type
219
314
  def node_for(name, values)
220
315
  present = values.compact
221
316
  return Node.new(name: name, **Types.physical_attributes(:string)) if present.empty?
@@ -233,6 +328,11 @@ module Herringbone
233
328
  end
234
329
  end
235
330
 
331
+ # @param name [String] field name, for the error message
332
+ # @param values [Array<Object>] non-nil values of the field
333
+ # @return [Array(Symbol), Array(Symbol, Hash{Symbol => Object})] DSL type, plus its options
334
+ # for types that take some (decimal, timestamp)
335
+ # @raise [ArgumentError] when the values have no common Parquet type
236
336
  def scalar_type(name, values)
237
337
  all = ->(*classes) { values.all? { |v| classes.any? { |c| v.is_a?(c) } } }
238
338
  if all.call(Integer)
@@ -241,15 +341,15 @@ module Herringbone
241
341
  [:boolean]
242
342
  elsif all.call(Integer, BigDecimal)
243
343
  scale = values.map { |v| v.is_a?(BigDecimal) ? v.to_s("F").split(".")[1].to_s.sub(/0+\z/, "").size : 0 }.max
244
- [:decimal, { precision: 38, scale: scale }]
344
+ [:decimal, {precision: 38, scale: scale}]
245
345
  elsif all.call(Numeric)
246
346
  [:double]
247
347
  elsif all.call(String)
248
- values.all? { |v| v.encoding != Encoding::BINARY && v.valid_encoding? } ? [:string] : [:binary]
348
+ (values.all? { |v| v.encoding != Encoding::BINARY && v.valid_encoding? }) ? [:string] : [:binary]
249
349
  elsif all.call(String, Symbol)
250
350
  [:string]
251
351
  elsif all.call(Time, DateTime)
252
- [:timestamp, { unit: :micros }]
352
+ [:timestamp, {unit: :micros}]
253
353
  elsif all.call(Date)
254
354
  [:date]
255
355
  else
@@ -260,6 +360,7 @@ module Herringbone
260
360
  end
261
361
  end
262
362
 
363
+ # @param root [Node] root group of the physical tree, with +children+ set
263
364
  def initialize(root)
264
365
  @root = root
265
366
  @columns = []
@@ -268,6 +369,8 @@ module Herringbone
268
369
  @fields = root.children.map { |child| build_field(child, 0, 0) }
269
370
  end
270
371
 
372
+ # @return [Array<Format::SchemaElement>] the tree flattened depth-first, root first, as stored
373
+ # in the footer
271
374
  def to_elements
272
375
  out = []
273
376
  walk = lambda do |node, is_root|
@@ -278,15 +381,21 @@ module Herringbone
278
381
  out
279
382
  end
280
383
 
384
+ # @param name [String, Symbol] top-level field name
385
+ # @return [Field, nil] the top-level field, or nil when there is none by that name
281
386
  def field(name)
282
387
  @fields.find { |f| f.name == name.to_s }
283
388
  end
284
389
 
390
+ # @param path [String, Array<String>] dotted path or path components of a leaf column
391
+ # @return [Column, nil] the leaf column, or nil when there is none at that path
285
392
  def column(path)
286
393
  path = path.split(".") if path.is_a?(String)
287
394
  @columns.find { |c| c.path == path }
288
395
  end
289
396
 
397
+ # @return [String] one line per node, indented by depth: repetition, physical type, name and
398
+ # annotation
290
399
  def inspect
291
400
  lines = []
292
401
  walk = lambda do |node, depth|
@@ -301,7 +410,7 @@ module Herringbone
301
410
  elsif node.logical_type then "UNKNOWN LOGICAL TYPE"
302
411
  else Format::ConvertedType::NAMES[node.converted_type]
303
412
  end
304
- lines << "#{" " * depth}#{node.repetition} #{desc} #{node.name}#{ann ? " (#{ann})" : ""}"
413
+ lines << "#{" " * depth}#{node.repetition} #{desc} #{node.name}#{" (#{ann})" if ann}"
305
414
  node.children&.each { |c| walk.call(c, depth + 1) }
306
415
  end
307
416
  @root.children.each { |c| walk.call(c, 0) }
@@ -311,9 +420,16 @@ module Herringbone
311
420
 
312
421
  private
313
422
 
423
+ # Appends a Column for every leaf under +node+, depth-first. A non-required node adds one
424
+ # definition level and a repeated node one repetition level.
425
+ #
426
+ # @param node [Node] group whose descendants are walked
427
+ # @param max_def [Integer] definition level of +node+ itself
428
+ # @param max_rep [Integer] repetition level of +node+ itself
429
+ # @return [void]
314
430
  def collect_columns(node, max_def, max_rep)
315
431
  node.children.each do |child|
316
- d = child.repetition == :required ? max_def : max_def + 1
432
+ d = (child.repetition == :required) ? max_def : max_def + 1
317
433
  r = child.repeated? ? max_rep + 1 : max_rep
318
434
  if child.leaf?
319
435
  @columns << Column.new(@columns.size, child, d, r)
@@ -323,6 +439,15 @@ module Herringbone
323
439
  end
324
440
  end
325
441
 
442
+ # Builds the logical Field for +node+, recognizing LIST and MAP annotations (including the
443
+ # legacy 2-level forms) and bare repeated fields.
444
+ #
445
+ # @param node [Node] physical node to interpret
446
+ # @param parent_def [Integer] definition level of the enclosing field
447
+ # @param parent_rep [Integer] repetition level of the enclosing field
448
+ # @param as_element [Boolean] true when +node+ is itself the repeated node of a list, so its
449
+ # repetition is already accounted for and it is not optional
450
+ # @return [Field]
326
451
  def build_field(node, parent_def, parent_rep, as_element: false)
327
452
  if node.repeated? && !as_element
328
453
  # A repeated field outside of a LIST/MAP annotation is a list of required elements
@@ -357,8 +482,10 @@ module Herringbone
357
482
  rr = parent_rep + 1
358
483
  key = build_field(kv.children[0], rd, rr)
359
484
  # A map without values is read as a list of its keys, like Arrow does
360
- return Field.new(kind: :list, name: node.name, optional: optional, def_level: d,
361
- rep_level: rr, item_def: rd, element: key, node: node) unless kv.children[1]
485
+ unless kv.children[1]
486
+ return Field.new(kind: :list, name: node.name, optional: optional, def_level: d,
487
+ rep_level: rr, item_def: rd, element: key, node: node)
488
+ end
362
489
  value = build_field(kv.children[1], rd, rr)
363
490
  Field.new(kind: :map, name: node.name, optional: optional, def_level: d,
364
491
  rep_level: rr, item_def: rd, key: key, value: value, node: node)
@@ -370,6 +497,11 @@ module Herringbone
370
497
  end
371
498
 
372
499
  # Backward-compatibility rules from the Parquet LogicalTypes spec
500
+ #
501
+ # @param list_node [Node] LIST-annotated group
502
+ # @param repeated [Node] its only (repeated) child
503
+ # @return [Boolean] true when +repeated+ is the element itself (2-level list), false when its
504
+ # single child is the element (standard 3-level list)
373
505
  def list_element_is_repeated_node?(list_node, repeated)
374
506
  return true if repeated.leaf?
375
507
  return true if repeated.children.size > 1
@@ -393,12 +525,16 @@ module Herringbone
393
525
  #
394
526
  # Fields are nullable unless null: false is given.
395
527
  class Builder
528
+ # @return [Array<Node>] fields declared so far, in declaration order
396
529
  attr_reader :nodes
397
530
 
531
+ # Starts with no fields; Schema.define evaluates the block against this builder
398
532
  def initialize
399
533
  @nodes = []
400
534
  end
401
535
 
536
+ # Types that need no options; each gets a DSL method taking a name and the options of #column,
537
+ # e.g. +int64 :id, null: false+
402
538
  PRIMITIVES = %i[
403
539
  boolean int8 int16 int32 int64 uint8 uint16 uint32 uint64 float double float16
404
540
  string binary json bson uuid date int96
@@ -408,22 +544,90 @@ module Herringbone
408
544
  define_method(t) { |name, **opts| column(name, t, **opts) }
409
545
  end
410
546
 
547
+ # A TIME column; millis are stored as INT32, micros and nanos as INT64.
548
+ #
549
+ # @param name [String, Symbol] field name
550
+ # @param unit [Symbol] +:millis+, +:micros+ or +:nanos+
551
+ # @param utc [Boolean] the isAdjustedToUTC flag of the logical type
552
+ # @param opts [Hash{Symbol => Object}] options of #column
553
+ # @option opts [Boolean] :null (true) whether the field is nullable
554
+ # @option opts [Integer, nil] :field_id (nil) field id to store in the schema
555
+ # @return [Node] the added node
556
+ # @raise [ArgumentError] for an unknown unit or a duplicate name
411
557
  def time(name, unit: :micros, utc: true, **opts) = column(name, :time, unit: unit, utc: utc, **opts)
558
+ # A TIMESTAMP column, stored as INT64.
559
+ #
560
+ # @param name [String, Symbol] field name
561
+ # @param unit [Symbol] +:millis+, +:micros+ or +:nanos+
562
+ # @param utc [Boolean] the isAdjustedToUTC flag: true for instants, false for local date-times
563
+ # @param opts [Hash{Symbol => Object}] options of #column
564
+ # @option opts [Boolean] :null (true) whether the field is nullable
565
+ # @option opts [Integer, nil] :field_id (nil) field id to store in the schema
566
+ # @return [Node] the added node
567
+ # @raise [ArgumentError] for an unknown unit or a duplicate name
412
568
  def timestamp(name, unit: :micros, utc: true, **opts) = column(name, :timestamp, unit: unit, utc: utc, **opts)
569
+ # A DECIMAL column. Up to 9 digits are stored as INT32, up to 18 as INT64, more as a
570
+ # FIXED_LEN_BYTE_ARRAY of the minimal width, unless +physical:+ says otherwise.
571
+ #
572
+ # @param name [String, Symbol] field name
573
+ # @param precision [Integer] total number of digits
574
+ # @param scale [Integer] digits after the decimal point, between 0 and +precision+
575
+ # @param opts [Hash{Symbol => Object}] options of #column
576
+ # @option opts [Symbol] :physical storage: +:int32+, +:int64+, +:binary+ or +:fixed+
577
+ # @option opts [Boolean] :null (true) whether the field is nullable
578
+ # @option opts [Integer, nil] :field_id (nil) field id to store in the schema
579
+ # @return [Node] the added node
580
+ # @raise [ArgumentError] for an invalid precision or scale, or a duplicate name
413
581
  def decimal(name, precision:, scale: 0, **opts) = column(name, :decimal, precision: precision, scale: scale, **opts)
582
+ # A FIXED_LEN_BYTE_ARRAY column without annotation.
583
+ #
584
+ # @param name [String, Symbol] field name
585
+ # @param length [Integer] byte width of every value
586
+ # @param opts [Hash{Symbol => Object}] options of #column
587
+ # @option opts [Boolean] :null (true) whether the field is nullable
588
+ # @option opts [Integer, nil] :field_id (nil) field id to store in the schema
589
+ # @return [Node] the added node
590
+ # @raise [ArgumentError] for a duplicate name
414
591
  def fixed(name, length:, **opts) = column(name, :fixed, length: length, **opts)
415
592
 
593
+ # A group of named fields.
594
+ #
595
+ # @param name [String, Symbol] field name
596
+ # @param null [Boolean] whether the struct as a whole may be null
597
+ # @param field_id [Integer, nil] field id to store in the schema
598
+ # @yield block evaluated with +instance_eval+ on a new Builder, declaring the struct's fields
599
+ # @return [Node] the added group node
600
+ # @raise [ArgumentError] when the block declares no fields (or is missing), or for a duplicate name
416
601
  def struct(name, null: true, field_id: nil, &block)
417
- inner = Builder.new
418
- inner.instance_eval(&block)
419
- raise ArgumentError, "struct #{name} has no fields" if inner.nodes.empty?
420
- add Node.new(name: name, repetition: rep(null), children: inner.nodes, field_id: field_id)
602
+ add Node.new(name: name, repetition: rep(null), children: struct_fields(name, &block), field_id: field_id)
421
603
  end
422
604
 
423
605
  # list :tags, :string
424
606
  # list :tags, :string, element_null: false
425
607
  # list :points, :struct do double :x; double :y; end
426
608
  # list :matrix do list :element, :double end (block declares the element)
609
+ #
610
+ # Written as the standard 3-level LIST: an optional (or required) group holding a repeated
611
+ # group "list" whose single child is "element".
612
+ #
613
+ # @param name [String, Symbol] field name
614
+ # @param type [Symbol, String, nil] element type (any #column type, or +:struct+ with a block);
615
+ # nil when the block declares the element
616
+ # @param null [Boolean] whether the list itself may be null
617
+ # @param element_null [Boolean] whether elements may be null; ignored when the block declares
618
+ # the element, which then keeps its own +null:+
619
+ # @param field_id [Integer, nil] field id to store in the schema
620
+ # @param type_opts [Hash{Symbol => Object}] options of the element type (+precision:+, +unit:+...)
621
+ # @option type_opts [Integer] :precision decimal precision
622
+ # @option type_opts [Integer] :scale decimal scale
623
+ # @option type_opts [Symbol] :unit time or timestamp unit
624
+ # @option type_opts [Boolean] :utc time or timestamp UTC adjustment
625
+ # @option type_opts [Integer] :length FIXED_LEN_BYTE_ARRAY width
626
+ # @yield block evaluated with +instance_eval+ on a new Builder: the struct's fields for a
627
+ # +:struct+ element, or exactly one field (renamed to "element") when +type+ is nil
628
+ # @return [Node] the added LIST group node
629
+ # @raise [ArgumentError] when neither a type nor a block is given, the block declares the wrong
630
+ # number of fields, or for a duplicate name
427
631
  def list(name, type = nil, null: true, element_null: true, field_id: nil, **type_opts, &block)
428
632
  element = element_node("element", type, element_null, type_opts, &block)
429
633
  repeated = Node.new(name: "list", repetition: :repeated, children: [element])
@@ -434,6 +638,27 @@ module Herringbone
434
638
 
435
639
  # map :scores, :string, :double
436
640
  # map :things, :string, :struct do int32 :a end
641
+ #
642
+ # Written as the standard MAP: a group holding a repeated group "key_value" with a required
643
+ # "key" and a "value". Keys are never null.
644
+ #
645
+ # @param name [String, Symbol] field name
646
+ # @param key_type [Symbol, String] key type (a primitive #column type)
647
+ # @param value_type [Symbol, String, nil] value type (any #column type, or +:struct+ with a
648
+ # block); nil when the block declares the value
649
+ # @param null [Boolean] whether the map itself may be null
650
+ # @param value_null [Boolean] whether values may be null; ignored when the block declares the value
651
+ # @param field_id [Integer, nil] field id to store in the schema
652
+ # @param type_opts [Hash{Symbol => Object}] options of the value type (+precision:+, +unit:+...)
653
+ # @option type_opts [Integer] :precision decimal precision
654
+ # @option type_opts [Integer] :scale decimal scale
655
+ # @option type_opts [Symbol] :unit time or timestamp unit
656
+ # @option type_opts [Boolean] :utc time or timestamp UTC adjustment
657
+ # @option type_opts [Integer] :length FIXED_LEN_BYTE_ARRAY width
658
+ # @yield block evaluated with +instance_eval+ on a new Builder: the struct's fields for a
659
+ # +:struct+ value, or exactly one field (renamed to "value") when +value_type+ is nil
660
+ # @return [Node] the added MAP group node
661
+ # @raise [ArgumentError] for an invalid key or value declaration, or a duplicate name
437
662
  def map(name, key_type, value_type = nil, null: true, value_null: true, field_id: nil, **type_opts, &block)
438
663
  key = element_node("key", key_type, false, {})
439
664
  value = element_node("value", value_type, value_null, type_opts, &block)
@@ -444,6 +669,23 @@ module Herringbone
444
669
  end
445
670
 
446
671
  # Generic column declaration: column :name, :int32, null: false
672
+ #
673
+ # @param name [String, Symbol] field name
674
+ # @param type [Symbol, String] DSL type: one of PRIMITIVES, or +:time+, +:timestamp+,
675
+ # +:decimal+, +:fixed+, +:enum+ with their options
676
+ # @param null [Boolean] whether the field is nullable (optional rather than required)
677
+ # @param field_id [Integer, nil] field id to store in the schema
678
+ # @param opts [Hash{Symbol => Object}] type options
679
+ # @option opts [Integer] :precision decimal precision (required for +:decimal+)
680
+ # @option opts [Integer] :scale (0) decimal scale
681
+ # @option opts [Symbol] :physical decimal storage: +:int32+, +:int64+, +:binary+ or +:fixed+
682
+ # @option opts [Symbol] :unit (:micros) time or timestamp unit
683
+ # @option opts [Boolean] :utc (true) time or timestamp UTC adjustment
684
+ # @option opts [Integer] :length FIXED_LEN_BYTE_ARRAY width (required for +:fixed+)
685
+ # @option opts [Array<String>, Hash] :values allowed values, see #enum
686
+ # @option opts [Boolean] :parquet_enum (false) ENUM instead of STRING annotation, see #enum
687
+ # @return [Node] the added leaf node
688
+ # @raise [ArgumentError] for an unknown type, invalid type options, or a duplicate name
447
689
  def column(name, type, null: true, field_id: nil, **opts)
448
690
  add leaf_node(name, type, rep(null), opts).tap { |n| n.field_id = field_id }
449
691
  end
@@ -452,20 +694,45 @@ module Herringbone
452
694
  # (note that pyarrow and pandas then read it as binary). values: restricts what can be
453
695
  # written: an Array of labels, or a Hash like Rails' `Order.statuses` (label => stored value),
454
696
  # in which case both labels and stored values are accepted and the label is written.
697
+ #
698
+ # @param name [String, Symbol] field name
699
+ # @param values [Array<String, Symbol>, Hash{String, Symbol => Object}, nil] allowed labels,
700
+ # or label => stored value; nil allows any string
701
+ # @param parquet_enum [Boolean] annotate as ENUM instead of STRING
702
+ # @param opts [Hash{Symbol => Object}] options of #column
703
+ # @option opts [Boolean] :null (true) whether the field is nullable
704
+ # @option opts [Integer, nil] :field_id (nil) field id to store in the schema
705
+ # @return [Node] the added leaf node
706
+ # @raise [ArgumentError] for a duplicate name
455
707
  def enum(name, values: nil, parquet_enum: false, **opts)
456
708
  column(name, :enum, values: values, parquet_enum: parquet_enum, **opts)
457
709
  end
458
710
 
459
711
  private
460
712
 
713
+ # @param node [Node] node to append to #nodes
714
+ # @return [Node] +node+
715
+ # @raise [ArgumentError] when a field of the same name was already declared
461
716
  def add(node)
462
717
  raise ArgumentError, "Duplicate field #{node.name}" if @nodes.any? { |n| n.name == node.name }
463
718
  @nodes << node
464
719
  node
465
720
  end
466
721
 
722
+ # @param nullable [Boolean] whether the field may be null
723
+ # @return [Symbol] +:optional+ or +:required+
467
724
  def rep(nullable) = nullable ? :optional : :required
468
725
 
726
+ # Builds the element of a list, or the key or value of a map.
727
+ #
728
+ # @param name [String] node name ("element", "key" or "value")
729
+ # @param type [Symbol, String, nil] DSL type, +:struct+, or nil to take the field the block declares
730
+ # @param nullable [Boolean] whether the node may be null (not applied to a block-declared field)
731
+ # @param type_opts [Hash{Symbol => Object}] type options passed to Types.physical_attributes
732
+ # @yield block evaluated with +instance_eval+ on a new Builder
733
+ # @return [Node] the element node, not added to #nodes
734
+ # @raise [ArgumentError] when the block is missing, declares the wrong number of fields, or
735
+ # declares no fields for a +:struct+
469
736
  def element_node(name, type, nullable, type_opts, &block)
470
737
  if type.nil?
471
738
  raise ArgumentError, "Give an element type or a block declaring the element" unless block
@@ -476,15 +743,34 @@ module Herringbone
476
743
  node.name = name
477
744
  node
478
745
  elsif type.to_sym == :struct
479
- raise ArgumentError, "A struct element needs a block" unless block
480
- inner = Builder.new
481
- inner.instance_eval(&block)
482
- Node.new(name: name, repetition: rep(nullable), children: inner.nodes)
746
+ Node.new(name: name, repetition: rep(nullable), children: struct_fields(name, &block))
483
747
  else
484
748
  leaf_node(name, type, rep(nullable), type_opts)
485
749
  end
486
750
  end
487
751
 
752
+ # Evaluates a struct's block on a new Builder; Parquet groups need at least one child.
753
+ #
754
+ # @param name [String, Symbol] struct name, for the error message
755
+ # @yield block evaluated with +instance_eval+ on a new Builder, declaring the struct's fields
756
+ # @return [Array<Node>] the declared fields
757
+ # @raise [ArgumentError] when the block is missing or declares no fields
758
+ def struct_fields(name, &block)
759
+ raise ArgumentError, "struct #{name} needs a block declaring its fields" unless block
760
+ inner = Builder.new
761
+ inner.instance_eval(&block)
762
+ raise ArgumentError, "struct #{name} has no fields" if inner.nodes.empty?
763
+ inner.nodes
764
+ end
765
+
766
+ # @param name [String, Symbol] field name
767
+ # @param type [Symbol, String] DSL type
768
+ # @param repetition [Symbol] +:optional+ or +:required+
769
+ # @param opts [Hash{Symbol => Object}] type options; +:values+ is taken out as the enum values
770
+ # and the rest go to Types.physical_attributes
771
+ # @option opts [Array<String>, Hash] :values allowed values of a string/enum column
772
+ # @return [Node] leaf node, not added to #nodes
773
+ # @raise [ArgumentError] for an unknown type or invalid type options
488
774
  def leaf_node(name, type, repetition, opts)
489
775
  opts = opts.dup
490
776
  values = opts.delete(:values)