decoding 0.4.0 → 0.6.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0af816a4ee858fb01b7affa44921ddb05b38199a2fbdee33f9439342d8c5dee7
4
- data.tar.gz: 18511340412ca51970844209ed9d878a2f46b32ffec15f1c9862c97a540c8921
3
+ metadata.gz: 3dcd003beb1f65e74c31189b632c9c290d636a20467f5fed1a8c382d1aff634b
4
+ data.tar.gz: 338652cc9d8ea559a31fc9b14adc14a83cdc10c5730a175a1fa3670b87435b3d
5
5
  SHA512:
6
- metadata.gz: 044227d25ff8af0830443a267b86455de69ba14c72fa37bed1b5c84080d5f1156aeb533cceb46d5e8cce7b57a6d2a55a85729fb652b36bb747dc6b9f704ef6ef
7
- data.tar.gz: f957565332b96ca5fa607fb12ecebfc0bc3379beb6aa154760b70f14f92f59b4c8fbb39e235621e020473f49d68e20fe7909c8a0f6bcef1e091cd7d6f02b6a99
6
+ metadata.gz: 1a20f3f88d3970063dd6b3f8890ee8f0646df418e0c157fffe3081f6de6c2c3b7718cd685335c1b7f108e21f110d5823be6bdab3e37a222c2d6b4a728bcb820f
7
+ data.tar.gz: 31edcf5c7f5721d93308269883d271b1cd1336738a1013c18c5d21e071dddfd3de855fb3091b5d2426ddc1e57f19baca471051cbffdddf2cc47e7b157120bd1e
data/.rubocop.yml CHANGED
@@ -25,13 +25,21 @@ Metrics/ModuleLength:
25
25
 
26
26
  Metrics/AbcSize:
27
27
  Max: 18
28
+ Exclude:
29
+ # The benchmarks build test data in long methods.
30
+ - benchmark/support/*.rb
28
31
 
29
32
  Metrics/BlockLength:
30
33
  Exclude:
31
34
  - spec/**/*_spec.rb
35
+ # The RSpec matcher DSL puts a matcher's whole definition inside one block.
36
+ - lib/decoding/rspec/*.rb
32
37
 
33
38
  Metrics/MethodLength:
34
39
  Max: 17
40
+ Exclude:
41
+ # The benchmarks build test data in long methods.
42
+ - benchmark/support/*.rb
35
43
 
36
44
  Style/SignalException:
37
45
  Enabled: false
@@ -53,3 +61,23 @@ RSpec/IdenticalEqualityAssertion:
53
61
 
54
62
  RSpec/ExampleLength:
55
63
  Enabled: false
64
+
65
+ # The matcher specs deliberately pass literal values to `expect` to assert on
66
+ # the messages the matchers themselves produce.
67
+ RSpec/DescribeClass:
68
+ Exclude:
69
+ - spec/matchers/*_spec.rb
70
+
71
+ RSpec/ExpectActual:
72
+ Exclude:
73
+ - spec/matchers/*_spec.rb
74
+
75
+ # The benchmarks print aligned tables, where plain format strings next to each
76
+ # other read better than annotated ones.
77
+ Style/FormatStringToken:
78
+ Exclude:
79
+ - benchmark/**/*.rb
80
+
81
+ Style/RedundantFormat:
82
+ Exclude:
83
+ - benchmark/**/*.rb
data/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.6.0]
4
+
5
+ * Decode arrays, hashes and multi-field decoders (such as `decode_hash` and `Decoding::Data`) in linear rather than quadratic time. Decoding 1,600 records is about 29 times faster.
6
+ * Decode valid input to `boolean`, `big_decimal`, `time`, `date`, `uri` and `unix_time` without first trying, and failing, a decoder for another type of input. Strings and numbers decode up to 3 times faster; values that are already a `Time`, `Date` or `URI` are now checked last and take a little longer.
7
+ * Stop decoding an array, hash or `map` at the first failure, as only that one is reported. A document whose first record is invalid fails about 90 times faster. Items after the failure are no longer decoded, so blocks given to `map` for them no longer run.
8
+ * Accept a block in `Result.all` to map each item to a result, stopping at the first `Err`
9
+ * Make `optional` remember when its decoder has failed to decode `nil`, and decode `nil` without asking it again. A `nil` now takes about 0.3 µs instead of up to 5 µs. A decoder that does decode `nil`, such as `optional(succeed(5))`, is still asked every time.
10
+
11
+ ## [0.5.0]
12
+
13
+ * Add a `zone:` argument to the `time` and `unix_time` decoders, for resolving times somewhere other than the system's time zone.
14
+ * Add `decoding/rspec` module with matchers to help with testing custom decoders
15
+
3
16
  ## [0.4.0]
4
17
 
5
18
  * Add `uri` decoder for URI objects and strings that can be parsed as one, available after `require "decoding/decoders/uri"`
data/CLAUDE.md CHANGED
@@ -19,6 +19,12 @@ Ruby gem for decoding dynamic/external data into known structures. Functional-st
19
19
  - Test file: `spec/<module>/<class>_spec.rb`
20
20
  - Run with `COVERAGE=true bundle exec rspec`
21
21
 
22
+ **Custom matchers** (defined in `spec/support/matchers/`, tested in `spec/matchers/`):
23
+ - `expect(decoder).to decode_value(input).to(value)` - assert what a decoder decodes an input to
24
+ - `expect(decoder).to decode_value(input).failing_with(msg).at(*path)` - assert how it fails, path outermost first
25
+ - `expect(result).to succeed_with(value)` / `expect(result).to fail_with(msg).at(*path)` - assert on a `Result`
26
+ - Expected values compare strictly (`eql?`) unless you pass a matcher, so `1` does not match `1.0`
27
+
22
28
  ## Architecture
23
29
 
24
30
  **Core concepts:**
data/README.md CHANGED
@@ -268,6 +268,24 @@ Decoding.decode(D.unix_time(:milliseconds), 1_595_674_680_123) # => Decoding::Ok
268
268
 
269
269
  A timestamp read in the wrong unit is not an error but a wildly different point in time, so a source reporting milliseconds has to say so. There is deliberately no default: `:parse` is lenient and fills in whatever the input leaves out from the current time, so it has to be asked for by name.
270
270
 
271
+ Both `time` and `unix_time` resolve times through `Time` itself, and so in the system's time zone. That is rarely the zone a Rails application means: its containers usually run UTC while the application runs somewhere else, and taking `to_date` of the two answers differs by a day for a late-evening timestamp. Pass anything that answers the format you name as `zone:` to decide it yourself:
272
+
273
+ ```ruby
274
+ Decoding.decode(D.time(:parse, zone: Time.zone), "2020-01-01 10:00:00")
275
+ # => Decoding::Ok(2020-01-01 10:00:00 +0100)
276
+ Decoding.decode(D.map(D.unix_time(zone: Time.zone), &:to_date), 1_710_113_400)
277
+ # => Decoding::Ok(#<Date: 2024-03-11>)
278
+ ```
279
+
280
+ An `ActiveSupport::TimeZone` answers only `iso8601`, `rfc3339` and `parse` of the format names above, plus `strptime` patterns, so a zone that cannot parse the format you asked for is refused when the decoder is built:
281
+
282
+ ```ruby
283
+ D.time(:httpdate, zone: Time.zone)
284
+ # raises ArgumentError: cannot decode a time in httpdate format: the given zone does not respond to httpdate
285
+ ```
286
+
287
+ Note `ActiveSupport::TimeZone#parse` answers a value it cannot parse with `nil` rather than by raising, unlike every method of `Time`. Both are read as a failure to decode.
288
+
271
289
  * `big_decimal` -- decode a `BigDecimal` object, or a number or string describing one. Only finite numbers are accepted:
272
290
 
273
291
  ```ruby
@@ -316,6 +334,45 @@ Decoding.env("SENTRY_DSN", D.optional(D.string)) # => nil when not set
316
334
 
317
335
  Pass `from:` to read from somewhere other than `ENV`, which is useful in tests.
318
336
 
337
+ ## Testing your own decoders
338
+
339
+ The matchers this gem tests itself with are available to applications that write decoders of their own. They need `rspec-expectations`, which is not a dependency of this gem, so require them from your spec helper:
340
+
341
+ ```ruby
342
+ # spec/spec_helper.rb
343
+ require "decoding/rspec"
344
+ ```
345
+
346
+ `decode_value` describes a decoder, running it for you, so an example says what a decoder does rather than how to call it:
347
+
348
+ ```ruby
349
+ expect(my_decoder).to decode_value({ "name" => "John" }).to({ name: "John" })
350
+ expect(my_decoder).to decode_value({}).failing_with(%(expected Hash with key "name"))
351
+ expect(my_decoder).to decode_value(nil) # decodes; the value is not asserted
352
+ expect(my_decoder).not_to decode_value(nil) # fails to decode
353
+ ```
354
+
355
+ An error nested in a structure is asserted with its location, outermost segment first, rather than by matching the rendered `Error at .` prefix:
356
+
357
+ ```ruby
358
+ expect(D.field("a", D.field("b", D.string)))
359
+ .to decode_value({ "a" => { "b" => 1 } })
360
+ .failing_with("expected String, got Integer").at("a", "b")
361
+ ```
362
+
363
+ `succeed_with` and `fail_with` describe a result you already have, for when making the call is part of what the example tests:
364
+
365
+ ```ruby
366
+ expect(Decoding.decode(my_decoder, input)).to succeed_with({ name: "John" })
367
+ expect(Decoding.decode(my_decoder, input)).to fail_with("expected String, got Integer").at("name")
368
+ ```
369
+
370
+ Expected values are compared strictly, so `1` does not match `1.0` and a decoder answering with the wrong type cannot pass. Pass a matcher where that is too strict:
371
+
372
+ ```ruby
373
+ expect(D.unix_time).to decode_value("1595674680.5").to(an_object_having_attributes(usec: 500_000))
374
+ ```
375
+
319
376
  ## Development
320
377
 
321
378
  After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
data/Rakefile CHANGED
@@ -12,3 +12,15 @@ RuboCop::RakeTask.new
12
12
  YARD::Rake::YardocTask.new
13
13
 
14
14
  task default: %i[spec rubocop yard]
15
+
16
+ BENCHMARKS = %w[realistic scaling invalid micro memory].freeze
17
+
18
+ namespace :bench do
19
+ BENCHMARKS.each do |name|
20
+ desc "Run benchmark/#{name}.rb"
21
+ task(name) { ruby "benchmark/#{name}.rb" }
22
+ end
23
+ end
24
+
25
+ desc "Run all benchmarks (see benchmark/README.md)"
26
+ task bench: BENCHMARKS.map { "bench:#{_1}" }
@@ -19,10 +19,7 @@ module Decoding
19
19
  # @return [Decoding::Result<a>]
20
20
  def call(value)
21
21
  if value.is_a?(::Array)
22
- value
23
- .each_with_index
24
- .map { |v, i| @decoder.call(v).map_err { _1.push(i) } }
25
- .then { all _1 }
22
+ all(value.each_index) { |i| @decoder.call(value[i]).map_err { _1.push(i) } }
26
23
  else
27
24
  err(failure("expected Array, got #{value.class}"))
28
25
  end
@@ -6,6 +6,12 @@ module Decoding
6
6
  # Decoders are composable functions for deconstructing unknown input values
7
7
  # into known output values.
8
8
  module Decoders
9
+ # Matches the values {big_decimal} converts, in one check rather than
10
+ # trying a decoder for each type and discarding their failures.
11
+ DECIMAL_SOURCES = [::BigDecimal, ::Integer, ::Float, ::String].freeze
12
+ DECIMAL_SOURCE = ->(value) { DECIMAL_SOURCES.any? { _1 === value } }
13
+ private_constant :DECIMAL_SOURCES, :DECIMAL_SOURCE
14
+
9
15
  module_function
10
16
 
11
17
  # Decode a `BigDecimal` object, or a number or string describing one.
@@ -25,12 +31,9 @@ module Decoding
25
31
  # @see Decoding::Decoders::MapErr
26
32
  def big_decimal
27
33
  Decoders.map_err(
28
- Decoders.and_then(
29
- Decoders.any(
30
- Decoders.match(::BigDecimal),
31
- Decoders.map(Decoders.any(Decoders.integer, Decoders.float, Decoders.string)) { BigDecimal(_1) }
32
- )
33
- ) { |number| number.finite? ? Decoders.succeed(number) : Decoders.fail("not a finite number") }
34
+ Decoders.map(Decoders.match(DECIMAL_SOURCE)) do |value|
35
+ BigDecimal(value).tap { |number| raise ArgumentError, "not a finite number" unless number.finite? }
36
+ end
34
37
  ) { |_message, value| "expected a decimal number, got #{value.inspect}" }
35
38
  end
36
39
  end
@@ -32,8 +32,8 @@ module Decoding
32
32
  parse, description = date_format(format)
33
33
  Decoders.map_err(
34
34
  Decoders.any(
35
- Decoders.match(::Date),
36
- Decoders.map(Decoders.string) { parse.call(_1) }
35
+ Decoders.map(Decoders.string) { parse.call(_1) },
36
+ Decoders.match(::Date)
37
37
  )
38
38
  ) { |_message, value| "expected #{description}, got #{value.inspect}" }
39
39
  end
@@ -21,20 +21,20 @@ module Decoding
21
21
  def call(value)
22
22
  return err(failure("expected Hash, got #{value.class}")) unless value.is_a?(::Hash)
23
23
 
24
- key_value_pairs = value.map do |k, v|
25
- all(
26
- [
27
- @key_decoder
28
- .call(k)
29
- .map_err { _1.map { |msg| "invalid key: #{msg}" }.push(k) },
24
+ all(value) { |k, v| decode_entry(k, v) }.map(&:to_h)
25
+ end
26
+
27
+ private
30
28
 
31
- @value_decoder
32
- .call(v)
33
- .map_err { _1.push(k) }
34
- ]
35
- )
36
- end
37
- all(key_value_pairs).map(&:to_h)
29
+ # Decode a single key and value into a pair, leaving the value alone
30
+ # when the key cannot be decoded.
31
+ def decode_entry(key, value)
32
+ @key_decoder
33
+ .call(key)
34
+ .map_err { _1.map { |msg| "invalid key: #{msg}" }.push(key) }
35
+ .and_then do |decoded_key|
36
+ @value_decoder.call(value).map_err { _1.push(key) }.map { [decoded_key, _1] }
37
+ end
38
38
  end
39
39
  end
40
40
  end
@@ -22,7 +22,7 @@ module Decoding
22
22
  # @return [Decoding::Result<b>]
23
23
  def call(value)
24
24
  Result
25
- .all(@decoders.map { _1.call(value) })
25
+ .all(@decoders) { _1.call(value) }
26
26
  .map { @block.call(*_1) }
27
27
  rescue StandardError => e
28
28
  err(failure("error in map block: #{e.message}"))
@@ -11,22 +11,30 @@ module Decoding
11
11
  # meaning of its own. Only when it fails to do so does this decoder treat
12
12
  # `nil` as an absent value.
13
13
  #
14
+ # Most decoders cannot decode `nil`, and building the failure that says so
15
+ # costs more than decoding most values. Since a decoder gives the same
16
+ # result for the same input, this decoder remembers when the given decoder
17
+ # has failed to decode `nil`, and stops asking it about `nil` from then on.
18
+ #
14
19
  # @see Decoding::Decoders.optional
15
20
  class Optional < Decoder
16
21
  # @param decoder [Decoding::Decoder<a>]
17
22
  def initialize(decoder)
18
23
  @decoder = decoder.to_decoder
24
+ @fails_on_nil = false
19
25
  super()
20
26
  end
21
27
 
22
28
  # @param value [Object]
23
29
  # @return [Decoding::Result<a, nil>]
24
30
  def call(value)
31
+ return ok(nil) if value.nil? && @fails_on_nil
32
+
25
33
  result = @decoder.call(value)
26
- return result if result.ok?
27
- return ok(nil) if value.nil?
34
+ return result if result.ok? || !value.nil?
28
35
 
29
- result
36
+ @fails_on_nil = true
37
+ ok(nil)
30
38
  end
31
39
  end
32
40
  end
@@ -10,6 +10,10 @@ module Decoding
10
10
  # the `Time` method that parses it.
11
11
  TIME_FORMATS = %i[iso8601 xmlschema rfc2822 rfc822 httpdate parse].freeze
12
12
 
13
+ # Matches the numbers {Decoding::Decoders.unix_time} reads as a timestamp.
14
+ TIMESTAMP_NUMBER = ->(value) { value.is_a?(::Integer) || value.is_a?(::Float) }
15
+ private_constant :TIMESTAMP_NUMBER
16
+
13
17
  # The units {Decoding::Decoders.unix_time} can read a timestamp in, mapped
14
18
  # to the number of them that make up a second.
15
19
  UNIX_TIME_UNITS = { seconds: 1, milliseconds: 1000 }.freeze
@@ -22,35 +26,50 @@ module Decoding
22
26
  # `strptime` pattern. Note that the `:parse` format is lenient: it fills in
23
27
  # any components the input value leaves out from the current time.
24
28
  #
29
+ # Times are resolved by `Time` itself, and so in the system's time zone,
30
+ # unless another zone is given. Anything answering the format you name will
31
+ # do: an `ActiveSupport::TimeZone` resolves in the application's zone, which
32
+ # is rarely the system's, but note it answers only `iso8601`, `rfc3339` and
33
+ # `parse` of the names above.
34
+ #
25
35
  # @example
26
36
  # decode(time(:iso8601), "2020-01-01T10:00:00Z")
27
37
  # # => Decoding::Ok(2020-01-01 10:00:00 UTC)
28
38
  # decode(time("%Y|%m"), "nope")
29
39
  # # => Decoding::Err("expected a time matching \"%Y|%m\", got \"nope\"")
40
+ # decode(time(:parse, zone: Time.zone), "2020-01-01 10:00:00")
41
+ # # => Decoding::Ok(2020-01-01 10:00:00 +0100)
30
42
  # @param format [Symbol, String]
31
- # @raise [ArgumentError] when the format is not a known name or a pattern.
32
- # This is raised when the decoder is built, not when it is used.
43
+ # @param zone [Object] anything answering the format you name, such as
44
+ # `Time` or an `ActiveSupport::TimeZone`.
45
+ # @raise [ArgumentError] when the format is not a known name or a pattern,
46
+ # or the zone cannot parse it. This is raised when the decoder is built,
47
+ # not when it is used.
33
48
  # @return [Decoding::Decoder<Time>]
34
49
  # @see Decoding::Decoders::MapErr
35
- def time(format)
36
- parse, description = time_format(format)
50
+ def time(format, zone: ::Time)
51
+ parse, description = time_format(format, zone)
37
52
  Decoders.map_err(
38
53
  Decoders.any(
39
- Decoders.match(::Time),
40
- Decoders.map(Decoders.string) { parse.call(_1) }
54
+ parsed_by(parse),
55
+ Decoders.match(::Time)
41
56
  )
42
57
  ) { |_message, value| "expected #{description}, got #{value.inspect}" }
43
58
  end
44
59
 
45
60
  # @private
46
- def time_format(format)
61
+ def time_format(format, zone)
47
62
  case format
48
63
  when ::String
49
- [->(str) { ::Time.strptime(str, format) }, "a time matching #{format.inspect}"]
64
+ description = "a time matching #{format.inspect}"
65
+ require_parser(zone, :strptime, description)
66
+ [->(str) { zone.strptime(str, format) }, description]
50
67
  when ::Symbol
51
68
  raise ArgumentError, "unknown time format: #{format.inspect}" unless TIME_FORMATS.include?(format)
52
69
 
53
- [->(str) { ::Time.public_send(format, str) }, format == :parse ? "a time" : "a time in #{format} format"]
70
+ description = format == :parse ? "a time" : "a time in #{format} format"
71
+ require_parser(zone, format, description)
72
+ [->(str) { zone.public_send(format, str) }, description]
54
73
  else
55
74
  raise ArgumentError, "expected a time format name or a strptime pattern, got #{format.inspect}"
56
75
  end
@@ -63,29 +82,66 @@ module Decoding
63
82
  # error but a wildly different point in time, so a source that reports
64
83
  # milliseconds has to say so.
65
84
  #
85
+ # A timestamp names a point in time rather than a local one, so the zone
86
+ # only decides how the value describes itself afterwards -- which matters as
87
+ # soon as anything derives a date from it.
88
+ #
66
89
  # @example
67
90
  # decode(unix_time, 1_595_674_680) # => Decoding::Ok(2020-07-25 10:58:00 UTC)
68
91
  # decode(unix_time(:milliseconds), 1_595_674_680_123)
69
92
  # # => Decoding::Ok(2020-07-25 10:58:00.123 UTC)
70
93
  # @param unit [Symbol] one of the keys of {UNIX_TIME_UNITS}.
71
- # @raise [ArgumentError] when the unit is not a known one. This is raised
72
- # when the decoder is built, not when it is used.
94
+ # @param zone [Object] anything answering `at`, such as `Time` or an
95
+ # `ActiveSupport::TimeZone`.
96
+ # @raise [ArgumentError] when the unit is not a known one, or the zone
97
+ # cannot read a timestamp. This is raised when the decoder is built, not
98
+ # when it is used.
73
99
  # @return [Decoding::Decoder<Time>]
74
100
  # @see Decoding::Decoders::MapErr
75
- def unix_time(unit = :seconds)
101
+ def unix_time(unit = :seconds, zone: ::Time)
76
102
  raise ArgumentError, "unknown unit: #{unit.inspect}" unless UNIX_TIME_UNITS.key?(unit)
77
103
 
104
+ require_parser(zone, :at, "a unix timestamp")
78
105
  per_second = UNIX_TIME_UNITS.fetch(unit)
106
+ # Cheapest checks first, so the most common inputs fail the fewest
107
+ # decoders on their way to the one that matches.
79
108
  Decoders.map_err(
80
109
  Decoders.any(
110
+ timestamp_in(Decoders.match(TIMESTAMP_NUMBER), zone, per_second),
81
111
  Decoders.match(::Time),
82
- Decoders.map(
83
- Decoders.any(Decoders.integer, Decoders.float, Decoders.parsed_integer, Decoders.parsed_float)
84
- ) { ::Time.at(per_second == 1 ? _1 : _1 / Rational(per_second)) }
112
+ timestamp_in(Decoders.any(Decoders.parsed_integer, Decoders.parsed_float), zone, per_second)
85
113
  )
86
114
  ) { |_message, value| "expected a unix timestamp, got #{value.inspect}" }
87
115
  end
88
116
 
89
- private_class_method :time_format
117
+ # Decode the number given by `decoder` as a point in time.
118
+ #
119
+ # @private
120
+ def timestamp_in(decoder, zone, per_second)
121
+ Decoders.map(decoder) { zone.at(per_second == 1 ? _1 : _1 / Rational(per_second)) }
122
+ end
123
+
124
+ # Decode a string with the given parser, treating a nil answer as a failure.
125
+ #
126
+ # Every method of `Time` raises for a value it cannot parse, but
127
+ # `ActiveSupport::TimeZone#parse` answers with nil instead, which would
128
+ # otherwise decode as a successful nil.
129
+ #
130
+ # @private
131
+ def parsed_by(parse)
132
+ Decoders.map(Decoders.string) { parse.call(_1) || raise(ArgumentError, "parsed to nothing") }
133
+ end
134
+
135
+ # Refuse a zone that cannot answer the parsing this decoder will ask of it,
136
+ # while the decoder is being built rather than once it is decoding.
137
+ #
138
+ # @private
139
+ def require_parser(zone, method, description)
140
+ return if zone.respond_to?(method)
141
+
142
+ raise ArgumentError, "cannot decode #{description}: the given zone does not respond to #{method}"
143
+ end
144
+
145
+ private_class_method :time_format, :parsed_by, :timestamp_in, :require_parser
90
146
  end
91
147
  end
@@ -18,8 +18,8 @@ module Decoding
18
18
  def uri
19
19
  Decoders.map_err(
20
20
  Decoders.any(
21
- Decoders::Match.new(::URI::Generic),
22
- Decoders.map(Decoders.string) { ::URI.parse(_1) }
21
+ Decoders.map(Decoders.string) { ::URI.parse(_1) },
22
+ Decoders::Match.new(::URI::Generic)
23
23
  )
24
24
  ) { |_msg, value| "expected a URI, got #{value.inspect}" }
25
25
  end
@@ -128,7 +128,7 @@ module Decoding
128
128
  # decode(boolean, true) # => Decoding::Ok(true)
129
129
  # decode(boolean, false) # => Decoding::Ok(false)
130
130
  # @return [Decoding::Decoder<Boolean>]
131
- def boolean = map_err(any(self.true, self.false)) { |_msg, value| "expected true or false, got #{value.class}" }
131
+ def boolean = map_err(enum(true, false)) { |_msg, value| "expected true or false, got #{value.class}" }
132
132
 
133
133
  # Decode a String value into a symbol.
134
134
  #
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Decoding
4
+ # Shared logic for the matchers that describe decoding, loaded along with
5
+ # them by `require "decoding/rspec"`.
6
+ #
7
+ # Decoders report failures as {Decoding::Failure} values, but {Decoding.decode}
8
+ # renders those into strings, so matchers have to understand both.
9
+ module MatcherHelpers
10
+ # Split the error value of a result into its message and the path it
11
+ # occurred at, with the outermost path segment first.
12
+ #
13
+ # @param result [Decoding::Result]
14
+ # @return [Array(String, Array)]
15
+ def describe_failure(result)
16
+ error = result.unwrap_err(nil)
17
+ return [error.msg, error.path.reverse] if error.is_a?(Decoding::Failure)
18
+
19
+ match = error.to_s.match(/\AError at \.(?<path>.+?): (?<msg>.*)\z/m)
20
+ match ? [match[:msg], match[:path].split(".")] : [error.to_s, []]
21
+ end
22
+
23
+ # @param path [Array]
24
+ # @return [String]
25
+ def render_path(path) = path.empty? ? "" : " at .#{path.join(".")}"
26
+
27
+ # The path set by the `at` chain, if any.
28
+ #
29
+ # @return [Array]
30
+ def expected_path = @expected_path ||= []
31
+
32
+ # @param path [Array]
33
+ # @return [Boolean]
34
+ def path_matches?(path) = path.map(&:to_s) == expected_path.map(&:to_s)
35
+
36
+ # Compare a decoded value to what the example expects. Values are
37
+ # compared strictly, so that a decoder returning a value of the wrong
38
+ # type is not mistaken for a success, unless the example expects a
39
+ # matcher rather than a value.
40
+ #
41
+ # @param expected [Object]
42
+ # @param actual [Object]
43
+ # @return [Boolean]
44
+ def matches_value?(expected, actual)
45
+ return values_match?(expected, actual) if RSpec::Support.is_a_matcher?(expected)
46
+
47
+ actual.eql?(expected)
48
+ end
49
+ end
50
+ end
@@ -30,15 +30,29 @@ module Decoding
30
30
  # Collapse array of result values into a single result, or return the
31
31
  # first `Err` value.
32
32
  #
33
+ # Given a block, each item is mapped to a result with the block first,
34
+ # one at a time, and items after the first `Err` are not mapped at all.
35
+ #
33
36
  # @example
34
37
  # all(Ok(1), Ok(2)) # => Ok([1, 2])
35
38
  # all(Ok(1), Err("error")) # => Err("error")
36
- # @param results [Array<Result<a>>]
37
- # @return [Result<a>]
38
- def self.all(results)
39
- results.reduce(ok([])) do |acc, el|
40
- acc.and(el.to_result) { [*_1, _2] }
39
+ # all([1, 2]) { Ok(_1 * 10) } # => Ok([10, 20])
40
+ # @overload all(results)
41
+ # @param results [Enumerable<Result<a>>]
42
+ # @overload all(items)
43
+ # @param items [Enumerable<Object>]
44
+ # @yieldparam item [Object]
45
+ # @yieldreturn [Result<a>]
46
+ # @return [Result<Array<a>>]
47
+ def self.all(items)
48
+ values = []
49
+ items.each_entry do |item|
50
+ result = (block_given? ? yield(item) : item).to_result
51
+ return result if result.err?
52
+
53
+ values << result.unwrap(nil)
41
54
  end
55
+ ok(values)
42
56
  end
43
57
 
44
58
  private_class_method :new
@@ -48,12 +62,15 @@ module Decoding
48
62
 
49
63
  protected :value
50
64
 
65
+ # Computed when asked for rather than up front: results are rarely used as
66
+ # hash keys, and hashing a large decoded value for every result created
67
+ # made decoding a collection quadratic.
68
+ #
51
69
  # @private
52
- attr_reader :hash
70
+ def hash = [self.class, value].hash
53
71
 
54
72
  def initialize(value)
55
73
  @value = value
56
- @hash = [self.class, value].hash
57
74
  freeze
58
75
  end
59
76
 
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../matcher_helpers"
4
+
5
+ # Passes when the given decoder decodes the given input. Chain `to` to also
6
+ # assert the decoded value, or `failing_with` (optionally with `at`) to assert
7
+ # that decoding fails with a particular message. Both expected values may
8
+ # themselves be matchers.
9
+ #
10
+ # expect(decoder).to decode_value(["foo"]).to(["foo"])
11
+ # expect(decoder).to decode_value([1]).failing_with("expected String, got Integer").at(0)
12
+ # expect(decoder).not_to decode_value(true)
13
+ RSpec::Matchers.define :decode_value do |input|
14
+ include Decoding::MatcherHelpers
15
+
16
+ match do |actual|
17
+ next false unless actual.respond_to?(:call)
18
+
19
+ result = Decoding.decode(actual, input)
20
+ next matches_failure?(result) if message_expected?
21
+ next result.ok? && matches_value?(@expected_value, result.unwrap!) if value_expected?
22
+
23
+ result.ok?
24
+ end
25
+
26
+ match_when_negated do |actual|
27
+ raise ArgumentError, "use `not_to decode_value(input)` without `to` or `failing_with`" if message_expected? || value_expected?
28
+
29
+ actual.respond_to?(:call) && Decoding.decode(actual, input).err?
30
+ end
31
+
32
+ chain :to do |value|
33
+ @expected_value = value
34
+ @value_expected = true
35
+ end
36
+
37
+ chain :failing_with do |message|
38
+ @expected_message = message
39
+ @message_expected = true
40
+ end
41
+
42
+ chain :at do |*segments|
43
+ @expected_path = segments
44
+ end
45
+
46
+ failure_message do |actual|
47
+ next "expected a decoder, got #{description_of(actual)}" unless actual.respond_to?(:call)
48
+
49
+ "expected the decoder to #{description}, but #{describe_outcome(Decoding.decode(actual, input))}"
50
+ end
51
+
52
+ failure_message_when_negated do |actual|
53
+ next "expected a decoder, got #{description_of(actual)}" unless actual.respond_to?(:call)
54
+
55
+ "expected the decoder not to decode #{description_of(input)}"
56
+ end
57
+
58
+ description do
59
+ if message_expected?
60
+ next "fail decoding #{description_of(input)} with #{description_of(@expected_message)}#{render_path(expected_path)}"
61
+ end
62
+ next "decode #{description_of(input)} to #{description_of(@expected_value)}" if value_expected?
63
+
64
+ "decode #{description_of(input)}"
65
+ end
66
+
67
+ def message_expected? = @message_expected
68
+ def value_expected? = @value_expected
69
+
70
+ def matches_failure?(result)
71
+ return false unless result.err?
72
+
73
+ msg, path = describe_failure(result)
74
+ matches_value?(@expected_message, msg) && path_matches?(path)
75
+ end
76
+
77
+ def describe_outcome(result)
78
+ unless result.ok?
79
+ msg, path = describe_failure(result)
80
+ return "it failed with #{description_of(msg)}#{render_path(path)}"
81
+ end
82
+
83
+ return "it succeeded with #{description_of(result.unwrap!)}" if message_expected?
84
+
85
+ "it decoded to #{description_of(result.unwrap!)}"
86
+ end
87
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../matcher_helpers"
4
+
5
+ # Passes when the given {Decoding::Result} is an `Err` that failed with the
6
+ # expected message. Chain `at` to also assert where the failure occurred,
7
+ # naming the path segments outermost first. The expected message may itself be
8
+ # a matcher.
9
+ #
10
+ # expect(Decoding.decode(decoder, [1])).to fail_with("expected String, got Integer").at(0)
11
+ #
12
+ # Failures are recognised both as {Decoding::Failure} values and as the strings
13
+ # they turn into once {Decoding.decode} has rendered them.
14
+ RSpec::Matchers.define :fail_with do |expected|
15
+ include Decoding::MatcherHelpers
16
+
17
+ match do |actual|
18
+ next false unless actual.is_a?(Decoding::Result) && actual.err?
19
+
20
+ msg, path = describe_failure(actual)
21
+ matches_value?(expected, msg) && path_matches?(path)
22
+ end
23
+
24
+ chain :at do |*segments|
25
+ @expected_path = segments
26
+ end
27
+
28
+ failure_message do |actual|
29
+ next "expected a Decoding::Result, got #{description_of(actual)}" unless actual.is_a?(Decoding::Result)
30
+
31
+ outcome =
32
+ if actual.ok?
33
+ "it succeeded with #{description_of(actual.unwrap!)}"
34
+ else
35
+ msg, path = describe_failure(actual)
36
+ "it failed with #{description_of(msg)}#{render_path(path)}"
37
+ end
38
+ "expected the result to #{description}, but #{outcome}"
39
+ end
40
+
41
+ failure_message_when_negated do
42
+ "expected the result not to #{description}"
43
+ end
44
+
45
+ description do
46
+ "fail with #{description_of(expected)}#{render_path(expected_path)}"
47
+ end
48
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../matcher_helpers"
4
+
5
+ # Passes when the given {Decoding::Result} is an `Ok` holding the expected
6
+ # value. The expected value may itself be a matcher.
7
+ #
8
+ # expect(Decoding.decode(decoder, "foo")).to succeed_with("foo")
9
+ RSpec::Matchers.define :succeed_with do |expected|
10
+ include Decoding::MatcherHelpers
11
+
12
+ match do |actual|
13
+ actual.is_a?(Decoding::Result) && actual.ok? && matches_value?(expected, actual.unwrap!)
14
+ end
15
+
16
+ failure_message do |actual|
17
+ next "expected a Decoding::Result, got #{description_of(actual)}" unless actual.is_a?(Decoding::Result)
18
+
19
+ outcome =
20
+ if actual.ok?
21
+ "it succeeded with #{description_of(actual.unwrap!)}"
22
+ else
23
+ "it failed with #{description_of(actual.unwrap_err(nil).to_s)}"
24
+ end
25
+ "expected the result to succeed with #{description_of(expected)}, but #{outcome}"
26
+ end
27
+
28
+ failure_message_when_negated do
29
+ "expected the result not to succeed with #{description_of(expected)}"
30
+ end
31
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Matchers for asserting on decoders and their results, for applications that
4
+ # write decoders of their own.
5
+ #
6
+ # These are the matchers this gem tests itself with. Require them from your
7
+ # spec helper; they need `rspec-expectations`, which is not a dependency of
8
+ # this gem, so nothing is loaded unless you ask for it.
9
+ #
10
+ # # spec/spec_helper.rb
11
+ # require "decoding/rspec"
12
+ #
13
+ # `decode_value` describes a decoder, and is what you want most of the time: it
14
+ # runs the decoder for you, so an example says what a decoder does rather than
15
+ # how to call it.
16
+ #
17
+ # expect(my_decoder).to decode_value({ "name" => "John" }).to({ name: "John" })
18
+ # expect(my_decoder).to decode_value({}).failing_with(%(expected Hash with key "name"))
19
+ # expect(my_decoder).to decode_value(nil) # decodes; the value is not asserted
20
+ # expect(my_decoder).not_to decode_value(nil) # fails to decode
21
+ #
22
+ # An error nested in a structure is asserted with its location, outermost
23
+ # segment first, rather than by matching the rendered "Error at ." prefix:
24
+ #
25
+ # expect(field("a", field("b", string)))
26
+ # .to decode_value({ "a" => { "b" => 1 } })
27
+ # .failing_with("expected String, got Integer").at("a", "b")
28
+ #
29
+ # `succeed_with` and `fail_with` describe a result you already have, for when
30
+ # making the call is part of what the example is testing:
31
+ #
32
+ # expect(Decoding.decode(my_decoder, input)).to succeed_with({ name: "John" })
33
+ # expect(Decoding.decode(my_decoder, input)).to fail_with("expected String, got Integer").at("name")
34
+ #
35
+ # Expected values are compared strictly, so `1` does not match `1.0` and a
36
+ # decoder answering with the wrong type cannot pass. Pass a matcher where that
37
+ # is too strict:
38
+ #
39
+ # expect(unix_time).to decode_value("1595674680.5").to(an_object_having_attributes(usec: 500_000))
40
+ require_relative "../decoding"
41
+ require_relative "matcher_helpers"
42
+ require_relative "rspec/decode_value"
43
+ require_relative "rspec/succeed_with"
44
+ require_relative "rspec/fail_with"
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Decoding
4
- VERSION = "0.4.0"
4
+ VERSION = "0.6.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: decoding
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Arjan van der Gaag
@@ -55,7 +55,12 @@ files:
55
55
  - lib/decoding/env.rb
56
56
  - lib/decoding/error.rb
57
57
  - lib/decoding/failure.rb
58
+ - lib/decoding/matcher_helpers.rb
58
59
  - lib/decoding/result.rb
60
+ - lib/decoding/rspec.rb
61
+ - lib/decoding/rspec/decode_value.rb
62
+ - lib/decoding/rspec/fail_with.rb
63
+ - lib/decoding/rspec/succeed_with.rb
59
64
  - lib/decoding/version.rb
60
65
  - sig/decoding.rbs
61
66
  homepage: https://github.com/avdgaag/decoding