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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +14 -0
- data/README.md +150 -16
- data/lib/herringbone/active_record.rb +1 -1
- data/lib/herringbone/byte_values.rb +9 -0
- data/lib/herringbone/inferring_writer.rb +7 -0
- data/lib/herringbone/redaction/rewriter.rb +533 -0
- data/lib/herringbone/redaction.rb +287 -0
- data/lib/herringbone/schema.rb +121 -57
- data/lib/herringbone/simple_writer.rb +14 -4
- data/lib/herringbone/version.rb +1 -1
- data/lib/herringbone/writer.rb +141 -25
- data/lib/herringbone.rb +47 -5
- metadata +3 -1
|
@@ -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"
|
data/lib/herringbone/schema.rb
CHANGED
|
@@ -248,12 +248,19 @@ module Herringbone
|
|
|
248
248
|
|
|
249
249
|
# Builds a schema with the DSL, see Schema::Builder
|
|
250
250
|
#
|
|
251
|
-
#
|
|
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,
|
|
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.
|
|
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
|
|
274
|
-
#
|
|
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,
|
|
277
|
-
#
|
|
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
|
-
#
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
#
|
|
606
|
-
#
|
|
607
|
-
#
|
|
608
|
-
#
|
|
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
|
|
627
|
-
#
|
|
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
|
|
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
|
-
|
|
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
|
-
#
|
|
640
|
-
#
|
|
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
|
|
659
|
-
#
|
|
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,
|
|
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
|
-
|
|
664
|
-
|
|
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
|
-
# @
|
|
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
|
|
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.
|
|
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
|
-
#
|
|
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
|
-
# @
|
|
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.
|
|
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
|
|
64
|
-
#
|
|
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) {
|
|
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 #<<
|
data/lib/herringbone/version.rb
CHANGED