herringbone 0.2.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.
@@ -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
@@ -177,6 +252,10 @@ module Herringbone
177
252
  end
178
253
 
179
254
  # Builds a schema with the DSL, see Schema::Builder
255
+ #
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
180
259
  def self.define(&block)
181
260
  builder = Builder.new
182
261
  builder.instance_eval(&block) if block
@@ -184,6 +263,7 @@ module Herringbone
184
263
  new(Node.new(name: "schema", repetition: :required, children: builder.nodes))
185
264
  end
186
265
 
266
+ # Number of leading rows Schema.infer looks at
187
267
  INFER_SAMPLE = 1000
188
268
 
189
269
  # Infers a schema from the first 1000 rows (Hashes, or objects responding to #attributes or
@@ -193,6 +273,13 @@ module Herringbone
193
273
  # Hash -> struct, Array -> list. Columns that are nil in every sampled row become strings.
194
274
  # Fields declared in the block (Builder DSL) replace the inferred ones of the same name:
195
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
196
283
  def self.infer(rows, &block)
197
284
  sample_rows = rows.first(INFER_SAMPLE).map { |r| Inference.row_hash(r) }
198
285
  raise ArgumentError, "Cannot infer a schema from zero rows" if sample_rows.empty?
@@ -206,16 +293,29 @@ module Herringbone
206
293
  new(Node.new(name: "schema", repetition: :required, children: nodes + declared.values))
207
294
  end
208
295
 
296
+ # Type inference behind Schema.infer
209
297
  module Inference
210
298
  module_function
211
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
212
304
  def row_hash(row)
213
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)
214
307
  return row.attributes if row.respond_to?(:attributes)
215
308
  return row.to_h if row.respond_to?(:to_h)
216
309
  raise ArgumentError, "Cannot infer a schema from a #{row.class}"
217
310
  end
218
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
219
319
  def node_for(name, values)
220
320
  present = values.compact
221
321
  return Node.new(name: name, **Types.physical_attributes(:string)) if present.empty?
@@ -233,6 +333,11 @@ module Herringbone
233
333
  end
234
334
  end
235
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
236
341
  def scalar_type(name, values)
237
342
  all = ->(*classes) { values.all? { |v| classes.any? { |c| v.is_a?(c) } } }
238
343
  if all.call(Integer)
@@ -241,15 +346,15 @@ module Herringbone
241
346
  [:boolean]
242
347
  elsif all.call(Integer, BigDecimal)
243
348
  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 }]
349
+ [:decimal, {precision: 38, scale: scale}]
245
350
  elsif all.call(Numeric)
246
351
  [:double]
247
352
  elsif all.call(String)
248
- 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]
249
354
  elsif all.call(String, Symbol)
250
355
  [:string]
251
356
  elsif all.call(Time, DateTime)
252
- [:timestamp, { unit: :micros }]
357
+ [:timestamp, {unit: :micros}]
253
358
  elsif all.call(Date)
254
359
  [:date]
255
360
  else
@@ -260,6 +365,7 @@ module Herringbone
260
365
  end
261
366
  end
262
367
 
368
+ # @param root [Node] root group of the physical tree, with +children+ set
263
369
  def initialize(root)
264
370
  @root = root
265
371
  @columns = []
@@ -268,6 +374,8 @@ module Herringbone
268
374
  @fields = root.children.map { |child| build_field(child, 0, 0) }
269
375
  end
270
376
 
377
+ # @return [Array<Format::SchemaElement>] the tree flattened depth-first, root first, as stored
378
+ # in the footer
271
379
  def to_elements
272
380
  out = []
273
381
  walk = lambda do |node, is_root|
@@ -278,15 +386,21 @@ module Herringbone
278
386
  out
279
387
  end
280
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
281
391
  def field(name)
282
392
  @fields.find { |f| f.name == name.to_s }
283
393
  end
284
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
285
397
  def column(path)
286
398
  path = path.split(".") if path.is_a?(String)
287
399
  @columns.find { |c| c.path == path }
288
400
  end
289
401
 
402
+ # @return [String] one line per node, indented by depth: repetition, physical type, name and
403
+ # annotation
290
404
  def inspect
291
405
  lines = []
292
406
  walk = lambda do |node, depth|
@@ -301,7 +415,7 @@ module Herringbone
301
415
  elsif node.logical_type then "UNKNOWN LOGICAL TYPE"
302
416
  else Format::ConvertedType::NAMES[node.converted_type]
303
417
  end
304
- lines << "#{" " * depth}#{node.repetition} #{desc} #{node.name}#{ann ? " (#{ann})" : ""}"
418
+ lines << "#{" " * depth}#{node.repetition} #{desc} #{node.name}#{" (#{ann})" if ann}"
305
419
  node.children&.each { |c| walk.call(c, depth + 1) }
306
420
  end
307
421
  @root.children.each { |c| walk.call(c, 0) }
@@ -311,9 +425,16 @@ module Herringbone
311
425
 
312
426
  private
313
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]
314
435
  def collect_columns(node, max_def, max_rep)
315
436
  node.children.each do |child|
316
- d = child.repetition == :required ? max_def : max_def + 1
437
+ d = (child.repetition == :required) ? max_def : max_def + 1
317
438
  r = child.repeated? ? max_rep + 1 : max_rep
318
439
  if child.leaf?
319
440
  @columns << Column.new(@columns.size, child, d, r)
@@ -323,6 +444,15 @@ module Herringbone
323
444
  end
324
445
  end
325
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]
326
456
  def build_field(node, parent_def, parent_rep, as_element: false)
327
457
  if node.repeated? && !as_element
328
458
  # A repeated field outside of a LIST/MAP annotation is a list of required elements
@@ -357,8 +487,10 @@ module Herringbone
357
487
  rr = parent_rep + 1
358
488
  key = build_field(kv.children[0], rd, rr)
359
489
  # 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]
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
362
494
  value = build_field(kv.children[1], rd, rr)
363
495
  Field.new(kind: :map, name: node.name, optional: optional, def_level: d,
364
496
  rep_level: rr, item_def: rd, key: key, value: value, node: node)
@@ -370,6 +502,11 @@ module Herringbone
370
502
  end
371
503
 
372
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)
373
510
  def list_element_is_repeated_node?(list_node, repeated)
374
511
  return true if repeated.leaf?
375
512
  return true if repeated.children.size > 1
@@ -393,12 +530,16 @@ module Herringbone
393
530
  #
394
531
  # Fields are nullable unless null: false is given.
395
532
  class Builder
533
+ # @return [Array<Node>] fields declared so far, in declaration order
396
534
  attr_reader :nodes
397
535
 
536
+ # Starts with no fields; Schema.define evaluates the block against this builder
398
537
  def initialize
399
538
  @nodes = []
400
539
  end
401
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+
402
543
  PRIMITIVES = %i[
403
544
  boolean int8 int16 int32 int64 uint8 uint16 uint32 uint64 float double float16
404
545
  string binary json bson uuid date int96
@@ -408,22 +549,90 @@ module Herringbone
408
549
  define_method(t) { |name, **opts| column(name, t, **opts) }
409
550
  end
410
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
411
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
412
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
413
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
414
596
  def fixed(name, length:, **opts) = column(name, :fixed, length: length, **opts)
415
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
416
606
  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)
607
+ add Node.new(name: name, repetition: rep(null), children: struct_fields(name, &block), field_id: field_id)
421
608
  end
422
609
 
423
610
  # list :tags, :string
424
611
  # list :tags, :string, element_null: false
425
612
  # list :points, :struct do double :x; double :y; end
426
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
427
636
  def list(name, type = nil, null: true, element_null: true, field_id: nil, **type_opts, &block)
428
637
  element = element_node("element", type, element_null, type_opts, &block)
429
638
  repeated = Node.new(name: "list", repetition: :repeated, children: [element])
@@ -434,6 +643,27 @@ module Herringbone
434
643
 
435
644
  # map :scores, :string, :double
436
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
437
667
  def map(name, key_type, value_type = nil, null: true, value_null: true, field_id: nil, **type_opts, &block)
438
668
  key = element_node("key", key_type, false, {})
439
669
  value = element_node("value", value_type, value_null, type_opts, &block)
@@ -444,6 +674,23 @@ module Herringbone
444
674
  end
445
675
 
446
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
447
694
  def column(name, type, null: true, field_id: nil, **opts)
448
695
  add leaf_node(name, type, rep(null), opts).tap { |n| n.field_id = field_id }
449
696
  end
@@ -452,20 +699,45 @@ module Herringbone
452
699
  # (note that pyarrow and pandas then read it as binary). values: restricts what can be
453
700
  # written: an Array of labels, or a Hash like Rails' `Order.statuses` (label => stored value),
454
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
455
712
  def enum(name, values: nil, parquet_enum: false, **opts)
456
713
  column(name, :enum, values: values, parquet_enum: parquet_enum, **opts)
457
714
  end
458
715
 
459
716
  private
460
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
461
721
  def add(node)
462
722
  raise ArgumentError, "Duplicate field #{node.name}" if @nodes.any? { |n| n.name == node.name }
463
723
  @nodes << node
464
724
  node
465
725
  end
466
726
 
727
+ # @param nullable [Boolean] whether the field may be null
728
+ # @return [Symbol] +:optional+ or +:required+
467
729
  def rep(nullable) = nullable ? :optional : :required
468
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+
469
741
  def element_node(name, type, nullable, type_opts, &block)
470
742
  if type.nil?
471
743
  raise ArgumentError, "Give an element type or a block declaring the element" unless block
@@ -476,15 +748,34 @@ module Herringbone
476
748
  node.name = name
477
749
  node
478
750
  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)
751
+ Node.new(name: name, repetition: rep(nullable), children: struct_fields(name, &block))
483
752
  else
484
753
  leaf_node(name, type, rep(nullable), type_opts)
485
754
  end
486
755
  end
487
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
488
779
  def leaf_node(name, type, repetition, opts)
489
780
  opts = opts.dup
490
781
  values = opts.delete(:values)