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
@@ -0,0 +1,616 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module IOStreams
6
+ # The fields of the rows and records of a tabular file and the type of each one, described with a Frictionless Table
7
+ # Schema, see https://datapackage.org/standard/table-schema/, so that reading rows and records returns each value
8
+ # as its type, such as an Integer or a Date, and writing them writes each value in the text form of its type.
9
+ #
10
+ # Supply a schema with `IOStreams::Stream#schema`, as a Hash, or as the path or url of a JSON file. It applies to
11
+ # every tabular format. A fixed width file also needs the `width` of each field, which IOStreams adds to the
12
+ # properties of Frictionless, see `IOStreams::Schema::Field`.
13
+ #
14
+ # The columns of a file are matched to the fields by their names, see #field_for, and the keys of each record that is
15
+ # read are the names of its fields. The columns that the schema does not list are skipped when `fieldsMatch` allows
16
+ # them, or kept with `extraColumns`, see `IOStreams::Schema::Columns`.
17
+ #
18
+ # Properties at the top level of the schema:
19
+ # fields: [Array<Hash>]
20
+ # The fields, in the order of the columns of the file, see `IOStreams::Schema::Field`.
21
+ #
22
+ # fieldsMatch: [String]
23
+ # How the columns of the file must match the fields, as Frictionless defines it:
24
+ # "exact": the columns are the fields, in the same order.
25
+ # "equal": the columns are the fields, in any order.
26
+ # "subset": the columns include every field, and the columns that the schema does not list are skipped.
27
+ # "superset": every column is a field, but a field can be missing.
28
+ # "partial": at least one column is a field, and the columns that the schema does not list are skipped.
29
+ # Whatever its value, a field whose `constraints` are `required` must be one of the columns, see
30
+ # `IOStreams::Schema::Field`. A format whose records supply their own keys, such as JSON, applies it to the keys
31
+ # of each record, in any order. The columns of a file without a header row, see `header`, are the fields in
32
+ # order, so for a format with a header row, such as CSV, it only accepts "exact". A fixed width file always has
33
+ # every field, in order.
34
+ # Default: "exact"
35
+ #
36
+ # header: [true|false]
37
+ # Whether the file has a header row, as the `header` of a Frictionless Table Dialect says. When false, the
38
+ # columns of a file of a format with a header row, such as CSV, are the fields, in order: reading takes every line
39
+ # as a row, and writing writes no header row, and the fields in order, so the schema needs fields, and fieldsMatch
40
+ # "exact", see #check_fields_in_order. A format without a header row, such as JSON or fixed width, ignores it.
41
+ # Default: true
42
+ #
43
+ # width: [Integer|"remainder"]
44
+ # The width of every line of a fixed width file, when it ends after the last field, such as after a filler.
45
+ # "remainder" accepts lines of any length, ignoring what follows the last field.
46
+ # Default: the end of the last field.
47
+ #
48
+ # keyType: [String]
49
+ # How the key of each field in the records read is formed from its name, which IOStreams adds to the properties
50
+ # of Frictionless, see #key_for:
51
+ # "string": the name as a String, such as "zip" for the name :zip.
52
+ # "cleansed": the name once cleansed as `cleanse_header` cleanses a header row, see .cleansed, such as
53
+ # "first_name" for the name "First Name".
54
+ # "symbol": the name as a Symbol, such as :zip for the name "zip", so that a schema held as JSON, whose
55
+ # names are Strings, reads the Symbol keys of a layout, see .from_layout.
56
+ # "cleansedSymbol": the cleansed name as a Symbol, such as :first_name for the name "First Name".
57
+ # The names of the fields are still the columns of the header row written, and the keys of the records written
58
+ # can be the keys that it forms, as well as the names, see #field_for.
59
+ # Default: the names as they are given, a String or a Symbol.
60
+ #
61
+ # extraColumns: [String]
62
+ # What reading does with the columns that the schema does not list, which `fieldsMatch` "subset" and "partial"
63
+ # accept, which IOStreams adds to the properties of Frictionless:
64
+ # "skip": leaves them out of the rows and records read.
65
+ # "keep": keeps them, after the fields, in the order of the columns, each by the key that `keyType` forms from
66
+ # its column, see #key_for, and with its value as it is, as for a field of type "any". So a schema can
67
+ # then have no fields, with "subset", to read every column. The keys of each JSON record are kept the
68
+ # same way. A fixed width file has only its fields, so it ignores it.
69
+ # Writing writes only the fields, whichever it is, since a file that is written has the columns of its fields.
70
+ # Raises ArgumentError for "keep" with any other `fieldsMatch`, which does not accept those columns.
71
+ # Default: "skip"
72
+ #
73
+ # Properties that only describe the schema to people, `$schema`, `title` and `description`, are accepted and have
74
+ # no effect, as are the properties of other tools, whose names include a `:`, such as `"mytool:owner"`. Any other
75
+ # property raises `ArgumentError`, including those of Frictionless that IOStreams does not support yet, such as
76
+ # `primaryKey`, so that a schema never appears to apply a rule that it does not.
77
+ class Schema
78
+ autoload :Columns, "io_streams/schema/columns"
79
+ autoload :Constraints, "io_streams/schema/constraints"
80
+ autoload :Field, "io_streams/schema/field"
81
+ autoload :JsonNumber, "io_streams/schema/json_number"
82
+ autoload :Keys, "io_streams/schema/keys"
83
+ autoload :Pattern, "io_streams/schema/pattern"
84
+ autoload :Source, "io_streams/schema/source"
85
+
86
+ # The properties of a schema that IOStreams supports.
87
+ PROPERTIES = %w[fields fieldsMatch header width keyType extraColumns].freeze
88
+
89
+ # The properties of a schema that only describe it to people.
90
+ DESCRIPTIVE_PROPERTIES = %w[$schema title description].freeze
91
+
92
+ # The properties of a Frictionless Table Schema that IOStreams does not support yet.
93
+ UNSUPPORTED_PROPERTIES = %w[missingValues primaryKey foreignKeys uniqueKeys].freeze
94
+
95
+ # The values of `fieldsMatch`, see above, those that skip the columns that the schema does not list, and those that
96
+ # need every field.
97
+ FIELDS_MATCH = %w[exact equal subset superset partial].freeze
98
+ SKIPS_UNKNOWN = %w[subset partial].freeze
99
+ NEEDS_EVERY_FIELD = %w[exact equal subset].freeze
100
+
101
+ # The values of `extraColumns`, see above.
102
+ EXTRA_COLUMNS = %w[skip keep].freeze
103
+
104
+ # The characters that the loose comparison of a column and a field ignores, see .match_key: all but letters, the
105
+ # marks that combine with them, and digits, in every language.
106
+ UNMATCHED = /[^\p{L}\p{M}\p{N}]/
107
+
108
+ # The characters that the loose comparison ignores in ASCII text, and in binary text, whose other bytes are not
109
+ # letters that it can compare: all but the ASCII letters and digits.
110
+ UNMATCHED_ASCII = /[^a-z0-9]/
111
+ UNMATCHED_BINARY = /[^a-z0-9]/n
112
+
113
+ # The bytes of the byte order mark that some editors, such as Notepad, write at the start of UTF-8 text.
114
+ BYTE_ORDER_MARK = "\xEF\xBB\xBF".b
115
+
116
+ # Returns [Array<IOStreams::Schema::Field>] the fields, in the order of the columns of the file.
117
+ attr_reader :fields
118
+
119
+ # Returns [String] how the columns of the file must match the fields, see `fieldsMatch` above.
120
+ attr_reader :fields_match
121
+
122
+ # Returns [Integer|:remainder|nil] the width of every line of a fixed width file, or nil when it ends after the last
123
+ # field.
124
+ attr_reader :width
125
+
126
+ # Returns [String] the text of the label once it is cleansed as `cleanse_header` cleanses a header row without a
127
+ # schema, see `IOStreams::Tabular::Header#cleanse!`, so that a field named as the cleansing named its column, such
128
+ # as `area_m` for the column `Area (m²)`, matches the column, see #field_for.
129
+ #
130
+ # Unlike the cleansing of a header row without a schema, which keeps only ASCII letters and digits, it keeps the
131
+ # letters and digits of every language, as IOStreams 3.0 cleanses a header row, so that a field named as 3.0 names
132
+ # the column matches it in both. The header row of a file without a schema is still cleansed as before.
133
+ def self.cleansed(label)
134
+ # Unlike `strip` and `\s`, which are only ASCII, `[[:space:]]` is whitespace of every language, such as a
135
+ # no-break space or an ideographic space, so that it separates words like a space. Like `strip`, also
136
+ # removes a leading or trailing null. The end is found by the last other character, rather than by matching
137
+ # what follows it, which takes time that grows with the square of the length of whitespace within the name.
138
+ cleansed = label.to_s.downcase.sub(/\A[[:space:]\0]+/, "")
139
+ last = cleansed.rindex(/[^[:space:]\0]/)
140
+ cleansed = last ? cleansed[0..last] : +""
141
+ cleansed.gsub!(/[[:space:]]+/, "_")
142
+ cleansed.gsub!(/-+/, "_")
143
+ # Unlike `\W`, which is only ASCII, keeps the letters and digits of every language.
144
+ cleansed.gsub!(/[^[:word:]]+/, "")
145
+ cleansed
146
+ end
147
+
148
+ # Returns [IOStreams::Schema] the schema of the descriptor read from the supplied path or url, a String or a
149
+ # path, such as `"s3://bucket/schemas/people.json"`, whose text is JSON.
150
+ #
151
+ # A byte order mark at the start of the text is ignored, since it is not part of the JSON.
152
+ #
153
+ # Raises ArgumentError when the file is not JSON, or is not a valid schema.
154
+ def self.read(location)
155
+ path = location.is_a?(IOStreams::Stream) ? location : IOStreams.path(location)
156
+ text = path.read
157
+ # Compared as bytes, since the text that a path reads is binary.
158
+ text = text.byteslice(BYTE_ORDER_MARK.bytesize..) if text.byteslice(0, BYTE_ORDER_MARK.bytesize).b == BYTE_ORDER_MARK
159
+ begin
160
+ new(JSON.parse(text))
161
+ rescue JSON::ParserError, ArgumentError => e
162
+ # The path within its store, without the credentials that the url of a store can hold.
163
+ name = path.is_a?(IOStreams::Path) ? path.path : path.inspect
164
+ raise(ArgumentError, "Invalid schema in #{name}: #{e.message}")
165
+ end
166
+ end
167
+
168
+ # Returns [Hash] the descriptor of the schema of the layout of a fixed width file, set with the deprecated
169
+ # `IOStreams::Stream#format_options`, so that the layout held by an application, such as the format options of a
170
+ # Rocket Job category, can be replaced with its schema. The names of its fields are the keys of the layout, so that
171
+ # the records read with it have the same keys. See `IOStreams::Tabular::Parser::Fixed.schema` for the layout,
172
+ # whose columns can have String keys, such as a layout read from JSON.
173
+ #
174
+ # The schema reads and writes the file as IOStreams 3.0 does, which is not always as the layout does: for
175
+ # example, the width of each field counts the characters of the UTF-8 text of the line, where the layout counts its
176
+ # bytes, and a number field that holds no number raises, where the layout reads it as zero. So check that the
177
+ # schema reads the files of the application before it replaces the layout. See "Moving a layout to a schema" in
178
+ # docs/upgrading.md for each difference.
179
+ #
180
+ # Unlike `IOStreams::Stream#format_options`, it reports no deprecation warning, since it replaces the layout, and
181
+ # Rocket Job 7.1 calls it. IOStreams 3.0 removes it, with the layout, so convert each layout with it once, and save
182
+ # the schema that it returns, rather than call it each time.
183
+ #
184
+ # Parameters:
185
+ # format_options:
186
+ # The format options of the layout, as `IOStreams::Stream#format_options` takes them: `layout:`, and
187
+ # optionally `truncate:`, see `IOStreams::Tabular::Parser::Fixed.schema`.
188
+ #
189
+ # Raises `IOStreams::Errors::InvalidLayout` when the layout is not valid, and ArgumentError for a property that a
190
+ # column of a layout does not have, for any other format option, or when its schema is not valid.
191
+ def self.from_layout(**format_options)
192
+ descriptor = IOStreams::Tabular::Parser::Fixed.schema(**format_options)
193
+ new(descriptor)
194
+ descriptor
195
+ end
196
+
197
+ # Returns [String|nil] the text by which a column is compared loosely to the names and aliases of the fields, see
198
+ # #field_for: its letters and digits, in every language, without their case, and with the forms of a character that
199
+ # Unicode treats as the same made the same, such as the full width `ID` and `ID`. So `First Name`, `first_name`,
200
+ # `first.name` and `FIRSTNAME` are the same, as are `E-mail` and `email`.
201
+ #
202
+ # Returns nil when the label has no letters or digits, such as `#`, so that it is not compared loosely, or is not
203
+ # valid text in its encoding.
204
+ def self.match_key(label)
205
+ text = label.is_a?(String) ? label : label.to_s
206
+ return unless text.valid_encoding?
207
+
208
+ key =
209
+ if text.ascii_only?
210
+ text.downcase(:ascii).gsub(UNMATCHED_ASCII, "")
211
+ elsif text.encoding == Encoding::BINARY
212
+ text.downcase(:ascii).gsub(UNMATCHED_BINARY, "")
213
+ else
214
+ text.encode(Encoding::UTF_8).unicode_normalize(:nfkc).downcase(:fold).gsub(UNMATCHED, "")
215
+ end
216
+ key unless key.empty?
217
+ rescue EncodingError
218
+ nil
219
+ end
220
+
221
+ # Parameters:
222
+ # descriptor: [Hash]
223
+ # The schema, see above, whose keys are Strings or Symbols.
224
+ #
225
+ # Raises ArgumentError when the descriptor is not a valid schema.
226
+ def initialize(descriptor)
227
+ raise(ArgumentError, "A schema must be a Hash: #{descriptor.inspect}") unless descriptor.is_a?(Hash)
228
+
229
+ descriptor = descriptor.transform_keys(&:to_s)
230
+ check_properties(descriptor)
231
+ @fields = build_fields(descriptor["fields"])
232
+ @header = check_header(descriptor["header"])
233
+ @fields_match = check_fields_match(descriptor["fieldsMatch"])
234
+ @width = check_width(descriptor["width"])
235
+ @keys = Keys.new(descriptor["keyType"], @fields)
236
+ @keep_extra = check_extra_columns(descriptor["extraColumns"])
237
+ @named = build_named
238
+ @loose = build_loose { |name| self.class.match_key(name) }
239
+ @cleansed = build_loose { |name| self.class.match_key(self.class.cleansed(name)) }
240
+ @decimals = @fields.any?(&:decimal?)
241
+ freeze
242
+ end
243
+
244
+ # Returns [true|false] whether a field is a number that keeps every digit, see `IOStreams::Schema::Field#decimal?`,
245
+ # so that a format that reads numbers itself, such as JSON, reads them without rounding them.
246
+ def decimals?
247
+ @decimals
248
+ end
249
+
250
+ # Returns [true|false] whether the file has a header row, see `header` above.
251
+ def header?
252
+ @header
253
+ end
254
+
255
+ # Returns [String|nil] how the keys of the records read are formed from the names of the fields, see `keyType`
256
+ # above, or nil when they are the names as they are given.
257
+ def key_type
258
+ @keys.type
259
+ end
260
+
261
+ # Returns [String|Symbol] the key in the records read of the field or column with the supplied name, formed as
262
+ # `keyType` says, see `IOStreams::Schema::Keys#key_for`.
263
+ def key_for(name)
264
+ @keys.key_for(name)
265
+ end
266
+
267
+ # Returns [String|Symbol] the key of the field in the records read, see #key_for.
268
+ def key(field)
269
+ @keys[field]
270
+ end
271
+
272
+ # Returns [IOStreams::Schema::Columns] a new matcher of the columns of one file to these fields, which a reader or
273
+ # writer holds while it reads or writes the file.
274
+ def columns
275
+ Columns.new(self)
276
+ end
277
+
278
+ # Returns [IOStreams::Schema::Field] the field that the supplied column of a header row, or key of a record, names:
279
+ # the field whose name, alias or key, see #key, it is, whether it is a String or a Symbol, so that a record written
280
+ # with the keys of the records read is written, otherwise the field whose name or alias
281
+ # is the same once both are compared loosely, see .match_key, so that the field `first_name` is the column
282
+ # `First Name`, and the field `E-mail` is the key `email`, otherwise the field whose name or alias is the same once
283
+ # both are cleansed, see .cleansed, and then compared loosely. Cleansing drops the characters that Unicode makes
284
+ # digits when it compares them loosely, such as `²`, `½` and `①`, so that the field `area_m`, named as
285
+ # `cleanse_header` named the column, is still the column `Area (m²)`, and the field `area_m2` is too.
286
+ #
287
+ # A column that is the same as the names or aliases of several fields once compared loosely, or as none, is the
288
+ # field whose name or alias is the column once cleansed, if there is one, so that a schema whose fields are named as
289
+ # `cleanse_header` named the columns finds every column that it found, such as `E-mail` for the field `e_mail`
290
+ # beside the field `email`.
291
+ #
292
+ # Returns nil when no field is the column, including a column whose bytes are not valid in its encoding, which is
293
+ # only the field whose name it is.
294
+ #
295
+ # Raises `IOStreams::Errors::InvalidHeader` when the column is the same as the names or aliases of several fields
296
+ # once compared loosely, and is not the name or alias of one of them once cleansed, since either could be meant.
297
+ # The noun, such as "column" or "key", names it.
298
+ def field_for(label, noun = "column")
299
+ return if label.nil?
300
+
301
+ @named[label.is_a?(String) ? label : label.to_s] || loose_field(label, noun)
302
+ end
303
+
304
+ # Raises `IOStreams::Errors::InvalidHeader` unless the columns of a header row, or the keys of a record, match the
305
+ # fields as `fieldsMatch` requires, and every required field is among them, see `constraints` in
306
+ # `IOStreams::Schema::Field`.
307
+ #
308
+ # Parameters:
309
+ # found: [Array<IOStreams::Schema::Field>]
310
+ # The fields of the columns, in the order of the columns, see #field_for.
311
+ #
312
+ # unknown: [Array]
313
+ # The columns that are not fields.
314
+ #
315
+ # ordered: [true|false]
316
+ # Whether the order of the columns matters, as it does for a header row, but not for the keys of a record.
317
+ #
318
+ # noun: [String]
319
+ # What the columns are, "column" or "key", for the message.
320
+ def check_columns(found, unknown, ordered:, noun:)
321
+ problems = []
322
+ problems << unknown_problem(unknown, noun) unless unknown.empty? || skip_unknown?
323
+ missing = fields - found
324
+ if every_field? && !missing.empty?
325
+ problems << "missing the #{plural('field', missing)} #{names_of(missing)}, which fieldsMatch #{fields_match.inspect} " \
326
+ "requires; \"superset\" and \"partial\" accept missing fields"
327
+ else
328
+ required = missing.select(&:required?)
329
+ problems << "missing the required #{plural('field', required)} #{names_of(required)}" unless required.empty?
330
+ end
331
+ if fields_match == "partial" && found.empty?
332
+ problems << "no #{noun} is a field of the schema, but fieldsMatch \"partial\" needs at least one"
333
+ end
334
+ if ordered && fields_match == "exact" && problems.empty? && found != fields
335
+ problems << "the #{noun}s are not in the order of the fields, #{names_of(fields)}, which fieldsMatch \"exact\" " \
336
+ "requires; \"equal\" accepts any order"
337
+ end
338
+ raise(IOStreams::Errors::InvalidHeader, "#{subject(noun)} #{problems.join('; ')}") unless problems.empty?
339
+ end
340
+
341
+ # Raises `IOStreams::Errors::InvalidHeader` when a record or row that is written has columns, keys or values that
342
+ # are not fields, unless `fieldsMatch` skips them. A file that is written always has every field, so this is the
343
+ # only part of `fieldsMatch` that applies to writing. Reading also applies it to the values of a row after its
344
+ # last column, see `IOStreams::Schema::Columns#row`.
345
+ #
346
+ # Parameters:
347
+ # unknown: [Array]
348
+ # The keys or columns that are not fields, or the values after the last field or column of a row.
349
+ #
350
+ # noun: [String]
351
+ # What they are, "key", "column" or "value", for the message.
352
+ #
353
+ # last: [String]
354
+ # What the values of a row come after, "field" or "column", for the message.
355
+ #
356
+ # fields_in_order: [true|false]
357
+ # Whether the columns of the file are the fields in order, see #check_fields_in_order, so that the message says
358
+ # why `header: false` does not accept them, rather than suggest a `fieldsMatch` that it does not accept.
359
+ def check_unknown(unknown, noun:, last: "field", fields_in_order: false)
360
+ return if unknown.empty? || skip_unknown?
361
+
362
+ problem = unknown_problem(unknown, noun, last: last, fields_in_order: fields_in_order)
363
+ raise(IOStreams::Errors::InvalidHeader, "#{subject(noun)} #{problem}")
364
+ end
365
+
366
+ # Raises `IOStreams::Errors::InvalidHeader` unless the columns of a header row that is written before its rows, see
367
+ # `IOStreams::Schema::Columns#match_written`, match the fields: a column that is not a field raises, unless
368
+ # `fieldsMatch` skips it, see #check_unknown, and so does a header row without a column that is a field, whatever
369
+ # `fieldsMatch` is, as reading with "partial" needs at least one, since the rows after it would have no value for
370
+ # any field, such as a row of values written as a header row.
371
+ #
372
+ # Parameters:
373
+ # found: [Array<IOStreams::Schema::Field>]
374
+ # The fields of the columns, see #check_columns.
375
+ #
376
+ # unknown: [Array]
377
+ # The columns that are not fields.
378
+ #
379
+ # fields_in_order: [true|false]
380
+ # See #check_unknown.
381
+ def check_written_columns(found, unknown, fields_in_order: false)
382
+ check_unknown(unknown, noun: "column", fields_in_order: fields_in_order)
383
+ return unless found.empty?
384
+
385
+ columns =
386
+ if unknown.empty?
387
+ "it has no columns"
388
+ else
389
+ "#{describe(unknown, 'column')} #{unknown.size == 1 ? 'is not a field' : 'are not fields'} of the schema"
390
+ end
391
+ raise(IOStreams::Errors::InvalidHeader, "#{subject('column')} #{columns}, so the rows after it would have no value " \
392
+ "for any field. A header row names the column of each value of the rows " \
393
+ "after it, so give it the names of the fields, or of their columns, or " \
394
+ "write rows of the values of the fields, in order, without one")
395
+ end
396
+
397
+ # Raises ArgumentError unless the columns of a file can be the fields in order, as they are for a format with a
398
+ # header row, such as CSV, whose file has none, see `header` above: the schema needs fields, and `fieldsMatch`
399
+ # "exact", since a file without a header row has no names to match its columns by. A format without a header row,
400
+ # such as JSON, ignores `header`, so these rules only apply to a format with one, once it is known, see
401
+ # `IOStreams::Schema::Columns#match_fields`.
402
+ def check_fields_in_order
403
+ raise(ArgumentError, "A schema with header: false needs fields, which are the columns of the file") if fields.empty?
404
+ return if fields_match == "exact"
405
+
406
+ raise(ArgumentError, "A schema with header: false has the fields in order, so fieldsMatch must be \"exact\": " \
407
+ "#{fields_match.inspect}")
408
+ end
409
+
410
+ # Returns [true|false] whether the columns that the schema does not list are skipped, rather than raise, see
411
+ # `fieldsMatch` above.
412
+ def skip_unknown?
413
+ SKIPS_UNKNOWN.include?(fields_match)
414
+ end
415
+
416
+ # Returns [true|false] whether reading keeps the columns that the schema does not list, see `extraColumns` above.
417
+ def keep_extra?
418
+ @keep_extra
419
+ end
420
+
421
+ # Returns [true|false] whether reading converts the value of every column that it reads to the type of its field,
422
+ # rather than read it as it is: no field of type "any", apart from one that it skips, which reading leaves out, and no
423
+ # columns kept that the schema does not list, see `extraColumns` above.
424
+ def types_every_column?
425
+ !keep_extra? && fields.none? { |field| field.type == "any" && !field.skip? }
426
+ end
427
+
428
+ private
429
+
430
+ # Returns [true|false] whether every field must be one of the columns.
431
+ def every_field?
432
+ NEEDS_EVERY_FIELD.include?(fields_match)
433
+ end
434
+
435
+ def subject(noun)
436
+ case noun
437
+ when "key" then "The keys of a record do not match the schema:"
438
+ when "value" then "The values of a row do not match the schema:"
439
+ else "The header row does not match the schema:"
440
+ end
441
+ end
442
+
443
+ # Returns [String] why the columns, keys or values are not accepted, and the values of `fieldsMatch` that skip them,
444
+ # unless the columns of the file are the fields in order, see #check_fields_in_order, so that it only accepts
445
+ # "exact".
446
+ def unknown_problem(unknown, noun, last: "field", fields_in_order: false)
447
+ text =
448
+ if noun == "value"
449
+ "#{unknown.size} #{plural('value', unknown)} after the last #{last}"
450
+ else
451
+ "#{describe(unknown, noun)} #{unknown.size == 1 ? 'is not a field' : 'are not fields'} of the schema"
452
+ end
453
+ if fields_in_order
454
+ return "#{text}, which a schema with header: false does not accept, since its columns are the fields in order"
455
+ end
456
+
457
+ "#{text}, which fieldsMatch #{fields_match.inspect} does not accept; \"subset\" and \"partial\" skip them"
458
+ end
459
+
460
+ # Returns [String] the columns, with those without a name counted, since they cannot be named, which are blank, see
461
+ # `IOStreams::Utils.blank_value?`.
462
+ def describe(labels, noun)
463
+ blank, named = labels.partition { |label| IOStreams::Utils.blank_value?(label) }
464
+ parts = []
465
+ parts << "the #{plural(noun, named)} #{sentence(named.map(&:inspect))}" unless named.empty?
466
+ parts << (blank.size == 1 ? "a #{noun} without a name" : "#{blank.size} #{noun}s without a name") unless blank.empty?
467
+ parts.join(" and ")
468
+ end
469
+
470
+ def names_of(fields)
471
+ sentence(fields.map { |field| field.name.to_s.inspect })
472
+ end
473
+
474
+ def plural(noun, items)
475
+ items.size == 1 ? noun : "#{noun}s"
476
+ end
477
+
478
+ def sentence(parts)
479
+ parts.size <= 2 ? parts.join(" and ") : "#{parts[0..-2].join(', ')} and #{parts.last}"
480
+ end
481
+
482
+ # Returns [IOStreams::Schema::Field] the field whose name or alias is the label once both are compared loosely, or
483
+ # else once both are cleansed and compared loosely, see #field_for. When that finds no field, or several, the field
484
+ # whose name or alias is the label once cleansed, if there is one, see .cleansed, as the deprecated `cleanse_header`
485
+ # named the column, so that `E-mail` is the field `e_mail` rather than either of `e_mail` and `email`, and ` _ ` is
486
+ # the field `_`, which has no letters or digits to compare loosely. A label whose bytes are not valid in its
487
+ # encoding is no field, since its text cannot be compared, nor cleansed.
488
+ def loose_field(label, noun)
489
+ text = label.is_a?(String) ? label : label.to_s
490
+ return unless text.valid_encoding?
491
+
492
+ key = self.class.match_key(text)
493
+ fields = key && @loose[key]
494
+ return fields.first if fields&.size == 1
495
+
496
+ cleansed = self.class.cleansed(text)
497
+ fields ||= key && @cleansed[self.class.match_key(cleansed)]
498
+ return fields.first if fields&.size == 1
499
+
500
+ named = @named[cleansed]
501
+ return named if named
502
+ return if fields.nil?
503
+
504
+ raise(
505
+ IOStreams::Errors::InvalidHeader,
506
+ "The #{noun} #{label.inspect} could be any of the fields #{names_of(fields)}, which are the same once compared " \
507
+ "without their case, spaces and punctuation. Rename the #{noun}, or give the field that it is an alias that is " \
508
+ "the #{noun} exactly"
509
+ )
510
+ end
511
+
512
+ def check_properties(descriptor)
513
+ descriptor.each_key do |name|
514
+ next if PROPERTIES.include?(name) || DESCRIPTIVE_PROPERTIES.include?(name) || name.include?(":")
515
+
516
+ raise(ArgumentError, "The schema property #{name.inspect} is not supported") if UNSUPPORTED_PROPERTIES.include?(name)
517
+
518
+ raise(ArgumentError, "Unknown schema property: #{name.inspect}")
519
+ end
520
+ end
521
+
522
+ def build_fields(fields)
523
+ raise(ArgumentError, "A schema must have an Array of fields: #{fields.inspect}") unless fields.is_a?(Array)
524
+
525
+ fields = fields.collect { |field| Field.new(field) }
526
+ names = fields.collect { |field| field.name.to_s }
527
+ duplicates = names.select { |name| names.count(name) > 1 }.uniq
528
+ raise(ArgumentError, "Field names must be unique: #{duplicates.join(', ')}") unless duplicates.empty?
529
+
530
+ fields
531
+ end
532
+
533
+ # Returns [Hash<String, IOStreams::Schema::Field>] the field of each name, alias and key, see #key, as text.
534
+ #
535
+ # Raises ArgumentError for an alias or key that is the name or alias of another field, since a column of that name
536
+ # could be either field.
537
+ def build_named
538
+ named = @fields.to_h { |field| [field.name.to_s, field] }
539
+ @fields.each do |field|
540
+ field.aliases.each { |name| add_named(named, name, field, "the alias #{name.inspect}") }
541
+ next unless key_type
542
+
543
+ name = key(field).to_s
544
+ add_named(named, name, field, "its key #{name.inspect}, which keyType #{key_type.inspect} forms,")
545
+ end
546
+ named
547
+ end
548
+
549
+ def add_named(named, name, field, what)
550
+ other = named[name]
551
+ if other && !other.equal?(field)
552
+ raise(ArgumentError, "Field #{field.name.inspect}: #{what} is already the name or an alias of the field " \
553
+ "#{other.name.inspect}")
554
+ end
555
+
556
+ named[name] = field
557
+ end
558
+
559
+ # Returns [Hash<String, Array<IOStreams::Schema::Field>>] the fields whose names or aliases are the same when compared
560
+ # loosely, by the text that the block returns for each name or alias, see .match_key. Several fields can share one,
561
+ # such as `Amount ($)` and `Amount (€)`, whose names are still matched exactly.
562
+ def build_loose
563
+ loose = {}
564
+ @fields.each do |field|
565
+ [field.name, *field.aliases].each do |name|
566
+ key = yield(name)
567
+ next unless key
568
+
569
+ list = (loose[key] ||= [])
570
+ list << field unless list.include?(field)
571
+ end
572
+ end
573
+ loose
574
+ end
575
+
576
+ def check_header(header)
577
+ return true if header.nil?
578
+ raise(ArgumentError, "The schema header must be true or false: #{header.inspect}") unless [true, false].include?(header)
579
+
580
+ header
581
+ end
582
+
583
+ def check_fields_match(fields_match)
584
+ return "exact" if fields_match.nil?
585
+
586
+ unless FIELDS_MATCH.include?(fields_match)
587
+ raise(ArgumentError, "The schema fieldsMatch must be one of #{sentence(FIELDS_MATCH.map(&:inspect))}: " \
588
+ "#{fields_match.inspect}")
589
+ end
590
+
591
+ fields_match
592
+ end
593
+
594
+ def check_extra_columns(extra_columns)
595
+ return false if extra_columns.nil?
596
+
597
+ unless EXTRA_COLUMNS.include?(extra_columns)
598
+ raise(ArgumentError, "The schema extraColumns must be one of #{sentence(EXTRA_COLUMNS.map(&:inspect))}: " \
599
+ "#{extra_columns.inspect}")
600
+ end
601
+ return false if extra_columns == "skip"
602
+ return true if skip_unknown?
603
+
604
+ raise(ArgumentError, "The schema extraColumns \"keep\" keeps the columns that the schema does not list, which " \
605
+ "fieldsMatch #{fields_match.inspect} does not accept; \"subset\" and \"partial\" accept them")
606
+ end
607
+
608
+ def check_width(width)
609
+ return if width.nil?
610
+ return :remainder if [:remainder, "remainder"].include?(width)
611
+ return width if width.is_a?(Integer) && width.positive?
612
+
613
+ raise(ArgumentError, "The schema width must be a positive Integer or \"remainder\": #{width.inspect}")
614
+ end
615
+ end
616
+ end