diff --git a/README.md b/README.md index 5731439..7626e6d 100644 --- a/README.md +++ b/README.md @@ -14,8 +14,7 @@ It is an easy way to make sure that everyone has to check if they have (not) rec ```php namespace PetrKnap\Optional; -/** @var Optinal $optionalString */ -$optionalString = Optional::of('value'); +$optionalString = OptionalString::of('value'); echo $optionalString->isPresent() ? $optionalString->get() : 'empty'; echo $optionalString->orElse('empty'); diff --git a/composer.json b/composer.json index 4b9f68a..738072c 100755 --- a/composer.json +++ b/composer.json @@ -14,6 +14,9 @@ "allow-plugins": false, "sort-packages": true }, + "conflict": { + "petrknap/shorts": "<2.1.1" + }, "description": "Optional (like in Java Platform SE 8 but in PHP)", "funding": [ { @@ -28,11 +31,11 @@ "license": "LGPL-3.0-or-later", "name": "petrknap/optional", "require": { - "php": ">=8.1" + "php": ">=8.1", + "petrknap/shorts": "^2.1" }, "require-dev": { "nunomaduro/phpinsights": "^2.11", - "petrknap/shorts": "^2.1", "phpstan/phpstan": "^1.10", "phpunit/phpunit": "^10.5", "squizlabs/php_codesniffer": "^3.7" diff --git a/src/AbstractOptional.php b/src/AbstractOptional.php new file mode 100644 index 0000000..84b0b37 --- /dev/null +++ b/src/AbstractOptional.php @@ -0,0 +1,141 @@ +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; + } + + /** + * @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; + } + + /** + * @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 T|mixed $value not null + */ + abstract protected static function isSupported(mixed $value): bool; +} diff --git a/src/AbstractOptionalObject.php b/src/AbstractOptionalObject.php new file mode 100644 index 0000000..b6f5671 --- /dev/null +++ b/src/AbstractOptionalObject.php @@ -0,0 +1,24 @@ + + */ +abstract class AbstractOptionalObject extends AbstractOptional +{ + protected static function isSupported(mixed $value): bool + { + $expectedObjectClassName = static::getObjectClassName(); + return $value instanceof $expectedObjectClassName; + } + + /** + * @return class-string + */ + abstract protected static function getObjectClassName(): string; +} diff --git a/src/AbstractOptionalResource.php b/src/AbstractOptionalResource.php new file mode 100644 index 0000000..7c0e68e --- /dev/null +++ b/src/AbstractOptionalResource.php @@ -0,0 +1,24 @@ + + */ +abstract class AbstractOptionalResource extends AbstractOptional +{ + protected static function isSupported(mixed $value): bool + { + $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/Optional.php b/src/Optional.php index 3fb9735..d2fcc81 100644 --- a/src/Optional.php +++ b/src/Optional.php @@ -8,135 +8,16 @@ use Throwable; /** - * @template T of mixed type of non-null value + * Please use another implementation of {@see AbstractOptional} if possible. * - * @see https://docs.oracle.com/javase/8/docs/api/java/util/Optional.html + * @todo make it final + * + * @template T of mixed + * + * @template-extends AbstractOptional */ -class Optional +class Optional extends AbstractOptional { - private bool|null $wasPresent = null; - - /** - * @deprecated will be changed to protected - use {@see self::ofNullable()}/{@see self::of()}/{@see self::empty()} - * - * @param T|null $value - */ - final public 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 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; - } - - /** - * @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; - } - - /** - * @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 T|mixed $value not null - */ protected static function isSupported(mixed $value): bool { trigger_error( diff --git a/src/OptionalArray.php b/src/OptionalArray.php new file mode 100644 index 0000000..84ff6ef --- /dev/null +++ b/src/OptionalArray.php @@ -0,0 +1,19 @@ +> + */ +final class OptionalArray extends AbstractOptional +{ + protected static function isSupported(mixed $value): bool + { + return is_array($value); + } +} diff --git a/src/OptionalBool.php b/src/OptionalBool.php new file mode 100644 index 0000000..617e721 --- /dev/null +++ b/src/OptionalBool.php @@ -0,0 +1,16 @@ + + */ +final class OptionalBool extends AbstractOptional +{ + protected static function isSupported(mixed $value): bool + { + return is_bool($value); + } +} diff --git a/src/OptionalFloat.php b/src/OptionalFloat.php new file mode 100644 index 0000000..94c7f07 --- /dev/null +++ b/src/OptionalFloat.php @@ -0,0 +1,16 @@ + + */ +final class OptionalFloat extends AbstractOptional +{ + protected static function isSupported(mixed $value): bool + { + return is_float($value); + } +} diff --git a/src/OptionalInt.php b/src/OptionalInt.php new file mode 100644 index 0000000..6d7e20e --- /dev/null +++ b/src/OptionalInt.php @@ -0,0 +1,16 @@ + + */ +final class OptionalInt extends AbstractOptional +{ + protected static function isSupported(mixed $value): bool + { + return is_int($value); + } +} diff --git a/src/OptionalObject.php b/src/OptionalObject.php new file mode 100644 index 0000000..7dfa8c4 --- /dev/null +++ b/src/OptionalObject.php @@ -0,0 +1,31 @@ + + */ +final class OptionalObject extends AbstractOptionalObject +{ + protected static function isSupported(mixed $value): bool + { + trigger_error( + self::class . ' does not check the instance of object.', + error_level: E_USER_NOTICE, + ); + return is_object($value); + } + + protected static function getObjectClassName(): string + { + NotImplemented::throw(__METHOD__); + } +} diff --git a/src/OptionalObject/OptionalStdClass.php b/src/OptionalObject/OptionalStdClass.php new file mode 100644 index 0000000..b3913e7 --- /dev/null +++ b/src/OptionalObject/OptionalStdClass.php @@ -0,0 +1,19 @@ + + */ +final class OptionalStdClass extends AbstractOptionalObject +{ + protected static function getObjectClassName(): string + { + return stdClass::class; + } +} diff --git a/src/OptionalResource.php b/src/OptionalResource.php new file mode 100644 index 0000000..2f6d836 --- /dev/null +++ b/src/OptionalResource.php @@ -0,0 +1,27 @@ + + */ +final class OptionalString extends AbstractOptional +{ + protected static function isSupported(mixed $value): bool + { + return is_string($value); + } +} diff --git a/tests/TypedOptionalsTest.php b/tests/TypedOptionalsTest.php new file mode 100644 index 0000000..e7d8635 --- /dev/null +++ b/tests/TypedOptionalsTest.php @@ -0,0 +1,78 @@ + $optionalClassName + */ + #[DataProvider('dataCouldBeCreated')] + public function testCouldBeCreated(string $optionalClassName, mixed $value): void + { + self::assertInstanceOf($optionalClassName, $optionalClassName::empty()); + self::assertInstanceOf($optionalClassName, $optionalClassName::of($value)); + self::assertInstanceOf($optionalClassName, $optionalClassName::ofNullable($value)); + self::assertInstanceOf($optionalClassName, $optionalClassName::ofNullable(null)); + } + + public static function dataCouldBeCreated(): array + { + return [ + // Scalars + 'bool' => [OptionalBool::class, true], + 'float' => [OptionalFloat::class, .1], + 'int' => [OptionalInt::class, 1], + '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']], + // Resources + 'resource(stream)' => [OptionalResource\OptionalStream::class, tmpfile(), ['resource']], + ]; + } + + /** + * @param class-string $optionalClassName + */ + #[DataProvider('dataCouldNotBeCreatedWithWrongType')] + public function testCouldNotBeCreatedWithWrongType(string $optionalClassName, mixed $value): void + { + self::expectException(InvalidArgumentException::class); + $optionalClassName::of($value); + } + + public static function dataCouldNotBeCreatedWithWrongType(): iterable + { + $supportedValues = self::dataCouldBeCreated(); + + foreach ($supportedValues as $supportedCase => [$optionalClassName, $_, $alsoSupportedCases]) { + foreach ($supportedValues as $unsupportedCase => [$_, $value]) { + if (in_array($unsupportedCase, [$supportedCase, ...($alsoSupportedCases ?? [])])) { + continue; + } + yield "({$supportedCase}) {$unsupportedCase}" => [$optionalClassName, $value]; + } + } + } + + public function testTwoEmptiesOfSameTypeAreEqual(): void + { + self::assertTrue(OptionalString::empty()->equals(OptionalString::empty())); + } + + public function testTwoEmptiesOfDifferentTypesAreNotEqual(): void + { + self::assertFalse(OptionalString::empty()->equals(OptionalBool::empty())); + } +}