herringbone 0.6.1 → 0.8.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.
Files changed (41) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +45 -0
  3. data/MANUAL.md +196 -17
  4. data/README.md +22 -5
  5. data/bin/herringbone +56 -1
  6. data/lib/herringbone/active_record.rb +3 -1
  7. data/lib/herringbone/bloom_filter.rb +2 -0
  8. data/lib/herringbone/byte_values.rb +2 -0
  9. data/lib/herringbone/codecs/lz4.rb +9 -4
  10. data/lib/herringbone/codecs/lzo.rb +2 -0
  11. data/lib/herringbone/codecs/snappy.rb +48 -6
  12. data/lib/herringbone/combiner.rb +426 -0
  13. data/lib/herringbone/compression.rb +76 -13
  14. data/lib/herringbone/encodings/delta.rb +4 -0
  15. data/lib/herringbone/encodings/plain.rb +2 -0
  16. data/lib/herringbone/encodings/rle.rb +2 -0
  17. data/lib/herringbone/encryption.rb +2 -0
  18. data/lib/herringbone/inferring_writer.rb +1 -1
  19. data/lib/herringbone/inspector.rb +29 -5
  20. data/lib/herringbone/io_buffer_support.rb +2 -0
  21. data/lib/herringbone/reader/bloom_filters.rb +1 -0
  22. data/lib/herringbone/reader/chunk_copier.rb +177 -0
  23. data/lib/herringbone/reader/column_chunk_reader.rb +6 -2
  24. data/lib/herringbone/reader/column_cursor.rb +2 -0
  25. data/lib/herringbone/reader/numo.rb +4 -0
  26. data/lib/herringbone/reader/page_stream.rb +2 -0
  27. data/lib/herringbone/reader/scan.rb +2 -0
  28. data/lib/herringbone/reader.rb +19 -6
  29. data/lib/herringbone/redaction/rewriter.rb +51 -157
  30. data/lib/herringbone/redaction.rb +25 -15
  31. data/lib/herringbone/restricted_readable_io.rb +85 -0
  32. data/lib/herringbone/schema/merge.rb +355 -0
  33. data/lib/herringbone/schema.rb +175 -12
  34. data/lib/herringbone/thrift.rb +49 -10
  35. data/lib/herringbone/types.rb +22 -12
  36. data/lib/herringbone/version.rb +1 -1
  37. data/lib/herringbone/visualizer.rb +2 -0
  38. data/lib/herringbone/writer.rb +58 -22
  39. data/lib/herringbone/xxhash.rb +2 -0
  40. data/lib/herringbone.rb +96 -9
  41. metadata +6 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cad7828c89f6cdda317d574829316e6f96cc967e2a19d3c7589ab54324ae0967
4
- data.tar.gz: f889fe0c5ac568435597a48d6cbdb779a0e3b5d1b6d66e5965e2a7a5f38168aa
3
+ metadata.gz: b0226448164e89104b937f6d2009b6b8de9893fbabf06b20ab7379cdfd306ccd
4
+ data.tar.gz: a90c4223cf2f849fc839e3389cf40bba2d17145525bc99e57412f53cbb83f866
5
5
  SHA512:
6
- metadata.gz: 7095d8deb5d3232381ca860a1e58251fb8dea505c4ec911e96cdb28dba970575510fec7ab5f96f7b640f9f3fcbb1e557d424975c5e4bdb6f31040b38186093f8
7
- data.tar.gz: fd061d539396bb88713c2c0daedcde0d6a70e6b797f7ce57728fa23f4076d9ca62c127e5c9d6b392300d55536ad898ee9ebf1d36082896b7c1aef5e086ff65f7
6
+ metadata.gz: fe6316662f7ed600969dfd0ed7c297faf26c9694e9143b277bffca342b5593eb346989a99cce41e52ca27b3b655d069e53f8e279863b869bdb3703fcb0e7bb20
7
+ data.tar.gz: 9c441b9927dbd48f589bf42309e3468e55e47f056c363fc2edce0692e9be745de1db164bb313c04181e64b88b0262770843a983bff4a7b235a437deddd5e7b9e
data/CHANGELOG.md CHANGED
@@ -2,6 +2,51 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.8.0
6
+
7
+ - `Herringbone::MissingCodecError` explains over several lines which gem to install and that it has
8
+ to be required before the read or write, and names the column it could not decompress up front.
9
+ - `Herringbone::Schema#only(:id, :name)` and `#except(:ssn)` make a schema with some of the
10
+ top-level fields.
11
+ - `time` columns convert a `Time` to UTC when writing, as `timestamp` columns already did, instead
12
+ of storing its time of day in its own zone.
13
+ - **Breaking:** `local_timestamp` and `local_time` (`s.local_timestamp :opens_at`) write the wall
14
+ clock time without converting it to UTC, replacing the `utc: false` option, which now raises.
15
+
16
+ ## 0.7.0
17
+
18
+ - `Herringbone.redact` and `Herringbone::Redaction#apply` take a `Herringbone::Reader` as well as an
19
+ IO, using the Reader's decryption.
20
+ - `Herringbone::Schema#union` (`+`) and `#intersect` (`&`) match fields by name at every level of
21
+ nesting and widen their types without loss (int32 + int64 -> int64, uint32 + int8 -> int64,
22
+ int32 + float -> double, millis + micros -> micros, string + binary -> binary).
23
+ - `Herringbone::IncompatibleSchema` lists every field two schemas cannot agree on, at once.
24
+ - `Herringbone::Schema#==` compares structure: field names, order, nullability, types, field ids
25
+ and nesting.
26
+ - `Herringbone.combine(ios_or_readers, output_io)` concatenates Parquet files, copying their column
27
+ chunks byte for byte wherever the stored values stay the same. `schema: :union` fills fields an
28
+ input lacks with nulls and widens the others, `schema: :intersect` keeps the fields all inputs
29
+ have.
30
+ - `Herringbone.combine` checks every input before writing and raises one
31
+ `Herringbone::IncompatibleSchema` naming every input and field that does not fit.
32
+ - `Herringbone.combine` encrypts the output like the encrypted inputs unless `encryption:` says
33
+ otherwise.
34
+ - `Herringbone::Combiner::Report#inputs` says which fields of each input were filled with nulls,
35
+ widened or dropped.
36
+ - `herringbone combine INPUT... --output=FILE` does the same from the command line, with
37
+ `--schema=union|intersect`, and `--encrypt-key=KEY` or `--plaintext` to encrypt the output
38
+ otherwise.
39
+ - `Herringbone.redact` raises `Herringbone::FormatError` for a corrupt page header in a row group it
40
+ copies, instead of retrying forever.
41
+ - Writing allocates less: compressed pages are no longer copied before they reach the IO, and GZIP,
42
+ ZSTD and pure-Ruby Snappy compress a page's levels and values without joining them first.
43
+ - Writing metadata whose integers don't fit their Thrift field raises `Herringbone::Thrift::Error`
44
+ instead of producing a file Herringbone itself cannot read back.
45
+ - Column names and key-value metadata in other encodings are converted to UTF-8 on write, and
46
+ ones that aren't valid UTF-8 raise `Herringbone::Thrift::Error`.
47
+ - **Breaking:** `Herringbone::Reader#io` returns a `Herringbone::RestrictedReadableIO` wrapping the
48
+ given IO, which answers only `#read`, `#seek`, `#pos`, `#size` and `#path`.
49
+
5
50
  ## 0.6.1
6
51
 
7
52
  - `Herringbone::Reader` raises `Herringbone::FormatError` for forged metadata or page headers that
data/MANUAL.md CHANGED
@@ -19,6 +19,7 @@ The full reference for Herringbone. For a quick start, see the [README](README.m
19
19
  - [SimpleWriter: coming from CSV](#simplewriter-coming-from-csv)
20
20
  - [Statistics, page indexes and bloom filters](#statistics-page-indexes-and-bloom-filters)
21
21
  - [Writing to S3](#writing-to-s3)
22
+ - [Writing to Google Cloud Storage](#writing-to-google-cloud-storage)
22
23
  - [Encryption](#encryption)
23
24
  - [Writing encrypted files](#writing-encrypted-files)
24
25
  - [Which settings other tools read](#which-settings-other-tools-read)
@@ -33,6 +34,9 @@ The full reference for Herringbone. For a quick start, see the [README](README.m
33
34
  - [What gets erased, and what doesn't](#what-gets-erased-and-what-doesnt)
34
35
  - [Encrypted files](#encrypted-files)
35
36
  - [Recipes](#recipes)
37
+ - [Combining files](#combining-files)
38
+ - [Comparing and uniting schemas](#comparing-and-uniting-schemas)
39
+ - [Combining encrypted files](#combining-encrypted-files)
36
40
  - [Type mapping](#type-mapping)
37
41
  - [Inspecting files](#inspecting-files)
38
42
  - [Command line](#command-line)
@@ -209,6 +213,17 @@ The block receives the schema builder, and the blocks of `struct`, `list` and `m
209
213
  builder of their own, so the block can still call your methods and read your instance variables.
210
214
  A block that takes no parameter raises `ArgumentError`.
211
215
 
216
+ `only` and `except` make a schema with some of the top-level fields, keeping their order, for
217
+ instance to write part of a file you are reading:
218
+
219
+ ```ruby
220
+ schema.only(:id, :name) # id and name
221
+ schema.except(:ssn) # every field but ssn
222
+ ```
223
+
224
+ A name the schema has no top-level field for raises `ArgumentError`, and so does leaving out
225
+ every field.
226
+
212
227
  ### Writing with an inferred schema
213
228
 
214
229
  `Herringbone.write(io, rows)` writes an Enumerable of rows in one go, inferring the schema from the
@@ -229,11 +244,17 @@ file and rename it.
229
244
  ### Column types
230
245
 
231
246
  `boolean int8 int16 int32 int64 uint8 uint16 uint32 uint64 float double float16 string binary
232
- json bson enum uuid date int96 time timestamp decimal fixed`, plus `struct`, `list` and `map`;
247
+ json bson enum uuid date int96 time local_time timestamp local_timestamp decimal fixed`, plus
248
+ `struct`, `list` and `map`;
233
249
  `s.column :name, :int32` declares one by name. Fields are nullable unless `null: false` is given;
234
250
  list elements unless `element_null: false`, map values unless `value_null: false`. Nested lists:
235
- `s.list :matrix do |matrix| matrix.list :element, :double end`. `time` and `timestamp` take `unit:`
236
- (`:millis`, `:micros`, `:nanos`) and `utc:`.
251
+ `s.list :matrix do |matrix| matrix.list :element, :double end`. The time and timestamp types take `unit:`
252
+ (`:millis`, `:micros`, `:nanos`).
253
+
254
+ `timestamp` stores instants: a `Time` in any zone is converted to UTC, so the file means the same
255
+ thing wherever it was written. `local_timestamp` stores the wall clock date and time as given, with
256
+ no zone, for values like "the shop opens at 09:00" that are not instants. `time` and `local_time`
257
+ do the same for a time of day.
237
258
 
238
259
  `enum` is a string column. `values:` restricts what may be written, and also takes a Rails-style
239
260
  Hash (`values: Order.statuses`), in which case both labels and stored integers are accepted and
@@ -247,8 +268,8 @@ Columns accept the values Ruby and Rails code usually has at hand:
247
268
  | column | accepts |
248
269
  |---|---|
249
270
  | `date` | `Date`, `Time`/`DateTime` (their date), `"2024-05-01"` |
250
- | `timestamp` | `Time`, `DateTime`, `ActiveSupport::TimeWithZone`, `Date` (midnight UTC), ISO-8601 strings, Integers in the column's unit |
251
- | `time` | `Time` (its time of day, as Rails returns for `time` columns), `"13:45:30.25"`, Integers |
271
+ | `timestamp`, `local_timestamp` | `Time`, `DateTime`, `ActiveSupport::TimeWithZone`, `Date` (midnight UTC), ISO-8601 strings, Integers in the column's unit |
272
+ | `time`, `local_time` | `Time` (its time of day, in UTC for `time`, as Rails returns for `time` columns), `"13:45:30.25"`, Integers |
252
273
  | `json` | Strings as-is, anything else through `JSON.generate` |
253
274
  | `string`, `enum` | Strings, Symbols, anything with `to_s` |
254
275
  | `boolean` | `true`/`false`, `1`/`0`, `"t"`/`"f"`, `"true"`/`"false"`, `"yes"`/`"no"` |
@@ -339,6 +360,18 @@ If the block raises, the SDK aborts the multipart upload and raises `Aws::S3::Mu
339
360
  so no partial object is left. S3 allows at most 10,000 parts, which with the default 5MB parts caps
340
361
  the file at about 48GB; raise `part_size:` for bigger files.
341
362
 
363
+ ### Writing to Google Cloud Storage
364
+
365
+ `google-cloud-storage` needs the size of an upload up front, so it can't take a stream. Use
366
+ [gcs_put](https://github.com/julik/gcs_put), which streams into a resumable upload of unknown size:
367
+
368
+ ```ruby
369
+ gcs_file = Google::Cloud::Storage.new.bucket("exports").file("orders.parquet", skip_lookup: true)
370
+ GCSPut.open_gcs_file(gcs_file) { |io| Herringbone.write(io, Order.all) }
371
+ ```
372
+
373
+ If the block raises, the upload is never finalized and no object appears.
374
+
342
375
  ## Encryption
343
376
 
344
377
  Herringbone reads and writes files encrypted with
@@ -520,22 +553,22 @@ nothing touches are copied byte for byte.
520
553
 
521
554
  ```ruby
522
555
  # Forget me: remove the rows
523
- Herringbone.redact(input, output) do |r|
556
+ Herringbone.redact(io, output_io) do |r|
524
557
  r.where(user_id: 42).delete
525
558
  end
526
559
 
527
560
  # Forget me, but keep the row for accounting: blank the personal columns
528
- Herringbone.redact(input, output) do |r|
561
+ Herringbone.redact(io, output_io) do |r|
529
562
  r.where(user_id: 42).replace(email: nil, name: nil, address: nil)
530
563
  end
531
564
 
532
565
  # Forget me, but keep the row linkable: pseudonymize just that user's email
533
- Herringbone.redact(input, output) do |r|
566
+ Herringbone.redact(io, output_io) do |r|
534
567
  r.where(user_id: 42).replace(:email) { |email| OpenSSL::HMAC.hexdigest("SHA256", KEY, email) }
535
568
  end
536
569
 
537
570
  # Pseudonymize a column across the whole file, mask another, drop a third
538
- Herringbone.redact(input, output) do |r|
571
+ Herringbone.redact(io, output_io) do |r|
539
572
  r.replace(:email) { |email| OpenSSL::HMAC.hexdigest("SHA256", KEY, email.downcase) }
540
573
  r.replace(:phone) { |phone| phone && "***#{phone[-3..]}" }
541
574
  r.drop :ssn, :ip_address
@@ -575,8 +608,8 @@ forget = Herringbone::Redaction.new do |r|
575
608
  r.where(created_at: ..2.years.ago).delete # retention works the same way
576
609
  end
577
610
 
578
- if forget.affects?(input) # statistics and bloom filters first, then the where columns only
579
- report = forget.apply(input, output)
611
+ if forget.affects?(io) # statistics and bloom filters first, then the where columns only
612
+ report = forget.apply(io, output_io)
580
613
  report.rows_deleted # => 3
581
614
  report.row_groups # => { copied: 61, rewritten: 1 }
582
615
  end
@@ -588,7 +621,16 @@ The `Redaction::Report` that `apply` returns has `rows_read`, `rows_deleted`, `r
588
621
  ### Input and output
589
622
 
590
623
  `apply` and `Herringbone.redact` take IOs like `Reader` and `Writer` do: the input must be
591
- seekable, the output only needs `#write`, and neither is closed. To redact in place, write to a
624
+ seekable, the output only needs `#write`, and neither is closed. Herringbone doesn't open files by
625
+ path, so open them yourself. The input can also be a `Herringbone::Reader` you already have, and
626
+ then its IO and its decryption are used. Whatever `keys:` or `time_zone:` it was built with, the
627
+ redaction reads values the same way (and blocks get rows with String keys):
628
+
629
+ ```ruby
630
+ reader = Herringbone::Reader.new(io, keys: :symbol, decryption: {keys: kms_lookup})
631
+ Herringbone.redact(reader, output_io) { |r| r.where(user_id: 42).delete }
632
+ ```
633
+ To redact in place, write to a
592
634
  temporary file and rename it over the original; on S3, read the object and write the new one
593
635
  through `upload_stream`.
594
636
 
@@ -619,10 +661,11 @@ there), and other files with the same person in them need their own pass.
619
661
  Pass the input's keys in `decryption:`. The output is then encrypted the same way: same algorithm,
620
662
  footer mode, AAD prefix, keys and key metadata, for the columns that are kept. `encryption:` (as
621
663
  for `Writer`) writes it with other settings, and `encryption: false` writes a plaintext file.
622
- `Redaction#affects?` takes `decryption:` too.
664
+ `Redaction#affects?` takes `decryption:` too. A `Herringbone::Reader` brings its own keys, so
665
+ `decryption:` is not given with one.
623
666
 
624
667
  ```ruby
625
- Herringbone.redact(input, output, decryption: {keys: kms_lookup}) do |r|
668
+ Herringbone.redact(io, output_io, decryption: {keys: kms_lookup}) do |r|
626
669
  r.where(user_id: 42).delete
627
670
  end
628
671
  ```
@@ -639,7 +682,7 @@ rotate or destroy it to cut the link:
639
682
  ```ruby
640
683
  require "openssl"
641
684
  KEY = ENV.fetch("PSEUDONYM_KEY")
642
- Herringbone.redact(input, output) do |r|
685
+ Herringbone.redact(io, output_io) do |r|
643
686
  r.replace(:email) { |email| email && OpenSSL::HMAC.hexdigest("SHA256", KEY, email.downcase) }
644
687
  end
645
688
  ```
@@ -647,7 +690,7 @@ end
647
690
  Masking keeps enough to be recognizable to the person but not to anyone else:
648
691
 
649
692
  ```ruby
650
- Herringbone.redact(input, output) do |r|
693
+ Herringbone.redact(io, output_io) do |r|
651
694
  r.replace(:card_number) { |number| number && number[-4..].rjust(number.size, "*") }
652
695
  r.replace(:email) { |email| email&.sub(/\A(.).*@/, '\1***@') }
653
696
  r.replace(:birth_date) { |date| date && Date.new(date.year, 1, 1) }
@@ -660,7 +703,7 @@ original value to get the same fake for the same person in every file:
660
703
  ```ruby
661
704
  require "faker"
662
705
  require "zlib"
663
- Herringbone.redact(input, output) do |r|
706
+ Herringbone.redact(io, output_io) do |r|
664
707
  r.replace(:name) do |name|
665
708
  next nil unless name
666
709
  Faker::Config.random = Random.new(Zlib.crc32(name))
@@ -672,6 +715,132 @@ Herringbone.redact(input, output) do |r|
672
715
  end
673
716
  ```
674
717
 
718
+ ## Combining files
719
+
720
+ `Herringbone.combine` concatenates Parquet files into one: every row group of every input, in
721
+ order. The inputs are an Array (or any Enumerable) of seekable IOs or `Herringbone::Reader`s, and
722
+ the output any IO with `#write`, like for `Reader` and `Writer`. Herringbone never opens files by
723
+ path, and closes nothing it was given.
724
+
725
+ ```ruby
726
+ paths = %w[2026-01.parquet 2026-02.parquet 2026-03.parquet]
727
+ ios = paths.map { |path| File.open(path, "rb") }
728
+ File.open("2026-q1.parquet", "wb") { |output_io| Herringbone.combine(ios, output_io) }
729
+ ios.each(&:close)
730
+ ```
731
+
732
+ Every input is read twice: its footer first, while `combine` works out the output schema and
733
+ checks every input against it, then its column chunks. Keep the IOs open until `combine` returns,
734
+ and mind the limit on open files (256 by default on macOS) when combining very many of them.
735
+
736
+ When the inputs have the same schema, nothing is decoded: the column chunks are copied byte for
737
+ byte, with their statistics, page indexes and bloom filters, and only the offsets pointing at them
738
+ are rebased. Row groups keep their boundaries, so a hundred small files make a file of a hundred
739
+ small row groups; to merge row groups, read the files and write the rows with `Writer` instead.
740
+
741
+ The footer's key/value metadata is the first input's (without `ARROW:schema` and `pandas` when the
742
+ output schema is another than the first input's), unless `metadata:` is given. Writer options
743
+ (`compression:`, `page_rows:`...) apply to the column chunks that have to be encoded again;
744
+ `row_group_bytes:` and `row_group_rows:` are refused. Those chunks keep the codec of the original
745
+ chunk, and get a bloom filter if they had one.
746
+
747
+ ### Inputs with different schemas
748
+
749
+ `schema:` says what the output holds:
750
+
751
+ | `schema:` | Output schema | Inputs |
752
+ |----------------|------------------------------------------------------------------|----------------------------------------------|
753
+ | not given | the schema of the first input | must all have it (`Schema#==`) |
754
+ | `:union` | every field of every input (`Schema#union`) | fields an input lacks are written as nulls |
755
+ | `:intersect` | the fields all inputs have (`Schema#intersect`) | the other fields are dropped |
756
+ | a `Schema` | as given | must each fit it: the same fields or fewer, the same types or narrower |
757
+
758
+ ```ruby
759
+ Herringbone.combine([old, new], output_io, schema: :union)
760
+ Herringbone.combine([old, new], output_io, schema: :intersect)
761
+ ```
762
+
763
+ Fields are matched by name at every level: struct members, list elements, map keys and values. A
764
+ field one input lacks, or has nullable, is nullable in a union. The types of fields both have are
765
+ widened to one that holds the values of both without loss:
766
+
767
+ | Types | Widened to |
768
+ |------------------------------------------|---------------------------------------------|
769
+ | two signed or two unsigned integers | the wider one |
770
+ | unsigned and signed integer | signed, twice as wide: `uint32 + int8` -> `int64` |
771
+ | two floats | the wider one |
772
+ | integer and float | the narrowest float holding every integer exactly: `int16 + float` -> `float`, `int32 + float` -> `double` |
773
+ | timestamps (or times) of different units | the finer unit: `millis + micros` -> `micros` |
774
+ | string, enum, json | string |
775
+ | any of those and binary | binary |
776
+
777
+ Anything else does not fit: `uint64` with a signed integer, `int64` with any float, decimals of
778
+ another precision or scale, UTC with local timestamps, dates with timestamps, a struct with a
779
+ list, fields with different field ids, a bare `repeated` field only some inputs have (it cannot be
780
+ null), and inputs with no fields in common.
781
+
782
+ Column chunks are copied wherever the stored bytes stay the same, which includes widenings of the
783
+ annotation alone (`int8` into `int32`, `uint16` into `int32`, `string` into `binary`) and list
784
+ elements named another way (pyarrow's `list.item`). Columns whose physical type, time unit or
785
+ nullability changes (`int32` into `int64`, `millis` into `micros`, required into nullable) are
786
+ encoded again from their values, and so are struct members that lose their siblings to an
787
+ intersection.
788
+
789
+ `combine` returns a `Combiner::Report`: `rows`, `row_groups` (`{ copied:, rewritten: }`) and
790
+ `inputs`, which says per input which fields were `filled` with nulls, `widened` or `dropped`:
791
+
792
+ ```ruby
793
+ report = Herringbone.combine(ios, output_io, schema: :union)
794
+ report.inputs.each { |input| puts "#{input.name}: nulls in #{input.filled.join(", ")}" if input.filled.any? }
795
+ ```
796
+
797
+ ### When inputs do not fit
798
+
799
+ Every input is checked before anything is written, and `Herringbone::IncompatibleSchema` names
800
+ every input and every field that does not fit at once. Inputs are named by their path when the
801
+ IO has one:
802
+
803
+ ```
804
+ 2 of 4 inputs have another schema than input 0 (2026-01.parquet):
805
+
806
+ input 2 (2026-03.parquet)
807
+ address.zip int32 in input 0, string here
808
+
809
+ input 3 (2026-04.parquet)
810
+ id required in input 0, nullable here
811
+ email missing here, input 0 has it
812
+ mail only here, input 0 lacks it
813
+
814
+ Combine files with the same schema, or pass schema: :union to fill the fields an input lacks
815
+ with nulls and widen the others, or schema: :intersect to keep only the fields all inputs have.
816
+ ```
817
+
818
+ `error.conflicts` has the same as `Conflict`s (`path`, `left`, `right`, `reason`, and `input`, the
819
+ position of the input). `Schema#union` and `Schema#intersect` raise the same error class, with
820
+ `input` nil.
821
+
822
+ ### Combining encrypted files
823
+
824
+ Pass the keys of encrypted inputs in `decryption:`, as for `Reader` (`[key, older_key]` lets each
825
+ file find its own), or pass Readers, which bring their own keys. An encrypted column chunk can't
826
+ be copied, since its encryption is tied to the file and to its position in it, so it is decrypted
827
+ and encoded again.
828
+
829
+ The output is encrypted like the encrypted inputs: the same algorithm, footer mode, footer key and
830
+ key metadata, and the same key for each column. Plaintext inputs in the same call get that
831
+ encryption too, so nothing gets decrypted into a plaintext file by accident. The AAD prefix the
832
+ inputs share is kept.
833
+
834
+ ```ruby
835
+ Herringbone.combine(ios, output_io, decryption: key) # encrypted with key, like the inputs
836
+ Herringbone.combine(ios, output_io, decryption: key, encryption: new_key) # re-keyed
837
+ Herringbone.combine(ios, output_io, decryption: key, encryption: false) # plaintext, on purpose
838
+ ```
839
+
840
+ Inputs encrypted differently (another footer key, algorithm, footer mode or AAD prefix, a column
841
+ encrypted with different keys) have no single "like the inputs", so `combine` raises `ArgumentError` listing
842
+ how they differ, and `encryption:` decides.
843
+
675
844
  ## Type mapping
676
845
 
677
846
  | Parquet | Ruby |
@@ -725,10 +894,18 @@ bin/herringbone inspect FILE [--pages] # text report (--pages li
725
894
  bin/herringbone inspect FILE --format=json # everything as JSON
726
895
  bin/herringbone inspect FILE --format=html # the HTML page, opened in your browser
727
896
  bin/herringbone inspect FILE --format=html > out.html # the HTML page, saved
897
+ bin/herringbone combine A B C --output=all.parquet # concatenate files with the same schema
898
+ bin/herringbone combine A B --schema=union > all.parquet # fill fields an input lacks with nulls
899
+ bin/herringbone combine A B --schema=intersect > all.parquet # keep the fields all inputs have
728
900
  ```
729
901
 
730
902
  Add `--verify-checksums` to any `inspect` form to check page CRCs.
731
903
 
904
+ `combine` does what [`Herringbone.combine`](#combining-files) does and prints how many row groups
905
+ it copied and encoded again. Without `--output` it writes to stdout when that is redirected.
906
+ `--compression=CODEC` sets the codec of the chunks it encodes again. If it fails, the output file
907
+ is removed rather than left without a footer, and an output that is also an input is refused.
908
+
732
909
  Encrypted files take keys on the command line: as they are (picked by their fingerprint id, like
733
910
  a keyring), or for the footer, a column path or the key id the file stores (which `inspect`
734
911
  shows):
@@ -743,6 +920,8 @@ bin/herringbone inspect FILE --aad-prefix=orders/part-0 --no-prompt
743
920
  Keys are hex, `base64:...` or `raw:...` (a 16, 24 or 32-character key that isn't valid hex is
744
921
  taken as typed too). A key the file needs but wasn't given is asked for on stdin, without echo in
745
922
  a terminal; an empty answer leaves that column unreadable. `--no-prompt` asks for nothing.
923
+ `combine` reads encrypted inputs with the same flags, and encrypts the output like them;
924
+ `--encrypt-key=KEY` encrypts it with one key instead, and `--plaintext` writes it decrypted.
746
925
 
747
926
  `--format` is `text` (the default), `json` or `html`; `--pages` only goes with `text`.
748
927
  In a terminal, `--format=html` writes the page to a temp file, prints its path and opens it with
data/README.md CHANGED
@@ -13,7 +13,7 @@ A pure-Ruby reader and writer for [Apache Parquet](https://parquet.apache.org/)
13
13
 
14
14
  This README covers the common cases. The [MANUAL](MANUAL.md) has everything else: reader and
15
15
  writer options, schemas and column types, bloom filters, Numo arrays, encryption, the redaction
16
- rules, type mapping and the inspector.
16
+ rules, combining files, type mapping and the inspector.
17
17
 
18
18
  ## Installation
19
19
 
@@ -46,7 +46,9 @@ end
46
46
  ```
47
47
 
48
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).
49
+ rows. See [ActiveRecord](MANUAL.md#activerecord) and [Writing to S3](MANUAL.md#writing-to-s3). For
50
+ Google Cloud Storage, use [gcs_put](https://github.com/julik/gcs_put), see
51
+ [Writing to Google Cloud Storage](MANUAL.md#writing-to-google-cloud-storage).
50
52
 
51
53
  ## Reading
52
54
 
@@ -125,20 +127,22 @@ footers and which tools read what.
125
127
 
126
128
  `Herringbone.redact` rewrites a file with rows removed or values replaced, for GDPR "forget me"
127
129
  requests and pseudonymization. The parts of the file nothing touches are copied byte for byte.
130
+ It reads from a seekable IO or from a `Herringbone::Reader`, which brings its own decryption, and
131
+ writes to any IO with `#write`.
128
132
 
129
133
  ```ruby
130
134
  # Forget me: remove the rows
131
- Herringbone.redact(input, output) do |r|
135
+ Herringbone.redact(io, output_io) do |r|
132
136
  r.where(user_id: 42).delete
133
137
  end
134
138
 
135
139
  # Forget me, but keep the row for accounting: blank the personal columns
136
- Herringbone.redact(input, output) do |r|
140
+ Herringbone.redact(io, output_io) do |r|
137
141
  r.where(user_id: 42).replace(email: nil, name: nil, address: nil)
138
142
  end
139
143
 
140
144
  # Pseudonymize a column across the whole file, mask another, drop a third
141
- Herringbone.redact(input, output) do |r|
145
+ Herringbone.redact(io, output_io) do |r|
142
146
  r.replace(:email) { |email| OpenSSL::HMAC.hexdigest("SHA256", KEY, email.downcase) }
143
147
  r.replace(:phone) { |phone| phone && "***#{phone[-3..]}" }
144
148
  r.drop :ssn, :ip_address
@@ -147,6 +151,19 @@ end
147
151
 
148
152
  See [Redaction](MANUAL.md#redaction) for reusable redactions, reports and recipes.
149
153
 
154
+ ## Combining files
155
+
156
+ `Herringbone.combine` concatenates files with the same schema by copying their row groups byte for
157
+ byte, without decoding anything:
158
+
159
+ ```ruby
160
+ Herringbone.combine([january, february, march], output_io)
161
+ ```
162
+
163
+ Files with different columns combine with `schema: :union`, which fills the gaps with nulls and
164
+ widens types (`int32` and `int64` to `int64`), or `schema: :intersect`, which keeps the fields all
165
+ of them have. See [Combining files](MANUAL.md#combining-files).
166
+
150
167
  ## Looking inside a file
151
168
 
152
169
  ```
data/bin/herringbone CHANGED
@@ -9,6 +9,12 @@
9
9
  # herringbone inspect FILE --format=html write a self-contained HTML page showing the file's layout
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
+ # herringbone combine INPUT... --output=FILE concatenate files with the same schema, copying their
13
+ # row groups (to stdout when it is redirected and
14
+ # --output is not given); --schema=union fills fields an
15
+ # input lacks with nulls, --schema=intersect keeps only
16
+ # the fields all inputs have, --compression=CODEC sets
17
+ # the codec of the chunks that are encoded again
12
18
  # `inspect` reads only the footer, page headers and page indexes: no values are decompressed.
13
19
  #
14
20
  # Encrypted files: both commands take --key=KEY (a key matched by its fingerprint id, or used for
@@ -17,6 +23,8 @@
17
23
  # base64:..., or raw:... for the bytes as typed (a 16, 24 or 32-character String that is not
18
24
  # valid hex is taken as is too). Keys that are needed but not given are asked for on stdin
19
25
  # (without echo in a terminal; an empty answer skips the key), unless --no-prompt is given.
26
+ # `combine` takes the same flags for its inputs, and encrypts the output like the encrypted inputs;
27
+ # --encrypt-key=KEY encrypts it with that key instead, and --plaintext not at all.
20
28
  $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
21
29
  require "herringbone"
22
30
  require "json"
@@ -110,13 +118,16 @@ def decryption_from(key_flags, prompt)
110
118
  end
111
119
  end
112
120
  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 } }
121
+ asked = Hash.new { |h, (metadata, owner)| h[[metadata, owner]] = ask_key(metadata, owner) } # combine's inputs may share keys
122
+ decryption[:keys] = ->(metadata, owner) { by_metadata.fetch(metadata) { asked[[metadata, owner]] if prompt } }
114
123
  decryption
115
124
  end
116
125
 
117
126
  USAGE = "usage: herringbone cat FILE [N] [KEY OPTIONS]\n" \
118
127
  " herringbone inspect FILE [--format=text|json|html] [--pages] [--verify-checksums] [KEY OPTIONS]\n" \
119
128
  " (--pages only with --format=text)\n" \
129
+ " herringbone combine INPUT... [--output=FILE] [--schema=union|intersect] [--compression=CODEC]\n" \
130
+ " [--encrypt-key=KEY | --plaintext] [KEY OPTIONS]\n" \
120
131
  "key options, for encrypted files: --key=KEY --key=KEY_ID=KEY --footer-key=KEY --column-key=PATH=KEY\n" \
121
132
  " --aad-prefix=PREFIX --no-prompt (KEY is hex, base64:... or raw:...)"
122
133
 
@@ -135,15 +146,54 @@ when "inspect"
135
146
  path && limit.nil? && format_flags.size <= 1 && %w[text json html].include?(format) &&
136
147
  (other_flags - %w[--pages --verify-checksums]).empty? &&
137
148
  (format == "text" || !other_flags.include?("--pages"))
149
+ when "combine"
150
+ valued, other_flags = flags.partition { |f| f.start_with?("--output=", "--compression=", "--encrypt-key=", "--schema=") }
151
+ options = valued.to_h { |f| f.delete_prefix("--").split("=", 2) }
152
+ plaintext = other_flags.delete("--plaintext")
153
+ !args.empty? && options.size == valued.size && other_flags.empty? && !(plaintext && options["encrypt-key"]) &&
154
+ [nil, "union", "intersect"].include?(options["schema"]) &&
155
+ (options.key?("output") ? !options["output"].empty? : !$stdout.tty?)
138
156
  end
139
157
  unless valid
140
158
  warn USAGE
141
159
  exit 1
142
160
  end
143
161
 
162
+ # Writes the inputs into one file, removing the output again when that fails
163
+ # @return [Herringbone::Combiner::Report]
164
+ def combine(paths, options, plaintext, decryption)
165
+ output_path = options["output"]
166
+ if output_path && paths.any? { |p| File.expand_path(p) == File.expand_path(output_path) || (File.exist?(output_path) && File.identical?(p, output_path)) }
167
+ raise ArgumentError, "#{output_path} is one of the inputs"
168
+ end
169
+ writer_options = {}
170
+ writer_options[:compression] = options["compression"].to_sym if options["compression"]
171
+ writer_options[:encryption] = Herringbone::Key.new(parse_key(options["encrypt-key"], "--encrypt-key")) if options["encrypt-key"]
172
+ writer_options[:encryption] = false if plaintext
173
+ inputs = paths.map { |p| File.open(p, "rb") }
174
+ output = output_path ? File.open(output_path, "wb") : $stdout.binmode
175
+ Herringbone.combine(inputs, output, schema: options["schema"]&.to_sym, decryption: decryption, **writer_options)
176
+ rescue Exception # standard:disable Lint/RescueException
177
+ # A file without its footer is of no use to anyone
178
+ if output_path && output
179
+ output.close
180
+ File.delete(output_path)
181
+ end
182
+ raise
183
+ ensure
184
+ output.close if output_path && output && !output.closed?
185
+ inputs&.each(&:close)
186
+ end
187
+
144
188
  # Parquet, file and key errors are reported in one line, without a backtrace
145
189
  begin
146
190
  decryption = decryption_from(key_flags, prompt)
191
+ if command == "combine"
192
+ report = combine(args, options, plaintext, decryption)
193
+ copied, rewritten = report.row_groups.values_at(:copied, :rewritten)
194
+ warn "herringbone: #{report.rows} rows, #{copied} row groups copied, #{rewritten} encoded again"
195
+ exit
196
+ end
147
197
  File.open(path, "rb") do |io|
148
198
  if command == "cat"
149
199
  n = limit&.to_i
@@ -171,5 +221,10 @@ begin
171
221
  rescue Herringbone::Error, SystemCallError, ArgumentError => e
172
222
  warn "herringbone: #{e.message}"
173
223
  warn "herringbone: give keys with --footer-key=KEY, --column-key=PATH=KEY or --key=KEY_METADATA=KEY" if e.is_a?(Herringbone::DecryptionError)
224
+ warn "herringbone: give --encrypt-key=KEY to encrypt the output, or --plaintext" if e.message.include?("Pass encryption: for the output")
225
+ if e.is_a?(Herringbone::IncompatibleSchema) && command == "combine"
226
+ warn "herringbone: give --schema=union to fill the fields an input lacks with nulls, or --schema=intersect to keep " \
227
+ "only the fields all inputs have"
228
+ end
174
229
  exit 1
175
230
  end
@@ -56,6 +56,8 @@ module Herringbone
56
56
  # hstore map<string, string>
57
57
  # anything else (string, text, citext, inet, cidr, macaddr, ...) -> string
58
58
  # Postgres arrays (column.array, or a sql_type ending in "[]") -> list of the element type
59
+ #
60
+ # @api private
59
61
  module ActiveRecordMapping
60
62
  module_function
61
63
 
@@ -126,7 +128,7 @@ module Herringbone
126
128
  when :boolean then [:boolean, {}]
127
129
  when :binary then [:binary, {}]
128
130
  when :date then [:date, {}]
129
- when :datetime, :timestamp, :timestamptz then [:timestamp, {unit: :micros, utc: true}]
131
+ when :datetime, :timestamp, :timestamptz then [:timestamp, {unit: :micros}]
130
132
  when :time then [:time, {unit: :micros}]
131
133
  when :json, :jsonb then [:json, {}]
132
134
  when :uuid then [:uuid, {}]
@@ -12,6 +12,8 @@ module Herringbone
12
12
  # like the writer converts them (the column's encoder), so a Date, Time, BigDecimal or UUID
13
13
  # String hashes the same bytes as the stored value. Nulls are never in a bloom filter.
14
14
  # The writer builds them (bloom_filters: option) and reads with where: consult them.
15
+ #
16
+ # @api private
15
17
  class BloomFilter
16
18
  # The spec's eight salt constants, one per word of a block
17
19
  SALT = [0x47b6137b, 0x44974d91, 0x8824ad5b, 0xa2b7289d, 0x705495c7, 0x2df1424b, 0x9efc4947, 0x5c6bfb31].freeze
@@ -11,6 +11,8 @@ module Herringbone
11
11
  # It starts out in dictionary mode (when allowed) and switches to raw bytes once the dictionary
12
12
  # grows too large or too many values turn out to be distinct. Strings are only rebuilt when the
13
13
  # row group is flushed, one column at a time.
14
+ #
15
+ # @api private
14
16
  class ByteValues
15
17
  # Values in the dictionary may take this many bytes before switching to raw bytes
16
18
  MAX_DICTIONARY_BYTES = 1024 * 1024
@@ -4,6 +4,8 @@ module Herringbone
4
4
  module Codecs
5
5
  # Pure-Ruby LZ4: raw block format (Parquet LZ4_RAW), Hadoop-framed blocks
6
6
  # (Parquet's deprecated LZ4) and a decoder for the LZ4 frame format.
7
+ #
8
+ # @api private
7
9
  module LZ4
8
10
  # Raised for corrupt, truncated or unsupported LZ4 input.
9
11
  class Error < StandardError; end
@@ -101,11 +103,13 @@ module Herringbone
101
103
 
102
104
  # Compress into a single raw LZ4 block.
103
105
  # @param input [String] bytes to compress
106
+ # @param skip [Integer] zero bytes to put before the block, for a header filled in later
104
107
  # @return [String] raw LZ4 block in ASCII-8BIT
105
- def compress_block(input)
108
+ def compress_block(input, skip: 0)
106
109
  src = binary(input)
107
110
  n = src.bytesize
108
- out = String.new(capacity: n + (n / 255) + 16, encoding: BINARY)
111
+ out = String.new(capacity: skip + n + (n / 255) + 16, encoding: BINARY)
112
+ out << ("\0" * skip) if skip > 0
109
113
  anchor = 0
110
114
 
111
115
  if n > MFLIMIT
@@ -171,8 +175,9 @@ module Herringbone
171
175
  # @return [String] Hadoop-framed LZ4 data in ASCII-8BIT
172
176
  def compress_hadoop(input)
173
177
  src = binary(input)
174
- block = compress_block(src)
175
- [src.bytesize, block.bytesize].pack("NN") << block
178
+ out = compress_block(src, skip: HADOOP_PREFIX)
179
+ out[0, HADOOP_PREFIX] = [src.bytesize, out.bytesize - HADOOP_PREFIX].pack("NN")
180
+ out
176
181
  end
177
182
 
178
183
  # -- internals --
@@ -8,6 +8,8 @@ module Herringbone
8
8
  #
9
9
  # Written from the bitstream description in the Linux kernel's
10
10
  # Documentation/staging/lzo.rst, not from the (GPL) LZO sources.
11
+ #
12
+ # @api private
11
13
  module LZO
12
14
  # Raised for corrupt or truncated LZO input.
13
15
  class Error < StandardError; end