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.
Files changed (75) hide show
  1. checksums.yaml +4 -4
  2. data/docs/config.md +13 -9
  3. data/docs/copy_files.md +20 -2
  4. data/docs/extensions.md +18 -4
  5. data/docs/formats.md +644 -16
  6. data/docs/path.md +18 -2
  7. data/docs/streams.md +54 -9
  8. data/docs/tutorial.md +14 -2
  9. data/docs/upgrading.md +470 -23
  10. data/lib/io_streams/builder/deprecations.rb +250 -0
  11. data/lib/io_streams/builder.rb +256 -39
  12. data/lib/io_streams/bzip2/reader.rb +18 -2
  13. data/lib/io_streams/bzip2/writer.rb +11 -2
  14. data/lib/io_streams/deprecation/methods.rb +51 -0
  15. data/lib/io_streams/deprecation/once.rb +27 -0
  16. data/lib/io_streams/deprecation/option_names.rb +26 -0
  17. data/lib/io_streams/deprecation/words.rb +42 -0
  18. data/lib/io_streams/deprecation.rb +259 -0
  19. data/lib/io_streams/encode/reader.rb +54 -3
  20. data/lib/io_streams/encode/writer.rb +19 -2
  21. data/lib/io_streams/encode.rb +99 -0
  22. data/lib/io_streams/errors.rb +51 -0
  23. data/lib/io_streams/gzip/reader.rb +11 -0
  24. data/lib/io_streams/io_streams.rb +365 -35
  25. data/lib/io_streams/line/reader.rb +2 -1
  26. data/lib/io_streams/line/utf8_reader.rb +23 -0
  27. data/lib/io_streams/path.rb +125 -35
  28. data/lib/io_streams/paths/file.rb +72 -9
  29. data/lib/io_streams/paths/http.rb +48 -2
  30. data/lib/io_streams/paths/matcher.rb +6 -3
  31. data/lib/io_streams/paths/s3.rb +180 -9
  32. data/lib/io_streams/paths/sftp.rb +47 -3
  33. data/lib/io_streams/paths.rb +23 -0
  34. data/lib/io_streams/pgp/reader.rb +18 -0
  35. data/lib/io_streams/pgp/writer.rb +16 -0
  36. data/lib/io_streams/pgp.rb +16 -2
  37. data/lib/io_streams/reader.rb +44 -0
  38. data/lib/io_streams/record/reader.rb +59 -68
  39. data/lib/io_streams/record/writer.rb +17 -10
  40. data/lib/io_streams/row/header_row.rb +21 -0
  41. data/lib/io_streams/row/reader.rb +52 -15
  42. data/lib/io_streams/row/writer.rb +64 -13
  43. data/lib/io_streams/schema/columns.rb +456 -0
  44. data/lib/io_streams/schema/constraints.rb +99 -0
  45. data/lib/io_streams/schema/field.rb +967 -0
  46. data/lib/io_streams/schema/json_number.rb +56 -0
  47. data/lib/io_streams/schema/keys.rb +78 -0
  48. data/lib/io_streams/schema/pattern.rb +257 -0
  49. data/lib/io_streams/schema/source.rb +31 -0
  50. data/lib/io_streams/schema.rb +616 -0
  51. data/lib/io_streams/stream.rb +836 -67
  52. data/lib/io_streams/symmetric_encryption/reader.rb +8 -0
  53. data/lib/io_streams/tabular/header.rb +29 -0
  54. data/lib/io_streams/tabular/header_mapping.rb +332 -0
  55. data/lib/io_streams/tabular/mapping.rb +165 -0
  56. data/lib/io_streams/tabular/parser/array.rb +5 -0
  57. data/lib/io_streams/tabular/parser/base.rb +24 -0
  58. data/lib/io_streams/tabular/parser/fixed.rb +197 -0
  59. data/lib/io_streams/tabular/parser/fixed_fields.rb +160 -0
  60. data/lib/io_streams/tabular/parser/hash.rb +10 -0
  61. data/lib/io_streams/tabular/parser/json.rb +17 -0
  62. data/lib/io_streams/tabular/parser/json_fields.rb +132 -0
  63. data/lib/io_streams/tabular/schema_mapping.rb +361 -0
  64. data/lib/io_streams/tabular.rb +344 -30
  65. data/lib/io_streams/utils.rb +441 -6
  66. data/lib/io_streams/version.rb +1 -1
  67. data/lib/io_streams/writer.rb +27 -0
  68. data/lib/io_streams/xlsx/cells.rb +127 -0
  69. data/lib/io_streams/xlsx/reader.rb +56 -0
  70. data/lib/io_streams/xlsx/schema_reader.rb +90 -0
  71. data/lib/io_streams/xlsx/v3_writer.rb +16 -0
  72. data/lib/io_streams/zip/reader.rb +7 -0
  73. data/lib/io_streams/zip/writer.rb +24 -2
  74. data/lib/iostreams.rb +21 -17
  75. 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