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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +42 -0
- data/MANUAL.md +773 -0
- data/README.md +81 -316
- data/bin/herringbone +86 -7
- data/lib/herringbone/active_record.rb +1 -1
- data/lib/herringbone/bloom_filter.rb +0 -55
- data/lib/herringbone/byte_values.rb +9 -0
- data/lib/herringbone/compression.rb +27 -4
- data/lib/herringbone/encryption.rb +716 -0
- data/lib/herringbone/encryption_configuration.rb +304 -0
- data/lib/herringbone/format.rb +55 -3
- data/lib/herringbone/inferring_writer.rb +24 -2
- data/lib/herringbone/inspector.rb +158 -28
- data/lib/herringbone/key.rb +107 -0
- data/lib/herringbone/reader/bloom_filters.rb +91 -0
- data/lib/herringbone/reader/column_chunk_reader.rb +55 -1
- data/lib/herringbone/reader.rb +100 -15
- data/lib/herringbone/redaction/rewriter.rb +585 -0
- data/lib/herringbone/redaction.rb +302 -0
- data/lib/herringbone/schema.rb +123 -57
- data/lib/herringbone/simple_writer.rb +42 -4
- data/lib/herringbone/version.rb +1 -1
- data/lib/herringbone/writer.rb +231 -37
- data/lib/herringbone.rb +115 -29
- metadata +9 -2
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
42
|
-
loads them, otherwise require them yourself:
|
|
30
|
+
Stream a relation straight into S3, no temp file needed:
|
|
43
31
|
|
|
44
32
|
```ruby
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
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
|
-
|
|
56
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 }
|
|
81
|
-
reader.each_batch(1000) { |rows| ... }
|
|
82
|
-
reader.
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
113
|
-
|
|
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
|
-
|
|
70
|
+
## Writing CSV-style
|
|
121
71
|
|
|
122
|
-
`
|
|
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
|
-
|
|
129
|
-
|
|
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
|
-
|
|
133
|
-
|
|
84
|
+
To control the types, declare a schema and use `Herringbone::Writer`, see
|
|
85
|
+
[Writing](MANUAL.md#writing).
|
|
134
86
|
|
|
135
|
-
|
|
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
|
-
|
|
146
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
244
|
-
sw
|
|
245
|
-
sw <<
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
285
|
-
|
|
286
|
-
the file
|
|
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
|
-
##
|
|
124
|
+
## Redacting
|
|
289
125
|
|
|
290
|
-
`Herringbone.
|
|
291
|
-
|
|
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
|
-
|
|
295
|
-
|
|
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
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
150
|
+
## Looking inside a file
|
|
363
151
|
|
|
364
152
|
```
|
|
365
|
-
bin/herringbone cat FILE [N]
|
|
366
|
-
bin/herringbone inspect FILE
|
|
367
|
-
bin/herringbone inspect FILE --format=
|
|
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
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
|
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)
|