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,967 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
require "json"
|
|
5
|
+
require "time"
|
|
6
|
+
|
|
7
|
+
module IOStreams
|
|
8
|
+
class Schema
|
|
9
|
+
# A field of a schema: its name and type, the text form of its values, and for a fixed width file its position.
|
|
10
|
+
#
|
|
11
|
+
# Properties of a field, as Frictionless defines them:
|
|
12
|
+
# name: [String|Symbol]
|
|
13
|
+
# The key of the field in each record, and its column in the header row that is written. Required.
|
|
14
|
+
# The records read have the names as they are given as their keys, so a Symbol name returns a Symbol key,
|
|
15
|
+
# whatever the column of the file is, see `IOStreams::Schema#field_for`.
|
|
16
|
+
#
|
|
17
|
+
# constraints: [Hash]
|
|
18
|
+
# The rules that the values of the field must follow, such as `{required: true}` for a field that must have a
|
|
19
|
+
# value, both when reading and when writing, see `IOStreams::Schema::Constraints`, unless it allows blank
|
|
20
|
+
# values, see `allowBlank` below.
|
|
21
|
+
#
|
|
22
|
+
# type: [String]
|
|
23
|
+
# "string": a String. Any other value is written as its text, except a Hash or an Array, which raise.
|
|
24
|
+
# "integer": an Integer, such as `-12` or `0042`.
|
|
25
|
+
# "number": a BigDecimal, such as `12.50`, `-1.5E3`, `NaN`, `INF` or `-INF`, or a Float with `floatNumber`.
|
|
26
|
+
# `Infinity` and `-Infinity`, as Ruby and JavaScript write them, are read too, whatever their case,
|
|
27
|
+
# as frictionless-py also reads them, while one that is not finite is written as the Frictionless
|
|
28
|
+
# spec spells it, `NaN`, `INF` or `-INF`, except to JSON, which cannot hold it, so that it raises.
|
|
29
|
+
# A whole number is written without a decimal point, such as `42`, unless `decimals` is set.
|
|
30
|
+
# "boolean": true or false, see `trueValues` and `falseValues`, or the number 1 or 0, or 1.0 or 0.0, of JSON,
|
|
31
|
+
# which IOStreams adds, since Frictionless reads only text, and true or false.
|
|
32
|
+
# "date": a Date, written as `2026-10-13` unless `format` is set. A Time or DateTime is its date, dropping its
|
|
33
|
+
# time of day, which is deprecated for one that is not at midnight, by its own clock, since v3.0 raises
|
|
34
|
+
# for it rather than lose its time of day, see #cast.
|
|
35
|
+
# "datetime": a Time, written as `2026-10-13T09:30:00Z` unless `format` is set. Without a format it reads ISO 8601
|
|
36
|
+
# with seconds, with a space in place of the T, such as `2026-10-13 09:30:00`, and an offset from UTC
|
|
37
|
+
# with or without a colon, or of hours alone, such as `+05:30`, `+0530` or `+05`, see #cast_datetime.
|
|
38
|
+
# "object": a Hash, read from the text of a JSON object, such as `{"a":1}`, and written as that text, or by a
|
|
39
|
+
# format of JSON as an object. A Hash is what JSON holds of it, see .json_data, so that its keys are
|
|
40
|
+
# Strings, which IOStreams adds, since Frictionless keeps a dict as it is.
|
|
41
|
+
# "array": an Array, read from the text of a JSON array, such as `[1,2]`, and written as that text, or by a
|
|
42
|
+
# format of JSON as an array. An Array is what JSON holds of it, see .json_data.
|
|
43
|
+
# "any": the value as it is read, without converting it.
|
|
44
|
+
# Default: "any"
|
|
45
|
+
# Empty text is a missing value, nil, in every type, and text of only whitespace raises in every type but a
|
|
46
|
+
# string or any, which keep it, see #cast.
|
|
47
|
+
#
|
|
48
|
+
# format: [String]
|
|
49
|
+
# For a date or datetime, the strptime pattern of its text, such as "%d/%m/%Y" or "%Y%m%d%H%M%S", in the syntax
|
|
50
|
+
# of Python's strptime, whose `%Y` reads exactly 4 digits and `%f` microseconds, see
|
|
51
|
+
# `IOStreams::Schema::Pattern`. A pattern that starts with `fmt:`, as Data Package v0 wrote it, such as
|
|
52
|
+
# "fmt:%d/%m/%Y", has that prefix removed, as Frictionless says an implementation must, so that "fmt:default" is
|
|
53
|
+
# the default form.
|
|
54
|
+
# Default: "default", the ISO 8601 form above.
|
|
55
|
+
#
|
|
56
|
+
# trueValues: [Array<String>]
|
|
57
|
+
# For a boolean, the text that is true. The first one is written. Default: ["true", "True", "TRUE", "1"]
|
|
58
|
+
#
|
|
59
|
+
# falseValues: [Array<String>]
|
|
60
|
+
# For a boolean, the text that is false. The first one is written. Default: ["false", "False", "FALSE", "0"]
|
|
61
|
+
#
|
|
62
|
+
# floatNumber: [true|false]
|
|
63
|
+
# For a number, whether to return a Float instead of a BigDecimal, as frictionless-py does.
|
|
64
|
+
# Default: false
|
|
65
|
+
#
|
|
66
|
+
# Properties that IOStreams adds:
|
|
67
|
+
# aliases: [Array<String>]
|
|
68
|
+
# The other names of the column of this field, such as `["DOB", "Birth Date"]` for the field `date_of_birth`, for
|
|
69
|
+
# files whose columns are not named the same way, such as spreadsheets made by hand. A column is matched to an
|
|
70
|
+
# alias as it is to the name, see `IOStreams::Schema#field_for`. The header row that is written has the name.
|
|
71
|
+
# Default: []
|
|
72
|
+
#
|
|
73
|
+
# allowBlank: [true|false]
|
|
74
|
+
# For a field whose `constraints` are `required`, whether its values can be blank: nil, or empty text, while text
|
|
75
|
+
# of only whitespace is not blank, so it is still read as the type of the field, see #cast. The header row, or
|
|
76
|
+
# each JSON record, must still have the field, as the deprecated `required_columns:` checked, but a value is not
|
|
77
|
+
# required, both when reading and when writing. A property of the field rather than a constraint,
|
|
78
|
+
# since Frictionless defines `required` as a boolean, and a tool reports an error for a constraint that it does
|
|
79
|
+
# not support, while it accepts other properties of a field. Raises ArgumentError for a field that is not
|
|
80
|
+
# required, whose values can be blank anyway.
|
|
81
|
+
# Default: false
|
|
82
|
+
#
|
|
83
|
+
# decimals: [Integer]
|
|
84
|
+
# For a number, the number of digits after the decimal point, rounding the value to them, both when reading and
|
|
85
|
+
# when writing, so that a record read holds the number that is written, see #round.
|
|
86
|
+
# Default: as many as the value has.
|
|
87
|
+
#
|
|
88
|
+
# width: [Integer|"remainder"]
|
|
89
|
+
# For a fixed width file, the number of characters of the field. "remainder" takes the rest of the line,
|
|
90
|
+
# for the last field only. Formats with delimiters ignore it.
|
|
91
|
+
#
|
|
92
|
+
# start: [Integer]
|
|
93
|
+
# For a fixed width file, the position of the first character of the field, counting from 1, as the
|
|
94
|
+
# specifications of fixed width files do. It skips the characters after the field before it, such as a filler.
|
|
95
|
+
# Default: right after the field before it.
|
|
96
|
+
#
|
|
97
|
+
# truncate: [true|false]
|
|
98
|
+
# For a string in a fixed width file, whether to write only the first `width` characters of a longer value,
|
|
99
|
+
# instead of raising `IOStreams::Errors::ValueTooLong`.
|
|
100
|
+
# Default: false
|
|
101
|
+
#
|
|
102
|
+
# skip: [true|false]
|
|
103
|
+
# Whether the field only holds the place of its column, such as a column of a file without a header row that
|
|
104
|
+
# the code does not read, as a `nil` in the deprecated `columns:` did: reading leaves it out of the rows and
|
|
105
|
+
# records, and writing writes an empty value in its column, or leaves it out of a JSON record. Its column is
|
|
106
|
+
# still a field when the columns are matched, see `fieldsMatch` in `IOStreams::Schema`. Since its values are
|
|
107
|
+
# not read, it only takes the properties that place its column, `name`, `aliases`, `width` and `start`, and
|
|
108
|
+
# the type "any". IOStreams adds this property.
|
|
109
|
+
# Default: false
|
|
110
|
+
#
|
|
111
|
+
# Properties that only describe the field to people, `title`, `description`, `example` and `rdfType`, are
|
|
112
|
+
# accepted and have no effect, as are the properties of other tools, whose names include a `:`.
|
|
113
|
+
#
|
|
114
|
+
# Any other property raises `ArgumentError`, as does a type that IOStreams does not support yet, such as "time",
|
|
115
|
+
# and the Frictionless properties that it does not support yet, such as `missingValues`, so that a schema never
|
|
116
|
+
# appears to check a value that it does not.
|
|
117
|
+
class Field
|
|
118
|
+
# The types of field that IOStreams supports.
|
|
119
|
+
TYPES = %w[any array boolean date datetime integer number object string].freeze
|
|
120
|
+
|
|
121
|
+
# The properties that each type of field accepts, in addition to those that every field accepts.
|
|
122
|
+
TYPE_PROPERTIES = {
|
|
123
|
+
"boolean" => %w[trueValues falseValues],
|
|
124
|
+
"date" => %w[format],
|
|
125
|
+
"datetime" => %w[format],
|
|
126
|
+
"number" => %w[decimals floatNumber],
|
|
127
|
+
"string" => %w[truncate]
|
|
128
|
+
}.freeze
|
|
129
|
+
|
|
130
|
+
# The properties that every field accepts.
|
|
131
|
+
PROPERTIES = %w[name type format width start constraints aliases allowBlank skip].freeze
|
|
132
|
+
|
|
133
|
+
# The properties of a field that is skipped, see `skip` above, which place its column.
|
|
134
|
+
SKIP_PROPERTIES = %w[name type width start aliases skip].freeze
|
|
135
|
+
|
|
136
|
+
# The properties of a field that only describe it to people.
|
|
137
|
+
DESCRIPTIVE_PROPERTIES = %w[title description example rdfType].freeze
|
|
138
|
+
|
|
139
|
+
# The properties of a Frictionless field that IOStreams does not support yet.
|
|
140
|
+
UNSUPPORTED_PROPERTIES = %w[missingValues categories categoriesOrdered decimalChar groupChar
|
|
141
|
+
bareNumber delimiter itemType].freeze
|
|
142
|
+
|
|
143
|
+
DEFAULT_TRUE_VALUES = %w[true True TRUE 1].freeze
|
|
144
|
+
DEFAULT_FALSE_VALUES = %w[false False FALSE 0].freeze
|
|
145
|
+
|
|
146
|
+
# The text of the numbers that are not finite, whose case does not matter: `NaN`, `INF` and `-INF`, as the
|
|
147
|
+
# Frictionless spec spells them, and `Infinity` and `-Infinity`, as Ruby and JavaScript write them, which
|
|
148
|
+
# frictionless-py also reads.
|
|
149
|
+
SPECIAL_NUMBERS = {
|
|
150
|
+
"nan" => Float::NAN,
|
|
151
|
+
"inf" => Float::INFINITY,
|
|
152
|
+
"-inf" => -Float::INFINITY,
|
|
153
|
+
"infinity" => Float::INFINITY,
|
|
154
|
+
"-infinity" => -Float::INFINITY
|
|
155
|
+
}.freeze
|
|
156
|
+
|
|
157
|
+
# The types whose values are numbers. A constant, so that using it does not create an Array each time, as an
|
|
158
|
+
# Array literal does.
|
|
159
|
+
NUMERIC_TYPES = %w[integer number].freeze
|
|
160
|
+
|
|
161
|
+
# The most Hashes and Arrays within each other that the value of an object or array field can have, as the json
|
|
162
|
+
# gem's `max_nesting` reads and writes them, so that a deeper one, such as a Hash that holds itself, raises
|
|
163
|
+
# `IOStreams::Errors::InvalidValue`, rather than overflow the stack, see .json_data.
|
|
164
|
+
MAX_NESTING = 100
|
|
165
|
+
|
|
166
|
+
INTEGER = /\A[+-]?\d+\z/
|
|
167
|
+
# A whole number with a decimal point, such as "12.0", since a spreadsheet stores every number as a double, and the
|
|
168
|
+
# layout of a fixed width file, see `IOStreams::Stream#format_options`, reads the column with `to_i`.
|
|
169
|
+
WHOLE = /\A([+-]?\d+)\.0*\z/
|
|
170
|
+
NUMBER = /\A[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?\z/
|
|
171
|
+
# A decimal point without digits after it, such as in "12.", which NUMBER accepts, as Ruby 3.4 does, but
|
|
172
|
+
# `Float()` and `BigDecimal()` raise for before Ruby 3.4.
|
|
173
|
+
BARE_POINT = /\.(?=[eE]|\z)/
|
|
174
|
+
DATE = /\A\d{4}-\d{2}-\d{2}\z/
|
|
175
|
+
# The text of a datetime without a format: ISO 8601 with seconds, on a clock, its hour below 24 and its minute and
|
|
176
|
+
# second below 60, rather than the `24:00:00` that ISO 8601 allows for the end of a day, which `Time.iso8601` reads
|
|
177
|
+
# as the midnight of the next day, with a T or a space between its date and its time, and an offset from UTC, Z, or
|
|
178
|
+
# the hours, with or without minutes, below 60, with or without a colon between them, such as `+05:30`, `+0530` or
|
|
179
|
+
# `+05`, as PostgreSQL writes it, or none, which is UTC. The date, the time and the offset are captured.
|
|
180
|
+
DATETIME = /\A(\d{4}-\d{2}-\d{2})[T ]((?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?)(Z|[+-]\d{2}(?::?[0-5]\d)?)?\z/
|
|
181
|
+
# The zones of text that are UTC, rather than an offset of zero, such as `+00:00` or `+0000`, which a time keeps,
|
|
182
|
+
# see #zero_offset? and #at_offset: `Z`, `UTC`, `UT`, and `-00:00`, `-0000` or `-00`, which RFC 3339 and RFC 2822
|
|
183
|
+
# give a time in UTC whose local offset is not known.
|
|
184
|
+
UTC_ZONE = /\A(?:Z|UTC?|-00(?::?00)?)\z/i
|
|
185
|
+
|
|
186
|
+
# Returns [String|Symbol] the name of the field, as it was given.
|
|
187
|
+
attr_reader :name
|
|
188
|
+
|
|
189
|
+
# Returns [String] the type of the field, such as "integer".
|
|
190
|
+
attr_reader :type
|
|
191
|
+
|
|
192
|
+
# Returns [Integer|:remainder|nil] the number of characters of the field in a fixed width file.
|
|
193
|
+
attr_reader :width
|
|
194
|
+
|
|
195
|
+
# Returns [Integer|nil] the position of the first character of the field in a fixed width file, counting from 1.
|
|
196
|
+
attr_reader :start
|
|
197
|
+
|
|
198
|
+
# Returns [Integer|nil] for a number, the number of digits after the decimal point, see `decimals` above.
|
|
199
|
+
attr_reader :decimals
|
|
200
|
+
|
|
201
|
+
# Returns [Array<String>] the other names of the column of this field, see `aliases` above.
|
|
202
|
+
attr_reader :aliases
|
|
203
|
+
|
|
204
|
+
# Parameters:
|
|
205
|
+
# descriptor: [Hash]
|
|
206
|
+
# The properties of the field, see above, whose keys are Strings or Symbols.
|
|
207
|
+
#
|
|
208
|
+
# Raises ArgumentError when the descriptor is not a valid field.
|
|
209
|
+
def initialize(descriptor) # rubocop:disable Metrics/AbcSize -- one check for each property of a field
|
|
210
|
+
raise(ArgumentError, "A schema field must be a Hash: #{descriptor.inspect}") unless descriptor.is_a?(Hash)
|
|
211
|
+
|
|
212
|
+
descriptor = descriptor.transform_keys(&:to_s)
|
|
213
|
+
@name = descriptor["name"]
|
|
214
|
+
unless @name.is_a?(String) || @name.is_a?(Symbol)
|
|
215
|
+
raise(ArgumentError, "A schema field must have a name: #{descriptor.inspect}")
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# A frozen copy, so that changing the name supplied does not change the field, nor the field the name supplied,
|
|
219
|
+
# and a record read uses it as its key without copying it.
|
|
220
|
+
@name = -@name if @name.is_a?(String)
|
|
221
|
+
|
|
222
|
+
@type = (descriptor["type"] || "any").to_s
|
|
223
|
+
check_type
|
|
224
|
+
@skip = check_skip(descriptor)
|
|
225
|
+
check_properties(descriptor)
|
|
226
|
+
@format = check_format(descriptor["format"])
|
|
227
|
+
@utc = @type == "datetime" && !@format.nil? && !@format.zone?
|
|
228
|
+
@width = check_width(descriptor["width"])
|
|
229
|
+
@start = check_positive("start", descriptor["start"])
|
|
230
|
+
@truncate = check_flag("truncate", descriptor["truncate"])
|
|
231
|
+
@float = check_flag("floatNumber", descriptor["floatNumber"])
|
|
232
|
+
@decimals = check_decimals(descriptor["decimals"])
|
|
233
|
+
@true_values = check_values("trueValues", descriptor["trueValues"], DEFAULT_TRUE_VALUES)
|
|
234
|
+
@false_values = check_values("falseValues", descriptor["falseValues"], DEFAULT_FALSE_VALUES)
|
|
235
|
+
@aliases = check_values("aliases", descriptor["aliases"], [].freeze, empty: true)
|
|
236
|
+
@constraints = Constraints.new(descriptor["constraints"], @name,
|
|
237
|
+
allow_blank: check_flag("allowBlank", descriptor["allowBlank"]))
|
|
238
|
+
require_bigdecimal if @type == "number" && !@float
|
|
239
|
+
# Reports the first time that is not at midnight, see #midnight, which the field is frozen without.
|
|
240
|
+
@time_of_day = Deprecation::Once.new if @type == "date"
|
|
241
|
+
freeze
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# Returns [Time] the time of the DateTime, at its offset from UTC, in UTC when its offset is zero, since a DateTime
|
|
245
|
+
# does not tell UTC from another zone at that offset, so that it is written as a Time in UTC is, with `Z`.
|
|
246
|
+
#
|
|
247
|
+
# Unlike `DateTime#to_time` in ActiveSupport 7, which converts it to the local time zone of the server, unless
|
|
248
|
+
# `ActiveSupport.to_time_preserves_timezone` is set.
|
|
249
|
+
def self.time_of(datetime)
|
|
250
|
+
seconds = datetime.sec + datetime.sec_fraction
|
|
251
|
+
if datetime.offset.zero?
|
|
252
|
+
return Time.utc(datetime.year, datetime.month, datetime.day, datetime.hour, datetime.minute, seconds)
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
Time.new(datetime.year, datetime.month, datetime.day, datetime.hour, datetime.minute, seconds,
|
|
256
|
+
(datetime.offset * 86_400).to_i)
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# Returns [String] the text of a value whose type is not known, as a field of type "any" writes it: a BigDecimal
|
|
260
|
+
# as a plain decimal, such as "12.5" rather than "0.125e2", a Float or BigDecimal that is not finite as the
|
|
261
|
+
# Frictionless spec spells it, "NaN", "INF" or "-INF", see .special_text, a Time or DateTime in ISO 8601, such as
|
|
262
|
+
# "2026-10-13T09:30:00Z", with as many digits of its fraction of a second as it needs, a Date in ISO 8601, such as
|
|
263
|
+
# "2026-10-13", a Hash or an Array as JSON, such as `{"a":1}` or `[1,2]`, which is what the JSON format writes
|
|
264
|
+
# for it, see .any_json, and any other value with `to_s`.
|
|
265
|
+
#
|
|
266
|
+
# Formats of text, such as CSV, write each value that has no field this way, as they do every value of a file
|
|
267
|
+
# without a schema.
|
|
268
|
+
#
|
|
269
|
+
# Raises `IOStreams::Errors::InvalidValue` for a Hash or an Array that holds a Float that JSON cannot hold, such as
|
|
270
|
+
# `Float::NAN`, see .any_json, which a field raises naming itself, see #json_text.
|
|
271
|
+
def self.any_text(value)
|
|
272
|
+
return value if value.is_a?(String)
|
|
273
|
+
# The most common values first, so that they skip the checks below.
|
|
274
|
+
return value.to_s if value.is_a?(Integer)
|
|
275
|
+
return value.finite? ? value.to_s : special_text(value) if value.is_a?(Float)
|
|
276
|
+
# `is_a?` rather than `case`, which an `ActiveSupport::TimeWithZone` does not match.
|
|
277
|
+
return time_text(value) if value.is_a?(Time)
|
|
278
|
+
return time_text(time_of(value)) if value.is_a?(DateTime)
|
|
279
|
+
return value.iso8601 if value.is_a?(Date)
|
|
280
|
+
return value.finite? ? value.to_s("F") : special_text(value) if defined?(::BigDecimal) && value.is_a?(::BigDecimal)
|
|
281
|
+
# The JSON that the JSON format writes, rather than `to_s`, whose text only Ruby reads, and which Ruby 3.4 changed.
|
|
282
|
+
return any_json(value).to_json if value.is_a?(::Hash) || value.is_a?(::Array)
|
|
283
|
+
|
|
284
|
+
value.to_s
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# Returns [String] the text of a number that is not finite, as the Frictionless spec spells it: "NaN", "INF" or
|
|
288
|
+
# "-INF", rather than Ruby's "Infinity".
|
|
289
|
+
def self.special_text(number)
|
|
290
|
+
return "NaN" if number.nan?
|
|
291
|
+
|
|
292
|
+
number.positive? ? "INF" : "-INF"
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# Returns the value as JSON holds it when its type is not known, as a field of type "any" writes it: a
|
|
296
|
+
# BigDecimal, Date, Time or DateTime as its text, see .any_text, so that a BigDecimal keeps all of its digits, and
|
|
297
|
+
# every other value as it is, except a `JSON::Fragment`, the text of a JSON value, such as a number that a field
|
|
298
|
+
# writes with all of its digits, see #render, which is an `IOStreams::Schema::JsonNumber`, so that a library that
|
|
299
|
+
# replaces how Ruby writes JSON, such as ActiveSupport, writes it as it is too. The values within a Hash or an
|
|
300
|
+
# Array are converted too, in a copy, and the value itself is returned when none of them needs it.
|
|
301
|
+
#
|
|
302
|
+
# Raises `IOStreams::Errors::InvalidValue` for a Float that is not finite, such as `Float::NAN`, which JSON cannot
|
|
303
|
+
# hold, also within a Hash or an Array, rather than leave it to the JSON writer, where the json gem raises its own
|
|
304
|
+
# exception, and ActiveSupport writes null. A field names itself in the message, see #any_json and #json_text.
|
|
305
|
+
def self.any_json(value)
|
|
306
|
+
case value
|
|
307
|
+
when ::Hash then json_hash(value)
|
|
308
|
+
when ::Array then json_array(value)
|
|
309
|
+
when String, Integer, true, false, nil then value
|
|
310
|
+
when Float then finite_json(value)
|
|
311
|
+
else
|
|
312
|
+
return any_text(value) if textual?(value)
|
|
313
|
+
return JsonNumber.new(value.json) if defined?(::JSON::Fragment) && value.is_a?(::JSON::Fragment)
|
|
314
|
+
|
|
315
|
+
value
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
# Returns [Float] the number, when it is finite.
|
|
320
|
+
#
|
|
321
|
+
# Raises `IOStreams::Errors::InvalidValue` for a number that is not finite, such as NaN, which JSON cannot hold.
|
|
322
|
+
def self.finite_json(number)
|
|
323
|
+
return number if number.finite?
|
|
324
|
+
|
|
325
|
+
raise(IOStreams::Errors::InvalidValue, "JSON cannot hold a number that is not finite: #{special_text(number)}")
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
# Returns [Hash] a copy of the hash with each of its values converted, see .any_json, or the hash itself when none
|
|
329
|
+
# of them needs it.
|
|
330
|
+
def self.json_hash(hash)
|
|
331
|
+
copy = nil
|
|
332
|
+
hash.each_pair do |key, item|
|
|
333
|
+
# Skips the call for a value that JSON holds as it is, see .any_json.
|
|
334
|
+
next if item.nil? || item.is_a?(String) || item.is_a?(Integer) || (item.is_a?(Float) && item.finite?) ||
|
|
335
|
+
item == true || item == false
|
|
336
|
+
|
|
337
|
+
json = any_json(item)
|
|
338
|
+
(copy ||= hash.dup)[key] = json unless json.equal?(item)
|
|
339
|
+
end
|
|
340
|
+
copy || hash
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
# Returns [Array] a copy of the array with each of its values converted, see .any_json, or the array itself when
|
|
344
|
+
# none of them needs it.
|
|
345
|
+
def self.json_array(array)
|
|
346
|
+
copy = nil
|
|
347
|
+
array.each_with_index do |item, index|
|
|
348
|
+
# Skips the call for a value that JSON holds as it is, see .any_json.
|
|
349
|
+
next if item.nil? || item.is_a?(String) || item.is_a?(Integer) || (item.is_a?(Float) && item.finite?) ||
|
|
350
|
+
item == true || item == false
|
|
351
|
+
|
|
352
|
+
json = any_json(item)
|
|
353
|
+
(copy ||= array.dup)[index] = json unless json.equal?(item)
|
|
354
|
+
end
|
|
355
|
+
copy || array
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
# Returns the value as JSON holds it, so that an object or array field reads the same value from a format whose
|
|
359
|
+
# rows are Ruby objects, such as `:hash`, as from JSON, or the text of JSON: a Hash with String keys, such as "a"
|
|
360
|
+
# for the key `:a`, a Symbol as its text, a BigDecimal, Date, Time or DateTime as the text that IOStreams writes
|
|
361
|
+
# for it to JSON, see .any_json, such as "12.5" or "2026-10-13", and any other value as it is, so that a value of
|
|
362
|
+
# another class, such as a Rational, can differ from the value that its JSON text reads. The values within a Hash
|
|
363
|
+
# or an Array are converted too, in a copy, and the value itself is returned when none of them needs it.
|
|
364
|
+
#
|
|
365
|
+
# Raises `JSON::NestingError`, which #cast_json rescues, for Hashes and Arrays nested more than MAX_NESTING deep,
|
|
366
|
+
# such as one that holds itself.
|
|
367
|
+
def self.json_data(value, depth = 0)
|
|
368
|
+
case value
|
|
369
|
+
when ::Hash then data_hash(value, depth + 1)
|
|
370
|
+
when ::Array then data_array(value, depth + 1)
|
|
371
|
+
when String, Integer, Float, true, false, nil then value
|
|
372
|
+
when Symbol then value.to_s
|
|
373
|
+
else textual?(value) ? any_text(value) : value
|
|
374
|
+
end
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
# Returns [Hash] a copy of the hash with String keys and each of its values converted, see .json_data, or the hash
|
|
378
|
+
# itself when none of them needs it.
|
|
379
|
+
def self.data_hash(hash, depth)
|
|
380
|
+
nesting(depth)
|
|
381
|
+
return hash.to_h { |key, item| [key.to_s, json_data(item, depth)] } unless hash.each_key.all?(String)
|
|
382
|
+
|
|
383
|
+
copy = nil
|
|
384
|
+
hash.each_pair do |key, item|
|
|
385
|
+
# The values that JSON holds as they are, without a call of .json_data for each one.
|
|
386
|
+
case item
|
|
387
|
+
when String, Integer, Float, true, false, nil then next
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
data = json_data(item, depth)
|
|
391
|
+
(copy ||= hash.dup)[key] = data unless data.equal?(item)
|
|
392
|
+
end
|
|
393
|
+
copy || hash
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
# Returns [Array] a copy of the array with each of its values converted, see .json_data, or the array itself when
|
|
397
|
+
# none of them needs it.
|
|
398
|
+
def self.data_array(array, depth)
|
|
399
|
+
nesting(depth)
|
|
400
|
+
copy = nil
|
|
401
|
+
array.each_with_index do |item, index|
|
|
402
|
+
# The values that JSON holds as they are, without a call of .json_data for each one.
|
|
403
|
+
case item
|
|
404
|
+
when String, Integer, Float, true, false, nil then next
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
data = json_data(item, depth)
|
|
408
|
+
(copy ||= array.dup)[index] = data unless data.equal?(item)
|
|
409
|
+
end
|
|
410
|
+
copy || array
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
# Raises `JSON::NestingError` when a Hash or an Array is nested deeper than MAX_NESTING.
|
|
414
|
+
def self.nesting(depth)
|
|
415
|
+
raise(JSON::NestingError, "nesting of #{depth} is too deep") if depth > MAX_NESTING
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
# Returns [true|false] whether JSON holds the value as its text, see .any_json.
|
|
419
|
+
def self.textual?(value)
|
|
420
|
+
value.is_a?(Time) || value.is_a?(Date) || (defined?(::BigDecimal) && value.is_a?(::BigDecimal))
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
# Returns [String] the time in ISO 8601, with as many digits of its fraction of a second as it needs.
|
|
424
|
+
def self.time_text(value)
|
|
425
|
+
nanoseconds = value.nsec
|
|
426
|
+
digits =
|
|
427
|
+
if nanoseconds.zero? then 0
|
|
428
|
+
elsif (nanoseconds % 1_000_000).zero? then 3
|
|
429
|
+
elsif (nanoseconds % 1000).zero? then 6
|
|
430
|
+
else 9
|
|
431
|
+
end
|
|
432
|
+
value.iso8601(digits)
|
|
433
|
+
end
|
|
434
|
+
private_class_method :time_text, :finite_json, :json_hash, :json_array, :data_hash, :data_array, :nesting,
|
|
435
|
+
:textual?
|
|
436
|
+
|
|
437
|
+
# Returns [true|false] whether the values of this field are numbers, which a fixed width file zero fills.
|
|
438
|
+
def numeric?
|
|
439
|
+
NUMERIC_TYPES.include?(type)
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
# Returns [true|false] whether the values of this field are numbers that keep every digit, a BigDecimal: a number
|
|
443
|
+
# field without `floatNumber`.
|
|
444
|
+
def decimal?
|
|
445
|
+
type == "number" && !@float
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
# Returns [true|false] whether to write only the first `width` characters of a longer string in a fixed width
|
|
449
|
+
# file.
|
|
450
|
+
def truncate?
|
|
451
|
+
@truncate
|
|
452
|
+
end
|
|
453
|
+
|
|
454
|
+
# Returns [true|false] whether a number is a Float rather than a BigDecimal, see `floatNumber` above.
|
|
455
|
+
def float? = @float
|
|
456
|
+
|
|
457
|
+
# Returns [true|false] whether the field only holds the place of its column, see `skip` above.
|
|
458
|
+
def skip? = @skip
|
|
459
|
+
|
|
460
|
+
# Returns [true|false] whether the header row, or each record, must have the field, and, unless it allows blank
|
|
461
|
+
# values, a value, see `constraints` and `allowBlank` above.
|
|
462
|
+
def required? = @constraints.required?
|
|
463
|
+
|
|
464
|
+
# Returns [true|false] whether each value of the field must be present: when it is required, unless it allows
|
|
465
|
+
# blank values, see `constraints` and `allowBlank` above, so that a value of nil, or empty text, raises.
|
|
466
|
+
def value_required? = @constraints.value_required?
|
|
467
|
+
|
|
468
|
+
# Returns the value converted to the type of this field, such as the Integer `12` for the text `"0012"`, or nil
|
|
469
|
+
# when the value is missing: nil, or empty text, which Frictionless' default `missingValues`, `[""]`, makes a
|
|
470
|
+
# missing value in every type, including a string. Text of only whitespace is not missing: a string or any field
|
|
471
|
+
# keeps it as the text, and any other type raises for it, as it does for text that is not one of its values. The
|
|
472
|
+
# leading and trailing whitespace of any other text is removed before it is read, except by a string or any field,
|
|
473
|
+
# which keeps the text as it is.
|
|
474
|
+
#
|
|
475
|
+
# Raises `IOStreams::Errors::InvalidValue` when the value is not one of this type, or when a required field has
|
|
476
|
+
# no value: nil, or empty text.
|
|
477
|
+
def cast(value)
|
|
478
|
+
return @constraints.no_value if value.nil? || (value.is_a?(String) && value.empty?)
|
|
479
|
+
|
|
480
|
+
case type
|
|
481
|
+
when "any" then return value
|
|
482
|
+
when "string" then return cast_string(value)
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
if value.is_a?(String)
|
|
486
|
+
text = value.strip
|
|
487
|
+
return invalid(value) if text.empty?
|
|
488
|
+
|
|
489
|
+
value = text
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
case type
|
|
493
|
+
when "integer" then cast_integer(value)
|
|
494
|
+
when "number" then round(cast_number(value))
|
|
495
|
+
when "boolean" then cast_boolean(value)
|
|
496
|
+
when "date" then cast_date(value)
|
|
497
|
+
when "object" then cast_json(value, ::Hash)
|
|
498
|
+
when "array" then cast_json(value, ::Array)
|
|
499
|
+
else cast_datetime(value)
|
|
500
|
+
end
|
|
501
|
+
end
|
|
502
|
+
|
|
503
|
+
# Returns [Numeric] the number, of the type of this field, see #cast, rounded to its decimals as its text is
|
|
504
|
+
# written, see #render: a BigDecimal half up, and a Float as Ruby's `format` writes it, which rounds a Float whose
|
|
505
|
+
# text ends in 5 to the even digit, whatever its binary value, such as 0.12 for 0.125, 0.02 for 0.025 and 2.68 for
|
|
506
|
+
# 2.675, so that a record read, and a format that holds the number itself, such as `:hash`, hold the number that
|
|
507
|
+
# CSV writes. A number that rounds to zero is zero, rather than -0.0, which CSV would write as "-0.00". A number
|
|
508
|
+
# that is not finite, or of a field without decimals, is returned as it is.
|
|
509
|
+
def round(number)
|
|
510
|
+
return number if @decimals.nil? || !number.finite?
|
|
511
|
+
|
|
512
|
+
rounded = number.is_a?(Float) ? Float(float_text(number)) : number.round(@decimals, :half_up)
|
|
513
|
+
rounded.zero? ? rounded.abs : rounded
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
# Returns the value converted to the type of this field, see #cast, in the form that the supplied format of
|
|
517
|
+
# file writes it:
|
|
518
|
+
# :text the text of the value, such as "12.50", for a format of text, such as CSV.
|
|
519
|
+
# :json the value as JSON represents it: an integer or boolean as itself, a number as its text, the same as
|
|
520
|
+
# :text, which JSON writes as it is, see #number_json, and any other value as its text.
|
|
521
|
+
# :typed the value itself, such as `BigDecimal("12.5")`, for a format whose rows are Ruby objects, such as
|
|
522
|
+
# `:hash`.
|
|
523
|
+
#
|
|
524
|
+
# Raises `IOStreams::Errors::InvalidValue` when the value is not one of this type.
|
|
525
|
+
def render(value, form)
|
|
526
|
+
value = cast(value)
|
|
527
|
+
return value if value.nil? || form == :typed
|
|
528
|
+
return json(value) if form == :json
|
|
529
|
+
|
|
530
|
+
text(value)
|
|
531
|
+
end
|
|
532
|
+
|
|
533
|
+
private
|
|
534
|
+
|
|
535
|
+
# Returns [String] the text of a value of this type, once it is converted, see #cast, such as "12.50" for the
|
|
536
|
+
# number `BigDecimal("12.5")` with 2 decimals.
|
|
537
|
+
def text(value)
|
|
538
|
+
case type
|
|
539
|
+
when "number" then number_text(value)
|
|
540
|
+
when "boolean" then value ? @true_values.first : @false_values.first
|
|
541
|
+
when "date" then @format ? @format.strftime(value) : self.class.any_text(value)
|
|
542
|
+
when "datetime" then @format ? datetime_text(value) : self.class.any_text(value)
|
|
543
|
+
else json_text(value)
|
|
544
|
+
end
|
|
545
|
+
end
|
|
546
|
+
|
|
547
|
+
# Returns [String] the text of a value of an object, array or any field, see .any_text, such as the JSON text of a
|
|
548
|
+
# Hash or an Array.
|
|
549
|
+
#
|
|
550
|
+
# Raises `IOStreams::Errors::InvalidValue`, naming the field, for a Hash or an Array that holds a Float that JSON
|
|
551
|
+
# cannot hold, such as `Float::NAN`, see .any_json, as writing it to a JSON file does, see #any_json.
|
|
552
|
+
def json_text(value)
|
|
553
|
+
self.class.any_text(value)
|
|
554
|
+
rescue IOStreams::Errors::InvalidValue => e
|
|
555
|
+
named(e)
|
|
556
|
+
end
|
|
557
|
+
|
|
558
|
+
# Raises [IOStreams::Errors::InvalidValue] the error of a value of this field that a class method raised, such as
|
|
559
|
+
# .any_json, which does not know the field, with the name of this field.
|
|
560
|
+
def named(error)
|
|
561
|
+
raise(IOStreams::Errors::InvalidValue, "Field #{name.inspect}: #{error.message}")
|
|
562
|
+
end
|
|
563
|
+
|
|
564
|
+
# Returns [String] the time in the format of this field. Text without a zone is read as UTC, see #parse_time, so a
|
|
565
|
+
# time is written in UTC with a format without a zone, so that it reads back as the same time.
|
|
566
|
+
def datetime_text(time)
|
|
567
|
+
@format.strftime(@utc ? time.getutc : time)
|
|
568
|
+
end
|
|
569
|
+
|
|
570
|
+
def check_type
|
|
571
|
+
return if TYPES.include?(type)
|
|
572
|
+
|
|
573
|
+
raise(ArgumentError, "Field #{name.inspect}: the type #{type.inspect} is not supported") if known_type?
|
|
574
|
+
|
|
575
|
+
raise(ArgumentError, "Field #{name.inspect}: unknown type #{type.inspect}")
|
|
576
|
+
end
|
|
577
|
+
|
|
578
|
+
# Returns [true|false] whether the type is one that Frictionless defines, but IOStreams does not support yet.
|
|
579
|
+
def known_type?
|
|
580
|
+
%w[list time year yearmonth duration geopoint geojson].include?(type)
|
|
581
|
+
end
|
|
582
|
+
|
|
583
|
+
def check_properties(descriptor)
|
|
584
|
+
accepted = PROPERTIES + TYPE_PROPERTIES.fetch(type, [])
|
|
585
|
+
descriptor.each_key do |property|
|
|
586
|
+
next if accepted.include?(property) || DESCRIPTIVE_PROPERTIES.include?(property) || property.include?(":")
|
|
587
|
+
|
|
588
|
+
if UNSUPPORTED_PROPERTIES.include?(property)
|
|
589
|
+
raise(ArgumentError, "Field #{name.inspect}: the property #{property.inspect} is not supported")
|
|
590
|
+
end
|
|
591
|
+
|
|
592
|
+
types = TYPE_PROPERTIES.select { |_type, properties| properties.include?(property) }.keys
|
|
593
|
+
unless types.empty?
|
|
594
|
+
raise(ArgumentError, "Field #{name.inspect}: #{property.inspect} only applies to a #{types.join(' or ')} field")
|
|
595
|
+
end
|
|
596
|
+
|
|
597
|
+
raise(ArgumentError, "Field #{name.inspect}: unknown property #{property.inspect}")
|
|
598
|
+
end
|
|
599
|
+
end
|
|
600
|
+
|
|
601
|
+
# Returns [IOStreams::Schema::Pattern|nil] the strptime pattern of a date or datetime, without the prefix `fmt:`
|
|
602
|
+
# that Data Package v0 wrote before it, or nil for the default form, also after that prefix, as frictionless-py
|
|
603
|
+
# reads `fmt:default`.
|
|
604
|
+
#
|
|
605
|
+
# Raises ArgumentError, naming the field, for a pattern that cannot read every date, such as one that reads a day of
|
|
606
|
+
# the month without a year, see `IOStreams::Schema::Pattern#initialize`.
|
|
607
|
+
def check_format(format)
|
|
608
|
+
pattern = format.is_a?(String) ? format.delete_prefix("fmt:") : format
|
|
609
|
+
return if pattern.nil? || pattern == "default"
|
|
610
|
+
|
|
611
|
+
unless %w[date datetime].include?(type) && pattern.is_a?(String) && pattern.include?("%")
|
|
612
|
+
raise(ArgumentError, "Field #{name.inspect}: the format #{format.inspect} is not supported for a #{type} field")
|
|
613
|
+
end
|
|
614
|
+
|
|
615
|
+
begin
|
|
616
|
+
Pattern.new(pattern)
|
|
617
|
+
rescue ArgumentError => e
|
|
618
|
+
raise(ArgumentError, "Field #{name.inspect}: #{e.message}")
|
|
619
|
+
end
|
|
620
|
+
end
|
|
621
|
+
|
|
622
|
+
def check_width(width)
|
|
623
|
+
return :remainder if [:remainder, "remainder"].include?(width)
|
|
624
|
+
return width if width.nil? || (width.is_a?(Integer) && width.positive?)
|
|
625
|
+
|
|
626
|
+
raise(ArgumentError, "Field #{name.inspect}: width must be a positive Integer or \"remainder\": #{width.inspect}")
|
|
627
|
+
end
|
|
628
|
+
|
|
629
|
+
def check_positive(property, value)
|
|
630
|
+
return value if value.nil? || (value.is_a?(Integer) && value.positive?)
|
|
631
|
+
|
|
632
|
+
raise(ArgumentError, "Field #{name.inspect}: #{property} must be a positive Integer: #{value.inspect}")
|
|
633
|
+
end
|
|
634
|
+
|
|
635
|
+
# Returns [true|false] whether the field is skipped, see `skip` above.
|
|
636
|
+
#
|
|
637
|
+
# Raises ArgumentError for a property of a field that is skipped other than those that place its column, or a
|
|
638
|
+
# type other than "any", since its values are not read.
|
|
639
|
+
def check_skip(descriptor)
|
|
640
|
+
return false unless check_flag("skip", descriptor["skip"])
|
|
641
|
+
|
|
642
|
+
other = descriptor.keys.find do |property|
|
|
643
|
+
!SKIP_PROPERTIES.include?(property) && !DESCRIPTIVE_PROPERTIES.include?(property) && !property.include?(":")
|
|
644
|
+
end
|
|
645
|
+
other ||= "type" unless type == "any"
|
|
646
|
+
return true if other.nil?
|
|
647
|
+
|
|
648
|
+
raise(ArgumentError, "Field #{name.inspect}: #{other.inspect} does not apply to a field with skip: true, which " \
|
|
649
|
+
"only holds the place of its column, since its values are not read")
|
|
650
|
+
end
|
|
651
|
+
|
|
652
|
+
def check_flag(property, value)
|
|
653
|
+
return false if value.nil?
|
|
654
|
+
return value if [true, false].include?(value)
|
|
655
|
+
|
|
656
|
+
raise(ArgumentError, "Field #{name.inspect}: #{property} must be true or false: #{value.inspect}")
|
|
657
|
+
end
|
|
658
|
+
|
|
659
|
+
def check_decimals(decimals)
|
|
660
|
+
return if decimals.nil?
|
|
661
|
+
return decimals if decimals.is_a?(Integer) && !decimals.negative?
|
|
662
|
+
|
|
663
|
+
raise(ArgumentError, "Field #{name.inspect}: decimals must be an Integer of 0 or more: #{decimals.inspect}")
|
|
664
|
+
end
|
|
665
|
+
|
|
666
|
+
# Returns [Array<String>] the text of a property, such as the true values of a boolean, each a frozen copy. Empty
|
|
667
|
+
# text raises, since a field never compares it, see #cast, as does an empty Array, unless the property can be
|
|
668
|
+
# `empty`, such as `aliases`, whose default it is.
|
|
669
|
+
def check_values(property, values, default, empty: false)
|
|
670
|
+
return default if values.nil?
|
|
671
|
+
|
|
672
|
+
texts = values.is_a?(Array) && (empty || !values.empty?) &&
|
|
673
|
+
values.all? { |value| value.is_a?(String) && !value.empty? }
|
|
674
|
+
return values.map(&:-@).freeze if texts
|
|
675
|
+
|
|
676
|
+
raise(ArgumentError, "Field #{name.inspect}: #{property} must be an Array of text that is not empty: #{values.inspect}")
|
|
677
|
+
end
|
|
678
|
+
|
|
679
|
+
# The bigdecimal gem is a default gem before Ruby 3.4, and a bundled gem since, so it is loaded only when a
|
|
680
|
+
# number field needs it.
|
|
681
|
+
def require_bigdecimal
|
|
682
|
+
require "bigdecimal"
|
|
683
|
+
rescue LoadError => e
|
|
684
|
+
raise(LoadError, "Field #{name.inspect}: a number field needs the bigdecimal gem, so add `gem \"bigdecimal\"` to " \
|
|
685
|
+
"the Gemfile, or set `floatNumber: true` to return a Float: #{e.message}")
|
|
686
|
+
end
|
|
687
|
+
|
|
688
|
+
# A Hash or an Array holds several values, rather than one value of text, so it raises. Any other value is its
|
|
689
|
+
# text, such as a `URI` or an id.
|
|
690
|
+
def cast_string(value)
|
|
691
|
+
return value if value.is_a?(String)
|
|
692
|
+
return invalid(value) if value.is_a?(::Hash) || value.is_a?(::Array)
|
|
693
|
+
|
|
694
|
+
self.class.any_text(value)
|
|
695
|
+
end
|
|
696
|
+
|
|
697
|
+
def cast_integer(value)
|
|
698
|
+
case value
|
|
699
|
+
when Integer then value
|
|
700
|
+
when String then integer_text(value)
|
|
701
|
+
when Float, Rational then value.finite? && value == value.floor ? value.to_i : invalid(value)
|
|
702
|
+
else bigdecimal?(value) && value.finite? && value.frac.zero? ? value.to_i : invalid(value)
|
|
703
|
+
end
|
|
704
|
+
end
|
|
705
|
+
|
|
706
|
+
# Returns [Integer] the whole number of the text, which can have a decimal point followed only by zeros, such as
|
|
707
|
+
# "12.0", see WHOLE.
|
|
708
|
+
def integer_text(text)
|
|
709
|
+
return Integer(text, 10) if INTEGER.match?(text)
|
|
710
|
+
|
|
711
|
+
whole = WHOLE.match(text)
|
|
712
|
+
whole ? Integer(whole[1], 10) : invalid(text)
|
|
713
|
+
end
|
|
714
|
+
|
|
715
|
+
def cast_number(value)
|
|
716
|
+
return parse_number(value) if value.is_a?(String)
|
|
717
|
+
return invalid(value) unless value.is_a?(Integer) || value.is_a?(Float) || value.is_a?(Rational) || bigdecimal?(value)
|
|
718
|
+
return value.to_f if @float
|
|
719
|
+
return rational_number(value) if value.is_a?(Rational)
|
|
720
|
+
|
|
721
|
+
bigdecimal?(value) ? value : BigDecimal(value.to_s)
|
|
722
|
+
end
|
|
723
|
+
|
|
724
|
+
# Returns [BigDecimal] the Rational as a decimal. A Rational whose decimal ends, since its denominator has no
|
|
725
|
+
# prime factors other than 2 and 5, such as `1234567890123456789/100r` or `1/2**70r`, keeps every digit, whatever
|
|
726
|
+
# the version of bigdecimal, whose division rounds. Any other, such as `1/3r`, is divided, which rounds it.
|
|
727
|
+
def rational_number(value)
|
|
728
|
+
denominator = value.denominator
|
|
729
|
+
twos = (denominator & -denominator).bit_length - 1
|
|
730
|
+
rest = denominator >> twos
|
|
731
|
+
fives = 0
|
|
732
|
+
while (rest % 5).zero?
|
|
733
|
+
rest /= 5
|
|
734
|
+
fives += 1
|
|
735
|
+
end
|
|
736
|
+
return BigDecimal(value.numerator) / denominator unless rest == 1
|
|
737
|
+
|
|
738
|
+
places = [twos, fives].max
|
|
739
|
+
BigDecimal("#{value.numerator * ((10**places) / denominator)}e-#{places}")
|
|
740
|
+
end
|
|
741
|
+
|
|
742
|
+
def parse_number(text)
|
|
743
|
+
return number(text) if NUMBER.match?(text)
|
|
744
|
+
|
|
745
|
+
special = SPECIAL_NUMBERS[text.downcase]
|
|
746
|
+
return invalid(text) unless special
|
|
747
|
+
|
|
748
|
+
@float ? special : BigDecimal(special.to_s)
|
|
749
|
+
end
|
|
750
|
+
|
|
751
|
+
# Returns [BigDecimal|Float] the number of text that NUMBER matches.
|
|
752
|
+
def number(text)
|
|
753
|
+
@float ? Float(text) : BigDecimal(text)
|
|
754
|
+
rescue ArgumentError
|
|
755
|
+
return invalid(text) unless BARE_POINT.match?(text)
|
|
756
|
+
|
|
757
|
+
# Before Ruby 3.4, so that "12." is read the same with every version of Ruby.
|
|
758
|
+
number(text.sub(BARE_POINT, ".0"))
|
|
759
|
+
end
|
|
760
|
+
|
|
761
|
+
# Returns [Hash|Array] the value, which must be of the class of the field, a Hash for an object or an Array for an
|
|
762
|
+
# array, as JSON holds it, see .json_data, or the text of one in JSON, whose numbers are read as JSON reads them
|
|
763
|
+
# without a schema.
|
|
764
|
+
def cast_json(value, klass)
|
|
765
|
+
# The JSON that text reads is already what JSON holds.
|
|
766
|
+
json = value.is_a?(String) ? parse_json(value) : self.class.json_data(value)
|
|
767
|
+
# The value as it was read is named, such as the text of a JSON array for an object field.
|
|
768
|
+
json.is_a?(klass) ? json : invalid(value)
|
|
769
|
+
rescue JSON::NestingError
|
|
770
|
+
invalid(value)
|
|
771
|
+
end
|
|
772
|
+
|
|
773
|
+
def parse_json(text)
|
|
774
|
+
JSON.parse(text)
|
|
775
|
+
rescue JSON::ParserError
|
|
776
|
+
invalid(text)
|
|
777
|
+
end
|
|
778
|
+
|
|
779
|
+
# A number is true when it is 1 and false when it is 0, such as the 1 or 0, or 1.0 or 0.0, of JSON, whatever the
|
|
780
|
+
# text of `trueValues` and `falseValues`, which a number is not. Any other number raises. IOStreams adds this, since
|
|
781
|
+
# Frictionless reads a boolean only from text, and true or false.
|
|
782
|
+
def cast_boolean(value)
|
|
783
|
+
return value if [true, false].include?(value)
|
|
784
|
+
return true if value.is_a?(String) && @true_values.include?(value)
|
|
785
|
+
return false if value.is_a?(String) && @false_values.include?(value)
|
|
786
|
+
return value == 1 if (value.is_a?(Integer) || value.is_a?(Float)) && (value == 1 || value.zero?)
|
|
787
|
+
|
|
788
|
+
invalid(value)
|
|
789
|
+
end
|
|
790
|
+
|
|
791
|
+
def cast_date(value)
|
|
792
|
+
return value if value.instance_of?(Date)
|
|
793
|
+
return midnight(value) if value.is_a?(Time) || value.is_a?(DateTime)
|
|
794
|
+
return value.to_date if value.is_a?(Date)
|
|
795
|
+
return invalid(value) unless value.is_a?(String)
|
|
796
|
+
return parse_time(Date, value) if @format
|
|
797
|
+
return invalid(value) unless DATE.match?(value)
|
|
798
|
+
|
|
799
|
+
Date.new(*value.split("-").map { |part| Integer(part, 10) })
|
|
800
|
+
rescue Date::Error
|
|
801
|
+
invalid(value)
|
|
802
|
+
end
|
|
803
|
+
|
|
804
|
+
# Returns [Date] the date of a Time or DateTime, dropping its time of day.
|
|
805
|
+
#
|
|
806
|
+
# A time that is not at midnight, by its own clock, is deprecated, since v3.0 raises
|
|
807
|
+
# `IOStreams::Errors::InvalidValue` for it, rather than lose its time of day, while it takes one at midnight, such as
|
|
808
|
+
# the time that a spreadsheet's date format gives, as its date. The first one that the field converts reports a
|
|
809
|
+
# deprecation warning, rather than each one, which would report one for each row of a file, see
|
|
810
|
+
# #time_of_day_warning.
|
|
811
|
+
def midnight(time)
|
|
812
|
+
@time_of_day.warn { time_of_day_warning(time) }
|
|
813
|
+
time.to_date
|
|
814
|
+
end
|
|
815
|
+
|
|
816
|
+
# Returns [String|nil] the deprecation warning of a time that a date field converts, see #midnight, or nil for one
|
|
817
|
+
# at midnight, by its own clock.
|
|
818
|
+
def time_of_day_warning(time)
|
|
819
|
+
fraction = time.is_a?(Time) ? time.subsec : time.sec_fraction
|
|
820
|
+
return if time.hour.zero? && time.min.zero? && time.sec.zero? && fraction.zero?
|
|
821
|
+
|
|
822
|
+
"a #{time.class.name} that is not at midnight is deprecated for the date field #{name.inspect}, which drops its " \
|
|
823
|
+
"time of day, since v3.0 raises IOStreams::Errors::InvalidValue for it rather than lose it: convert it to a " \
|
|
824
|
+
"Date in the time zone that it belongs to, such as with #to_date, or give the field the type datetime"
|
|
825
|
+
end
|
|
826
|
+
|
|
827
|
+
# `Time` moves a day past the end of its month into the next month, such as February 30 into March 2, so the
|
|
828
|
+
# date is checked first, as a date field does.
|
|
829
|
+
#
|
|
830
|
+
# Text without an offset from UTC is read as UTC, rather than in the time zone of the process, so that a file
|
|
831
|
+
# reads the same on every server.
|
|
832
|
+
#
|
|
833
|
+
# Text without a format must match DATETIME, and is read with `Time.iso8601`, which reads each form of its offset,
|
|
834
|
+
# so that only a space in place of the T is replaced, and Z added to text without an offset, see #at_offset.
|
|
835
|
+
def cast_datetime(value)
|
|
836
|
+
return value if value.is_a?(Time)
|
|
837
|
+
return self.class.time_of(value) if value.is_a?(DateTime)
|
|
838
|
+
return invalid(value) unless value.is_a?(String)
|
|
839
|
+
return parse_time(Time, value) if @format
|
|
840
|
+
|
|
841
|
+
date, time, offset = DATETIME.match(value)&.captures
|
|
842
|
+
return invalid(value) unless date && Date.valid_date?(date[0, 4].to_i, date[5, 2].to_i, date[8, 2].to_i)
|
|
843
|
+
|
|
844
|
+
at_offset(Time.iso8601("#{date}T#{time}#{offset || 'Z'}"), offset)
|
|
845
|
+
rescue ArgumentError
|
|
846
|
+
invalid(value)
|
|
847
|
+
end
|
|
848
|
+
|
|
849
|
+
# Returns [Time] the time that `Time.iso8601` read from text with the supplied offset: in UTC without an offset, or
|
|
850
|
+
# for an offset that UTC_ZONE names, such as `-0000`, and otherwise at the offset, such as `+0000`. The time gem
|
|
851
|
+
# 0.5.0's `Time.iso8601` reads the text with `Time.new`, which on JRuby reads `-0000` and `-00` as an offset of zero
|
|
852
|
+
# rather than UTC, see https://github.com/jruby/jruby/issues/10016.
|
|
853
|
+
def at_offset(time, offset)
|
|
854
|
+
offset.nil? || UTC_ZONE.match?(offset) ? time.utc : time
|
|
855
|
+
end
|
|
856
|
+
|
|
857
|
+
# Returns [Date|Time] the text read with the strptime pattern of the format, which must match all of the text, with
|
|
858
|
+
# a time on a clock, see `IOStreams::Schema::Pattern#parts`, and whose date must exist, which the pattern checks,
|
|
859
|
+
# raising `Date::Error`, see `IOStreams::Schema::Pattern#date` and `#datetime`, on the date that Python's strptime
|
|
860
|
+
# gives text without a year, rather than on the day that it is read.
|
|
861
|
+
#
|
|
862
|
+
# A time is at the offset from UTC that its zone gives, or in UTC without one, see #cast_datetime. A zone whose
|
|
863
|
+
# offset is not known, such as "XYZ", raises, rather than be read in the time zone of the process.
|
|
864
|
+
def parse_time(klass, text)
|
|
865
|
+
parts = @format.parts(text)
|
|
866
|
+
return invalid(text) if parts.nil?
|
|
867
|
+
return @format.date(text, parts) if klass == Date
|
|
868
|
+
return invalid(text) if parts.key?(:zone) && parts[:offset].nil?
|
|
869
|
+
|
|
870
|
+
# A DateTime reads text without an offset as UTC, and knows the offset of every zone that Date._strptime knows,
|
|
871
|
+
# such as "CET", which `Time.strptime` reads in the time zone of the process, as it does text without an offset.
|
|
872
|
+
# A DateTime at an offset of zero is a time in UTC, see .time_of, so a time keeps an offset of zero that the
|
|
873
|
+
# text says, such as `+0000`, see #zero_offset?, with `Time.at`, since JRuby's `getlocal(0)` of a time in UTC is
|
|
874
|
+
# in UTC.
|
|
875
|
+
time = self.class.time_of(@format.datetime(text, parts))
|
|
876
|
+
zero_offset?(parts) ? Time.at(time, in: "+00:00") : time
|
|
877
|
+
rescue ArgumentError
|
|
878
|
+
invalid(text)
|
|
879
|
+
end
|
|
880
|
+
|
|
881
|
+
# Returns [true|false] whether the parts of text read with a format say an offset of zero that is not UTC, such as
|
|
882
|
+
# `+00:00` or `+0000`, which a time keeps, as it does without a format, see UTC_ZONE.
|
|
883
|
+
def zero_offset?(parts)
|
|
884
|
+
parts[:offset]&.zero? && !UTC_ZONE.match?(parts[:zone])
|
|
885
|
+
end
|
|
886
|
+
|
|
887
|
+
# Returns [String] the text of the number: with the decimals of the field, such as "42.00", or else with the digits
|
|
888
|
+
# that it has, so that a whole number has no decimal point, such as "42" for the Integer 42, the text "3" or the
|
|
889
|
+
# Float 42.0, as frictionless-py writes it, rather than the "42.0" of `BigDecimal#to_s("F")` and `Float#to_s`.
|
|
890
|
+
def number_text(value)
|
|
891
|
+
return self.class.special_text(value) unless value.finite?
|
|
892
|
+
return (@decimals ? float_text(value) : plain_float_text(value)) if value.is_a?(Float)
|
|
893
|
+
return value.to_s("F").delete_suffix(".0") unless @decimals
|
|
894
|
+
# The value is already rounded to the decimals, see #cast.
|
|
895
|
+
return value.to_i.to_s if @decimals.zero?
|
|
896
|
+
|
|
897
|
+
whole, fraction = value.to_s("F").split(".")
|
|
898
|
+
"#{whole}.#{fraction.ljust(@decimals, '0')[0, @decimals]}"
|
|
899
|
+
end
|
|
900
|
+
|
|
901
|
+
# Returns [String] the text of a finite Float with the decimals of this field, which `format` rounds as its text
|
|
902
|
+
# reads, a Float whose text ends in 5 to the even digit, whatever its binary value, see #round.
|
|
903
|
+
def float_text(value)
|
|
904
|
+
format("%.#{@decimals}f", value)
|
|
905
|
+
end
|
|
906
|
+
|
|
907
|
+
# Returns [String] the text of a finite Float without decimals, as `Float#to_s` writes it, without the ".0" of a
|
|
908
|
+
# whole number, such as "42" for 42.0, nor of the digits before an exponent, such as "1e+20" for 1.0e+20.
|
|
909
|
+
def plain_float_text(value)
|
|
910
|
+
text = value.to_s
|
|
911
|
+
return text.delete_suffix(".0") if text.end_with?(".0")
|
|
912
|
+
|
|
913
|
+
text.include?(".0e") ? text.sub(".0e", "e") : text
|
|
914
|
+
end
|
|
915
|
+
|
|
916
|
+
def json(value)
|
|
917
|
+
case type
|
|
918
|
+
when "number" then number_json(value)
|
|
919
|
+
when "integer", "boolean", "string" then value
|
|
920
|
+
when "any", "object", "array" then any_json(value)
|
|
921
|
+
else text(value)
|
|
922
|
+
end
|
|
923
|
+
end
|
|
924
|
+
|
|
925
|
+
# Returns the value of a field of type any, object or array as JSON holds it, see .any_json. A Float or BigDecimal
|
|
926
|
+
# that is not finite raises, see #not_finite_json, as it does for a number field, and so does a Float inside a
|
|
927
|
+
# Hash or an Array, naming the field, see .any_json, while a BigDecimal inside one is written as its text.
|
|
928
|
+
def any_json(value)
|
|
929
|
+
return not_finite_json(value) if (value.is_a?(Float) || bigdecimal?(value)) && !value.finite?
|
|
930
|
+
|
|
931
|
+
begin
|
|
932
|
+
self.class.any_json(value)
|
|
933
|
+
rescue IOStreams::Errors::InvalidValue => e
|
|
934
|
+
named(e)
|
|
935
|
+
end
|
|
936
|
+
end
|
|
937
|
+
|
|
938
|
+
# Returns the number as JSON writes it: its text, see #number_text, which JSON writes as it is, see
|
|
939
|
+
# `IOStreams::Schema::JsonNumber.fragment`, so that a BigDecimal keeps all of its digits, and the digits after the
|
|
940
|
+
# decimal point that the field gives are written, such as `12.50`, and a whole number without a decimal point,
|
|
941
|
+
# such as `42`, the same as in CSV. A number that is not finite, such as NaN, raises, see #not_finite_json.
|
|
942
|
+
def number_json(value)
|
|
943
|
+
return not_finite_json(value) unless value.finite?
|
|
944
|
+
|
|
945
|
+
JsonNumber.fragment(number_text(value))
|
|
946
|
+
end
|
|
947
|
+
|
|
948
|
+
# Raises `IOStreams::Errors::InvalidValue` for a number that is not finite, such as NaN, which JSON cannot hold,
|
|
949
|
+
# naming the field, rather than leave it to the JSON writer, where the json gem raises its own exception, and
|
|
950
|
+
# ActiveSupport writes `null`.
|
|
951
|
+
def not_finite_json(value)
|
|
952
|
+
raise(IOStreams::Errors::InvalidValue,
|
|
953
|
+
"Field #{name.inspect}: JSON cannot hold a number that is not finite: #{self.class.special_text(value)}")
|
|
954
|
+
end
|
|
955
|
+
|
|
956
|
+
def bigdecimal?(value)
|
|
957
|
+
defined?(::BigDecimal) && value.is_a?(::BigDecimal)
|
|
958
|
+
end
|
|
959
|
+
|
|
960
|
+
def invalid(value)
|
|
961
|
+
text = value.inspect
|
|
962
|
+
text = "#{text[0, 60]}..." if text.length > 63
|
|
963
|
+
raise(IOStreams::Errors::InvalidValue, "Field #{name.inspect}: #{text} is not a valid #{type}")
|
|
964
|
+
end
|
|
965
|
+
end
|
|
966
|
+
end
|
|
967
|
+
end
|