herringbone 0.4.0 → 0.6.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.
data/README.md CHANGED
@@ -7,20 +7,13 @@ A pure-Ruby reader and writer for [Apache Parquet](https://parquet.apache.org/)
7
7
  LZO, from Hadoop-era files, can be read but not written
8
8
  - Full nesting support (structs, lists, maps, any depth) via Dremel record shredding/assembly
9
9
  - Reads files from parquet-mr, Arrow, Spark, Impala, DuckDB, Rust writers etc.
10
- - Optimized reads with batches and pages
10
+ - Optimized reads with batches and pages
11
+ - Parquet modular encryption (per column and of the footer), interoperable with parquet-mr and Arrow
11
12
  - Ruby 3.0+
12
13
 
13
- ## Diving in: dumping records in Rails
14
-
15
- Stream a relation straight into S3, no temp file needed:
16
-
17
- ```ruby
18
- s3 = Aws::S3::TransferManager.new
19
- s3.upload_stream(bucket: "exports", key: "payments.parquet") do |io|
20
- # Schema will be auto-inferred, find_each will be used automatically
21
- Herringbone.write(io, Payment.where(status: "settled", created_at: 1.month.ago..))
22
- end
23
- ```
14
+ This README covers the common cases. The [MANUAL](MANUAL.md) has everything else: reader and
15
+ writer options, schemas and column types, bloom filters, Numo arrays, encryption, the redaction
16
+ rules, type mapping and the inspector.
24
17
 
25
18
  ## Installation
26
19
 
@@ -28,370 +21,142 @@ end
28
21
  gem "herringbone"
29
22
  ```
30
23
 
31
- Add these libraries to speed up and enable certain functionality:
24
+ Optionally add `snappy` (faster), `zstd-ruby` and `brotli` (more codecs), `xxhash` (faster bloom
25
+ filters) or `numo-narray-alt` (`read(as: :numo)`). Herringbone uses whichever of them are loaded,
26
+ see [Optional gems](MANUAL.md#optional-gems).
32
27
 
33
- ```ruby
34
- gem "snappy" # native Snappy, 2-3x faster reads and writes of typical files
35
- gem "zstd-ruby" # ZSTD
36
- gem "brotli" # Brotli
37
- gem "xxhash" # faster bloom filters
38
- gem "numo-narray-alt" # read(as: :numo)
39
- ```
28
+ ## Exporting from Rails
40
29
 
41
- Herringbone never requires them itself: it uses whichever are loaded. Bundler.require (as in Rails)
42
- loads them, otherwise require them yourself:
30
+ Stream a relation straight into S3, no temp file needed:
43
31
 
44
32
  ```ruby
45
- require "herringbone"
46
- require "zstd-ruby"
47
- require "numo/narray"
33
+ s3 = Aws::S3::TransferManager.new
34
+ s3.upload_stream(bucket: "exports", key: "payments.parquet") do |io|
35
+ # Schema will be auto-inferred, find_each will be used automatically
36
+ Herringbone.write(io, Payment.where(status: "settled", created_at: 1.month.ago..))
37
+ end
48
38
  ```
49
39
 
50
- On Ruby 3.1 and later, Herringbone works from any Ractor. Pass a schema to a Ractor with
51
- `Ractor.make_shareable(schema)`. Rows made shareable the same way reach a writing Ractor by
52
- reference, without being copied:
40
+ Or to a local file:
53
41
 
54
42
  ```ruby
55
- writer = Ractor.new("out.parquet", Ractor.make_shareable(schema)) do |path, schema|
56
- File.open(path, "wb") do |f|
57
- Herringbone::Writer.open(f, schema) do |w|
58
- while (row = Ractor.receive) != :done
59
- w << row
60
- end
61
- end
62
- end
43
+ File.open("orders.parquet", "wb") do |file|
44
+ Herringbone.write(file, Order.where(created_at: 1.year.ago..), compression: :zstd)
63
45
  end
64
- rows.each { |row| writer.send(Ractor.make_shareable(row)) }
65
- writer.send(:done)
66
46
  ```
67
47
 
68
- ## Reading
48
+ Any Enumerable of Hashes, Structs or Arrays works too; the schema is inferred from the first 1000
49
+ rows. See [ActiveRecord](MANUAL.md#activerecord) and [Writing to S3](MANUAL.md#writing-to-s3).
69
50
 
70
- Herringbone can read from any IO-ish object with random access (the IO should be seekable).
51
+ ## Reading
71
52
 
72
53
  ```ruby
73
- require "herringbone"
74
-
75
54
  File.open("data.parquet", "rb") do |file|
76
55
  reader = Herringbone::Reader.new(file)
77
- reader.schema
78
56
  reader.num_rows
79
57
 
80
- reader.each_row { |row| p row } # Hashes with String keys, nested values as Hash/Array
81
- reader.each_batch(1000) { |rows| ... } # Arrays of up to 1000 rows
82
- reader.each_batch(10_000, as: :columns) { |batch| batch["amount"].sum } # { "id" => [...], ... }
83
- reader.read # all rows at once; read(as: :columns) for column Arrays
84
- end
85
- ```
58
+ reader.each_row { |row| p row } # Hashes with String keys, nested values as Hash/Array
59
+ reader.each_batch(1000) { |rows| ... } # Arrays of up to 1000 rows
60
+ reader.read # all rows at once
86
61
 
87
- Reads stream: pages are read and decoded one at a time per column and rows are assembled in
88
- batches, so memory depends on the batch and page sizes rather than on the row group size (1M
89
- rows in a single row group peak at about 60–160 MB RSS growth instead of 660 MB). Column-order
90
- batches (`as: :columns`) skip building a Hash per row and are 25–30% faster.
91
-
92
- `Reader.new` takes `keys: :symbol` for Symbol keys (in rows and in structs; map keys stay as
93
- stored) and `time_zone:` to return timestamps in a zone instead of UTC: a UTC offset (`"+02:00"`,
94
- or seconds), `"UTC"`, a `TZInfo::Timezone`, or anything responding to `#at` such as `Time.zone`
95
- in Rails (which yields `ActiveSupport::TimeWithZone`). Zone names like `"Europe/Amsterdam"` work
96
- when ActiveSupport or TZInfo is loaded. Timestamps stored with `isAdjustedToUTC=false` are
97
- wall-clock values and stay as they are.
98
-
99
- ### Selecting rows
100
-
101
- `each_row`, `each_batch` and `read` take `columns:`, `where:`, `from:` and `limit:`:
102
-
103
- ```ruby
104
- reader.read(columns: %w[id name]) # projection
105
- reader.read(where: { user_id: 42 })
106
- reader.read(where: { status: %w[paid shipped], created_at: 1.week.ago.. }) # IN, Ranges
107
- reader.read(where: { "address.city" => "Amsterdam", deleted_at: nil }) # struct members, IS NULL
108
- reader.read(where: { amount: ->(v) { v && v > 100 } }) # any callable
109
- reader.read(from: 1_000_000, limit: 100) # rows 1,000,000..1,000,099
62
+ reader.read(columns: %w[id name], where: { user_id: 42 })
63
+ reader.read(where: { status: %w[paid shipped], created_at: 1.week.ago.. })
64
+ end
110
65
  ```
111
66
 
112
- All conditions must hold; filtered columns need not be among `columns:`, and any column not inside
113
- a list or map can be filtered on. Row groups are skipped using min/max statistics, null counts and
114
- bloom filters, pages using the page index (written by Herringbone, parquet-mr, Arrow and others),
115
- and `from:` jumps to its row through the offset index; every row read is then checked, so results
116
- are exact. On a 1M-row file with 20k-row pages, looking up one `id` takes 0.1 s instead of 6.3 s.
117
- Filters help most on columns the data is sorted or clustered by. `reader.scan_plan(where: ...)`
118
- shows which row groups and row ranges a read would touch, without reading them.
67
+ Reads stream page by page, and `where:` skips row groups and pages it can rule out using
68
+ statistics, page indexes and bloom filters. See [Reading](MANUAL.md#reading).
119
69
 
120
- ### Numo arrays
70
+ ## Writing CSV-style
121
71
 
122
- `as: :numo` returns (or yields, with `each_batch`) a Hash of column name => Numo array, and takes
123
- the same `columns:`, `where:`, `from:` and `limit:`. Add `gem "numo-narray-alt"` (or
124
- `numo-narray`) to your Gemfile and `require "numo/narray"`: without it, `as: :numo` raises
125
- `Herringbone::UnsupportedError` naming the gem.
72
+ `SimpleWriter` writes like the CSV gem: name the columns, then append rows. The types are inferred.
126
73
 
127
74
  ```ruby
128
- cols = reader.read(as: :numo, columns: %w[id amount]) # { "id" => Numo::Int64, "amount" => Numo::DFloat }
129
- df = Rover::DataFrame.new(reader.read(as: :numo)) # no conversion needed
75
+ File.open("people.parquet", "wb") do |file|
76
+ Herringbone::SimpleWriter.open(file) do |sw|
77
+ sw.headers!(:id, :name, :age)
78
+ sw << [123, "John", 12]
79
+ sw << { id: 124, name: "Jane" } # Hashes work too
80
+ end
81
+ end
130
82
  ```
131
83
 
132
- Flat numeric and boolean columns are decoded from the page bytes without a Ruby object per value:
133
- two numeric columns of 1M uncompressed rows read in about 25 ms, against 50 ms with `as: :columns`.
84
+ To control the types, declare a schema and use `Herringbone::Writer`, see
85
+ [Writing](MANUAL.md#writing).
134
86
 
135
- | Parquet | Numo |
136
- |---|---|
137
- | INT32, INT64 (also TIME) | `Int32`, `Int64` |
138
- | INT(8/16) signed; INT(8/16/32/64) unsigned | `Int8`, `Int16`; `UInt8` … `UInt64` |
139
- | FLOAT, FLOAT16, DOUBLE | `SFloat`, `SFloat`, `DFloat` (nulls are NaN) |
140
- | integers with nulls | `DFloat` with NaN (exact up to 2**53) |
141
- | BOOLEAN | `Bit`; with nulls `RObject` of true/false/nil |
142
- | list of numbers, every row the same length and no nulls | 2-D `[rows, length]` (e.g. embeddings) |
143
- | strings, binary, decimals, dates, timestamps, UUIDs, structs, maps, other lists | `RObject` of the values `read` returns |
87
+ ## Encrypting
144
88
 
145
- Whether a column has nulls (or a list column is rectangular) is decided from the rows read, so
146
- with `each_batch` it can differ between batches.
147
-
148
- ## Writing
89
+ Parquet files tend to wander off: to S3 buckets, laptops, other teams. Encrypting them takes one
90
+ key and one line, no KMS, no Hadoop configuration. Make a key once and keep it with your other
91
+ secrets:
149
92
 
150
93
  ```ruby
151
- schema = Herringbone::Schema.define do
152
- int64 :id, null: false
153
- string :name
154
- enum :status, values: %w[pending paid shipped]
155
- list :tags, :string
156
- map :scores, :string, :double
157
- struct :address do
158
- string :city
159
- string :zip
160
- end
161
- decimal :price, precision: 12, scale: 2
162
- json :payload
163
- timestamp :created_at
164
- end
165
-
166
- File.open("out.parquet", "wb") do |file|
167
- Herringbone::Writer.open(file, schema) do |w|
168
- w << { "id" => 1, "name" => "Anna", "status" => "paid", "tags" => ["a", "b"],
169
- "scores" => { "x" => 1.5 }, "address" => { "city" => "Amsterdam" },
170
- "price" => BigDecimal("9.99"), "payload" => { "any" => ["json"] }, "created_at" => Time.now }
171
- w << { id: 2, status: :pending } # Symbol keys and values work; missing keys are nulls
172
- w << [3, "Bo", nil, nil, nil, nil, nil, nil, nil, nil] # Arrays in schema order
173
- w << order # anything with #attributes (ActiveRecord) or #to_h (Struct, Data)
174
- end
175
- end
94
+ Herringbone::Key.generate.hex # => "9f86d081884c7d65..." - put it in your credentials or ENV
176
95
  ```
177
96
 
178
- `Herringbone.write(io, rows)` writes an Enumerable of rows in one go, inferring the schema from the
179
- first 1000 rows unless `schema:` is given; fields declared in a block replace inferred ones:
180
- `Herringbone.write(io, rows) { json :payload }`. The rows are iterated once, holding back only
181
- those first 1000, so lazy Enumerators and cursors that can't be rewound work. A later row that
182
- doesn't fit the inferred types raises `Herringbone::SchemaMismatch`, which explains what was
183
- inferred and how to declare the column, and leaves the file unfinished.
184
-
185
- The writer writes to any IO that responds to `#write` (a `File`, `StringIO`, `Tempfile`, socket or
186
- pipe), sequentially, and never seeks, rewinds or closes it (it does switch it to binary mode). If
187
- the `Writer.open` block raises (or `#abort` is called), no footer is written and what was written
188
- so far is left for you to discard. To replace a file only once it is complete, write to a temporary
189
- file and rename it.
190
-
191
- Column types: `boolean int8 int16 int32 int64 uint8 uint16 uint32 uint64 float double float16
192
- string binary json bson enum uuid date int96 time timestamp decimal fixed`, plus `struct`, `list`
193
- and `map`; `column :name, :int32` declares one by name. Fields are nullable unless `null: false`
194
- is given; list elements unless `element_null: false`, map values unless `value_null: false`.
195
- Nested lists: `list :matrix do list :element, :double end`. `time` and `timestamp` take `unit:`
196
- (`:millis`, `:micros`, `:nanos`) and `utc:`.
197
-
198
- `enum` is a string column. `values:` restricts what may be written, and also takes a Rails-style
199
- Hash (`values: Order.statuses`), in which case both labels and stored integers are accepted and
200
- the label is written. `parquet_enum: true` adds the Parquet ENUM annotation (pyarrow and pandas
201
- read such columns as binary, which is why it is off by default).
202
-
203
- Columns accept the values Ruby and Rails code usually has at hand:
204
-
205
- | column | accepts |
206
- |---|---|
207
- | `date` | `Date`, `Time`/`DateTime` (their date), `"2024-05-01"` |
208
- | `timestamp` | `Time`, `DateTime`, `ActiveSupport::TimeWithZone`, `Date` (midnight UTC), ISO-8601 strings, Integers in the column's unit |
209
- | `time` | `Time` (its time of day, as Rails returns for `time` columns), `"13:45:30.25"`, Integers |
210
- | `json` | Strings as-is, anything else through `JSON.generate` |
211
- | `string`, `enum` | Strings, Symbols, anything with `to_s` |
212
- | `boolean` | `true`/`false`, `1`/`0`, `"t"`/`"f"`, `"true"`/`"false"`, `"yes"`/`"no"` |
213
- | integers | Integers, whole-number Floats/BigDecimals/Rationals, numeric Strings; out-of-range values raise |
214
- | `decimal` | `BigDecimal`, Integer, Rational, Float, numeric Strings |
215
- | `uuid` | Strings with or without dashes, or 16 raw bytes |
216
-
217
- Values that don't fit raise `Herringbone::EncodeError` naming the row number and column path; the
218
- failed row is discarded and the writer can carry on.
219
-
220
- Writer options:
221
-
222
- | option | default | |
223
- |---|---|---|
224
- | `compression` | `:snappy` | `:none`, `:snappy`, `:gzip`, `:lz4` (LZ4_RAW), `:lz4_hadoop`, `:zstd`, `:brotli` |
225
- | `row_group_bytes` | 16MB | flush a row group once the buffered values take about this much memory, which bounds memory use (a 15-column table peaks around 290 MB RSS) |
226
- | `row_group_rows` | none | also flush after this many rows |
227
- | `page_bytes` | 1MB | approximate data page size |
228
- | `page_rows` | `20_000` | at most this many rows per data page |
229
- | `data_page_version` | `1` | `1` or `2` |
230
- | `dictionary` | `true` | `false`, or an Array of column paths to dictionary-encode |
231
- | `encodings` | `{}` | e.g. `{ "id" => :delta_binary_packed, "x" => :byte_stream_split }` |
232
- | `metadata` | `{}` | footer key/value metadata, read back with `reader.metadata` |
233
- | `bloom_filters` | none | `true`, an Array of column paths, or `{ "path" => { ndv:, fpp:, max_bytes: } }` |
234
-
235
- ### Coming from CSV
236
-
237
- `SimpleWriter` writes like the CSV gem: name the columns, then append Arrays. The types are
238
- inferred from the first 1000 rows, as above.
97
+ Then write with it:
239
98
 
240
99
  ```ruby
241
100
  File.open("people.parquet", "wb") do |file|
242
101
  Herringbone::SimpleWriter.open(file) do |sw|
243
- sw.headers!(:id, :name, :age)
244
- sw << [123, "John", 12]
245
- sw << { id: 124, name: "Jane" } # Hashes work too
102
+ sw.encrypt!(key: ENV["PARQUET_KEY"])
103
+ sw.headers!(:id, :name, :email)
104
+ sw << [1, "John", "john@example.com"]
246
105
  end
247
106
  end
248
107
  ```
249
108
 
250
- Unlike CSV, headers are required. A column that holds more than one type can be declared up front:
251
- `Herringbone::SimpleWriter.new(io) { string :code }` (then call `close` when done).
252
-
253
- ### Statistics, page indexes and bloom filters
254
-
255
- Every column chunk gets min/max/null-count statistics and the Parquet page index (per-page min/max
256
- and row offsets), which Herringbone, DuckDB, Spark, Trino, Arrow and DataFusion use to skip pages.
257
- With 20,000 rows per page, `WHERE id BETWEEN ...` on a sorted column reads a handful of pages
258
- instead of the whole row group, so sort rows by the columns you filter on.
259
-
260
- Min/max cannot rule out a value inside a row group's range, the usual case for unsorted IDs,
261
- emails or UUIDs; a bloom filter can. They are off by default. `bloom_filters: ["user_id", "email"]`
262
- (or `true` for every non-boolean column) writes Parquet's split block bloom filters, sized from
263
- each row group's distinct values at a false positive probability of 1% (`fpp:`, up to `max_bytes:`,
264
- default 1MB) unless `ndv:` gives the number of distinct values. Nested leaves are named by their
265
- dotted path (`"tags.list.element"`). Hashing is pure Ruby unless the `xxhash` gem is loaded:
266
- a million rows take about 1.2 s longer to write with a filter on an INT64 column and 2.8 s longer
267
- with one on a ~22-byte string column, and about 0.5 s longer with `xxhash`.
268
-
269
- ### Writing to S3
270
-
271
- Parquet keeps its metadata in a footer, so a file can be streamed into an S3 multipart upload
272
- without a local copy, using `upload_stream` from `aws-sdk-s3`:
109
+ and read with it:
273
110
 
274
111
  ```ruby
275
- s3 = Aws::S3::TransferManager.new # aws-sdk-s3 1.197+; before that, Aws::S3::Object#upload_stream
276
- s3.upload_stream(bucket: "exports", key: "events.parquet", part_size: 16 * 1024 * 1024) do |io|
277
- Herringbone::Writer.open(io, schema) do |w|
278
- events.each { |event| w << event }
279
- end
280
- end
281
- s3.upload_stream(bucket: "exports", key: "orders.parquet") { |io| Herringbone.write(io, Order.all) }
112
+ Herringbone::Reader.new(file, decryption: ENV["PARQUET_KEY"]).read
282
113
  ```
283
114
 
284
- If the block raises, the SDK aborts the multipart upload and raises `Aws::S3::MultipartUploadError`,
285
- so no partial object is left. S3 allows at most 10,000 parts, which with the default 5MB parts caps
286
- the file at about 48GB; raise `part_size:` for bigger files.
115
+ The schema, the values and the statistics are all encrypted (AES-256-GCM); without the key the
116
+ file is just noise. `encryption: key` does the same for `Herringbone.write` and
117
+ `Herringbone::Writer`. Your colleagues can open the file with the same key in pyarrow 25+
118
+ (`pq.read_table(path, decryption_properties=pyarrow.parquet.encryption.create_decryption_properties(bytes.fromhex(key)))`),
119
+ Arrow, DataFusion, Trino or Spark, and `herringbone inspect people.parquet` asks for the key.
120
+ When the time comes to rotate, read with all your keys, `decryption: [new_key, old_key]`, and
121
+ each file finds its own. See [Encryption](MANUAL.md#encryption) for per-column keys, plaintext
122
+ footers and which tools read what.
287
123
 
288
- ## ActiveRecord
124
+ ## Redacting
289
125
 
290
- `Herringbone.write` also takes a model or relation, which it reads with `find_each`, using a schema
291
- built from the model's columns. It returns the number of rows written:
126
+ `Herringbone.redact` rewrites a file with rows removed or values replaced, for GDPR "forget me"
127
+ requests and pseudonymization. The parts of the file nothing touches are copied byte for byte.
292
128
 
293
129
  ```ruby
294
- File.open("orders.parquet", "wb") do |file|
295
- Herringbone.write(file, Order.where(created_at: 1.year.ago..), compression: :zstd)
130
+ # Forget me: remove the rows
131
+ Herringbone.redact(input, output) do |r|
132
+ r.where(user_id: 42).delete
296
133
  end
297
- schema = Herringbone::Schema.from_active_record(Order, only: %w[id status total])
298
- Herringbone.write(io, Order, schema: schema)
299
- ```
300
134
 
301
- `from_active_record` takes `only:`, `except:` and `parquet_enum: true`. Rails is not a dependency:
302
- it only calls `columns`, `primary_key` and `defined_enums` on the model. Enum attributes are
303
- written as their labels, and the writer rejects values outside the enum.
304
-
305
- | Column | Parquet |
306
- |---|---|
307
- | `integer` | `int16`/`int32`/`int64` by SQL type (`smallint`, `integer`, `bigint`...) or limit; `uintN` for `unsigned`; primary keys are always `int64` |
308
- | `float` | `double` (`float` for Postgres `float4`) |
309
- | `decimal` | `decimal(precision, scale)`; `decimal(38, 9)` when the column has no precision |
310
- | `boolean`, `date`, `binary`, `uuid` | same |
311
- | `json`, `jsonb` | `json` |
312
- | `datetime`, `timestamp`, `timestamptz` | `timestamp` (microseconds, UTC) |
313
- | `time` | `time` (microseconds) |
314
- | `hstore` | `map` of `string` to `string` |
315
- | enum attributes | `string` (or `enum`) |
316
- | `string`, `text`, `citext`, anything else | `string` |
317
- | Postgres array columns | `list` of the element type |
318
-
319
- Columns declared `NOT NULL` (and primary keys) are required, all others nullable. Column order
320
- follows `Model.columns`.
321
-
322
- ## Type mapping
323
-
324
- | Parquet | Ruby |
325
- |---|---|
326
- | BOOLEAN | `true`/`false` |
327
- | INT32/INT64 (incl. signed/unsigned INTEGER) | `Integer` |
328
- | FLOAT, DOUBLE, FLOAT16 | `Float` |
329
- | STRING, ENUM, JSON | `String` (UTF-8) |
330
- | BYTE_ARRAY, FIXED_LEN_BYTE_ARRAY, BSON | `String` (binary) |
331
- | DATE | `Date` (proleptic Gregorian) |
332
- | TIMESTAMP, INT96 | `Time` (UTC) |
333
- | TIME | `Integer` in the column's unit since midnight |
334
- | DECIMAL | `BigDecimal` |
335
- | UUID | `String` like `"0f1e2d3c-..."` |
336
- | struct / list / map | `Hash` / `Array` / `Hash` |
337
-
338
- ## Inspecting files
339
-
340
- > The inspector and its HTML view are modelled on
341
- > **[Parquet X-ray](https://huggingface.co/spaces/cfahlgren1/parquet-xray) by cfahlgren1** —
342
- > the design and the idea are theirs. Go check it out. It is amazing!
343
-
344
- `Herringbone::Inspector` examines a file using only its footer, page headers, page indexes and
345
- bloom filter headers. Nothing is decompressed, so it is fast on big files and works for ZSTD and
346
- Brotli files without the codec gems.
135
+ # Forget me, but keep the row for accounting: blank the personal columns
136
+ Herringbone.redact(input, output) do |r|
137
+ r.where(user_id: 42).replace(email: nil, name: nil, address: nil)
138
+ end
347
139
 
348
- ```ruby
349
- File.open("data.parquet", "rb") do |file|
350
- inspector = Herringbone::Inspector.new(file)
351
- inspector.summary # size, rows, row groups, codecs, page index / bloom filter presence...
352
- inspector.row_groups[0].column("name").pages # page headers; also statistics, column_index...
353
- puts inspector.report # the schema, every column chunk, key/value metadata (Arrow schema decoded)
354
- inspector.to_h # all of it, JSON-serializable
355
- inspector.to_html # one self-contained HTML page with a to-scale byte map of the file
140
+ # Pseudonymize a column across the whole file, mask another, drop a third
141
+ Herringbone.redact(input, output) do |r|
142
+ r.replace(:email) { |email| OpenSSL::HMAC.hexdigest("SHA256", KEY, email.downcase) }
143
+ r.replace(:phone) { |phone| phone && "***#{phone[-3..]}" }
144
+ r.drop :ssn, :ip_address
356
145
  end
357
146
  ```
358
147
 
359
- `inspector.verify_checksums` reads every page body (still without decompressing) to check the page
360
- CRCs; the results then appear in `summary`, `report`, `to_h` and `to_html`.
148
+ See [Redaction](MANUAL.md#redaction) for reusable redactions, reports and recipes.
361
149
 
362
- ## Command line
150
+ ## Looking inside a file
363
151
 
364
152
  ```
365
- bin/herringbone cat FILE [N] # rows as JSON lines
366
- bin/herringbone inspect FILE [--pages] # text report (--pages lists every page header)
367
- bin/herringbone inspect FILE --format=json # everything as JSON
368
- bin/herringbone inspect FILE --format=html # the HTML page, opened in your browser
369
- bin/herringbone inspect FILE --format=html > out.html # the HTML page, saved
153
+ bin/herringbone cat FILE [N] # rows as JSON lines
154
+ bin/herringbone inspect FILE # schema, row groups, column chunks
155
+ bin/herringbone inspect FILE --format=html # a byte map of the file, opened in your browser
370
156
  ```
371
157
 
372
- Add `--verify-checksums` to any `inspect` form to check page CRCs.
373
-
374
- `--format` is `text` (the default), `json` or `html`; `--pages` only goes with `text`.
375
- In a terminal, `--format=html` writes the page to a temp file, prints its path and opens it with `open` on
376
- macOS, `start` on Windows or `xdg-open` elsewhere. When stdout is redirected or piped it prints the
377
- page instead.
378
-
379
- ## Supported format features
380
-
381
- - Encodings (read and write): PLAIN, PLAIN_DICTIONARY/RLE_DICTIONARY, RLE, DELTA_BINARY_PACKED,
382
- DELTA_LENGTH_BYTE_ARRAY, DELTA_BYTE_ARRAY, BYTE_STREAM_SPLIT; legacy BIT_PACKED levels (read)
383
- - Data page v1 and v2, dictionary pages, page CRCs (written)
384
- - Page indexes and split block bloom filters (read and written)
385
- - Legacy list and map layouts per the Parquet backward-compatibility rules
386
- - Not supported: encryption, column chunks in external files
158
+ See [Inspecting files](MANUAL.md#inspecting-files) and [Command line](MANUAL.md#command-line).
387
159
 
388
160
  ## Development
389
161
 
390
- ```
391
- bundle install
392
- bundle exec rake test
393
- HERRINGBONE_PYTHON=/path/to/python-with-pyarrow bundle exec rake test # also run pyarrow interop tests
394
- ```
395
-
396
- `test/fixtures/parquet-testing` holds files from [apache/parquet-testing](https://github.com/apache/parquet-testing)
397
- (Apache-2.0); expectations for them were generated with pyarrow, see `test/fixtures/generate_expectations.py`.
162
+ See [Development](MANUAL.md#development) and [CONTRIBUTING](CONTRIBUTING.md).
data/bin/herringbone CHANGED
@@ -10,6 +10,13 @@
10
10
  # and open it in the browser (printed when stdout is redirected)
11
11
  # add --verify-checksums to any inspect form to read page bodies and check their CRCs
12
12
  # `inspect` reads only the footer, page headers and page indexes: no values are decompressed.
13
+ #
14
+ # Encrypted files: both commands take --key=KEY (a key matched by its fingerprint id, or used for
15
+ # the footer when it is the only one), --key=KEY_ID=KEY, --footer-key=KEY and
16
+ # --column-key=PATH=KEY (all repeatable but --footer-key), and --aad-prefix=PREFIX. KEY is hex, or
17
+ # base64:..., or raw:... for the bytes as typed (a 16, 24 or 32-character String that is not
18
+ # valid hex is taken as is too). Keys that are needed but not given are asked for on stdin
19
+ # (without echo in a terminal; an empty answer skips the key), unless --no-prompt is given.
13
20
  $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
14
21
  require "herringbone"
15
22
  require "json"
@@ -18,6 +25,7 @@ require "json"
18
25
  rescue LoadError
19
26
  end
20
27
  require "tmpdir"
28
+ require "io/console"
21
29
 
22
30
  def jsonable(v)
23
31
  case v
@@ -43,12 +51,81 @@ def open_with_default_app(path)
43
51
  !!system(*command, out: File::NULL, err: File::NULL, exception: false)
44
52
  end
45
53
 
46
- USAGE = "usage: herringbone cat FILE [N]\n" \
47
- " herringbone inspect FILE [--format=text|json|html] [--pages] [--verify-checksums]\n" \
48
- " (--pages only with --format=text)"
54
+ # A key as given on the command line or at the prompt: hex, base64:..., raw:..., or the bytes as
55
+ # typed when they are not valid hex but have the size of a key
56
+ # @return [String] the key
57
+ # @raise [ArgumentError] when it is none of those, or not 16, 24 or 32 bytes long
58
+ def parse_key(text, what)
59
+ text = text.strip
60
+ bytes = if text.start_with?("base64:")
61
+ text.delete_prefix("base64:").unpack1("m0")
62
+ elsif text.start_with?("raw:")
63
+ text.delete_prefix("raw:")
64
+ elsif text.match?(/\A(\h\h)+\z/) && Herringbone::Encryption::KEY_SIZES.include?(text.size / 2)
65
+ [text].pack("H*")
66
+ else
67
+ text
68
+ end
69
+ Herringbone::Encryption.check_key!(bytes, what)
70
+ end
71
+
72
+ # Asks for a key on stderr and reads it from stdin, without echo when stdin is a terminal
73
+ # @return [String, nil] the key, nil when the answer is empty or stdin is exhausted
74
+ def ask_key(metadata, owner)
75
+ what = (owner == :footer) ? "the footer" : "column #{owner}"
76
+ $stderr.print "Key for #{what}#{" (key metadata #{metadata.inspect})" if metadata}, hex: "
77
+ line = $stdin.tty? ? $stdin.noecho(&:gets) : $stdin.gets
78
+ $stderr.puts
79
+ return nil if line.nil? || line.strip.empty?
80
+ parse_key(line, "the key for #{what}")
81
+ rescue ArgumentError => e
82
+ raise unless $stdin.tty?
83
+ warn "herringbone: #{e.message}"
84
+ retry
85
+ end
86
+
87
+ # The decryption: option from the key flags
88
+ # @return [Hash{Symbol => Object}]
89
+ def decryption_from(key_flags, prompt)
90
+ by_metadata = {}
91
+ plain = []
92
+ decryption = {columns: {}}
93
+ key_flags.each do |flag|
94
+ name, value = flag.split("=", 2)
95
+ case name
96
+ when "--footer-key" then decryption[:footer_key] = parse_key(value.to_s, "--footer-key")
97
+ when "--aad-prefix" then decryption[:aad_prefix] = value.to_s
98
+ when "--key"
99
+ target, key = value.to_s.split("=", 2)
100
+ if key.nil? || value.start_with?("base64:", "raw:")
101
+ plain << Herringbone::Key.new(parse_key(value.to_s, "--key"))
102
+ by_metadata[plain.last.id] = plain.last.bytes
103
+ else
104
+ by_metadata[target] = parse_key(key, "--key=#{target}")
105
+ end
106
+ when "--column-key"
107
+ target, key = value.to_s.split("=", 2)
108
+ raise ArgumentError, "--column-key expects --column-key=PATH=KEY" unless key
109
+ decryption[:columns][target] = parse_key(key, "--column-key=#{target}")
110
+ end
111
+ end
112
+ decryption[:footer_key] ||= plain.first.bytes if plain.size == 1
113
+ decryption[:keys] = ->(metadata, owner) { by_metadata.fetch(metadata) { prompt ? ask_key(metadata, owner) : nil } }
114
+ decryption
115
+ end
116
+
117
+ USAGE = "usage: herringbone cat FILE [N] [KEY OPTIONS]\n" \
118
+ " herringbone inspect FILE [--format=text|json|html] [--pages] [--verify-checksums] [KEY OPTIONS]\n" \
119
+ " (--pages only with --format=text)\n" \
120
+ "key options, for encrypted files: --key=KEY --key=KEY_ID=KEY --footer-key=KEY --column-key=PATH=KEY\n" \
121
+ " --aad-prefix=PREFIX --no-prompt (KEY is hex, base64:... or raw:...)"
122
+
123
+ KEY_FLAGS = %w[--footer-key --column-key --key --aad-prefix].freeze
49
124
 
50
125
  command, *rest = ARGV
51
126
  flags, args = rest.partition { |a| a.start_with?("--") }
127
+ key_flags, flags = flags.partition { |f| KEY_FLAGS.include?(f.split("=", 2).first) }
128
+ prompt = !flags.delete("--no-prompt")
52
129
  path, limit = args
53
130
  valid = case command
54
131
  when "cat" then flags.empty? && path && (limit.nil? || limit.match?(/\A\d+\z/))
@@ -64,14 +141,15 @@ unless valid
64
141
  exit 1
65
142
  end
66
143
 
67
- # Parquet and file errors are reported in one line, without a backtrace
144
+ # Parquet, file and key errors are reported in one line, without a backtrace
68
145
  begin
146
+ decryption = decryption_from(key_flags, prompt)
69
147
  File.open(path, "rb") do |io|
70
148
  if command == "cat"
71
149
  n = limit&.to_i
72
- Herringbone::Reader.new(io).each_row(limit: n) { |row| puts JSON.generate(jsonable(row)) }
150
+ Herringbone::Reader.new(io, decryption: decryption).each_row(limit: n) { |row| puts JSON.generate(jsonable(row)) }
73
151
  else
74
- inspector = Herringbone::Inspector.new(io)
152
+ inspector = Herringbone::Inspector.new(io, decryption: decryption)
75
153
  inspector.verify_checksums if flags.include?("--verify-checksums")
76
154
  case format
77
155
  when "json"
@@ -90,7 +168,8 @@ begin
90
168
  end
91
169
  end
92
170
  end
93
- rescue Herringbone::Error, SystemCallError => e
171
+ rescue Herringbone::Error, SystemCallError, ArgumentError => e
94
172
  warn "herringbone: #{e.message}"
173
+ warn "herringbone: give keys with --footer-key=KEY, --column-key=PATH=KEY or --key=KEY_METADATA=KEY" if e.is_a?(Herringbone::DecryptionError)
95
174
  exit 1
96
175
  end
@@ -92,7 +92,7 @@ module Herringbone
92
92
 
93
93
  if type == :hstore
94
94
  return builder.map(column.name, :string, :string, null: nullable) unless array
95
- return builder.list(column.name, null: nullable) { map :element, :string, :string }
95
+ return builder.list(column.name, null: nullable) { |list| list.map :element, :string, :string }
96
96
  end
97
97
 
98
98
  dsl_type, opts = scalar_type(column, type, sql_type, primary)