herringbone 0.3.0 → 0.5.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 +66 -0
- data/README.md +236 -38
- data/bin/herringbone +40 -10
- data/lib/herringbone/active_record.rb +1 -1
- data/lib/herringbone/byte_values.rb +9 -0
- data/lib/herringbone/codecs/lzo.rb +224 -0
- data/lib/herringbone/codecs/snappy.rb +13 -20
- data/lib/herringbone/compression.rb +26 -38
- data/lib/herringbone/encodings/plain.rb +2 -2
- data/lib/herringbone/inferring_writer.rb +168 -0
- data/lib/herringbone/reader/column_chunk_reader.rb +5 -8
- data/lib/herringbone/reader/numo.rb +18 -26
- data/lib/herringbone/reader.rb +3 -3
- data/lib/herringbone/redaction/rewriter.rb +533 -0
- data/lib/herringbone/redaction.rb +287 -0
- data/lib/herringbone/schema.rb +128 -69
- data/lib/herringbone/simple_writer.rb +162 -0
- data/lib/herringbone/thrift.rb +5 -5
- data/lib/herringbone/version.rb +1 -1
- data/lib/herringbone/writer.rb +156 -38
- data/lib/herringbone/xxhash.rb +21 -36
- data/lib/herringbone.rb +95 -9
- metadata +8 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4aca873b946a39da4bee947137c6914f0b321f95266f252f35487087e40ce3f4
|
|
4
|
+
data.tar.gz: 6d7f439f61d6d871cfbf8682e2af71bf2c502b5360355570aabeed373240d0d7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 9cc7d70651be9426aec7608b1fa456545ea0c5318c4aaf193a67b14c7d2371a7173c62cca23a73f603d412dd2ddafa5d8f8e0833b08e6efc9c0632fe367e56d2
|
|
7
|
+
data.tar.gz: bb1b6ed8e32eb6bcbdb0c29ca1dbd773a74dd2f5d2587253a4a297d76a27ffddc7f0a508baabb9a3d45d0bcd40423f3f5ea531cdc4adf3b4564c3344f61f3fda
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
## 0.5.0
|
|
6
|
+
|
|
7
|
+
- `Herringbone.redact` and `Herringbone::Redaction` rewrite a file with rows deleted, values
|
|
8
|
+
replaced or columns dropped, for GDPR erasure and pseudonymization, copying the row groups they
|
|
9
|
+
don't touch byte for byte.
|
|
10
|
+
- `Herringbone::Redaction#affects?` says whether a redaction would change a file, reading only
|
|
11
|
+
statistics, bloom filters and the `where` columns.
|
|
12
|
+
- `Herringbone::Writer#inspect` (and `SimpleWriter`, `ByteValues`) prints a one-line summary with
|
|
13
|
+
the writer's state and row count instead of dumping the buffered rows.
|
|
14
|
+
- **Breaking:** schema DSL blocks receive the builder as a parameter instead of running with
|
|
15
|
+
`instance_eval`: `Herringbone::Schema.define { |s| s.int64 :id }`, likewise for `Schema.infer`,
|
|
16
|
+
`Herringbone.write`, `SimpleWriter.new` and nested `struct`/`list`/`map` blocks. A block without
|
|
17
|
+
a parameter raises `ArgumentError`.
|
|
18
|
+
|
|
19
|
+
## 0.4.0
|
|
20
|
+
|
|
21
|
+
- herringbone is now Ractor-enabled! Reading and writing work from any Ractor on Ruby 3.1+, and schemas can be passed to Ractors with
|
|
22
|
+
- **Breaking:** the optional gems (`zstd-ruby`, `brotli`, `snappy`, `xxhash`, `numo-narray-alt`)
|
|
23
|
+
are used only when loaded: require them (`Bundler.require` does) instead of relying on
|
|
24
|
+
Herringbone to require them on first use.
|
|
25
|
+
- Files with LZO-compressed pages (written by parquet-mr with hadoop-lzo) can be read. Writing
|
|
26
|
+
LZO is not supported.
|
|
27
|
+
- `Herringbone::SimpleWriter`: CSV-style writing with `headers!` and Array rows, types inferred.
|
|
28
|
+
- `Herringbone.write` without `schema:` reads the rows once, holding back only the first 1000 for
|
|
29
|
+
inference, so cursors and one-shot Enumerators no longer lose rows.
|
|
30
|
+
- `Herringbone.write(io, rows) { json :payload }` declares fields that replace inferred ones.
|
|
31
|
+
- `Herringbone::SchemaMismatch` (an `EncodeError`) explains a row that doesn't fit an inferred
|
|
32
|
+
schema and stops the write; `EncodeError` has `row`, `column` and `value`.
|
|
33
|
+
- `herringbone inspect --format=html` opens the page in the browser when run in a terminal.
|
|
34
|
+
`Ractor.make_shareable(schema)`.
|
|
35
|
+
- **Breaking:** `herringbone inspect` takes `--format=text|json|html` instead of `--json`/`--html`.
|
|
36
|
+
|
|
37
|
+
## 0.3.0
|
|
38
|
+
|
|
39
|
+
- `read(as: :numo)` and `each_batch(as: :numo)` return Numo arrays (optional `numo-narray-alt`).
|
|
40
|
+
- The `snappy` gem is used for Snappy when installed: 27x faster compression, 12x faster decompression.
|
|
41
|
+
- Writes are forward-only, so files can be streamed into S3 multipart uploads.
|
|
42
|
+
- YARD documentation for the whole library.
|
|
43
|
+
|
|
44
|
+
## 0.2.0
|
|
45
|
+
|
|
46
|
+
- Streaming reads: pages are decoded incrementally and rows assembled in batches.
|
|
47
|
+
- `each_batch(as: :columns)` and `read(as: :columns)` for column-order reads.
|
|
48
|
+
- `where:`, `from:` and `limit:` skip row groups and pages using statistics, bloom filters and the
|
|
49
|
+
page index.
|
|
50
|
+
- Page indexes (ColumnIndex and OffsetIndex) are written for every column chunk.
|
|
51
|
+
- Bloom filters are written and read, using the `xxhash` gem when it is installed.
|
|
52
|
+
- `Herringbone::Inspector`, an HTML visualizer and `herringbone inspect`: page headers, page
|
|
53
|
+
indexes, CRC verification and the decoded ARROW:schema, without decompressing any data.
|
|
54
|
+
- `Herringbone.write` takes ActiveRecord models and relations.
|
|
55
|
+
- `zstd-ruby` and `brotli` are optional; a missing codec raises `MissingCodecError`.
|
|
56
|
+
- **Breaking:** the public API was simplified: `Reader.new(io)` and `Writer.open(io, schema)`
|
|
57
|
+
only take IOs, Hash schemas and `Herringbone.export`/`open`/`read` are gone, and `DecodeError`
|
|
58
|
+
is now `FormatError`.
|
|
59
|
+
|
|
60
|
+
## 0.1.0
|
|
61
|
+
|
|
62
|
+
- Pure-Ruby Parquet reader and writer, with no Thrift gem or native extensions.
|
|
63
|
+
- Full nesting support (structs, lists, maps) via Dremel shredding and assembly.
|
|
64
|
+
- All standard encodings, v1 and v2 data pages, Snappy, LZ4 and GZIP in Ruby, ZSTD and Brotli via gems.
|
|
65
|
+
- Schemas from a block DSL, inferred from rows, or built from ActiveRecord models.
|
|
66
|
+
- Writer coercions for Rails values, byte-based row groups and compact buffers.
|
data/README.md
CHANGED
|
@@ -3,34 +3,71 @@
|
|
|
3
3
|
A pure-Ruby reader and writer for [Apache Parquet](https://parquet.apache.org/) files.
|
|
4
4
|
|
|
5
5
|
- No Thrift gem and no native extensions of its own: the Thrift compact protocol, all encodings
|
|
6
|
-
and the Snappy and LZ4 codecs are implemented in Ruby (ZSTD and Brotli use optional gems)
|
|
6
|
+
and the Snappy and LZ4 codecs are implemented in Ruby (ZSTD and Brotli use optional gems).
|
|
7
|
+
LZO, from Hadoop-era files, can be read but not written
|
|
7
8
|
- Full nesting support (structs, lists, maps, any depth) via Dremel record shredding/assembly
|
|
8
9
|
- Reads files from parquet-mr, Arrow, Spark, Impala, DuckDB, Rust writers etc.
|
|
10
|
+
- Optimized reads with batches and pages
|
|
9
11
|
- Ruby 3.0+
|
|
10
12
|
|
|
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
|
+
```
|
|
24
|
+
|
|
11
25
|
## Installation
|
|
12
26
|
|
|
13
27
|
```ruby
|
|
14
28
|
gem "herringbone"
|
|
15
|
-
gem "snappy" # optional: native Snappy, 2-3x faster reads and writes of typical files
|
|
16
|
-
gem "zstd-ruby" # optional: ZSTD (faster writes and smaller files than the default Snappy)
|
|
17
|
-
gem "brotli" # optional: Brotli
|
|
18
|
-
gem "xxhash" # optional: faster bloom filters
|
|
19
|
-
gem "numo-narray-alt" # optional: read(as: :numo)
|
|
20
29
|
```
|
|
21
30
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
31
|
+
Add these libraries to speed up and enable certain functionality:
|
|
32
|
+
|
|
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
|
+
```
|
|
40
|
+
|
|
41
|
+
Herringbone never requires them itself: it uses whichever are loaded. Bundler.require (as in Rails)
|
|
42
|
+
loads them, otherwise require them yourself:
|
|
43
|
+
|
|
44
|
+
```ruby
|
|
45
|
+
require "herringbone"
|
|
46
|
+
require "zstd-ruby"
|
|
47
|
+
require "numo/narray"
|
|
48
|
+
```
|
|
49
|
+
|
|
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:
|
|
53
|
+
|
|
54
|
+
```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
|
|
63
|
+
end
|
|
64
|
+
rows.each { |row| writer.send(Ractor.make_shareable(row)) }
|
|
65
|
+
writer.send(:done)
|
|
66
|
+
```
|
|
29
67
|
|
|
30
68
|
## Reading
|
|
31
69
|
|
|
32
|
-
Herringbone
|
|
33
|
-
`"rb"`, `StringIO`, `Tempfile`...), which stays open and belongs to the caller.
|
|
70
|
+
Herringbone can read from any IO-ish object with random access (the IO should be seekable).
|
|
34
71
|
|
|
35
72
|
```ruby
|
|
36
73
|
require "herringbone"
|
|
@@ -84,8 +121,8 @@ shows which row groups and row ranges a read would touch, without reading them.
|
|
|
84
121
|
|
|
85
122
|
`as: :numo` returns (or yields, with `each_batch`) a Hash of column name => Numo array, and takes
|
|
86
123
|
the same `columns:`, `where:`, `from:` and `limit:`. Add `gem "numo-narray-alt"` (or
|
|
87
|
-
`numo-narray`) to your Gemfile
|
|
88
|
-
`Herringbone::UnsupportedError` naming the gem
|
|
124
|
+
`numo-narray`) to your Gemfile and `require "numo/narray"`: without it, `as: :numo` raises
|
|
125
|
+
`Herringbone::UnsupportedError` naming the gem.
|
|
89
126
|
|
|
90
127
|
```ruby
|
|
91
128
|
cols = reader.read(as: :numo, columns: %w[id amount]) # { "id" => Numo::Int64, "amount" => Numo::DFloat }
|
|
@@ -111,19 +148,19 @@ with `each_batch` it can differ between batches.
|
|
|
111
148
|
## Writing
|
|
112
149
|
|
|
113
150
|
```ruby
|
|
114
|
-
schema = Herringbone::Schema.define do
|
|
115
|
-
int64 :id, null: false
|
|
116
|
-
string :name
|
|
117
|
-
enum :status, values: %w[pending paid shipped]
|
|
118
|
-
list :tags, :string
|
|
119
|
-
map :scores, :string, :double
|
|
120
|
-
struct :address do
|
|
121
|
-
string :city
|
|
122
|
-
string :zip
|
|
151
|
+
schema = Herringbone::Schema.define do |s|
|
|
152
|
+
s.int64 :id, null: false
|
|
153
|
+
s.string :name
|
|
154
|
+
s.enum :status, values: %w[pending paid shipped]
|
|
155
|
+
s.list :tags, :string
|
|
156
|
+
s.map :scores, :string, :double
|
|
157
|
+
s.struct :address do |address|
|
|
158
|
+
address.string :city
|
|
159
|
+
address.string :zip
|
|
123
160
|
end
|
|
124
|
-
decimal :price, precision: 12, scale: 2
|
|
125
|
-
json :payload
|
|
126
|
-
timestamp :created_at
|
|
161
|
+
s.decimal :price, precision: 12, scale: 2
|
|
162
|
+
s.json :payload
|
|
163
|
+
s.timestamp :created_at
|
|
127
164
|
end
|
|
128
165
|
|
|
129
166
|
File.open("out.parquet", "wb") do |file|
|
|
@@ -140,7 +177,10 @@ end
|
|
|
140
177
|
|
|
141
178
|
`Herringbone.write(io, rows)` writes an Enumerable of rows in one go, inferring the schema from the
|
|
142
179
|
first 1000 rows unless `schema:` is given; fields declared in a block replace inferred ones:
|
|
143
|
-
`Herringbone.write(io, rows
|
|
180
|
+
`Herringbone.write(io, rows) { |s| s.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.
|
|
144
184
|
|
|
145
185
|
The writer writes to any IO that responds to `#write` (a `File`, `StringIO`, `Tempfile`, socket or
|
|
146
186
|
pipe), sequentially, and never seeks, rewinds or closes it (it does switch it to binary mode). If
|
|
@@ -148,11 +188,15 @@ the `Writer.open` block raises (or `#abort` is called), no footer is written and
|
|
|
148
188
|
so far is left for you to discard. To replace a file only once it is complete, write to a temporary
|
|
149
189
|
file and rename it.
|
|
150
190
|
|
|
191
|
+
The block receives the schema builder, and the blocks of `struct`, `list` and `map` receive a
|
|
192
|
+
builder of their own, so the block can still call your methods and read your instance variables.
|
|
193
|
+
A block that takes no parameter raises `ArgumentError`.
|
|
194
|
+
|
|
151
195
|
Column types: `boolean int8 int16 int32 int64 uint8 uint16 uint32 uint64 float double float16
|
|
152
196
|
string binary json bson enum uuid date int96 time timestamp decimal fixed`, plus `struct`, `list`
|
|
153
|
-
and `map`; `column :name, :int32` declares one by name. Fields are nullable unless `null: false`
|
|
197
|
+
and `map`; `s.column :name, :int32` declares one by name. Fields are nullable unless `null: false`
|
|
154
198
|
is given; list elements unless `element_null: false`, map values unless `value_null: false`.
|
|
155
|
-
Nested lists: `list :matrix do list :element, :double end`. `time` and `timestamp` take `unit:`
|
|
199
|
+
Nested lists: `s.list :matrix do |matrix| matrix.list :element, :double end`. `time` and `timestamp` take `unit:`
|
|
156
200
|
(`:millis`, `:micros`, `:nanos`) and `utc:`.
|
|
157
201
|
|
|
158
202
|
`enum` is a string column. `values:` restricts what may be written, and also takes a Rails-style
|
|
@@ -192,6 +236,24 @@ Writer options:
|
|
|
192
236
|
| `metadata` | `{}` | footer key/value metadata, read back with `reader.metadata` |
|
|
193
237
|
| `bloom_filters` | none | `true`, an Array of column paths, or `{ "path" => { ndv:, fpp:, max_bytes: } }` |
|
|
194
238
|
|
|
239
|
+
### Coming from CSV
|
|
240
|
+
|
|
241
|
+
`SimpleWriter` writes like the CSV gem: name the columns, then append Arrays. The types are
|
|
242
|
+
inferred from the first 1000 rows, as above.
|
|
243
|
+
|
|
244
|
+
```ruby
|
|
245
|
+
File.open("people.parquet", "wb") do |file|
|
|
246
|
+
Herringbone::SimpleWriter.open(file) do |sw|
|
|
247
|
+
sw.headers!(:id, :name, :age)
|
|
248
|
+
sw << [123, "John", 12]
|
|
249
|
+
sw << { id: 124, name: "Jane" } # Hashes work too
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
Unlike CSV, headers are required. A column that holds more than one type can be declared up front:
|
|
255
|
+
`Herringbone::SimpleWriter.new(io) { |s| s.string :code }` (then call `close` when done).
|
|
256
|
+
|
|
195
257
|
### Statistics, page indexes and bloom filters
|
|
196
258
|
|
|
197
259
|
Every column chunk gets min/max/null-count statistics and the Parquet page index (per-page min/max
|
|
@@ -204,7 +266,7 @@ emails or UUIDs; a bloom filter can. They are off by default. `bloom_filters: ["
|
|
|
204
266
|
(or `true` for every non-boolean column) writes Parquet's split block bloom filters, sized from
|
|
205
267
|
each row group's distinct values at a false positive probability of 1% (`fpp:`, up to `max_bytes:`,
|
|
206
268
|
default 1MB) unless `ndv:` gives the number of distinct values. Nested leaves are named by their
|
|
207
|
-
dotted path (`"tags.list.element"`). Hashing is pure Ruby unless the `xxhash` gem is
|
|
269
|
+
dotted path (`"tags.list.element"`). Hashing is pure Ruby unless the `xxhash` gem is loaded:
|
|
208
270
|
a million rows take about 1.2 s longer to write with a filter on an INT64 column and 2.8 s longer
|
|
209
271
|
with one on a ~22-byte string column, and about 0.5 s longer with `xxhash`.
|
|
210
272
|
|
|
@@ -227,6 +289,136 @@ If the block raises, the SDK aborts the multipart upload and raises `Aws::S3::Mu
|
|
|
227
289
|
so no partial object is left. S3 allows at most 10,000 parts, which with the default 5MB parts caps
|
|
228
290
|
the file at about 48GB; raise `part_size:` for bigger files.
|
|
229
291
|
|
|
292
|
+
## Redaction
|
|
293
|
+
|
|
294
|
+
`Herringbone.redact` rewrites a file with rows removed or values replaced, for GDPR "forget me"
|
|
295
|
+
requests and pseudonymization. One file goes in and one comes out, and the parts of the file
|
|
296
|
+
nothing touches are copied byte for byte.
|
|
297
|
+
|
|
298
|
+
```ruby
|
|
299
|
+
# Forget me: remove the rows
|
|
300
|
+
Herringbone.redact(input, output) do |r|
|
|
301
|
+
r.where(user_id: 42).delete
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
# Forget me, but keep the row for accounting: blank the personal columns
|
|
305
|
+
Herringbone.redact(input, output) do |r|
|
|
306
|
+
r.where(user_id: 42).replace(email: nil, name: nil, address: nil)
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
# Forget me, but keep the row linkable: pseudonymize just that user's email
|
|
310
|
+
Herringbone.redact(input, output) do |r|
|
|
311
|
+
r.where(user_id: 42).replace(:email) { |email| OpenSSL::HMAC.hexdigest("SHA256", KEY, email) }
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
# Pseudonymize a column across the whole file, mask another, drop a third
|
|
315
|
+
Herringbone.redact(input, output) do |r|
|
|
316
|
+
r.replace(:email) { |email| OpenSSL::HMAC.hexdigest("SHA256", KEY, email.downcase) }
|
|
317
|
+
r.replace(:phone) { |phone| phone && "***#{phone[-3..]}" }
|
|
318
|
+
r.drop :ssn, :ip_address
|
|
319
|
+
end
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
A `Herringbone::Redaction` is built once and applies itself to any number of files, which suits a
|
|
323
|
+
forget-me job going over a bucket:
|
|
324
|
+
|
|
325
|
+
```ruby
|
|
326
|
+
forget = Herringbone::Redaction.new do |r|
|
|
327
|
+
r.where(user_id: 42).delete
|
|
328
|
+
r.where(email: "anna@example.com").delete # separate statements OR together
|
|
329
|
+
r.where(created_at: ..2.years.ago).delete # retention works the same way
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
if forget.affects?(input) # statistics and bloom filters first, then the where columns only
|
|
333
|
+
report = forget.apply(input, output)
|
|
334
|
+
report.rows_deleted # => 3
|
|
335
|
+
report.row_groups # => { copied: 61, rewritten: 1 }
|
|
336
|
+
end
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
The block receives the redaction being built, like `Writer.open` passes the writer, so it can use
|
|
340
|
+
the methods and instance variables around it; a block without the `|r|` raises `ArgumentError`.
|
|
341
|
+
`where` takes what `read(where:)` takes (values, Arrays, Ranges, `nil`, callables and dotted
|
|
342
|
+
struct paths, but no columns inside lists or maps), and skips row groups and pages the same way.
|
|
343
|
+
`where(...).delete` removes the matching rows. `replace` sets constants,
|
|
344
|
+
`replace(email: nil, name: "[deleted]")`, or computes each value with a block,
|
|
345
|
+
`replace(:email, :phone) { |value| ... }`. A block that takes two parameters also gets the whole
|
|
346
|
+
row, as a Hash with String keys like `read` returns. Without `where`, `replace` applies to every
|
|
347
|
+
row. A column is a top-level field or a struct member by dotted path (`"address.city"`; a member
|
|
348
|
+
of a null struct is left alone). To change what is inside a list or map, replace the whole field
|
|
349
|
+
with a block that receives the Array or Hash. `drop :a, :b` removes columns from the schema.
|
|
350
|
+
|
|
351
|
+
Statements apply in declared order, row by row: a deleted row is gone for later statements, and
|
|
352
|
+
later statements see what earlier ones replaced, in their conditions as well as in their blocks.
|
|
353
|
+
Mistakes raise `ArgumentError` before anything is written: a `where` without a verb, a column that
|
|
354
|
+
doesn't exist, `nil` (or a value of the wrong type) for a `null: false` column. A block that
|
|
355
|
+
returns something its column can't store raises `Herringbone::EncodeError` when it gets there and
|
|
356
|
+
leaves the output unfinished.
|
|
357
|
+
|
|
358
|
+
`apply` and `Herringbone.redact` take IOs like `Reader` and `Writer` do: the input must be
|
|
359
|
+
seekable, the output only needs `#write`, and neither is closed. To redact in place, write to a
|
|
360
|
+
temporary file and rename it over the original; on S3, read the object and write the new one
|
|
361
|
+
through `upload_stream`. The `Redaction::Report` that `apply` returns has `rows_read`,
|
|
362
|
+
`rows_deleted`, `rows_changed` and `row_groups` (`{ copied:, rewritten: }`), which is the audit
|
|
363
|
+
trail an erasure needs.
|
|
364
|
+
|
|
365
|
+
Each row group is handled in the cheapest way that is still exact. If no statement can match it,
|
|
366
|
+
judging by statistics, bloom filters and the page index, or by reading only the `where` columns,
|
|
367
|
+
its column chunks are copied as they are and only their offsets are rebased. If rows match but
|
|
368
|
+
none is deleted and only leaf columns change, just those column chunks are encoded again. If rows
|
|
369
|
+
are deleted, or a nested field is replaced whole, the row group is rewritten, as one row group
|
|
370
|
+
(or none, when every row goes). Rewritten chunks keep the codec of the original chunk (LZO, which
|
|
371
|
+
Herringbone can't write, becomes Snappy) and get a new bloom filter if they had one. Writer
|
|
372
|
+
options (`compression:`, `bloom_filters:`, `page_rows:`...) apply to the rewritten chunks;
|
|
373
|
+
`row_group_bytes:` and `row_group_rows:` are refused, since row groups keep their boundaries. The
|
|
374
|
+
footer's key/value metadata is copied (without `ARROW:schema` and `pandas` when columns are
|
|
375
|
+
dropped, since those describe the columns) unless `metadata:` is given.
|
|
376
|
+
|
|
377
|
+
No deleted or replaced value survives in the output: not in data pages, dictionary pages, column
|
|
378
|
+
chunk min/max statistics, the page index or bloom filters. What Herringbone can't reach is up to
|
|
379
|
+
you: the original file, its S3 versions and backups still hold the data until you delete them,
|
|
380
|
+
only the columns you name are touched (an email that also sits in a free-text `notes` column stays
|
|
381
|
+
there), and other files with the same person in them need their own pass.
|
|
382
|
+
|
|
383
|
+
Some recipes. Keyed hashing keeps a column joinable without keeping the value; keep the key out
|
|
384
|
+
of the data, and rotate or destroy it to cut the link:
|
|
385
|
+
|
|
386
|
+
```ruby
|
|
387
|
+
require "openssl"
|
|
388
|
+
KEY = ENV.fetch("PSEUDONYM_KEY")
|
|
389
|
+
Herringbone.redact(input, output) do |r|
|
|
390
|
+
r.replace(:email) { |email| email && OpenSSL::HMAC.hexdigest("SHA256", KEY, email.downcase) }
|
|
391
|
+
end
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
Masking keeps enough to be recognizable to the person but not to anyone else:
|
|
395
|
+
|
|
396
|
+
```ruby
|
|
397
|
+
Herringbone.redact(input, output) do |r|
|
|
398
|
+
r.replace(:card_number) { |number| number && number[-4..].rjust(number.size, "*") }
|
|
399
|
+
r.replace(:email) { |email| email&.sub(/\A(.).*@/, '\1***@') }
|
|
400
|
+
r.replace(:birth_date) { |date| date && Date.new(date.year, 1, 1) }
|
|
401
|
+
end
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
Fake values (with the `faker` gem) make a copy for staging that looks real. Seed Faker from the
|
|
405
|
+
original value to get the same fake for the same person in every file:
|
|
406
|
+
|
|
407
|
+
```ruby
|
|
408
|
+
require "faker"
|
|
409
|
+
require "zlib"
|
|
410
|
+
Herringbone.redact(input, output) do |r|
|
|
411
|
+
r.replace(:name) do |name|
|
|
412
|
+
next nil unless name
|
|
413
|
+
Faker::Config.random = Random.new(Zlib.crc32(name))
|
|
414
|
+
Faker::Name.name
|
|
415
|
+
end
|
|
416
|
+
r.replace(:address) do |address|
|
|
417
|
+
address && { "city" => Faker::Address.city, "zip" => Faker::Address.zip_code }
|
|
418
|
+
end
|
|
419
|
+
end
|
|
420
|
+
```
|
|
421
|
+
|
|
230
422
|
## ActiveRecord
|
|
231
423
|
|
|
232
424
|
`Herringbone.write` also takes a model or relation, which it reads with `find_each`, using a schema
|
|
@@ -281,7 +473,7 @@ follows `Model.columns`.
|
|
|
281
473
|
|
|
282
474
|
> The inspector and its HTML view are modelled on
|
|
283
475
|
> **[Parquet X-ray](https://huggingface.co/spaces/cfahlgren1/parquet-xray) by cfahlgren1** —
|
|
284
|
-
> the design and the idea are theirs. Go check it out.
|
|
476
|
+
> the design and the idea are theirs. Go check it out. It is amazing!
|
|
285
477
|
|
|
286
478
|
`Herringbone::Inspector` examines a file using only its footer, page headers, page indexes and
|
|
287
479
|
bloom filter headers. Nothing is decompressed, so it is fast on big files and works for ZSTD and
|
|
@@ -304,14 +496,20 @@ CRCs; the results then appear in `summary`, `report`, `to_h` and `to_html`.
|
|
|
304
496
|
## Command line
|
|
305
497
|
|
|
306
498
|
```
|
|
307
|
-
bin/herringbone cat FILE [N]
|
|
308
|
-
bin/herringbone inspect FILE [--pages]
|
|
309
|
-
bin/herringbone inspect FILE --json # everything as JSON
|
|
310
|
-
bin/herringbone inspect FILE --html
|
|
499
|
+
bin/herringbone cat FILE [N] # rows as JSON lines
|
|
500
|
+
bin/herringbone inspect FILE [--pages] # text report (--pages lists every page header)
|
|
501
|
+
bin/herringbone inspect FILE --format=json # everything as JSON
|
|
502
|
+
bin/herringbone inspect FILE --format=html # the HTML page, opened in your browser
|
|
503
|
+
bin/herringbone inspect FILE --format=html > out.html # the HTML page, saved
|
|
311
504
|
```
|
|
312
505
|
|
|
313
506
|
Add `--verify-checksums` to any `inspect` form to check page CRCs.
|
|
314
507
|
|
|
508
|
+
`--format` is `text` (the default), `json` or `html`; `--pages` only goes with `text`.
|
|
509
|
+
In a terminal, `--format=html` writes the page to a temp file, prints its path and opens it with `open` on
|
|
510
|
+
macOS, `start` on Windows or `xdg-open` elsewhere. When stdout is redirected or piped it prints the
|
|
511
|
+
page instead.
|
|
512
|
+
|
|
315
513
|
## Supported format features
|
|
316
514
|
|
|
317
515
|
- Encodings (read and write): PLAIN, PLAIN_DICTIONARY/RLE_DICTIONARY, RLE, DELTA_BINARY_PACKED,
|
data/bin/herringbone
CHANGED
|
@@ -3,14 +3,21 @@
|
|
|
3
3
|
|
|
4
4
|
# Usage:
|
|
5
5
|
# herringbone cat FILE [N] print rows as JSON lines (optionally only the first N)
|
|
6
|
-
# herringbone inspect FILE [--pages]
|
|
7
|
-
#
|
|
8
|
-
# herringbone inspect FILE --
|
|
6
|
+
# herringbone inspect FILE [--pages] print the schema, row groups, column chunks (and every
|
|
7
|
+
# page header); same as --format=text
|
|
8
|
+
# herringbone inspect FILE --format=json print all of it as JSON
|
|
9
|
+
# herringbone inspect FILE --format=html write a self-contained HTML page showing the file's layout
|
|
10
|
+
# and open it in the browser (printed when stdout is redirected)
|
|
9
11
|
# add --verify-checksums to any inspect form to read page bodies and check their CRCs
|
|
10
12
|
# `inspect` reads only the footer, page headers and page indexes: no values are decompressed.
|
|
11
13
|
$LOAD_PATH.unshift File.expand_path("../lib", __dir__)
|
|
12
14
|
require "herringbone"
|
|
13
15
|
require "json"
|
|
16
|
+
%w[zstd-ruby brotli snappy xxhash].each do |lib|
|
|
17
|
+
require lib
|
|
18
|
+
rescue LoadError
|
|
19
|
+
end
|
|
20
|
+
require "tmpdir"
|
|
14
21
|
|
|
15
22
|
def jsonable(v)
|
|
16
23
|
case v
|
|
@@ -25,8 +32,20 @@ def jsonable(v)
|
|
|
25
32
|
end
|
|
26
33
|
end
|
|
27
34
|
|
|
35
|
+
# Opens a file with the platform's default application
|
|
36
|
+
# @return [Boolean] whether the opener could be started
|
|
37
|
+
def open_with_default_app(path)
|
|
38
|
+
command = case RbConfig::CONFIG["host_os"]
|
|
39
|
+
when /darwin/ then ["open", path]
|
|
40
|
+
when /mswin|mingw|cygwin/ then ["cmd", "/c", "start", "", path]
|
|
41
|
+
else ["xdg-open", path]
|
|
42
|
+
end
|
|
43
|
+
!!system(*command, out: File::NULL, err: File::NULL, exception: false)
|
|
44
|
+
end
|
|
45
|
+
|
|
28
46
|
USAGE = "usage: herringbone cat FILE [N]\n" \
|
|
29
|
-
" herringbone inspect FILE [--
|
|
47
|
+
" herringbone inspect FILE [--format=text|json|html] [--pages] [--verify-checksums]\n" \
|
|
48
|
+
" (--pages only with --format=text)"
|
|
30
49
|
|
|
31
50
|
command, *rest = ARGV
|
|
32
51
|
flags, args = rest.partition { |a| a.start_with?("--") }
|
|
@@ -34,8 +53,11 @@ path, limit = args
|
|
|
34
53
|
valid = case command
|
|
35
54
|
when "cat" then flags.empty? && path && (limit.nil? || limit.match?(/\A\d+\z/))
|
|
36
55
|
when "inspect"
|
|
37
|
-
|
|
38
|
-
|
|
56
|
+
format_flags, other_flags = flags.partition { |f| f.start_with?("--format=") }
|
|
57
|
+
format = format_flags.empty? ? "text" : format_flags.first.delete_prefix("--format=")
|
|
58
|
+
path && limit.nil? && format_flags.size <= 1 && %w[text json html].include?(format) &&
|
|
59
|
+
(other_flags - %w[--pages --verify-checksums]).empty? &&
|
|
60
|
+
(format == "text" || !other_flags.include?("--pages"))
|
|
39
61
|
end
|
|
40
62
|
unless valid
|
|
41
63
|
warn USAGE
|
|
@@ -51,11 +73,19 @@ begin
|
|
|
51
73
|
else
|
|
52
74
|
inspector = Herringbone::Inspector.new(io)
|
|
53
75
|
inspector.verify_checksums if flags.include?("--verify-checksums")
|
|
54
|
-
|
|
76
|
+
case format
|
|
77
|
+
when "json"
|
|
55
78
|
puts JSON.pretty_generate(inspector.to_h)
|
|
56
|
-
|
|
57
|
-
$stdout.
|
|
58
|
-
|
|
79
|
+
when "html"
|
|
80
|
+
if $stdout.tty?
|
|
81
|
+
html_path = File.join(Dir.tmpdir, "#{File.basename(path, ".*")}-#{Process.pid}.html")
|
|
82
|
+
File.binwrite(html_path, inspector.to_html)
|
|
83
|
+
puts html_path
|
|
84
|
+
warn "herringbone: could not open the page, open #{html_path} in a browser" unless open_with_default_app(html_path)
|
|
85
|
+
else
|
|
86
|
+
$stdout.write(inspector.to_html)
|
|
87
|
+
end
|
|
88
|
+
when "text"
|
|
59
89
|
puts inspector.report(pages: flags.include?("--pages"))
|
|
60
90
|
end
|
|
61
91
|
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)
|
|
@@ -45,6 +45,15 @@ module Herringbone
|
|
|
45
45
|
# @return [Boolean] true while still dictionary-encoding (not yet switched to raw bytes)
|
|
46
46
|
def dictionary? = !@indices.nil?
|
|
47
47
|
|
|
48
|
+
# Short summary for the console, without the values
|
|
49
|
+
#
|
|
50
|
+
# @return [String] value count, the dictionary size (or "raw" after switching to raw bytes),
|
|
51
|
+
# the width for FIXED_LEN_BYTE_ARRAY and the estimated memory
|
|
52
|
+
def inspect
|
|
53
|
+
mode = dictionary? ? "dictionary=#{@dictionary.size}" : "raw"
|
|
54
|
+
"#<#{self.class.name} size=#{size} #{mode}#{" width=#{@width}" if @width} memory_bytes=#{memory_bytes}>"
|
|
55
|
+
end
|
|
56
|
+
|
|
48
57
|
# Appends a value, switching to raw bytes when the dictionary limits are exceeded.
|
|
49
58
|
# @param value [String] bytes of the value; for FIXED_LEN_BYTE_ARRAY it must be +width+ bytes
|
|
50
59
|
# long (not checked here)
|