herringbone 0.1.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.
@@ -0,0 +1,551 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Herringbone
4
+ # A Parquet schema. Holds two views of the same tree:
5
+ #
6
+ # * the physical tree of Schema::Node (what is stored in the footer as SchemaElements),
7
+ # whose leaves are the column chunks (Schema::Column), and
8
+ # * the logical tree of Schema::Field, which interprets LIST/MAP annotations and is
9
+ # what rows are assembled from (and shredded into) when reading and writing.
10
+ class Schema
11
+ REPETITIONS = {
12
+ Format::Repetition::REQUIRED => :required,
13
+ Format::Repetition::OPTIONAL => :optional,
14
+ Format::Repetition::REPEATED => :repeated
15
+ }.freeze
16
+
17
+ # A node of the physical schema tree.
18
+ class Node
19
+ attr_accessor :name, :repetition, :type, :type_length, :converted_type, :logical_type,
20
+ :scale, :precision, :field_id, :children, :parent
21
+ # Allowed values of a string/enum column (writer-side validation only, not stored in the file)
22
+ attr_accessor :enum_values
23
+
24
+ def initialize(name:, repetition: :optional, type: nil, type_length: nil, converted_type: nil,
25
+ logical_type: nil, scale: nil, precision: nil, field_id: nil, children: nil, enum_values: nil)
26
+ @name = name.to_s
27
+ @enum_values = enum_values
28
+ @repetition = repetition
29
+ @type = type
30
+ @type_length = type_length
31
+ @converted_type = converted_type
32
+ @logical_type = logical_type
33
+ @scale = scale
34
+ @precision = precision
35
+ @field_id = field_id
36
+ @children = children
37
+ @children&.each { |c| c.parent = self }
38
+ end
39
+
40
+ def group? = !@children.nil?
41
+ def leaf? = @children.nil?
42
+ def repeated? = @repetition == :repeated
43
+ def optional? = @repetition == :optional
44
+
45
+ def logical_kind
46
+ @logical_type&.kind&.first
47
+ end
48
+
49
+ def list_annotated?
50
+ logical_kind == :list || @converted_type == Format::ConvertedType::LIST
51
+ end
52
+
53
+ def map_annotated?
54
+ logical_kind == :map || @converted_type == Format::ConvertedType::MAP ||
55
+ @converted_type == Format::ConvertedType::MAP_KEY_VALUE
56
+ end
57
+
58
+ def path
59
+ parent&.parent ? parent.path + [name] : [name]
60
+ end
61
+
62
+ def self.from_element(el)
63
+ new(
64
+ name: el.name,
65
+ repetition: REPETITIONS.fetch(el.repetition_type || Format::Repetition::REQUIRED),
66
+ type: el.num_children ? nil : el.type,
67
+ type_length: el.type_length,
68
+ converted_type: el.converted_type,
69
+ logical_type: el.logical_type,
70
+ scale: el.scale,
71
+ precision: el.precision,
72
+ field_id: el.field_id
73
+ )
74
+ end
75
+
76
+ def to_element(root: false)
77
+ Format::SchemaElement.new(
78
+ name: name,
79
+ repetition_type: root ? nil : REPETITIONS.key(repetition),
80
+ type: type,
81
+ type_length: type_length,
82
+ num_children: group? ? children.size : nil,
83
+ converted_type: converted_type,
84
+ logical_type: logical_type,
85
+ scale: scale,
86
+ precision: precision,
87
+ field_id: field_id
88
+ )
89
+ end
90
+ end
91
+
92
+ # A leaf column (one column chunk per row group)
93
+ class Column
94
+ attr_reader :index, :node, :path, :max_definition_level, :max_repetition_level
95
+
96
+ def initialize(index, node, max_def, max_rep)
97
+ @index = index
98
+ @node = node
99
+ @path = node.path
100
+ @max_definition_level = max_def
101
+ @max_repetition_level = max_rep
102
+ end
103
+
104
+ def type = @node.type
105
+ def type_length = @node.type_length
106
+ def dotted_path = @path.join(".")
107
+
108
+ def converter
109
+ @converter ||= Types.reader_for(@node)
110
+ end
111
+
112
+ def encoder
113
+ @encoder ||= Types.writer_for(@node)
114
+ end
115
+ end
116
+
117
+ # A node of the logical tree.
118
+ # kind: :leaf, :struct, :list or :map
119
+ # optional: whether this field itself may be null
120
+ # def_level: definition level at which this field counts as present
121
+ # For :list and :map:
122
+ # rep_level: repetition level of the repeated node
123
+ # item_def: definition level at which the repeated node has at least one entry
124
+ # element: element Field (lists); key/value Fields (maps, value may be nil)
125
+ class Field
126
+ attr_reader :kind, :name, :optional, :def_level, :rep_level, :item_def,
127
+ :children, :element, :key, :value, :column, :leaves, :node
128
+
129
+ def initialize(kind:, name:, optional:, def_level:, node:, rep_level: nil, item_def: nil,
130
+ children: nil, element: nil, key: nil, value: nil, column: nil)
131
+ @kind = kind
132
+ @name = name
133
+ @optional = optional
134
+ @def_level = def_level
135
+ @node = node
136
+ @rep_level = rep_level
137
+ @item_def = item_def
138
+ @children = children
139
+ @element = element
140
+ @key = key
141
+ @value = value
142
+ @column = column
143
+ @leaves = case kind
144
+ when :leaf then [column]
145
+ when :struct then children.flat_map(&:leaves)
146
+ when :list then element.leaves
147
+ when :map then key.leaves + (value ? value.leaves : [])
148
+ end
149
+ end
150
+
151
+ def first_leaf = @leaves.first
152
+ def leaf? = @kind == :leaf
153
+
154
+ def children_by_name
155
+ @children_by_name ||= @children.to_h { |c| [c.name, c] }
156
+ end
157
+ end
158
+
159
+ attr_reader :root, :columns, :fields
160
+
161
+ def self.from_elements(elements)
162
+ raise FormatError, "Empty schema" if elements.empty?
163
+ pos = 0
164
+ build = lambda do
165
+ el = elements[pos] or raise FormatError, "Schema is truncated"
166
+ pos += 1
167
+ node = Node.from_element(el)
168
+ if el.num_children
169
+ node.children = Array.new(el.num_children) { build.call }
170
+ node.children.each { |c| c.parent = node }
171
+ end
172
+ node
173
+ end
174
+ root = build.call
175
+ root.children ||= []
176
+ new(root)
177
+ end
178
+
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})
183
+ #
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)
187
+ builder = Builder.new
188
+ builder.fields(spec) if spec
189
+ builder.instance_eval(&block) if block
190
+ raise ArgumentError, "A schema needs at least one field" if builder.nodes.empty?
191
+ new(Node.new(name: "schema", repetition: :required, children: builder.nodes))
192
+ end
193
+
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
202
+
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,
205
+ # String/Symbol -> string (binary if not valid UTF-8), true/false -> boolean,
206
+ # Time/DateTime -> timestamp(micros), Date -> date, BigDecimal -> decimal(38, max scale seen),
207
+ # 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) }
212
+ raise ArgumentError, "Cannot infer a schema from zero rows" if sample_rows.empty?
213
+ overrides = types.to_h { |k, v| [k.to_s, v] }
214
+ names = sample_rows.flat_map { |r| r.keys.map(&:to_s) }.uniq
215
+ 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
224
+ end
225
+ new(Node.new(name: "schema", repetition: :required, children: nodes))
226
+ end
227
+
228
+ module Inference
229
+ module_function
230
+
231
+ def row_hash(row)
232
+ return row if row.is_a?(Hash)
233
+ return row.attributes if row.respond_to?(:attributes)
234
+ return row.to_h if row.respond_to?(:to_h)
235
+ raise ArgumentError, "Cannot infer a schema from a #{row.class}"
236
+ end
237
+
238
+ def node_for(name, values)
239
+ present = values.compact
240
+ return Node.new(name: name, **Types.physical_attributes(:string)) if present.empty?
241
+ if present.all? { |v| v.is_a?(Hash) }
242
+ keys = present.flat_map { |h| h.keys.map(&:to_s) }.uniq
243
+ children = keys.map { |k| node_for(k, present.map { |h| h.fetch(k) { h[k.to_sym] } }) }
244
+ Node.new(name: name, children: children)
245
+ elsif present.all? { |v| v.is_a?(Array) }
246
+ element = node_for("element", present.flatten(1))
247
+ Node.new(name: name, children: [Node.new(name: "list", repetition: :repeated, children: [element])],
248
+ logical_type: Format::LogicalType.new(list: Format::ListType.new), converted_type: Format::ConvertedType::LIST)
249
+ else
250
+ type, opts = scalar_type(name, present)
251
+ Node.new(name: name, **Types.physical_attributes(type, **(opts || {})))
252
+ end
253
+ end
254
+
255
+ def scalar_type(name, values)
256
+ all = ->(*classes) { values.all? { |v| classes.any? { |c| v.is_a?(c) } } }
257
+ if all.call(Integer)
258
+ [:int64]
259
+ elsif all.call(true.class, false.class)
260
+ [:boolean]
261
+ elsif all.call(Integer, BigDecimal)
262
+ 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 }]
264
+ elsif all.call(Numeric)
265
+ [:double]
266
+ elsif all.call(String)
267
+ values.all? { |v| v.encoding != Encoding::BINARY && v.valid_encoding? } ? [:string] : [:binary]
268
+ elsif all.call(String, Symbol)
269
+ [:string]
270
+ elsif all.call(Time, DateTime)
271
+ [:timestamp, { unit: :micros }]
272
+ elsif all.call(Date)
273
+ [:date]
274
+ else
275
+ classes = values.map(&:class).uniq
276
+ raise ArgumentError, "Cannot infer a Parquet type for #{name} from #{classes.map(&:name).join(", ")}; " \
277
+ "pass types: { #{name}: ... }"
278
+ end
279
+ end
280
+ end
281
+
282
+ def initialize(root)
283
+ @root = root
284
+ @columns = []
285
+ collect_columns(root, 0, 0)
286
+ @column_by_node = @columns.to_h { |c| [c.node, c] }
287
+ @fields = root.children.map { |child| build_field(child, 0, 0) }
288
+ end
289
+
290
+ def to_elements
291
+ out = []
292
+ walk = lambda do |node, is_root|
293
+ out << node.to_element(root: is_root)
294
+ node.children&.each { |c| walk.call(c, false) }
295
+ end
296
+ walk.call(@root, true)
297
+ out
298
+ end
299
+
300
+ def field(name)
301
+ @fields.find { |f| f.name == name.to_s }
302
+ end
303
+
304
+ def column(path)
305
+ path = path.split(".") if path.is_a?(String)
306
+ @columns.find { |c| c.path == path }
307
+ end
308
+
309
+ def inspect
310
+ lines = []
311
+ walk = lambda do |node, depth|
312
+ desc = if node.leaf?
313
+ [Format::Type::NAMES[node.type], node.type_length && "(#{node.type_length})"].compact.join
314
+ else
315
+ "group"
316
+ 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})" : ""}"
319
+ node.children&.each { |c| walk.call(c, depth + 1) }
320
+ end
321
+ @root.children.each { |c| walk.call(c, 0) }
322
+ "#<Herringbone::Schema\n#{lines.join("\n")}>"
323
+ end
324
+ alias_method :to_s, :inspect
325
+
326
+ private
327
+
328
+ def collect_columns(node, max_def, max_rep)
329
+ node.children.each do |child|
330
+ d = child.repetition == :required ? max_def : max_def + 1
331
+ r = child.repeated? ? max_rep + 1 : max_rep
332
+ if child.leaf?
333
+ @columns << Column.new(@columns.size, child, d, r)
334
+ else
335
+ collect_columns(child, d, r)
336
+ end
337
+ end
338
+ end
339
+
340
+ def build_field(node, parent_def, parent_rep, as_element: false)
341
+ if node.repeated? && !as_element
342
+ # A repeated field outside of a LIST/MAP annotation is a list of required elements
343
+ d = parent_def + 1
344
+ r = parent_rep + 1
345
+ element = build_field(node, d, r, as_element: true)
346
+ return Field.new(kind: :list, name: node.name, optional: false, def_level: parent_def,
347
+ rep_level: r, item_def: d, element: element, node: node)
348
+ end
349
+
350
+ optional = node.optional? && !as_element
351
+ d = optional ? parent_def + 1 : parent_def
352
+
353
+ if node.leaf?
354
+ Field.new(kind: :leaf, name: node.name, optional: optional, def_level: d,
355
+ column: @column_by_node.fetch(node), node: node)
356
+ elsif node.list_annotated? && node.children.size == 1 && node.children[0].repeated?
357
+ repeated = node.children[0]
358
+ rd = d + 1
359
+ rr = parent_rep + 1
360
+ element = if list_element_is_repeated_node?(node, repeated)
361
+ build_field(repeated, rd, rr, as_element: true)
362
+ else
363
+ build_field(repeated.children[0], rd, rr)
364
+ end
365
+ Field.new(kind: :list, name: node.name, optional: optional, def_level: d,
366
+ rep_level: rr, item_def: rd, element: element, node: node)
367
+ elsif node.map_annotated? && node.children.size == 1 && node.children[0].repeated? &&
368
+ node.children[0].group? && node.children[0].children.size.between?(1, 2)
369
+ kv = node.children[0]
370
+ rd = d + 1
371
+ rr = parent_rep + 1
372
+ key = build_field(kv.children[0], rd, rr)
373
+ # 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]
376
+ value = build_field(kv.children[1], rd, rr)
377
+ Field.new(kind: :map, name: node.name, optional: optional, def_level: d,
378
+ rep_level: rr, item_def: rd, key: key, value: value, node: node)
379
+ else
380
+ children = node.children.map { |c| build_field(c, d, parent_rep) }
381
+ Field.new(kind: :struct, name: node.name, optional: optional, def_level: d,
382
+ children: children, node: node)
383
+ end
384
+ end
385
+
386
+ # Backward-compatibility rules from the Parquet LogicalTypes spec
387
+ def list_element_is_repeated_node?(list_node, repeated)
388
+ return true if repeated.leaf?
389
+ return true if repeated.children.size > 1
390
+ return true if repeated.name == "array" || repeated.name == "#{list_node.name}_tuple"
391
+ false
392
+ end
393
+
394
+ # DSL for defining schemas:
395
+ #
396
+ # Herringbone::Schema.define do
397
+ # int64 :id, null: false
398
+ # string :name
399
+ # list :tags, :string
400
+ # map :scores, :string, :double
401
+ # struct :address do
402
+ # string :city
403
+ # end
404
+ # decimal :price, precision: 12, scale: 2
405
+ # timestamp :created_at, unit: :micros
406
+ # end
407
+ #
408
+ # Fields are nullable unless null: false is given.
409
+ class Builder
410
+ attr_reader :nodes
411
+
412
+ def initialize
413
+ @nodes = []
414
+ end
415
+
416
+ PRIMITIVES = %i[
417
+ boolean int8 int16 int32 int64 uint8 uint16 uint32 uint64 float double float16
418
+ string binary json bson uuid date int96
419
+ ].freeze
420
+
421
+ PRIMITIVES.each do |t|
422
+ define_method(t) { |name, **opts| column(name, t, **opts) }
423
+ end
424
+
425
+ def time(name, unit: :micros, utc: true, **opts) = column(name, :time, unit: unit, utc: utc, **opts)
426
+ def timestamp(name, unit: :micros, utc: true, **opts) = column(name, :timestamp, unit: unit, utc: utc, **opts)
427
+ def decimal(name, precision:, scale: 0, **opts) = column(name, :decimal, precision: precision, scale: scale, **opts)
428
+ def fixed(name, length:, **opts) = column(name, :fixed, length: length, **opts)
429
+
430
+ 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)
435
+ end
436
+
437
+ # list :tags, :string
438
+ # list :tags, :string, element_null: false
439
+ # list :points, :struct do double :x; double :y; end
440
+ # list :matrix do list :element, :double end (block declares the element)
441
+ def list(name, type = nil, null: true, element_null: true, field_id: nil, **type_opts, &block)
442
+ element = element_node("element", type, element_null, type_opts, &block)
443
+ repeated = Node.new(name: "list", repetition: :repeated, children: [element])
444
+ add Node.new(name: name, repetition: rep(null), children: [repeated],
445
+ logical_type: Format::LogicalType.new(list: Format::ListType.new),
446
+ converted_type: Format::ConvertedType::LIST, field_id: field_id)
447
+ end
448
+
449
+ # map :scores, :string, :double
450
+ # map :things, :string, :struct do int32 :a end
451
+ def map(name, key_type, value_type = nil, null: true, value_null: true, field_id: nil, **type_opts, &block)
452
+ key = element_node("key", key_type, false, {})
453
+ value = element_node("value", value_type, value_null, type_opts, &block)
454
+ kv = Node.new(name: "key_value", repetition: :repeated, children: [key, value])
455
+ add Node.new(name: name, repetition: rep(null), children: [kv],
456
+ logical_type: Format::LogicalType.new(map: Format::MapType.new),
457
+ converted_type: Format::ConvertedType::MAP, field_id: field_id)
458
+ end
459
+
460
+ # Generic column declaration: column :name, :int32, null: false
461
+ def column(name, type, null: true, field_id: nil, **opts)
462
+ add leaf_node(name, type, rep(null), opts).tap { |n| n.field_id = field_id }
463
+ end
464
+
465
+ # A string column. With parquet_enum: true it carries the ENUM annotation instead of STRING
466
+ # (note that pyarrow and pandas then read it as binary). values: restricts what can be
467
+ # written: an Array of labels, or a Hash like Rails' `Order.statuses` (label => stored value),
468
+ # in which case both labels and stored values are accepted and the label is written.
469
+ def enum(name, values: nil, parquet_enum: false, **opts)
470
+ column(name, :enum, values: values, parquet_enum: parquet_enum, **opts)
471
+ end
472
+
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
+ private
515
+
516
+ def add(node)
517
+ raise ArgumentError, "Duplicate field #{node.name}" if @nodes.any? { |n| n.name == node.name }
518
+ @nodes << node
519
+ node
520
+ end
521
+
522
+ def rep(nullable) = nullable ? :optional : :required
523
+
524
+ def element_node(name, type, nullable, type_opts, &block)
525
+ if type.nil?
526
+ raise ArgumentError, "Give an element type or a block declaring the element" unless block
527
+ inner = Builder.new
528
+ inner.instance_eval(&block)
529
+ raise ArgumentError, "The element block must declare exactly one field" unless inner.nodes.size == 1
530
+ node = inner.nodes.first
531
+ node.name = name
532
+ node
533
+ 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)
538
+ else
539
+ leaf_node(name, type, rep(nullable), type_opts)
540
+ end
541
+ end
542
+
543
+ def leaf_node(name, type, repetition, opts)
544
+ opts = opts.dup
545
+ values = opts.delete(:values)
546
+ attrs = Types.physical_attributes(type.to_sym, **opts)
547
+ Node.new(name: name, repetition: repetition, enum_values: values, **attrs)
548
+ end
549
+ end
550
+ end
551
+ end