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 +4 -4
- data/CHANGELOG.md +17 -0
- data/MANUAL.md +46 -20
- data/README.md +10 -9
- data/lib/herringbone/compression.rb +9 -1
- data/lib/herringbone/encryption_configuration.rb +1 -1
- data/lib/herringbone/inferring_writer.rb +59 -17
- data/lib/herringbone/inspector.rb +2 -0
- data/lib/herringbone/key.rb +2 -3
- data/lib/herringbone/reader/column_chunk_reader.rb +2 -1
- data/lib/herringbone/reader.rb +59 -27
- data/lib/herringbone/redaction.rb +7 -0
- data/lib/herringbone/schema.rb +5 -1
- data/lib/herringbone/thrift.rb +5 -2
- data/lib/herringbone/version.rb +1 -1
- data/lib/herringbone/writer.rb +25 -6
- data/lib/herringbone.rb +23 -7
- data/rbi/herringbone.rbi +12003 -0
- data/sig/herringbone.rbs +10521 -0
- data/sig/manifest.yaml +2 -0
- metadata +12 -4
- data/lib/herringbone/simple_writer.rb +0 -190
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6bcc4cf1c07a6664ec7a481ff9f889e8e9b3c63b24a4bb269bb8019562da3893
|
|
4
|
+
data.tar.gz: 6d8977ddd7b16c9796bbe5bf8e66951542959da197c4503b60b4fecbb95d9b55
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
- [
|
|
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
|
-
###
|
|
306
|
+
### Coming from CSV
|
|
305
307
|
|
|
306
|
-
`
|
|
307
|
-
|
|
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::
|
|
312
|
-
|
|
313
|
-
|
|
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
|
-
|
|
320
|
-
|
|
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
|
-
|
|
323
|
-
|
|
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
|
-
|
|
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::
|
|
79
|
-
|
|
80
|
-
|
|
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::
|
|
104
|
-
|
|
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
|
|
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
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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,
|
|
28
|
-
#
|
|
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,
|
|
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
|
data/lib/herringbone/key.rb
CHANGED
|
@@ -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
|
-
#
|
|
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
|
|
data/lib/herringbone/reader.rb
CHANGED
|
@@ -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
|
|
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 =
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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 =
|
|
205
|
-
|
|
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 =
|
|
433
|
-
|
|
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
|
-
|
|
436
|
-
|
|
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 =
|
|
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
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
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?
|
data/lib/herringbone/schema.rb
CHANGED
|
@@ -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}"
|
data/lib/herringbone/thrift.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
data/lib/herringbone/version.rb
CHANGED