diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e8afc3..93360f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,31 @@ ## [Unreleased] +## [0.9.2] - 2026-09-10 + +This release makes `Option#and`, `#xor`, `#zip` and `#zip_with` check their operand the way `or` already did. + +### Upgrading from 0.9.1 + +`Option#and`, `#xor`, `#zip` and `#zip_with` raise `Errgonomic::ArgumentError` on a bare operand, on a `None` receiver as well as a `Some`. Code that passed a bare value to `and` and read it back has to wrap it. + +### Changes + +- [Behavior change] `Option#and`, `#xor`, `#zip` and `#zip_with` check their operand the way `or` already did, raising `Errgonomic::ArgumentError` (`other must be an Option, was Integer`) before the receiver's variant is consulted. 0.9.x let `Some(2).and(3)` hand back the bare `3`, let `None().and(3)` and `None().zip(2)` accept the operand silently, and let `Some(1).zip(2)` and `Some(:l).xor(:r)` fall into a bare `NoMethodError` on `some?` or `none?` + +## [0.9.1] - 2026-09-10 + +This release reverts the 0.9.0 change that made `to_s` render an Option or a Result. The raise is back, with a message that says what to call instead. + +### Upgrading from 0.9.0 + +`to_s` on an Option or a Result raises `Errgonomic::SerializeError` again, so a string built from a wrapped reader fails where it is built rather than writing `Some("...")` or `None` into it. Code written against 0.9.0's rendering, whether a string interpolation, an `Array#join`, a `format`, a `String()` or a bare ERB `<%= %>`, has to take the value first: `unwrap_or` or `expect!` for the value, or `inspect` for a log line. A `rescue` that interpolates a wrapper into its message writes `inspect` there. A `rescue Errgonomic::SerializeError` written against 0.8.x still matches. A `logger.info(opt)` that rendered through 0.9.0 still renders through a plain `Logger`, but raises under Rails' `TaggedLogging` once a tag such as `request_id` is set, so it can pass in tests and raise in production: write `logger.info(opt.inspect)`. The README's Option section describes this and a `case/in` that matches nothing on a wrapper, whose `NoMatchingPatternError` no longer prints its subject. + +### Changes + +- [Behavior change] `to_s` on an Option or a Result raises `Errgonomic::SerializeError` where 0.9.0 rendered it as `inspect` does. The message names the value with its `inspect`, bounded to 60 characters, says `to_s` is refused, and names `inspect` for a log line and `unwrap_or` / `expect!` for the value: `Some(1) refuses to_s; use inspect for a log line, or unwrap_or / expect! for the value`. 0.9.0's rendering wrote wrapper text into data at every site that built a string from a wrapped reader, with no exception to find the site by: a UNIQUE identity column, a hostname, a hashed auth token and a customer-facing page. A raise that names the remedy serves the log-line case 0.9.0 traded for, and `inspect` is unchanged +- An Option or a Result in a Hash key raises on its way to JSON again. The json gem and ActiveSupport's `as_json` both stringify a key with `to_s`, so 0.9.0's rendering let `{ Some(1) => 2 }.as_json` write `{"Some(1)" => 2}` where a value position had always raised +- `Result#unwrap_err!` on an `Ok` raises an `Errgonomic::UnwrapError` whose message is the Ok's value as `inspect` renders it, bounded to 60 characters, and whose `value` is the value itself. The message used to be the value's `to_s`, so once `to_s` refuses, `Ok(Some(1)).unwrap_err!` printed only the class name and its `message` raised. A String value is quoted in the message, as `inspect` quotes it + ## [0.9.0] - 2026-09-08 This release turns the ActiveRecord integration from a set of wrapped readers into a full set of boundaries, covering readers, writers, query binds, validation and serialization, with the behavior changes named in the bullets below. diff --git a/Gemfile.lock b/Gemfile.lock index 36d970e..2777e2a 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - errgonomic (0.9.0) + errgonomic (0.9.2) concurrent-ruby (~> 1.0) GEM @@ -385,7 +385,7 @@ CHECKSUMS diff-lcs (1.6.2) sha256=9ae0d2cba7d4df3075fe8cd8602a8604993efc0dfa934cff568969efb1909962 drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373 erb (6.0.6) sha256=a9b24986700f5bf127c4f297c5403c3ca41b83b0a316c0cd09a096b56e644ae5 - errgonomic (0.9.0) + errgonomic (0.9.2) erubi (1.13.1) sha256=a082103b0885dbc5ecf1172fede897f9ebdb745a4b97a5e8dc63953db1ee4ad9 globalid (1.4.0) sha256=037f12fbf1d9d7a014d501c2d5c77356fd4ddd96d7a7991d6700bba96706f427 highline (3.0.1) sha256=ca18b218fd581b1fae832f89bfeaf2b34d3a93429c44fd4411042ffce286f009 diff --git a/README.md b/README.md index 03efbef..bcd9db2 100644 --- a/README.md +++ b/README.md @@ -122,9 +122,13 @@ in Errgonomic::Option::None end ``` -An unhandled Option refuses to leak into your output: `to_json` and `as_json` raise `Errgonomic::SerializeError`, so you handle the inner value deliberately rather than shipping `#` to a user. The refusal names what it was carrying (`cannot serialize an unwrapped Some("cell-a1b2")`), so a payload built out of many values says which one went unhandled; the value's `inspect` is bounded to 60 characters, with an ellipsis past that. The refusal covers `as_json` because Hash and Array serialization recurses through that method, and an Option nested in a payload would otherwise serialize as `{"value": ...}`. A converted ActiveRecord model is the one exception, at the model boundary: it unwraps each attribute as it serializes, so a record's own `as_json` says what an unconverted record's says. See [Rails integration](#rails-integration). +An unhandled Option refuses to leak into your output: `to_s`, `to_json` and `as_json` raise `Errgonomic::SerializeError`, so you handle the inner value deliberately rather than shipping `Some("...")` to a user. The refusal names what it was carrying (`cannot serialize an unwrapped Some("cell-a1b2")`), so a payload built out of many values says which one went unhandled; the value's `inspect` is bounded to 60 characters, with an ellipsis past that. The refusal covers `as_json` because Hash and Array serialization recurses through that method, and an Option nested in a payload would otherwise serialize as `{"value": ...}`. A converted ActiveRecord model is the one exception, at the model boundary: it unwraps each attribute as it serializes, so a record's own `as_json` says what an unconverted record's says. See [Rails integration](#rails-integration). -`to_s` renders rather than refusing: `Some(1).to_s` is `"Some(1)"` and `None().to_s` is `"None"`, matching `inspect`, and the same holds for `Ok` and `Err`. Rust gives `Option` a `Debug` and no `Display`, so raising was the faithful reading, but a `to_s` that raises replaces the real exception while a `rescue` builds its log line, which is the worst possible place to be strict. The rendered form is unambiguous: a `Some(1)` in a log says a wrapper arrived where a value was meant. +The `to_s` refusal is the loudest guard of the three, because a string is where a wrapper turns into data: string interpolation, `Array#join`, `format`, `String()`, a bare ERB `<%= %>` and the key of a Hash on its way to JSON all reach the value through `to_s`, and every one of them raises rather than writing `Some("...")` into a hostname, a column or a page. Rust gives `Option` a `Debug` and no `Display`, and `inspect` is the `Debug` here: it renders `Some(1)`, and it is what a log line or a `rescue` should call (`"got #{opt.inspect}"`). The refusal says so: `Some(1) refuses to_s; use inspect for a log line, or unwrap_or / expect! for the value`. + +Two places reach `to_s` on your behalf, and read badly when it refuses. The first is a `case/in` with more than one `in` branch where none matches. Ruby raises `NoMatchingPatternError` with the unmatched Option or Result itself as the message, and printing that message calls `to_s`. Uncaught, it prints as a bare `NoMatchingPatternError` with no subject, and inside a Minitest test the reporter crashes on it with `Some(1) refuses to_s; …` before it prints the test's name or the run summary. The subject is a variant the `case` has no branch for, such as a Result handed to Option patterns, so the fix is the missing `in` branch, or an `else`; an `inspect` has nowhere to go. A single-branch `case/in` and a rightward `=>` build their message with `inspect` and print normally. + +The second is a logger. `logger.info(opt)` renders `Some(1)` through a plain `Logger`, whose formatter calls `inspect` on a message that is not a String, but raises through Rails' `ActiveSupport::TaggedLogging` once a tag is set, because the tagged formatter interpolates the message. Rails' generated `production.rb` tags every request with `log_tags = [:request_id]`, so the same line passes in tests and raises in production. Write `logger.info(opt.inspect)`. `expect!` also takes a block, on an Option and a Result alike, so a message that interpolates is built only on the branch that raises: `tier.expect! { "no tier for #{account.id}" }`. `present_or_raise!` takes one on the same terms. The positional form is unchanged. @@ -177,7 +181,7 @@ in Errgonomic::Result::Err, Exception => e end ``` -Like Options, unwrapped Results refuse `to_json` and `as_json`, and render `to_s` as `inspect` does. And `Object#result?` / `Object#assert_result!` help enforce at runtime that a value is a Result. +Like Options, unwrapped Results refuse `to_s`, `to_json` and `as_json`, and render through `inspect`. And `Object#result?` / `Object#assert_result!` help enforce at runtime that a value is a Result. ### Optional collections diff --git a/lib/errgonomic/option.rb b/lib/errgonomic/option.rb index 1b5f721..7d1ac9a 100644 --- a/lib/errgonomic/option.rb +++ b/lib/errgonomic/option.rb @@ -594,10 +594,10 @@ def ok_or_else(&block) # @example # None().or(Some(1)) # => Some(1) # Some(2).or(Some(3)) # => Some(2) - # None().or(2) # => raise Errgonomic::ArgumentError.new, "other must be an Option, was Integer" + # None().or(2) # => raise Errgonomic::ArgumentError, "other must be an Option, was Integer" + # Some(1).or(2) # => raise Errgonomic::ArgumentError, "other must be an Option, was Integer" def or(other) - raise ArgumentError, "other must be an Option, was #{other.class.name}" unless other.is_a?(Any) - + option_operand!(other) return self if some? other @@ -620,12 +620,17 @@ def or_else(&block) val end - # If self is Some, return the provided other Option. + # If self is Some, return the provided other Option. The operand is + # checked on both variants, so a None-heavy path still learns that it + # was handed a bare value. # # @example # None().and(Some(1)) # => None() # Some(2).and(Some(3)) # => Some(3) + # Some(2).and(3) # => raise Errgonomic::ArgumentError, "other must be an Option, was Integer" + # None().and(3) # => raise Errgonomic::ArgumentError, "other must be an Option, was Integer" def and(other) + option_operand!(other) return self if none? other @@ -657,7 +662,10 @@ def and_then(&block) # None().zip(Some(1)) # => None() # Some(1).zip(None()) # => None() # Some(2).zip(Some(3)) # => Some([2, 3]) + # Some(1).zip(2) # => raise Errgonomic::ArgumentError, "other must be an Option, was Integer" + # None().zip(2) # => raise Errgonomic::ArgumentError, "other must be an Option, was Integer" def zip(other) + option_operand!(other) return None() unless some? && other.some? Some([value, other.value]) @@ -671,25 +679,32 @@ def zip(other) # None().zip_with(Some(1)) { |a, b| a + b } # => None() # Some(1).zip_with(None()) { |a, b| a + b } # => None() # Some(2).zip_with(Some(3)) { |a, b| a + b } # => Some(5) + # Some(1).zip_with(2) { |a, b| a + b } # => raise Errgonomic::ArgumentError, "other must be an Option, was Integer" + # None().zip_with(2) { |a, b| a + b } # => raise Errgonomic::ArgumentError, "other must be an Option, was Integer" def zip_with(other, &block) + option_operand!(other) return None() unless some? && other.some? other = block.call(value, other.value) Some(other) end - # Render as inspect does. Rust gives Option a Debug and no Display, so - # refusing was faithful, but a to_s that raises replaces the real - # exception while a rescue builds its log line, and the rendered form - # says plainly that a wrapper arrived where a value was meant. + # Refuse to render as a String. Rust gives Option a Debug and no + # Display: a wrapper that reaches a string went unhandled, and a string + # is where it turns into data, a hostname, a hash key or a page. The + # refusal names the value and says how to log it or take it. # # @example - # Some(1).to_s # => "Some(1)" - # Some("x").to_s # => "Some(\"x\")" - # None().to_s # => "None" - # "value: #{Some(1)}" # => "value: Some(1)" + # Some(1).to_s # => raise Errgonomic::SerializeError, "Some(1) refuses to_s; use inspect for a log line, or unwrap_or / expect! for the value" + # None().to_s # => raise Errgonomic::SerializeError, "None refuses to_s; use inspect for a log line, or unwrap_or / expect! for the value" + # "value: #{Some(1)}" # => raise Errgonomic::SerializeError, "Some(1) refuses to_s; use inspect for a log line, or unwrap_or / expect! for the value" + # [Some("org"), Some("metrics")].join("/") # => raise Errgonomic::SerializeError, "Some(\"org\") refuses to_s; use inspect for a log line, or unwrap_or / expect! for the value" + # format("%s", None()) # => raise Errgonomic::SerializeError, "None refuses to_s; use inspect for a log line, or unwrap_or / expect! for the value" + # String(Some(1)) # => raise Errgonomic::SerializeError, "Some(1) refuses to_s; use inspect for a log line, or unwrap_or / expect! for the value" + # Some("a" * 100).to_s # => raise Errgonomic::SerializeError, "Some(\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa... refuses to_s; use inspect for a log line, or unwrap_or / expect! for the value" + # Some(1).inspect # => "Some(1)" def to_s - inspect + raise Errgonomic::SerializeError, to_s_refusal end # Refuse to serialize an unwrapped Option as JSON. Not only should we @@ -763,8 +778,10 @@ def flatten # Some(:left).xor(Some(:right)) # => None() # Some(:left).xor(None()) #=> Some(:left) # None().xor(Some(:right)) #=> Some(:right) - # + # Some(:left).xor(:right) # => raise Errgonomic::ArgumentError, "other must be an Option, was Symbol" + # None().xor(:right) # => raise Errgonomic::ArgumentError, "other must be an Option, was Symbol" def xor(other) + option_operand!(other) return self if some? && other.none? return other if other.some? && none? @@ -773,12 +790,27 @@ def xor(other) private + # Checked before the discriminant is consulted, so a None-heavy path + # learns about a bare operand as soon as a Some-heavy one would. + def option_operand!(other) + return if other.is_a?(Errgonomic::Option::Any) + + raise Errgonomic::ArgumentError, "other must be an Option, was #{other.class.name}" + end + + def to_s_refusal + "#{bounded_inspect} refuses to_s; use inspect for a log line, or unwrap_or / expect! for the value" + end + + def serialize_refusal + "cannot serialize an unwrapped #{bounded_inspect}" + end + # Name the value the caller failed to handle, bounded: an inspect of a # record or a long payload would bury the message carrying it. - def serialize_refusal + def bounded_inspect rendered = inspect - rendered = "#{rendered[0, 57]}..." if rendered.length > 60 - "cannot serialize an unwrapped #{rendered}" + rendered.length > 60 ? "#{rendered[0, 57]}..." : rendered end def presence_nudge(from, to) diff --git a/lib/errgonomic/result.rb b/lib/errgonomic/result.rb index c7c598b..59447bf 100644 --- a/lib/errgonomic/result.rb +++ b/lib/errgonomic/result.rb @@ -241,12 +241,16 @@ def expect!(msg = nil, &block) end # Return the inner value of an Err, else raise an exception when Ok. + # The message is the Ok's value as inspect renders it, bounded, so an + # Ok holding an Option or a Result still has a message to print. # # @example - # Ok(1).unwrap_err! # => raise Errgonomic::UnwrapError, 1 + # Ok(1).unwrap_err! # => raise Errgonomic::UnwrapError, "1" + # Ok(Some(1)).unwrap_err! # => raise Errgonomic::UnwrapError, "Some(1)" + # Ok("a" * 100).unwrap_err! # => raise Errgonomic::UnwrapError, "\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa..." # Err(:e).unwrap_err! # => :e def unwrap_err! - raise Errgonomic::UnwrapError, value unless err? + raise Errgonomic::UnwrapError.new(bounded_inspect(value), value) unless err? @value end @@ -405,18 +409,22 @@ def map_err(&block) Err(block.call(value)) end - # Render as inspect does. Rust gives Result a Debug and no Display, so - # refusing was faithful, but a to_s that raises replaces the real - # exception while a rescue builds its log line, and the rendered form - # says plainly that a wrapper arrived where a value was meant. + # Refuse to render as a String. Rust gives Result a Debug and no + # Display: a wrapper that reaches a string went unhandled, and a string + # is where it turns into data, a hostname, a hash key or a page. The + # refusal names the value and says how to log it or take it. # # @example - # Ok(1).to_s # => "Ok(1)" - # Err(:nope).to_s # => "Err(:nope)" - # Err().to_s # => "Err()" - # "outcome: #{Ok(1)}" # => "outcome: Ok(1)" + # Ok(1).to_s # => raise Errgonomic::SerializeError, "Ok(1) refuses to_s; use inspect for a log line, or unwrap_or / expect! for the value" + # Err(:nope).to_s # => raise Errgonomic::SerializeError, "Err(:nope) refuses to_s; use inspect for a log line, or unwrap_or / expect! for the value" + # "outcome: #{Ok(1)}" # => raise Errgonomic::SerializeError, "Ok(1) refuses to_s; use inspect for a log line, or unwrap_or / expect! for the value" + # [Ok(1), Err(:x)].join(",") # => raise Errgonomic::SerializeError, "Ok(1) refuses to_s; use inspect for a log line, or unwrap_or / expect! for the value" + # format("%s", Err(:x)) # => raise Errgonomic::SerializeError, "Err(:x) refuses to_s; use inspect for a log line, or unwrap_or / expect! for the value" + # String(Ok(1)) # => raise Errgonomic::SerializeError, "Ok(1) refuses to_s; use inspect for a log line, or unwrap_or / expect! for the value" + # Err("a" * 100).to_s # => raise Errgonomic::SerializeError, "Err(\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa... refuses to_s; use inspect for a log line, or unwrap_or / expect! for the value" + # Err(:nope).inspect # => "Err(:nope)" def to_s - inspect + raise Errgonomic::SerializeError, to_s_refusal end # Refuse to serialize an unwrapped Result as JSON. Not only should we @@ -471,6 +479,17 @@ def deconstruct private + def to_s_refusal + "#{bounded_inspect} refuses to_s; use inspect for a log line, or unwrap_or / expect! for the value" + end + + # Name the value the caller failed to handle, bounded: an inspect of a + # record or a long payload would bury the message carrying it. + def bounded_inspect(object = self) + rendered = object.inspect + rendered.length > 60 ? "#{rendered[0, 57]}..." : rendered + end + def strict_equality!(other, operator) return unless Errgonomic.strict_equality? return if other.is_a?(Errgonomic::Result::Any) diff --git a/lib/errgonomic/version.rb b/lib/errgonomic/version.rb index 5f4ba0a..7fd5b80 100644 --- a/lib/errgonomic/version.rb +++ b/lib/errgonomic/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module Errgonomic - VERSION = '0.9.0' + VERSION = '0.9.2' end diff --git a/test/rails_test.rb b/test/rails_test.rb index 9b05219..d45ca2a 100644 --- a/test/rails_test.rb +++ b/test/rails_test.rb @@ -14,6 +14,8 @@ require 'stringio' require 'tmpdir' require 'fileutils' +require 'open3' +require 'rbconfig' require_relative '../lib/errgonomic/rails' @@ -896,6 +898,116 @@ def test_nested_options_and_results_refuse_to_serialize assert_raises(Errgonomic::SerializeError) { [Ok(5)].to_json } end + # A string is where a wrapper turns into data: a join builds an identity + # column, an interpolation a hostname, a format a node role. Each reaches + # the value through to_s, so each refuses. + def test_a_wrapper_refuses_to_become_a_string + assert_raises(Errgonomic::SerializeError) { [Some('org'), Some('metrics')].join('/') } + assert_raises(Errgonomic::SerializeError) { [Some(1), None()].join(',') } + assert_raises(Errgonomic::SerializeError) { "#{None()}.us-east-1.example" } + assert_raises(Errgonomic::SerializeError) { "data_#{Some('hot')}" } + assert_raises(Errgonomic::SerializeError) { None().to_s.split(',') } + assert_raises(Errgonomic::SerializeError) { format('%s', Some(1)) } + assert_raises(Errgonomic::SerializeError) { String(Some(1)) } + assert_raises(Errgonomic::SerializeError) { "outcome: #{Ok(1)}" } + assert_raises(Errgonomic::SerializeError) { [Err(:x), Ok(1)].join(',') } + assert_raises(Errgonomic::SerializeError) { format('%s', Err(:x)) } + assert_raises(Errgonomic::SerializeError) { String(Ok(1)) } + assert_equal 'Some("hot")', Some('hot').inspect + assert_equal 'Err(:x)', Err(:x).inspect + end + + # A case/in with several branches and none matching raises + # NoMatchingPatternError with the subject itself as its message, so + # printing it reaches to_s. The README says what that looks like. + def test_an_unmatched_case_in_over_a_wrapper_has_a_message_that_refuses + error = assert_raises(NoMatchingPatternError) do + case Some(1) + in Errgonomic::Result::Ok, v then v + in Errgonomic::Result::Err, e then e + end + end + assert_raises(Errgonomic::SerializeError) { error.message } + unmatched = 'case Some(1); in Errgonomic::Result::Ok, v then v; in Errgonomic::Result::Err, e then e; end' + + _, printed, = run_ruby(unmatched) + assert_match(/in '
': NoMatchingPatternError$/, printed) + refute_includes printed, 'Some(1)' + + suite = "class T < Minitest::Test; def test_unmatched = #{unmatched}; end" + reported, crashed, = run_ruby(suite, 'minitest/autorun') + assert_includes crashed, 'Some(1) refuses to_s' + refute_match(/\d+ runs, \d+ assertions/, reported) + refute_includes reported + crashed, 'test_unmatched' + end + + # Logger's own formatter inspects a message that is not a String, and the + # tagged formatter interpolates it, so one call behaves two ways. + def test_a_tagged_logger_refuses_a_wrapper_that_a_plain_logger_inspects + io = StringIO.new + logger = ActiveSupport::TaggedLogging.new(ActiveSupport::Logger.new(io)) + + logger.info(Some(1)) + assert_raises(Errgonomic::SerializeError) { logger.tagged('req-1') { logger.info(Some(1)) } } + logger.tagged('req-1') { logger.info(Some(1).inspect) } + assert_equal "Some(1)\n[req-1] Some(1)\n", io.string + end + + # ActionView's output buffer appends a value through to_s, so a bare + # <%= reader %> of a Some raises rather than shipping Some("...") + # to a page; the template wraps the refusal as its cause. A None answers + # nil? under the Rails integration, and the buffer skips a nil before it + # asks for to_s, so a None renders as nothing. + def test_a_bare_erb_tag_refuses_a_wrapper + view = ActionView::Base.with_empty_template_cache.empty + [Some('visible'), Ok(1), Err(:x)].each do |wrapper| + error = assert_raises(ActionView::Template::Error) { view.render(inline: '<%= value %>', locals: { value: wrapper }) } + assert_kind_of Errgonomic::SerializeError, error.cause + end + assert_equal '', view.render(inline: '<%= value %>', locals: { value: None() }) + end + + # The json gem and ActiveSupport's as_json both stringify a Hash key with + # to_s, so the refusal reaches a key position through to_s alone. + def test_an_option_in_a_hash_key_refuses_to_serialize + assert_raises(Errgonomic::SerializeError) { { Some(1) => 2 }.as_json } + assert_raises(Errgonomic::SerializeError) { JSON.generate({ Some(1) => 2 }) } + assert_raises(Errgonomic::SerializeError) { [1, 2, 3].group_by { |i| i.even? ? Some(:even) : None() }.as_json } + assert_raises(Errgonomic::SerializeError) { { Ok(1) => 2 }.as_json } + assert_raises(Errgonomic::SerializeError) { JSON.generate({ Err(:x) => 2 }) } + end + + # A converted model's optional association keys a group_by by its reader, + # and the grouped payload is what reaches JSON. + def test_a_group_by_over_a_wrapped_reader_refuses_to_serialize + author = Author.create!(name: 'Ursula K. Le Guin') + Book.create!(title: 'The Dispossessed', author_id: author.id) + Book.create!(title: 'Anonymous') + + assert_raises(Errgonomic::SerializeError) { Book.all.group_by(&:author).to_json } + end + + # ActiveSupport's to_json reaches a key through as_json, which refuses + # whatever to_s does, so the json gem's own key path runs in a child + # process that loads the json gem and nothing of ActiveSupport. + def test_the_json_gem_alone_refuses_an_option_in_a_hash_key + script = <<~CHILD + [-> { { Some(1) => 2 }.to_json }, + -> { [1, 2, 3].group_by { |i| i.even? ? Some(:even) : None() }.to_json }].each do |call| + puts call.call + rescue Errgonomic::SerializeError => e + puts e.message + end + puts defined?(ActiveSupport).inspect + CHILD + out, err, status = run_ruby(script, 'json') + + assert status.success?, err + assert_equal ['Some(1) refuses to_s; use inspect for a log line, or unwrap_or / expect! for the value', + 'None refuses to_s; use inspect for a log line, or unwrap_or / expect! for the value', + 'nil'], out.lines(chomp: true) + end + # A conversion changes what a reader returns, not what a record serializes: # the payload has to match the model that was never converted, key for key. def test_a_converted_record_serializes_as_the_unconverted_one_does @@ -2139,6 +2251,13 @@ def render_note_form(record) end end + # Run a script in a fresh Ruby that loads errgonomic and the named + # libraries and nothing of this process's Rails. + def run_ruby(script, *libraries) + requires = [*libraries, 'errgonomic'].map { |library| "-r#{library}" } + Open3.capture3(RbConfig.ruby, '-I', File.expand_path('../lib', __dir__), *requires, '-e', script) + end + def capture_stderr original = $stderr $stderr = StringIO.new