iostreams 2.1.0 → 2.2.0

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