herringbone 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -23,7 +23,15 @@ module Herringbone
23
23
  # Columns declared in a block (Schema::Builder DSL) replace inferred ones, e.g. for a column that
24
24
  # holds both numbers and text:
25
25
  #
26
- # Herringbone::SimpleWriter.new(io) { string :code }
26
+ # Herringbone::SimpleWriter.new(io) { |s| s.string :code }
27
+ #
28
+ # #encrypt! encrypts the file with one key (see Key):
29
+ #
30
+ # Herringbone::SimpleWriter.open(file) do |sw|
31
+ # sw.encrypt!(key: ENV["PARQUET_KEY"]) # the key's hex, or a Herringbone::Key
32
+ # sw.headers!(:id, :name)
33
+ # sw << [1, "John"]
34
+ # end
27
35
  class SimpleWriter
28
36
  # Opens a writer, yields it and closes it, finishing the file. If the block raises, the file is
29
37
  # left unfinished (no footer), as with Writer.open. To declare columns, use #initialize and #close.
@@ -31,6 +39,7 @@ module Herringbone
31
39
  # @param io [IO, #write] destination; written sequentially, never closed
32
40
  # @param options [Hash{Symbol => Object}] Writer options (compression:, row_group_bytes:...)
33
41
  # @option options [Symbol] :compression (:snappy) codec, see Herringbone.codecs
42
+ # @option options [Integer, nil] :compression_level (nil) level for :zstd, :gzip or :brotli
34
43
  # @option options [Integer] :row_group_bytes (16MB) approximate buffered size that triggers a row group
35
44
  # @option options [Integer, nil] :row_group_rows (nil) also flush a row group after this many rows
36
45
  # (other Writer options are passed on as well)
@@ -57,16 +66,21 @@ module Herringbone
57
66
  # @param io [IO, #write] destination; nothing is written to it until the column types are known
58
67
  # @param options [Hash{Symbol => Object}] Writer options (compression:, row_group_bytes:...)
59
68
  # @option options [Symbol] :compression (:snappy) codec, see Herringbone.codecs
69
+ # @option options [Integer, nil] :compression_level (nil) level for :zstd, :gzip or :brotli
60
70
  # @option options [Integer] :row_group_bytes (16MB) approximate buffered size that triggers a row group
61
71
  # @option options [Integer, nil] :row_group_rows (nil) also flush a row group after this many rows
62
72
  # (other Writer options are passed on as well)
63
- # @yield optional block evaluated with +instance_eval+ on a Schema::Builder, declaring columns
64
- # (named in #headers!) that replace inferred ones
73
+ # @yield [s] optional, declares columns (named in #headers!) that replace inferred ones
74
+ # @yieldparam s [Schema::Builder] the builder to declare columns on
75
+ # @yieldreturn [void]
65
76
  # @raise [MissingCodecError] when the codec's optional gem is not loaded
77
+ # @raise [ArgumentError] when the block takes no parameter
66
78
  def initialize(io, **options, &overrides)
79
+ Schema::Builder.check_block!(overrides, "Herringbone::SimpleWriter.new(io) { |s| s.string :code }")
67
80
  @headers = nil
81
+ @encrypted = !!options[:encryption]
68
82
  @overrides = overrides
69
- fix = "Herringbone::SimpleWriter.new(io) { %s }"
83
+ fix = "Herringbone::SimpleWriter.new(io) { |s| s.%s }"
70
84
  @writer = InferringWriter.new(io, fix: fix, **options) { |sample| schema_for(sample) }
71
85
  end
72
86
 
@@ -86,6 +100,23 @@ module Herringbone
86
100
  self
87
101
  end
88
102
 
103
+ # Encrypts the file with one key, the way most Parquet readers can decrypt with that key (see
104
+ # EncryptionConfiguration.simple). Must be called before the first row. For a new key, pass
105
+ # +key: Herringbone::Key.generate+ and keep it (+key.hex+): the file can't be read without it.
106
+ #
107
+ # @param key [Key, String] a key, or its hex (32 or 64 digits; raw bytes go through Key.new)
108
+ # @return [Key] the key the file is encrypted with
109
+ # @raise [ArgumentError] after the first row, when called twice or after +encryption:+ was
110
+ # given, for a String that is not the hex of a key, or a 192-bit key
111
+ def encrypt!(key:)
112
+ raise ArgumentError, "encrypt! must be called before the first row" if rows_written.positive?
113
+ raise ArgumentError, "The file is already encrypted (encrypt! or encryption:)" if @encrypted
114
+ key = Key.from(key)
115
+ @writer.encryption = EncryptionConfiguration.simple(key)
116
+ @encrypted = true
117
+ key
118
+ end
119
+
89
120
  # Appends a row: an Array with one value per header, in header order, or a Hash keyed by
90
121
  # header (String or Symbol keys; missing columns are nulls).
91
122
  #
@@ -117,6 +148,13 @@ module Herringbone
117
148
  # @return [void]
118
149
  def abort = @writer.abort
119
150
 
151
+ # Short summary for the console, without the rows
152
+ #
153
+ # @return [String] the number of columns and the underlying writer's summary
154
+ def inspect
155
+ "#<#{self.class.name} columns=#{@headers&.size.inspect} writer=#{@writer.inspect}>"
156
+ end
157
+
120
158
  private
121
159
 
122
160
  # @param row [Array, Hash] row given to #<<
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Herringbone
4
4
  # Gem version, also written to the +created_by+ field of every file's footer
5
- VERSION = "0.4.0"
5
+ VERSION = "0.6.0"
6
6
  end
@@ -3,10 +3,10 @@
3
3
  module Herringbone
4
4
  # Writes Parquet files.
5
5
  #
6
- # schema = Herringbone::Schema.define do
7
- # int64 :id, null: false
8
- # string :name
9
- # list :tags, :string
6
+ # schema = Herringbone::Schema.define do |s|
7
+ # s.int64 :id, null: false
8
+ # s.string :name
9
+ # s.list :tags, :string
10
10
  # end
11
11
  # File.open("out.parquet", "wb") do |file|
12
12
  # Herringbone::Writer.open(file, schema) do |w|
@@ -24,6 +24,8 @@ module Herringbone
24
24
  # Options:
25
25
  # compression: :snappy (default), :zstd, :gzip, :lz4 (LZ4_RAW), :lz4_hadoop, :brotli, :none
26
26
  # (:zstd and :brotli need the zstd-ruby / brotli gems)
27
+ # compression_level: nil (the codec's default), or a level for :zstd (up to 22), :gzip (0-9)
28
+ # or :brotli (0-11)
27
29
  # row_group_bytes: flush a row group once the buffered values take roughly this much memory
28
30
  # (default 16MB). This bounds memory use while writing.
29
31
  # row_group_rows: also flush after this many rows (default: no row limit)
@@ -38,6 +40,9 @@ module Herringbone
38
40
  # an Array of column paths, or { "path" => true | { ndv:, fpp:, max_bytes: } }.
39
41
  # Without ndv: the distinct values of each row group are counted. fpp defaults
40
42
  # to 0.01 and max_bytes to 1MB. Filters are written after each row group.
43
+ # encryption: Parquet modular encryption: { footer_key: "...", columns: { "ssn" => "..." } }.
44
+ # Without columns: every column is encrypted with the footer key. See
45
+ # #initialize for the settings.
41
46
  #
42
47
  # Statistics and page indexes (ColumnIndex/OffsetIndex) are always written.
43
48
  class Writer
@@ -83,6 +88,7 @@ module Herringbone
83
88
  # @param schema [Schema] schema of the rows
84
89
  # @param options [Hash{Symbol => Object}] see the class description and #initialize
85
90
  # @option options [Symbol] :compression (:snappy) codec, see Herringbone.codecs
91
+ # @option options [Integer, nil] :compression_level (nil) level for :zstd, :gzip or :brotli
86
92
  # @option options [Integer] :row_group_bytes (16MB) approximate buffered size that triggers a row group
87
93
  # @option options [Integer, nil] :row_group_rows (nil) also flush a row group after this many rows
88
94
  # @option options [Integer] :page_bytes (1MB) approximate uncompressed data page size
@@ -95,6 +101,8 @@ module Herringbone
95
101
  # @option options [Hash{String => String}] :metadata ({}) key/value metadata for the footer
96
102
  # @option options [Boolean, Array<String>, Hash{String => Boolean, Hash}] :bloom_filters (nil)
97
103
  # columns to write split block bloom filters for
104
+ # @option options [EncryptionConfiguration, Hash{Symbol => Object}, nil] :encryption (nil) modular
105
+ # encryption settings, see #initialize
98
106
  # @yield [writer] the open writer
99
107
  # @yieldparam writer [Writer] writer to append rows to
100
108
  # @yieldreturn [Object] returned by open
@@ -118,6 +126,8 @@ module Herringbone
118
126
  # @param io [IO, #write] destination; switched to binmode when it supports that
119
127
  # @param schema [Schema] schema of the rows
120
128
  # @param compression [Symbol, Integer] codec name (see Herringbone.codecs) or Format::Codec id
129
+ # @param compression_level [Integer, nil] level for :zstd, :gzip or :brotli (see
130
+ # Compression::LEVELS), nil for the codec's default
121
131
  # @param row_group_bytes [Integer] approximate buffered size that triggers a row group
122
132
  # @param row_group_rows [Integer, nil] also flush a row group after this many rows
123
133
  # @param page_bytes [Integer] approximate uncompressed data page size
@@ -131,17 +141,35 @@ module Herringbone
131
141
  # @param metadata [Hash{#to_s => #to_s}] key/value metadata for the footer
132
142
  # @param bloom_filters [Boolean, Array<String>, Hash{String => Boolean, Hash}, nil] see the class
133
143
  # description
134
- # @raise [ArgumentError] for an invalid IO, schema or option
144
+ # @param encryption [EncryptionConfiguration, Hash{Symbol => Object}, false, nil] encrypts the file
145
+ # (Parquet modular encryption); a Hash holds the keywords of EncryptionConfiguration.new.
146
+ # Keys are 16, 24 or 32-byte Strings; key metadata is stored as given, for readers to find
147
+ # the keys by.
148
+ # @option encryption [String] :footer_key key of the footer, and of columns without their own
149
+ # @option encryption [String, nil] :footer_key_metadata stored for the footer key
150
+ # @option encryption [Hash{String => String, Hash, Symbol}, nil] :columns column path or field
151
+ # name => key, +{key:, key_metadata:}+ or +:footer+. Columns not listed are not encrypted;
152
+ # without this option every column is, with the footer key.
153
+ # @option encryption [Boolean] :plaintext_footer (false) store the footer in the clear (signed),
154
+ # so readers without keys can read the plaintext columns
155
+ # @option encryption [Symbol] :algorithm (:aes_gcm) or :aes_gcm_ctr, which encrypts pages with
156
+ # AES-CTR (faster, but page contents are not authenticated)
157
+ # @option encryption [String, nil] :aad_prefix identity of the file, which readers can check
158
+ # @option encryption [Boolean] :store_aad_prefix (true) false leaves it out of the file, so
159
+ # readers must supply it
160
+ # @raise [ArgumentError] for an invalid IO, schema or option, or a compression level the codec
161
+ # does not take
135
162
  # @raise [MissingCodecError] when the codec's optional gem is not loaded
136
163
  # @raise [UnsupportedError] when the codec is not supported
137
- def initialize(io, schema, compression: :snappy, row_group_bytes: 16 * 1024 * 1024, row_group_rows: nil,
164
+ def initialize(io, schema, compression: :snappy, compression_level: nil, row_group_bytes: 16 * 1024 * 1024, row_group_rows: nil,
138
165
  page_bytes: 1024 * 1024, page_rows: 20_000, data_page_version: 1, dictionary: true, encodings: {},
139
- metadata: {}, bloom_filters: nil)
166
+ metadata: {}, bloom_filters: nil, encryption: nil)
140
167
  raise ArgumentError, "Expected a Herringbone::Schema, got #{schema.class}" unless schema.is_a?(Schema)
141
168
  @schema = schema
142
169
  @codec = Compression.codec_id(compression)
143
170
  # Fail before creating any file if the codec's library is missing
144
171
  Compression.ensure_available!(@codec)
172
+ @compression_level = Compression.check_level!(@codec, compression_level)
145
173
  @row_group_bytes = Integer(row_group_bytes)
146
174
  @row_group_rows = row_group_rows && Integer(row_group_rows)
147
175
  @row_limit = @row_group_rows || ESTIMATE_AFTER_ROWS
@@ -159,14 +187,16 @@ module Herringbone
159
187
  @encodings.each { |path, enc| check_encoding!(columns[path], enc) }
160
188
  @metadata = metadata
161
189
  @bloom_filters = bloom_filter_config(bloom_filters)
162
- @pending_bloom_filters = [] # [ColumnMetaData, BloomFilter] for the row group being written
190
+ @pending_bloom_filters = [] # [ColumnMetaData, BloomFilter, ModuleCrypto] for the row group being written
191
+ @encryption = encryption ? Encryption::FileEncryptor.new(EncryptionConfiguration.from(encryption), schema) : nil
163
192
  @row_groups = []
164
193
  @total_rows = 0
165
194
  @pos = 0
166
195
  @closed = false
196
+ @aborted = false
167
197
  @bytes_per_row = nil
168
198
  @io = check_io!(io)
169
- write_raw(MAGIC)
199
+ write_raw(@encryption ? @encryption.magic : MAGIC)
170
200
  reset_buffers
171
201
  end
172
202
 
@@ -222,21 +252,92 @@ module Herringbone
222
252
  # @return [void]
223
253
  def flush_row_group
224
254
  return if @buffered_rows.zero?
255
+ write_row_group(@buffered_rows)
256
+ end
257
+
258
+ # A column chunk taken as it is from another file, for #write_row_group
259
+ #
260
+ # @!attribute chunk
261
+ # @return [Format::ColumnChunk] the chunk's footer entry in the source file
262
+ # @!attribute start
263
+ # @return [Integer] source file offset of the chunk's first page
264
+ # @!attribute bytes
265
+ # @return [String] the chunk's pages, headers included
266
+ # @!attribute column_index
267
+ # @return [String, nil] the chunk's encoded ColumnIndex
268
+ # @!attribute offset_index
269
+ # @return [Format::OffsetIndex, nil] the chunk's OffsetIndex, with source file offsets
270
+ # @!attribute bloom_filter
271
+ # @return [String, nil] the chunk's encoded bloom filter, header included
272
+ CopiedChunk = Struct.new(:chunk, :start, :bytes, :column_index, :offset_index, :bloom_filter)
273
+
274
+ # Internal (used by Redaction): writes a row group of +num_rows+ rows from the buffered values,
275
+ # except for the columns in +copies+, whose chunks are copied byte for byte from another file
276
+ # with their offsets rebased. Starts a new row group afterwards.
277
+ #
278
+ # @param num_rows [Integer] rows in the row group
279
+ # @param copies [Hash{Integer => CopiedChunk}] column index => chunk to copy instead of encoding
280
+ # (only plaintext chunks, into columns this writer does not encrypt)
281
+ # @param codecs [Hash{Integer => Integer}] column index => codec id for an encoded chunk, instead
282
+ # of the +compression:+ option
283
+ # @param bloom_filters [Hash{Integer => Boolean}] column index => true to give an encoded chunk a
284
+ # bloom filter (default settings) when the +bloom_filters:+ option does not ask for one
285
+ # @param sorting_columns [Array<Format::SortingColumn>, nil] stored on the RowGroup as given
286
+ # @return [void]
287
+ # @raise [Error] when the writer is closed
288
+ def write_row_group(num_rows, copies: {}, codecs: {}, bloom_filters: {}, sorting_columns: nil)
289
+ raise Error, "Writer is closed" if @closed
290
+ if @encryption && (encrypted = copies.keys.find { |i| @encryption.encrypted?(i) })
291
+ raise ArgumentError, "Cannot copy a chunk into the encrypted column #{@schema.columns[encrypted].dotted_path}"
292
+ end
225
293
  start = @pos
226
- chunks = @schema.columns.map { |col| write_column_chunk(col, @buffers[col.index]) }
294
+ chunks = @schema.columns.map do |col|
295
+ if (copy = copies[col.index])
296
+ copy_column_chunk(copy)
297
+ else
298
+ bloom = @bloom_filters[col.dotted_path]
299
+ bloom ||= bloom_filter_settings({}) if bloom_filters[col.index] && BloomFilter::TYPES.include?(col.type)
300
+ write_column_chunk(col, @buffers[col.index], codec: codecs.fetch(col.index, @codec), bloom: bloom)
301
+ end
302
+ end
227
303
  @row_groups << Format::RowGroup.new(
228
304
  columns: chunks,
229
305
  total_byte_size: chunks.sum { |c| c.meta_data.total_uncompressed_size },
230
- num_rows: @buffered_rows,
306
+ num_rows: num_rows,
307
+ sorting_columns: sorting_columns,
231
308
  file_offset: start,
232
309
  total_compressed_size: @pos - start,
233
310
  ordinal: @row_groups.size
234
311
  )
235
312
  write_bloom_filters
236
- @total_rows += @buffered_rows
313
+ @total_rows += num_rows
237
314
  reset_buffers
238
315
  end
239
316
 
317
+ # Internal (used by Redaction): whether the column is encrypted in this file
318
+ #
319
+ # @param index [Integer] leaf column index
320
+ # @return [Boolean]
321
+ def encrypted_column?(index) = @encryption&.encrypted?(index) || false
322
+
323
+ # Internal (used by Redaction): shreds one value per row of a top-level field into the buffers,
324
+ # for #write_row_group. Other fields are left as they are, so the caller decides which columns
325
+ # are encoded and which are copied.
326
+ #
327
+ # @param name [String] top-level field name
328
+ # @param values [Array] the field's value for each row
329
+ # @return [void]
330
+ # @raise [ArgumentError] when the schema has no such field
331
+ # @raise [EncodeError] when a value cannot be written to the field
332
+ def buffer_field(name, values)
333
+ field = @schema.field(name) or raise ArgumentError, "No such field #{name.inspect}"
334
+ values.each_with_index do |value, i|
335
+ shred(field, value, 0, 0)
336
+ rescue EncodeError => e
337
+ raise EncodeError.new("Row #{@total_rows + i}: #{e.message}", row: @total_rows + i, column: e.column, value: e.value)
338
+ end
339
+ end
340
+
240
341
  # Flushes buffered rows, then writes the page indexes and the footer and flushes the IO.
241
342
  # Does nothing when already closed or aborted. The IO itself is not closed.
242
343
  #
@@ -245,6 +346,7 @@ module Herringbone
245
346
  return if @closed
246
347
  flush_row_group
247
348
  write_page_indexes
349
+ @encryption&.finish(@row_groups)
248
350
  meta = Format::FileMetaData.new(
249
351
  version: 2,
250
352
  schema: @schema.to_elements,
@@ -254,10 +356,10 @@ module Herringbone
254
356
  created_by: "herringbone-ruby #{VERSION}",
255
357
  column_orders: @schema.columns.map { Format::ColumnOrder.new(type_order: Format::TypeDefinedOrder.new) }
256
358
  )
257
- footer = meta.encode
359
+ footer = @encryption ? @encryption.footer(meta) : meta.encode
258
360
  write_raw(footer)
259
361
  write_raw([footer.bytesize].pack("V"))
260
- write_raw(MAGIC)
362
+ write_raw(@encryption ? @encryption.magic : MAGIC)
261
363
  @io.flush if @io.respond_to?(:flush)
262
364
  @closed = true
263
365
  end
@@ -267,9 +369,23 @@ module Herringbone
267
369
  #
268
370
  # @return [void]
269
371
  def abort
372
+ @aborted = true unless @closed
270
373
  @closed = true
271
374
  end
272
375
 
376
+ # Short summary for the console, without the buffered values
377
+ #
378
+ # @return [String] state (open, closed or aborted), rows written including buffered ones,
379
+ # row groups flushed and the codec
380
+ def inspect
381
+ state = if @aborted then "aborted"
382
+ elsif @closed then "closed"
383
+ else "open"
384
+ end
385
+ "#<#{self.class.name} #{state} rows_written=#{rows_written} row_groups=#{@row_groups.size} " \
386
+ "compression=#{Compression::NAMES.fetch(@codec, @codec).inspect}>"
387
+ end
388
+
273
389
  private
274
390
 
275
391
  # Pathname responds to #write too (writing a whole file by path), so it is rejected explicitly.
@@ -534,8 +650,11 @@ module Herringbone
534
650
  #
535
651
  # @param col [Schema::Column] column being written
536
652
  # @param buffer [ColumnBuffer] the column's buffered levels and values
653
+ # @param codec [Integer] codec id to compress the pages with
654
+ # @param bloom [Hash{Symbol => Numeric, nil}, nil] bloom filter settings (see
655
+ # #bloom_filter_settings), nil for no bloom filter
537
656
  # @return [Format::ColumnChunk] chunk with its ColumnMetaData, for the row group
538
- def write_column_chunk(col, buffer)
657
+ def write_column_chunk(col, buffer, codec: @codec, bloom: @bloom_filters[col.dotted_path])
539
658
  type = col.type
540
659
  path = col.dotted_path
541
660
  dict_values = nil
@@ -558,6 +677,7 @@ module Herringbone
558
677
  @encodings[path] || E::PLAIN
559
678
  end
560
679
 
680
+ crypto = @encryption&.chunk(@row_groups.size, col.index)
561
681
  chunk_start = @pos
562
682
  uncompressed_total = 0
563
683
  dictionary_offset = nil
@@ -568,7 +688,7 @@ module Herringbone
568
688
  type: Format::PageType::DICTIONARY_PAGE,
569
689
  dictionary_page_header: Format::DictionaryPageHeader.new(num_values: dict_values.size, encoding: E::PLAIN)
570
690
  )
571
- uncompressed_total += write_page(header, plain)
691
+ uncompressed_total += write_page(header, plain, codec: codec, crypto: crypto)
572
692
  end
573
693
 
574
694
  data_offset = @pos
@@ -601,11 +721,12 @@ module Herringbone
601
721
  encoded = encode_values(page_values, value_encoding, type, col.type_length, dict_values&.size)
602
722
  rep_bytes = max_rep.positive? ? Encodings::RLE.encode_hybrid(reps, max_rep.bit_length) : "".b
603
723
  def_bytes = max_def.positive? ? Encodings::RLE.encode_hybrid(defs, max_def.bit_length) : "".b
724
+ page_crypto = crypto && [crypto, pages.size]
604
725
  uncompressed_total += if @data_page_version == 1
605
- write_data_page_v1(n, rep_bytes, def_bytes, encoded, value_encoding)
726
+ write_data_page_v1(n, rep_bytes, def_bytes, encoded, value_encoding, codec, page_crypto)
606
727
  else
607
728
  num_rows = max_rep.zero? ? n : reps.count(0)
608
- write_data_page_v2(n, n - non_null, num_rows, rep_bytes, def_bytes, encoded, value_encoding)
729
+ write_data_page_v2(n, n - non_null, num_rows, rep_bytes, def_bytes, encoded, value_encoding, codec, page_crypto)
609
730
  end
610
731
  pages << PageInfo.new(page_offset, @pos - page_offset, first_row, n - non_null, non_null, range)
611
732
  first_row += max_rep.zero? ? n : reps.count(0)
@@ -618,7 +739,7 @@ module Herringbone
618
739
  type: type,
619
740
  encodings: encodings.uniq,
620
741
  path_in_schema: col.path,
621
- codec: @codec,
742
+ codec: codec,
622
743
  num_values: buf.defs.size,
623
744
  total_uncompressed_size: uncompressed_total,
624
745
  total_compressed_size: @pos - chunk_start,
@@ -627,10 +748,10 @@ module Herringbone
627
748
  statistics: statistics_for(col, buf.defs, values || indices.uniq.map { |i| dict_values[i] }, order)
628
749
  )
629
750
  chunk = Format::ColumnChunk.new(file_offset: chunk_start, meta_data: meta)
630
- if (bloom = @bloom_filters[path])
631
- @pending_bloom_filters << [meta, build_bloom_filter(col, bloom, dict_values, values)]
751
+ if bloom
752
+ @pending_bloom_filters << [meta, build_bloom_filter(col, bloom, dict_values, values), crypto]
632
753
  end
633
- @page_indexes << [chunk, column_index_for(col, pages, order), offset_index_for(pages)]
754
+ @page_indexes << [chunk, column_index_for(col, pages, order), offset_index_for(pages), crypto]
634
755
  chunk
635
756
  end
636
757
 
@@ -658,6 +779,37 @@ module Herringbone
658
779
  end)
659
780
  end
660
781
 
782
+ # Writes a chunk copied from another file. The pages, the ColumnIndex and the bloom filter
783
+ # hold no file offsets and go out as they are; the ColumnMetaData and the OffsetIndex do, so
784
+ # those are rebased onto where the chunk lands in this file.
785
+ #
786
+ # @param copy [CopiedChunk] the chunk to copy
787
+ # @return [Format::ColumnChunk] chunk with its ColumnMetaData, for the row group
788
+ def copy_column_chunk(copy)
789
+ start = @pos
790
+ shift = start - copy.start
791
+ meta = Format::ColumnMetaData.decode(copy.chunk.meta_data.encode).first
792
+ meta.data_page_offset += shift
793
+ dict = meta.dictionary_page_offset
794
+ # Some writers store 0 when there is no dictionary page
795
+ meta.dictionary_page_offset = dict&.positive? ? dict + shift : nil
796
+ meta.index_page_offset += shift if meta.index_page_offset
797
+ meta.total_compressed_size = copy.bytes.bytesize
798
+ meta.bloom_filter_offset = meta.bloom_filter_length = nil
799
+ write_raw(copy.bytes)
800
+ chunk = Format::ColumnChunk.new(file_offset: start, meta_data: meta)
801
+ @pending_bloom_filters << [meta, copy.bloom_filter] if copy.bloom_filter
802
+ offset_index = copy.offset_index && Format::OffsetIndex.new(
803
+ page_locations: copy.offset_index.page_locations.map do |loc|
804
+ Format::PageLocation.new(offset: loc.offset + shift, compressed_page_size: loc.compressed_page_size,
805
+ first_row_index: loc.first_row_index)
806
+ end,
807
+ unencoded_byte_array_data_bytes: copy.offset_index.unencoded_byte_array_data_bytes
808
+ )
809
+ @page_indexes << [chunk, copy.column_index, offset_index]
810
+ chunk
811
+ end
812
+
661
813
  # nil when the column has no defined sort order, or a page's values have no min/max (all NaN)
662
814
  #
663
815
  # @param col [Schema::Column] column the pages belong to
@@ -693,19 +845,24 @@ module Herringbone
693
845
  end
694
846
  end
695
847
 
696
- # Page indexes go after the last row group: all column indexes, then all offset indexes
848
+ # Page indexes go after the last row group: all column indexes, then all offset indexes.
849
+ # A copied chunk brings its ColumnIndex already encoded, and may come without either index.
850
+ # Those of encrypted columns are encrypted.
697
851
  #
698
852
  # @return [void]
699
853
  def write_page_indexes
700
- @page_indexes.each do |chunk, column_index, _|
854
+ @page_indexes.each do |chunk, column_index, _, crypto|
701
855
  next unless column_index
702
- bytes = column_index.encode
856
+ bytes = column_index.is_a?(String) ? column_index : column_index.encode
857
+ bytes = crypto.encrypt(Encryption::COLUMN_INDEX, bytes) if crypto
703
858
  chunk.column_index_offset = @pos
704
859
  chunk.column_index_length = bytes.bytesize
705
860
  write_raw(bytes)
706
861
  end
707
- @page_indexes.each do |chunk, _, offset_index|
862
+ @page_indexes.each do |chunk, _, offset_index, crypto|
863
+ next unless offset_index
708
864
  bytes = offset_index.encode
865
+ bytes = crypto.encrypt(Encryption::OFFSET_INDEX, bytes) if crypto
709
866
  chunk.offset_index_offset = @pos
710
867
  chunk.offset_index_length = bytes.bytesize
711
868
  write_raw(bytes)
@@ -780,12 +937,19 @@ module Herringbone
780
937
  filter
781
938
  end
782
939
 
783
- # Bloom filters go right after the row group's column chunks, in column order
940
+ # Bloom filters go right after the row group's column chunks, in column order. A copied
941
+ # chunk brings its filter already encoded. In an encrypted column the header and the bitset
942
+ # are encrypted separately.
784
943
  #
785
944
  # @return [void]
786
945
  def write_bloom_filters
787
- @pending_bloom_filters.each do |meta, filter|
788
- bytes = filter.encode
946
+ @pending_bloom_filters.each do |meta, filter, crypto|
947
+ bytes = if crypto
948
+ crypto.encrypt(Encryption::BLOOM_FILTER_HEADER, filter.header.encode) <<
949
+ crypto.encrypt(Encryption::BLOOM_FILTER_BITSET, filter.bitset)
950
+ else
951
+ filter.is_a?(String) ? filter : filter.encode
952
+ end
789
953
  meta.bloom_filter_offset = @pos
790
954
  meta.bloom_filter_length = bytes.bytesize
791
955
  write_raw(bytes)
@@ -902,17 +1066,40 @@ module Herringbone
902
1066
 
903
1067
  # Writes a page, returning the uncompressed size including the header
904
1068
  #
1069
+ # Compresses with the configured level when +codec+ is the writer's own; chunks a redaction
1070
+ # keeps in their source codec use that codec's default
1071
+ #
1072
+ # @param codec [Integer] codec id
1073
+ # @param data [String] bytes to compress
1074
+ # @return [String] compressed bytes
1075
+ def compress(codec, data)
1076
+ Compression.compress(codec, data, (codec == @codec) ? @compression_level : nil)
1077
+ end
1078
+
1079
+ # In an encrypted column the body and the header are encrypted; the CRC covers the body as
1080
+ # stored, encrypted.
1081
+ #
905
1082
  # @param header [Format::PageHeader] header; sizes and CRC32 are filled in here
906
1083
  # @param body [String] uncompressed page body
907
1084
  # @param compressed [String, nil] bytes to write as the page body, when already prepared (v2 data
908
1085
  # pages, whose levels stay uncompressed); +body+ is compressed otherwise
1086
+ # @param codec [Integer] codec id to compress +body+ with
1087
+ # @param crypto [Encryption::ModuleCrypto, nil] encryption of the column's modules
1088
+ # @param ordinal [Integer, nil] data page ordinal within the chunk; nil for a dictionary page
909
1089
  # @return [Integer]
910
- def write_page(header, body, compressed = nil)
911
- compressed ||= Compression.compress(@codec, body)
1090
+ def write_page(header, body, compressed = nil, codec: @codec, crypto: nil, ordinal: nil)
1091
+ compressed ||= compress(codec, body)
912
1092
  header.uncompressed_page_size ||= body.bytesize
1093
+ dictionary = header.type == Format::PageType::DICTIONARY_PAGE
1094
+ if crypto
1095
+ compressed = crypto.encrypt(dictionary ? Encryption::DICTIONARY_PAGE : Encryption::DATA_PAGE, compressed, ordinal)
1096
+ end
913
1097
  header.compressed_page_size = compressed.bytesize
914
1098
  header.crc = Zlib.crc32(compressed).then { |c| (c >= 0x8000_0000) ? c - 0x1_0000_0000 : c }
915
1099
  encoded = header.encode
1100
+ if crypto
1101
+ encoded = crypto.encrypt(dictionary ? Encryption::DICTIONARY_PAGE_HEADER : Encryption::DATA_PAGE_HEADER, encoded, ordinal)
1102
+ end
916
1103
  write_raw(encoded)
917
1104
  write_raw(compressed)
918
1105
  encoded.bytesize + header.uncompressed_page_size
@@ -925,8 +1112,11 @@ module Herringbone
925
1112
  # @param def_bytes [String] RLE-encoded definition levels, empty when the column has none
926
1113
  # @param encoded [String] encoded values
927
1114
  # @param encoding [Integer] encoding id of the values
1115
+ # @param codec [Integer] codec id to compress the page with
1116
+ # @param page_crypto [Array(Encryption::ModuleCrypto, Integer), nil] encryption of the column's
1117
+ # modules and the page's ordinal, for an encrypted column
928
1118
  # @return [Integer] uncompressed size including the header
929
- def write_data_page_v1(n, rep_bytes, def_bytes, encoded, encoding)
1119
+ def write_data_page_v1(n, rep_bytes, def_bytes, encoded, encoding, codec, page_crypto)
930
1120
  body = String.new(encoding: Encoding::BINARY)
931
1121
  body << [rep_bytes.bytesize].pack("V") << rep_bytes unless rep_bytes.empty?
932
1122
  body << [def_bytes.bytesize].pack("V") << def_bytes unless def_bytes.empty?
@@ -938,7 +1128,7 @@ module Herringbone
938
1128
  definition_level_encoding: E::RLE, repetition_level_encoding: E::RLE
939
1129
  )
940
1130
  )
941
- write_page(header, body)
1131
+ write_page(header, body, codec: codec, crypto: page_crypto&.first, ordinal: page_crypto&.last)
942
1132
  end
943
1133
 
944
1134
  # A DATA_PAGE_V2: levels without length prefixes and uncompressed, then the compressed values
@@ -950,9 +1140,12 @@ module Herringbone
950
1140
  # @param def_bytes [String] RLE-encoded definition levels, empty when the column has none
951
1141
  # @param encoded [String] encoded values
952
1142
  # @param encoding [Integer] encoding id of the values
1143
+ # @param codec [Integer] codec id to compress the values with
1144
+ # @param page_crypto [Array(Encryption::ModuleCrypto, Integer), nil] encryption of the column's
1145
+ # modules and the page's ordinal, for an encrypted column
953
1146
  # @return [Integer] uncompressed size including the header
954
- def write_data_page_v2(n, nulls, rows, rep_bytes, def_bytes, encoded, encoding)
955
- compressed = Compression.compress(@codec, encoded)
1147
+ def write_data_page_v2(n, nulls, rows, rep_bytes, def_bytes, encoded, encoding, codec, page_crypto)
1148
+ compressed = compress(codec, encoded)
956
1149
  header = Format::PageHeader.new(
957
1150
  type: Format::PageType::DATA_PAGE_V2,
958
1151
  uncompressed_page_size: rep_bytes.bytesize + def_bytes.bytesize + encoded.bytesize,
@@ -960,10 +1153,11 @@ module Herringbone
960
1153
  num_values: n, num_nulls: nulls, num_rows: rows, encoding: encoding,
961
1154
  definition_levels_byte_length: def_bytes.bytesize,
962
1155
  repetition_levels_byte_length: rep_bytes.bytesize,
963
- is_compressed: @codec != Format::Codec::UNCOMPRESSED
1156
+ is_compressed: codec != Format::Codec::UNCOMPRESSED
964
1157
  )
965
1158
  )
966
- write_page(header, "".b, rep_bytes + def_bytes + compressed)
1159
+ write_page(header, "".b, rep_bytes + def_bytes + compressed, codec: codec, crypto: page_crypto&.first,
1160
+ ordinal: page_crypto&.last)
967
1161
  end
968
1162
 
969
1163
  # Statistics and column index bounds longer than this are truncated, see #truncate_min