decoding 0.5.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: 27eee73e0aa7f430b7fb6aac6aea19c872dd5d825633d1f0e54362668ae355e5
4
- data.tar.gz: b6ceb7aa0bd5cda5812949c165a4f8614dd0da97f5e05a8cbbf77cefa3e10e4d
3
+ metadata.gz: 3dcd003beb1f65e74c31189b632c9c290d636a20467f5fed1a8c382d1aff634b
4
+ data.tar.gz: 338652cc9d8ea559a31fc9b14adc14a83cdc10c5730a175a1fa3670b87435b3d
5
5
  SHA512:
6
- metadata.gz: 8119c1c00072ccbe6393ba6265d8eb5bd18e4f3a6b0c821a66eac6f1a9d52481b4cdf6b7e70656545c08cecbbb365f7736a505b3df25a9bd03c1b2e9ec486e9e
7
- data.tar.gz: 5a76a8b7c125c47e2afd5f8a37bd0290ca5e2fb6de03a42cc2dabeeba4121f4601a27bd804e62a088aad8e165e2f85aca7ac8a07f6fc537be3019d9a610baae7
6
+ metadata.gz: 1a20f3f88d3970063dd6b3f8890ee8f0646df418e0c157fffe3081f6de6c2c3b7718cd685335c1b7f108e21f110d5823be6bdab3e37a222c2d6b4a728bcb820f
7
+ data.tar.gz: 31edcf5c7f5721d93308269883d271b1cd1336738a1013c18c5d21e071dddfd3de855fb3091b5d2426ddc1e57f19baca471051cbffdddf2cc47e7b157120bd1e
data/.rubocop.yml CHANGED
@@ -25,6 +25,9 @@ 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:
@@ -34,6 +37,9 @@ Metrics/BlockLength:
34
37
 
35
38
  Metrics/MethodLength:
36
39
  Max: 17
40
+ Exclude:
41
+ # The benchmarks build test data in long methods.
42
+ - benchmark/support/*.rb
37
43
 
38
44
  Style/SignalException:
39
45
  Enabled: false
@@ -65,3 +71,13 @@ RSpec/DescribeClass:
65
71
  RSpec/ExpectActual:
66
72
  Exclude:
67
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,13 @@
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
+
3
11
  ## [0.5.0]
4
12
 
5
13
  * Add a `zone:` argument to the `time` and `unix_time` decoders, for resolving times somewhere other than the system's time zone.
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
@@ -47,8 +51,8 @@ module Decoding
47
51
  parse, description = time_format(format, zone)
48
52
  Decoders.map_err(
49
53
  Decoders.any(
50
- Decoders.match(::Time),
51
- parsed_by(parse)
54
+ parsed_by(parse),
55
+ Decoders.match(::Time)
52
56
  )
53
57
  ) { |_message, value| "expected #{description}, got #{value.inspect}" }
54
58
  end
@@ -98,21 +102,23 @@ module Decoding
98
102
  raise ArgumentError, "unknown unit: #{unit.inspect}" unless UNIX_TIME_UNITS.key?(unit)
99
103
 
100
104
  require_parser(zone, :at, "a unix timestamp")
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.
101
108
  Decoders.map_err(
102
109
  Decoders.any(
110
+ timestamp_in(Decoders.match(TIMESTAMP_NUMBER), zone, per_second),
103
111
  Decoders.match(::Time),
104
- timestamp_in(zone, UNIX_TIME_UNITS.fetch(unit))
112
+ timestamp_in(Decoders.any(Decoders.parsed_integer, Decoders.parsed_float), zone, per_second)
105
113
  )
106
114
  ) { |_message, value| "expected a unix timestamp, got #{value.inspect}" }
107
115
  end
108
116
 
109
- # Decode a number, or a string describing one, as a point in time.
117
+ # Decode the number given by `decoder` as a point in time.
110
118
  #
111
119
  # @private
112
- def timestamp_in(zone, per_second)
113
- Decoders.map(
114
- Decoders.any(Decoders.integer, Decoders.float, Decoders.parsed_integer, Decoders.parsed_float)
115
- ) { zone.at(per_second == 1 ? _1 : _1 / Rational(per_second)) }
120
+ def timestamp_in(decoder, zone, per_second)
121
+ Decoders.map(decoder) { zone.at(per_second == 1 ? _1 : _1 / Rational(per_second)) }
116
122
  end
117
123
 
118
124
  # Decode a string with the given parser, treating a nil answer as a failure.
@@ -123,9 +129,7 @@ module Decoding
123
129
  #
124
130
  # @private
125
131
  def parsed_by(parse)
126
- Decoders.and_then(Decoders.map(Decoders.string) { parse.call(_1) }) do |value|
127
- value.nil? ? Decoders.fail("parsed to nothing") : Decoders.succeed(value)
128
- end
132
+ Decoders.map(Decoders.string) { parse.call(_1) || raise(ArgumentError, "parsed to nothing") }
129
133
  end
130
134
 
131
135
  # Refuse a zone that cannot answer the parsing this decoder will ask of it,
@@ -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
  #
@@ -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
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Decoding
4
- VERSION = "0.5.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.5.0
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Arjan van der Gaag