Skip to content
Merged
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
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ if ($failure->isErr()) {

// Unwrapping values (throws exception on error)
$value = $success->unwrap(); // 42
// $failure->unwrap(); // throws LogicException
// $failure->unwrap(); // throws UnwrapException

// Safe unwrapping with default values
$value = $failure->unwrapOr(0); // 0
Expand Down Expand Up @@ -202,8 +202,8 @@ All Result types (both Ok and Err) implement these methods:
- `isErrAnd(callable $fn): bool` - Returns true if the Result is Err and the predicate returns true

#### Value Extraction
- `unwrap(): mixed` - Returns the success value or throws LogicException
- `unwrapErr(): mixed` - Returns the error value or throws LogicException
- `unwrap(): mixed` - Returns the success value or throws UnwrapException (extends LogicException)
- `unwrapErr(): mixed` - Returns the error value or throws UnwrapException (extends LogicException)
- `unwrapOr(mixed $default): mixed` - Returns the success value or a default
- `unwrapOrElse(callable $fn): mixed` - Returns the success value or computes it from the error

Expand Down
2 changes: 1 addition & 1 deletion src/Err.php
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ public function isErrAnd(callable $fn): bool
#[Override]
public function unwrap(): never
{
throw new \LogicException('called Result::unwrap() on an Err value');
throw UnwrapException::unwrapOnErr($this->value);
}

/**
Expand Down
2 changes: 1 addition & 1 deletion src/Ok.php
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ public function unwrap(): mixed
#[Override]
public function unwrapErr(): never
{
throw new \LogicException('called Result::unwrapErr() on an Ok value');
throw UnwrapException::unwrapErrOnOk($this->value);
}

/**
Expand Down
4 changes: 4 additions & 0 deletions src/Result.php
Original file line number Diff line number Diff line change
Expand Up @@ -60,13 +60,17 @@ public function isErrAnd(callable $fn): bool;
* 成功値を返します。失敗の場合は例外を投げます.
*
* @return ($this is Ok<mixed> ? T : never)
*
* @throws UnwrapException $this が Err の場合
*/
public function unwrap(): mixed;

/**
* エラー値を返します。成功の場合は例外を投げます.
*
* @return ($this is Err<mixed> ? E : never)
*
* @throws UnwrapException $this が Ok の場合
*/
public function unwrapErr(): mixed;

Expand Down
89 changes: 89 additions & 0 deletions src/UnwrapException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
<?php

declare(strict_types=1);

namespace Valbeat\Result;

/**
* unwrap() / unwrapErr() を反対側の変種に対して呼び出したときに送出される例外です.
*
* \LogicException を継承しているため、既存の catch (\LogicException) はそのまま動作します.
* メッセージには保持している値の要約が含まれます(Rust の panic メッセージに相当).
* 注意: スカラー値はメッセージにそのまま(切り詰めの上)現れるため、機微な文字列を
* エラー値に載せる場合はログ出力先に注意してください.
*/
final class UnwrapException extends \LogicException
{
/**
* メッセージに埋め込む値要約の最大長(超過分は切り詰め).
*/
private const int MAX_SUMMARY_LENGTH = 120;

/**
* Err に対して unwrap() が呼ばれた場合の例外を生成します.
*/
public static function unwrapOnErr(mixed $error): self
{
return new self(\sprintf('called Result::unwrap() on an Err value: %s', self::describe($error)));
}

/**
* Ok に対して unwrapErr() が呼ばれた場合の例外を生成します.
*/
public static function unwrapErrOnOk(mixed $value): self
{
return new self(\sprintf('called Result::unwrapErr() on an Ok value: %s', self::describe($value)));
}

/**
* 例外メッセージ用に値の要約を生成します.
*
* 要約は単一行に正規化し、MAX_SUMMARY_LENGTH を超える部分は切り詰めます.
*/
private static function describe(mixed $value): string
{
$summary = match (true) {
$value instanceof \Throwable => \sprintf('%s: %s', self::className($value), $value->getMessage()),
$value instanceof \UnitEnum => \sprintf('%s::%s', $value::class, $value->name),
$value instanceof \Stringable => self::describeStringable($value),
\is_object($value) => self::className($value),
\is_scalar($value), null === $value => var_export($value, true),
default => get_debug_type($value),
};

$summary = str_replace(["\r\n", "\r", "\n"], '\n', $summary);
if (\strlen($summary) > self::MAX_SUMMARY_LENGTH) {
return substr($summary, 0, self::MAX_SUMMARY_LENGTH) . '... (truncated)';
}

return $summary;
}

/**
* Stringable の要約を生成します。__toString() が例外を投げてもこの例外を
* 置き換えないよう、失敗時はクラス名のみへフォールバックします.
*/
private static function describeStringable(\Stringable $value): string
{
try {
return \sprintf('%s: %s', self::className($value), (string) $value);
} catch (\Throwable) {
return self::className($value);
}
}

/**
* クラス名を返します。匿名クラスはファイルパス・行番号を除いた
* 「Foo@anonymous」形式に正規化します.
*/
private static function className(object $value): string
{
$class = $value::class;
$pos = strpos($class, '@anonymous');
if ($pos === false) {
return $class;
}

return substr($class, 0, $pos + \strlen('@anonymous'));
}
Comment on lines +43 to +88

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Stringable インターフェースを実装したオブジェクトを文字列キャストする際、__toString() メソッド内で例外(Throwable)がスローされる可能性があります。

もし __toString() が例外をスローした場合、本来スローされるべき UnwrapException がその例外によって上書き(マスク)されてしまい、unwrap 失敗のデバッグが困難になります。

これを防ぐため、Stringable のキャスト処理を try-catch ブロックで囲み、例外が発生した場合はクラス名のみを返すようにフォールバックする設計に改善することをお勧めします。

    private static function describe(mixed $value): string
    {
        return match (true) {
            $value instanceof \\Throwable => \\sprintf('%s: %s', $value::class, $value->getMessage()),
            $value instanceof \\Stringable => self::describeStringable($value),
            \\is_object($value) => $value::class,
            \\is_scalar($value), null === $value => \\var_export($value, true),
            default => \\get_debug_type($value),
        };
    }

    private static function describeStringable(\\Stringable $value): string
    {
        try {
            return \\sprintf('%s: %s', $value::class, (string) $value);
        } catch (\\Throwable) {
            return $value::class;
        }
    }

}
96 changes: 96 additions & 0 deletions tests/ErrTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use PHPUnit\Framework\TestCase;
use Valbeat\Result\Err;
use Valbeat\Result\Ok;
use Valbeat\Result\UnwrapException;

class ErrTest extends TestCase
{
Expand Down Expand Up @@ -58,6 +59,93 @@ public function unwrap_throws_exception(): void
$err->unwrap();
}

#[Test]
public function unwrap_throwsUnwrapException_withErrorValueInMessage(): void
{
$err = new Err('error');
$this->expectException(UnwrapException::class);
$this->expectExceptionMessage("called Result::unwrap() on an Err value: 'error'");
$err->unwrap();
}

#[Test]
public function unwrap_withThrowableError_includesClassAndMessage(): void
{
$err = new Err(new \RuntimeException('boom'));
$this->expectException(UnwrapException::class);
$this->expectExceptionMessage('called Result::unwrap() on an Err value: RuntimeException: boom');
$err->unwrap();
}

#[Test]
public function unwrap_withArrayError_describesType(): void
{
$err = new Err(['code' => 500]);
$this->expectException(UnwrapException::class);
$this->expectExceptionMessage('called Result::unwrap() on an Err value: array');
$err->unwrap();
}

#[Test]
public function unwrapException_remainsCatchableAsLogicException(): void
{
$err = new Err('error');

try {
$err->unwrap();
} catch (\LogicException $e) {
$this->assertInstanceOf(UnwrapException::class, $e);
}
}
Comment on lines +94 to +99

#[Test]
public function unwrap_withThrowingStringableError_stillThrowsUnwrapException(): void
{
$stringable = new class () implements \Stringable {
public function __toString(): string
{
throw new \RuntimeException('rendering failed');
}
};
$err = new Err($stringable);
$this->expectException(UnwrapException::class);
$err->unwrap();
}

#[Test]
public function unwrap_withEnumError_includesCaseName(): void
{
$err = new Err(SampleEnumError::NotFound);
$this->expectException(UnwrapException::class);
$this->expectExceptionMessage('SampleEnumError::NotFound');
$err->unwrap();
}

#[Test]
public function unwrap_withLongStringError_truncatesMessage(): void
{
$err = new Err(str_repeat('a', 10000));

try {
$err->unwrap();
} catch (UnwrapException $e) {
$this->assertLessThan(300, \strlen($e->getMessage()));
$this->assertStringContainsString('(truncated)', $e->getMessage());
}
}

#[Test]
public function unwrap_withMultilineStringError_keepsMessageSingleLine(): void
{
$err = new Err("line1\nline2");

try {
$err->unwrap();
} catch (UnwrapException $e) {
$this->assertStringNotContainsString("\n", $e->getMessage());
}
}

#[Test]
public function unwrapErr_returns_error_value(): void
{
Expand Down Expand Up @@ -351,3 +439,11 @@ private static function asString(string $value): string
return $value;
}
}

/**
* UnwrapException のメッセージが enum のケース名を含むことを検証するためのフィクスチャ.
*/
enum SampleEnumError
{
case NotFound;
}
25 changes: 25 additions & 0 deletions tests/OkTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use PHPUnit\Framework\TestCase;
use Valbeat\Result\Err;
use Valbeat\Result\Ok;
use Valbeat\Result\UnwrapException;

class OkTest extends TestCase
{
Expand Down Expand Up @@ -89,6 +90,30 @@ public function unwrapErr_throws_exception(): void
$ok->unwrapErr();
}

#[Test]
public function unwrapErr_throwsUnwrapException_withValueInMessage(): void
{
$ok = new Ok(42);
$this->expectException(UnwrapException::class);
$this->expectExceptionMessage('called Result::unwrapErr() on an Ok value: 42');
$ok->unwrapErr();
}

#[Test]
public function unwrapErr_withStringableValue_includesClassAndString(): void
{
$value = new class () implements \Stringable {
public function __toString(): string
{
return 'stringable value';
}
};
$ok = new Ok($value);
$this->expectException(UnwrapException::class);
$this->expectExceptionMessage('stringable value');
$ok->unwrapErr();
}
Comment on lines +102 to +115

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

__toString() が例外をスローした場合でも、UnwrapException が正しくスローされ、クラス名にフォールバックされることを検証するテストケースを追加することをお勧めします。

    #[Test]
    public function unwrapErr_withStringableValue_includesClassAndString(): void
    {
        $value = new class () implements \\Stringable {
            public function __toString(): string
            {
                return 'stringable value';
            }
        };
        $ok = new Ok($value);
        $this->expectException(UnwrapException::class);
        $this->expectExceptionMessage('stringable value');
        $ok->unwrapErr();
    }

    #[Test]
    public function unwrapErr_withStringableValueThatThrows_fallsBackToClassName(): void
    {
        $value = new class () implements \\Stringable {
            public function __toString(): string
            {
                throw new \\RuntimeException('toString failed');
            }
        };
        $ok = new Ok($value);
        $this->expectException(UnwrapException::class);
        $this->expectExceptionMessage('called Result::unwrapErr() on an Ok value:');
        $ok->unwrapErr();
    }


#[Test]
public function unwrapOr_returns_value(): void
{
Expand Down
Loading