herringbone 0.9.1 → 1.0.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 14cacf678c274af811c0ba7cf8d2810f251655fe8c0f8a80411d714170ade738
4
- data.tar.gz: c9ae864f1f2a15ba9972bd742556a909a534077aac7251c26841054b3e173e2e
3
+ metadata.gz: 6bcc4cf1c07a6664ec7a481ff9f889e8e9b3c63b24a4bb269bb8019562da3893
4
+ data.tar.gz: 6d8977ddd7b16c9796bbe5bf8e66951542959da197c4503b60b4fecbb95d9b55
5
5
  SHA512:
6
- metadata.gz: 9767ea6bb316dd5271ab1ced6476587036aaaaae7439b95d9d39ad721f2ada4af4514d75d1274d030c7ebe041a2f76b22a69ee34bd35b6a49f6941f9ed75b3ce
7
- data.tar.gz: 2487e14f78f16a8c2a823afc11b131f2bca9019ce54e4e6f52d423c1954715872ad2e9f1d9f5796674fdd70b34cd8780b5786b2b251bfa91f35f7a901d61d937
6
+ metadata.gz: ed4935d82ff796d9bbed85b44d67a36081569fab77bccfea3afc108349514809f20dc8f488c0145109c66b9ce8d5a5390d2b7888ddc07f417f826d338092c5c0
7
+ data.tar.gz: 75d11fc670e04725f7cb79314275fb32d9fb7b4c79e5530528a1300a3692591dfb6eca26f3d50f61bd706efa8ec15d74a53ac299b61a93d6c9e6604f28d9cbc1
data/CHANGELOG.md CHANGED
@@ -2,6 +2,23 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ - `Herringbone::Writer.open(io, headers: [:id, :name]) { |w| w << [1, "John"] }` infers the column
6
+ types when no schema is given, and `Herringbone.write(io, rows, headers: [:id, :name])` writes
7
+ Array rows with inferred types.
8
+ - `Herringbone::FormatError` is raised for a page that cannot be decompressed, naming the codec and
9
+ the column, instead of `Zlib::DataError`, zstd-ruby's `RuntimeError` or an internal codec error.
10
+ - `Herringbone::Reader` raises `Herringbone::FormatError` for a footer or page header that is
11
+ damaged in a way that still decodes, instead of a `NoMethodError`, `KeyError` or `TypeError`.
12
+ - `Herringbone::Thrift::Error` is a `Herringbone::Error`, so `rescue Herringbone::Error` covers
13
+ footer metadata that cannot be written.
14
+ - The gem ships its type signatures: `sig/herringbone.rbs` for RBS tools such as Steep (now valid
15
+ under `rbs validate`) and `rbi/herringbone.rbi` for Sorbet.
16
+ - **Breaking:** `Herringbone::SimpleWriter` is removed: use `Herringbone::Writer.open` with
17
+ `headers:` (and `encryption:` instead of `encrypt!(key:)`).
18
+ - **Breaking:** `Herringbone::Inspector::ArrowSchema`, `Herringbone::Redaction::Statement`,
19
+ `Redaction#statements` and `#drops`, `Schema::Column#converter` and `#encoder` and
20
+ `Schema::Builder#nodes` are marked `@api private`; nothing replaces them.
21
+
5
22
  ## 0.9.1
6
23
 
7
24
  - `Herringbone.combine` works in Ractors other than the main one, and so does reading ZSTD without
data/MANUAL.md CHANGED
@@ -16,7 +16,7 @@ The full reference for Herringbone. For a quick start, see the [README](README.m
16
16
  - [Column types](#column-types)
17
17
  - [Accepted values](#accepted-values)
18
18
  - [Writer options](#writer-options)
19
- - [SimpleWriter: coming from CSV](#simplewriter-coming-from-csv)
19
+ - [Coming from CSV](#coming-from-csv)
20
20
  - [Statistics, page indexes and bloom filters](#statistics-page-indexes-and-bloom-filters)
21
21
  - [Writing to S3](#writing-to-s3)
22
22
  - [Writing to Google Cloud Storage](#writing-to-google-cloud-storage)
@@ -41,6 +41,8 @@ The full reference for Herringbone. For a quick start, see the [README](README.m
41
41
  - [Inspecting files](#inspecting-files)
42
42
  - [Command line](#command-line)
43
43
  - [Supported format features](#supported-format-features)
44
+ - [Errors](#errors)
45
+ - [Public API and type signatures](#public-api-and-type-signatures)
44
46
  - [Development](#development)
45
47
 
46
48
  ## Installation
@@ -301,34 +303,27 @@ failed row is discarded and the writer can carry on.
301
303
  | `metadata` | `{}` | footer key/value metadata, read back with `reader.metadata` |
302
304
  | `bloom_filters` | none | `true`, an Array of column paths, or `{ "path" => { ndv:, fpp:, max_bytes: } }` |
303
305
 
304
- ### SimpleWriter: coming from CSV
306
+ ### Coming from CSV
305
307
 
306
- `SimpleWriter` writes like the CSV gem: name the columns, then append Arrays. The types are
307
- inferred from the first 1000 rows, as with `Herringbone.write`.
308
+ `Herringbone::Writer.open` without a schema infers the column types from the first 1000 rows, and
309
+ `headers:` names the columns of Array rows, like the CSV gem's `headers:`:
308
310
 
309
311
  ```ruby
310
312
  File.open("people.parquet", "wb") do |file|
311
- Herringbone::SimpleWriter.open(file) do |sw|
312
- sw.headers!(:id, :name, :age)
313
- sw << [123, "John", 12]
314
- sw << { id: 124, name: "Jane" } # Hashes work too
313
+ Herringbone::Writer.open(file, headers: [:id, :name, :age]) do |w|
314
+ w << [123, "John", 12]
315
+ w << { id: 124, name: "Jane" } # Hashes work too; missing columns are nulls
315
316
  end
316
317
  end
317
318
  ```
318
319
 
319
- Unlike CSV, headers are required. A column that holds more than one type can be declared up front:
320
- `Herringbone::SimpleWriter.new(io) { |s| s.string :code }` (then call `close` when done).
320
+ Without a block it returns the writer, to keep around and `close` when done. Unlike CSV, headers
321
+ are required for Array rows: a Parquet file always has named columns. With a schema, Array rows
322
+ list their values in schema order, so `headers:` can't be given as well. For a column that holds
323
+ more than one type, pass the schema.
321
324
 
322
- `encrypt!` encrypts the file with one key (see [Encryption](#encryption)): give it the key's hex
323
- or a `Herringbone::Key` (`key: Herringbone::Key.generate` for a new one; keep it).
324
-
325
- ```ruby
326
- Herringbone::SimpleWriter.open(file) do |sw|
327
- sw.encrypt!(key: ENV["PARQUET_KEY"])
328
- sw.headers!(:id, :name)
329
- sw << [1, "John"]
330
- end
331
- ```
325
+ Rows you already have go in one call, where the block can declare just the columns that need it:
326
+ `Herringbone.write(io, rows, headers: [:id, :code]) { |s| s.string :code }`.
332
327
 
333
328
  ### Statistics, page indexes and bloom filters
334
329
 
@@ -945,6 +940,37 @@ prints the page instead.
945
940
  footers, footer and column keys, AAD prefixes
946
941
  - Not supported: column chunks in external files
947
942
 
943
+ ## Errors
944
+
945
+ Every error Herringbone raises on purpose is a `Herringbone::Error`:
946
+
947
+ - `Herringbone::FormatError`: the file is not valid Parquet, or a part of it is damaged. A page
948
+ that cannot be decompressed raises it too, naming the codec and the column, and so does
949
+ metadata with a field missing; the error that gave the damage away (`Zlib::DataError`, a
950
+ `NoMethodError` on nil) is its `cause`.
951
+ - `Herringbone::EncodeError`: a value does not fit its column; it carries `row`, `column` and
952
+ `value`. `Herringbone::SchemaMismatch` is the same for a schema that was inferred.
953
+ - `Herringbone::UnsupportedError`: a Parquet feature Herringbone does not implement.
954
+ `Herringbone::MissingCodecError` says which gem a codec needs.
955
+ - `Herringbone::IncompatibleSchema`: schemas that cannot be united, intersected or combined.
956
+ - `Herringbone::DecryptionError`: a key is missing or wrong, or the encrypted bytes were changed.
957
+ - `Herringbone::Thrift::Error`: footer metadata that cannot be written, such as a `metadata:`
958
+ value that is not valid UTF-8.
959
+
960
+ Invalid arguments and options raise `ArgumentError`.
961
+
962
+ ## Public API and type signatures
963
+
964
+ The public API is what the YARD docs show without an `@api private` tag: `Herringbone.write`,
965
+ `redact`, `combine` and `codecs`, `Reader`, `Writer`, `Schema`, `Key`, the encryption
966
+ configurations, `Redaction`, `Inspector`, the errors and the `herringbone` executable. Classes,
967
+ modules and methods tagged `@api private` are there for the library itself and can change in any
968
+ release.
969
+
970
+ The gem ships type signatures for [RBS](https://github.com/ruby/rbs) (`sig/herringbone.rbs`, for
971
+ Steep and other RBS tools) and for [Sorbet](https://sorbet.org) (`rbi/herringbone.rbi`), both
972
+ generated from the YARD docs.
973
+
948
974
  ## Development
949
975
 
950
976
  ```
data/README.md CHANGED
@@ -71,18 +71,21 @@ statistics, page indexes and bloom filters. See [Reading](MANUAL.md#reading).
71
71
 
72
72
  ## Writing CSV-style
73
73
 
74
- `SimpleWriter` writes like the CSV gem: name the columns, then append rows. The types are inferred.
74
+ Name the columns with `headers:` and append the rows as Arrays, like with the CSV gem. The types are
75
+ inferred.
75
76
 
76
77
  ```ruby
77
78
  File.open("people.parquet", "wb") do |file|
78
- Herringbone::SimpleWriter.open(file) do |sw|
79
- sw.headers!(:id, :name, :age)
80
- sw << [123, "John", 12]
81
- sw << { id: 124, name: "Jane" } # Hashes work too
79
+ Herringbone::Writer.open(file, headers: [:id, :name, :age]) do |w|
80
+ w << [123, "John", 12]
81
+ w << { id: 124, name: "Jane" } # Hashes work too
82
82
  end
83
83
  end
84
84
  ```
85
85
 
86
+ Rows you already have in an Array or an Enumerable go in one call:
87
+ `Herringbone.write(file, rows, headers: [:id, :name, :age])`.
88
+
86
89
  To control the types, declare a schema and use `Herringbone::Writer`, see
87
90
  [Writing](MANUAL.md#writing).
88
91
 
@@ -100,10 +103,8 @@ Then write with it:
100
103
 
101
104
  ```ruby
102
105
  File.open("people.parquet", "wb") do |file|
103
- Herringbone::SimpleWriter.open(file) do |sw|
104
- sw.encrypt!(key: ENV["PARQUET_KEY"])
105
- sw.headers!(:id, :name, :email)
106
- sw << [1, "John", "john@example.com"]
106
+ Herringbone::Writer.open(file, headers: [:id, :name, :email], encryption: ENV["PARQUET_KEY"]) do |w|
107
+ w << [1, "John", "john@example.com"]
107
108
  end
108
109
  end
109
110
  ```
@@ -147,7 +147,8 @@ module Herringbone
147
147
  # @return [String] decompressed bytes (binary)
148
148
  # @raise [UnsupportedError] for a codec herringbone does not implement
149
149
  # @raise [MissingCodecError] when the codec's gem is not loaded
150
- # @raise [FormatError] when the decompressed size does not match +uncompressed_size+
150
+ # @raise [FormatError] when the data is corrupt, or the decompressed size does not match
151
+ # +uncompressed_size+
151
152
  def decompress(codec, data, uncompressed_size)
152
153
  return "".b if uncompressed_size.zero? && data.empty?
153
154
  out = case codec
@@ -167,6 +168,13 @@ module Herringbone
167
168
  raise FormatError, "Decompressed #{out.bytesize} bytes, expected #{uncompressed_size}"
168
169
  end
169
170
  out
171
+ rescue Error
172
+ raise
173
+ rescue => e
174
+ # zstd-ruby raises a bare RuntimeError and zlib and brotli have errors of their own, so
175
+ # there is no narrower list to rescue
176
+ raise FormatError, "Cannot decompress #{data.bytesize} bytes of #{Format::Codec::NAMES[codec] || codec} data, " \
177
+ "they are corrupt or cut short: #{e.message} (#{e.class})"
170
178
  end
171
179
 
172
180
  # Compresses a page body. Given several Strings, compresses their concatenation; GZIP, ZSTD
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Herringbone
4
4
  # How Writer encrypts a file (Parquet modular encryption), checked as soon as it is built. The
5
- # +encryption:+ option of Writer, Herringbone.write, SimpleWriter and Herringbone.redact takes
5
+ # +encryption:+ option of Writer, Herringbone.write and Herringbone.redact takes
6
6
  # one, or a Hash of the same keywords, which is turned into one with EncryptionConfiguration.from.
7
7
  #
8
8
  # config = Herringbone::EncryptionConfiguration.new(
@@ -10,13 +10,18 @@ module Herringbone
10
10
  # A row that does not fit the inferred schema raises SchemaMismatch and stops the writer: the
11
11
  # file is left unfinished (no footer) rather than written with rows missing.
12
12
  #
13
- # Used by Herringbone.write and SimpleWriter.
13
+ # With +headers:+, rows are Arrays of values in header order (or Hashes keyed by header), and
14
+ # only the held-back rows are turned into Hashes for inference.
14
15
  #
15
- # @api private
16
+ # Writer.open returns one when it is given no schema, and Herringbone.write uses one when it has
17
+ # to infer the schema.
16
18
  class InferringWriter
19
+ # @api private
17
20
  # @param io [IO, #write] destination, passed to Writer.new
18
21
  # @param fix [String] how a caller declares a column, for SchemaMismatch messages; +%s+ is
19
22
  # replaced by a declaration such as +string :age+
23
+ # @param headers [Array<String, Symbol>, nil] column names, in the order row Arrays list their
24
+ # values; nil for rows the Writer takes as they are
20
25
  # @param options [Hash{Symbol => Object}] Writer options
21
26
  # @option options [Symbol] :compression (:snappy) codec, checked before any row is read
22
27
  # (the other Writer options are passed on as well)
@@ -24,14 +29,15 @@ module Herringbone
24
29
  # @yieldparam sample [Array] the rows held back, at most Schema::INFER_SAMPLE
25
30
  # @yieldreturn [Schema]
26
31
  # @raise [MissingCodecError] when the codec's optional gem is not loaded
27
- # @raise [ArgumentError] for a compression level the codec does not take, or invalid
28
- # encryption settings
29
- def initialize(io, fix:, **options, &schema_for)
32
+ # @raise [ArgumentError] for a compression level the codec does not take, invalid encryption
33
+ # settings, or +headers+ that are empty or name a column twice
34
+ def initialize(io, fix:, headers: nil, **options, &schema_for)
30
35
  # Fail before reading any rows if the codec's library is missing or the level is wrong
31
36
  codec = Compression.codec_id(options.fetch(:compression, :snappy))
32
37
  Compression.ensure_available!(codec)
33
38
  Compression.check_level!(codec, options[:compression_level])
34
39
  options = options.merge(encryption: EncryptionConfiguration.from(options[:encryption])) if options[:encryption]
40
+ @headers = headers && check_headers(headers)
35
41
  @io = io
36
42
  @fix = fix
37
43
  @options = options
@@ -42,12 +48,15 @@ module Herringbone
42
48
  @mismatch = nil
43
49
  end
44
50
 
45
- # @param row [Object] row to write, in any form the Writer accepts
51
+ # @param row [Object] row to write, in any form the Writer accepts; with +headers+, an Array or
52
+ # a Hash
46
53
  # @return [self]
54
+ # @raise [ArgumentError] when the row does not match the headers
47
55
  # @raise [SchemaMismatch] when the row does not fit the inferred schema
48
56
  # @raise [Error] after an earlier SchemaMismatch
49
57
  def <<(row)
50
58
  check_usable!
59
+ row = values_of(row) if @headers
51
60
  if @writer
52
61
  write(row)
53
62
  else
@@ -57,16 +66,6 @@ module Herringbone
57
66
  self
58
67
  end
59
68
 
60
- # Sets the encryption before the first row
61
- #
62
- # @param config [EncryptionConfiguration] how to encrypt the file
63
- # @return [void]
64
- # @raise [Error] once rows were given
65
- def encryption=(config)
66
- raise Error, "Encryption must be set before the first row" if @writer || !@sample.empty?
67
- @options = @options.merge(encryption: config)
68
- end
69
-
70
69
  # @return [Integer] rows accepted so far, including the ones held back
71
70
  def rows_written = @writer ? @writer.rows_written : @sample.size
72
71
 
@@ -101,9 +100,10 @@ module Herringbone
101
100
  #
102
101
  # @return [void]
103
102
  # @raise [SchemaMismatch] when a held-back row does not fit
103
+ # @raise [ArgumentError] when a column declared in the block is not among the headers
104
104
  def start
105
105
  @sample_size = @sample.size
106
- @writer = Writer.new(@io, @schema_for.call(@sample), **@options)
106
+ @writer = Writer.new(@io, infer_schema, **@options)
107
107
  sample, @sample = @sample, nil
108
108
  sample.each { |row| write(row) }
109
109
  end
@@ -119,6 +119,48 @@ module Herringbone
119
119
  raise @mismatch
120
120
  end
121
121
 
122
+ # @return [Schema] built by the block from the held-back rows, as Hashes when there are headers
123
+ # @raise [ArgumentError] when a column declared in the block is not among the headers
124
+ def infer_schema
125
+ return @schema_for.call(@sample) unless @headers
126
+ # Without rows, every column is nil and so becomes a string
127
+ rows = @sample.empty? ? [@headers.to_h { |name| [name, nil] }] : @sample.map { |values| @headers.zip(values).to_h }
128
+ schema = @schema_for.call(rows)
129
+ extra = schema.fields.map(&:name) - @headers
130
+ raise ArgumentError, "Declared columns not among the headers (#{@headers.join(", ")}): #{extra.join(", ")}" unless extra.empty?
131
+ schema
132
+ end
133
+
134
+ # @param headers [Array<String, Symbol>] column names given as +headers:+
135
+ # @return [Array<String>] the names as frozen Strings
136
+ # @raise [ArgumentError] when there are none or a name is given twice
137
+ def check_headers(headers)
138
+ names = Array(headers).map(&:to_s)
139
+ raise ArgumentError, "headers: needs at least one column name" if names.empty?
140
+ duplicates = names.tally.select { |_, n| n > 1 }.keys
141
+ raise ArgumentError, "headers: names a column more than once: #{duplicates.join(", ")}" unless duplicates.empty?
142
+ names.freeze
143
+ end
144
+
145
+ # @param row [Array, Hash] row given to #<<
146
+ # @return [Array] its values in header order
147
+ # @raise [ArgumentError] when it does not match the headers
148
+ def values_of(row)
149
+ case row
150
+ when Array
151
+ unless row.size == @headers.size
152
+ raise ArgumentError, "Expected #{@headers.size} values (#{@headers.join(", ")}), got #{row.size}"
153
+ end
154
+ row
155
+ when Hash
156
+ unknown = row.keys.map(&:to_s) - @headers
157
+ raise ArgumentError, "Not among the headers (#{@headers.join(", ")}): #{unknown.join(", ")}" unless unknown.empty?
158
+ @headers.map { |name| row.fetch(name) { row[name.to_sym] } }
159
+ else
160
+ raise ArgumentError, "With headers:, rows must be Arrays or Hashes, got #{row.class}"
161
+ end
162
+ end
163
+
122
164
  # @return [void]
123
165
  # @raise [Error] after a SchemaMismatch
124
166
  def check_usable!
@@ -1155,6 +1155,8 @@ module Herringbone
1155
1155
  #
1156
1156
  # Fields carry :name, :type, :nullable and, when present, :children, :dictionary
1157
1157
  # ({ index_type:, ordered:, id: }), :extension (ARROW:extension:name) and :metadata.
1158
+ #
1159
+ # @api private
1158
1160
  module ArrowSchema
1159
1161
  # Raised for malformed or unsupported ARROW:schema values
1160
1162
  class Error < StandardError; end
@@ -14,9 +14,8 @@ module Herringbone
14
14
  # Herringbone.write(io, rows, encryption: key)
15
15
  # Herringbone::Reader.new(io, decryption: key) # or [new_key, old_key, ...]
16
16
  #
17
- # Where a key may be given as a String instead (+encryption:+, +decryption:+,
18
- # SimpleWriter#encrypt!), the String must be its hex: raw bytes are easy to mangle and to confuse
19
- # with text, so they go through Key.new.
17
+ # Where a key may be given as a String instead (+encryption:+, +decryption:+), the String must be
18
+ # its hex: raw bytes are easy to mangle and to confuse with text, so they go through Key.new.
20
19
  #
21
20
  # Instances are frozen; #inspect and #to_s show the id but not the key.
22
21
  class Key
@@ -301,11 +301,12 @@ module Herringbone
301
301
  # @param size [Integer] uncompressed size from the page header
302
302
  # @return [String] uncompressed bytes
303
303
  # @raise [UnsupportedError] for an unsupported codec, naming the column
304
+ # @raise [FormatError] for a body that cannot be decompressed, naming the column
304
305
  def decompress(body, size)
305
306
  Compression.decompress(@meta.codec, body, size)
306
307
  rescue MissingCodecError => e
307
308
  raise e.for_column(@column.dotted_path)
308
- rescue UnsupportedError => e
309
+ rescue UnsupportedError, FormatError => e
309
310
  raise e, "#{e.message} (column #{@column.dotted_path})"
310
311
  end
311
312
 
@@ -67,7 +67,7 @@ module Herringbone
67
67
  # to check the stored one)
68
68
  # @raise [ArgumentError] when +io+ cannot seek and read, or +keys+ / +time_zone+ /
69
69
  # +decryption+ are invalid
70
- # @raise [FormatError] when the footer is missing or cannot be decoded
70
+ # @raise [FormatError] when the footer is missing, cannot be decoded or holds a schema that makes no sense
71
71
  # @raise [DecryptionError] when the footer is encrypted and cannot be decrypted, or its
72
72
  # signature or a column's metadata does not check out
73
73
  def initialize(io, keys: :string, time_zone: nil, decryption: nil)
@@ -83,7 +83,7 @@ module Herringbone
83
83
  @decryption = decryption.nil? ? nil : DecryptionConfiguration.from(decryption)
84
84
  @io = RestrictedReadableIO.wrap(io)
85
85
  @file_metadata = read_footer
86
- @schema = Schema.from_elements(@file_metadata.schema)
86
+ @schema = damage_as_format_error("the schema") { Schema.from_elements(@file_metadata.schema) }
87
87
  end
88
88
 
89
89
  # Total number of rows, as stated in the footer (some writers store 0)
@@ -186,23 +186,27 @@ module Herringbone
186
186
  plan_rows(filter, from).each do |rg_index, ranges|
187
187
  rg = row_groups[rg_index]
188
188
  partial = ranges != [[0, rg.num_rows]]
189
- cursors = fields.map do |f|
190
- f.leaves.map do |col|
191
- reader = chunk_reader(rg_index, col, converter: converters[col.index], lazy: true)
192
- reader.locations = page_index(rg_index, col)[1]&.page_locations if partial
193
- [col.index, ColumnCursor.new(reader)]
189
+ cursors = damage_as_format_error("row group #{rg_index}") do
190
+ fields.map do |f|
191
+ f.leaves.map do |col|
192
+ reader = chunk_reader(rg_index, col, converter: converters[col.index], lazy: true)
193
+ reader.locations = page_index(rg_index, col)[1]&.page_locations if partial
194
+ [col.index, ColumnCursor.new(reader)]
195
+ end
194
196
  end
195
197
  end
196
198
  ranges.each do |first, stop|
197
- cursors.each { |cs| cs.each { |_, cursor| cursor.seek(first) } }
199
+ damage_as_format_error("row group #{rg_index}") { cursors.each { |cs| cs.each { |_, cursor| cursor.seek(first) } } }
198
200
  left = stop - first
199
201
  while left.positive?
200
202
  # Rows still missing from the current batch (pending_rows counts rows in both modes)
201
203
  k = pending ? size - pending_rows : size
202
204
  raise Error, "Internal error: batch needs #{k} rows" unless k.positive? # never loop without progress
203
205
  k = left if left < k
204
- data = assemblers.each_with_index.map do |asm, j|
205
- asm.read_rows(k, cursors[j].to_h { |idx, cursor| [idx, cursor.take(k)] })
206
+ data = damage_as_format_error("row group #{rg_index}") do
207
+ assemblers.each_with_index.map do |asm, j|
208
+ asm.read_rows(k, cursors[j].to_h { |idx, cursor| [idx, cursor.take(k)] })
209
+ end
206
210
  end
207
211
  left -= k
208
212
  kept = k
@@ -429,21 +433,25 @@ module Herringbone
429
433
  reader.locations = page_index(rg_index, col)[1]&.page_locations if partial
430
434
  reader
431
435
  end
432
- ruby_cursors = ruby_fields.map { |f| f.leaves.map { |col| [col.index, ColumnCursor.new(open.call(col))] } }
433
- fast_cursors = fast.map { |j| NumoCursor.new(open.call(out_fields[j].column), specs[j]) }
436
+ ruby_cursors, fast_cursors = damage_as_format_error("row group #{rg_index}") do
437
+ [ruby_fields.map { |f| f.leaves.map { |col| [col.index, ColumnCursor.new(open.call(col))] } },
438
+ fast.map { |j| NumoCursor.new(open.call(out_fields[j].column), specs[j]) }]
439
+ end
434
440
  ranges.each do |first, stop|
435
- ruby_cursors.each { |cs| cs.each { |_, cursor| cursor.seek(first) } }
436
- fast_cursors.each { |cursor| cursor.seek(first) }
441
+ damage_as_format_error("row group #{rg_index}") do
442
+ ruby_cursors.each { |cs| cs.each { |_, cursor| cursor.seek(first) } }
443
+ fast_cursors.each { |cursor| cursor.seek(first) }
444
+ end
437
445
  left = stop - first
438
446
  while left.positive?
439
447
  k = size - pending_rows
440
448
  raise Error, "Internal error: batch needs #{k} rows" unless k.positive? # never loop without progress
441
449
  k = step if k > step
442
450
  k = left if left < k
443
- data = assemblers.each_with_index.map do |asm, j|
444
- asm.read_rows(k, ruby_cursors[j].to_h { |idx, cursor| [idx, cursor.take(k)] })
451
+ data, numo = damage_as_format_error("row group #{rg_index}") do
452
+ [assemblers.each_with_index.map { |asm, j| asm.read_rows(k, ruby_cursors[j].to_h { |idx, cursor| [idx, cursor.take(k)] }) },
453
+ fast_cursors.map { |cursor| cursor.take(k) }]
445
454
  end
446
- numo = fast_cursors.map { |cursor| cursor.take(k) }
447
455
  left -= k
448
456
  kept = k
449
457
  if filter
@@ -515,21 +523,45 @@ module Herringbone
515
523
  raise ArgumentError, "from: must not be negative" if from.negative?
516
524
  offset = 0
517
525
  plan = []
518
- row_groups.each_with_index do |rg, i|
519
- n = rg.num_rows
520
- first = from - offset
521
- offset += n
522
- next if n.zero? || first >= n
523
- ranges = [[first.positive? ? first : 0, n]]
524
- if filter
525
- next unless filter.row_group_may_match?(self, i)
526
- ranges = Filter.intersect(ranges, filter.page_ranges(self, i))
526
+ damage_as_format_error("the list of row groups") do
527
+ row_groups.each_with_index do |rg, i|
528
+ n = rg.num_rows
529
+ first = from - offset
530
+ offset += n
531
+ next if n.zero? || first >= n
532
+ ranges = [[first.positive? ? first : 0, n]]
533
+ if filter
534
+ next unless filter.row_group_may_match?(self, i)
535
+ ranges = Filter.intersect(ranges, filter.page_ranges(self, i))
536
+ end
537
+ plan << [i, ranges] unless ranges.empty?
527
538
  end
528
- plan << [i, ranges] unless ranges.empty?
529
539
  end
530
540
  plan
531
541
  end
532
542
 
543
+ # What code tripping over damaged metadata raises: a field the Thrift struct lacks is nil, a
544
+ # count or an offset is negative or far too large, an enum has a value nobody defined
545
+ DAMAGE_ERRORS = [NoMethodError, TypeError, ArgumentError, IndexError, RangeError, ZeroDivisionError].freeze
546
+ private_constant :DAMAGE_ERRORS
547
+
548
+ # Runs a block that decodes a part of the file, raising FormatError where damaged metadata
549
+ # makes it trip. Required fields are not checked when the metadata is decoded (that would cost
550
+ # on every page header), so a missing one shows up as a nil or a nonsensical number later on.
551
+ # The block must not call into the caller's code, whose errors are not ours to rename.
552
+ #
553
+ # @param what [String] the part of the file being read, for the message
554
+ # @yield decodes that part
555
+ # @yieldreturn [Object] returned as is
556
+ # @return [Object] the block's value
557
+ # @raise [FormatError] when the block raises one of DAMAGE_ERRORS, kept as the +cause+
558
+ def damage_as_format_error(what)
559
+ yield
560
+ rescue *DAMAGE_ERRORS => e
561
+ raise FormatError, "The file is damaged: #{what} cannot be read, because the metadata describing it has " \
562
+ "a field missing or holds a value that makes no sense (#{e.class}: #{e.message})"
563
+ end
564
+
533
565
  # Decodes one Thrift struct stored elsewhere in the file (ColumnIndex, OffsetIndex)
534
566
  #
535
567
  # @param klass [Class] Format struct class to decode with (responds to .decode)
@@ -35,6 +35,8 @@ module Herringbone
35
35
  # @return [Hash{String => Object}] column => value, for a replace without a block
36
36
  # @!attribute block
37
37
  # @return [Proc, nil] computes the replacement values
38
+ #
39
+ # @api private
38
40
  Statement = Struct.new(:kind, :where, :targets, :constants, :block)
39
41
 
40
42
  # What #apply did, for the audit trail an erasure needs
@@ -52,6 +54,7 @@ module Herringbone
52
54
  # Rows selected with Redaction#where, waiting for a verb: #delete or #replace
53
55
  class Scope
54
56
  # @return [Hash{String, Symbol => Object}] the conditions given to Redaction#where
57
+ # @api private
55
58
  attr_reader :conditions
56
59
 
57
60
  # @param redaction [Redaction] the redaction the statement is added to
@@ -63,6 +66,7 @@ module Herringbone
63
66
  end
64
67
 
65
68
  # @return [Boolean] whether a verb was called on this scope
69
+ # @api private
66
70
  def used? = @used
67
71
 
68
72
  # Removes the matching rows
@@ -99,9 +103,11 @@ module Herringbone
99
103
  end
100
104
 
101
105
  # @return [Array<Statement>] the statements, in declared order
106
+ # @api private
102
107
  attr_reader :statements
103
108
 
104
109
  # @return [Array<String>] fields and struct members to remove from the schema
110
+ # @api private
105
111
  attr_reader :drops
106
112
 
107
113
  # Builds a redaction. The block receives the redaction and declares the statements on it,
@@ -275,6 +281,7 @@ module Herringbone
275
281
  # @param block [Proc, nil] computes the values
276
282
  # @return [Statement]
277
283
  # @raise [ArgumentError] when given both or neither of +constants+ and a block
284
+ # @api private
278
285
  def self.replace_statement(where, columns, constants, block)
279
286
  if block
280
287
  unless constants.empty?
@@ -163,12 +163,14 @@ module Herringbone
163
163
  #
164
164
  # @return [Proc, Method, nil] converts a physical value into its Ruby value, or nil when the physical
165
165
  # value is used as-is
166
+ # @api private
166
167
  def converter = Types.reader_for(@node)
167
168
 
168
169
  # Types.writer_for of the node. Not memoized, like #converter.
169
170
  #
170
171
  # @return [Proc, Method] converts a Ruby value into the physical value to store, raising
171
172
  # ArgumentError / TypeError / RangeError for values that do not fit
173
+ # @api private
172
174
  def encoder = Types.writer_for(@node)
173
175
  end
174
176
 
@@ -318,7 +320,7 @@ module Herringbone
318
320
  # to one
319
321
  def row_hash(row)
320
322
  return row if row.is_a?(Hash)
321
- raise ArgumentError, "Cannot infer a schema from Array rows (values in schema order); pass a schema" if row.is_a?(Array)
323
+ raise ArgumentError, "Cannot infer a schema from Array rows (values in schema order); pass headers: with the column names, or a schema" if row.is_a?(Array)
322
324
  return row.attributes if row.respond_to?(:attributes)
323
325
  return row.to_h if row.respond_to?(:to_h)
324
326
  raise ArgumentError, "Cannot infer a schema from a #{row.class}"
@@ -666,6 +668,7 @@ module Herringbone
666
668
  # a Builder of their own.
667
669
  class Builder
668
670
  # @return [Array<Node>] fields declared so far, in declaration order
671
+ # @api private
669
672
  attr_reader :nodes
670
673
 
671
674
  # Yields a new Builder to the block.
@@ -690,6 +693,7 @@ module Herringbone
690
693
  # @param usage [String] how the entry point is called with a block, for the error message
691
694
  # @return [void]
692
695
  # @raise [ArgumentError] when the block takes no parameter
696
+ # @api private
693
697
  def self.check_block!(block, usage)
694
698
  return if block.nil? || !block.parameters.empty?
695
699
  raise ArgumentError, "The block receives the schema builder as a parameter: #{usage}"
@@ -7,8 +7,11 @@ module Herringbone
7
7
  #
8
8
  # @api private
9
9
  module Thrift
10
- # Raised on malformed or truncated Thrift data, and on values the writer cannot encode
11
- class Error < StandardError; end
10
+ # Raised on malformed or truncated Thrift data, and on values the writer cannot encode. Readers
11
+ # turn it into FormatError; from a writer it reaches the caller as it is.
12
+ #
13
+ # @api public
14
+ class Error < Herringbone::Error; end
12
15
 
13
16
  # Compact protocol wire type: end of a struct's fields
14
17
  T_STOP = 0
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Herringbone
4
4
  # Gem version, also written to the +created_by+ field of every file's footer
5
- VERSION = "0.9.1"
5
+ VERSION = "1.0.0"
6
6
  end