Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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):
Expand Down
28 changes: 28 additions & 0 deletions README.pt-BR.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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):
Expand Down
24 changes: 16 additions & 8 deletions lib/micro/case/result/wrapper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down
234 changes: 234 additions & 0 deletions test/micro/case/result/wrapper_test.rb
Original file line number Diff line number Diff line change
@@ -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
Loading