iostreams 2.1.0 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/docs/config.md +13 -9
- data/docs/copy_files.md +20 -2
- data/docs/extensions.md +18 -4
- data/docs/formats.md +644 -16
- data/docs/path.md +18 -2
- data/docs/streams.md +54 -9
- data/docs/tutorial.md +14 -2
- data/docs/upgrading.md +470 -23
- data/lib/io_streams/builder/deprecations.rb +250 -0
- data/lib/io_streams/builder.rb +256 -39
- data/lib/io_streams/bzip2/reader.rb +18 -2
- data/lib/io_streams/bzip2/writer.rb +11 -2
- data/lib/io_streams/deprecation/methods.rb +51 -0
- data/lib/io_streams/deprecation/once.rb +27 -0
- data/lib/io_streams/deprecation/option_names.rb +26 -0
- data/lib/io_streams/deprecation/words.rb +42 -0
- data/lib/io_streams/deprecation.rb +259 -0
- data/lib/io_streams/encode/reader.rb +54 -3
- data/lib/io_streams/encode/writer.rb +19 -2
- data/lib/io_streams/encode.rb +99 -0
- data/lib/io_streams/errors.rb +51 -0
- data/lib/io_streams/gzip/reader.rb +11 -0
- data/lib/io_streams/io_streams.rb +365 -35
- data/lib/io_streams/line/reader.rb +2 -1
- data/lib/io_streams/line/utf8_reader.rb +23 -0
- data/lib/io_streams/path.rb +125 -35
- data/lib/io_streams/paths/file.rb +72 -9
- data/lib/io_streams/paths/http.rb +48 -2
- data/lib/io_streams/paths/matcher.rb +6 -3
- data/lib/io_streams/paths/s3.rb +180 -9
- data/lib/io_streams/paths/sftp.rb +47 -3
- data/lib/io_streams/paths.rb +23 -0
- data/lib/io_streams/pgp/reader.rb +18 -0
- data/lib/io_streams/pgp/writer.rb +16 -0
- data/lib/io_streams/pgp.rb +16 -2
- data/lib/io_streams/reader.rb +44 -0
- data/lib/io_streams/record/reader.rb +59 -68
- data/lib/io_streams/record/writer.rb +17 -10
- data/lib/io_streams/row/header_row.rb +21 -0
- data/lib/io_streams/row/reader.rb +52 -15
- data/lib/io_streams/row/writer.rb +64 -13
- data/lib/io_streams/schema/columns.rb +456 -0
- data/lib/io_streams/schema/constraints.rb +99 -0
- data/lib/io_streams/schema/field.rb +967 -0
- data/lib/io_streams/schema/json_number.rb +56 -0
- data/lib/io_streams/schema/keys.rb +78 -0
- data/lib/io_streams/schema/pattern.rb +257 -0
- data/lib/io_streams/schema/source.rb +31 -0
- data/lib/io_streams/schema.rb +616 -0
- data/lib/io_streams/stream.rb +836 -67
- data/lib/io_streams/symmetric_encryption/reader.rb +8 -0
- data/lib/io_streams/tabular/header.rb +29 -0
- data/lib/io_streams/tabular/header_mapping.rb +332 -0
- data/lib/io_streams/tabular/mapping.rb +165 -0
- data/lib/io_streams/tabular/parser/array.rb +5 -0
- data/lib/io_streams/tabular/parser/base.rb +24 -0
- data/lib/io_streams/tabular/parser/fixed.rb +197 -0
- data/lib/io_streams/tabular/parser/fixed_fields.rb +160 -0
- data/lib/io_streams/tabular/parser/hash.rb +10 -0
- data/lib/io_streams/tabular/parser/json.rb +17 -0
- data/lib/io_streams/tabular/parser/json_fields.rb +132 -0
- data/lib/io_streams/tabular/schema_mapping.rb +361 -0
- data/lib/io_streams/tabular.rb +344 -30
- data/lib/io_streams/utils.rb +441 -6
- data/lib/io_streams/version.rb +1 -1
- data/lib/io_streams/writer.rb +27 -0
- data/lib/io_streams/xlsx/cells.rb +127 -0
- data/lib/io_streams/xlsx/reader.rb +56 -0
- data/lib/io_streams/xlsx/schema_reader.rb +90 -0
- data/lib/io_streams/xlsx/v3_writer.rb +16 -0
- data/lib/io_streams/zip/reader.rb +7 -0
- data/lib/io_streams/zip/writer.rb +24 -2
- data/lib/iostreams.rb +21 -17
- metadata +28 -2
|
@@ -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
|