diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 234b58d..f2d4ba7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,7 +8,7 @@ jobs: strategy: matrix: - php: ['8.3'] + php: ['8.4'] steps: - name: Checkout Code diff --git a/CHANGELOG.md b/CHANGELOG.md index 53bd67a..69597ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,10 @@ -3.3.1 (unreleased) +3.4.0 ===== * (improvement) Log raw JSON when fetching the JSON content from a request fails. +* (feature) Add `ImportData` helper VO. +* (improvement) Add `EntityModel::persist()` as unified `add/edit`. +* (improvement) Require PHP 8.4+ 3.3.0 diff --git a/composer.json b/composer.json index a7b02a9..806f89b 100644 --- a/composer.json +++ b/composer.json @@ -11,20 +11,21 @@ ], "homepage": "https://github.com/21TORR/RadBundle", "require": { - "php": ">= 8.3", + "php": ">= 8.4", "ext-json": "*", "21torr/bundle-helpers": "^2.2", "21torr/html-builder": "^2.1", "psr/log": "^3.0", - "symfony/dependency-injection": "^6.4 || ^7.0", - "symfony/deprecation-contracts": "^3.4", - "symfony/framework-bundle": "^6.4 || ^7.0", - "symfony/http-foundation": "^6.4 || ^7.0", - "symfony/http-kernel": "^6.4 || ^7.0" + "symfony/dependency-injection": "^6.4 || ^7.0 || ^8.0", + "symfony/deprecation-contracts": "^3.5", + "symfony/framework-bundle": "^6.4 || ^7.0 || ^8.0", + "symfony/http-foundation": "^6.4 || ^7.0 || ^8.0", + "symfony/http-kernel": "^6.4 || ^7.0 || ^8.0", + "symfony/property-access": "^7.0 || ^8.0" }, "require-dev": { - "21torr/janus": "^1.3.3", - "bamarni/composer-bin-plugin": "^1.8", + "21torr/janus": "^2.0.0", + "bamarni/composer-bin-plugin": "^1.8.2", "doctrine/dbal": "^3.0 || ^4.0", "doctrine/orm": "^3.0", "roave/security-advisories": "dev-latest", @@ -58,6 +59,7 @@ }, "config": { "allow-plugins": { + "21torr/janus": true, "bamarni/composer-bin-plugin": true }, "sort-packages": true @@ -75,11 +77,11 @@ "scripts": { "fix-lint": [ "@composer bin c-norm normalize \"$(pwd)/composer.json\" --indent-style tab --indent-size 1 --ansi", - "vendor-bin/cs-fixer/vendor/bin/php-cs-fixer fix --diff --config vendor-bin/cs-fixer/vendor/21torr/php-cs-fixer/.php-cs-fixer.dist.php --no-interaction --ansi" + "PHP_CS_FIXER_IGNORE_ENV=1 vendor-bin/cs-fixer/vendor/bin/php-cs-fixer fix --diff --config vendor-bin/cs-fixer/vendor/21torr/php-cs-fixer/.php-cs-fixer.dist.php --no-interaction --ansi" ], "lint": [ "@composer bin c-norm normalize \"$(pwd)/composer.json\" --indent-style tab --indent-size 1 --dry-run --ansi", - "vendor-bin/cs-fixer/vendor/bin/php-cs-fixer check --diff --config vendor-bin/cs-fixer/vendor/21torr/php-cs-fixer/.php-cs-fixer.dist.php --no-interaction --ansi" + "PHP_CS_FIXER_IGNORE_ENV=1 vendor-bin/cs-fixer/vendor/bin/php-cs-fixer check --diff --config vendor-bin/cs-fixer/vendor/21torr/php-cs-fixer/.php-cs-fixer.dist.php --no-interaction --ansi" ], "test": [ "simple-phpunit", diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon new file mode 100644 index 0000000..cd50233 --- /dev/null +++ b/phpstan-baseline.neon @@ -0,0 +1,49 @@ +parameters: + ignoreErrors: + - + message: '#^Method Torr\\Rad\\Controller\\BaseController\:\:normalizeFormErrors\(\) has parameter \$form with generic interface Symfony\\Component\\Form\\FormInterface but does not specify its types\: TData$#' + identifier: missingType.generics + count: 1 + path: src/Controller/BaseController.php + + - + message: '#^Method Torr\\Rad\\Form\\FormErrorNormalizer\:\:normalize\(\) has parameter \$form with generic interface Symfony\\Component\\Form\\FormInterface but does not specify its types\: TData$#' + identifier: missingType.generics + count: 1 + path: src/Form/FormErrorNormalizer.php + + - + message: '#^Method Torr\\Rad\\Form\\FormErrorNormalizer\:\:normalizeNested\(\) has parameter \$parent with generic interface Symfony\\Component\\Form\\FormInterface but does not specify its types\: TData$#' + identifier: missingType.generics + count: 1 + path: src/Form/FormErrorNormalizer.php + + - + message: '#^Method Torr\\Rad\\Import\\ImportData\:\:getEnum\(\) never returns null so it can be removed from the return type\.$#' + identifier: return.unusedType + count: 1 + path: src/Import/ImportData.php + + - + message: '#^Method Torr\\Rad\\Pagination\\Doctrine\\Paginator\:\:fetchPaginated\(\) should return Torr\\Rad\\Pagination\\PaginatedList\ but returns Torr\\Rad\\Pagination\\PaginatedList\\.$#' + identifier: return.type + count: 1 + path: src/Pagination/Doctrine/Paginator.php + + - + message: '#^Method Torr\\Rad\\Pagination\\PaginatedList\:\:fromArray\(\) should return Torr\\Rad\\Pagination\\PaginatedList\ but returns Torr\\Rad\\Pagination\\PaginatedList\\.$#' + identifier: return.type + count: 1 + path: src/Pagination/PaginatedList.php + + - + message: '#^Parameter \#2 \$label of method Torr\\Rad\\Stats\\StatsLog\:\:setLabel\(\) expects string, mixed given\.$#' + identifier: argument.type + count: 1 + path: src/Stats/StatsLog.php + + - + message: '#^Parameter \#3 \$description of method Torr\\Rad\\Stats\\StatsLog\:\:setLabel\(\) expects string\|null, mixed given\.$#' + identifier: argument.type + count: 1 + path: src/Stats/StatsLog.php diff --git a/phpstan.neon b/phpstan.neon index c97c69f..3303d4e 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -1,8 +1,20 @@ includes: - vendor/21torr/janus/phpstan/lib.neon + - phpstan-baseline.neon + +parameters: # If you use simple-phpunit, you need to uncomment the following line. # Always make sure to first run simple-phpunit and then PHPStan. -parameters: bootstrapFiles: - vendor/bin/.phpunit/phpunit/vendor/autoload.php + +# These are temporarily copied here, as normally they should be in the lib.neon of janus. +# However, due to a bug in PHPStan, this currently doesn't work (https://github.com/phpstan/phpstan/issues/12844) + excludePaths: + analyse: + - vendor + analyseAndScan: + - node_modules (?) + - var (?) + - vendor-bin diff --git a/src/Exception/Import/InvalidImportDataException.php b/src/Exception/Import/InvalidImportDataException.php new file mode 100644 index 0000000..20bced7 --- /dev/null +++ b/src/Exception/Import/InvalidImportDataException.php @@ -0,0 +1,12 @@ + + */ +readonly class ImportData implements \IteratorAggregate, \Countable +{ + private PropertyAccessor $accessor; + + public function __construct ( + private array $data, + ) + { + $this->accessor = PropertyAccess::createPropertyAccessor(); + } + + /** + * + */ + public function get (string $path) : mixed + { + // for simple paths, we automatically wrap it in [...], so that you don't have to write it explicitly. + // we only require it for nested paths / complex names + if (preg_match('~^[a-z0-9\\-_]+$~', $path)) + { + $path = "[{$path}]"; + } + + return $this->accessor->getValue($this->data, $path); + } + + // region String + /** + * + */ + public function getString (string $path) : string + { + return $this->filterOutNull( + $this->getOptionalString($path), + "string", + $path, + ); + } + + /** + * + */ + public function getOptionalString (string $path) : ?string + { + $value = $this->get($path); + + if (null === $value) + { + return null; + } + + if (!\is_scalar($value) && !$value instanceof \Stringable) + { + throw new InvalidImportDataException(\sprintf( + "Expected string at path '%s', but got '%s'", + $path, + get_debug_type($value), + )); + } + + return (string) $value; + } + + /** + * Returns the value as string and will never throw + */ + public function getSafeOptionalString (string $path) : ?string + { + try + { + return $this->getOptionalString($path); + } + catch (InvalidImportDataException) + { + return null; + } + } + // endregion + + // region Integer + /** + * + */ + public function getInt (string $path) : int + { + return $this->filterOutNull( + $this->getOptionalInt($path), + "int", + $path, + ); + } + + /** + * + */ + public function getOptionalInt (string $path) : ?int + { + $value = $this->filter($path, "int"); + \assert(null === $value || \is_int($value)); + + return $value; + } + // endregion + + // region Float + /** + * + */ + public function getFloat (string $path) : float + { + return $this->filterOutNull( + $this->getOptionalFloat($path), + "float", + $path, + ); + } + + /** + * + */ + public function getOptionalFloat (string $path) : ?float + { + $value = $this->filter($path, "float"); + \assert(null === $value || \is_float($value)); + + return $value; + } + // endregion + + // region Boolean + /** + * + */ + public function getBoolean (string $path) : bool + { + return $this->filterOutNull( + $this->getOptionalBoolean($path), + "bool", + $path, + ); + } + + /** + * + */ + public function getOptionalBoolean (string $path) : ?bool + { + $value = $this->get($path); + + if (null !== $value && !\is_bool($value)) + { + throw new InvalidImportDataException(\sprintf( + "Expected bool at path '%s', but got '%s'", + $path, + get_debug_type($value), + )); + } + + return $value; + } + // endregion + + /** + * @template EnumClass of \BackedEnum + * + * @param class-string $enumClass + * + * @return EnumClass + */ + public function getEnum (string $path, string $enumClass) : ?\BackedEnum + { + $value = $this->getOptionalEnum($path, $enumClass); + + if (null === $value) + { + throw new InvalidImportDataException(\sprintf( + "Could not fetch value of backed enum of type '%s' at path '%s', as there is no value at this path.", + $enumClass, + $path, + )); + } + + return $value; + } + + /** + * @template EnumClass of \BackedEnum + * + * @param class-string $enumClass + * + * @return EnumClass|null + */ + public function getOptionalEnum (string $path, string $enumClass) : ?\BackedEnum + { + $value = $this->get($path); + + if (null === $value) + { + return null; + } + + if (!\is_int($value) && !\is_string($value)) + { + throw new InvalidImportDataException(\sprintf( + "Could not use value of type '%s' as value for a backed enum of type '%s' at path '%s'", + get_debug_type($value), + $enumClass, + $path, + )); + } + + try + { + return $enumClass::tryFrom($value); + } + catch (UnexpectedValueException $exception) + { + throw new InvalidImportDataException( + message: \sprintf( + "Could not parse value '%s' as value for backend enum '%s' at path '%s'", + $value, + $enumClass, + $path, + ), + previous: $exception, + ); + } + } + + /** + * @phpstan-param "int"|"float" $expectedType + */ + private function filter ( + string $path, + string $expectedType, + ) : mixed + { + $value = $this->get($path); + + if (null === $value) + { + return null; + } + + if (!\is_int($value) && !\is_float($value) && !\is_string($value)) + { + throw new InvalidImportDataException(\sprintf( + "Expected %s at path '%s', but got '%s'", + $expectedType, + $path, + get_debug_type($value), + )); + } + + $filter = match ($expectedType) + { + "int" => \FILTER_VALIDATE_INT, + "float" => \FILTER_VALIDATE_FLOAT, + }; + + $options['flags'] = \FILTER_REQUIRE_SCALAR | \FILTER_NULL_ON_FAILURE; + $filtered = filter_var($value, $filter, $options); + + if (null === $filtered) + { + throw new InvalidImportDataException(\sprintf( + "Expected %s at path '%s', but got '%s'", + $expectedType, + $path, + get_debug_type($value), + )); + } + + return $filtered; + } + + /** + * @template DataType + * + * @param DataType|null $value + * + * @return DataType + */ + private function filterOutNull (mixed $value, string $expectedType, string $path) : mixed + { + if (null === $value) + { + throw new InvalidImportDataException(\sprintf( + "Expected %s at path '%s', but got '%s'", + $expectedType, + $path, + get_debug_type($value), + )); + } + + return $value; + } + + /** + * Returns an iterator for parameters. + * + * @return \ArrayIterator + */ + public function getIterator() : \ArrayIterator + { + return new \ArrayIterator($this->data); + } + + /** + * Returns the number of parameters. + */ + public function count() : int + { + return \count($this->data); + } +} diff --git a/src/Model/EntityModel.php b/src/Model/EntityModel.php index ac7be5f..79e7e80 100644 --- a/src/Model/EntityModel.php +++ b/src/Model/EntityModel.php @@ -47,6 +47,23 @@ public function remove (EntityInterface $entity) : static return $this; } + /** + * If the entity is new, it will "add" it, otherwise it will "update" it + */ + public function persist (EntityInterface $entity) : static + { + if ($entity->isNew()) + { + $this->add($entity); + } + else + { + $this->update($entity); + } + + return $this; + } + /** * @inheritDoc */ diff --git a/src/Structure/ArgumentBag.php b/src/Structure/ArgumentBag.php index 345ff57..a377405 100644 --- a/src/Structure/ArgumentBag.php +++ b/src/Structure/ArgumentBag.php @@ -10,13 +10,13 @@ /** * Stricter version of {@see ParameterBag} for usage in flexible argument lists. * - * @implements \IteratorAggregate - * @implements \ArrayAccess + * @implements \IteratorAggregate + * @implements \ArrayAccess */ final readonly class ArgumentBag implements \IteratorAggregate, \Countable, \ArrayAccess { /** - * @param array $arguments + * @param array $arguments */ public function __construct ( private array $arguments = [], diff --git a/tests/Import/ImportDataTest.php b/tests/Import/ImportDataTest.php new file mode 100644 index 0000000..a5881a6 --- /dev/null +++ b/tests/Import/ImportDataTest.php @@ -0,0 +1,205 @@ + 2, + "int-text" => "3", + "float" => 2.5, + "float-text" => "3.5", + "bool" => true, + "string" => "text", + "enum" => "test", + "null" => null, + "nested" => [ + "a" => 15, + ], + ]); + + // int + self::assertSame(2, $data->getOptionalInt("int")); + self::assertSame(2, $data->getInt("int")); + self::assertSame(3, $data->getOptionalInt("int-text")); + self::assertSame(3, $data->getInt("int-text")); + + // float + self::assertSame(2.5, $data->getOptionalFloat("float")); + self::assertSame(2.5, $data->getFloat("float")); + self::assertSame(3.5, $data->getOptionalFloat("float-text")); + self::assertSame(3.5, $data->getFloat("float-text")); + + // string should transform + self::assertSame("text", $data->getOptionalString("string")); + self::assertSame("text", $data->getString("string")); + self::assertSame("2", $data->getString("int")); + self::assertSame("2.5", $data->getString("float")); + self::assertSame("1", $data->getString("bool")); + + // enum + self::assertSame(ExampleBackedEnum::Test, $data->getEnum("enum", ExampleBackedEnum::class)); + self::assertSame(ExampleBackedEnum::Test, $data->getOptionalEnum("enum", ExampleBackedEnum::class)); + + // nested + self::assertSame(15, $data->getInt("[nested][a]")); + + // safe string + // -> this is an unparseable string, but it will never throw + self::assertNull($data->getSafeOptionalString("nested")); + self::assertSame("text", $data->getSafeOptionalString("string")); + + // optional ignores missing / explicit null paths + self::assertNull($data->getOptionalString("missing")); + self::assertNull($data->getOptionalInt("missing")); + self::assertNull($data->getOptionalFloat("missing")); + self::assertNull($data->getOptionalBoolean("missing")); + self::assertNull($data->getOptionalEnum("missing", ExampleBackedEnum::class)); + self::assertNull($data->getOptionalString("null")); + self::assertNull($data->getOptionalInt("null")); + self::assertNull($data->getOptionalFloat("null")); + self::assertNull($data->getOptionalBoolean("null")); + self::assertNull($data->getOptionalEnum("null", ExampleBackedEnum::class)); + } + + public static function provideInvalid () : iterable + { + // unparseable: required + yield "unparseable int" => [ + static fn (ImportData $data) => $data->getInt("string"), + "Expected int at path 'string', but got 'string'", + ]; + + yield "unparseable float: invalid string" => [ + static fn (ImportData $data) => $data->getFloat("string"), + "Expected float at path 'string', but got 'string'", + ]; + + yield "unparseable float: bool" => [ + static fn (ImportData $data) => $data->getFloat("bool"), + "Expected float at path 'bool', but got 'bool'", + ]; + + yield "unparseable bool" => [ + static fn (ImportData $data) => $data->getBoolean("string"), + "Expected bool at path 'string', but got 'string'", + ]; + + yield "unparseable string" => [ + static fn (ImportData $data) => $data->getString("nested"), + "Expected string at path 'nested', but got 'array'", + ]; + + // unparseable: optional + yield "unparseable optional int" => [ + static fn (ImportData $data) => $data->getOptionalInt("string"), + "Expected int at path 'string', but got 'string'", + ]; + + yield "unparseable optional float: invalid string" => [ + static fn (ImportData $data) => $data->getOptionalFloat("string"), + "Expected float at path 'string', but got 'string'", + ]; + + yield "unparseable optional bool" => [ + static fn (ImportData $data) => $data->getOptionalBoolean("string"), + "Expected bool at path 'string', but got 'string'", + ]; + + yield "unparseable optional string" => [ + static fn (ImportData $data) => $data->getOptionalString("nested"), + "Expected string at path 'nested', but got 'array'", + ]; + + // missing fields + yield "missing string" => [ + static fn (ImportData $data) => $data->getString("missing"), + "Expected string at path 'missing', but got 'null'", + ]; + + yield "missing int" => [ + static fn (ImportData $data) => $data->getInt("missing"), + "Expected int at path 'missing', but got 'null'", + ]; + + yield "missing float" => [ + static fn (ImportData $data) => $data->getFloat("missing"), + "Expected float at path 'missing', but got 'null'", + ]; + + yield "missing bool" => [ + static fn (ImportData $data) => $data->getBoolean("missing"), + "Expected bool at path 'missing', but got 'null'", + ]; + + yield "missing enum" => [ + static fn (ImportData $data) => $data->getEnum("missing", ExampleBackedEnum::class), + "Could not fetch value of backed enum of type 'Tests\Torr\Rad\Fixtures\ExampleBackedEnum' at path 'missing', as there is no value at this path.", + ]; + + yield "explicit null as string" => [ + static fn (ImportData $data) => $data->getString("null"), + "Expected string at path 'null', but got 'null'", + ]; + + yield "explicit null as int" => [ + static fn (ImportData $data) => $data->getInt("null"), + "Expected int at path 'null', but got 'null'", + ]; + + yield "explicit null as float" => [ + static fn (ImportData $data) => $data->getFloat("null"), + "Expected float at path 'null', but got 'null'", + ]; + + yield "explicit null as bool" => [ + static fn (ImportData $data) => $data->getBoolean("null"), + "Expected bool at path 'null', but got 'null'", + ]; + + yield "explicit null as enum" => [ + static fn (ImportData $data) => $data->getEnum("null", ExampleBackedEnum::class), + "Could not fetch value of backed enum of type 'Tests\Torr\Rad\Fixtures\ExampleBackedEnum' at path 'null', as there is no value at this path.", + ]; + } + + /** + * @dataProvider provideInvalid + */ + public function testInvalid ( + callable $callback, + string $expectedExceptionMessage, + ) : void + { + $this->expectException(InvalidImportDataException::class); + $this->expectExceptionMessage($expectedExceptionMessage); + + $data = new ImportData([ + "int" => 2, + "int-text" => "3", + "float" => 2.5, + "float-text" => "3.5", + "bool" => true, + "string" => "text", + "null" => null, + "nested" => [ + "a" => 15, + ], + ]); + + $callback($data); + } +} diff --git a/vendor-bin/c-norm/composer.json b/vendor-bin/c-norm/composer.json index 29d96fe..a3f7a79 100644 --- a/vendor-bin/c-norm/composer.json +++ b/vendor-bin/c-norm/composer.json @@ -1,6 +1,6 @@ { "require-dev": { - "ergebnis/composer-normalize": "^2.42", + "ergebnis/composer-normalize": "^2.48.2", "roave/security-advisories": "dev-latest" }, "config": { diff --git a/vendor-bin/cs-fixer/composer.json b/vendor-bin/cs-fixer/composer.json index ceadfce..61c950e 100644 --- a/vendor-bin/cs-fixer/composer.json +++ b/vendor-bin/cs-fixer/composer.json @@ -1,6 +1,6 @@ { "require-dev": { - "21torr/php-cs-fixer": "^1.1.1", + "21torr/php-cs-fixer": "^1.1.8", "roave/security-advisories": "dev-latest" } } diff --git a/vendor-bin/phpstan/composer.json b/vendor-bin/phpstan/composer.json index cf165ff..4eb9387 100644 --- a/vendor-bin/phpstan/composer.json +++ b/vendor-bin/phpstan/composer.json @@ -3,14 +3,14 @@ "php": "^8.3" }, "require-dev": { - "phpstan/extension-installer": "^1.3.1", - "phpstan/phpstan": "^1.11", - "phpstan/phpstan-deprecation-rules": "^1.2", - "phpstan/phpstan-doctrine": "^1.4", - "phpstan/phpstan-phpunit": "^1.4", - "phpstan/phpstan-symfony": "^1.4", + "phpstan/extension-installer": "^1.4.2", + "phpstan/phpstan": "^2.1.11", + "phpstan/phpstan-deprecation-rules": "^2.0.1", + "phpstan/phpstan-doctrine": "^2.0.2", + "phpstan/phpstan-phpunit": "^2.0.6", + "phpstan/phpstan-symfony": "^2.0.4", "roave/security-advisories": "dev-latest", - "staabm/phpstan-todo-by": "^0.1.25" + "staabm/phpstan-todo-by": "^0.2" }, "config": { "sort-packages": true,