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.
@@ -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 (see Schema::Builder), from a Hash, or both:
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
- # Hash values: a type Symbol; [element] for a list; a Hash with :type (plus DSL options such as
185
- # null:, precision:, of: for lists, key:/value: for maps); or a Hash without :type for a struct.
186
- def self.define(spec = nil, &block)
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
- # Accepts a Schema or a Hash spec for Schema.define
195
- def self.coerce(schema)
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 sample rows (Hashes, or objects responding to #attributes or #to_h).
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
- # +types+ overrides inference for some columns, using the Hash spec of Schema.define:
209
- # Schema.infer(rows, types: { payload: :json, status: { type: :enum, values: %w[a b] } })
210
- def self.infer(rows, sample: 1000, types: {})
211
- sample_rows = rows.first(sample).map { |r| Inference.row_hash(r) }
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 = types.to_h { |k, v| [k.to_s, v] }
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
- if overrides.key?(name)
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, { precision: 38, scale: scale }]
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, { unit: :micros }]
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
- "pass types: { #{name}: ... }"
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
- ann = node.logical_type ? node.logical_type.kind.first.to_s.upcase : Format::ConvertedType::NAMES[node.converted_type]
318
- lines << "#{" " * depth}#{node.repetition} #{desc} #{node.name}#{ann ? " (#{ann})" : ""}"
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
- return Field.new(kind: :list, name: node.name, optional: optional, def_level: d,
375
- 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
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
- inner = Builder.new
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
- raise ArgumentError, "A struct element needs a block" unless block
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)