herringbone 0.6.1 → 0.8.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.
Files changed (41) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +45 -0
  3. data/MANUAL.md +196 -17
  4. data/README.md +22 -5
  5. data/bin/herringbone +56 -1
  6. data/lib/herringbone/active_record.rb +3 -1
  7. data/lib/herringbone/bloom_filter.rb +2 -0
  8. data/lib/herringbone/byte_values.rb +2 -0
  9. data/lib/herringbone/codecs/lz4.rb +9 -4
  10. data/lib/herringbone/codecs/lzo.rb +2 -0
  11. data/lib/herringbone/codecs/snappy.rb +48 -6
  12. data/lib/herringbone/combiner.rb +426 -0
  13. data/lib/herringbone/compression.rb +76 -13
  14. data/lib/herringbone/encodings/delta.rb +4 -0
  15. data/lib/herringbone/encodings/plain.rb +2 -0
  16. data/lib/herringbone/encodings/rle.rb +2 -0
  17. data/lib/herringbone/encryption.rb +2 -0
  18. data/lib/herringbone/inferring_writer.rb +1 -1
  19. data/lib/herringbone/inspector.rb +29 -5
  20. data/lib/herringbone/io_buffer_support.rb +2 -0
  21. data/lib/herringbone/reader/bloom_filters.rb +1 -0
  22. data/lib/herringbone/reader/chunk_copier.rb +177 -0
  23. data/lib/herringbone/reader/column_chunk_reader.rb +6 -2
  24. data/lib/herringbone/reader/column_cursor.rb +2 -0
  25. data/lib/herringbone/reader/numo.rb +4 -0
  26. data/lib/herringbone/reader/page_stream.rb +2 -0
  27. data/lib/herringbone/reader/scan.rb +2 -0
  28. data/lib/herringbone/reader.rb +19 -6
  29. data/lib/herringbone/redaction/rewriter.rb +51 -157
  30. data/lib/herringbone/redaction.rb +25 -15
  31. data/lib/herringbone/restricted_readable_io.rb +85 -0
  32. data/lib/herringbone/schema/merge.rb +355 -0
  33. data/lib/herringbone/schema.rb +175 -12
  34. data/lib/herringbone/thrift.rb +49 -10
  35. data/lib/herringbone/types.rb +22 -12
  36. data/lib/herringbone/version.rb +1 -1
  37. data/lib/herringbone/visualizer.rb +2 -0
  38. data/lib/herringbone/writer.rb +58 -22
  39. data/lib/herringbone/xxhash.rb +2 -0
  40. data/lib/herringbone.rb +96 -9
  41. metadata +6 -2
@@ -0,0 +1,355 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Herringbone
4
+ class Schema
5
+ # The union or intersection of two schemas, behind Schema#union and Schema#intersect. Fields
6
+ # are matched by name at every level of nesting, and the type of a field both schemas have is
7
+ # widened to one that holds the values of both without loss. Fields that do not fit are
8
+ # collected rather than raised one by one, so a single IncompatibleSchema names them all.
9
+ #
10
+ # Leaf types are compared as Arrays like +[:int, 32, true]+ or +[:timestamp, :micros, false]+,
11
+ # so the same type spelled with a logical type in one file and a converted type in another
12
+ # still matches.
13
+ #
14
+ # @api private
15
+ class Merge
16
+ # Shorthand for Format::Type
17
+ T = Format::Type
18
+ # Shorthand for Format::ConvertedType
19
+ C = Format::ConvertedType
20
+
21
+ # Converted integer annotations => [bit width, signed]
22
+ INT_CONVERTED = {
23
+ C::INT_8 => [8, true], C::INT_16 => [16, true], C::INT_32 => [32, true], C::INT_64 => [64, true],
24
+ C::UINT_8 => [8, false], C::UINT_16 => [16, false], C::UINT_32 => [32, false], C::UINT_64 => [64, false]
25
+ }.freeze
26
+
27
+ # Converted annotations of time and timestamp columns => [kind, unit, adjusted to UTC]
28
+ TIME_CONVERTED = {
29
+ C::TIME_MILLIS => [:time, :millis, true], C::TIME_MICROS => [:time, :micros, true],
30
+ C::TIMESTAMP_MILLIS => [:timestamp, :millis, true], C::TIMESTAMP_MICROS => [:timestamp, :micros, true]
31
+ }.freeze
32
+
33
+ # Converted annotations of UTF-8 byte arrays => the text kind
34
+ TEXT_CONVERTED = {C::UTF8 => :string, C::ENUM => :enum, C::JSON => :json}.freeze
35
+
36
+ # Time units from coarsest to finest
37
+ TIME_UNITS = %i[millis micros nanos].freeze
38
+
39
+ # Float width => bits of its significand, which is how wide an Integer it holds exactly
40
+ FLOAT_SIGNIFICANDS = {16 => 11, 32 => 24, 64 => 53}.freeze
41
+
42
+ # @param mode [Symbol] +:union+ or +:intersect+
43
+ def initialize(mode)
44
+ @mode = mode
45
+ @conflicts = []
46
+ end
47
+
48
+ # @param mine [Schema] the receiver, whose field order comes first
49
+ # @param theirs [Schema] the other schema
50
+ # @return [Schema] a new schema sharing no nodes with either
51
+ # @raise [IncompatibleSchema] listing every field that does not fit
52
+ def call(mine, theirs)
53
+ nodes = merge_members(mine.fields, theirs.fields, [])
54
+ unless @conflicts.empty?
55
+ raise IncompatibleSchema.new(@conflicts, operation: (@mode == :union) ? "unite" : "intersect")
56
+ end
57
+ Schema.new(Node.new(name: mine.root.name, repetition: :required, children: nodes))
58
+ end
59
+
60
+ # @param field [Field] any field
61
+ # @return [String] its type as error messages name it: the leaf type's label ("uint16",
62
+ # "timestamp(millis, UTC)", "decimal(10, 2)"...), or "struct", "list" or "map"
63
+ def describe(field) = kind_label(field)
64
+
65
+ private
66
+
67
+ # Matches the members of two groups (or the top-level fields) by name: those of +mine+ in
68
+ # order, then, for a union, those only +theirs+ has.
69
+ #
70
+ # @param mine [Array<Field>] fields of the receiver
71
+ # @param theirs [Array<Field>] fields of the other schema
72
+ # @param path [Array<String>] path of the enclosing group, empty at the top level
73
+ # @return [Array<Node>] merged nodes; incomplete when a conflict was recorded
74
+ def merge_members(mine, theirs, path)
75
+ by_name = theirs.to_h { |f| [f.name, f] }
76
+ names = mine.map(&:name)
77
+ if @mode == :intersect && (names & by_name.keys).empty?
78
+ conflict(path, "fields #{names.join(", ")}", "fields #{by_name.keys.join(", ")}", "no fields in common")
79
+ return []
80
+ end
81
+ nodes = mine.map do |field|
82
+ if (twin = by_name[field.name])
83
+ merge(field, twin, path + [field.name])
84
+ elsif @mode == :union
85
+ nullable(field, path + [field.name], :left)
86
+ end
87
+ end
88
+ if @mode == :union
89
+ nodes += theirs.reject { |f| names.include?(f.name) }.map { |f| nullable(f, path + [f.name], :right) }
90
+ end
91
+ nodes.compact
92
+ end
93
+
94
+ # A field only one side of a union has: rows from the other side hold no value for it
95
+ #
96
+ # @param field [Field] the field only one schema has
97
+ # @param path [Array<String>] path of the field
98
+ # @param side [Symbol] +:left+ or +:right+, the schema that has it
99
+ # @return [Node, nil] an optional copy of the field's node; nil for a conflict
100
+ def nullable(field, path, side)
101
+ unless field.node.repeated?
102
+ return copy(field.node, repetition: :optional)
103
+ end
104
+ sides = ["repeated #{kind_label(field.element)}", "absent"]
105
+ conflict(path, *((side == :left) ? sides : sides.reverse), "a repeated field cannot be null, so both schemas need it")
106
+ end
107
+
108
+ # @param a [Field] the field in the receiver
109
+ # @param b [Field] the field of the same name in the other schema
110
+ # @param path [Array<String>] path of the field
111
+ # @return [Node, nil] the merged node; nil when a conflict was recorded
112
+ def merge(a, b, path)
113
+ an = a.node
114
+ bn = b.node
115
+ a_id = field_id_of(a)
116
+ b_id = field_id_of(b)
117
+ if a_id && b_id && a_id != b_id
118
+ return conflict(path, "field_id #{a_id}", "field_id #{b_id}", "different field ids, so different columns")
119
+ end
120
+ if a.kind != b.kind
121
+ return conflict(path, kind_label(a), kind_label(b), "a #{a.kind} and a #{b.kind} do not mix")
122
+ end
123
+ both_bare = a.kind == :list && an.repeated? && bn.repeated?
124
+ repetition = if both_bare then :repeated
125
+ elsif a.optional || b.optional then :optional
126
+ else :required
127
+ end
128
+ field_id = a_id || b_id
129
+ if shape(an) == shape(bn)
130
+ enum_values = an.enum_values if an.enum_values == bn.enum_values
131
+ return copy(an, repetition: repetition, field_id: field_id, enum_values: enum_values)
132
+ end
133
+
134
+ case a.kind
135
+ when :leaf
136
+ attrs = merge_leaf(an, bn, path) or return
137
+ enum_values = an.enum_values if an.enum_values == bn.enum_values
138
+ Node.new(name: an.name, repetition: repetition, field_id: field_id, enum_values: enum_values, **attrs)
139
+ when :struct
140
+ children = merge_members(a.children, b.children, path)
141
+ Node.new(name: an.name, repetition: repetition, field_id: field_id, children: children) unless children.empty?
142
+ when :list
143
+ element = merge(a.element, b.element, path + ["element"]) or return
144
+ if both_bare
145
+ element.name = an.name
146
+ element.repetition = :repeated
147
+ element.field_id = field_id
148
+ return element
149
+ end
150
+ element.name = "element"
151
+ Node.new(name: an.name, repetition: repetition, field_id: field_id,
152
+ children: [Node.new(name: "list", repetition: :repeated, children: [element])],
153
+ logical_type: Format::LogicalType.new(list: Format::ListType.new), converted_type: C::LIST)
154
+ when :map
155
+ key = merge(a.key, b.key, path + ["key"])
156
+ value = merge(a.value, b.value, path + ["value"])
157
+ return unless key && value
158
+ key.name = "key"
159
+ key.repetition = :required
160
+ value.name = "value"
161
+ Node.new(name: an.name, repetition: repetition, field_id: field_id,
162
+ children: [Node.new(name: "key_value", repetition: :repeated, children: [key, value])],
163
+ logical_type: Format::LogicalType.new(map: Format::MapType.new), converted_type: C::MAP)
164
+ end
165
+ end
166
+
167
+ # @param an [Node] leaf in the receiver
168
+ # @param bn [Node] leaf of the same name in the other schema
169
+ # @param path [Array<String>] path of the field
170
+ # @return [Hash{Symbol => Object}, nil] physical attributes for Node.new; nil when a conflict
171
+ # was recorded
172
+ def merge_leaf(an, bn, path)
173
+ ta = leaf_type(an)
174
+ tb = leaf_type(bn)
175
+ return attributes_of(an) if ta == tb && ta.first == :other
176
+ type, reason = (ta == tb) ? [ta] : widen(ta, tb)
177
+ return conflict(path, label(ta), label(tb), reason) if reason
178
+ attributes(type)
179
+ end
180
+
181
+ # @param field [Field] any field
182
+ # @return [Integer, nil] the field id of its node, nil for the element of a bare repeated
183
+ # field, whose node (and field id) is that of the list
184
+ def field_id_of(field)
185
+ field.node.field_id unless field.node.repeated? && field.kind != :list
186
+ end
187
+
188
+ # @param ta [Array] leaf type of the receiver's field (see #leaf_type)
189
+ # @param tb [Array] a different leaf type of the other schema's field
190
+ # @return [Array(Array, nil), Array(nil, String)] the widened type, or why there is none
191
+ def widen(ta, tb)
192
+ case [ta.first, tb.first]
193
+ when [:int, :int] then widen_ints(ta, tb)
194
+ when [:float, :float] then [[:float, [ta[1], tb[1]].max]]
195
+ when [:int, :float] then int_to_float(ta, tb)
196
+ when [:float, :int] then int_to_float(tb, ta)
197
+ when [:timestamp, :timestamp], [:time, :time]
198
+ return [nil, "one is adjusted to UTC and the other is not"] if ta[2] != tb[2]
199
+ [[ta[0], TIME_UNITS[[TIME_UNITS.index(ta[1]), TIME_UNITS.index(tb[1])].max], ta[2]]]
200
+ when [:text, :text] then [[:text, :string]]
201
+ when [:text, :binary], [:binary, :text] then [[:binary]]
202
+ when [:decimal, :decimal] then [nil, "decimals need the same precision and scale"]
203
+ else [nil, "no common type"]
204
+ end
205
+ end
206
+
207
+ # @param ta [Array] an integer type
208
+ # @param tb [Array] another integer type
209
+ # @return [Array(Array, nil), Array(nil, String)] the narrowest integer holding both, or why
210
+ # there is none
211
+ def widen_ints(ta, tb)
212
+ _, a_bits, a_signed = ta
213
+ _, b_bits, b_signed = tb
214
+ return [[:int, [a_bits, b_bits].max, a_signed]] if a_signed == b_signed
215
+ signed_bits, unsigned_bits = a_signed ? [a_bits, b_bits] : [b_bits, a_bits]
216
+ return [nil, "uint64 does not fit any signed integer"] if unsigned_bits == 64
217
+ [[:int, [signed_bits, unsigned_bits * 2].max, true]]
218
+ end
219
+
220
+ # @param int [Array] an integer type
221
+ # @param float [Array] a float type
222
+ # @return [Array(Array, nil), Array(nil, String)] the narrowest float at least as wide as
223
+ # +float+ that holds every value of +int+ exactly, or why there is none
224
+ def int_to_float(int, float)
225
+ _, bits, signed = int
226
+ magnitude = signed ? bits - 1 : bits
227
+ width = FLOAT_SIGNIFICANDS.find { |w, significand| w >= float[1] && significand >= magnitude }&.first
228
+ return [nil, "#{label(int)} does not fit a double exactly"] unless width
229
+ [[:float, width]]
230
+ end
231
+
232
+ # @param node [Node] a leaf
233
+ # @return [Array] its type, independent of whether a logical or converted type spells it;
234
+ # +[:other, ...]+ for annotations nothing widens (INTERVAL, unknown logical types)
235
+ def leaf_type(node)
236
+ lt = node.logical_type
237
+ case node.logical_kind
238
+ when :integer then [:int, lt.integer.bit_width, lt.integer.is_signed]
239
+ when :decimal then [:decimal, lt.decimal.precision, lt.decimal.scale]
240
+ when :timestamp then [:timestamp, lt.timestamp.unit.to_sym, lt.timestamp.is_adjusted_to_utc]
241
+ when :time then [:time, lt.time.unit.to_sym, lt.time.is_adjusted_to_utc]
242
+ when :string, :enum, :json then [:text, node.logical_kind]
243
+ when :float16 then [:float, 16]
244
+ when :date, :uuid, :bson then [node.logical_kind]
245
+ when nil
246
+ # A logical type this version does not know decodes as an empty union
247
+ lt ? [:other, shape(node), "an unknown logical type"] : converted_type(node)
248
+ else [:other, shape(node), node.logical_kind.to_s]
249
+ end
250
+ end
251
+
252
+ # @param node [Node] a leaf without a logical type
253
+ # @return [Array] its type, see #leaf_type
254
+ def converted_type(node)
255
+ ct = node.converted_type
256
+ if (int = INT_CONVERTED[ct]) then [:int, *int]
257
+ elsif (time = TIME_CONVERTED[ct]) then time
258
+ elsif (text = TEXT_CONVERTED[ct]) then [:text, text]
259
+ elsif ct == C::DECIMAL then [:decimal, node.precision, node.scale || 0]
260
+ elsif ct == C::DATE then [:date]
261
+ elsif ct == C::BSON then [:bson]
262
+ elsif ct then [:other, shape(node), C::NAMES[ct].to_s.downcase]
263
+ else
264
+ case node.type
265
+ when T::BOOLEAN then [:boolean]
266
+ when T::INT32 then [:int, 32, true]
267
+ when T::INT64 then [:int, 64, true]
268
+ when T::INT96 then [:int96]
269
+ when T::FLOAT then [:float, 32]
270
+ when T::DOUBLE then [:float, 64]
271
+ when T::BYTE_ARRAY then [:binary]
272
+ when T::FIXED_LEN_BYTE_ARRAY then [:fixed, node.type_length]
273
+ end
274
+ end
275
+ end
276
+
277
+ # @param type [Array] a leaf type, see #leaf_type
278
+ # @return [Hash{Symbol => Object}] physical attributes for Node.new, as the Builder DSL
279
+ # declares that type
280
+ def attributes(type)
281
+ case type
282
+ in [:int, 32, true] then Types.physical_attributes(:int32)
283
+ in [:int, 64, true] then Types.physical_attributes(:int64)
284
+ in [:int, bits, signed] then Types.int_type(bits, signed)
285
+ in [:float, bits] then Types.physical_attributes({16 => :float16, 32 => :float, 64 => :double}.fetch(bits))
286
+ in [:timestamp | :time => kind, unit, utc] then Types.physical_attributes(utc ? kind : :"local_#{kind}", unit: unit)
287
+ in [:text, :enum] then Types.physical_attributes(:enum, parquet_enum: true)
288
+ in [:text, kind] then Types.physical_attributes(kind)
289
+ in [:decimal, precision, scale] then Types.physical_attributes(:decimal, precision: precision, scale: scale)
290
+ in [:fixed, length] then Types.physical_attributes(:fixed, length: length)
291
+ in [kind] then Types.physical_attributes(kind)
292
+ end
293
+ end
294
+
295
+ # @param node [Node] a leaf
296
+ # @return [Hash{Symbol => Object}] its physical attributes as they are
297
+ def attributes_of(node)
298
+ {type: node.type, type_length: node.type_length, converted_type: node.converted_type,
299
+ logical_type: node.logical_type, scale: node.scale, precision: node.precision}
300
+ end
301
+
302
+ # @param type [Array] a leaf type, see #leaf_type
303
+ # @return [String] e.g. "uint16", "timestamp(millis, UTC)", "decimal(10, 2)"
304
+ def label(type)
305
+ case type
306
+ in [:int, bits, signed] then "#{"u" unless signed}int#{bits}"
307
+ in [:float, bits] then {16 => "float16", 32 => "float", 64 => "double"}.fetch(bits)
308
+ in [:timestamp | :time => kind, unit, utc] then "#{kind}(#{unit}, #{utc ? "UTC" : "local"})"
309
+ in [:text, kind] then kind.to_s
310
+ in [:decimal, precision, scale] then "decimal(#{precision}, #{scale})"
311
+ in [:fixed, length] then "fixed(#{length})"
312
+ in [:other, _, description] then description
313
+ in [kind] then kind.to_s
314
+ end
315
+ end
316
+
317
+ # @param field [Field] any field
318
+ # @return [String] the leaf type's label, or "struct", "list" or "map"
319
+ def kind_label(field)
320
+ field.leaf? ? label(leaf_type(field.node)) : field.kind.to_s
321
+ end
322
+
323
+ # What two nodes must share to be merged as they are: Node#signature without the name,
324
+ # repetition and field id of the node itself
325
+ #
326
+ # @param node [Node] any node
327
+ # @return [Array]
328
+ def shape(node)
329
+ node.signature.values_at(2, 3, 4, 6)
330
+ end
331
+
332
+ # @param node [Node] node to copy, with its children
333
+ # @param changes [Hash{Symbol => Object}] attributes to set on the copy (not on its children)
334
+ # @option changes [Symbol] :repetition repetition of the copy
335
+ # @option changes [Integer, nil] :field_id field id of the copy
336
+ # @option changes [Array<String>, Hash, nil] :enum_values enum values of the copy
337
+ # @return [Node] a copy sharing no nodes with the original
338
+ def copy(node, **changes)
339
+ attrs = {name: node.name, repetition: node.repetition, field_id: node.field_id,
340
+ enum_values: node.enum_values, children: node.children&.map { |c| copy(c) }}
341
+ Node.new(**attrs.merge(attributes_of(node)).merge(changes))
342
+ end
343
+
344
+ # @param path [Array<String>] path of the field
345
+ # @param left [String] the field in the receiver
346
+ # @param right [String] the field in the other schema
347
+ # @param reason [String] why they do not fit
348
+ # @return [nil]
349
+ def conflict(path, left, right, reason)
350
+ @conflicts << IncompatibleSchema::Conflict.new(path.join("."), left, right, reason)
351
+ nil
352
+ end
353
+ end
354
+ end
355
+ end
@@ -120,6 +120,17 @@ module Herringbone
120
120
  field_id: field_id
121
121
  )
122
122
  end
123
+
124
+ # What Schema#== compares: name, repetition, physical type and width, annotation and field id,
125
+ # and the same of the children. A logical type supersedes the converted type (and the
126
+ # decimal scale and precision, which it holds), since writers differ in whether they also
127
+ # store the legacy annotation. +enum_values+ is not stored in the file and does not count.
128
+ #
129
+ # @return [Array] nested Arrays of plain values, comparable with == and usable as a Hash key
130
+ def signature
131
+ annotation = logical_type ? logical_type.to_h : [converted_type, scale, precision]
132
+ [name, repetition, type, type_length, annotation, field_id, children&.map(&:signature)]
133
+ end
123
134
  end
124
135
 
125
136
  # A leaf column (one column chunk per row group)
@@ -296,6 +307,8 @@ module Herringbone
296
307
  end
297
308
 
298
309
  # Type inference behind Schema.infer
310
+ #
311
+ # @api private
299
312
  module Inference
300
313
  module_function
301
314
 
@@ -401,6 +414,86 @@ module Herringbone
401
414
  @columns.find { |c| c.path == path }
402
415
  end
403
416
 
417
+ # Structural equality: the same fields in the same order, with the same names, repetition,
418
+ # physical types and widths, annotations, field ids and nesting (see Node#signature). The name
419
+ # of the root (+schema+, +spark_schema+...) does not count.
420
+ #
421
+ # @param other [Object] object to compare with
422
+ # @return [Boolean]
423
+ def ==(other)
424
+ other.is_a?(Schema) && signature == other.signature
425
+ end
426
+ alias_method :eql?, :==
427
+
428
+ # @return [Integer] hash consistent with #==
429
+ def hash = signature.hash
430
+
431
+ # The fields of this schema, then those only +other+ has, matched by name at every level of
432
+ # nesting (struct members, list elements, map keys and values). A field only one side has
433
+ # becomes optional, as does one that is optional on either side. The types of fields both
434
+ # have are widened without loss:
435
+ #
436
+ # * integers to the wider one: int32 + int64 -> int64, uint8 + uint32 -> uint32, and unsigned
437
+ # with signed to a signed integer twice as wide: uint32 + int8 -> int64
438
+ # * floats to the wider one, and integers with floats to a float holding every integer
439
+ # exactly: int16 + float -> float, int32 + float -> double
440
+ # * timestamps and times to the finer unit: millis + micros -> micros
441
+ # * string, enum and json to string; any of them with binary to binary
442
+ #
443
+ # (a.schema + b.schema).fields.map(&:name) # => ["id", "name", "email"]
444
+ #
445
+ # @param other [Schema] the schema to unite with
446
+ # @return [Schema] a new schema, sharing no nodes with either
447
+ # @raise [IncompatibleSchema] listing every field without a common type (int64 + double,
448
+ # uint64 + signed, decimals of another precision or scale, UTC + local timestamps, a struct and
449
+ # a list...), with different field ids, or repeated and only in one schema
450
+ # @raise [ArgumentError] when +other+ is not a Schema
451
+ def union(other)
452
+ raise ArgumentError, "Expected a Herringbone::Schema, got #{other.class}" unless other.is_a?(Schema)
453
+ Merge.new(:union).call(self, other)
454
+ end
455
+ alias_method :+, :union
456
+
457
+ # The fields both schemas have, in the order of this one, matched by name at every level of
458
+ # nesting. Nullability and types are widened as by #union.
459
+ #
460
+ # (a.schema & b.schema).fields.map(&:name) # => ["id", "name"]
461
+ #
462
+ # @param other [Schema] the schema to intersect with
463
+ # @return [Schema] a new schema, sharing no nodes with either
464
+ # @raise [IncompatibleSchema] listing every field without a common type or with different field
465
+ # ids, and the schemas or structs that have no fields in common
466
+ # @raise [ArgumentError] when +other+ is not a Schema
467
+ def intersect(other)
468
+ raise ArgumentError, "Expected a Herringbone::Schema, got #{other.class}" unless other.is_a?(Schema)
469
+ Merge.new(:intersect).call(self, other)
470
+ end
471
+ alias_method :&, :intersect
472
+
473
+ # The given top-level fields, in the order of this schema.
474
+ #
475
+ # schema.only(:id, :name).fields.map(&:name) # => ["id", "name"]
476
+ #
477
+ # @param names [Array<String, Symbol>] names of top-level fields
478
+ # @return [Schema] a new schema, sharing no nodes with this one
479
+ # @raise [ArgumentError] when a name is not a top-level field, or no name is given
480
+ def only(*names)
481
+ keep = field_names!(names, "only")
482
+ subset { |f| keep.include?(f.name) }
483
+ end
484
+
485
+ # Every top-level field but the given ones, in the order of this schema.
486
+ #
487
+ # schema.except(:ssn).fields.map(&:name) # => ["id", "name"]
488
+ #
489
+ # @param names [Array<String, Symbol>] names of top-level fields
490
+ # @return [Schema] a new schema, sharing no nodes with this one
491
+ # @raise [ArgumentError] when a name is not a top-level field, or all fields would be left out
492
+ def except(*names)
493
+ drop = field_names!(names, "except")
494
+ subset { |f| !drop.include?(f.name) }
495
+ end
496
+
404
497
  # @return [String] one line per node, indented by depth: repetition, physical type, name and
405
498
  # annotation
406
499
  def inspect
@@ -425,8 +518,47 @@ module Herringbone
425
518
  end
426
519
  alias_method :to_s, :inspect
427
520
 
521
+ protected
522
+
523
+ # @return [Array] the signatures of the top-level nodes (Node#signature), for #== and #hash
524
+ def signature = @root.children.map(&:signature)
525
+
428
526
  private
429
527
 
528
+ # @param names [Array<String, Symbol, Array>] names given to #only or #except
529
+ # @param method [String] the method, for the error message
530
+ # @return [Array<String>] the names as Strings
531
+ # @raise [ArgumentError] when a name is not a top-level field, or no name is given
532
+ def field_names!(names, method)
533
+ names = names.flatten.map(&:to_s)
534
+ raise ArgumentError, "Schema##{method} needs the names of top-level fields, e.g. schema.#{method}(:id, :name)" if names.empty?
535
+ missing = names - @fields.map(&:name)
536
+ unless missing.empty?
537
+ raise ArgumentError, "Schema##{method}: no top-level field #{missing.join(", ")}. " \
538
+ "The schema has #{@fields.map(&:name).join(", ")}"
539
+ end
540
+ names
541
+ end
542
+
543
+ # @yieldparam field [Field] a top-level field
544
+ # @yieldreturn [Boolean] whether to keep it
545
+ # @return [Schema] a schema of copies of the kept fields' nodes
546
+ # @raise [ArgumentError] when no field is kept
547
+ def subset
548
+ nodes = @fields.select { |f| yield f }.map { |f| copy_node(f.node) }
549
+ raise ArgumentError, "A schema needs at least one field, and this would leave out all of #{@fields.map(&:name).join(", ")}" if nodes.empty?
550
+ Schema.new(Node.new(name: @root.name, repetition: :required, children: nodes))
551
+ end
552
+
553
+ # @param node [Node] node to copy, with its children
554
+ # @return [Node] a copy sharing no nodes with the original
555
+ def copy_node(node)
556
+ Node.new(name: node.name, repetition: node.repetition, type: node.type, type_length: node.type_length,
557
+ converted_type: node.converted_type, logical_type: node.logical_type, scale: node.scale,
558
+ precision: node.precision, field_id: node.field_id, enum_values: node.enum_values,
559
+ children: node.children&.map { |c| copy_node(c) })
560
+ end
561
+
430
562
  # Appends a Column for every leaf under +node+, depth-first. A non-required node adds one
431
563
  # definition level and a repeated node one repetition level.
432
564
  #
@@ -579,28 +711,50 @@ module Herringbone
579
711
  define_method(t) { |name, **opts| column(name, t, **opts) }
580
712
  end
581
713
 
582
- # A TIME column; millis are stored as INT32, micros and nanos as INT64.
714
+ # A TIME column. A Time is converted to UTC when written, and its time of day in UTC is
715
+ # stored. Millis are stored as INT32, micros and nanos as INT64.
716
+ #
717
+ # @param name [String, Symbol] field name
718
+ # @param unit [Symbol] +:millis+, +:micros+ or +:nanos+
719
+ # @param opts [Hash{Symbol => Object}] options of #column
720
+ # @option opts [Boolean] :null (true) whether the field is nullable
721
+ # @option opts [Integer, nil] :field_id (nil) field id to store in the schema
722
+ # @return [Node] the added node
723
+ # @raise [ArgumentError] for an unknown unit or a duplicate name
724
+ def time(name, unit: :micros, **opts) = column(name, :time, unit: unit, **opts)
725
+ # A TIME column for wall clock times of day. A Time is written as its time of day in its
726
+ # own zone, without converting it to UTC. Millis are stored as INT32, micros and nanos as INT64.
583
727
  #
584
728
  # @param name [String, Symbol] field name
585
729
  # @param unit [Symbol] +:millis+, +:micros+ or +:nanos+
586
- # @param utc [Boolean] the isAdjustedToUTC flag of the logical type
587
730
  # @param opts [Hash{Symbol => Object}] options of #column
588
731
  # @option opts [Boolean] :null (true) whether the field is nullable
589
732
  # @option opts [Integer, nil] :field_id (nil) field id to store in the schema
590
733
  # @return [Node] the added node
591
734
  # @raise [ArgumentError] for an unknown unit or a duplicate name
592
- def time(name, unit: :micros, utc: true, **opts) = column(name, :time, unit: unit, utc: utc, **opts)
593
- # A TIMESTAMP column, stored as INT64.
735
+ def local_time(name, unit: :micros, **opts) = column(name, :local_time, unit: unit, **opts)
736
+ # A TIMESTAMP column, stored as INT64. A Time in any zone is converted to UTC when written,
737
+ # so the column holds instants.
594
738
  #
595
739
  # @param name [String, Symbol] field name
596
740
  # @param unit [Symbol] +:millis+, +:micros+ or +:nanos+
597
- # @param utc [Boolean] the isAdjustedToUTC flag: true for instants, false for local date-times
598
741
  # @param opts [Hash{Symbol => Object}] options of #column
599
742
  # @option opts [Boolean] :null (true) whether the field is nullable
600
743
  # @option opts [Integer, nil] :field_id (nil) field id to store in the schema
601
744
  # @return [Node] the added node
602
745
  # @raise [ArgumentError] for an unknown unit or a duplicate name
603
- def timestamp(name, unit: :micros, utc: true, **opts) = column(name, :timestamp, unit: unit, utc: utc, **opts)
746
+ def timestamp(name, unit: :micros, **opts) = column(name, :timestamp, unit: unit, **opts)
747
+ # A TIMESTAMP column for wall clock date-times, stored as INT64. A Time is written as its
748
+ # date and time in its own zone, without converting it to UTC, and no zone is stored.
749
+ #
750
+ # @param name [String, Symbol] field name
751
+ # @param unit [Symbol] +:millis+, +:micros+ or +:nanos+
752
+ # @param opts [Hash{Symbol => Object}] options of #column
753
+ # @option opts [Boolean] :null (true) whether the field is nullable
754
+ # @option opts [Integer, nil] :field_id (nil) field id to store in the schema
755
+ # @return [Node] the added node
756
+ # @raise [ArgumentError] for an unknown unit or a duplicate name
757
+ def local_timestamp(name, unit: :micros, **opts) = column(name, :local_timestamp, unit: unit, **opts)
604
758
  # A DECIMAL column. Up to 9 digits are stored as INT32, up to 18 as INT64, more as a
605
759
  # FIXED_LEN_BYTE_ARRAY of the minimal width, unless +physical:+ says otherwise.
606
760
  #
@@ -660,7 +814,6 @@ module Herringbone
660
814
  # @option type_opts [Integer] :precision decimal precision
661
815
  # @option type_opts [Integer] :scale decimal scale
662
816
  # @option type_opts [Symbol] :unit time or timestamp unit
663
- # @option type_opts [Boolean] :utc time or timestamp UTC adjustment
664
817
  # @option type_opts [Integer] :length FIXED_LEN_BYTE_ARRAY width
665
818
  # @yield [list] declares the struct's fields for a +:struct+ element, or exactly one field
666
819
  # (renamed to "element") when +type+ is nil
@@ -699,7 +852,6 @@ module Herringbone
699
852
  # @option type_opts [Integer] :precision decimal precision
700
853
  # @option type_opts [Integer] :scale decimal scale
701
854
  # @option type_opts [Symbol] :unit time or timestamp unit
702
- # @option type_opts [Boolean] :utc time or timestamp UTC adjustment
703
855
  # @option type_opts [Integer] :length FIXED_LEN_BYTE_ARRAY width
704
856
  # @yield [map] declares the struct's fields for a +:struct+ value, or exactly one field
705
857
  # (renamed to "value") when +value_type+ is nil
@@ -721,8 +873,8 @@ module Herringbone
721
873
  # Generic column declaration: s.column :name, :int32, null: false
722
874
  #
723
875
  # @param name [String, Symbol] field name
724
- # @param type [Symbol, String] DSL type: one of PRIMITIVES, or +:time+, +:timestamp+,
725
- # +:decimal+, +:fixed+, +:enum+ with their options
876
+ # @param type [Symbol, String] DSL type: one of PRIMITIVES, or +:time+, +:local_time+,
877
+ # +:timestamp+, +:local_timestamp+, +:decimal+, +:fixed+, +:enum+ with their options
726
878
  # @param null [Boolean] whether the field is nullable (optional rather than required)
727
879
  # @param field_id [Integer, nil] field id to store in the schema
728
880
  # @param opts [Hash{Symbol => Object}] type options
@@ -730,7 +882,6 @@ module Herringbone
730
882
  # @option opts [Integer] :scale (0) decimal scale
731
883
  # @option opts [Symbol] :physical decimal storage: +:int32+, +:int64+, +:binary+ or +:fixed+
732
884
  # @option opts [Symbol] :unit (:micros) time or timestamp unit
733
- # @option opts [Boolean] :utc (true) time or timestamp UTC adjustment
734
885
  # @option opts [Integer] :length FIXED_LEN_BYTE_ARRAY width (required for +:fixed+)
735
886
  # @option opts [Array<String>, Hash] :values allowed values, see #enum
736
887
  # @option opts [Boolean] :parquet_enum (false) ENUM instead of STRING annotation, see #enum
@@ -834,8 +985,14 @@ module Herringbone
834
985
  # and the rest go to Types.physical_attributes
835
986
  # @option opts [Array<String>, Hash] :values allowed values of a string/enum column
836
987
  # @return [Node] leaf node, not added to #nodes
837
- # @raise [ArgumentError] for an unknown type or invalid type options
988
+ # @raise [ArgumentError] for an unknown type or invalid type options, or for +utc:+, which
989
+ # the local_time and local_timestamp types replace
838
990
  def leaf_node(name, type, repetition, opts)
991
+ if opts.key?(:utc)
992
+ raise ArgumentError, "#{name}: the utc: option is gone. #{type} columns always convert Times to UTC when " \
993
+ "writing. To keep the wall clock time as given, without converting it, declare a local_#{type}, " \
994
+ "e.g. s.local_#{type} :#{name} or s.column :#{name}, :local_#{type}"
995
+ end
839
996
  opts = opts.dup
840
997
  values = opts.delete(:values)
841
998
  attrs = Types.physical_attributes(type.to_sym, **opts)
@@ -846,3 +1003,9 @@ module Herringbone
846
1003
  end
847
1004
 
848
1005
  require_relative "active_record"
1006
+
1007
+ module Herringbone
1008
+ class Schema
1009
+ autoload :Merge, File.expand_path("schema/merge", __dir__)
1010
+ end
1011
+ end