herringbone 0.3.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 +66 -0
- data/README.md +236 -38
- data/bin/herringbone +40 -10
- data/lib/herringbone/active_record.rb +1 -1
- data/lib/herringbone/byte_values.rb +9 -0
- data/lib/herringbone/codecs/lzo.rb +224 -0
- data/lib/herringbone/codecs/snappy.rb +13 -20
- data/lib/herringbone/compression.rb +26 -38
- data/lib/herringbone/encodings/plain.rb +2 -2
- data/lib/herringbone/inferring_writer.rb +168 -0
- data/lib/herringbone/reader/column_chunk_reader.rb +5 -8
- data/lib/herringbone/reader/numo.rb +18 -26
- data/lib/herringbone/reader.rb +3 -3
- data/lib/herringbone/redaction/rewriter.rb +533 -0
- data/lib/herringbone/redaction.rb +287 -0
- data/lib/herringbone/schema.rb +128 -69
- data/lib/herringbone/simple_writer.rb +162 -0
- data/lib/herringbone/thrift.rb +5 -5
- data/lib/herringbone/version.rb +1 -1
- data/lib/herringbone/writer.rb +156 -38
- data/lib/herringbone/xxhash.rb +21 -36
- data/lib/herringbone.rb +95 -9
- metadata +8 -2
|
@@ -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
|
@@ -147,21 +147,18 @@ module Herringbone
|
|
|
147
147
|
# @return [String] path joined with dots, as used for column names in options
|
|
148
148
|
def dotted_path = @path.join(".")
|
|
149
149
|
|
|
150
|
-
#
|
|
150
|
+
# Types.reader_for of the node. Not memoized: a Schema holding Procs could not be made
|
|
151
|
+
# Ractor-shareable.
|
|
151
152
|
#
|
|
152
153
|
# @return [Proc, Method, nil] converts a physical value into its Ruby value, or nil when the physical
|
|
153
154
|
# value is used as-is
|
|
154
|
-
def converter
|
|
155
|
-
@converter ||= Types.reader_for(@node)
|
|
156
|
-
end
|
|
155
|
+
def converter = Types.reader_for(@node)
|
|
157
156
|
|
|
158
|
-
#
|
|
157
|
+
# Types.writer_for of the node. Not memoized, like #converter.
|
|
159
158
|
#
|
|
160
159
|
# @return [Proc, Method] converts a Ruby value into the physical value to store, raising
|
|
161
160
|
# ArgumentError / TypeError / RangeError for values that do not fit
|
|
162
|
-
def encoder
|
|
163
|
-
@encoder ||= Types.writer_for(@node)
|
|
164
|
-
end
|
|
161
|
+
def encoder = Types.writer_for(@node)
|
|
165
162
|
end
|
|
166
163
|
|
|
167
164
|
# A node of the logical tree.
|
|
@@ -217,12 +214,10 @@ module Herringbone
|
|
|
217
214
|
# @return [Boolean] true for a primitive (+:leaf+) field
|
|
218
215
|
def leaf? = @kind == :leaf
|
|
219
216
|
|
|
220
|
-
#
|
|
217
|
+
# Lookup of a struct's children; only valid for +:struct+ fields.
|
|
221
218
|
#
|
|
222
219
|
# @return [Hash{String => Field}] child fields by name
|
|
223
|
-
def children_by_name
|
|
224
|
-
@children_by_name ||= @children.to_h { |c| [c.name, c] }
|
|
225
|
-
end
|
|
220
|
+
def children_by_name = @children.to_h { |c| [c.name, c] }
|
|
226
221
|
end
|
|
227
222
|
|
|
228
223
|
# The root Node, the leaf Columns in file order, and the top-level Fields of the logical tree
|
|
@@ -253,12 +248,19 @@ module Herringbone
|
|
|
253
248
|
|
|
254
249
|
# Builds a schema with the DSL, see Schema::Builder
|
|
255
250
|
#
|
|
256
|
-
#
|
|
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]
|
|
257
259
|
# @return [Schema]
|
|
258
|
-
# @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
|
|
259
262
|
def self.define(&block)
|
|
260
|
-
builder = Builder.
|
|
261
|
-
builder.instance_eval(&block) if block
|
|
263
|
+
builder = Builder.build("Herringbone::Schema.define { |s| s.int64 :id }", &block)
|
|
262
264
|
raise ArgumentError, "A schema needs at least one field" if builder.nodes.empty?
|
|
263
265
|
new(Node.new(name: "schema", repetition: :required, children: builder.nodes))
|
|
264
266
|
end
|
|
@@ -272,19 +274,19 @@ module Herringbone
|
|
|
272
274
|
# Time/DateTime -> timestamp(micros), Date -> date, BigDecimal -> decimal(38, max scale seen),
|
|
273
275
|
# Hash -> struct, Array -> list. Columns that are nil in every sampled row become strings.
|
|
274
276
|
# Fields declared in the block (Builder DSL) replace the inferred ones of the same name:
|
|
275
|
-
# Schema.infer(rows) { json :payload }
|
|
277
|
+
# Schema.infer(rows) { |s| s.json :payload }
|
|
276
278
|
#
|
|
277
279
|
# @param rows [Enumerable<Hash, Object>] rows to sample; only the first INFER_SAMPLE are read
|
|
278
|
-
# @yield optional
|
|
279
|
-
#
|
|
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]
|
|
280
283
|
# @return [Schema]
|
|
281
|
-
# @raise [ArgumentError] when there are no rows, a row is not Hash-like,
|
|
282
|
-
#
|
|
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
|
|
283
286
|
def self.infer(rows, &block)
|
|
287
|
+
overrides = Builder.build("Herringbone::Schema.infer(rows) { |s| s.json :payload }", &block)
|
|
284
288
|
sample_rows = rows.first(INFER_SAMPLE).map { |r| Inference.row_hash(r) }
|
|
285
289
|
raise ArgumentError, "Cannot infer a schema from zero rows" if sample_rows.empty?
|
|
286
|
-
overrides = Builder.new
|
|
287
|
-
overrides.instance_eval(&block) if block
|
|
288
290
|
declared = overrides.nodes.to_h { |n| [n.name, n] }
|
|
289
291
|
names = sample_rows.flat_map { |r| r.keys.map(&:to_s) }.uniq
|
|
290
292
|
nodes = names.map do |name|
|
|
@@ -360,7 +362,7 @@ module Herringbone
|
|
|
360
362
|
else
|
|
361
363
|
classes = values.map(&:class).uniq
|
|
362
364
|
raise ArgumentError, "Cannot infer a Parquet type for #{name} from #{classes.map(&:name).join(", ")}; " \
|
|
363
|
-
"declare it in a block: Schema.infer(rows) { string :#{name} }"
|
|
365
|
+
"declare it in a block: Schema.infer(rows) { |s| s.string :#{name} }"
|
|
364
366
|
end
|
|
365
367
|
end
|
|
366
368
|
end
|
|
@@ -516,30 +518,58 @@ module Herringbone
|
|
|
516
518
|
|
|
517
519
|
# DSL for defining schemas:
|
|
518
520
|
#
|
|
519
|
-
# Herringbone::Schema.define do
|
|
520
|
-
# int64 :id, null: false
|
|
521
|
-
# string :name
|
|
522
|
-
# list :tags, :string
|
|
523
|
-
# map :scores, :string, :double
|
|
524
|
-
# struct :address do
|
|
525
|
-
# 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
|
|
526
528
|
# end
|
|
527
|
-
# decimal :price, precision: 12, scale: 2
|
|
528
|
-
# timestamp :created_at, unit: :micros
|
|
529
|
+
# s.decimal :price, precision: 12, scale: 2
|
|
530
|
+
# s.timestamp :created_at, unit: :micros
|
|
529
531
|
# end
|
|
530
532
|
#
|
|
531
|
-
# 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.
|
|
532
535
|
class Builder
|
|
533
536
|
# @return [Array<Node>] fields declared so far, in declaration order
|
|
534
537
|
attr_reader :nodes
|
|
535
538
|
|
|
536
|
-
#
|
|
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
|
|
537
567
|
def initialize
|
|
538
568
|
@nodes = []
|
|
539
569
|
end
|
|
540
570
|
|
|
541
571
|
# Types that need no options; each gets a DSL method taking a name and the options of #column,
|
|
542
|
-
# e.g. +int64 :id, null: false+
|
|
572
|
+
# e.g. +s.int64 :id, null: false+
|
|
543
573
|
PRIMITIVES = %i[
|
|
544
574
|
boolean int8 int16 int32 int64 uint8 uint16 uint32 uint64 float double float16
|
|
545
575
|
string binary json bson uuid date int96
|
|
@@ -600,17 +630,21 @@ module Herringbone
|
|
|
600
630
|
# @param name [String, Symbol] field name
|
|
601
631
|
# @param null [Boolean] whether the struct as a whole may be null
|
|
602
632
|
# @param field_id [Integer, nil] field id to store in the schema
|
|
603
|
-
# @yield
|
|
633
|
+
# @yield [struct] declares the struct's fields
|
|
634
|
+
# @yieldparam struct [Builder] a new builder for the struct's fields
|
|
635
|
+
# @yieldreturn [void]
|
|
604
636
|
# @return [Node] the added group node
|
|
605
|
-
# @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
|
|
606
639
|
def struct(name, null: true, field_id: nil, &block)
|
|
607
|
-
|
|
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)
|
|
608
642
|
end
|
|
609
643
|
|
|
610
|
-
#
|
|
611
|
-
#
|
|
612
|
-
#
|
|
613
|
-
#
|
|
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
|
|
614
648
|
#
|
|
615
649
|
# Written as the standard 3-level LIST: an optional (or required) group holding a repeated
|
|
616
650
|
# group "list" whose single child is "element".
|
|
@@ -628,21 +662,28 @@ module Herringbone
|
|
|
628
662
|
# @option type_opts [Symbol] :unit time or timestamp unit
|
|
629
663
|
# @option type_opts [Boolean] :utc time or timestamp UTC adjustment
|
|
630
664
|
# @option type_opts [Integer] :length FIXED_LEN_BYTE_ARRAY width
|
|
631
|
-
# @yield
|
|
632
|
-
#
|
|
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]
|
|
633
669
|
# @return [Node] the added LIST group node
|
|
634
|
-
# @raise [ArgumentError] when neither a type nor a block is given, the block
|
|
635
|
-
# 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
|
|
636
672
|
def list(name, type = nil, null: true, element_null: true, field_id: nil, **type_opts, &block)
|
|
637
|
-
|
|
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)
|
|
638
679
|
repeated = Node.new(name: "list", repetition: :repeated, children: [element])
|
|
639
680
|
add Node.new(name: name, repetition: rep(null), children: [repeated],
|
|
640
681
|
logical_type: Format::LogicalType.new(list: Format::ListType.new),
|
|
641
682
|
converted_type: Format::ConvertedType::LIST, field_id: field_id)
|
|
642
683
|
end
|
|
643
684
|
|
|
644
|
-
#
|
|
645
|
-
#
|
|
685
|
+
# s.map :scores, :string, :double
|
|
686
|
+
# s.map :things, :string, :struct do |things| things.int32 :a end
|
|
646
687
|
#
|
|
647
688
|
# Written as the standard MAP: a group holding a repeated group "key_value" with a required
|
|
648
689
|
# "key" and a "value". Keys are never null.
|
|
@@ -660,20 +701,24 @@ module Herringbone
|
|
|
660
701
|
# @option type_opts [Symbol] :unit time or timestamp unit
|
|
661
702
|
# @option type_opts [Boolean] :utc time or timestamp UTC adjustment
|
|
662
703
|
# @option type_opts [Integer] :length FIXED_LEN_BYTE_ARRAY width
|
|
663
|
-
# @yield
|
|
664
|
-
#
|
|
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]
|
|
665
708
|
# @return [Node] the added MAP group node
|
|
666
|
-
# @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
|
|
667
711
|
def map(name, key_type, value_type = nil, null: true, value_null: true, field_id: nil, **type_opts, &block)
|
|
668
|
-
|
|
669
|
-
|
|
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)
|
|
670
715
|
kv = Node.new(name: "key_value", repetition: :repeated, children: [key, value])
|
|
671
716
|
add Node.new(name: name, repetition: rep(null), children: [kv],
|
|
672
717
|
logical_type: Format::LogicalType.new(map: Format::MapType.new),
|
|
673
718
|
converted_type: Format::ConvertedType::MAP, field_id: field_id)
|
|
674
719
|
end
|
|
675
720
|
|
|
676
|
-
# Generic column declaration: column :name, :int32, null: false
|
|
721
|
+
# Generic column declaration: s.column :name, :int32, null: false
|
|
677
722
|
#
|
|
678
723
|
# @param name [String, Symbol] field name
|
|
679
724
|
# @param type [Symbol, String] DSL type: one of PRIMITIVES, or +:time+, +:timestamp+,
|
|
@@ -734,40 +779,54 @@ module Herringbone
|
|
|
734
779
|
# @param type [Symbol, String, nil] DSL type, +:struct+, or nil to take the field the block declares
|
|
735
780
|
# @param nullable [Boolean] whether the node may be null (not applied to a block-declared field)
|
|
736
781
|
# @param type_opts [Hash{Symbol => Object}] type options passed to Types.physical_attributes
|
|
737
|
-
# @
|
|
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]
|
|
738
786
|
# @return [Node] the element node, not added to #nodes
|
|
739
|
-
# @raise [ArgumentError] when the block is missing, declares the wrong
|
|
740
|
-
# declares no fields for a +:struct+
|
|
741
|
-
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)
|
|
742
790
|
if type.nil?
|
|
743
791
|
raise ArgumentError, "Give an element type or a block declaring the element" unless block
|
|
744
|
-
inner = Builder.
|
|
745
|
-
inner.instance_eval(&block)
|
|
792
|
+
inner = Builder.build(example, &block)
|
|
746
793
|
raise ArgumentError, "The element block must declare exactly one field" unless inner.nodes.size == 1
|
|
747
794
|
node = inner.nodes.first
|
|
748
795
|
node.name = name
|
|
749
796
|
node
|
|
750
797
|
elsif type.to_sym == :struct
|
|
751
|
-
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))
|
|
752
799
|
else
|
|
753
800
|
leaf_node(name, type, rep(nullable), type_opts)
|
|
754
801
|
end
|
|
755
802
|
end
|
|
756
803
|
|
|
757
|
-
#
|
|
804
|
+
# Yields a new Builder for a struct's fields; Parquet groups need at least one child.
|
|
758
805
|
#
|
|
759
806
|
# @param name [String, Symbol] struct name, for the error message
|
|
760
|
-
# @
|
|
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]
|
|
761
811
|
# @return [Array<Node>] the declared fields
|
|
762
|
-
# @raise [ArgumentError] when the block is missing or declares no fields
|
|
763
|
-
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)
|
|
764
814
|
raise ArgumentError, "struct #{name} needs a block declaring its fields" unless block
|
|
765
|
-
inner = Builder.
|
|
766
|
-
inner.instance_eval(&block)
|
|
815
|
+
inner = Builder.build(example, &block)
|
|
767
816
|
raise ArgumentError, "struct #{name} has no fields" if inner.nodes.empty?
|
|
768
817
|
inner.nodes
|
|
769
818
|
end
|
|
770
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
|
+
|
|
771
830
|
# @param name [String, Symbol] field name
|
|
772
831
|
# @param type [Symbol, String] DSL type
|
|
773
832
|
# @param repetition [Symbol] +:optional+ or +:required+
|