herringbone 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,287 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Herringbone
4
+ # Rewrites a Parquet file with rows removed or column values replaced, for GDPR erasure ("forget
5
+ # me") and pseudonymization. A Redaction is built once and applied to any number of files, one
6
+ # file in and one file out:
7
+ #
8
+ # forget = Herringbone::Redaction.new do |r|
9
+ # r.where(user_id: 42).delete
10
+ # r.where(email: "anna@example.com").replace(email: nil, name: nil)
11
+ # r.replace(:phone) { |phone| phone&.gsub(/\d(?=\d{3})/, "#") }
12
+ # r.drop :ssn
13
+ # end
14
+ # File.open("in.parquet", "rb") do |input|
15
+ # File.open("out.parquet", "wb") { |output| forget.apply(input, output) }
16
+ # end
17
+ #
18
+ # Statements apply in declared order, per row: a deleted row is gone for later statements, and
19
+ # later statements (their conditions and their blocks) see what earlier ones replaced.
20
+ #
21
+ # Row groups no statement touches are copied byte for byte. A row group where only leaf columns
22
+ # are replaced gets just those column chunks re-encoded. A row group with deleted rows, or with a
23
+ # nested field replaced as a whole, is rewritten. Either way no deleted or replaced value is
24
+ # left in data pages, dictionary pages, statistics, the page index or bloom filters.
25
+ class Redaction
26
+ # One statement of a Redaction
27
+ #
28
+ # @!attribute kind
29
+ # @return [Symbol] +:delete+ or +:replace+
30
+ # @!attribute where
31
+ # @return [Hash{String, Symbol => Object}, nil] conditions as given to #where, nil for every row
32
+ # @!attribute targets
33
+ # @return [Array<String>] columns to replace (top-level names or dotted struct member paths)
34
+ # @!attribute constants
35
+ # @return [Hash{String => Object}] column => value, for a replace without a block
36
+ # @!attribute block
37
+ # @return [Proc, nil] computes the replacement values
38
+ Statement = Struct.new(:kind, :where, :targets, :constants, :block)
39
+
40
+ # What #apply did, for the audit trail an erasure needs
41
+ #
42
+ # @!attribute rows_read
43
+ # @return [Integer] rows decoded (rows of row groups that were copied unread are not counted)
44
+ # @!attribute rows_deleted
45
+ # @return [Integer] rows removed
46
+ # @!attribute rows_changed
47
+ # @return [Integer] rows kept where at least one replace produced a different value
48
+ # @!attribute row_groups
49
+ # @return [Hash{Symbol => Integer}] +{copied:, rewritten:}+ row group counts
50
+ Report = Struct.new(:rows_read, :rows_deleted, :rows_changed, :row_groups, keyword_init: true)
51
+
52
+ # Rows selected with Redaction#where, waiting for a verb: #delete or #replace
53
+ class Scope
54
+ # @return [Hash{String, Symbol => Object}] the conditions given to Redaction#where
55
+ attr_reader :conditions
56
+
57
+ # @param redaction [Redaction] the redaction the statement is added to
58
+ # @param conditions [Hash{String, Symbol => Object}] column => condition, as Reader#read(where:)
59
+ def initialize(redaction, conditions)
60
+ @redaction = redaction
61
+ @conditions = conditions
62
+ @used = false
63
+ end
64
+
65
+ # @return [Boolean] whether a verb was called on this scope
66
+ def used? = @used
67
+
68
+ # Removes the matching rows
69
+ #
70
+ # @return [Redaction] the redaction, for chaining
71
+ def delete
72
+ @used = true
73
+ @redaction.add_statement(Statement.new(:delete, @conditions, [], {}, nil))
74
+ end
75
+
76
+ # Replaces values in the matching rows, see Redaction#replace
77
+ #
78
+ # @param columns [Array<String, Symbol>] columns whose values the block computes
79
+ # @param constants [Hash{String, Symbol => Object}] column => value to set
80
+ # @option constants [Object] :any_column value to store in that column (keys are column names)
81
+ # @yield [value, row] once per matching row and named column
82
+ # @yieldparam value [Object] the current value
83
+ # @yieldparam row [Hash{String => Object}] the whole row, only when the block takes two parameters
84
+ # @yieldreturn [Object] the value to store
85
+ # @return [Redaction] the redaction, for chaining
86
+ # @raise [ArgumentError] when given both or neither of +constants+ and a block
87
+ def replace(*columns, **constants, &block)
88
+ statement = Redaction.replace_statement(@conditions, columns, constants, block)
89
+ @used = true
90
+ @redaction.add_statement(statement)
91
+ end
92
+
93
+ # Short summary for the console: the columns the conditions name, not their values
94
+ #
95
+ # @return [String]
96
+ def inspect
97
+ "#<#{self.class.name} where #{@conditions.keys.join(", ")}#{" (no verb yet)" unless @used}>"
98
+ end
99
+ end
100
+
101
+ # @return [Array<Statement>] the statements, in declared order
102
+ attr_reader :statements
103
+
104
+ # @return [Array<String>] fields and struct members to remove from the schema
105
+ attr_reader :drops
106
+
107
+ # Builds a redaction. The block receives the redaction and declares the statements on it,
108
+ # see the class description.
109
+ #
110
+ # @yield [r] declares the statements
111
+ # @yieldparam r [Redaction] the redaction being built
112
+ # @yieldreturn [void]
113
+ # @raise [ArgumentError] for a block that takes no parameter, an invalid statement, or a
114
+ # #where left without a verb
115
+ def initialize(&block)
116
+ @statements = []
117
+ @drops = []
118
+ @scopes = []
119
+ return unless block
120
+ if block.arity.zero?
121
+ raise ArgumentError, "The block receives the redaction: Redaction.new { |r| r.where(user_id: 42).delete }"
122
+ end
123
+ yield self
124
+ check_scopes!
125
+ end
126
+
127
+ # Selects the rows the next verb applies to. Takes what Reader#read(where:) takes: values,
128
+ # Arrays (IN), Ranges, nil (IS NULL), callables and dotted struct member paths, all of which
129
+ # must hold.
130
+ #
131
+ # where(user_id: 42).delete
132
+ # where("address.city" => "Amsterdam", created_at: ..cutoff).replace(name: nil)
133
+ #
134
+ # @param conditions [Hash{String, Symbol => Object}, nil] column => condition
135
+ # @param more [Hash{String, Symbol => Object}] more conditions, as keyword arguments
136
+ # @option more [Object] :any_column condition on that column (keys are column names)
137
+ # @return [Scope] call #delete or #replace on it
138
+ # @raise [ArgumentError] when there are no conditions
139
+ def where(conditions = nil, **more)
140
+ raise ArgumentError, "where expects a Hash of column => condition" unless conditions.nil? || conditions.is_a?(Hash)
141
+ conditions = (conditions || {}).merge(more)
142
+ raise ArgumentError, "where needs at least one condition" if conditions.empty?
143
+ scope = Scope.new(self, conditions)
144
+ @scopes << scope
145
+ scope
146
+ end
147
+
148
+ # Replaces values in every row (or, after #where, in the matching rows). Either constants:
149
+ #
150
+ # replace(email: nil, name: "[deleted]")
151
+ #
152
+ # or column names and a block computing each value from the current one, and from the whole
153
+ # row (a Hash with String keys, as Reader returns it) when the block takes two parameters:
154
+ #
155
+ # replace(:email) { |email| email && OpenSSL::HMAC.hexdigest("SHA256", key, email) }
156
+ # replace(:name) { |name, row| row["consent"] ? name : nil }
157
+ #
158
+ # A column is a top-level field or a struct member by dotted path. Values inside a list or map
159
+ # are replaced by replacing the whole field with a block that receives the Array or Hash.
160
+ # A struct member of a row whose struct is null is left alone.
161
+ #
162
+ # @param columns [Array<String, Symbol>] columns whose values the block computes
163
+ # @param constants [Hash{String, Symbol => Object}] column => value to set
164
+ # @option constants [Object] :any_column value to store in that column (keys are column names)
165
+ # @yield [value, row] once per row and named column
166
+ # @yieldparam value [Object] the current value
167
+ # @yieldparam row [Hash{String => Object}] the whole row, only when the block takes two parameters
168
+ # @yieldreturn [Object] the value to store
169
+ # @return [Redaction] self
170
+ # @raise [ArgumentError] when given both or neither of +constants+ and a block
171
+ def replace(*columns, **constants, &block)
172
+ add_statement(Redaction.replace_statement(nil, columns, constants, block))
173
+ end
174
+
175
+ # Removes columns from the schema: top-level fields, or struct members by dotted path
176
+ #
177
+ # @param columns [Array<String, Symbol>] columns to remove
178
+ # @return [Redaction] self
179
+ # @raise [ArgumentError] when no column is given
180
+ def drop(*columns)
181
+ raise ArgumentError, "drop needs at least one column" if columns.empty?
182
+ @drops.concat(columns.map(&:to_s))
183
+ self
184
+ end
185
+
186
+ # Internal (used by Scope): appends a statement
187
+ #
188
+ # @param statement [Statement] the statement to add
189
+ # @return [Redaction] self
190
+ def add_statement(statement)
191
+ @statements << statement
192
+ self
193
+ end
194
+
195
+ # Whether #apply would change +input_io+: true when a column is dropped, when a replace without
196
+ # #where meets a non-empty file, or when some row matches a #where. Reads as little as it can:
197
+ # row groups are ruled out with statistics, bloom filters and the page index first, and only
198
+ # the columns the conditions name are read for the rest.
199
+ #
200
+ # @param input_io [IO, StringIO] the Parquet file, read with #seek and #read
201
+ # @return [Boolean]
202
+ # @raise [ArgumentError] when the redaction does not fit the file's schema
203
+ def affects?(input_io)
204
+ check_scopes!
205
+ Rewriter.new(self, input_io).affects?
206
+ end
207
+
208
+ # Writes the redacted copy of +input_io+ to +output_io+. The redaction is checked against the
209
+ # file's schema first, so a missing column, a nil for a required column or a #where without a
210
+ # verb raise before anything is written. The output is left unfinished (no footer) when an
211
+ # error happens later, for instance a block returning a value its column cannot store.
212
+ #
213
+ # @param input_io [IO, StringIO] the Parquet file, read with #seek and #read; not closed
214
+ # @param output_io [IO, #write] destination, written sequentially; not closed
215
+ # @param writer_options [Hash{Symbol => Object}] Writer options for the re-encoded column chunks
216
+ # (+compression:+, +bloom_filters:+, +page_rows:+, +dictionary:+...), and +metadata:+ to
217
+ # replace the footer key/value metadata instead of copying it
218
+ # @option writer_options [Symbol] :compression (codec of each source chunk) codec for re-encoded chunks
219
+ # @option writer_options [Boolean, Array<String>, Hash{String => Boolean, Hash}] :bloom_filters (nil)
220
+ # columns whose re-encoded chunks get a bloom filter, besides those whose source chunk had one
221
+ # @option writer_options [Hash{String => String}] :metadata (the input's) footer key/value metadata
222
+ # @option writer_options [Integer] :page_bytes (1MB) approximate uncompressed data page size
223
+ # @option writer_options [Integer] :page_rows (20_000) maximum rows per data page
224
+ # @option writer_options [Integer] :data_page_version (1) 1 or 2
225
+ # @option writer_options [Boolean, Array<String>] :dictionary (true) see Writer
226
+ # @option writer_options [Hash{String => Symbol}] :encodings ({}) see Writer
227
+ # @return [Report] what was done
228
+ # @raise [ArgumentError] when the redaction does not fit the file's schema, or for a writer
229
+ # option that does not apply (+row_group_bytes:+, +row_group_rows:+)
230
+ # @raise [EncodeError] when a replacement value cannot be written to its column
231
+ def apply(input_io, output_io, **writer_options)
232
+ check_scopes!
233
+ Rewriter.new(self, input_io).apply(output_io, **writer_options)
234
+ end
235
+
236
+ # Short summary for the console: one clause per statement, naming the columns but not the
237
+ # condition values, since a where may hold a long list of ids
238
+ #
239
+ # @return [String]
240
+ def inspect
241
+ clauses = @statements.map do |s|
242
+ clause = (s.kind == :delete) ? "delete" : "replace #{s.targets.join(", ")}"
243
+ s.where ? "#{clause} where #{s.where.keys.join(", ")}" : clause
244
+ end
245
+ clauses << "drop #{@drops.join(", ")}" unless @drops.empty?
246
+ "#<#{self.class.name} #{clauses.empty? ? "(no statements)" : clauses.join("; ")}>"
247
+ end
248
+
249
+ # Builds a replace statement from the arguments of #replace
250
+ #
251
+ # @param where [Hash{String, Symbol => Object}, nil] conditions, nil for every row
252
+ # @param columns [Array<String, Symbol>] columns for the block
253
+ # @param constants [Hash{String, Symbol => Object}] column => value
254
+ # @param block [Proc, nil] computes the values
255
+ # @return [Statement]
256
+ # @raise [ArgumentError] when given both or neither of +constants+ and a block
257
+ def self.replace_statement(where, columns, constants, block)
258
+ if block
259
+ unless constants.empty?
260
+ raise ArgumentError, "replace takes column names and a block, or column => value pairs, not both"
261
+ end
262
+ raise ArgumentError, "replace with a block needs the names of the columns to replace" if columns.empty?
263
+ Statement.new(:replace, where, columns.map(&:to_s).uniq, {}, block)
264
+ else
265
+ unless columns.empty?
266
+ raise ArgumentError, "replace(#{columns.map(&:inspect).join(", ")}) needs a block computing the " \
267
+ "values; use replace(column: value) to set constants"
268
+ end
269
+ raise ArgumentError, "replace needs columns: replace(email: nil) or replace(:email) { |v| ... }" if constants.empty?
270
+ constants = constants.transform_keys(&:to_s)
271
+ Statement.new(:replace, where, constants.keys, constants, nil)
272
+ end
273
+ end
274
+
275
+ private
276
+
277
+ # @return [void]
278
+ # @raise [ArgumentError] when a #where was not followed by #delete or #replace
279
+ def check_scopes!
280
+ idle = @scopes.reject(&:used?)
281
+ return if idle.empty?
282
+ raise ArgumentError, "where(#{idle.first.conditions.inspect}) needs a verb: .delete or .replace(...)"
283
+ end
284
+ end
285
+ end
286
+
287
+ require_relative "redaction/rewriter"
@@ -248,12 +248,19 @@ module Herringbone
248
248
 
249
249
  # Builds a schema with the DSL, see Schema::Builder
250
250
  #
251
- # @yield block evaluated with +instance_eval+ on a Builder, declaring the top-level fields
251
+ # Herringbone::Schema.define do |s|
252
+ # s.int64 :id, null: false
253
+ # s.string :name
254
+ # end
255
+ #
256
+ # @yield [s] declares the top-level fields
257
+ # @yieldparam s [Builder] the builder to declare fields on
258
+ # @yieldreturn [void]
252
259
  # @return [Schema]
253
- # @raise [ArgumentError] when no field is declared, or a declaration is invalid
260
+ # @raise [ArgumentError] when no field is declared, a declaration is invalid, or the block takes
261
+ # no parameter
254
262
  def self.define(&block)
255
- builder = Builder.new
256
- builder.instance_eval(&block) if block
263
+ builder = Builder.build("Herringbone::Schema.define { |s| s.int64 :id }", &block)
257
264
  raise ArgumentError, "A schema needs at least one field" if builder.nodes.empty?
258
265
  new(Node.new(name: "schema", repetition: :required, children: builder.nodes))
259
266
  end
@@ -267,19 +274,19 @@ module Herringbone
267
274
  # Time/DateTime -> timestamp(micros), Date -> date, BigDecimal -> decimal(38, max scale seen),
268
275
  # Hash -> struct, Array -> list. Columns that are nil in every sampled row become strings.
269
276
  # Fields declared in the block (Builder DSL) replace the inferred ones of the same name:
270
- # Schema.infer(rows) { json :payload }
277
+ # Schema.infer(rows) { |s| s.json :payload }
271
278
  #
272
279
  # @param rows [Enumerable<Hash, Object>] rows to sample; only the first INFER_SAMPLE are read
273
- # @yield optional block evaluated with +instance_eval+ on a Builder, declaring fields that
274
- # replace inferred ones (or are added after them)
280
+ # @yield [s] optional, declares fields that replace inferred ones (or are added after them)
281
+ # @yieldparam s [Builder] the builder to declare fields on
282
+ # @yieldreturn [void]
275
283
  # @return [Schema]
276
- # @raise [ArgumentError] when there are no rows, a row is not Hash-like, or a column mixes
277
- # values that map to no single Parquet type
284
+ # @raise [ArgumentError] when there are no rows, a row is not Hash-like, a column mixes values
285
+ # that map to no single Parquet type, or the block takes no parameter
278
286
  def self.infer(rows, &block)
287
+ overrides = Builder.build("Herringbone::Schema.infer(rows) { |s| s.json :payload }", &block)
279
288
  sample_rows = rows.first(INFER_SAMPLE).map { |r| Inference.row_hash(r) }
280
289
  raise ArgumentError, "Cannot infer a schema from zero rows" if sample_rows.empty?
281
- overrides = Builder.new
282
- overrides.instance_eval(&block) if block
283
290
  declared = overrides.nodes.to_h { |n| [n.name, n] }
284
291
  names = sample_rows.flat_map { |r| r.keys.map(&:to_s) }.uniq
285
292
  nodes = names.map do |name|
@@ -355,7 +362,7 @@ module Herringbone
355
362
  else
356
363
  classes = values.map(&:class).uniq
357
364
  raise ArgumentError, "Cannot infer a Parquet type for #{name} from #{classes.map(&:name).join(", ")}; " \
358
- "declare it in a block: Schema.infer(rows) { string :#{name} }"
365
+ "declare it in a block: Schema.infer(rows) { |s| s.string :#{name} }"
359
366
  end
360
367
  end
361
368
  end
@@ -511,30 +518,58 @@ module Herringbone
511
518
 
512
519
  # DSL for defining schemas:
513
520
  #
514
- # Herringbone::Schema.define do
515
- # int64 :id, null: false
516
- # string :name
517
- # list :tags, :string
518
- # map :scores, :string, :double
519
- # struct :address do
520
- # string :city
521
+ # Herringbone::Schema.define do |s|
522
+ # s.int64 :id, null: false
523
+ # s.string :name
524
+ # s.list :tags, :string
525
+ # s.map :scores, :string, :double
526
+ # s.struct :address do |address|
527
+ # address.string :city
521
528
  # end
522
- # decimal :price, precision: 12, scale: 2
523
- # timestamp :created_at, unit: :micros
529
+ # s.decimal :price, precision: 12, scale: 2
530
+ # s.timestamp :created_at, unit: :micros
524
531
  # end
525
532
  #
526
- # Fields are nullable unless null: false is given.
533
+ # Fields are nullable unless null: false is given. The blocks of #struct, #list and #map get
534
+ # a Builder of their own.
527
535
  class Builder
528
536
  # @return [Array<Node>] fields declared so far, in declaration order
529
537
  attr_reader :nodes
530
538
 
531
- # Starts with no fields; Schema.define evaluates the block against this builder
539
+ # Yields a new Builder to the block.
540
+ #
541
+ # @param usage [String] how the entry point is called with a block, for the error message
542
+ # @yield [s] declares fields
543
+ # @yieldparam s [Builder] the new builder
544
+ # @yieldreturn [void]
545
+ # @return [Builder] the builder, with no fields when there is no block
546
+ # @raise [ArgumentError] when the block takes no parameter
547
+ def self.build(usage, &block)
548
+ check_block!(block, usage)
549
+ builder = new
550
+ block&.call(builder)
551
+ builder
552
+ end
553
+
554
+ # A block without a parameter was most likely written for the +instance_eval+ DSL of
555
+ # earlier versions, and would fail on its first declaration with a NoMethodError
556
+ #
557
+ # @param block [Proc, nil] the block given to the entry point
558
+ # @param usage [String] how the entry point is called with a block, for the error message
559
+ # @return [void]
560
+ # @raise [ArgumentError] when the block takes no parameter
561
+ def self.check_block!(block, usage)
562
+ return if block.nil? || !block.parameters.empty?
563
+ raise ArgumentError, "The block receives the schema builder as a parameter: #{usage}"
564
+ end
565
+
566
+ # Starts with no fields
532
567
  def initialize
533
568
  @nodes = []
534
569
  end
535
570
 
536
571
  # Types that need no options; each gets a DSL method taking a name and the options of #column,
537
- # e.g. +int64 :id, null: false+
572
+ # e.g. +s.int64 :id, null: false+
538
573
  PRIMITIVES = %i[
539
574
  boolean int8 int16 int32 int64 uint8 uint16 uint32 uint64 float double float16
540
575
  string binary json bson uuid date int96
@@ -595,17 +630,21 @@ module Herringbone
595
630
  # @param name [String, Symbol] field name
596
631
  # @param null [Boolean] whether the struct as a whole may be null
597
632
  # @param field_id [Integer, nil] field id to store in the schema
598
- # @yield block evaluated with +instance_eval+ on a new Builder, declaring the struct's fields
633
+ # @yield [struct] declares the struct's fields
634
+ # @yieldparam struct [Builder] a new builder for the struct's fields
635
+ # @yieldreturn [void]
599
636
  # @return [Node] the added group node
600
- # @raise [ArgumentError] when the block declares no fields (or is missing), or for a duplicate name
637
+ # @raise [ArgumentError] when the block is missing, takes no parameter or declares no fields,
638
+ # or for a duplicate name
601
639
  def struct(name, null: true, field_id: nil, &block)
602
- add Node.new(name: name, repetition: rep(null), children: struct_fields(name, &block), field_id: field_id)
640
+ children = struct_fields(name, usage(name, "struct :#{name}", "string :city"), &block)
641
+ add Node.new(name: name, repetition: rep(null), children: children, field_id: field_id)
603
642
  end
604
643
 
605
- # list :tags, :string
606
- # list :tags, :string, element_null: false
607
- # list :points, :struct do double :x; double :y; end
608
- # list :matrix do list :element, :double end (block declares the element)
644
+ # s.list :tags, :string
645
+ # s.list :tags, :string, element_null: false
646
+ # s.list :points, :struct do |points| points.double :x; points.double :y end
647
+ # s.list :matrix do |matrix| matrix.list :element, :double end # block declares the element
609
648
  #
610
649
  # Written as the standard 3-level LIST: an optional (or required) group holding a repeated
611
650
  # group "list" whose single child is "element".
@@ -623,21 +662,28 @@ module Herringbone
623
662
  # @option type_opts [Symbol] :unit time or timestamp unit
624
663
  # @option type_opts [Boolean] :utc time or timestamp UTC adjustment
625
664
  # @option type_opts [Integer] :length FIXED_LEN_BYTE_ARRAY width
626
- # @yield block evaluated with +instance_eval+ on a new Builder: the struct's fields for a
627
- # +:struct+ element, or exactly one field (renamed to "element") when +type+ is nil
665
+ # @yield [list] declares the struct's fields for a +:struct+ element, or exactly one field
666
+ # (renamed to "element") when +type+ is nil
667
+ # @yieldparam list [Builder] a new builder for the element
668
+ # @yieldreturn [void]
628
669
  # @return [Node] the added LIST group node
629
- # @raise [ArgumentError] when neither a type nor a block is given, the block declares the wrong
630
- # number of fields, or for a duplicate name
670
+ # @raise [ArgumentError] when neither a type nor a block is given, the block takes no parameter
671
+ # or declares the wrong number of fields, or for a duplicate name
631
672
  def list(name, type = nil, null: true, element_null: true, field_id: nil, **type_opts, &block)
632
- element = element_node("element", type, element_null, type_opts, &block)
673
+ example = if type
674
+ usage(name, "list :#{name}, :#{type}", "double :x")
675
+ else
676
+ usage(name, "list :#{name}", "list :element, :double")
677
+ end
678
+ element = element_node("element", type, element_null, type_opts, example, &block)
633
679
  repeated = Node.new(name: "list", repetition: :repeated, children: [element])
634
680
  add Node.new(name: name, repetition: rep(null), children: [repeated],
635
681
  logical_type: Format::LogicalType.new(list: Format::ListType.new),
636
682
  converted_type: Format::ConvertedType::LIST, field_id: field_id)
637
683
  end
638
684
 
639
- # map :scores, :string, :double
640
- # map :things, :string, :struct do int32 :a end
685
+ # s.map :scores, :string, :double
686
+ # s.map :things, :string, :struct do |things| things.int32 :a end
641
687
  #
642
688
  # Written as the standard MAP: a group holding a repeated group "key_value" with a required
643
689
  # "key" and a "value". Keys are never null.
@@ -655,20 +701,24 @@ module Herringbone
655
701
  # @option type_opts [Symbol] :unit time or timestamp unit
656
702
  # @option type_opts [Boolean] :utc time or timestamp UTC adjustment
657
703
  # @option type_opts [Integer] :length FIXED_LEN_BYTE_ARRAY width
658
- # @yield block evaluated with +instance_eval+ on a new Builder: the struct's fields for a
659
- # +:struct+ value, or exactly one field (renamed to "value") when +value_type+ is nil
704
+ # @yield [map] declares the struct's fields for a +:struct+ value, or exactly one field
705
+ # (renamed to "value") when +value_type+ is nil
706
+ # @yieldparam map [Builder] a new builder for the value
707
+ # @yieldreturn [void]
660
708
  # @return [Node] the added MAP group node
661
- # @raise [ArgumentError] for an invalid key or value declaration, or a duplicate name
709
+ # @raise [ArgumentError] for an invalid key or value declaration, a block that takes no
710
+ # parameter, or a duplicate name
662
711
  def map(name, key_type, value_type = nil, null: true, value_null: true, field_id: nil, **type_opts, &block)
663
- key = element_node("key", key_type, false, {})
664
- value = element_node("value", value_type, value_null, type_opts, &block)
712
+ head = ["map :#{name}", ":#{key_type}", (":#{value_type}" if value_type)].compact.join(", ")
713
+ key = element_node("key", key_type, false, {}, nil)
714
+ value = element_node("value", value_type, value_null, type_opts, usage(name, head, "int32 :a"), &block)
665
715
  kv = Node.new(name: "key_value", repetition: :repeated, children: [key, value])
666
716
  add Node.new(name: name, repetition: rep(null), children: [kv],
667
717
  logical_type: Format::LogicalType.new(map: Format::MapType.new),
668
718
  converted_type: Format::ConvertedType::MAP, field_id: field_id)
669
719
  end
670
720
 
671
- # Generic column declaration: column :name, :int32, null: false
721
+ # Generic column declaration: s.column :name, :int32, null: false
672
722
  #
673
723
  # @param name [String, Symbol] field name
674
724
  # @param type [Symbol, String] DSL type: one of PRIMITIVES, or +:time+, +:timestamp+,
@@ -729,40 +779,54 @@ module Herringbone
729
779
  # @param type [Symbol, String, nil] DSL type, +:struct+, or nil to take the field the block declares
730
780
  # @param nullable [Boolean] whether the node may be null (not applied to a block-declared field)
731
781
  # @param type_opts [Hash{Symbol => Object}] type options passed to Types.physical_attributes
732
- # @yield block evaluated with +instance_eval+ on a new Builder
782
+ # @param example [String, nil] the declaration called with a block, for the error message
783
+ # @yield [inner] declares the element, or the fields of a +:struct+
784
+ # @yieldparam inner [Builder] a new builder
785
+ # @yieldreturn [void]
733
786
  # @return [Node] the element node, not added to #nodes
734
- # @raise [ArgumentError] when the block is missing, declares the wrong number of fields, or
735
- # declares no fields for a +:struct+
736
- def element_node(name, type, nullable, type_opts, &block)
787
+ # @raise [ArgumentError] when the block is missing or takes no parameter, declares the wrong
788
+ # number of fields, or declares no fields for a +:struct+
789
+ def element_node(name, type, nullable, type_opts, example, &block)
737
790
  if type.nil?
738
791
  raise ArgumentError, "Give an element type or a block declaring the element" unless block
739
- inner = Builder.new
740
- inner.instance_eval(&block)
792
+ inner = Builder.build(example, &block)
741
793
  raise ArgumentError, "The element block must declare exactly one field" unless inner.nodes.size == 1
742
794
  node = inner.nodes.first
743
795
  node.name = name
744
796
  node
745
797
  elsif type.to_sym == :struct
746
- Node.new(name: name, repetition: rep(nullable), children: struct_fields(name, &block))
798
+ Node.new(name: name, repetition: rep(nullable), children: struct_fields(name, example, &block))
747
799
  else
748
800
  leaf_node(name, type, rep(nullable), type_opts)
749
801
  end
750
802
  end
751
803
 
752
- # Evaluates a struct's block on a new Builder; Parquet groups need at least one child.
804
+ # Yields a new Builder for a struct's fields; Parquet groups need at least one child.
753
805
  #
754
806
  # @param name [String, Symbol] struct name, for the error message
755
- # @yield block evaluated with +instance_eval+ on a new Builder, declaring the struct's fields
807
+ # @param example [String] the declaration called with a block, for the error message
808
+ # @yield [inner] declares the struct's fields
809
+ # @yieldparam inner [Builder] a new builder
810
+ # @yieldreturn [void]
756
811
  # @return [Array<Node>] the declared fields
757
- # @raise [ArgumentError] when the block is missing or declares no fields
758
- def struct_fields(name, &block)
812
+ # @raise [ArgumentError] when the block is missing, takes no parameter or declares no fields
813
+ def struct_fields(name, example, &block)
759
814
  raise ArgumentError, "struct #{name} needs a block declaring its fields" unless block
760
- inner = Builder.new
761
- inner.instance_eval(&block)
815
+ inner = Builder.build(example, &block)
762
816
  raise ArgumentError, "struct #{name} has no fields" if inner.nodes.empty?
763
817
  inner.nodes
764
818
  end
765
819
 
820
+ # @param name [String, Symbol] field name, which names the block parameter when it can
821
+ # @param head [String] the declaration without its block, e.g. "struct :address"
822
+ # @param declaration [String] a declaration for the block's body, e.g. "string :city"
823
+ # @return [String] the declaration with a block taking a parameter, e.g.
824
+ # "s.struct :address do |address| address.string :city end"
825
+ def usage(name, head, declaration)
826
+ var = name.to_s.match?(/\A[a-z_][a-z0-9_]*\z/) ? name : "inner"
827
+ "s.#{head} do |#{var}| #{var}.#{declaration} end"
828
+ end
829
+
766
830
  # @param name [String, Symbol] field name
767
831
  # @param type [Symbol, String] DSL type
768
832
  # @param repetition [Symbol] +:optional+ or +:required+
@@ -23,7 +23,7 @@ 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
27
  class SimpleWriter
28
28
  # Opens a writer, yields it and closes it, finishing the file. If the block raises, the file is
29
29
  # left unfinished (no footer), as with Writer.open. To declare columns, use #initialize and #close.
@@ -60,13 +60,16 @@ module Herringbone
60
60
  # @option options [Integer] :row_group_bytes (16MB) approximate buffered size that triggers a row group
61
61
  # @option options [Integer, nil] :row_group_rows (nil) also flush a row group after this many rows
62
62
  # (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
63
+ # @yield [s] optional, declares columns (named in #headers!) that replace inferred ones
64
+ # @yieldparam s [Schema::Builder] the builder to declare columns on
65
+ # @yieldreturn [void]
65
66
  # @raise [MissingCodecError] when the codec's optional gem is not loaded
67
+ # @raise [ArgumentError] when the block takes no parameter
66
68
  def initialize(io, **options, &overrides)
69
+ Schema::Builder.check_block!(overrides, "Herringbone::SimpleWriter.new(io) { |s| s.string :code }")
67
70
  @headers = nil
68
71
  @overrides = overrides
69
- fix = "Herringbone::SimpleWriter.new(io) { %s }"
72
+ fix = "Herringbone::SimpleWriter.new(io) { |s| s.%s }"
70
73
  @writer = InferringWriter.new(io, fix: fix, **options) { |sample| schema_for(sample) }
71
74
  end
72
75
 
@@ -117,6 +120,13 @@ module Herringbone
117
120
  # @return [void]
118
121
  def abort = @writer.abort
119
122
 
123
+ # Short summary for the console, without the rows
124
+ #
125
+ # @return [String] the number of columns and the underlying writer's summary
126
+ def inspect
127
+ "#<#{self.class.name} columns=#{@headers&.size.inspect} writer=#{@writer.inspect}>"
128
+ end
129
+
120
130
  private
121
131
 
122
132
  # @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.5.0"
6
6
  end