herringbone 0.6.0 → 0.7.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 (40) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +40 -0
  3. data/MANUAL.md +161 -12
  4. data/README.md +19 -4
  5. data/bin/herringbone +56 -1
  6. data/lib/herringbone/active_record.rb +2 -0
  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 +53 -9
  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/inspector.rb +29 -5
  19. data/lib/herringbone/io_buffer_support.rb +2 -0
  20. data/lib/herringbone/reader/bloom_filters.rb +1 -0
  21. data/lib/herringbone/reader/chunk_copier.rb +177 -0
  22. data/lib/herringbone/reader/column_chunk_reader.rb +4 -2
  23. data/lib/herringbone/reader/column_cursor.rb +2 -0
  24. data/lib/herringbone/reader/numo.rb +4 -0
  25. data/lib/herringbone/reader/page_stream.rb +2 -0
  26. data/lib/herringbone/reader/scan.rb +2 -0
  27. data/lib/herringbone/reader.rb +19 -6
  28. data/lib/herringbone/redaction/rewriter.rb +51 -157
  29. data/lib/herringbone/redaction.rb +25 -15
  30. data/lib/herringbone/restricted_readable_io.rb +85 -0
  31. data/lib/herringbone/schema/merge.rb +355 -0
  32. data/lib/herringbone/schema.rb +80 -0
  33. data/lib/herringbone/thrift.rb +166 -68
  34. data/lib/herringbone/types.rb +2 -0
  35. data/lib/herringbone/version.rb +1 -1
  36. data/lib/herringbone/visualizer.rb +2 -0
  37. data/lib/herringbone/writer.rb +58 -22
  38. data/lib/herringbone/xxhash.rb +2 -0
  39. data/lib/herringbone.rb +96 -9
  40. 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(kind, unit: unit, utc: utc)
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,62 @@ 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
+
404
473
  # @return [String] one line per node, indented by depth: repetition, physical type, name and
405
474
  # annotation
406
475
  def inspect
@@ -425,6 +494,11 @@ module Herringbone
425
494
  end
426
495
  alias_method :to_s, :inspect
427
496
 
497
+ protected
498
+
499
+ # @return [Array] the signatures of the top-level nodes (Node#signature), for #== and #hash
500
+ def signature = @root.children.map(&:signature)
501
+
428
502
  private
429
503
 
430
504
  # Appends a Column for every leaf under +node+, depth-first. A non-required node adds one
@@ -846,3 +920,9 @@ module Herringbone
846
920
  end
847
921
 
848
922
  require_relative "active_record"
923
+
924
+ module Herringbone
925
+ class Schema
926
+ autoload :Merge, File.expand_path("schema/merge", __dir__)
927
+ end
928
+ end