From 99a846db52dff1bdf0699ab2a09bd06610225e36 Mon Sep 17 00:00:00 2001 From: Petr Knap <8299754+petrknap@users.noreply.github.com> Date: Sun, 19 May 2024 13:51:13 +0200 Subject: [PATCH] refactor: refactored abstraction --- README.md | 4 +- src/AbstractOptional.php | 205 ----------------- src/AbstractOptionalObject.php | 29 --- src/AbstractOptionalResource.php | 29 --- src/AnOptional.php | 22 ++ .../CouldNotGetValueOfEmptyOptional.php | 11 + src/Exception/NoSuchElement.php | 11 - src/NoSuchElementException.php | 14 ++ src/Optional.php | 210 ++++++++++++++++-- src/OptionalObject.php | 23 +- src/OptionalResource.php | 27 ++- src/TypedOptional.php | 5 +- tests/OptionalTest.php | 4 +- tests/TypedOptionalsTest.php | 10 +- 14 files changed, 280 insertions(+), 324 deletions(-) delete mode 100644 src/AbstractOptional.php delete mode 100644 src/AbstractOptionalObject.php delete mode 100644 src/AbstractOptionalResource.php create mode 100644 src/AnOptional.php create mode 100644 src/Exception/CouldNotGetValueOfEmptyOptional.php delete mode 100644 src/Exception/NoSuchElement.php create mode 100644 src/NoSuchElementException.php diff --git a/README.md b/README.md index 902fc81..8ae19ad 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ It is an easy way to make sure that everyone has to check if they have (not) rec ```php namespace PetrKnap\Optional; -$optionalString = OptionalString::of('value'); +$optionalString = Optional::of('value'); echo $optionalString->isPresent() ? $optionalString->get() : 'empty'; echo $optionalString->orElse('empty'); @@ -30,7 +30,7 @@ if ($optionalString->equals('value')) { } echo $optionalString->map(fn ($s) => "`{$s}`")->orElse('empty'); -echo $optionalString->flatMap(fn ($s) => OptionalString::of("`{$s}`"))->orElse('empty'); +echo $optionalString->flatMap(fn ($s) => Optional::of("`{$s}`"))->orElse('empty'); ``` ### Create and use your own typed optional diff --git a/src/AbstractOptional.php b/src/AbstractOptional.php deleted file mode 100644 index acf1995..0000000 --- a/src/AbstractOptional.php +++ /dev/null @@ -1,205 +0,0 @@ -value !== null && !static::isSupported($this->value)) { - throw new InvalidArgumentException('Value is not supported.'); - } - } - - public static function empty(): static - { - return new static(null); - } - - /** - * @param T $value - */ - public static function of(mixed $value): static - { - return $value !== null ? new static($value) : throw new InvalidArgumentException('Value must not be null.'); - } - - /** - * @param T|null $value - */ - public static function ofNullable(mixed $value): static - { - return new static($value); - } - - public function equals(mixed $obj): bool - { - if ($obj instanceof static) { - $obj = $obj->isPresent() ? $obj->get() : null; - } - return ($obj === null || static::isSupported($obj)) && $this->value == $obj; - } - - /** - * @param callable(T): bool $predicate - * - * @return static - */ - public function filter(callable $predicate): static - { - if ($this->value !== null) { - $matches = $predicate($this->value); - if (!is_bool($matches)) { - throw new InvalidArgumentException('Predicate must return boolean.'); - } - if (!$matches) { - return static::empty(); - } - } - return $this; - } - - /** - * @template U of mixed - * - * @param callable(T): self $mapper - * - * @return self - */ - public function flatMap(callable $mapper): self - { - if ($this->value !== null) { - $mapped = $mapper($this->value); - if (!$mapped instanceof self) { - throw new InvalidArgumentException('Mapper must return instance of ' . self::class . '.'); - } - return $mapped; - } - return $this; - } - - /** - * @return T - * - * @throws Exception\NoSuchElement - */ - public function get(): mixed - { - if ($this->wasPresent === null) { - trigger_error( - 'Call `isPresent()` before accessing the value.', - error_level: E_USER_NOTICE, - ); - } - return $this->orElseThrow(static fn (): Exception\NoSuchElement => new Exception\NoSuchElement()); - } - - /** - * @param callable(T): void $consumer - */ - public function ifPresent(callable $consumer): void - { - if ($this->value !== null) { - $consumer($this->value); - } - } - - public function isPresent(): bool - { - return $this->wasPresent = $this->value !== null; - } - - /** - * @template U of mixed - * - * @param callable(T): U $mapper - * - * @return self - */ - public function map(callable $mapper): self - { - /** @var callable(T): self $flatMapper */ - $flatMapper = static function (mixed $value) use ($mapper): self { - $mapped = $mapper($value); - try { - return TypedOptional::of($mapped); - } catch (Exception\CouldNotFindTypedOptionalForValue) { - return Optional::of($mapped); - } - }; - return $this->flatMap($flatMapper); - } - - /** - * @param T $other - * - * @return T - */ - public function orElse(mixed $other): mixed - { - return $this->orElseGet(static fn (): mixed => $other); - } - - /** - * @param callable(): T $otherSupplier - * - * @return T - */ - public function orElseGet(callable $otherSupplier): mixed - { - if ($this->value !== null) { - return $this->value; - } - $other = $otherSupplier(); - return static::isSupported($other) ? $other : throw new InvalidArgumentException('Other supplier must return supported other.'); - } - - /** - * @template E of Throwable - * - * @param callable(): E $exceptionSupplier - * - * @return T - * - * @throws E - */ - public function orElseThrow(callable $exceptionSupplier): mixed - { - return $this->orElseGet(static function () use ($exceptionSupplier): never { - /** @var Throwable|mixed $exception */ - $exception = $exceptionSupplier(); - if ($exception instanceof Throwable) { - throw $exception; - } - throw new InvalidArgumentException('Exception supplier must return ' . Throwable::class . '.'); - }); - } - - /** - * @param mixed $value not null - */ - abstract protected static function isSupported(mixed $value): bool; -} diff --git a/src/AbstractOptionalObject.php b/src/AbstractOptionalObject.php deleted file mode 100644 index a82d7e9..0000000 --- a/src/AbstractOptionalObject.php +++ /dev/null @@ -1,29 +0,0 @@ - - */ -abstract class AbstractOptionalObject extends Optional -{ - protected static function isSupported(mixed $value): bool - { - /** @var string $expectedObjectClassName */ - $expectedObjectClassName = static::getObjectClassName(); - return is_object($value) && ($expectedObjectClassName === '' || $value instanceof $expectedObjectClassName); - } - - /** - * @return class-string - */ - abstract protected static function getObjectClassName(): string; -} diff --git a/src/AbstractOptionalResource.php b/src/AbstractOptionalResource.php deleted file mode 100644 index 534c477..0000000 --- a/src/AbstractOptionalResource.php +++ /dev/null @@ -1,29 +0,0 @@ - - */ -abstract class AbstractOptionalResource extends Optional -{ - protected static function isSupported(mixed $value): bool - { - /** @var string $expectedResourceType */ - $expectedResourceType = static::getResourceType(); - return is_resource($value) && ($expectedResourceType === '' || get_resource_type($value) === $expectedResourceType); - } - - /** - * @see get_resource_type() - * - * @return non-empty-string - */ - abstract protected static function getResourceType(): string; -} diff --git a/src/AnOptional.php b/src/AnOptional.php new file mode 100644 index 0000000..813178c --- /dev/null +++ b/src/AnOptional.php @@ -0,0 +1,22 @@ + + */ +final class AnOptional extends Optional +{ + protected static function isSupported(mixed $value): bool + { + trigger_error( + self::class . ' does not check the type of value.', + error_level: E_USER_NOTICE, + ); + return true; + } +} diff --git a/src/Exception/CouldNotGetValueOfEmptyOptional.php b/src/Exception/CouldNotGetValueOfEmptyOptional.php new file mode 100644 index 0000000..6ac6ac0 --- /dev/null +++ b/src/Exception/CouldNotGetValueOfEmptyOptional.php @@ -0,0 +1,11 @@ + + * @see https://docs.oracle.com/javase/8/docs/api/java/util/Optional.html */ -/* abstract */ class Optional extends AbstractOptional +abstract class Optional { - /* abstract */ protected static function isSupported(mixed $value): bool + private bool|null $wasPresent = null; + + /** + * @param T|null $value + */ + final protected function __construct( + protected readonly mixed $value, + ) { + if ($this->value !== null && !static::isSupported($this->value)) { + throw new InvalidArgumentException('Value is not supported.'); + } + } + + public static function empty(): static + { + return static::ofNullable(null); + } + + /** + * @param T $value + */ + public static function of(mixed $value): static + { + if ($value === null) { + throw new InvalidArgumentException('Value must not be null.'); + } + return static::ofNullable($value); + } + + /** + * @param T|null $value + */ + public static function ofNullable(mixed $value): static + { + if (self::class === static::class) { // @phpstan-ignore-line + try { + /** @var static */ + return TypedOptional::of($value); + } catch (Exception\CouldNotFindTypedOptionalForValue) { + /** @var static */ + return AnOptional::ofNullable($value); + } + } + return new static($value); + } + + public function equals(mixed $obj): bool + { + if ($obj instanceof static) { + $obj = $obj->isPresent() ? $obj->get() : null; + } + return ($obj === null || static::isSupported($obj)) && $this->value == $obj; + } + + /** + * @param callable(T): bool $predicate + * + * @return static + */ + public function filter(callable $predicate): static + { + if ($this->value !== null) { + $matches = $predicate($this->value); + if (!is_bool($matches)) { + throw new InvalidArgumentException('Predicate must return boolean.'); + } + if (!$matches) { + return static::empty(); + } + } + return $this; + } + + /** + * @template U of mixed + * + * @param callable(T): self $mapper + * + * @return self + */ + public function flatMap(callable $mapper): self { - trigger_error( - static::class . ' does not check the type of value.', - error_level: E_USER_NOTICE, - ); - return true; + if ($this->value !== null) { + $mapped = $mapper($this->value); + if (!$mapped instanceof self) { + throw new InvalidArgumentException('Mapper must return instance of ' . self::class . '.'); + } + return $mapped; + } + return $this; } + + /** + * @return T + * + * @throws NoSuchElementException + */ + public function get(): mixed + { + if ($this->wasPresent === null) { + trigger_error( + 'Call `isPresent()` before accessing the value.', + error_level: E_USER_NOTICE, + ); + } + return $this->orElseThrow(static fn (): Exception\CouldNotGetValueOfEmptyOptional => new Exception\CouldNotGetValueOfEmptyOptional()); + } + + /** + * @param callable(T): void $consumer + */ + public function ifPresent(callable $consumer): void + { + if ($this->value !== null) { + $consumer($this->value); + } + } + + public function isPresent(): bool + { + return $this->wasPresent = $this->value !== null; + } + + /** + * @template U of mixed + * + * @param callable(T): U $mapper + * + * @return self + */ + public function map(callable $mapper): self + { + /** @var callable(T): self $flatMapper */ + $flatMapper = static function (mixed $value) use ($mapper): self { + $mapped = $mapper($value); + try { + return TypedOptional::of($mapped); + } catch (Exception\CouldNotFindTypedOptionalForValue) { + return AnOptional::of($mapped); + } + }; + return $this->flatMap($flatMapper); + } + + /** + * @param T $other + * + * @return T + */ + public function orElse(mixed $other): mixed + { + return $this->orElseGet(static fn (): mixed => $other); + } + + /** + * @param callable(): T $otherSupplier + * + * @return T + */ + public function orElseGet(callable $otherSupplier): mixed + { + if ($this->value !== null) { + return $this->value; + } + $other = $otherSupplier(); + return static::isSupported($other) ? $other : throw new InvalidArgumentException('Other supplier must return supported other.'); + } + + /** + * @template E of Throwable + * + * @param callable(): E $exceptionSupplier + * + * @return T + * + * @throws E + */ + public function orElseThrow(callable $exceptionSupplier): mixed + { + return $this->orElseGet(static function () use ($exceptionSupplier): never { + /** @var Throwable|mixed $exception */ + $exception = $exceptionSupplier(); + if ($exception instanceof Throwable) { + throw $exception; + } + throw new InvalidArgumentException('Exception supplier must return ' . Throwable::class . '.'); + }); + } + + /** + * @param mixed $value not null + */ + abstract protected static function isSupported(mixed $value): bool; } diff --git a/src/OptionalObject.php b/src/OptionalObject.php index a9ec9f0..e3b2d0d 100644 --- a/src/OptionalObject.php +++ b/src/OptionalObject.php @@ -1,9 +1,5 @@ + * @template-extends Optional */ -/* abstract */ class OptionalObject extends AbstractOptionalObject +abstract class OptionalObject extends Optional { - /* abstract */ protected static function getObjectClassName(): string + protected static function isSupported(mixed $value): bool { - trigger_error( - static::class . ' does not check the instance of object.', - error_level: E_USER_NOTICE, - ); - /** @var class-string */ - return ''; + $expectedObjectClassName = static::getObjectClassName(); + return $value instanceof $expectedObjectClassName; } + + /** + * @return class-string + */ + abstract protected static function getObjectClassName(): string; } diff --git a/src/OptionalResource.php b/src/OptionalResource.php index 574c588..2d2939b 100644 --- a/src/OptionalResource.php +++ b/src/OptionalResource.php @@ -1,22 +1,25 @@ + */ +abstract class OptionalResource extends Optional { - /* abstract */ protected static function getResourceType(): string + protected static function isSupported(mixed $value): bool { - trigger_error( - static::class . ' does not check the type of resource.', - error_level: E_USER_NOTICE, - ); - /** @var non-empty-string */ - return ''; + /** @var string $expectedResourceType */ + $expectedResourceType = static::getResourceType(); + return is_resource($value) && get_resource_type($value) === $expectedResourceType; } + + /** + * @see get_resource_type() + * + * @return non-empty-string + */ + abstract protected static function getResourceType(): string; } diff --git a/src/TypedOptional.php b/src/TypedOptional.php index 521771b..f3f3c12 100644 --- a/src/TypedOptional.php +++ b/src/TypedOptional.php @@ -6,6 +6,9 @@ use InvalidArgumentException; +/** + * @internal use {@see Optional} + */ final class TypedOptional { /** @var array must be iterated in reverse order */ @@ -14,9 +17,7 @@ final class TypedOptional OptionalBool::class, OptionalFloat::class, OptionalInt::class, - OptionalObject::class, OptionalObject\OptionalStdClass::class, - OptionalResource::class, OptionalResource\OptionalStream::class, OptionalString::class, ]; diff --git a/tests/OptionalTest.php b/tests/OptionalTest.php index df23d15..c634b72 100644 --- a/tests/OptionalTest.php +++ b/tests/OptionalTest.php @@ -80,7 +80,7 @@ public static function dataMethodEqualsWorks(): array public function testMethodFilterWorks(Optional $optional, bool $expected): void { self::assertEquals( - $expected ? $optional : Optional::empty(), + $expected ? $optional : $optional::empty(), $optional->filter(static fn (string $value): bool => $value === self::VALUE), ); } @@ -120,7 +120,7 @@ public static function dataMethodGetWorks(): array { return self::makeDataSet([ [self::VALUE, null], - [null, Exception\NoSuchElement::class], + [null, NoSuchElementException::class], ]); } diff --git a/tests/TypedOptionalsTest.php b/tests/TypedOptionalsTest.php index 628c5ad..ca524c6 100644 --- a/tests/TypedOptionalsTest.php +++ b/tests/TypedOptionalsTest.php @@ -34,12 +34,10 @@ public static function dataCouldBeCreated(): array 'string' => [OptionalString::class, ''], // Non-scalars 'array' => [OptionalArray::class, []], - 'object' => [OptionalObject::class, new stdClass(), ['object(stdClass)']], - 'resource' => [OptionalResource::class, tmpfile(), ['resource(stream)']], // Objects - 'object(stdClass)' => [OptionalObject\OptionalStdClass::class, new stdClass(), ['object']], + 'object(stdClass)' => [OptionalObject\OptionalStdClass::class, new stdClass()], // Resources - 'resource(stream)' => [OptionalResource\OptionalStream::class, tmpfile(), ['resource']], + 'resource(stream)' => [OptionalResource\OptionalStream::class, tmpfile()], ]; } @@ -57,9 +55,9 @@ public static function dataCouldNotBeCreatedWithWrongType(): iterable { $supportedValues = self::dataCouldBeCreated(); - foreach ($supportedValues as $supportedCase => [$optionalClassName, $_, $alsoSupportedCases]) { + foreach ($supportedValues as $supportedCase => [$optionalClassName, $_]) { foreach ($supportedValues as $unsupportedCase => [$_, $value]) { - if (in_array($unsupportedCase, [$supportedCase, ...($alsoSupportedCases ?? [])])) { + if ($unsupportedCase === $supportedCase) { continue; } yield "({$supportedCase}) {$unsupportedCase}" => [$optionalClassName, $value];