iostreams 2.1.0 → 2.2.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/docs/config.md +13 -9
- data/docs/copy_files.md +20 -2
- data/docs/extensions.md +18 -4
- data/docs/formats.md +644 -16
- data/docs/path.md +18 -2
- data/docs/streams.md +54 -9
- data/docs/tutorial.md +14 -2
- data/docs/upgrading.md +470 -23
- data/lib/io_streams/builder/deprecations.rb +250 -0
- data/lib/io_streams/builder.rb +256 -39
- data/lib/io_streams/bzip2/reader.rb +18 -2
- data/lib/io_streams/bzip2/writer.rb +11 -2
- data/lib/io_streams/deprecation/methods.rb +51 -0
- data/lib/io_streams/deprecation/once.rb +27 -0
- data/lib/io_streams/deprecation/option_names.rb +26 -0
- data/lib/io_streams/deprecation/words.rb +42 -0
- data/lib/io_streams/deprecation.rb +259 -0
- data/lib/io_streams/encode/reader.rb +54 -3
- data/lib/io_streams/encode/writer.rb +19 -2
- data/lib/io_streams/encode.rb +99 -0
- data/lib/io_streams/errors.rb +51 -0
- data/lib/io_streams/gzip/reader.rb +11 -0
- data/lib/io_streams/io_streams.rb +365 -35
- data/lib/io_streams/line/reader.rb +2 -1
- data/lib/io_streams/line/utf8_reader.rb +23 -0
- data/lib/io_streams/path.rb +125 -35
- data/lib/io_streams/paths/file.rb +72 -9
- data/lib/io_streams/paths/http.rb +48 -2
- data/lib/io_streams/paths/matcher.rb +6 -3
- data/lib/io_streams/paths/s3.rb +180 -9
- data/lib/io_streams/paths/sftp.rb +47 -3
- data/lib/io_streams/paths.rb +23 -0
- data/lib/io_streams/pgp/reader.rb +18 -0
- data/lib/io_streams/pgp/writer.rb +16 -0
- data/lib/io_streams/pgp.rb +16 -2
- data/lib/io_streams/reader.rb +44 -0
- data/lib/io_streams/record/reader.rb +59 -68
- data/lib/io_streams/record/writer.rb +17 -10
- data/lib/io_streams/row/header_row.rb +21 -0
- data/lib/io_streams/row/reader.rb +52 -15
- data/lib/io_streams/row/writer.rb +64 -13
- data/lib/io_streams/schema/columns.rb +456 -0
- data/lib/io_streams/schema/constraints.rb +99 -0
- data/lib/io_streams/schema/field.rb +967 -0
- data/lib/io_streams/schema/json_number.rb +56 -0
- data/lib/io_streams/schema/keys.rb +78 -0
- data/lib/io_streams/schema/pattern.rb +257 -0
- data/lib/io_streams/schema/source.rb +31 -0
- data/lib/io_streams/schema.rb +616 -0
- data/lib/io_streams/stream.rb +836 -67
- data/lib/io_streams/symmetric_encryption/reader.rb +8 -0
- data/lib/io_streams/tabular/header.rb +29 -0
- data/lib/io_streams/tabular/header_mapping.rb +332 -0
- data/lib/io_streams/tabular/mapping.rb +165 -0
- data/lib/io_streams/tabular/parser/array.rb +5 -0
- data/lib/io_streams/tabular/parser/base.rb +24 -0
- data/lib/io_streams/tabular/parser/fixed.rb +197 -0
- data/lib/io_streams/tabular/parser/fixed_fields.rb +160 -0
- data/lib/io_streams/tabular/parser/hash.rb +10 -0
- data/lib/io_streams/tabular/parser/json.rb +17 -0
- data/lib/io_streams/tabular/parser/json_fields.rb +132 -0
- data/lib/io_streams/tabular/schema_mapping.rb +361 -0
- data/lib/io_streams/tabular.rb +344 -30
- data/lib/io_streams/utils.rb +441 -6
- data/lib/io_streams/version.rb +1 -1
- data/lib/io_streams/writer.rb +27 -0
- data/lib/io_streams/xlsx/cells.rb +127 -0
- data/lib/io_streams/xlsx/reader.rb +56 -0
- data/lib/io_streams/xlsx/schema_reader.rb +90 -0
- data/lib/io_streams/xlsx/v3_writer.rb +16 -0
- data/lib/io_streams/zip/reader.rb +7 -0
- data/lib/io_streams/zip/writer.rb +24 -2
- data/lib/iostreams.rb +21 -17
- metadata +28 -2
|
@@ -5,6 +5,47 @@ module IOStreams
|
|
|
5
5
|
class Fixed < Base
|
|
6
6
|
attr_reader :layout, :truncate
|
|
7
7
|
|
|
8
|
+
# Returns [IOStreams::Tabular::Parser::FixedFields] the parser of the lines of a fixed width file whose schema
|
|
9
|
+
# gives the position of each field, in place of the layout of this parser, see
|
|
10
|
+
# `IOStreams::Tabular::Parser::Base.schema_parser`.
|
|
11
|
+
def self.schema_parser(schema)
|
|
12
|
+
FixedFields.new(schema)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Returns [Hash] the schema of the layout of a fixed width file, supplied with `IOStreams::Stream#format_options`,
|
|
16
|
+
# so that an application that holds a layout can replace it with its schema, see `IOStreams::Schema.from_layout`.
|
|
17
|
+
# The layout itself is still read and written by this parser, as before.
|
|
18
|
+
#
|
|
19
|
+
# Its `fieldsMatch` is "subset", so that writing skips the keys of a record that the layout does not have, as a
|
|
20
|
+
# layout always did. A line that is read always has every field. When every key of the layout is a Symbol, its
|
|
21
|
+
# `keyType` is "symbol", so that the records read have the same keys once the schema is held as JSON, or in a
|
|
22
|
+
# database such as MongoDB, whose names are Strings, see `IOStreams::Schema`.
|
|
23
|
+
#
|
|
24
|
+
# Parameters:
|
|
25
|
+
# layout: [Array<Hash>]
|
|
26
|
+
# Each column of the file, in order, a Hash whose keys are Symbols or Strings, such as those of a layout read
|
|
27
|
+
# from JSON or YAML:
|
|
28
|
+
# size: [Integer|:remainder] The number of characters of the column, or `:remainder` to take the
|
|
29
|
+
# rest of the line. The layout reads nothing after a column of the size `:remainder`, so the
|
|
30
|
+
# columns after it are not in the schema. Required.
|
|
31
|
+
# key: [String|Symbol] The key of the column in each record, which must be its own. A column without
|
|
32
|
+
# a key is a filler, which is skipped when reading and is spaces when writing.
|
|
33
|
+
# type: [:string|:integer|:float] Default: :string
|
|
34
|
+
# decimals: [Integer] The number of decimals of a :float column, 0 or more. A float that takes the
|
|
35
|
+
# remainder of the line is written with the digits that it has, as the layout writes it, so
|
|
36
|
+
# it ignores them. Default: 2
|
|
37
|
+
#
|
|
38
|
+
# truncate: [true|false]
|
|
39
|
+
# Whether to truncate a string that is longer than its column, rather than raise
|
|
40
|
+
# `IOStreams::Errors::ValueTooLong`.
|
|
41
|
+
# Default: true
|
|
42
|
+
#
|
|
43
|
+
# Raises `IOStreams::Errors::InvalidLayout` when the layout is not valid, including when its schema could not
|
|
44
|
+
# hold it, such as for two columns with the same key, which the records read could not hold either.
|
|
45
|
+
def self.schema(layout:, **)
|
|
46
|
+
new(layout: Layout.properties(layout), **).schema
|
|
47
|
+
end
|
|
48
|
+
|
|
8
49
|
# Returns [IOStreams::Tabular::Parser]
|
|
9
50
|
#
|
|
10
51
|
# Parameters:
|
|
@@ -58,6 +99,11 @@ module IOStreams
|
|
|
58
99
|
@truncate = truncate
|
|
59
100
|
end
|
|
60
101
|
|
|
102
|
+
# Returns [Hash] the schema of this layout, see .schema.
|
|
103
|
+
def schema
|
|
104
|
+
layout.schema(truncate)
|
|
105
|
+
end
|
|
106
|
+
|
|
61
107
|
# The required line length for every fixed length line
|
|
62
108
|
def line_length
|
|
63
109
|
layout.length
|
|
@@ -112,6 +158,18 @@ module IOStreams
|
|
|
112
158
|
class Layout
|
|
113
159
|
attr_reader :columns, :length
|
|
114
160
|
|
|
161
|
+
# Returns [Array<Hash>] the columns of a layout held as data, such as a layout read from JSON or YAML, with
|
|
162
|
+
# the properties of each one as .new takes them, see `Column.properties`.
|
|
163
|
+
#
|
|
164
|
+
# Raises `IOStreams::Errors::InvalidLayout` when the layout is not an Array.
|
|
165
|
+
def self.properties(layout)
|
|
166
|
+
unless layout.is_a?(::Array)
|
|
167
|
+
raise(Errors::InvalidLayout, "The layout must be an Array of columns: #{layout.inspect}")
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
layout.map { |column| Column.properties(column) }
|
|
171
|
+
end
|
|
172
|
+
|
|
115
173
|
# Returns [Array<FixedLayout>] the layout for this fixed width file.
|
|
116
174
|
# Also validates values
|
|
117
175
|
def initialize(layout)
|
|
@@ -119,8 +177,63 @@ module IOStreams
|
|
|
119
177
|
@columns = parse_layout(layout)
|
|
120
178
|
end
|
|
121
179
|
|
|
180
|
+
# Returns [Hash] the descriptor of the schema of this layout, whose fields are at the positions of its
|
|
181
|
+
# columns, see `IOStreams::Tabular::Parser::Fixed.schema`, with its strings truncated when `truncate` is true.
|
|
182
|
+
#
|
|
183
|
+
# A column after a filler starts after the filler, and a filler at the end of the line gives the width of the
|
|
184
|
+
# line. Since nothing is read after a column of the size `:remainder`, which takes the rest of the line, the
|
|
185
|
+
# columns after it are left out.
|
|
186
|
+
#
|
|
187
|
+
# Raises `IOStreams::Errors::InvalidLayout` when a field could not hold a column, see `Column#field`, or two
|
|
188
|
+
# columns have the same key.
|
|
189
|
+
def schema(truncate)
|
|
190
|
+
fields = []
|
|
191
|
+
keys = {}
|
|
192
|
+
position = 0
|
|
193
|
+
filler = false
|
|
194
|
+
remainder = false
|
|
195
|
+
columns.each do |column|
|
|
196
|
+
field = column.field(filler ? position + 1 : nil, truncate)
|
|
197
|
+
if field
|
|
198
|
+
check_key(field[:name], keys)
|
|
199
|
+
fields << field
|
|
200
|
+
end
|
|
201
|
+
filler = field.nil?
|
|
202
|
+
remainder = column.size == -1
|
|
203
|
+
break if remainder
|
|
204
|
+
|
|
205
|
+
position += column.size
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
schema = {fields: fields, fieldsMatch: "subset"}
|
|
209
|
+
# A filler at the end of the line gives the width of the line.
|
|
210
|
+
schema[:width] = remainder ? "remainder" : position if filler
|
|
211
|
+
# Symbol keys stay Symbols once the schema is held as JSON, whose names are Strings.
|
|
212
|
+
schema[:keyType] = "symbol" if !fields.empty? && fields.all? { |field| field[:name].is_a?(Symbol) }
|
|
213
|
+
schema
|
|
214
|
+
end
|
|
215
|
+
|
|
122
216
|
private
|
|
123
217
|
|
|
218
|
+
# Raises `IOStreams::Errors::InvalidLayout` when another column of the layout has the same key as text, see
|
|
219
|
+
# #schema, since the key of each column is its key in each record, and the name of its field. Adds the key to
|
|
220
|
+
# the keys of the columns before it, by their text.
|
|
221
|
+
def check_key(key, keys)
|
|
222
|
+
other = keys[key.to_s]
|
|
223
|
+
keys[key.to_s] = key
|
|
224
|
+
return if other.nil?
|
|
225
|
+
|
|
226
|
+
subject =
|
|
227
|
+
if other.eql?(key)
|
|
228
|
+
"More than one column of the layout has the key #{key.inspect}"
|
|
229
|
+
else
|
|
230
|
+
"The columns of the layout with the keys #{other.inspect} and #{key.inspect} have the same key"
|
|
231
|
+
end
|
|
232
|
+
raise(Errors::InvalidLayout, "#{subject}, but each column needs a key of its own, its key in each record. A " \
|
|
233
|
+
"column without a key is a filler, which is skipped when reading and written as " \
|
|
234
|
+
"spaces, so remove the key of a filler.")
|
|
235
|
+
end
|
|
236
|
+
|
|
124
237
|
def parse_layout(layout)
|
|
125
238
|
@length = 0
|
|
126
239
|
layout.collect do |hash|
|
|
@@ -144,9 +257,46 @@ module IOStreams
|
|
|
144
257
|
class Column
|
|
145
258
|
TYPES = %i[string integer float].freeze
|
|
146
259
|
LINE_BREAK = /\r\n|\r|\n/
|
|
260
|
+
# The type of the field of a schema for each type of column, see #field.
|
|
261
|
+
FIELD_TYPES = {string: "string", integer: "integer", float: "number"}.freeze
|
|
262
|
+
# The decimals of a :float column as text that #render writes: digits, or none for no decimals.
|
|
263
|
+
DECIMALS = /\A\d*\z/
|
|
147
264
|
|
|
148
265
|
attr_reader :key, :size, :type, :decimals
|
|
149
266
|
|
|
267
|
+
# Returns [Hash] the properties of a column of a layout held as data, such as one read from JSON or YAML, or
|
|
268
|
+
# taken from the params of a Rails request, with Symbol keys, as .new takes them.
|
|
269
|
+
#
|
|
270
|
+
# Raises `IOStreams::Errors::InvalidLayout` when the column is not a Hash, has a property both as a String and
|
|
271
|
+
# as a Symbol, since either could be meant, or has a size or a type that .new cannot convert, such as true or
|
|
272
|
+
# nil, for which .new raises NoMethodError, as it did before.
|
|
273
|
+
def self.properties(column)
|
|
274
|
+
unless column.is_a?(::Hash)
|
|
275
|
+
raise(Errors::InvalidLayout, "Each column of the layout must be a Hash, such as {size: 10, key: :name}: " \
|
|
276
|
+
"#{column.inspect}")
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# A plain Hash, since a Hash of another class, such as ActiveSupport::HashWithIndifferentAccess, can keep its
|
|
280
|
+
# keys as Strings.
|
|
281
|
+
properties = column.to_h.transform_keys { |name| name.is_a?(String) ? name.to_sym : name }
|
|
282
|
+
unless properties.size == column.size
|
|
283
|
+
raise(Errors::InvalidLayout, "A column of the layout has a property both as a String and as a Symbol: " \
|
|
284
|
+
"#{column.inspect}")
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
size = properties[:size]
|
|
288
|
+
unless size == :remainder || size.respond_to?(:to_i)
|
|
289
|
+
raise(Errors::InvalidLayout, "The size of a column of the layout must be a number or :remainder: " \
|
|
290
|
+
"#{column.inspect}")
|
|
291
|
+
end
|
|
292
|
+
if properties.key?(:type) && !properties[:type].respond_to?(:to_sym)
|
|
293
|
+
raise(Errors::InvalidLayout, "The type of a column of the layout must be :string, :integer or :float: " \
|
|
294
|
+
"#{column.inspect}")
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
properties
|
|
298
|
+
end
|
|
299
|
+
|
|
150
300
|
def initialize(size:, key: nil, type: :string, decimals: 2)
|
|
151
301
|
@key = key
|
|
152
302
|
@size = [:remainder, "remainder"].include?(size) ? -1 : size.to_i
|
|
@@ -205,6 +355,53 @@ module IOStreams
|
|
|
205
355
|
|
|
206
356
|
formatted
|
|
207
357
|
end
|
|
358
|
+
|
|
359
|
+
# Returns [Hash|nil] the field of a schema for this column, named by its key, with its size, type and decimals,
|
|
360
|
+
# see `IOStreams::Tabular::Parser::Fixed.schema`, or nil for a filler, which has no key.
|
|
361
|
+
#
|
|
362
|
+
# Parameters:
|
|
363
|
+
# start: [Integer|nil]
|
|
364
|
+
# The position of the first character of the column, counting from 1, or nil when it is right after the
|
|
365
|
+
# field before it.
|
|
366
|
+
#
|
|
367
|
+
# truncate: [true|false]
|
|
368
|
+
# Whether #render truncates a string that is longer than its column.
|
|
369
|
+
#
|
|
370
|
+
# Raises `IOStreams::Errors::InvalidLayout` when a field cannot hold the column: for a key that is neither a
|
|
371
|
+
# String nor a Symbol, which cannot name a field, or for decimals that #render cannot write.
|
|
372
|
+
def field(start, truncate)
|
|
373
|
+
return unless key
|
|
374
|
+
|
|
375
|
+
unless key.is_a?(String) || key.is_a?(Symbol)
|
|
376
|
+
raise(Errors::InvalidLayout, "The key of a column of the layout must be a String or a Symbol, such as " \
|
|
377
|
+
"#{key.to_s.inspect}, or none for a filler: #{key.inspect}")
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
field = {name: key, type: FIELD_TYPES[type], width: size == -1 ? "remainder" : size}
|
|
381
|
+
field[:start] = start if start
|
|
382
|
+
case type
|
|
383
|
+
when :string then field[:truncate] = true if truncate
|
|
384
|
+
when :float
|
|
385
|
+
field[:floatNumber] = true
|
|
386
|
+
# A float that takes the remainder of the line is written with the digits that it has.
|
|
387
|
+
field[:decimals] = field_decimals unless size == -1
|
|
388
|
+
end
|
|
389
|
+
field
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
private
|
|
393
|
+
|
|
394
|
+
# Returns [Integer] the decimals of a :float column, see #field, as #render writes them: nil or empty text
|
|
395
|
+
# for none.
|
|
396
|
+
#
|
|
397
|
+
# Raises `IOStreams::Errors::InvalidLayout` for decimals that #render cannot write, such as -1, 2.5 or "abc".
|
|
398
|
+
def field_decimals
|
|
399
|
+
return decimals if decimals.is_a?(Integer) && !decimals.negative?
|
|
400
|
+
return decimals.to_i if decimals.nil? || (decimals.is_a?(String) && DECIMALS.match?(decimals))
|
|
401
|
+
|
|
402
|
+
raise(Errors::InvalidLayout, "The decimals of the column #{key.inspect} of the layout must be a whole " \
|
|
403
|
+
"number of 0 or more: #{decimals.inspect}")
|
|
404
|
+
end
|
|
208
405
|
end
|
|
209
406
|
end
|
|
210
407
|
end
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module IOStreams
|
|
4
|
+
class Tabular
|
|
5
|
+
module Parser
|
|
6
|
+
# Parses and renders the lines of a fixed width file whose schema gives the position of each field in every line,
|
|
7
|
+
# with the `width`, and optionally the `start`, of each field, see `IOStreams::Schema`. Each row is the values of
|
|
8
|
+
# the fields, in their order, as for CSV, since a line has no names, so the schema names them, see
|
|
9
|
+
# `IOStreams::Tabular::SchemaMapping`. A fixed width file without a schema is still parsed by the layout of
|
|
10
|
+
# `IOStreams::Tabular::Parser::Fixed`, as before.
|
|
11
|
+
#
|
|
12
|
+
# When reading, each value is the text at the position of its field without its leading and trailing spaces,
|
|
13
|
+
# which the schema then converts to the type of its field.
|
|
14
|
+
#
|
|
15
|
+
# When writing, a number is right justified and zero filled, and a missing number is written as zero, as most
|
|
16
|
+
# specifications of fixed width files require, except a number that is not finite, such as NaN, which is space
|
|
17
|
+
# filled, so that it can be read back. Any other value is left justified and space filled, with each
|
|
18
|
+
# line break replaced with a space, so that a value cannot add records. The characters that no field covers,
|
|
19
|
+
# such as fillers, are spaces.
|
|
20
|
+
#
|
|
21
|
+
# The positions count the characters of the line, which a schema reads as UTF-8, unless an encoding is set,
|
|
22
|
+
# while the layout counts the bytes of the binary line, see `IOStreams::Schema.from_layout`.
|
|
23
|
+
class FixedFields < Base
|
|
24
|
+
LINE_BREAK = /\r\n|\r|\n/
|
|
25
|
+
DIGIT = /\d/
|
|
26
|
+
|
|
27
|
+
# Where a field is in each line: the offset of its first character, and its width, or -1 for the remainder of
|
|
28
|
+
# the line.
|
|
29
|
+
Column = Struct.new(:field, :index, :width)
|
|
30
|
+
|
|
31
|
+
# Returns [Integer] the length of every line, or -1 when a line can be any length, after a field or a schema
|
|
32
|
+
# width of "remainder", or 0 when there are no fields.
|
|
33
|
+
attr_reader :line_length
|
|
34
|
+
|
|
35
|
+
# Parameters:
|
|
36
|
+
# schema: [IOStreams::Schema]
|
|
37
|
+
# The schema, which must give the width of each field.
|
|
38
|
+
#
|
|
39
|
+
# Raises ArgumentError when the schema does not give the position of each field.
|
|
40
|
+
def initialize(schema)
|
|
41
|
+
super()
|
|
42
|
+
@schema = schema
|
|
43
|
+
@columns = []
|
|
44
|
+
@line_length = build_columns
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Returns [Array] the text of each field of the line, in the order of the fields, without its leading and
|
|
48
|
+
# trailing spaces, or the row supplied, an Array that is already parsed, as it is, as CSV does, see
|
|
49
|
+
# `IOStreams::Tabular#record_parse`.
|
|
50
|
+
def parse(line)
|
|
51
|
+
return line if line.is_a?(::Array)
|
|
52
|
+
unless line.is_a?(String)
|
|
53
|
+
raise(Errors::TypeMismatch, "Line must be a String when format is :fixed. Actual: #{line.class.name}")
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
if line_length.positive? && line.length != line_length
|
|
57
|
+
raise(Errors::InvalidLineLength, "Expected line length: #{line_length}, actual line length: #{line.length}")
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
@columns.map do |column|
|
|
61
|
+
value = column.width == -1 ? line[column.index..] : line[column.index, column.width]
|
|
62
|
+
value&.strip
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Returns [String] the line of the values of the fields, in the order of the fields, which the schema has already
|
|
67
|
+
# rendered as text, see `IOStreams::Tabular::SchemaMapping#render`, so that the header supplied is not needed.
|
|
68
|
+
def render(row, _header)
|
|
69
|
+
line = +""
|
|
70
|
+
@columns.each_with_index do |column, index|
|
|
71
|
+
line << (" " * (column.index - line.length)) if column.index > line.length
|
|
72
|
+
line << render_value(column, row[index])
|
|
73
|
+
end
|
|
74
|
+
line << (" " * (line_length - line.length)) if line_length > line.length
|
|
75
|
+
line
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# The header is required as an argument and cannot be supplied in the file itself.
|
|
79
|
+
def requires_header?
|
|
80
|
+
false
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
# Returns [Integer] the length of every line, see #line_length, once it works out where each field is.
|
|
86
|
+
def build_columns
|
|
87
|
+
position = 0
|
|
88
|
+
@schema.fields.each do |field|
|
|
89
|
+
if position == -1
|
|
90
|
+
raise(ArgumentError, "Only the last field can have a width of \"remainder\", " \
|
|
91
|
+
"but field #{field.name.inspect} follows it")
|
|
92
|
+
end
|
|
93
|
+
raise(ArgumentError, "Field #{field.name.inspect} needs a width for the :fixed format") if field.width.nil?
|
|
94
|
+
|
|
95
|
+
index = field.start ? field.start - 1 : position
|
|
96
|
+
if index < position
|
|
97
|
+
raise(ArgumentError, "Field #{field.name.inspect} starts at #{field.start}, within the field before it, " \
|
|
98
|
+
"which ends at #{position}")
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
size = field.width == :remainder ? -1 : field.width
|
|
102
|
+
@columns << Column.new(field, index, size)
|
|
103
|
+
position = size == -1 ? -1 : index + size
|
|
104
|
+
end
|
|
105
|
+
schema_line_length(position)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def schema_line_length(position)
|
|
109
|
+
width = @schema.width
|
|
110
|
+
return position if width.nil?
|
|
111
|
+
return -1 if width == :remainder
|
|
112
|
+
|
|
113
|
+
if position == -1
|
|
114
|
+
raise(ArgumentError, "The schema width cannot be set when the last field takes the remainder of the line")
|
|
115
|
+
end
|
|
116
|
+
if width < position
|
|
117
|
+
raise(ArgumentError, "The schema width #{width} is less than the end of the last field, at #{position}")
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
width
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def render_value(column, text)
|
|
124
|
+
field = column.field
|
|
125
|
+
return render_number(column, text.nil? ? field.render(0, :text) : text.to_s) if field.numeric?
|
|
126
|
+
|
|
127
|
+
text = text.to_s
|
|
128
|
+
text = text.gsub(LINE_BREAK, " ") if LINE_BREAK.match?(text)
|
|
129
|
+
return text if column.width == -1
|
|
130
|
+
return text.ljust(column.width) if text.length <= column.width
|
|
131
|
+
raise(value_too_long(column, text)) unless field.truncate?
|
|
132
|
+
|
|
133
|
+
text[0, column.width]
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def render_number(column, text)
|
|
137
|
+
return text if column.width == -1
|
|
138
|
+
|
|
139
|
+
padded =
|
|
140
|
+
# A number that is not finite, such as NaN or -INF, has no digits to zero fill, so that it can be read back.
|
|
141
|
+
if !DIGIT.match?(text)
|
|
142
|
+
text.rjust(column.width)
|
|
143
|
+
elsif text.start_with?("-")
|
|
144
|
+
"-#{text.delete_prefix('-').rjust(column.width - 1, '0')}"
|
|
145
|
+
else
|
|
146
|
+
text.rjust(column.width, "0")
|
|
147
|
+
end
|
|
148
|
+
raise(value_too_long(column, text)) if padded.length > column.width
|
|
149
|
+
|
|
150
|
+
padded
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def value_too_long(column, text)
|
|
154
|
+
message = "Value: #{text} is too large to fit into column:#{column.field.name} of size:#{column.width}"
|
|
155
|
+
Errors::ValueTooLong.new(message)
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
@@ -3,6 +3,16 @@ module IOStreams
|
|
|
3
3
|
class Tabular
|
|
4
4
|
module Parser
|
|
5
5
|
class Hash < Base
|
|
6
|
+
# Returns [true] each row is a Hash, whose keys name its values.
|
|
7
|
+
def self.keyed?
|
|
8
|
+
true
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# Returns [:typed] each row is a Ruby object, so it holds the values of a schema themselves.
|
|
12
|
+
def self.value_form
|
|
13
|
+
:typed
|
|
14
|
+
end
|
|
15
|
+
|
|
6
16
|
def parse(row)
|
|
7
17
|
raise(IOStreams::Errors::TypeMismatch, "Format is :hash. Invalid input: #{row.class.name}") unless row.is_a?(::Hash)
|
|
8
18
|
|
|
@@ -4,6 +4,23 @@ module IOStreams
|
|
|
4
4
|
module Parser
|
|
5
5
|
# For parsing a single line of JSON at a time
|
|
6
6
|
class Json < Base
|
|
7
|
+
# Returns [true] each line is a JSON object, whose keys name its values.
|
|
8
|
+
def self.keyed?
|
|
9
|
+
true
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Returns [:json] JSON writes the numbers and booleans of a schema as themselves, and every other value as text.
|
|
13
|
+
def self.value_form
|
|
14
|
+
:json
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Returns [IOStreams::Tabular::Parser::JsonFields] the parser of the lines of JSON with the supplied schema,
|
|
18
|
+
# which reads every digit of the numbers of its number fields, and writes them, see
|
|
19
|
+
# `IOStreams::Tabular::Parser::Base.schema_parser`.
|
|
20
|
+
def self.schema_parser(schema)
|
|
21
|
+
JsonFields.new(schema)
|
|
22
|
+
end
|
|
23
|
+
|
|
7
24
|
def parse(row)
|
|
8
25
|
return row if row.is_a?(::Hash)
|
|
9
26
|
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
module IOStreams
|
|
5
|
+
class Tabular
|
|
6
|
+
module Parser
|
|
7
|
+
# Parses and renders each line of JSON of a file with a schema, see `IOStreams::Stream#schema`, a record whose keys
|
|
8
|
+
# the schema matches to its fields, see `IOStreams::Tabular::SchemaMapping`, which converts its values:
|
|
9
|
+
# * Reads the numbers of each number field with every digit of their text, as BigDecimals, rather than through a
|
|
10
|
+
# Float, which keeps about 16, while every other number is still a Float, as without a schema.
|
|
11
|
+
# * Raises for a line that is not an object, since only a record has the keys that name its values.
|
|
12
|
+
# * Writes the numbers that the schema renders with every digit, see `IOStreams::Schema::JsonNumber`.
|
|
13
|
+
#
|
|
14
|
+
# A file without a schema is still parsed by `IOStreams::Tabular::Parser::Json`, as before.
|
|
15
|
+
class JsonFields < Json
|
|
16
|
+
# Returns [JSON::Coder|nil] a coder that writes a record as `IOStreams::Schema::Field.any_json(record).to_json`
|
|
17
|
+
# does, in one pass over its values in the json gem, rather than one pass in Ruby, then another to write them.
|
|
18
|
+
#
|
|
19
|
+
# Returns nil before json 2.10, which added `JSON::Coder`, for a version whose coder does not write a key that is
|
|
20
|
+
# not a String the same way, and when another library, such as ActiveSupport, has replaced `Hash#to_json`, since
|
|
21
|
+
# the coder would not write the JSON that it does.
|
|
22
|
+
def self.coder
|
|
23
|
+
return unless ::Hash.instance_method(:to_json).owner.name&.start_with?("JSON::")
|
|
24
|
+
return @coder if defined?(@coder)
|
|
25
|
+
|
|
26
|
+
@coder = build_coder
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.build_coder
|
|
30
|
+
return unless defined?(::JSON::Coder) && defined?(::JSON::Fragment)
|
|
31
|
+
|
|
32
|
+
coder = ::JSON::Coder.new do |value, key|
|
|
33
|
+
# Like `Hash#to_json`, which writes a key that is not a String with `to_s`.
|
|
34
|
+
next value.to_s if key
|
|
35
|
+
|
|
36
|
+
json = IOStreams::Schema::Field.any_json(value)
|
|
37
|
+
json.equal?(value) ? ::JSON::Fragment.new(value.to_json) : json
|
|
38
|
+
end
|
|
39
|
+
coder if coder.dump({1 => nil}) == '{"1":null}'
|
|
40
|
+
rescue StandardError
|
|
41
|
+
nil
|
|
42
|
+
end
|
|
43
|
+
private_class_method :build_coder
|
|
44
|
+
|
|
45
|
+
# The most keys whose fields it keeps, see #decimal_key?.
|
|
46
|
+
DECIMAL_KEYS = 1000
|
|
47
|
+
|
|
48
|
+
# Parameters:
|
|
49
|
+
# schema: [IOStreams::Schema]
|
|
50
|
+
# The fields of the records, which say which numbers keep every digit, see `IOStreams::Schema#decimals?`.
|
|
51
|
+
def initialize(schema)
|
|
52
|
+
super()
|
|
53
|
+
@schema = schema
|
|
54
|
+
@coder = self.class.coder
|
|
55
|
+
# Whether each key of the records is the column of a number field that keeps every digit, see #parse.
|
|
56
|
+
@decimal_keys = {} if schema.decimals?
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Returns [Hash] the record of the line, or nil for a line of `null`, which is a blank line. Returns the record or
|
|
60
|
+
# row supplied, a Hash, or an Array that is already parsed, as it is, see `IOStreams::Tabular#record_parse`.
|
|
61
|
+
#
|
|
62
|
+
# With a schema that has a number field that keeps every digit, see `IOStreams::Schema#decimals?`, the numbers
|
|
63
|
+
# of its column are read as BigDecimals, with every digit of their text, rather than through a Float, which keeps
|
|
64
|
+
# about 16. Every other number is still a Float, the same as without a schema.
|
|
65
|
+
#
|
|
66
|
+
# Raises `IOStreams::Errors::TypeMismatch` for a line that is not an object, such as `[1,2]` or `5`, since only an
|
|
67
|
+
# object has the keys that name its values.
|
|
68
|
+
def parse(row)
|
|
69
|
+
# Each line read is a String, so that is checked first.
|
|
70
|
+
unless row.is_a?(String)
|
|
71
|
+
return row if row.is_a?(::Hash) || row.is_a?(::Array)
|
|
72
|
+
|
|
73
|
+
raise(IOStreams::Errors::TypeMismatch, "Format is :json. Invalid input: #{row.class.name}")
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
values = @decimal_keys ? parse_decimals(row) : JSON.parse(row)
|
|
77
|
+
return values if values.is_a?(::Hash) || values.nil?
|
|
78
|
+
|
|
79
|
+
raise(IOStreams::Errors::TypeMismatch, "Each line of the :json format must be an object, whose keys name its " \
|
|
80
|
+
"values, rather than #{values.class.name}")
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Returns [String] the record, by the names of its fields, as a single line JSON string, once the schema has
|
|
84
|
+
# rendered its values, see `IOStreams::Schema::Field#render`, which the header supplied leaves as they are, see
|
|
85
|
+
# `IOStreams::Tabular::SchemaMapping#render`.
|
|
86
|
+
#
|
|
87
|
+
# A BigDecimal, Date or Time is written as its text, see `IOStreams::Schema::Field.any_json`, such as "12.5"
|
|
88
|
+
# rather than "0.125e2", so that a BigDecimal keeps all of its digits. A Float that JSON cannot hold, such as
|
|
89
|
+
# `Float::NAN`, never reaches it, since the field of its value raises `IOStreams::Errors::InvalidValue` for it,
|
|
90
|
+
# see `IOStreams::Schema::Field#render`.
|
|
91
|
+
def render(row, _header)
|
|
92
|
+
@coder ? @coder.dump(row) : IOStreams::Schema::Field.any_json(row).to_json
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
private
|
|
96
|
+
|
|
97
|
+
# Returns the value of the line, with the numbers of each key that is the column of a number field that keeps
|
|
98
|
+
# every digit as BigDecimals, and every other number as a Float, see #parse.
|
|
99
|
+
def parse_decimals(line)
|
|
100
|
+
values = JSON.parse(line, decimal_class: ::BigDecimal)
|
|
101
|
+
return values unless values.is_a?(::Hash)
|
|
102
|
+
|
|
103
|
+
values.each_pair do |key, value|
|
|
104
|
+
next unless value.is_a?(::BigDecimal) || value.is_a?(::Array) || value.is_a?(::Hash)
|
|
105
|
+
|
|
106
|
+
values[key] = floats(value) unless decimal_key?(key)
|
|
107
|
+
end
|
|
108
|
+
values
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Returns [true|false] whether the key is the column of a number field that keeps every digit, worked out once
|
|
112
|
+
# for each key, see `IOStreams::Schema#field_for`.
|
|
113
|
+
def decimal_key?(key)
|
|
114
|
+
@decimal_keys.fetch(key) do
|
|
115
|
+
@decimal_keys.clear if @decimal_keys.size >= DECIMAL_KEYS
|
|
116
|
+
@decimal_keys[key] = @schema.field_for(key, "key")&.decimal? || false
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Returns the value, with each BigDecimal within it as a Float, as JSON reads a number without a schema.
|
|
121
|
+
def floats(value)
|
|
122
|
+
case value
|
|
123
|
+
when ::BigDecimal then value.to_f
|
|
124
|
+
when ::Array then value.map { |item| floats(item) }
|
|
125
|
+
when ::Hash then value.transform_values { |item| floats(item) }
|
|
126
|
+
else value
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|