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
@@ -27,20 +27,78 @@ module IOStreams
27
27
  #
28
28
  # tabular.render({"third"=>"3", "first_field"=>"1" })
29
29
  # # => "1,,3"
30
+ #
31
+ # With a schema, see `IOStreams::Stream#schema`, the columns of the file are matched to the fields of the schema,
32
+ # whose names are the keys of the records read, and whose types convert the values read and render those written:
33
+ #
34
+ # tabular = Tabular.new(schema: IOStreams::Schema.new(fields: [{name: "id", type: "integer"}, {name: "name"}]))
35
+ # tabular.read_header("ID,Name")
36
+ # # => ["id", "name"]
37
+ #
38
+ # tabular.record_parse("1,Jack")
39
+ # # => {"id"=>1, "name"=>"Jack"}
30
40
  class Tabular
31
41
  autoload :Header, "io_streams/tabular/header"
42
+ autoload :HeaderMapping, "io_streams/tabular/header_mapping"
43
+ autoload :Mapping, "io_streams/tabular/mapping"
44
+ autoload :SchemaMapping, "io_streams/tabular/schema_mapping"
32
45
 
33
46
  module Parser
34
47
  autoload :Array, "io_streams/tabular/parser/array"
35
48
  autoload :Base, "io_streams/tabular/parser/base"
36
49
  autoload :Csv, "io_streams/tabular/parser/csv"
37
50
  autoload :Fixed, "io_streams/tabular/parser/fixed"
51
+ autoload :FixedFields, "io_streams/tabular/parser/fixed_fields"
38
52
  autoload :Hash, "io_streams/tabular/parser/hash"
39
53
  autoload :Json, "io_streams/tabular/parser/json"
54
+ autoload :JsonFields, "io_streams/tabular/parser/json_fields"
40
55
  autoload :Psv, "io_streams/tabular/parser/psv"
41
56
  end
42
57
 
43
- attr_reader :format, :header, :parser
58
+ # The default of the `cleanse_header` option, see #initialize, which cleanses the header row without a schema, and
59
+ # of the `cleanse:` of `IOStreams::Tabular::SchemaMapping#read_header`. An object of its own, which no caller can
60
+ # supply, so that an option that is supplied, whatever its value, such as `:default`, is told apart from one that is
61
+ # not, since with a schema, whose fields name the keys of the records instead, one that is supplied raises.
62
+ CLEANSE_BY_DEFAULT = Object.new.freeze
63
+ private_constant :CLEANSE_BY_DEFAULT
64
+
65
+ # The options of #initialize that describe the file, rather than its columns: its format, the file name that names
66
+ # the format, and the schema or the layout of its fields. `IOStreams::Stream` sets them from its path, see
67
+ # `IOStreams::Stream#format` and `IOStreams::Stream#schema`.
68
+ FORMAT_OPTION_NAMES = %i[format file_name schema format_options default_format].freeze
69
+
70
+ # The format of a file whose format is neither supplied nor named by its file name, see #initialize.
71
+ DEFAULT_FORMAT = :csv
72
+ private_constant :DEFAULT_FORMAT
73
+
74
+ # Returns [Array<Symbol>] the names of the supplied options of a reader of rows or records, see
75
+ # `IOStreams::Row::Reader`, that are deprecated, since a schema describes the columns of the file instead, see
76
+ # `IOStreams::Schema`: the options of the header, see `IOStreams::Tabular::Header`, and the `cleanse_header` option
77
+ # of the reader, even when they are nil, since IOStreams 3.0 raises ArgumentError for them, whatever their value.
78
+ def self.deprecated_read_option_names(options)
79
+ names = options.keys & Header::OPTION_NAMES
80
+ names << :cleanse_header if options.key?(:cleanse_header)
81
+ names
82
+ end
83
+
84
+ # Returns [Array<Symbol>] the names of the supplied options of a writer of rows or records, see
85
+ # `IOStreams::Row::Writer`, that are deprecated, since a schema describes the columns of the file instead: the
86
+ # options of the header, even when they are nil, as for a reader, see .deprecated_read_option_names, other than
87
+ # those that only apply when reading a header, see `IOStreams::Tabular::Header::READ_OPTION_NAMES`, which writing
88
+ # does not apply.
89
+ def self.deprecated_write_option_names(options)
90
+ options.keys & (Header::OPTION_NAMES - Header::READ_OPTION_NAMES)
91
+ end
92
+
93
+ attr_reader :format, :parser
94
+
95
+ # Returns [IOStreams::Schema|nil] the schema of the rows and records, see #initialize.
96
+ attr_reader :schema
97
+
98
+ # Returns [IOStreams::Tabular::Mapping] the mapping of the columns of the file to the values of its rows and records,
99
+ # by the header row or the columns supplied, or by the fields of a schema, which is chosen when the Tabular is
100
+ # created, see #initialize.
101
+ attr_reader :mapping
44
102
 
45
103
  # Parse a delimited data source.
46
104
  #
@@ -82,79 +140,258 @@ module IOStreams
82
140
  # then this default format will be used.
83
141
  # Default: :csv
84
142
  # Set to nil to force it to raise an exception when the format is undefined.
85
- def initialize(format: nil, file_name: nil, format_options: nil, default_format: :csv, **args)
86
- @header = Header.new(**args)
143
+ #
144
+ # schema: [IOStreams::Schema]
145
+ # The fields of the file, whose names are the keys of each record read, and the type of each one, which converts
146
+ # the values of each row read, and renders those written, see `IOStreams::Stream#schema`. The columns of the
147
+ # file are matched to the fields, see `IOStreams::Schema::Columns`, and the schema gives the position of each
148
+ # field of a fixed width file.
149
+ # Since the schema describes the columns of the file, the options above that describe them without one,
150
+ # `columns`, `allowed_columns` and `required_columns`, raise ArgumentError with it, unless they are nil, and so
151
+ # do `format_options`, unless they are empty, and `cleanse_header`, see below. `skip_unknown` only applies to
152
+ # `allowed_columns`, so it does not, and has no effect.
153
+ # Optional. Default: nil
154
+ #
155
+ # decoded: [true|false]
156
+ # With a schema, whether the lines read are already in the encoding of their text, as `IOStreams::Stream` reads
157
+ # them, see `IOStreams::Builder#utf8_text?`, so that they are taken as they are. Otherwise a binary line, as a
158
+ # stream reads it without an encoding, is taken as UTF-8, see `IOStreams::Tabular::SchemaMapping`. Raises
159
+ # ArgumentError without a schema, whose lines are taken as they are.
160
+ # Default: false
161
+ #
162
+ # cleanse_header: [true|false]
163
+ # Whether to cleanse the header row read, see `IOStreams::Tabular::Header#cleanse!`, as the `cleanse_header`
164
+ # option of a reader of rows or records says, see `IOStreams::Row::Reader`, which #read_header, #read_row,
165
+ # #read_record_line and #restrict_columns apply, unless #read_header is told otherwise. nil does not cleanse it,
166
+ # as before. It only applies when reading, so a writer of rows or records does not accept it, see
167
+ # .check_write_options. With a schema it raises ArgumentError, even when nil, since the fields of the schema name
168
+ # the keys of the records instead.
169
+ # Default: cleanses it.
170
+ def initialize(format: nil, file_name: nil, format_options: nil, default_format: DEFAULT_FORMAT, schema: nil,
171
+ decoded: nil, cleanse_header: CLEANSE_BY_DEFAULT, **args)
172
+ if schema
173
+ reject_schema_options(format_options, args)
174
+ reject_header_cleansing(cleanse_header)
175
+ elsif !decoded.nil?
176
+ raise(ArgumentError, "decoded: only applies with a schema, without which the lines are taken as they are")
177
+ end
178
+ header = Header.new(**args)
87
179
  @format = file_name && format.nil? ? self.class.format_from_file_name(file_name) : format
88
180
  @format ||= default_format
89
181
  raise(Errors::UnknownFormat, "The format cannot be inferred from the file name: #{file_name}") unless @format
90
182
 
91
183
  klass = self.class.parser_class(@format)
92
- @parser = format_options ? klass.new(**format_options) : klass.new
184
+ @schema = schema
185
+ if schema
186
+ unless klass.respond_to?(:schema_parser)
187
+ raise(ArgumentError, "The #{@format.inspect} format cannot be read or written with a schema, since its parser " \
188
+ "is not an IOStreams::Tabular::Parser::Base")
189
+ end
190
+
191
+ @parser = klass.schema_parser(schema)
192
+ @mapping = SchemaMapping.new(schema, header: header, parser: @parser, format: @format, decoded: decoded || false)
193
+ else
194
+ @parser = format_options ? klass.new(**format_options) : klass.new
195
+ @mapping = HeaderMapping.new(header: header, parser: @parser, format: @format, cleanse: cleanse_header ? true : false)
196
+ end
197
+ end
198
+
199
+ # Returns [IOStreams::Tabular::Header] the columns of the file: the header row read, or the columns supplied in
200
+ # place of one, and their restrictions, see `IOStreams::Tabular::Header`. With a schema, the header row as it was
201
+ # read, see #read_header.
202
+ def header
203
+ @mapping.header
93
204
  end
94
205
 
95
206
  # Returns [true|false] whether a header is still required in order to parse or render the current format.
96
207
  def header?
97
- parser.requires_header? && IOStreams::Utils.blank?(header.columns)
208
+ @mapping.header?
98
209
  end
99
210
 
100
- # Returns [true|false] whether a header row show be rendered on output.
211
+ # Returns [true|false] whether a header row show be rendered on output: when the format has one, unless the
212
+ # schema says that the file has none, see `IOStreams::Schema#header?`.
101
213
  def requires_header?
102
- parser.requires_header?
214
+ @mapping.requires_header?
103
215
  end
104
216
 
105
- # Returns [Array] the header row/line after parsing and cleansing.
217
+ # Returns [true|false] whether the rows read start with a header row, see #read_row, until it is read: the header
218
+ # row of a file with one, see #header?, or with a schema, the names of the fields of a file without one, such as
219
+ # fixed width, JSON, or a schema whose `header` is false.
220
+ def rows_header?
221
+ @mapping.rows_header?
222
+ end
223
+
224
+ # Returns [true|false] whether the rows read, see #read_row, are the records of the file, Hashes keyed by the names
225
+ # of their values, rather than Arrays: those of a format whose rows are records, see
226
+ # `IOStreams::Tabular::Parser::Base.keyed?`, such as JSON, without a schema. IOStreams v3.0 reads them as Arrays,
227
+ # after a header row of the keys of the first record, as this version does with a schema.
228
+ def record_rows?
229
+ @mapping.record_rows?
230
+ end
231
+
232
+ # Returns [String|nil] the key of a record that two columns of the file are, such as "a" for a header row of `a,a,b`
233
+ # without a schema, of which each record read, and each row written to a format whose rows are records, such as
234
+ # JSON, holds the value of only the last column, see `IOStreams::Tabular::Header#repeated_key`. Returns nil when each
235
+ # column is its own key, as with a schema, which raises `IOStreams::Errors::InvalidHeader` for two columns of the
236
+ # same field. IOStreams v3.0 raises `IOStreams::Errors::InvalidHeader` without a schema too.
237
+ def repeated_key
238
+ @mapping.repeated_key
239
+ end
240
+
241
+ # Returns [Array<String|Symbol>] the columns of the rows of a file without a header row, which #read_row yields as
242
+ # their header row, so that a copy can give them to the target, see #start_copy: the names of the fields of a
243
+ # schema, see `IOStreams::Schema#header?`, such as of fixed width or JSON.
244
+ # Returns nil for any other file, such as one whose rows start with a header row of its own, or without a schema.
245
+ def headerless_columns
246
+ @mapping.headerless_columns
247
+ end
248
+
249
+ # Returns [Array] the header row/line after parsing.
106
250
  # Returns `nil` if the row/line is blank, or a header is not required for the supplied format (:json, :hash).
107
251
  #
108
252
  # Notes:
109
253
  # * Call `header?` first to determine if the header should be parsed first.
110
254
  # * The header columns are set after parsing the row, but the header is not cleansed.
255
+ # * With a schema, its columns are matched to the fields, see `IOStreams::Schema::Columns#match`.
111
256
  def parse_header(line)
112
- return if IOStreams::Utils.blank?(line) || !parser.requires_header?
257
+ @mapping.parse_header(line)
258
+ end
113
259
 
114
- header.columns = parser.parse(line)
260
+ # Parses the header row, then cleanses its columns when `cleanse:` is truthy, otherwise applies the column
261
+ # restrictions to them, as a reader of records does, see `IOStreams::Record::Reader`. `cleanse:` defaults to the
262
+ # `cleanse_header` option, see #initialize.
263
+ #
264
+ # With a schema, matches its columns to the fields instead, see `IOStreams::Schema::Columns#match`, so `cleanse:`
265
+ # cannot be supplied, other than nil. The header columns are the header row as it was read, so that another Tabular
266
+ # with the same schema, such as one that parses the rest of the file elsewhere, matches the same columns with
267
+ # `read_header(header.columns)`.
268
+ #
269
+ # Returns [Array] the header row as read, before it is cleansed, or with a schema, the names of the fields of its
270
+ # columns, in the order of the fields.
271
+ # Returns nil when the line is blank, or the format does not have a header row.
272
+ def read_header(line, **)
273
+ @mapping.read_header(line, **)
115
274
  end
116
275
 
117
276
  # Returns [Hash<String,Object>] the line as a hash.
277
+ # With a schema, the keys are the names of the fields, and the values are converted to their types, see
278
+ # `IOStreams::Schema::Columns`.
118
279
  # Returns nil if the line is blank.
119
280
  def record_parse(line)
120
- line = row_parse(line)
121
- header.to_hash(line) if line
281
+ @mapping.record_parse(line)
122
282
  end
123
283
 
124
284
  # Returns [Array] the row/line as a parsed Array of values.
285
+ # With a schema, the values of its fields, in the order of the fields, converted to their types, see
286
+ # `IOStreams::Schema::Columns`.
125
287
  # Returns nil if the row/line is blank.
126
288
  def row_parse(line)
127
- return if IOStreams::Utils.blank?(line)
128
-
129
- parser.parse(line)
289
+ @mapping.row_parse(line)
130
290
  end
131
291
 
132
292
  # Renders the output row
293
+ # With a schema, the value of each field is rendered in the form that the format writes, see
294
+ # `IOStreams::Schema::Field#render`, in the order of the fields.
133
295
  def render(row)
134
- return if IOStreams::Utils.blank?(row)
135
-
136
- parser.render(row, header)
296
+ @mapping.render(row)
137
297
  end
138
298
 
139
299
  # Returns [String] the header rendered for the output format
140
300
  # Return nil if no header is required.
301
+ # With a schema, the names of its fields.
141
302
  def render_header
142
- return unless requires_header?
143
-
144
- if IOStreams::Utils.blank?(header.columns)
145
- raise(
146
- Errors::MissingHeader,
147
- "Header columns must be set before attempting to render a header for format: #{format.inspect}"
148
- )
149
- end
150
-
151
- parser.render(header.columns, header)
303
+ @mapping.render_header
152
304
  end
153
305
 
154
306
  # Returns [Array<String>] the cleansed columns
307
+ #
308
+ # With a schema it raises ArgumentError, as `cleanse_header:` does, see #initialize, since the keys of the records
309
+ # are the names of the fields, which the columns of the header row are matched to instead, see #read_header.
155
310
  def cleanse_header!
156
- header.cleanse!
157
- header.columns
311
+ @mapping.cleanse_header!
312
+ end
313
+
314
+ # Reading and writing a line at a time: a reader or writer of rows or records, see `IOStreams::Row::Reader` and the
315
+ # others, hands each line or value to these, which the mapping of the columns reads or renders, see
316
+ # `IOStreams::Tabular::Mapping`, so that the reader or writer only iterates. The reading methods cleanse the header
317
+ # row as the `cleanse_header` option says, see #initialize.
318
+
319
+ # Yields [Array|nil] each row of the line of a file of rows: the header row as it is parsed, while it is expected,
320
+ # see #header?, otherwise the row, see #row_parse, or nil for a blank line. With a schema, the header row is an
321
+ # `IOStreams::Row::HeaderRow`, and the rows of a file without one start with the names of the fields, see
322
+ # `IOStreams::Tabular::SchemaMapping#read_row`.
323
+ #
324
+ # Returns the result of the block.
325
+ def read_row(line, &)
326
+ @mapping.read_row(line, &)
327
+ end
328
+
329
+ # Yields [Hash|nil] the record of the line of a file of records, see #record_parse, or nil for a blank line, unless
330
+ # the line is the header row, while it is expected, see #header?, which it reads instead, see #read_header.
331
+ def read_record_line(line, &)
332
+ @mapping.read_record_line(line, &)
333
+ end
334
+
335
+ # Applies the column restrictions to the columns supplied in place of a header row, before a reader of records
336
+ # reads the file, see `IOStreams::Record::Reader#initialize`. With a schema no column options apply.
337
+ def restrict_columns
338
+ @mapping.restrict_columns
339
+ end
340
+
341
+ # Yields [IOStreams::Row::HeaderRow] the header row that a copy of the rows of the file writes before its first line,
342
+ # see `IOStreams::Row::Reader#copy_to`, so that the target has the columns of the rows even when the file has no
343
+ # lines: with a schema, the names of the fields of a file without a header row, such as fixed width or JSON, see
344
+ # #headerless_columns, which #copy_row then does not yield again. Yields nothing for any other file, whose header row
345
+ # is a line of its own, if it has one.
346
+ def start_copy(&)
347
+ @mapping.start_copy(&)
348
+ end
349
+
350
+ # Yields [Array|nil] each row of the line that a copy of the rows of the file writes, see
351
+ # `IOStreams::Row::Reader#copy_to`, as #read_row yields it, or nil for a blank line, but with the header row as an
352
+ # `IOStreams::Row::HeaderRow`, also without a schema, so that the writer takes it as the header row of the rows after
353
+ # it, see `IOStreams::Row::Writer#<<`, rather than as a row of values.
354
+ #
355
+ # Returns the result of the block.
356
+ def copy_row(line, &)
357
+ @mapping.copy_row(line, &)
358
+ end
359
+
360
+ # Yields [String] the header line that a writer of rows writes before the first row: the columns supplied, or with
361
+ # a schema, the names of its fields, when the file has a header row.
362
+ def start_rows(&)
363
+ @mapping.start_rows(&)
364
+ end
365
+
366
+ # Yields [String|nil] the line that a writer of rows writes for the row. Without a schema, the first row is the
367
+ # header row, unless the columns were supplied.
368
+ #
369
+ # Returns the result of the block.
370
+ def render_row(row, &)
371
+ @mapping.render_row(row, &)
372
+ end
373
+
374
+ # Yields [String|nil] the line that a writer of rows writes for the header row of the rows after it, such as the
375
+ # `IOStreams::Row::HeaderRow` that `IOStreams::Stream#each(:array)` yields with a schema, or the header row of the
376
+ # file that is copied, see #copy_row, or the names of the fields of a file without one, see #start_copy. With a
377
+ # schema, it gives the columns of the rows after it, which are matched to the fields, without a line. Without one,
378
+ # it yields nothing when the columns were supplied, which name the values of the rows instead.
379
+ def render_header_row(columns, &)
380
+ @mapping.render_header_row(columns, &)
381
+ end
382
+
383
+ # Yields [String] the header line that a writer of records writes before the first record: the columns supplied,
384
+ # or with a schema, the names of its fields, when the file has a header row.
385
+ def start_records(&)
386
+ @mapping.start_records(&)
387
+ end
388
+
389
+ # Yields [String|nil] each line that a writer of records writes for the record. Without a schema, the header line
390
+ # of the keys of the first record comes first, unless the columns were supplied.
391
+ #
392
+ # Returns the result of the block.
393
+ def render_record(record, &)
394
+ @mapping.render_record(record, &)
158
395
  end
159
396
 
160
397
  # Register a format and the parser class for it.
@@ -193,17 +430,94 @@ module IOStreams
193
430
  nil
194
431
  end
195
432
 
433
+ # Returns [Symbol|nil] the format that this version reads and writes the rows and records of the file name in, when
434
+ # IOStreams v3.0 reads and writes them in its `:text` format instead, as the text of each line: when the last
435
+ # extension of the file name that names a format is `.text`, which this version does not register, so that it uses
436
+ # the format of an extension before it, see .format_from_file_name, or CSV, the default. Returns nil when v3.0 uses
437
+ # the same format, or when `:text` is registered.
438
+ #
439
+ # The extensions are those that v3.0 takes from the file name: those of its last name, after its first part,
440
+ # compared without case, so that `ids.TEXT` names `:text` too, while a file named `text` names no format.
441
+ def self.text_file_format(file_name)
442
+ return if @formats.include?(:text)
443
+
444
+ name = Utils::PathName.basename(file_name.to_s).b.sub(/\A\.+/, "")
445
+ extensions = name.split(".").drop(1).map(&:downcase)
446
+ last = extensions.reverse_each.find { |extension| extension == "text" || @formats.include?(extension.to_sym) }
447
+ format_from_file_name(file_name) || DEFAULT_FORMAT if last == "text"
448
+ end
449
+
450
+ # Returns [true|false] whether the rows of the registered format are lines of text, such as those of CSV or JSON,
451
+ # rather than Ruby objects, such as those of `:array` and `:hash`, see `Parser::Base.value_form`, so that IOStreams
452
+ # v3.0 converts a copy without a mode between it and another format, see `IOStreams::Builder#copy_format`.
453
+ def self.text_rows?(format)
454
+ parser_class(format).value_form != :typed
455
+ end
456
+
457
+ # Returns [:array|:hash] the mode of a copy that converts the rows of a file in one registered format into a file in
458
+ # another, as IOStreams v3.0 converts them in a copy without a mode, see `IOStreams::Stream#warn_copy_format`:
459
+ # `:hash` when the rows of either one are records, such as those of JSON, see `Parser::Base.keyed?`, since the rows
460
+ # of a file of records are read as records without a schema, and a writer of records takes records, otherwise
461
+ # `:array`, which keeps the columns of the header row as they are, where the keys of records are cleansed.
462
+ def self.conversion_mode(source, target)
463
+ parser_class(source).keyed? || parser_class(target).keyed? ? :hash : :array
464
+ end
465
+
196
466
  # Returns the parser class for the registered format.
197
467
  def self.parser_class(format)
198
468
  @formats[format&.to_sym] ||
199
469
  raise(ArgumentError, "Unknown Tabular Format: #{format.inspect}")
200
470
  end
201
471
 
472
+ # Raises ArgumentError for the options of #initialize that a writer of rows or records does not accept, see
473
+ # `IOStreams::Row::Writer#initialize`, as before: `cleanse_header`, which only applies when reading, so that it
474
+ # would have no effect.
475
+ def self.check_write_options(options)
476
+ return unless options.key?(:cleanse_header)
477
+
478
+ raise(ArgumentError, "cleanse_header: only applies when reading the header row, so it cannot be supplied when writing")
479
+ end
480
+
202
481
  register_format(:array, IOStreams::Tabular::Parser::Array)
203
482
  register_format(:csv, IOStreams::Tabular::Parser::Csv)
204
483
  register_format(:fixed, IOStreams::Tabular::Parser::Fixed)
205
484
  register_format(:hash, IOStreams::Tabular::Parser::Hash)
206
485
  register_format(:json, IOStreams::Tabular::Parser::Json)
207
486
  register_format(:psv, IOStreams::Tabular::Parser::Psv)
487
+
488
+ private
489
+
490
+ # Raises ArgumentError for the options that describe the columns of a file without a schema, since the schema
491
+ # describes them, see `IOStreams::Schema`, rather than apply one and ignore the other: the options of the header
492
+ # that are not nil, see `IOStreams::Tabular::Header.column_option_names`, and the layout of a fixed width file in
493
+ # format options that are not empty.
494
+ #
495
+ # An option that is nil is not supplied, and `skip_unknown` only applies to `allowed_columns`, so they are accepted,
496
+ # and have no effect, so that a caller, such as Rocket Job, can supply its options whether or not there is a schema.
497
+ def reject_schema_options(format_options, args)
498
+ unless format_options.nil? || format_options.empty?
499
+ raise(ArgumentError, "Supply either schema: or format_options:, not both")
500
+ end
501
+
502
+ names = Header.column_option_names(args)
503
+ return if names.empty?
504
+
505
+ raise(
506
+ ArgumentError,
507
+ "#{names.map { |name| "#{name}:" }.join(', ')} cannot be supplied with a schema, which describes the columns " \
508
+ "of the file: its fields are the columns, `fieldsMatch` says which other columns the file can have, and " \
509
+ "`constraints: {required: true}` which fields it must have, see IOStreams::Schema"
510
+ )
511
+ end
512
+
513
+ # Raises ArgumentError for `cleanse_header:` with a schema, whose fields name the keys of the records read instead,
514
+ # including nil, which does not cleanse the header row, since it would have no effect.
515
+ def reject_header_cleansing(cleanse_header)
516
+ return if cleanse_header.equal?(CLEANSE_BY_DEFAULT)
517
+
518
+ raise(ArgumentError, "cleanse_header: cannot be supplied with a schema, since the keys of its records are the " \
519
+ "names of its fields, which the columns of the header row are matched to, see " \
520
+ "IOStreams::Schema#field_for")
521
+ end
208
522
  end
209
523
  end