herringbone 0.2.0 → 0.4.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 +52 -0
- data/README.md +137 -21
- data/bin/herringbone +40 -10
- data/lib/herringbone/active_record.rb +44 -12
- data/lib/herringbone/bloom_filter.rb +112 -12
- data/lib/herringbone/byte_values.rb +29 -0
- data/lib/herringbone/codecs/lz4.rb +66 -3
- data/lib/herringbone/codecs/lzo.rb +224 -0
- data/lib/herringbone/codecs/snappy.rb +125 -3
- data/lib/herringbone/compression.rb +63 -27
- data/lib/herringbone/encodings/delta.rb +70 -13
- data/lib/herringbone/encodings/plain.rb +24 -2
- data/lib/herringbone/encodings/rle.rb +53 -1
- data/lib/herringbone/format.rb +150 -0
- data/lib/herringbone/inferring_writer.rb +161 -0
- data/lib/herringbone/inspector.rb +477 -81
- data/lib/herringbone/io_buffer_support.rb +3 -1
- data/lib/herringbone/reader/column_chunk_reader.rb +102 -13
- data/lib/herringbone/reader/column_cursor.rb +46 -2
- data/lib/herringbone/reader/numo.rb +357 -0
- data/lib/herringbone/reader/page_stream.rb +280 -4
- data/lib/herringbone/reader/scan.rb +103 -9
- data/lib/herringbone/reader.rb +308 -17
- data/lib/herringbone/schema.rb +311 -25
- data/lib/herringbone/simple_writer.rb +152 -0
- data/lib/herringbone/thrift.rb +160 -9
- data/lib/herringbone/types.rb +176 -41
- data/lib/herringbone/version.rb +2 -1
- data/lib/herringbone/visualizer.rb +51 -11
- data/lib/herringbone/writer.rb +322 -43
- data/lib/herringbone/xxhash.rb +122 -31
- data/lib/herringbone.rb +80 -8
- metadata +7 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 826898400d423a36ccd89740d408da3d367726cf84c6cf33c4067a3c54b8861d
|
|
4
|
+
data.tar.gz: 0667170a3ede8376299aff04b4081259533c8877f9823ad8fcc9aee916a86650
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 21984e5695bf75e086fa091a4c69456edd4fc7ef21ea2d73c89e0ad3de1316238d2f66cf9d8c86183c5e962a1ce965aaeac8036dc1a5c1a43aa6a3b85c983405
|
|
7
|
+
data.tar.gz: 9377178bd66028014087cc55ed255c4abc2ed1c0217cdfbd86d5f9fe647a4ac0d741a973415f393c31fc7af83d653ce14df33d7a7629dee69aaed554e1fa5719
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
## 0.4.0
|
|
6
|
+
|
|
7
|
+
- herringbone is now Ractor-enabled! Reading and writing work from any Ractor on Ruby 3.1+, and schemas can be passed to Ractors with
|
|
8
|
+
- **Breaking:** the optional gems (`zstd-ruby`, `brotli`, `snappy`, `xxhash`, `numo-narray-alt`)
|
|
9
|
+
are used only when loaded: require them (`Bundler.require` does) instead of relying on
|
|
10
|
+
Herringbone to require them on first use.
|
|
11
|
+
- Files with LZO-compressed pages (written by parquet-mr with hadoop-lzo) can be read. Writing
|
|
12
|
+
LZO is not supported.
|
|
13
|
+
- `Herringbone::SimpleWriter`: CSV-style writing with `headers!` and Array rows, types inferred.
|
|
14
|
+
- `Herringbone.write` without `schema:` reads the rows once, holding back only the first 1000 for
|
|
15
|
+
inference, so cursors and one-shot Enumerators no longer lose rows.
|
|
16
|
+
- `Herringbone.write(io, rows) { json :payload }` declares fields that replace inferred ones.
|
|
17
|
+
- `Herringbone::SchemaMismatch` (an `EncodeError`) explains a row that doesn't fit an inferred
|
|
18
|
+
schema and stops the write; `EncodeError` has `row`, `column` and `value`.
|
|
19
|
+
- `herringbone inspect --format=html` opens the page in the browser when run in a terminal.
|
|
20
|
+
`Ractor.make_shareable(schema)`.
|
|
21
|
+
- **Breaking:** `herringbone inspect` takes `--format=text|json|html` instead of `--json`/`--html`.
|
|
22
|
+
|
|
23
|
+
## 0.3.0
|
|
24
|
+
|
|
25
|
+
- `read(as: :numo)` and `each_batch(as: :numo)` return Numo arrays (optional `numo-narray-alt`).
|
|
26
|
+
- The `snappy` gem is used for Snappy when installed: 27x faster compression, 12x faster decompression.
|
|
27
|
+
- Writes are forward-only, so files can be streamed into S3 multipart uploads.
|
|
28
|
+
- YARD documentation for the whole library.
|
|
29
|
+
|
|
30
|
+
## 0.2.0
|
|
31
|
+
|
|
32
|
+
- Streaming reads: pages are decoded incrementally and rows assembled in batches.
|
|
33
|
+
- `each_batch(as: :columns)` and `read(as: :columns)` for column-order reads.
|
|
34
|
+
- `where:`, `from:` and `limit:` skip row groups and pages using statistics, bloom filters and the
|
|
35
|
+
page index.
|
|
36
|
+
- Page indexes (ColumnIndex and OffsetIndex) are written for every column chunk.
|
|
37
|
+
- Bloom filters are written and read, using the `xxhash` gem when it is installed.
|
|
38
|
+
- `Herringbone::Inspector`, an HTML visualizer and `herringbone inspect`: page headers, page
|
|
39
|
+
indexes, CRC verification and the decoded ARROW:schema, without decompressing any data.
|
|
40
|
+
- `Herringbone.write` takes ActiveRecord models and relations.
|
|
41
|
+
- `zstd-ruby` and `brotli` are optional; a missing codec raises `MissingCodecError`.
|
|
42
|
+
- **Breaking:** the public API was simplified: `Reader.new(io)` and `Writer.open(io, schema)`
|
|
43
|
+
only take IOs, Hash schemas and `Herringbone.export`/`open`/`read` are gone, and `DecodeError`
|
|
44
|
+
is now `FormatError`.
|
|
45
|
+
|
|
46
|
+
## 0.1.0
|
|
47
|
+
|
|
48
|
+
- Pure-Ruby Parquet reader and writer, with no Thrift gem or native extensions.
|
|
49
|
+
- Full nesting support (structs, lists, maps) via Dremel shredding and assembly.
|
|
50
|
+
- All standard encodings, v1 and v2 data pages, Snappy, LZ4 and GZIP in Ruby, ZSTD and Brotli via gems.
|
|
51
|
+
- Schemas from a block DSL, inferred from rows, or built from ActiveRecord models.
|
|
52
|
+
- Writer coercions for Rails values, byte-based row groups and compact buffers.
|
data/README.md
CHANGED
|
@@ -3,30 +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 "zstd-ruby" # optional: ZSTD (faster writes and smaller files than the default Snappy)
|
|
16
|
-
gem "brotli" # optional: Brotli
|
|
17
|
-
gem "xxhash" # optional: faster bloom filters
|
|
18
29
|
```
|
|
19
30
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
+
```
|
|
25
67
|
|
|
26
68
|
## Reading
|
|
27
69
|
|
|
28
|
-
Herringbone
|
|
29
|
-
`"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).
|
|
30
71
|
|
|
31
72
|
```ruby
|
|
32
73
|
require "herringbone"
|
|
@@ -76,6 +117,34 @@ are exact. On a 1M-row file with 20k-row pages, looking up one `id` takes 0.1 s
|
|
|
76
117
|
Filters help most on columns the data is sorted or clustered by. `reader.scan_plan(where: ...)`
|
|
77
118
|
shows which row groups and row ranges a read would touch, without reading them.
|
|
78
119
|
|
|
120
|
+
### Numo arrays
|
|
121
|
+
|
|
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.
|
|
126
|
+
|
|
127
|
+
```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
|
|
130
|
+
```
|
|
131
|
+
|
|
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`.
|
|
134
|
+
|
|
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 |
|
|
144
|
+
|
|
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
|
+
|
|
79
148
|
## Writing
|
|
80
149
|
|
|
81
150
|
```ruby
|
|
@@ -108,12 +177,16 @@ end
|
|
|
108
177
|
|
|
109
178
|
`Herringbone.write(io, rows)` writes an Enumerable of rows in one go, inferring the schema from the
|
|
110
179
|
first 1000 rows unless `schema:` is given; fields declared in a block replace inferred ones:
|
|
111
|
-
`Herringbone.write(io, rows
|
|
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.
|
|
112
184
|
|
|
113
185
|
The writer writes to any IO that responds to `#write` (a `File`, `StringIO`, `Tempfile`, socket or
|
|
114
|
-
pipe), sequentially, and never seeks, rewinds or closes it
|
|
115
|
-
`#abort` is called), no footer is written and what was written
|
|
116
|
-
To replace a file only once it is complete, write to a temporary
|
|
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.
|
|
117
190
|
|
|
118
191
|
Column types: `boolean int8 int16 int32 int64 uint8 uint16 uint32 uint64 float double float16
|
|
119
192
|
string binary json bson enum uuid date int96 time timestamp decimal fixed`, plus `struct`, `list`
|
|
@@ -159,6 +232,24 @@ Writer options:
|
|
|
159
232
|
| `metadata` | `{}` | footer key/value metadata, read back with `reader.metadata` |
|
|
160
233
|
| `bloom_filters` | none | `true`, an Array of column paths, or `{ "path" => { ndv:, fpp:, max_bytes: } }` |
|
|
161
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.
|
|
239
|
+
|
|
240
|
+
```ruby
|
|
241
|
+
File.open("people.parquet", "wb") do |file|
|
|
242
|
+
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
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
```
|
|
249
|
+
|
|
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
|
+
|
|
162
253
|
### Statistics, page indexes and bloom filters
|
|
163
254
|
|
|
164
255
|
Every column chunk gets min/max/null-count statistics and the Parquet page index (per-page min/max
|
|
@@ -171,10 +262,29 @@ emails or UUIDs; a bloom filter can. They are off by default. `bloom_filters: ["
|
|
|
171
262
|
(or `true` for every non-boolean column) writes Parquet's split block bloom filters, sized from
|
|
172
263
|
each row group's distinct values at a false positive probability of 1% (`fpp:`, up to `max_bytes:`,
|
|
173
264
|
default 1MB) unless `ndv:` gives the number of distinct values. Nested leaves are named by their
|
|
174
|
-
dotted path (`"tags.list.element"`). Hashing is pure Ruby unless the `xxhash` gem is
|
|
265
|
+
dotted path (`"tags.list.element"`). Hashing is pure Ruby unless the `xxhash` gem is loaded:
|
|
175
266
|
a million rows take about 1.2 s longer to write with a filter on an INT64 column and 2.8 s longer
|
|
176
267
|
with one on a ~22-byte string column, and about 0.5 s longer with `xxhash`.
|
|
177
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`:
|
|
273
|
+
|
|
274
|
+
```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) }
|
|
282
|
+
```
|
|
283
|
+
|
|
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.
|
|
287
|
+
|
|
178
288
|
## ActiveRecord
|
|
179
289
|
|
|
180
290
|
`Herringbone.write` also takes a model or relation, which it reads with `find_each`, using a schema
|
|
@@ -229,7 +339,7 @@ follows `Model.columns`.
|
|
|
229
339
|
|
|
230
340
|
> The inspector and its HTML view are modelled on
|
|
231
341
|
> **[Parquet X-ray](https://huggingface.co/spaces/cfahlgren1/parquet-xray) by cfahlgren1** —
|
|
232
|
-
> the design and the idea are theirs. Go check it out.
|
|
342
|
+
> the design and the idea are theirs. Go check it out. It is amazing!
|
|
233
343
|
|
|
234
344
|
`Herringbone::Inspector` examines a file using only its footer, page headers, page indexes and
|
|
235
345
|
bloom filter headers. Nothing is decompressed, so it is fast on big files and works for ZSTD and
|
|
@@ -252,14 +362,20 @@ CRCs; the results then appear in `summary`, `report`, `to_h` and `to_html`.
|
|
|
252
362
|
## Command line
|
|
253
363
|
|
|
254
364
|
```
|
|
255
|
-
bin/herringbone cat FILE [N]
|
|
256
|
-
bin/herringbone inspect FILE [--pages]
|
|
257
|
-
bin/herringbone inspect FILE --json # everything as JSON
|
|
258
|
-
bin/herringbone inspect FILE --html
|
|
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
|
|
259
370
|
```
|
|
260
371
|
|
|
261
372
|
Add `--verify-checksums` to any `inspect` form to check page CRCs.
|
|
262
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
|
+
|
|
263
379
|
## Supported format features
|
|
264
380
|
|
|
265
381
|
- 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
|
|
@@ -11,13 +11,18 @@ module Herringbone
|
|
|
11
11
|
# ActiveRecord is not required: this only uses what a model class exposes
|
|
12
12
|
# (+columns+, +primary_key+ and, when present, +defined_enums+).
|
|
13
13
|
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
14
|
+
# Primary key columns are never nullable. Rails enum attributes are string columns holding the
|
|
15
|
+
# labels (the writer rejects values outside the enum; stored values such as 0/1 are written as
|
|
16
|
+
# their labels).
|
|
17
|
+
#
|
|
18
|
+
# @param model [Class] ActiveRecord model class, or anything exposing +columns+ the same way
|
|
19
|
+
# @param only [Array<String, Symbol>, String, Symbol, nil] attribute names to include
|
|
20
|
+
# @param except [Array<String, Symbol>, String, Symbol, nil] attribute names to leave out
|
|
21
|
+
# @param parquet_enum [Boolean] true adds the Parquet ENUM annotation to enum columns, see Builder#enum
|
|
22
|
+
# @return [Schema] schema with one top-level field per selected column, in model column order
|
|
23
|
+
# @raise [ArgumentError] when no columns are left after +only+ / +except+
|
|
19
24
|
def self.from_active_record(model, only: nil, except: nil, parquet_enum: false)
|
|
20
|
-
only
|
|
25
|
+
only &&= Array(only).map(&:to_s)
|
|
21
26
|
except = Array(except).map(&:to_s)
|
|
22
27
|
defined_enums = model.respond_to?(:defined_enums) ? model.defined_enums.to_h { |k, v| [k.to_s, v] } : {}
|
|
23
28
|
primary_keys = Array(model.respond_to?(:primary_key) ? model.primary_key : nil).map(&:to_s)
|
|
@@ -54,9 +59,14 @@ module Herringbone
|
|
|
54
59
|
module ActiveRecordMapping
|
|
55
60
|
module_function
|
|
56
61
|
|
|
62
|
+
# Precision for decimal columns that declare none (Postgres +numeric+ without arguments);
|
|
63
|
+
# 38 digits is the most a 16-byte FIXED_LEN_BYTE_ARRAY decimal holds
|
|
57
64
|
DEFAULT_DECIMAL_PRECISION = 38
|
|
65
|
+
# Scale used together with DEFAULT_DECIMAL_PRECISION when the column declares neither
|
|
58
66
|
DEFAULT_DECIMAL_SCALE = 9
|
|
59
67
|
|
|
68
|
+
# Bit width of named SQL integer types (MySQL, Postgres and SQLite spellings), keyed by the
|
|
69
|
+
# leading word of the lowercased +sql_type+
|
|
60
70
|
INTEGER_SQL_TYPES = {
|
|
61
71
|
"tinyint" => 8, "int1" => 8,
|
|
62
72
|
"smallint" => 16, "int2" => 16, "smallserial" => 16, "serial2" => 16,
|
|
@@ -64,6 +74,16 @@ module Herringbone
|
|
|
64
74
|
"bigint" => 64, "int8" => 64, "bigserial" => 64, "serial8" => 64
|
|
65
75
|
}.freeze
|
|
66
76
|
|
|
77
|
+
# Declares +column+ on +builder+: hstore as map<string, string>, array columns as a list of
|
|
78
|
+
# the element type, everything else as the scalar type picked by #scalar_type.
|
|
79
|
+
#
|
|
80
|
+
# @param builder [Builder] builder to add the field to
|
|
81
|
+
# @param column [ActiveRecord::ConnectionAdapters::Column] column metadata (+name+, +type+, and
|
|
82
|
+
# when available +sql_type+, +array+, +limit+, +precision+, +scale+)
|
|
83
|
+
# @param nullable [Boolean] whether the field may be null
|
|
84
|
+
# @param primary [Boolean] whether the column is (part of) the primary key
|
|
85
|
+
# @return [Node] the added schema node
|
|
86
|
+
# @raise [ArgumentError] when the builder rejects the field (e.g. a duplicate name)
|
|
67
87
|
def add_column(builder, column, nullable, primary)
|
|
68
88
|
sql_type = column.respond_to?(:sql_type) ? column.sql_type.to_s.downcase : ""
|
|
69
89
|
array = (column.respond_to?(:array) && column.array) || sql_type.end_with?("[]")
|
|
@@ -83,10 +103,18 @@ module Herringbone
|
|
|
83
103
|
end
|
|
84
104
|
end
|
|
85
105
|
|
|
106
|
+
# Picks the DSL type for a non-array, non-hstore column, following the table above.
|
|
107
|
+
#
|
|
108
|
+
# @param column [ActiveRecord::ConnectionAdapters::Column] column metadata, read for
|
|
109
|
+
# +limit+, +precision+ and +scale+
|
|
110
|
+
# @param type [Symbol, nil] ActiveRecord's abstract type (+column.type+)
|
|
111
|
+
# @param sql_type [String] lowercased database type with any array suffix removed
|
|
112
|
+
# @param primary [Boolean] whether the column is (part of) the primary key
|
|
113
|
+
# @return [Array(Symbol, Hash{Symbol => Object})] DSL type and its options for Builder#column
|
|
86
114
|
def scalar_type(column, type, sql_type, primary)
|
|
87
115
|
case type
|
|
88
116
|
when :integer, :bigint then [integer_type(column, sql_type, primary), {}]
|
|
89
|
-
when :float then [sql_type == "float4" ? :float : :double, {}]
|
|
117
|
+
when :float then [(sql_type == "float4") ? :float : :double, {}]
|
|
90
118
|
when :decimal, :money
|
|
91
119
|
precision = column.respond_to?(:precision) ? column.precision : nil
|
|
92
120
|
scale = column.respond_to?(:scale) ? column.scale : nil
|
|
@@ -94,12 +122,12 @@ module Herringbone
|
|
|
94
122
|
precision = DEFAULT_DECIMAL_PRECISION
|
|
95
123
|
scale ||= DEFAULT_DECIMAL_SCALE
|
|
96
124
|
end
|
|
97
|
-
[:decimal, {
|
|
125
|
+
[:decimal, {precision: precision, scale: scale || 0}]
|
|
98
126
|
when :boolean then [:boolean, {}]
|
|
99
127
|
when :binary then [:binary, {}]
|
|
100
128
|
when :date then [:date, {}]
|
|
101
|
-
when :datetime, :timestamp, :timestamptz then [:timestamp, {
|
|
102
|
-
when :time then [:time, {
|
|
129
|
+
when :datetime, :timestamp, :timestamptz then [:timestamp, {unit: :micros, utc: true}]
|
|
130
|
+
when :time then [:time, {unit: :micros}]
|
|
103
131
|
when :json, :jsonb then [:json, {}]
|
|
104
132
|
when :uuid then [:uuid, {}]
|
|
105
133
|
else [:string, {}]
|
|
@@ -108,6 +136,11 @@ module Herringbone
|
|
|
108
136
|
|
|
109
137
|
# Named SQL types (smallint, bigint, ...) decide the width; a generic "integer"/"int"
|
|
110
138
|
# uses the column limit in bytes, since e.g. SQLite reports `t.integer limit: 2` as "integer(2)".
|
|
139
|
+
#
|
|
140
|
+
# @param column [ActiveRecord::ConnectionAdapters::Column] column metadata, read for +limit+
|
|
141
|
+
# @param sql_type [String] lowercased database type with any array suffix removed
|
|
142
|
+
# @param primary [Boolean] true forces int64, since row ids can outgrow the declared width
|
|
143
|
+
# @return [Symbol] one of +:int8+ .. +:int64+ or +:uint8+ .. +:uint64+
|
|
111
144
|
def integer_type(column, sql_type, primary)
|
|
112
145
|
bits = INTEGER_SQL_TYPES[sql_type[/\A[a-z0-9]+/]]
|
|
113
146
|
bits ||= case (column.respond_to?(:limit) ? column.limit : nil)
|
|
@@ -120,9 +153,8 @@ module Herringbone
|
|
|
120
153
|
bits = 64 if primary
|
|
121
154
|
unsigned = sql_type.include?("unsigned")
|
|
122
155
|
return :"uint#{bits}" if unsigned
|
|
123
|
-
{
|
|
156
|
+
{8 => :int8, 16 => :int16, 32 => :int32, 64 => :int64}.fetch(bits)
|
|
124
157
|
end
|
|
125
158
|
end
|
|
126
159
|
end
|
|
127
160
|
end
|
|
128
|
-
|