diff --git a/CHANGELOG.md b/CHANGELOG.md index 95f445f..872b346 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 > **Note:** This gem was originally published as `u-service` (versions 0.1.0 – 1.0.0) and renamed to `u-case` starting with `u-case 1.0.0` on 2019-09-15. +## [Unreleased] +### Added +- Block-form pattern-match-shaped DSL at the call site: `Micro::Case::Result::Wrapper#success` / `#failure` now accept multiple types (`*types`) and match if `result.type` is any of them — a strict superset of the prior single-optional-`type` form. In addition, 2-arity blocks passed to `#success` / `#failure` / `#unknown` now receive `(data, type)` instead of the full `Result`; 1-arity blocks (and 0/-1/-2) continue to receive the `Result` exactly as before. Together these unlock the motivating call site `MyCase.call(input) { |on| on.success(:ok, :created) { |data, type| ... }; on.failure { |data, type| ... } }` without introducing any new public constants or any new exceptions on previously-silent paths (closes #4). Exhaustiveness (raise when no branch matched and no `unknown` declared) is intentionally deferred to a follow-up gated by an opt-in, to preserve the current silent-`Kind::Undefined` fallback. `Micro::Case::Result#on_success` / `#on_failure` chain hooks are unchanged. + ## [5.7.1] - 2026-05-26 ### Added - A `[!IMPORTANT]` GitHub alert at the top of both READMEs (EN + pt-BR) surfacing the **no-breaking-changes-to-the-API** policy (see [issue #131](https://github.com/serradura/u-case/issues/131#issuecomment-4531231882)) — the gem will remain a stable, backward-compatible foundation; redesigns belong in [`solid-process`](https://github.com/solid-process/solid-process). The alert also clarifies that major version bumps happen only when a Ruby or Rails version is dropped from the supported matrix (per SemVer dependency-floor semantics). diff --git a/README.md b/README.md index 3f3f58e..386291a 100644 --- a/README.md +++ b/README.md @@ -190,6 +190,7 @@ Success(result: { slug: slug }) - [Default and custom result types](#default-and-custom-result-types) - [Result contracts](#result-contracts) - [Result hooks](#result-hooks) + - [Block-form handler at the call site](#block-form-handler-at-the-call-site) - [Pattern matching](#pattern-matching) - [Decomposition](#decomposition) - [Dynamic continuations with `Result#then`](#dynamic-continuations-with-resultthen) @@ -623,6 +624,33 @@ result calls # => 4 ``` +#### Block-form handler at the call site + +`Micro::Case.call(input) { |on| ... }` yields a handler with `on.success` / `on.failure` / `on.unknown` branches. The first branch that matches wins; the call returns the winning block's value (or `Kind::Undefined` if none matched). It's the call-site equivalent of `on_success` / `on_failure`, shaped like a pattern match. + +```ruby +output = ChangePassword.call(user: ada, new_password: 'short') do |on| + on.success { |data| audit "password updated for #{data[:user].id}"; :ok } + on.failure(:weak, :reused) { |data, type| raise ArgumentError, "#{type}: #{data[:msg]}" } + on.failure { |result| audit "#{result.use_case.class.name} failed"; :failed } +end +``` + +Each branch accepts a splat of types (`on.success(:ok, :created)`) — match if `result.type` is any of them, or any matching type when the splat is empty. Block signature dispatches on arity: + +- A **2-arity block** (`|data, type|`) receives the result's `data` hash and `type` symbol — handy for terse, pattern-match-shaped branches. +- A **1-arity block** (`|r|`) receives the full `Micro::Case::Result`, same as the chain form. + +```ruby +PublishPost.call(post: post) do |on| + on.success(:ok, :already_published) { |data, type| redirect_to(data[:post], notice: type) } + on.failure(:missing_content) { |data, type| render :edit, alert: data[:msg] } + on.unknown { |result| Rails.logger.warn(result.inspect) } +end +``` + +This complements the chain form on `Result`: chain hooks return the `Result` so you can keep chaining; the block form on `Micro::Case.call` returns the matching block's value, so it composes inline with assignments and Rails controller responses. + #### Pattern matching `Micro::Case::Result` implements [`deconstruct`](https://docs.ruby-lang.org/en/3.4/syntax/pattern_matching_rdoc.html) and [`deconstruct_keys`](https://docs.ruby-lang.org/en/3.4/syntax/pattern_matching_rdoc.html), so Ruby `case`/`in` works out of the box (Ruby ≥ 2.7): diff --git a/README.pt-BR.md b/README.pt-BR.md index 92f5932..352dc5a 100644 --- a/README.pt-BR.md +++ b/README.pt-BR.md @@ -176,6 +176,7 @@ Success(result: { slug: slug }) - [Tipos de resultado padrão e customizados](#tipos-de-resultado-padrão-e-customizados) - [Contratos de resultado](#contratos-de-resultado) - [Hooks de resultado](#hooks-de-resultado) + - [Handler em forma de bloco no local da chamada](#handler-em-forma-de-bloco-no-local-da-chamada) - [Pattern matching](#pattern-matching) - [Decomposição](#decomposição) - [Continuações dinâmicas com `Result#then`](#continuações-dinâmicas-com-resultthen) @@ -609,6 +610,33 @@ result calls # => 4 ``` +#### Handler em forma de bloco no local da chamada + +`Micro::Case.call(input) { |on| ... }` cede um handler com os ramos `on.success` / `on.failure` / `on.unknown`. O primeiro ramo que casar vence; a chamada retorna o valor do bloco vencedor (ou `Kind::Undefined` se nenhum casou). É o equivalente de `on_success` / `on_failure` no local da chamada, com formato de pattern matching. + +```ruby +output = ChangePassword.call(user: ada, new_password: 'short') do |on| + on.success { |data| audit "password updated for #{data[:user].id}"; :ok } + on.failure(:weak, :reused) { |data, type| raise ArgumentError, "#{type}: #{data[:msg]}" } + on.failure { |result| audit "#{result.use_case.class.name} failed"; :failed } +end +``` + +Cada ramo aceita um splat de tipos (`on.success(:ok, :created)`) — casa se `result.type` for qualquer um deles, ou qualquer tipo correspondente quando o splat estiver vazio. A assinatura do bloco é despachada por aridade: + +- Um **bloco de aridade 2** (`|data, type|`) recebe o hash `data` e o `type` do resultado — útil para ramos curtos no formato pattern match. +- Um **bloco de aridade 1** (`|r|`) recebe o `Micro::Case::Result` completo, igual à forma encadeada. + +```ruby +PublishPost.call(post: post) do |on| + on.success(:ok, :already_published) { |data, type| redirect_to(data[:post], notice: type) } + on.failure(:missing_content) { |data, type| render :edit, alert: data[:msg] } + on.unknown { |result| Rails.logger.warn(result.inspect) } +end +``` + +Esta forma complementa a forma encadeada no `Result`: os hooks encadeados retornam o próprio `Result` para que você continue encadeando; a forma em bloco no `Micro::Case.call` retorna o valor do bloco que casou, então compõe inline com atribuições e respostas de controller no Rails. + #### Pattern matching `Micro::Case::Result` implementa [`deconstruct`](https://docs.ruby-lang.org/en/3.4/syntax/pattern_matching_rdoc.html) e [`deconstruct_keys`](https://docs.ruby-lang.org/en/3.4/syntax/pattern_matching_rdoc.html), então o `case`/`in` do Ruby funciona direto (requer Ruby ≥ 2.7): diff --git a/lib/micro/case/result/wrapper.rb b/lib/micro/case/result/wrapper.rb index c0d2aec..553869c 100644 --- a/lib/micro/case/result/wrapper.rb +++ b/lib/micro/case/result/wrapper.rb @@ -13,24 +13,32 @@ def initialize(result) @__is_unknown = true end - def failure(type = nil) + def failure(*types, &block) return if @result.success? || !undefined_output? - set_output(yield(@result)) if result_type?(type) + set_output(call_block(block)) if result_type?(types) end - def success(type = nil) + def success(*types, &block) return if @result.failure? || !undefined_output? - set_output(yield(@result)) if result_type?(type) + set_output(call_block(block)) if result_type?(types) end - def unknown - @output = yield(@result) if @__is_unknown && undefined_output? + def unknown(&block) + @output = call_block(block) if @__is_unknown && undefined_output? end private + def call_block(block) + if block.arity == 2 + block.call(@result.data, @result.type) + else + block.call(@result) + end + end + def set_output(value) @__is_unknown = false @@ -41,8 +49,8 @@ def undefined_output? ::Kind::Undefined == @output end - def result_type?(type) - type.nil? || @result.type == type + def result_type?(types) + types.empty? || types.any?(@result.type) end end end diff --git a/test/micro/case/result/wrapper_test.rb b/test/micro/case/result/wrapper_test.rb new file mode 100644 index 0000000..cde5451 --- /dev/null +++ b/test/micro/case/result/wrapper_test.rb @@ -0,0 +1,234 @@ +require 'test_helper' + +class Micro::Case::Result::WrapperTest < Minitest::Test + class Echo < Micro::Case + attribute :value + + def call! + case value + when :ok then Success(:ok, result: { value: value }) + when :created then Success(:created, result: { value: value }) + when :weak then Failure(:weak, result: { reason: 'too short' }) + when :reused then Failure(:reused, result: { reason: 'already used' }) + else Success(result: { value: value }) + end + end + end + + # --- Backward compatibility: existing 1-arity / single-type / no-type forms + + def test_success_no_type_one_arity_receives_full_result + captured = nil + + output = Echo.call(value: :ok) do |on| + on.success { |r| captured = r; r.data[:value] } + end + + assert_instance_of(Micro::Case::Result, captured) + assert_predicate(captured, :success?) + assert_equal(:ok, captured.type) + assert_equal(:ok, output) + end + + def test_success_single_symbol_type_still_matches + output = Echo.call(value: :ok) do |on| + on.success(:ok) { |r| r.type } + end + + assert_equal(:ok, output) + end + + def test_failure_no_type_one_arity_receives_full_result + captured = nil + + output = Echo.call(value: :weak) do |on| + on.failure { |r| captured = r; r.type } + end + + assert_instance_of(Micro::Case::Result, captured) + assert_predicate(captured, :failure?) + assert_equal(:weak, output) + end + + def test_unknown_one_arity_receives_full_result + captured = nil + + output = Echo.call(value: :ok) do |on| + on.failure { raise } + on.unknown { |r| captured = r; r.type } + end + + assert_instance_of(Micro::Case::Result, captured) + assert_equal(:ok, output) + end + + # --- New: *types splat matching + + def test_success_splat_matches_any_listed_type + output = Echo.call(value: :created) do |on| + on.success(:ok, :created) { |r| r.type } + end + + assert_equal(:created, output) + end + + def test_success_splat_does_not_match_when_type_not_listed + output = Echo.call(value: :ok) do |on| + on.success(:created) { raise } + on.success(:ok) { |r| r.type } + end + + assert_equal(:ok, output) + end + + def test_failure_splat_matches_any_listed_type + output = Echo.call(value: :reused) do |on| + on.failure(:weak, :reused) { |r| r.type } + end + + assert_equal(:reused, output) + end + + # --- New: 2-arity blocks receive (data, type) + + def test_success_two_arity_block_receives_data_and_type + captured_data = nil + captured_type = nil + + Echo.call(value: :ok) do |on| + on.success { |data, type| captured_data = data; captured_type = type } + end + + assert_equal({ value: :ok }, captured_data) + assert_equal(:ok, captured_type) + end + + def test_failure_two_arity_block_receives_data_and_type + captured_data = nil + captured_type = nil + + Echo.call(value: :weak) do |on| + on.failure { |data, type| captured_data = data; captured_type = type } + end + + assert_equal({ reason: 'too short' }, captured_data) + assert_equal(:weak, captured_type) + end + + def test_unknown_two_arity_block_receives_data_and_type + captured_data = nil + captured_type = nil + + Echo.call(value: :ok) do |on| + on.failure { raise } + on.unknown { |data, type| captured_data = data; captured_type = type } + end + + assert_equal({ value: :ok }, captured_data) + assert_equal(:ok, captured_type) + end + + # --- New: motivating mixed-arity shape + + def test_motivating_shape_success_one_arity_failure_two_arity + success_capture = nil + failure_capture = nil + + Echo.call(value: :ok) do |on| + on.success { |r| success_capture = r } + on.failure { |data, type| failure_capture = [data, type] } + end + + assert_instance_of(Micro::Case::Result, success_capture) + assert_equal(:ok, success_capture.type) + assert_nil(failure_capture) + end + + def test_motivating_shape_failure_path + success_capture = nil + failure_capture = nil + + Echo.call(value: :weak) do |on| + on.success { |result| success_capture = result } + on.failure { |data, type| failure_capture = [data, type] } + end + + assert_nil(success_capture) + assert_equal([{ reason: 'too short' }, :weak], failure_capture) + end + + # --- Type filter that misses still leaves unknown to fire + + def test_type_filter_miss_falls_through_to_unknown + unknown_capture = nil + + output = Echo.call(value: :ok) do |on| + on.success(:created) { raise } + on.failure { raise } + on.unknown { |r| unknown_capture = r; r.type } + end + + assert_equal(:ok, output) + assert_instance_of(Micro::Case::Result, unknown_capture) + end + + # --- One-branch-wins + + def test_first_matching_success_wins + calls = [] + + output = Echo.call(value: :ok) do |on| + on.success(:ok) { |_| calls << :first; :one } + on.success(:ok) { |_| calls << :second; :two } + on.success { |_| calls << :third; :three } + end + + assert_equal([:first], calls) + assert_equal(:one, output) + end + + # --- Return value semantics for Micro::Case.call + + def test_block_form_returns_wrapper_output + output = Echo.call(value: :ok) do |on| + on.success { 42 } + end + + assert_equal(42, output) + end + + def test_block_form_returns_kind_undefined_when_no_branch_matches + output = Echo.call(value: :ok) do |on| + on.failure { raise } + end + + assert_equal(::Kind::Undefined, output) + end + + def test_non_block_form_returns_result + out = Echo.call(value: :ok) + + assert_instance_of(Micro::Case::Result, out) + assert_predicate(out, :success?) + end + + # --- unknown does not run if a success/failure branch already matched + + def test_unknown_skipped_when_success_matched + output = Echo.call(value: :ok) do |on| + on.success { :matched } + on.unknown { raise } + end + + assert_equal(:matched, output) + end + + def test_unknown_skipped_when_failure_matched + output = Echo.call(value: :weak) do |on| + on.failure { :matched } + on.unknown { raise } + end + + assert_equal(:matched, output) + end +end