herringbone 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -17,10 +17,28 @@ module Herringbone
17
17
  # OffsetIndex), leaving ranges of rows to read; and every row that is read is checked, so
18
18
  # results are exact.
19
19
  class Filter
20
+ # One where: entry, resolved against the schema
21
+ #
22
+ # @!attribute [rw] name
23
+ # @return [String] the key as given (dotted path of a leaf column)
24
+ # @!attribute [rw] column
25
+ # @return [Schema::Column] the leaf column the condition reads
26
+ # @!attribute [rw] field
27
+ # @return [Schema::Field] the top-level field holding that column
28
+ # @!attribute [rw] path
29
+ # @return [Array<String>] member names from the top-level field down to the column
30
+ # (empty for a top-level column)
31
+ # @!attribute [rw] test
32
+ # @return [Object] the condition, with Symbols turned into Strings
20
33
  Condition = Struct.new(:name, :column, :field, :path, :test)
21
34
 
35
+ # @return [Array<Condition>] one per where: entry, in the order given
22
36
  attr_reader :conditions
23
37
 
38
+ # @param schema [Schema] schema of the file being read
39
+ # @param where [Hash{String, Symbol => Object}] column => condition (see the class docs)
40
+ # @raise [ArgumentError] when +where+ is not a Hash, names an unknown column, or a column
41
+ # inside a list or map
24
42
  def initialize(schema, where)
25
43
  raise ArgumentError, "where: must be a Hash of column => condition" unless where.is_a?(Hash)
26
44
  @conditions = where.map do |key, test|
@@ -35,6 +53,8 @@ module Herringbone
35
53
  end
36
54
 
37
55
  # Top-level fields the conditions need to read
56
+ #
57
+ # @return [Array<Schema::Field>] distinct fields, in condition order
38
58
  def fields
39
59
  @conditions.map(&:field).uniq
40
60
  end
@@ -43,6 +63,10 @@ module Herringbone
43
63
 
44
64
  # Whether any row of row group +rg_index+ may match, judging by chunk statistics and bloom
45
65
  # filters
66
+ #
67
+ # @param reader [Reader] reader of the file (for its footer and bloom filters)
68
+ # @param rg_index [Integer] position of the row group in the footer
69
+ # @return [Boolean] false only when no row of the row group can match
46
70
  def row_group_may_match?(reader, rg_index)
47
71
  chunks = reader.row_groups[rg_index].columns
48
72
  @conditions.all? do |c|
@@ -58,6 +82,10 @@ module Herringbone
58
82
  # Sorted, non-overlapping [first_row, end_row) ranges of row group +rg_index+ that may hold
59
83
  # matching rows, judging by the page index. Columns without a page index do not narrow
60
84
  # the ranges.
85
+ #
86
+ # @param reader [Reader] reader of the file (for its page index)
87
+ # @param rg_index [Integer] position of the row group in the footer
88
+ # @return [Array<Array(Integer, Integer)>] half-open row ranges within the row group
61
89
  def page_ranges(reader, rg_index)
62
90
  n = reader.row_groups[rg_index].num_rows
63
91
  ranges = [[0, n]]
@@ -75,7 +103,7 @@ module Herringbone
75
103
  end
76
104
  nulls = column_index.null_counts&.[](i)
77
105
  next unless Filter.may_match?(c.test, min, max, nulls, column_index.null_pages[i])
78
- stop = i + 1 < locs.size ? locs[i + 1].first_row_index : n
106
+ stop = (i + 1 < locs.size) ? locs[i + 1].first_row_index : n
79
107
  candidates << [loc.first_row_index, stop]
80
108
  end
81
109
  ranges = Filter.intersect(ranges, Filter.merge(candidates))
@@ -88,6 +116,12 @@ module Herringbone
88
116
 
89
117
  # Indexes (0...k) of the rows of an assembled batch that match. +data+ holds one Array per
90
118
  # field in +fields+; +symbolize+ says how struct Hashes are keyed.
119
+ #
120
+ # @param data [Array<Array>] assembled values, one Array of +k+ entries per field
121
+ # @param fields [Array<Schema::Field>] the fields +data+ holds, in the same order
122
+ # @param k [Integer] number of rows in the batch
123
+ # @param symbolize [Boolean] whether struct Hashes are keyed by Symbol
124
+ # @return [Array<Integer>] indexes of matching rows, ascending
91
125
  def matching_rows(data, fields, k, symbolize)
92
126
  columns = @conditions.map do |c|
93
127
  values = data.fetch(fields.index(c.field))
@@ -109,11 +143,22 @@ module Herringbone
109
143
 
110
144
  # --- condition evaluation ---
111
145
 
146
+ # Whether a value satisfies a condition: nil matches nulls, an Array any of its elements,
147
+ # a Range by #cover? (never nil, incomparable values do not match), a String by bytes
148
+ # (ignoring the encoding), a callable by its result, anything else by ==.
149
+ #
150
+ # @param test [Object] the condition
151
+ # @param v [Object] the row's value
152
+ # @return [Boolean] true when the value matches
112
153
  def self.matches?(test, v)
113
154
  case test
114
155
  when nil then v.nil?
115
156
  when Array then test.any? { |t| matches?(t, v) }
116
- when Range then !v.nil? && (test.cover?(v) rescue false)
157
+ when Range then !v.nil? && begin
158
+ test.cover?(v)
159
+ rescue
160
+ false
161
+ end
117
162
  when String then v.is_a?(String) && (v == test || (v.bytesize == test.bytesize && v.b == test.b))
118
163
  else
119
164
  if test.respond_to?(:call) then test.call(v)
@@ -124,6 +169,13 @@ module Herringbone
124
169
 
125
170
  # Whether a set of values with the given bounds may contain a match. Unknown bounds or
126
171
  # values that cannot be compared never rule anything out.
172
+ #
173
+ # @param test [Object] the condition
174
+ # @param min [Object, nil] lower bound of the values, nil when unknown
175
+ # @param max [Object, nil] upper bound of the values, nil when unknown
176
+ # @param nulls [Integer, nil] number of nulls, nil when unknown
177
+ # @param all_null [Boolean, nil] whether every value is null
178
+ # @return [Boolean] false only when no value can match
127
179
  def self.may_match?(test, min, max, nulls, all_null)
128
180
  case test
129
181
  when nil then nulls.nil? || nulls.positive?
@@ -150,19 +202,29 @@ module Herringbone
150
202
  end
151
203
  end
152
204
 
153
- BOOLEAN_ORDER = { false => 0, true => 1 }.freeze
205
+ # Booleans compare as integers, false before true (the Parquet sort order)
206
+ BOOLEAN_ORDER = {false => 0, true => 1}.freeze
154
207
 
208
+ # Compares two values in Parquet order: Strings byte-wise, false before true
209
+ #
210
+ # @param a [Object] left-hand value
211
+ # @param b [Object] right-hand value
212
+ # @return [Integer, nil] -1, 0 or 1, or nil when the values cannot be compared
155
213
  def self.compare(a, b)
156
214
  a = a.b if a.is_a?(String)
157
215
  b = b.b if b.is_a?(String)
158
216
  a = BOOLEAN_ORDER.fetch(a, a)
159
217
  b = BOOLEAN_ORDER.fetch(b, b)
160
218
  a <=> b
161
- rescue StandardError
219
+ rescue
162
220
  nil
163
221
  end
164
222
 
165
223
  # [min, max] of a chunk's statistics as Ruby values, or [nil, nil] when unusable
224
+ #
225
+ # @param column [Schema::Column] the chunk's column, for decoding the bounds
226
+ # @param stats [Format::Statistics, nil] the chunk's statistics
227
+ # @return [Array(Object, Object)] min and max, each nil when unknown
166
228
  def self.stat_range(column, stats)
167
229
  return [nil, nil] unless stats
168
230
  if stats.min_value && stats.max_value
@@ -176,6 +238,9 @@ module Herringbone
176
238
 
177
239
  # The deprecated min/max fields were written with signed comparisons, which only agree
178
240
  # with the logical order for signed numbers and booleans
241
+ #
242
+ # @param column [Schema::Column] column whose statistics are being read
243
+ # @return [Boolean] true when the legacy min/max fields can be trusted
179
244
  def self.legacy_order_ok?(column)
180
245
  kind, _, signed = Types.logical_of(column.node)
181
246
  case column.type
@@ -185,6 +250,12 @@ module Herringbone
185
250
  end
186
251
  end
187
252
 
253
+ # Decodes a min/max bound (PLAIN encoding of one value) into a Ruby value
254
+ #
255
+ # @param column [Schema::Column] the column the bound belongs to
256
+ # @param bytes [String, nil] the encoded bound
257
+ # @return [Object, nil] the value, or nil when it is absent or cannot be used (INT96,
258
+ # truncated FIXED_LEN_BYTE_ARRAY, too short, undecodable)
188
259
  def self.decode_stat(column, bytes)
189
260
  return nil if bytes.nil?
190
261
  type = column.type
@@ -192,22 +263,31 @@ module Herringbone
192
263
  when Format::Type::INT96 then return nil # no defined order
193
264
  when Format::Type::BYTE_ARRAY, Format::Type::FIXED_LEN_BYTE_ARRAY
194
265
  # Truncated bounds (shorter than the type length) still bound byte-wise
195
- return type == Format::Type::FIXED_LEN_BYTE_ARRAY && bytes.bytesize != column.type_length ? nil : convert(column, bytes.dup)
266
+ return (type == Format::Type::FIXED_LEN_BYTE_ARRAY && bytes.bytesize != column.type_length) ? nil : convert(column, bytes.dup)
196
267
  when Format::Type::BOOLEAN then bytes.getbyte(0) == 1
197
268
  else
198
- return nil if bytes.bytesize < { Format::Type::INT32 => 4, Format::Type::FLOAT => 4 }.fetch(type, 8)
269
+ return nil if bytes.bytesize < {Format::Type::INT32 => 4, Format::Type::FLOAT => 4}.fetch(type, 8)
199
270
  Encodings::Plain.decode(bytes, 0, 1, type).first.first
200
271
  end
201
272
  convert(column, value)
202
- rescue StandardError
273
+ rescue
203
274
  nil
204
275
  end
205
276
 
277
+ # Applies the column's converter, so bounds compare with the values rows hold
278
+ #
279
+ # @param column [Schema::Column] the column the value belongs to
280
+ # @param value [Object] physical value
281
+ # @return [Object] the Ruby value
206
282
  def self.convert(column, value)
207
283
  conv = column.converter
208
284
  conv ? conv.call(value) : value
209
285
  end
210
286
 
287
+ # Sorts ranges and merges overlapping or touching ones
288
+ #
289
+ # @param ranges [Array<Array(Integer, Integer)>] half-open row ranges
290
+ # @return [Array<Array(Integer, Integer)>] sorted, non-overlapping ranges
211
291
  def self.merge(ranges)
212
292
  ranges.sort.each_with_object([]) do |(s, e), out|
213
293
  if out.any? && s <= out.last[1]
@@ -218,6 +298,11 @@ module Herringbone
218
298
  end
219
299
  end
220
300
 
301
+ # Intersection of two sorted, non-overlapping lists of half-open ranges
302
+ #
303
+ # @param a [Array<Array(Integer, Integer)>] first list of ranges
304
+ # @param b [Array<Array(Integer, Integer)>] second list of ranges
305
+ # @return [Array<Array(Integer, Integer)>] the rows in both, sorted
221
306
  def self.intersect(a, b)
222
307
  out = []
223
308
  i = j = 0
@@ -225,13 +310,17 @@ module Herringbone
225
310
  s = [a[i][0], b[j][0]].max
226
311
  e = [a[i][1], b[j][1]].min
227
312
  out << [s, e] if s < e
228
- a[i][1] < b[j][1] ? i += 1 : j += 1
313
+ (a[i][1] < b[j][1]) ? i += 1 : j += 1
229
314
  end
230
315
  out
231
316
  end
232
317
 
233
318
  private
234
319
 
320
+ # Symbols (also inside Arrays) become Strings, since columns never hold Symbols
321
+ #
322
+ # @param test [Object] a condition as given in where:
323
+ # @return [Object] the condition to store in Condition#test
235
324
  def normalize(test)
236
325
  case test
237
326
  when Symbol then test.to_s
@@ -241,6 +330,11 @@ module Herringbone
241
330
  end
242
331
 
243
332
  # Bloom filters answer equality lookups (a value or a list of values)
333
+ #
334
+ # @param reader [Reader] reader of the file (for its bloom filters)
335
+ # @param rg_index [Integer] position of the row group in the footer
336
+ # @param condition [Condition] the condition to check
337
+ # @return [Boolean] false only when the bloom filter rules out every value of the condition
244
338
  def bloom_may_match?(reader, rg_index, condition)
245
339
  values = Array(condition.test.is_a?(Array) ? condition.test : [condition.test])
246
340
  return true if values.empty? || values.any? { |v| v.nil? || v.is_a?(Range) || v.respond_to?(:call) }
@@ -248,7 +342,7 @@ module Herringbone
248
342
  filter = reader.bloom_filter(rg_index, condition.column.path)
249
343
  return true unless filter
250
344
  values.any? { |v| filter.might_contain?(v) }
251
- rescue StandardError
345
+ rescue
252
346
  true # values the column cannot store never rule a row group out here; rows are checked anyway
253
347
  end
254
348
  end