From 2893edb6a3020199e64e588456371b0364465326 Mon Sep 17 00:00:00 2001 From: Jannik Zschiesche Date: Tue, 23 Dec 2025 14:51:50 +0100 Subject: [PATCH 1/8] Add ImportData helper --- CHANGELOG.md | 3 +- composer.json | 11 +- .../Import/InvalidImportDataException.php | 12 + src/Import/ImportData.php | 286 ++++++++++++++++++ 4 files changed, 306 insertions(+), 6 deletions(-) create mode 100644 src/Exception/Import/InvalidImportDataException.php create mode 100644 src/Import/ImportData.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 53bd67a..61dedfa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,8 @@ -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. 3.3.0 diff --git a/composer.json b/composer.json index a7b02a9..7e0d7b3 100644 --- a/composer.json +++ b/composer.json @@ -16,11 +16,12 @@ "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", 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 + { + 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, \FILTER_VALIDATE_INT, [ + "flags" => \FILTER_REQUIRE_SCALAR, + ]); + + return null !== $value + ? (int) $value + : null; + } + // 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, \FILTER_VALIDATE_FLOAT, [ + "flags" => \FILTER_REQUIRE_SCALAR, + ]); + + return null !== $value + ? (float) $value + : null; + } + // 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->filter($path, \FILTER_VALIDATE_BOOL, [ + "flags" => \FILTER_REQUIRE_SCALAR, + ]); + + return null !== $value + ? (bool) $value + : null; + } + // endregion + + /** + * @template EnumClass of \BackedEnum + * + * @param class-string $class + */ + public function getEnum (string $path, string $class) : ?\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), + $class, + $path, + )); + } + + try + { + return $class::from($value); + } + catch (UnexpectedValueException $exception) + { + throw new InvalidImportDataException( + message: \sprintf( + "Could not parse value '%s' as value for backend enum '%s' at path '%s'", + $value, + $class, + $path, + ), + previous: $exception, + ); + } + } + + /** + * @param int $filter FILTER_* constant + * @param array $options Flags from FILTER_* constants + */ + private function filter ( + string $path, + int $filter = \FILTER_DEFAULT, + array $options = [], + ) : mixed + { + $value = $this->get($path); + + if (null === $value) + { + return null; + } + + $options['flags'] ??= 0; + $options['flags'] |= \FILTER_NULL_ON_FAILURE; + $filtered = filter_var($value, $filter, $options); + + if (null === $filtered) + { + throw new InvalidImportDataException(\sprintf( + "Could not properly filter data of type '%s' with filter type '%d' at path '%s'", + get_debug_type($value), + $filter, + $path, + )); + } + + 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); + } +} From b9827ef23cf409e9bb40d3825a3306f0f1c453ba Mon Sep 17 00:00:00 2001 From: Jannik Zschiesche Date: Tue, 23 Dec 2025 14:52:16 +0100 Subject: [PATCH 2/8] Add `EntityModel::persist()` as unified `add/edit` --- CHANGELOG.md | 1 + src/Model/EntityModel.php | 17 +++++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 61dedfa..a81c8b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ * (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`. 3.3.0 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 */ From f59ffeac91fe96b15459f97570be969e7dc69999 Mon Sep 17 00:00:00 2001 From: Jannik Zschiesche Date: Tue, 23 Dec 2025 15:59:07 +0100 Subject: [PATCH 3/8] Add helper for property paths --- src/Import/ImportData.php | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/Import/ImportData.php b/src/Import/ImportData.php index 8159f6e..3c5fa99 100644 --- a/src/Import/ImportData.php +++ b/src/Import/ImportData.php @@ -28,6 +28,13 @@ public function __construct ( */ 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); } From f513c79cebeabc91e9f0aeccd5f66e768d2ee615 Mon Sep 17 00:00:00 2001 From: Jannik Zschiesche Date: Tue, 23 Dec 2025 16:00:04 +0100 Subject: [PATCH 4/8] Make import data more strict --- src/Import/ImportData.php | 97 +++++++++++++++++++++++++++------------ 1 file changed, 67 insertions(+), 30 deletions(-) diff --git a/src/Import/ImportData.php b/src/Import/ImportData.php index 3c5fa99..a2a0bd8 100644 --- a/src/Import/ImportData.php +++ b/src/Import/ImportData.php @@ -109,13 +109,10 @@ public function getInt (string $path) : int */ public function getOptionalInt (string $path) : ?int { - $value = $this->filter($path, \FILTER_VALIDATE_INT, [ - "flags" => \FILTER_REQUIRE_SCALAR, - ]); + $value = $this->filter($path, "int"); + \assert(null === $value || is_int($value)); - return null !== $value - ? (int) $value - : null; + return $value; } // endregion @@ -137,13 +134,10 @@ public function getFloat (string $path) : float */ public function getOptionalFloat (string $path) : ?float { - $value = $this->filter($path, \FILTER_VALIDATE_FLOAT, [ - "flags" => \FILTER_REQUIRE_SCALAR, - ]); + $value = $this->filter($path, "float"); + assert(null === $value || is_float($value)); - return null !== $value - ? (float) $value - : null; + return $value; } // endregion @@ -165,22 +159,50 @@ public function getBoolean (string $path) : bool */ public function getOptionalBoolean (string $path) : ?bool { - $value = $this->filter($path, \FILTER_VALIDATE_BOOL, [ - "flags" => \FILTER_REQUIRE_SCALAR, - ]); + $value = $this->get($path); - return null !== $value - ? (bool) $value - : null; + 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 $class + * @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 getEnum (string $path, string $class) : ?\BackedEnum + public function getOptionalEnum (string $path, string $enumClass) : ?\BackedEnum { $value = $this->get($path); @@ -194,14 +216,14 @@ public function getEnum (string $path, string $class) : ?\BackedEnum 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), - $class, + $enumClass, $path, )); } try { - return $class::from($value); + return $enumClass::tryFrom($value); } catch (UnexpectedValueException $exception) { @@ -209,7 +231,7 @@ public function getEnum (string $path, string $class) : ?\BackedEnum message: \sprintf( "Could not parse value '%s' as value for backend enum '%s' at path '%s'", $value, - $class, + $enumClass, $path, ), previous: $exception, @@ -218,13 +240,13 @@ public function getEnum (string $path, string $class) : ?\BackedEnum } /** + * @phpstan-param "int"|"float" $expectedType * @param int $filter FILTER_* constant * @param array $options Flags from FILTER_* constants */ private function filter ( string $path, - int $filter = \FILTER_DEFAULT, - array $options = [], + string $expectedType, ) : mixed { $value = $this->get($path); @@ -234,17 +256,32 @@ private function filter ( return null; } - $options['flags'] ??= 0; - $options['flags'] |= \FILTER_NULL_ON_FAILURE; + 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( - "Could not properly filter data of type '%s' with filter type '%d' at path '%s'", - get_debug_type($value), - $filter, + "Expected %s at path '%s', but got '%s'", + $expectedType, $path, + get_debug_type($value), )); } From c0e8ca660030f3a1920bb36ec4214f0178074bab Mon Sep 17 00:00:00 2001 From: Jannik Zschiesche Date: Tue, 23 Dec 2025 16:00:09 +0100 Subject: [PATCH 5/8] Add tests for import data --- tests/Import/ImportDataTest.php | 202 ++++++++++++++++++++++++++++++++ 1 file changed, 202 insertions(+) create mode 100644 tests/Import/ImportDataTest.php diff --git a/tests/Import/ImportDataTest.php b/tests/Import/ImportDataTest.php new file mode 100644 index 0000000..0d04eed --- /dev/null +++ b/tests/Import/ImportDataTest.php @@ -0,0 +1,202 @@ + 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 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); + } +} From a6cd7592cbde81399b9b328d140341cc5fb4d437 Mon Sep 17 00:00:00 2001 From: Jannik Zschiesche Date: Tue, 23 Dec 2025 16:09:02 +0100 Subject: [PATCH 6/8] Fix CS --- src/Import/ImportData.php | 12 ++++++------ src/Structure/ArgumentBag.php | 6 +++--- tests/Import/ImportDataTest.php | 9 ++++++--- 3 files changed, 15 insertions(+), 12 deletions(-) diff --git a/src/Import/ImportData.php b/src/Import/ImportData.php index a2a0bd8..5e7fb88 100644 --- a/src/Import/ImportData.php +++ b/src/Import/ImportData.php @@ -110,7 +110,7 @@ public function getInt (string $path) : int public function getOptionalInt (string $path) : ?int { $value = $this->filter($path, "int"); - \assert(null === $value || is_int($value)); + \assert(null === $value || \is_int($value)); return $value; } @@ -135,7 +135,7 @@ public function getFloat (string $path) : float public function getOptionalFloat (string $path) : ?float { $value = $this->filter($path, "float"); - assert(null === $value || is_float($value)); + \assert(null === $value || \is_float($value)); return $value; } @@ -161,7 +161,7 @@ public function getOptionalBoolean (string $path) : ?bool { $value = $this->get($path); - if (null !== $value && !is_bool($value)) + if (null !== $value && !\is_bool($value)) { throw new InvalidImportDataException(\sprintf( "Expected bool at path '%s', but got '%s'", @@ -178,6 +178,7 @@ public function getOptionalBoolean (string $path) : ?bool * @template EnumClass of \BackedEnum * * @param class-string $enumClass + * * @return EnumClass */ public function getEnum (string $path, string $enumClass) : ?\BackedEnum @@ -200,6 +201,7 @@ public function getEnum (string $path, string $enumClass) : ?\BackedEnum * @template EnumClass of \BackedEnum * * @param class-string $enumClass + * * @return EnumClass|null */ public function getOptionalEnum (string $path, string $enumClass) : ?\BackedEnum @@ -241,8 +243,6 @@ public function getOptionalEnum (string $path, string $enumClass) : ?\BackedEnum /** * @phpstan-param "int"|"float" $expectedType - * @param int $filter FILTER_* constant - * @param array $options Flags from FILTER_* constants */ private function filter ( string $path, @@ -256,7 +256,7 @@ private function filter ( return null; } - if (!is_int($value) && !is_float($value) && !\is_string($value)) + if (!\is_int($value) && !\is_float($value) && !\is_string($value)) { throw new InvalidImportDataException(\sprintf( "Expected %s at path '%s', but got '%s'", 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 index 0d04eed..a5881a6 100644 --- a/tests/Import/ImportDataTest.php +++ b/tests/Import/ImportDataTest.php @@ -2,12 +2,15 @@ namespace Tests\Torr\Rad\Import; +use PHPUnit\Framework\TestCase; use Tests\Torr\Rad\Fixtures\ExampleBackedEnum; use Torr\Rad\Exception\Import\InvalidImportDataException; use Torr\Rad\Import\ImportData; -use PHPUnit\Framework\TestCase; -class ImportDataTest extends TestCase +/** + * @internal + */ +final class ImportDataTest extends TestCase { /** * @@ -72,7 +75,7 @@ public function testValid () : void self::assertNull($data->getOptionalEnum("null", ExampleBackedEnum::class)); } - public function provideInvalid () : iterable + public static function provideInvalid () : iterable { // unparseable: required yield "unparseable int" => [ From 8ac08ae5491aa84c3d3ce3a6008e064183772277 Mon Sep 17 00:00:00 2001 From: Jannik Zschiesche Date: Tue, 23 Dec 2025 16:09:19 +0100 Subject: [PATCH 7/8] Bump CI + require PHP 8.4 --- CHANGELOG.md | 1 + composer.json | 11 +++---- phpstan-baseline.neon | 49 +++++++++++++++++++++++++++++++ phpstan.neon | 14 ++++++++- vendor-bin/c-norm/composer.json | 2 +- vendor-bin/cs-fixer/composer.json | 2 +- vendor-bin/phpstan/composer.json | 14 ++++----- 7 files changed, 78 insertions(+), 15 deletions(-) create mode 100644 phpstan-baseline.neon diff --git a/CHANGELOG.md b/CHANGELOG.md index a81c8b3..69597ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ * (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 7e0d7b3..806f89b 100644 --- a/composer.json +++ b/composer.json @@ -11,7 +11,7 @@ ], "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", @@ -24,8 +24,8 @@ "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", @@ -59,6 +59,7 @@ }, "config": { "allow-plugins": { + "21torr/janus": true, "bamarni/composer-bin-plugin": true }, "sort-packages": true @@ -76,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/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, From 802401f2a3c617eb4e99c60bf86efeeade940c34 Mon Sep 17 00:00:00 2001 From: Jannik Zschiesche Date: Tue, 23 Dec 2025 16:10:14 +0100 Subject: [PATCH 8/8] Run CI with PHP 8.4 --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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