From c088cf0a14c4df4efce86db5a220632961fefd53 Mon Sep 17 00:00:00 2001 From: Luis Medina <6675716+luimedi@users.noreply.github.com> Date: Wed, 4 Mar 2026 20:08:24 +0100 Subject: [PATCH 1/8] Move all interfaces to a folder `Contracts` --- src/Attribute/Cast/CastDateTime.php | 3 ++- src/Attribute/Cast/CastDefault.php | 3 ++- src/Attribute/Cast/CastIterable.php | 3 ++- src/Attribute/Cast/CastTransformer.php | 7 ++++--- src/Attribute/ConstructorMapper.php | 6 ++++-- src/Attribute/MapGetter.php | 3 ++- src/Attribute/MapProperty.php | 3 ++- src/Attribute/PropertyMapper.php | 6 ++++-- src/Context.php | 2 +- src/{Attribute/Cast => Contracts}/CastInterface.php | 4 ++-- src/{ => Contracts}/ContextInterface.php | 4 ++-- src/{ => Contracts}/EngineInterface.php | 2 +- src/{Attribute => Contracts}/MapInterface.php | 4 ++-- src/{Attribute => Contracts}/TransformerInterface.php | 4 ++-- src/Engine.php | 8 +++++--- src/Mapper.php | 2 ++ 16 files changed, 39 insertions(+), 25 deletions(-) rename src/{Attribute/Cast => Contracts}/CastInterface.php (68%) rename src/{ => Contracts}/ContextInterface.php (89%) rename src/{ => Contracts}/EngineInterface.php (96%) rename src/{Attribute => Contracts}/MapInterface.php (66%) rename src/{Attribute => Contracts}/TransformerInterface.php (78%) diff --git a/src/Attribute/Cast/CastDateTime.php b/src/Attribute/Cast/CastDateTime.php index fc747c8..0a6e7c4 100644 --- a/src/Attribute/Cast/CastDateTime.php +++ b/src/Attribute/Cast/CastDateTime.php @@ -5,7 +5,8 @@ use Attribute; use DateTime; use DateTimeInterface; -use Luimedi\Remap\ContextInterface; +use Luimedi\Remap\Contracts\CastInterface; +use Luimedi\Remap\Contracts\ContextInterface; use Luimedi\Remap\MappingTarget; #[Attribute(Attribute::TARGET_PARAMETER | Attribute::TARGET_PROPERTY)] diff --git a/src/Attribute/Cast/CastDefault.php b/src/Attribute/Cast/CastDefault.php index a1110a7..7e007fc 100644 --- a/src/Attribute/Cast/CastDefault.php +++ b/src/Attribute/Cast/CastDefault.php @@ -5,7 +5,8 @@ use Attribute; use DateTime; use DateTimeInterface; -use Luimedi\Remap\ContextInterface; +use Luimedi\Remap\Contracts\CastInterface; +use Luimedi\Remap\Contracts\ContextInterface; use Luimedi\Remap\MappingTarget; #[Attribute(Attribute::TARGET_PARAMETER | Attribute::TARGET_PROPERTY)] diff --git a/src/Attribute/Cast/CastIterable.php b/src/Attribute/Cast/CastIterable.php index a22740c..6fe835b 100644 --- a/src/Attribute/Cast/CastIterable.php +++ b/src/Attribute/Cast/CastIterable.php @@ -4,7 +4,8 @@ use ArrayIterator; use Attribute; -use Luimedi\Remap\ContextInterface; +use Luimedi\Remap\Contracts\CastInterface; +use Luimedi\Remap\Contracts\ContextInterface; use Luimedi\Remap\MappingTarget; #[Attribute(Attribute::TARGET_PARAMETER | Attribute::TARGET_PROPERTY)] diff --git a/src/Attribute/Cast/CastTransformer.php b/src/Attribute/Cast/CastTransformer.php index 40e7a4b..047b1f9 100644 --- a/src/Attribute/Cast/CastTransformer.php +++ b/src/Attribute/Cast/CastTransformer.php @@ -3,16 +3,17 @@ namespace Luimedi\Remap\Attribute\Cast; use Attribute; -use Luimedi\Remap\ContextInterface; +use Luimedi\Remap\Contracts\CastInterface; +use Luimedi\Remap\Contracts\ContextInterface; +use Luimedi\Remap\Contracts\TransformerInterface; use Luimedi\Remap\MappingTarget; -use Luimedi\Remap\Attribute\Cast\CastInterface; #[Attribute(Attribute::TARGET_PARAMETER | Attribute::TARGET_PROPERTY)] class CastTransformer implements CastInterface { public function cast(mixed $value, ContextInterface $context, MappingTarget $mappingTarget): mixed { - /** @var \Luimedi\Remap\EngineInterface $engine */ + /** @var \Luimedi\Remap\Contracts\EngineInterface $engine */ $engine = $context->get('__engine__'); $targetType = is_string($mappingTarget->type) && class_exists($mappingTarget->type) ? $mappingTarget->type diff --git a/src/Attribute/ConstructorMapper.php b/src/Attribute/ConstructorMapper.php index a37f8a6..945ff4c 100644 --- a/src/Attribute/ConstructorMapper.php +++ b/src/Attribute/ConstructorMapper.php @@ -3,8 +3,10 @@ namespace Luimedi\Remap\Attribute; use InvalidArgumentException; -use Luimedi\Remap\Attribute\Cast\CastInterface; -use Luimedi\Remap\ContextInterface; +use Luimedi\Remap\Contracts\CastInterface; +use Luimedi\Remap\Contracts\ContextInterface; +use Luimedi\Remap\Contracts\MapInterface; +use Luimedi\Remap\Contracts\TransformerInterface; use Luimedi\Remap\MappingTarget; use ReflectionClass; diff --git a/src/Attribute/MapGetter.php b/src/Attribute/MapGetter.php index cb41b6f..2d8bce4 100644 --- a/src/Attribute/MapGetter.php +++ b/src/Attribute/MapGetter.php @@ -4,7 +4,8 @@ use Attribute; use InvalidArgumentException; -use Luimedi\Remap\ContextInterface; +use Luimedi\Remap\Contracts\ContextInterface; +use Luimedi\Remap\Contracts\MapInterface; use Luimedi\Remap\MappingTarget; #[Attribute(Attribute::TARGET_PARAMETER | Attribute::TARGET_PROPERTY)] diff --git a/src/Attribute/MapProperty.php b/src/Attribute/MapProperty.php index ed15c3c..32ca9f5 100644 --- a/src/Attribute/MapProperty.php +++ b/src/Attribute/MapProperty.php @@ -3,7 +3,8 @@ namespace Luimedi\Remap\Attribute; use Attribute; -use Luimedi\Remap\ContextInterface; +use Luimedi\Remap\Contracts\ContextInterface; +use Luimedi\Remap\Contracts\MapInterface; use Luimedi\Remap\Data; use Luimedi\Remap\MappingTarget; diff --git a/src/Attribute/PropertyMapper.php b/src/Attribute/PropertyMapper.php index d0dc0b0..9113a1d 100644 --- a/src/Attribute/PropertyMapper.php +++ b/src/Attribute/PropertyMapper.php @@ -3,8 +3,10 @@ namespace Luimedi\Remap\Attribute; use InvalidArgumentException; -use Luimedi\Remap\Attribute\Cast\CastInterface; -use Luimedi\Remap\ContextInterface; +use Luimedi\Remap\Contracts\CastInterface; +use Luimedi\Remap\Contracts\ContextInterface; +use Luimedi\Remap\Contracts\MapInterface; +use Luimedi\Remap\Contracts\TransformerInterface; use Luimedi\Remap\MappingTarget; use ReflectionClass; use ReflectionProperty; diff --git a/src/Context.php b/src/Context.php index 0337fe5..7d7f43c 100644 --- a/src/Context.php +++ b/src/Context.php @@ -2,8 +2,8 @@ namespace Luimedi\Remap; - use ArrayIterator; +use Luimedi\Remap\Contracts\ContextInterface; use Traversable; diff --git a/src/Attribute/Cast/CastInterface.php b/src/Contracts/CastInterface.php similarity index 68% rename from src/Attribute/Cast/CastInterface.php rename to src/Contracts/CastInterface.php index d5f56e5..48e3ed9 100644 --- a/src/Attribute/Cast/CastInterface.php +++ b/src/Contracts/CastInterface.php @@ -1,8 +1,8 @@ Date: Wed, 4 Mar 2026 20:14:24 +0100 Subject: [PATCH 2/8] Update GitHub Actions workflow to trigger on 'develop' branch --- .github/workflows/php.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index e092b91..8a0e60d 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -2,9 +2,9 @@ name: PHP Composer on: push: - branches: [ "main" ] + branches: [ "main", "develop" ] pull_request: - branches: [ "main" ] + branches: [ "main", "develop" ] permissions: contents: read From 99d03f74c1548ee1cd9ac923b307da39ded824bf Mon Sep 17 00:00:00 2001 From: Luis Medina <6675716+luimedi@users.noreply.github.com> Date: Wed, 4 Mar 2026 20:36:34 +0100 Subject: [PATCH 3/8] Refactor MappingTarget usage to implement MappingTargetInterface across casting and mapping classes --- src/Attribute/Cast/CastDateTime.php | 4 ++-- src/Attribute/Cast/CastDefault.php | 4 ++-- src/Attribute/Cast/CastIterable.php | 4 ++-- src/Attribute/Cast/CastTransformer.php | 8 ++++---- src/Attribute/ConstructorMapper.php | 3 ++- src/Attribute/MapGetter.php | 12 ++++++------ src/Attribute/MapProperty.php | 6 +++--- src/Attribute/PropertyMapper.php | 3 ++- src/Contracts/CastInterface.php | 4 ++-- src/Contracts/MapInterface.php | 4 ++-- src/Contracts/MappingTargetInterface.php | 10 ++++++++++ src/Contracts/TransformerInterface.php | 4 ++-- src/MappingTarget.php | 18 +++++++++++++++--- 13 files changed, 54 insertions(+), 30 deletions(-) create mode 100644 src/Contracts/MappingTargetInterface.php diff --git a/src/Attribute/Cast/CastDateTime.php b/src/Attribute/Cast/CastDateTime.php index 0a6e7c4..1970063 100644 --- a/src/Attribute/Cast/CastDateTime.php +++ b/src/Attribute/Cast/CastDateTime.php @@ -7,7 +7,7 @@ use DateTimeInterface; use Luimedi\Remap\Contracts\CastInterface; use Luimedi\Remap\Contracts\ContextInterface; -use Luimedi\Remap\MappingTarget; +use Luimedi\Remap\Contracts\MappingTargetInterface; #[Attribute(Attribute::TARGET_PARAMETER | Attribute::TARGET_PROPERTY)] class CastDateTime implements CastInterface @@ -16,7 +16,7 @@ public function __construct(protected ?string $format = DateTime::ATOM) { } - public function cast(mixed $value, ContextInterface $context, MappingTarget $mappingTarget): mixed + public function cast(mixed $value, ContextInterface $context, MappingTargetInterface $mappingTarget): mixed { if ($value instanceof DateTimeInterface) { return $value->format(DateTime::ATOM); diff --git a/src/Attribute/Cast/CastDefault.php b/src/Attribute/Cast/CastDefault.php index 7e007fc..e142d90 100644 --- a/src/Attribute/Cast/CastDefault.php +++ b/src/Attribute/Cast/CastDefault.php @@ -7,7 +7,7 @@ use DateTimeInterface; use Luimedi\Remap\Contracts\CastInterface; use Luimedi\Remap\Contracts\ContextInterface; -use Luimedi\Remap\MappingTarget; +use Luimedi\Remap\Contracts\MappingTargetInterface; #[Attribute(Attribute::TARGET_PARAMETER | Attribute::TARGET_PROPERTY)] class CastDefault implements CastInterface @@ -21,7 +21,7 @@ public function __construct(protected mixed $default = null, protected bool $str { } - public function cast(mixed $value, ContextInterface $context, MappingTarget $mappingTarget): mixed + public function cast(mixed $value, ContextInterface $context, MappingTargetInterface $mappingTarget): mixed { if ($this->strict) { if (is_null($value) ) { diff --git a/src/Attribute/Cast/CastIterable.php b/src/Attribute/Cast/CastIterable.php index 6fe835b..b9f9c4c 100644 --- a/src/Attribute/Cast/CastIterable.php +++ b/src/Attribute/Cast/CastIterable.php @@ -6,7 +6,7 @@ use Attribute; use Luimedi\Remap\Contracts\CastInterface; use Luimedi\Remap\Contracts\ContextInterface; -use Luimedi\Remap\MappingTarget; +use Luimedi\Remap\Contracts\MappingTargetInterface; #[Attribute(Attribute::TARGET_PARAMETER | Attribute::TARGET_PROPERTY)] class CastIterable implements CastInterface @@ -15,7 +15,7 @@ public function __construct(private string $class, private array $args = []) { } - public function cast(mixed $value, ContextInterface $context, MappingTarget $mappingTarget): mixed + public function cast(mixed $value, ContextInterface $context, MappingTargetInterface $mappingTarget): mixed { $caster = new $this->class(...$this->args); $output = []; diff --git a/src/Attribute/Cast/CastTransformer.php b/src/Attribute/Cast/CastTransformer.php index 047b1f9..b4e4e8e 100644 --- a/src/Attribute/Cast/CastTransformer.php +++ b/src/Attribute/Cast/CastTransformer.php @@ -6,17 +6,17 @@ use Luimedi\Remap\Contracts\CastInterface; use Luimedi\Remap\Contracts\ContextInterface; use Luimedi\Remap\Contracts\TransformerInterface; -use Luimedi\Remap\MappingTarget; +use Luimedi\Remap\Contracts\MappingTargetInterface; #[Attribute(Attribute::TARGET_PARAMETER | Attribute::TARGET_PROPERTY)] class CastTransformer implements CastInterface { - public function cast(mixed $value, ContextInterface $context, MappingTarget $mappingTarget): mixed + public function cast(mixed $value, ContextInterface $context, MappingTargetInterface $mappingTarget): mixed { /** @var \Luimedi\Remap\Contracts\EngineInterface $engine */ $engine = $context->get('__engine__'); - $targetType = is_string($mappingTarget->type) && class_exists($mappingTarget->type) - ? $mappingTarget->type + $targetType = is_string($mappingTarget->getType()) && class_exists($mappingTarget->getType()) + ? $mappingTarget->getType() : null; // If the value is null, nothing to map. diff --git a/src/Attribute/ConstructorMapper.php b/src/Attribute/ConstructorMapper.php index 945ff4c..180ed19 100644 --- a/src/Attribute/ConstructorMapper.php +++ b/src/Attribute/ConstructorMapper.php @@ -8,6 +8,7 @@ use Luimedi\Remap\Contracts\MapInterface; use Luimedi\Remap\Contracts\TransformerInterface; use Luimedi\Remap\MappingTarget; +use Luimedi\Remap\Contracts\MappingTargetInterface; use ReflectionClass; #[\Attribute(\Attribute::TARGET_CLASS)] @@ -16,7 +17,7 @@ class ConstructorMapper implements TransformerInterface /** * Transforms the given source object into an instance of the target class. */ - public function transform(mixed $source, mixed $target, ContextInterface $context, MappingTarget $mappingTarget): mixed + public function transform(mixed $source, mixed $target, ContextInterface $context, MappingTargetInterface $mappingTarget): mixed { $reflectionClass = new ReflectionClass($target); diff --git a/src/Attribute/MapGetter.php b/src/Attribute/MapGetter.php index 2d8bce4..604c759 100644 --- a/src/Attribute/MapGetter.php +++ b/src/Attribute/MapGetter.php @@ -6,7 +6,7 @@ use InvalidArgumentException; use Luimedi\Remap\Contracts\ContextInterface; use Luimedi\Remap\Contracts\MapInterface; -use Luimedi\Remap\MappingTarget; +use Luimedi\Remap\Contracts\MappingTargetInterface; #[Attribute(Attribute::TARGET_PARAMETER | Attribute::TARGET_PROPERTY)] class MapGetter implements MapInterface @@ -16,23 +16,23 @@ public function __construct(protected ?string $source = null) // } - public function map(mixed $from, ContextInterface $context, MappingTarget $target): mixed + public function map(mixed $from, ContextInterface $context, MappingTargetInterface $target): mixed { $method = $this->source; if ($method === null) { - $getterMethod = 'get' . ucfirst($target->name); + $getterMethod = 'get' . ucfirst($target->getName()); if (is_object($from) && method_exists($from, $getterMethod)) { $method = $getterMethod; - } elseif (is_object($from) && method_exists($from, $target->name)) { - $method = $target->name; + } elseif (is_object($from) && method_exists($from, $target->getName())) { + $method = $target->getName(); } } if (!is_object($from) || !is_string($method) || !method_exists($from, $method)) { throw new InvalidArgumentException( - "MapGetter could not resolve a method for target '{$target->name}'" + "MapGetter could not resolve a method for target '" . $target->getName() . "'" ); } diff --git a/src/Attribute/MapProperty.php b/src/Attribute/MapProperty.php index 32ca9f5..4022882 100644 --- a/src/Attribute/MapProperty.php +++ b/src/Attribute/MapProperty.php @@ -5,8 +5,8 @@ use Attribute; use Luimedi\Remap\Contracts\ContextInterface; use Luimedi\Remap\Contracts\MapInterface; +use Luimedi\Remap\Contracts\MappingTargetInterface; use Luimedi\Remap\Data; -use Luimedi\Remap\MappingTarget; #[Attribute(Attribute::TARGET_PARAMETER | Attribute::TARGET_PROPERTY)] class MapProperty implements MapInterface @@ -16,8 +16,8 @@ public function __construct(protected ?string $source = null) // } - public function map(mixed $from, ContextInterface $context, MappingTarget $target): mixed + public function map(mixed $from, ContextInterface $context, MappingTargetInterface $target): mixed { - return Data::get($from, $this->source ?? $target->name); + return Data::get($from, $this->source ?? $target->getName()); } } diff --git a/src/Attribute/PropertyMapper.php b/src/Attribute/PropertyMapper.php index 9113a1d..8b45a3c 100644 --- a/src/Attribute/PropertyMapper.php +++ b/src/Attribute/PropertyMapper.php @@ -8,6 +8,7 @@ use Luimedi\Remap\Contracts\MapInterface; use Luimedi\Remap\Contracts\TransformerInterface; use Luimedi\Remap\MappingTarget; +use Luimedi\Remap\Contracts\MappingTargetInterface; use ReflectionClass; use ReflectionProperty; @@ -17,7 +18,7 @@ class PropertyMapper implements TransformerInterface /** * Transforms the given source object into an instance of the target class. */ - public function transform(mixed $source, mixed $target, ContextInterface $context, MappingTarget $mappingTarget): mixed + public function transform(mixed $source, mixed $target, ContextInterface $context, MappingTargetInterface $mappingTarget): mixed { $reflectionClass = new ReflectionClass($target); $properties = $reflectionClass->getProperties(ReflectionProperty::IS_PUBLIC); diff --git a/src/Contracts/CastInterface.php b/src/Contracts/CastInterface.php index 48e3ed9..7661de9 100644 --- a/src/Contracts/CastInterface.php +++ b/src/Contracts/CastInterface.php @@ -3,9 +3,9 @@ namespace Luimedi\Remap\Contracts; use Luimedi\Remap\Contracts\ContextInterface; -use Luimedi\Remap\MappingTarget; +use Luimedi\Remap\Contracts\MappingTargetInterface; interface CastInterface { - public function cast(mixed $value, ContextInterface $context, MappingTarget $mappingTarget): mixed; + public function cast(mixed $value, ContextInterface $context, MappingTargetInterface $mappingTarget): mixed; } diff --git a/src/Contracts/MapInterface.php b/src/Contracts/MapInterface.php index 148034a..4fc81d4 100644 --- a/src/Contracts/MapInterface.php +++ b/src/Contracts/MapInterface.php @@ -3,9 +3,9 @@ namespace Luimedi\Remap\Contracts; use Luimedi\Remap\Contracts\ContextInterface; -use Luimedi\Remap\MappingTarget; +use Luimedi\Remap\Contracts\MappingTargetInterface; interface MapInterface { - public function map(mixed $from, ContextInterface $context, MappingTarget $target): mixed; + public function map(mixed $from, ContextInterface $context, MappingTargetInterface $target): mixed; } diff --git a/src/Contracts/MappingTargetInterface.php b/src/Contracts/MappingTargetInterface.php new file mode 100644 index 0000000..eb1a45a --- /dev/null +++ b/src/Contracts/MappingTargetInterface.php @@ -0,0 +1,10 @@ +name; + } + + public function getType(): ?string + { + return $this->type; + } } From a129a983c44e7671fd71d6cca2d71d5c679e1d43 Mon Sep 17 00:00:00 2001 From: Luis Medina <6675716+luimedi@users.noreply.github.com> Date: Wed, 4 Mar 2026 20:40:01 +0100 Subject: [PATCH 4/8] Refactor Data usage to use Helpers namespace and update related tests --- src/Attribute/MapProperty.php | 2 +- src/{ => Helpers}/Data.php | 4 ++-- tests/DataObjectTest/DataObjectTest.php | 2 +- tests/DataTest.php | 4 +++- 4 files changed, 7 insertions(+), 5 deletions(-) rename src/{ => Helpers}/Data.php (94%) diff --git a/src/Attribute/MapProperty.php b/src/Attribute/MapProperty.php index 4022882..12f5b67 100644 --- a/src/Attribute/MapProperty.php +++ b/src/Attribute/MapProperty.php @@ -6,7 +6,7 @@ use Luimedi\Remap\Contracts\ContextInterface; use Luimedi\Remap\Contracts\MapInterface; use Luimedi\Remap\Contracts\MappingTargetInterface; -use Luimedi\Remap\Data; +use Luimedi\Remap\Helpers\Data; #[Attribute(Attribute::TARGET_PARAMETER | Attribute::TARGET_PROPERTY)] class MapProperty implements MapInterface diff --git a/src/Data.php b/src/Helpers/Data.php similarity index 94% rename from src/Data.php rename to src/Helpers/Data.php index f16dfce..3f8b901 100644 --- a/src/Data.php +++ b/src/Helpers/Data.php @@ -1,6 +1,6 @@ Date: Sat, 7 Mar 2026 16:11:42 +0100 Subject: [PATCH 5/8] Enhance error handling by introducing a dedicated exception hierarchy and updating related classes and tests --- README.md | 41 ++++- src/Attribute/ConstructorMapper.php | 67 ++++++-- src/Attribute/MapGetter.php | 6 +- src/Attribute/PropertyMapper.php | 48 +++++- src/Contracts/EngineInterface.php | 9 +- src/Engine.php | 153 +++++++++++++----- src/Exception/BindingNotFoundException.php | 11 ++ src/Exception/BindingResolutionException.php | 15 ++ src/Exception/InvalidTargetTypeException.php | 13 ++ .../MapGetterResolutionException.php | 11 ++ src/Exception/MappingExecutionException.php | 21 +++ src/Exception/MissingMappedValueException.php | 11 ++ src/Exception/RemapException.php | 54 +++++++ src/Mapper.php | 6 +- tests/CastDefaultTest/CastDefaultTest.php | 21 ++- tests/EngineTest/EngineTest.php | 17 +- 16 files changed, 437 insertions(+), 67 deletions(-) create mode 100644 src/Exception/BindingNotFoundException.php create mode 100644 src/Exception/BindingResolutionException.php create mode 100644 src/Exception/InvalidTargetTypeException.php create mode 100644 src/Exception/MapGetterResolutionException.php create mode 100644 src/Exception/MappingExecutionException.php create mode 100644 src/Exception/MissingMappedValueException.php create mode 100644 src/Exception/RemapException.php diff --git a/README.md b/README.md index 46ba833..4afd91e 100644 --- a/README.md +++ b/README.md @@ -222,10 +222,47 @@ Notes and tips: - `MapProperty` supports nested keys via dot-notation and works with arrays and objects; if a segment is missing the default `null` (or provided default) will be returned. - `MapGetter` requires a callable method on the source — if the method does not - exist a PHP exception will occur, so prefer checks or defensive code on the source - or use a resolver to choose a safer mapping strategy. + exist a `MapGetterResolutionException` will be thrown. - Use `MapGetter` for computed values and `MapProperty` for direct data lookups. +### Error Handling + +Remap exposes a dedicated exception hierarchy under `Luimedi\Remap\Exception`. +All library-specific failures extend `RemapException`, making it simple to catch +all Remap errors in one place. + +Main exceptions include: + +- `BindingNotFoundException`: no mapping binding exists for the source type. +- `BindingResolutionException`: a binding resolver returned an invalid target type. +- `InvalidTargetTypeException`: the resolved target type could not be instantiated. +- `MapGetterResolutionException`: `MapGetter` could not resolve a source method. +- `MissingMappedValueException`: a caster was applied to a parameter with no mapped value. + +Example: + +```php +use Luimedi\Remap\Exception\RemapException; + +try { + $result = $mapper->map($source); +} catch (RemapException $exception) { + // Centralized handling for all Remap-specific failures. + $mappingTrace = $exception->getMappingTrace(); + $previous = $exception->getPrevious(); // Original low-level error, if any. +} +``` + +`getMappingTrace()` returns an ordered list of mapping steps, for example: + +```php +[ + ['phase' => 'execute', 'targetType' => 'App\\Output', 'sourceType' => 'App\\Input'], + ['phase' => 'attribute.transform', 'targetType' => 'App\\Output', 'attribute' => 'Luimedi\\Remap\\Attribute\\ConstructorMapper'], + ['phase' => 'constructor.parameter.cast', 'parameter' => 'status', 'caster' => 'Luimedi\\Remap\\Attribute\\Cast\\CastDefault'], +] +``` + ### Casters Casters implement `CastInterface` and are responsible for transforming mapped diff --git a/src/Attribute/ConstructorMapper.php b/src/Attribute/ConstructorMapper.php index 180ed19..b173edf 100644 --- a/src/Attribute/ConstructorMapper.php +++ b/src/Attribute/ConstructorMapper.php @@ -2,14 +2,17 @@ namespace Luimedi\Remap\Attribute; -use InvalidArgumentException; use Luimedi\Remap\Contracts\CastInterface; use Luimedi\Remap\Contracts\ContextInterface; use Luimedi\Remap\Contracts\MapInterface; use Luimedi\Remap\Contracts\TransformerInterface; +use Luimedi\Remap\Exception\MappingExecutionException; +use Luimedi\Remap\Exception\MissingMappedValueException; +use Luimedi\Remap\Exception\RemapException; use Luimedi\Remap\MappingTarget; use Luimedi\Remap\Contracts\MappingTargetInterface; use ReflectionClass; +use Throwable; #[\Attribute(\Attribute::TARGET_CLASS)] class ConstructorMapper implements TransformerInterface @@ -38,8 +41,6 @@ public function transform(mixed $source, mixed $target, ContextInterface $contex * @param ReflectionClass $reflectionClass The reflection of the target class. * @param ContextInterface $context The contextual information for the mapping process. * @return mixed A new instance of the target class with mapped parameters. - * - * @throws InvalidArgumentException if a required parameter cannot be mapped. */ private function newInstance(mixed $from, ReflectionClass $reflectionClass, ContextInterface $context): mixed { @@ -63,7 +64,16 @@ private function newInstance(mixed $from, ReflectionClass $reflectionClass, Cont $type = $paramType->getName(); } $target = new MappingTarget($name, $type); - $parameterValues[$name] = $instance->map($from, $context, $target); + + try { + $parameterValues[$name] = $instance->map($from, $context, $target); + } catch (Throwable $exception) { + $this->throwWithTrace($exception, $context, [ + 'phase' => 'constructor.parameter.map', + 'parameter' => $name, + 'mapper' => $instance::class, + ]); + } } } }; @@ -96,7 +106,16 @@ private function populateInstance(mixed $from, ReflectionClass $reflectionClass, $type = $paramType->getName(); } $target = new \Luimedi\Remap\MappingTarget($name, $type); - $parameterValues[$name] = $attrInstance->map($from, $context, $target); + + try { + $parameterValues[$name] = $attrInstance->map($from, $context, $target); + } catch (Throwable $exception) { + $this->throwWithTrace($exception, $context, [ + 'phase' => 'constructor.parameter.map', + 'parameter' => $name, + 'mapper' => $attrInstance::class, + ]); + } } } } @@ -137,8 +156,6 @@ private function populateInstance(mixed $from, ReflectionClass $reflectionClass, * @param ContextInterface $context The context for the mapping process. * * @return array The parameter values after applying casters. - * - * @throws InvalidArgumentException if a caster is applied to a parameter without a value. */ protected function applyCasters(array $values, array $parameters, ContextInterface $context): array { @@ -151,7 +168,15 @@ protected function applyCasters(array $values, array $parameters, ContextInterfa if ($instance instanceof CastInterface) { if (!array_key_exists($name, $values)) { - throw new InvalidArgumentException("Cannot cast parameter '$name' because it has no value."); + $this->throwWithTrace( + MissingMappedValueException::forParameter($name), + $context, + [ + 'phase' => 'constructor.parameter.cast', + 'parameter' => $name, + 'caster' => $instance::class, + ] + ); } $paramType = $parameter->getType(); @@ -162,11 +187,35 @@ protected function applyCasters(array $values, array $parameters, ContextInterfa } $target = new MappingTarget($name, $type); - $values[$name] = $instance->cast($values[$name], $context, $target); + + try { + $values[$name] = $instance->cast($values[$name], $context, $target); + } catch (Throwable $exception) { + $this->throwWithTrace($exception, $context, [ + 'phase' => 'constructor.parameter.cast', + 'parameter' => $name, + 'caster' => $instance::class, + ]); + } } } } return $values; } + + /** + * @param array $step + */ + private function throwWithTrace(Throwable $exception, ContextInterface $context, array $step): never + { + $trace = $context->get('__mapping_trace__', []); + $trace[] = $step; + + if ($exception instanceof RemapException) { + throw $exception->appendMappingTrace($trace); + } + + throw MappingExecutionException::fromThrowable($exception, $trace); + } } diff --git a/src/Attribute/MapGetter.php b/src/Attribute/MapGetter.php index 604c759..5c73655 100644 --- a/src/Attribute/MapGetter.php +++ b/src/Attribute/MapGetter.php @@ -3,10 +3,10 @@ namespace Luimedi\Remap\Attribute; use Attribute; -use InvalidArgumentException; use Luimedi\Remap\Contracts\ContextInterface; use Luimedi\Remap\Contracts\MapInterface; use Luimedi\Remap\Contracts\MappingTargetInterface; +use Luimedi\Remap\Exception\MapGetterResolutionException; #[Attribute(Attribute::TARGET_PARAMETER | Attribute::TARGET_PROPERTY)] class MapGetter implements MapInterface @@ -31,9 +31,7 @@ public function map(mixed $from, ContextInterface $context, MappingTargetInterfa } if (!is_object($from) || !is_string($method) || !method_exists($from, $method)) { - throw new InvalidArgumentException( - "MapGetter could not resolve a method for target '" . $target->getName() . "'" - ); + throw MapGetterResolutionException::forTarget($target->getName()); } return $from->{$method}(); diff --git a/src/Attribute/PropertyMapper.php b/src/Attribute/PropertyMapper.php index 8b45a3c..0f8736f 100644 --- a/src/Attribute/PropertyMapper.php +++ b/src/Attribute/PropertyMapper.php @@ -2,15 +2,17 @@ namespace Luimedi\Remap\Attribute; -use InvalidArgumentException; use Luimedi\Remap\Contracts\CastInterface; use Luimedi\Remap\Contracts\ContextInterface; use Luimedi\Remap\Contracts\MapInterface; use Luimedi\Remap\Contracts\TransformerInterface; +use Luimedi\Remap\Exception\MappingExecutionException; +use Luimedi\Remap\Exception\RemapException; use Luimedi\Remap\MappingTarget; use Luimedi\Remap\Contracts\MappingTargetInterface; use ReflectionClass; use ReflectionProperty; +use Throwable; #[\Attribute(\Attribute::TARGET_CLASS)] class PropertyMapper implements TransformerInterface @@ -38,13 +40,36 @@ public function transform(mixed $source, mixed $target, ContextInterface $contex foreach ($this->getValidAttributes($property) as $attribute) { if ($attribute instanceof CastInterface) { - $value = $attribute->cast($value, $context, $propTarget); + try { + $value = $attribute->cast($value, $context, $propTarget); + } catch (Throwable $exception) { + $this->throwWithTrace($exception, $context, [ + 'phase' => 'property.cast', + 'property' => $property->getName(), + 'caster' => $attribute::class, + ]); + } } elseif ($attribute instanceof MapInterface) { - $value = $attribute->map($source, $context, $propTarget); + try { + $value = $attribute->map($source, $context, $propTarget); + } catch (Throwable $exception) { + $this->throwWithTrace($exception, $context, [ + 'phase' => 'property.map', + 'property' => $property->getName(), + 'mapper' => $attribute::class, + ]); + } } } - $property->setValue($instance, $value); + try { + $property->setValue($instance, $value); + } catch (Throwable $exception) { + $this->throwWithTrace($exception, $context, [ + 'phase' => 'property.set', + 'property' => $property->getName(), + ]); + } } return $instance; @@ -83,4 +108,19 @@ private function getValidAttributes(ReflectionProperty $property): array return $validAttributes; } + + /** + * @param array $step + */ + private function throwWithTrace(Throwable $exception, ContextInterface $context, array $step): never + { + $trace = $context->get('__mapping_trace__', []); + $trace[] = $step; + + if ($exception instanceof RemapException) { + throw $exception->appendMappingTrace($trace); + } + + throw MappingExecutionException::fromThrowable($exception, $trace); + } } diff --git a/src/Contracts/EngineInterface.php b/src/Contracts/EngineInterface.php index ba5ac1d..ac8cb9f 100644 --- a/src/Contracts/EngineInterface.php +++ b/src/Contracts/EngineInterface.php @@ -2,6 +2,10 @@ namespace Luimedi\Remap\Contracts; +use Luimedi\Remap\Exception\BindingNotFoundException; +use Luimedi\Remap\Exception\BindingResolutionException; +use Luimedi\Remap\Exception\InvalidTargetTypeException; + interface EngineInterface { /** @@ -17,12 +21,15 @@ public function bind(string $abstract, string|callable $resolver): static; /** * Resolves the target type for the given object. * - * @throws InvalidArgumentException if no binding is found or cannot be resolved. + * @throws BindingNotFoundException if no binding is found. + * @throws BindingResolutionException if a binding cannot be resolved. */ public function resolve(mixed $object, ContextInterface $context): string; /** * Executes the mapping from the source object to an instance of the target type. + * + * @throws InvalidTargetTypeException if the target type cannot be instantiated. */ public function execute(mixed $from, string $type, ContextInterface $context): mixed; } diff --git a/src/Engine.php b/src/Engine.php index 62611bb..aad5957 100644 --- a/src/Engine.php +++ b/src/Engine.php @@ -2,15 +2,18 @@ namespace Luimedi\Remap; -use InvalidArgumentException; +use Luimedi\Remap\Exception\BindingNotFoundException; +use Luimedi\Remap\Exception\BindingResolutionException; +use Luimedi\Remap\Exception\InvalidTargetTypeException; +use Luimedi\Remap\Exception\MappingExecutionException; +use Luimedi\Remap\Exception\RemapException; use Luimedi\Remap\Contracts\EngineInterface; -use Luimedi\Remap\Contracts\MapInterface; use Luimedi\Remap\Contracts\TransformerInterface; -use Luimedi\Remap\Contracts\CastInterface; use Luimedi\Remap\Contracts\ContextInterface; use Luimedi\Remap\MappingTarget; use ReflectionClass; use ReflectionException; +use Throwable; class Engine implements EngineInterface { @@ -36,77 +39,151 @@ public function bind(string $abstract, string|callable $resolver): static /** * Resolves the target type for the given object. * - * @throws InvalidArgumentException if no binding is found or cannot be resolved. + * @throws BindingNotFoundException if no binding is found. + * @throws BindingResolutionException if a binding cannot be resolved to a valid class name. */ public function resolve(mixed $object, ContextInterface $context): string { $type = is_object($object) ? get_class($object) : 'type:' . gettype($object); + $trace = [[ + 'phase' => 'resolve', + 'sourceType' => $type, + ]]; if (!isset($this->bindings[$type])) { - throw new InvalidArgumentException("No binding found for {$type}"); + throw BindingNotFoundException::forType($type)->appendMappingTrace($trace); } $resolver = $this->bindings[$type]; if (is_callable($resolver)) { - return $resolver($object, $context); + $resolvedType = $resolver($object, $context); + + if (is_string($resolvedType) && class_exists($resolvedType)) { + return $resolvedType; + } + + throw BindingResolutionException::forType($type, $resolvedType) + ->appendMappingTrace($trace); } - if (class_exists($resolver)) { + if (is_string($resolver) && class_exists($resolver)) { return $resolver; } - throw new InvalidArgumentException( - "Cannot resolve binding for {$type}"); + throw BindingResolutionException::forType($type, $resolver)->appendMappingTrace($trace); } /** * Executes the mapping process from the source object to an instance of the target type. * - * @throws ReflectionException if the target type class does not exist + * @throws InvalidTargetTypeException if the target type cannot be instantiated. */ public function execute(mixed $from, string $type, ContextInterface $context): mixed { - $reflectionClass = new ReflectionClass($type); - $attributes = $reflectionClass->getAttributes(); + return $this->withMappingStep($context, [ + 'phase' => 'execute', + 'targetType' => $type, + 'sourceType' => is_object($from) ? get_class($from) : gettype($from), + ], function () use ($from, $type, $context) { + try { + $reflectionClass = new ReflectionClass($type); + } catch (ReflectionException $exception) { + throw InvalidTargetTypeException::forType($type, $exception); + } + + $attributes = $reflectionClass->getAttributes(); - $instance = null; + $instance = null; // If the source is an object, prepare a registry mapping so recursive // references can return the already-created target instance. - if (is_object($from)) { - $id = spl_object_hash($from); - $registry = $context->get('__mapping_registry__', []); - - if (isset($registry[$id])) { - // There is already a mapped instance for this source. - $instance = $registry[$id]; - } else { - // Create a placeholder instance without invoking constructor so - // it can be returned for recursive references during mapping. - $instance = $reflectionClass->newInstanceWithoutConstructor(); + if (is_object($from)) { + $id = spl_object_hash($from); + $registry = $context->get('__mapping_registry__', []); + + if (isset($registry[$id])) { + // There is already a mapped instance for this source. + $instance = $registry[$id]; + } else { + // Create a placeholder instance without invoking constructor so + // it can be returned for recursive references during mapping. + try { + $instance = $reflectionClass->newInstanceWithoutConstructor(); + } catch (ReflectionException $exception) { + throw InvalidTargetTypeException::forType($type, $exception); + } + + $registry[$id] = $instance; + $context->set('__mapping_registry__', $registry); + } + } + + foreach ($attributes as $attribute) { + $attributeClass = $attribute->getName(); + + $attributeInstance = $this->withMappingStep($context, [ + 'phase' => 'attribute.instantiate', + 'targetType' => $type, + 'attribute' => $attributeClass, + ], static function () use ($attribute) { + return $attribute->newInstance(); + }); + + if ($attributeInstance instanceof TransformerInterface) { + $mappingTarget = new MappingTarget($reflectionClass->getName(), $type); + + $instance = $this->withMappingStep($context, [ + 'phase' => 'attribute.transform', + 'targetType' => $type, + 'attribute' => $attributeClass, + ], static function () use ($attributeInstance, $from, $instance, $type, $context, $mappingTarget) { + return $attributeInstance->transform($from, $instance ?? $type, $context, $mappingTarget); + }); + } + } + + // Ensure registry points to the final instance if source was object. + if (is_object($from)) { + $id = spl_object_hash($from); + $registry = $context->get('__mapping_registry__', []); $registry[$id] = $instance; $context->set('__mapping_registry__', $registry); } - } - foreach ($attributes as $attribute) { - $attributeInstance = $attribute->newInstance(); + return $instance; + }); + } - if ($attributeInstance instanceof TransformerInterface) { - $mappingTarget = new MappingTarget($reflectionClass->getName(), $type); - $instance = $attributeInstance->transform($from, $instance ?? $type, $context, $mappingTarget); - } + /** + * @param array $step + */ + private function withMappingStep(ContextInterface $context, array $step, callable $callback): mixed + { + $trace = $context->get('__mapping_trace__', []); + $trace[] = $step; + $context->set('__mapping_trace__', $trace); + + try { + return $callback(); + } catch (Throwable $exception) { + throw $this->enrichException($exception, $trace); + } finally { + $finalTrace = $context->get('__mapping_trace__', []); + array_pop($finalTrace); + $context->set('__mapping_trace__', $finalTrace); } + } - // Ensure registry points to the final instance if source was object. - if (is_object($from)) { - $id = spl_object_hash($from); - $registry = $context->get('__mapping_registry__', []); - $registry[$id] = $instance; - $context->set('__mapping_registry__', $registry); + /** + * @param array> $trace + */ + private function enrichException(Throwable $exception, array $trace): RemapException + { + if ($exception instanceof RemapException) { + return $exception->appendMappingTrace($trace); } - return $instance; + return MappingExecutionException::fromThrowable($exception, $trace); } } \ No newline at end of file diff --git a/src/Exception/BindingNotFoundException.php b/src/Exception/BindingNotFoundException.php new file mode 100644 index 0000000..0f6d47c --- /dev/null +++ b/src/Exception/BindingNotFoundException.php @@ -0,0 +1,11 @@ +> $mappingTrace + */ + public static function fromThrowable(Throwable $previous, array $mappingTrace = []): self + { + $message = sprintf( + 'Mapping execution failed: %s', + $previous->getMessage() !== '' ? $previous->getMessage() : $previous::class + ); + + return new self($message, 0, $previous, $mappingTrace); + } +} diff --git a/src/Exception/MissingMappedValueException.php b/src/Exception/MissingMappedValueException.php new file mode 100644 index 0000000..87a2b82 --- /dev/null +++ b/src/Exception/MissingMappedValueException.php @@ -0,0 +1,11 @@ +> + */ + protected array $mappingTrace = []; + + /** + * @param array> $mappingTrace + */ + public function __construct(string $message = '', int $code = 0, ?Throwable $previous = null, array $mappingTrace = []) + { + parent::__construct($message, $code, $previous); + $this->mappingTrace = $mappingTrace; + } + + /** + * @param array> $trace + */ + public function appendMappingTrace(array $trace): static + { + if ($trace === []) { + return $this; + } + + $existing = array_map('serialize', $this->mappingTrace); + + foreach ($trace as $step) { + $serialized = serialize($step); + + if (!in_array($serialized, $existing, true)) { + $this->mappingTrace[] = $step; + $existing[] = $serialized; + } + } + + return $this; + } + + /** + * @return array> + */ + public function getMappingTrace(): array + { + return $this->mappingTrace; + } +} diff --git a/src/Mapper.php b/src/Mapper.php index 002bd04..1733fe8 100644 --- a/src/Mapper.php +++ b/src/Mapper.php @@ -3,10 +3,10 @@ namespace Luimedi\Remap; use ArrayIterator; -use InvalidArgumentException; use Iterator; use Luimedi\Remap\Contracts\ContextInterface; use Luimedi\Remap\Contracts\EngineInterface; +use Luimedi\Remap\Exception\RemapException; class Mapper { @@ -79,7 +79,7 @@ public function getContext(): ContextInterface * @param mixed $from The source object to be mapped. * @param array $data Additional contextual data for this mapping operation. * - * @throws InvalidArgumentException if no binding is found or cannot be resolved. + * @throws RemapException if mapping cannot be resolved or executed. */ public function map(mixed $from, array $data = []): mixed { @@ -95,7 +95,7 @@ public function map(mixed $from, array $data = []): mixed * @param mixed $iterable The source iterable to be mapped. * @param array $data Additional contextual data for this mapping operation. * - * @throws InvalidArgumentException if no binding is found or cannot be resolved. + * @throws RemapException if mapping cannot be resolved or executed. */ public function mapAsIterable(iterable $from, array $data = []): array { diff --git a/tests/CastDefaultTest/CastDefaultTest.php b/tests/CastDefaultTest/CastDefaultTest.php index cd5345b..2db2272 100644 --- a/tests/CastDefaultTest/CastDefaultTest.php +++ b/tests/CastDefaultTest/CastDefaultTest.php @@ -2,7 +2,7 @@ namespace Tests\CastDefaultTest; -use InvalidArgumentException; +use Luimedi\Remap\Exception\MissingMappedValueException; use Luimedi\Remap\Mapper; use PHPUnit\Framework\TestCase; @@ -49,11 +49,24 @@ public function testCastDefaultStrictOnlyNull() public function testConstructorCasterThrowsWhenNoValue() { - $this->expectException(InvalidArgumentException::class); - $mapper = new Mapper(); $mapper->bind(Input::class, OutputCasterMissing::class); - $mapper->map(new Input(maybe: null)); + try { + $mapper->map(new Input(maybe: null)); + $this->fail('Expected MissingMappedValueException to be thrown'); + } catch (MissingMappedValueException $exception) { + $trace = $exception->getMappingTrace(); + + $this->assertNotEmpty($trace); + $this->assertSame('execute', $trace[0]['phase'] ?? null); + + $castStep = array_values(array_filter($trace, static function (array $step): bool { + return ($step['phase'] ?? null) === 'constructor.parameter.cast'; + })); + + $this->assertNotEmpty($castStep); + $this->assertSame('maybe', $castStep[0]['parameter'] ?? null); + } } } diff --git a/tests/EngineTest/EngineTest.php b/tests/EngineTest/EngineTest.php index 6173144..730310d 100644 --- a/tests/EngineTest/EngineTest.php +++ b/tests/EngineTest/EngineTest.php @@ -2,7 +2,8 @@ namespace Tests\EngineTest; -use InvalidArgumentException; +use Luimedi\Remap\Exception\BindingNotFoundException; +use Luimedi\Remap\Exception\RemapException; use Luimedi\Remap\Mapper; use PHPUnit\Framework\TestCase; @@ -23,10 +24,22 @@ public function testCallableResolverIsUsed() public function testResolveThrowsWhenNoBinding() { - $this->expectException(InvalidArgumentException::class); + $this->expectException(BindingNotFoundException::class); $mapper = new Mapper(); $mapper->map(new class {}); } + + public function testLibraryExceptionsCanBeCaughtByBaseRemapException() + { + $mapper = new Mapper(); + + try { + $mapper->map(new class {}); + $this->fail('Expected an exception to be thrown'); + } catch (\Throwable $exception) { + $this->assertInstanceOf(RemapException::class, $exception); + } + } } From e2e41999dda22d7a5dfd4007aaf421562b5f779a Mon Sep 17 00:00:00 2001 From: Luis Medina <6675716+luimedi@users.noreply.github.com> Date: Sat, 7 Mar 2026 16:33:08 +0100 Subject: [PATCH 6/8] Add comprehensive exception tests and refactor existing tests with data providers --- tests/CastDefaultTest/CastDefaultTest.php | 43 ++- tests/EngineTest/EngineTest.php | 65 ++++- tests/ExceptionTest/AbstractTarget.php | 8 + tests/ExceptionTest/EmptySource.php | 8 + tests/ExceptionTest/ExceptionTest.php | 267 ++++++++++++++++++ tests/ExceptionTest/SourceWithoutGetter.php | 8 + .../TargetThatThrowsInConstructor.php | 17 ++ tests/ExceptionTest/TargetWithCasterOnly.php | 15 + tests/ExceptionTest/TargetWithMapGetter.php | 13 + .../TargetWithPropertyThatThrows.php | 16 ++ tests/ExceptionTest/ThrowingCaster.php | 17 ++ tests/ExceptionTest/UnboundSource.php | 8 + tests/ExceptionTest/ValidSource.php | 8 + tests/MapperTest/MapperTest.php | 75 +++-- 14 files changed, 523 insertions(+), 45 deletions(-) create mode 100644 tests/ExceptionTest/AbstractTarget.php create mode 100644 tests/ExceptionTest/EmptySource.php create mode 100644 tests/ExceptionTest/ExceptionTest.php create mode 100644 tests/ExceptionTest/SourceWithoutGetter.php create mode 100644 tests/ExceptionTest/TargetThatThrowsInConstructor.php create mode 100644 tests/ExceptionTest/TargetWithCasterOnly.php create mode 100644 tests/ExceptionTest/TargetWithMapGetter.php create mode 100644 tests/ExceptionTest/TargetWithPropertyThatThrows.php create mode 100644 tests/ExceptionTest/ThrowingCaster.php create mode 100644 tests/ExceptionTest/UnboundSource.php create mode 100644 tests/ExceptionTest/ValidSource.php diff --git a/tests/CastDefaultTest/CastDefaultTest.php b/tests/CastDefaultTest/CastDefaultTest.php index 2db2272..e5aab47 100644 --- a/tests/CastDefaultTest/CastDefaultTest.php +++ b/tests/CastDefaultTest/CastDefaultTest.php @@ -4,6 +4,7 @@ use Luimedi\Remap\Exception\MissingMappedValueException; use Luimedi\Remap\Mapper; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use Tests\CastDefaultTest\Input; @@ -13,38 +14,50 @@ class CastDefaultTest extends TestCase { - public function testCastDefaultNonStrictReplacesEmpty() + #[DataProvider('nonStrictEmptyValuesProvider')] + public function testCastDefaultNonStrictReplacesEmpty(mixed $emptyValue, string $expectedReplacement) { $mapper = new Mapper(); - $mapper->bind(Input::class, OutputNonStrict::class); - $input = new Input(maybe: ''); - + $input = new Input(maybe: $emptyValue); $result = $mapper->map($input); $this->assertInstanceOf(OutputNonStrict::class, $result); - $this->assertSame('fallback', $result->maybe); + $this->assertSame($expectedReplacement, $result->maybe); } - public function testCastDefaultStrictOnlyNull() + public static function nonStrictEmptyValuesProvider(): array { - $mapper = new Mapper(); + return [ + 'empty string' => ['', 'fallback'], + 'null value' => [null, 'fallback'], + 'zero integer' => [0, 'fallback'], + 'false boolean' => [false, 'fallback'], + ]; + } + #[DataProvider('strictModeValuesProvider')] + public function testCastDefaultStrictOnlyNull(mixed $inputValue, mixed $expectedOutput) + { + $mapper = new Mapper(); $mapper->bind(Input::class, OutputStrict::class); - // Empty string should not be replaced when strict=true - $input = new Input(maybe: ''); - + $input = new Input(maybe: $inputValue); $result = $mapper->map($input); $this->assertInstanceOf(OutputStrict::class, $result); - $this->assertSame('', $result->maybe); + $this->assertSame($expectedOutput, $result->maybe); + } - // Null should be replaced - $input2 = new Input(maybe: null); - $result2 = $mapper->map($input2); - $this->assertSame('fallback', $result2->maybe); + public static function strictModeValuesProvider(): array + { + return [ + 'empty string not replaced' => ['', ''], + 'zero not replaced' => [0, 0], + 'false not replaced' => [false, false], + 'null is replaced' => [null, 'fallback'], + ]; } public function testConstructorCasterThrowsWhenNoValue() diff --git a/tests/EngineTest/EngineTest.php b/tests/EngineTest/EngineTest.php index 730310d..0971484 100644 --- a/tests/EngineTest/EngineTest.php +++ b/tests/EngineTest/EngineTest.php @@ -5,30 +5,56 @@ use Luimedi\Remap\Exception\BindingNotFoundException; use Luimedi\Remap\Exception\RemapException; use Luimedi\Remap\Mapper; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; class EngineTest extends TestCase { - public function testCallableResolverIsUsed() + #[DataProvider('callableResolverProvider')] + public function testCallableResolverIsUsed(callable $resolver, string $expectedClass) { $mapper = new Mapper(); - - $mapper->bind(Input::class, function ($obj, $ctx) { - return Output::class; - }); + $mapper->bind(Input::class, $resolver); $result = $mapper->map(new Input()); - $this->assertInstanceOf(Output::class, $result); + $this->assertInstanceOf($expectedClass, $result); } - public function testResolveThrowsWhenNoBinding() + public static function callableResolverProvider(): array { - $this->expectException(BindingNotFoundException::class); + return [ + 'simple resolver' => [ + fn($obj, $ctx) => Output::class, + Output::class, + ], + 'context-aware resolver' => [ + function ($obj, $ctx) { + // Could check context for routing logic + return Output::class; + }, + Output::class, + ], + ]; + } + public function testResolveThrowsWhenNoBinding() + { $mapper = new Mapper(); - $mapper->map(new class {}); + try { + $mapper->map(new class {}); + $this->fail('Expected BindingNotFoundException'); + } catch (BindingNotFoundException $exception) { + // Verify exception details + $this->assertStringContainsString('No binding found', $exception->getMessage()); + + // Verify mapping trace + $trace = $exception->getMappingTrace(); + $this->assertNotEmpty($trace); + $this->assertSame('resolve', $trace[0]['phase'] ?? null); + $this->assertArrayHasKey('sourceType', $trace[0]); + } } public function testLibraryExceptionsCanBeCaughtByBaseRemapException() @@ -38,8 +64,27 @@ public function testLibraryExceptionsCanBeCaughtByBaseRemapException() try { $mapper->map(new class {}); $this->fail('Expected an exception to be thrown'); - } catch (\Throwable $exception) { + } catch (RemapException $exception) { $this->assertInstanceOf(RemapException::class, $exception); + $this->assertInstanceOf(BindingNotFoundException::class, $exception); + + // Verify we can get trace and previous exception info + $this->assertIsArray($exception->getMappingTrace()); } } + + public function testContextPreservationAcrossMappings() + { + $mapper = new Mapper(); + $mapper->bind(Input::class, Output::class); + $mapper->withContext('test_key', 'test_value'); + + $result = $mapper->map(new Input()); + + $this->assertInstanceOf(Output::class, $result); + + // Context should be preserved in the mapper + $context = $mapper->getContext(); + $this->assertSame('test_value', $context->get('test_key')); + } } diff --git a/tests/ExceptionTest/AbstractTarget.php b/tests/ExceptionTest/AbstractTarget.php new file mode 100644 index 0000000..77c9570 --- /dev/null +++ b/tests/ExceptionTest/AbstractTarget.php @@ -0,0 +1,8 @@ +map(new UnboundSource()); + $this->fail('Expected BindingNotFoundException'); + } catch (BindingNotFoundException $exception) { + // Verify exception message + $this->assertStringContainsString('No binding found', $exception->getMessage()); + $this->assertStringContainsString(UnboundSource::class, $exception->getMessage()); + + // Verify mapping trace exists and has useful data + $trace = $exception->getMappingTrace(); + $this->assertNotEmpty($trace, 'Mapping trace should not be empty'); + + $firstStep = $trace[0] ?? []; + $this->assertSame('resolve', $firstStep['phase'] ?? null); + $this->assertArrayHasKey('sourceType', $firstStep); + + // Verify it's a RemapException + $this->assertInstanceOf(RemapException::class, $exception); + } + } + + public function testBindingResolutionExceptionWithCallableReturningInvalid() + { + $mapper = new Mapper(); + + // Bind to a callable that returns a non-existent class + $mapper->bind(ValidSource::class, function () { + return 'NonExistentClass'; + }); + + try { + $mapper->map(new ValidSource()); + $this->fail('Expected BindingResolutionException'); + } catch (BindingResolutionException $exception) { + $this->assertStringContainsString('Cannot resolve binding', $exception->getMessage()); + $this->assertStringContainsString('NonExistentClass', $exception->getMessage()); + + // Verify trace + $trace = $exception->getMappingTrace(); + $this->assertNotEmpty($trace); + $this->assertSame('resolve', $trace[0]['phase'] ?? null); + } + } + + public function testBindingResolutionExceptionWithCallableReturningNonString() + { + $mapper = new Mapper(); + + // Bind to a callable that returns wrong type + $mapper->bind(ValidSource::class, function () { + return 123; + }); + + try { + $mapper->map(new ValidSource()); + $this->fail('Expected BindingResolutionException'); + } catch (BindingResolutionException $exception) { + $this->assertStringContainsString('Cannot resolve binding', $exception->getMessage()); + + $trace = $exception->getMappingTrace(); + $this->assertNotEmpty($trace); + } + } + + public function testInvalidTargetTypeException() + { + $mapper = new Mapper(); + + // Bind to an abstract class that cannot be instantiated + // This will throw MappingExecutionException wrapping the Error from newInstanceWithoutConstructor + $mapper->bind(ValidSource::class, AbstractTarget::class); + + try { + $mapper->map(new ValidSource()); + $this->fail('Expected MappingExecutionException'); + } catch (MappingExecutionException $exception) { + $this->assertStringContainsString('Mapping execution failed', $exception->getMessage()); + $this->assertStringContainsString('Cannot instantiate abstract class', $exception->getMessage()); + + // Verify it wraps the original Error + $this->assertNotNull($exception->getPrevious()); + + // Verify trace + $trace = $exception->getMappingTrace(); + $this->assertNotEmpty($trace); + + $executeStep = array_values(array_filter($trace, fn($s) => ($s['phase'] ?? null) === 'execute')); + $this->assertNotEmpty($executeStep); + $this->assertArrayHasKey('targetType', $executeStep[0]); + } + } + + public function testInvalidTargetTypeExceptionForInternalClass() + { + $mapper = new Mapper(); + + // Try to map to an internal class that may cause reflection issues + // Using a callable to bypass initial class_exists check + $mapper->bind(ValidSource::class, fn() => \PDO::class); + + try { + $result = $mapper->map(new ValidSource()); + // PDO might actually work, so we just verify it doesn't throw our custom exception + $this->assertInstanceOf(\PDO::class, $result); + } catch (InvalidTargetTypeException | MappingExecutionException $exception) { + // Either exception is acceptable for this edge case + $this->assertNotEmpty($exception->getMappingTrace()); + } + } + + public function testMapGetterResolutionException() + { + $mapper = new Mapper(); + $mapper->bind(SourceWithoutGetter::class, TargetWithMapGetter::class); + + try { + $mapper->map(new SourceWithoutGetter()); + $this->fail('Expected MapGetterResolutionException'); + } catch (MapGetterResolutionException $exception) { + $this->assertStringContainsString('MapGetter could not resolve', $exception->getMessage()); + $this->assertStringContainsString('missingMethod', $exception->getMessage()); + + // Verify trace includes property mapping phase + $trace = $exception->getMappingTrace(); + $this->assertNotEmpty($trace); + + $propertyMapStep = array_values(array_filter($trace, fn($s) => + ($s['phase'] ?? null) === 'property.map' + )); + $this->assertNotEmpty($propertyMapStep); + $this->assertSame('missingMethod', $propertyMapStep[0]['property'] ?? null); + } + } + + public function testMissingMappedValueException() + { + $mapper = new Mapper(); + $mapper->bind(EmptySource::class, TargetWithCasterOnly::class); + + try { + $mapper->map(new EmptySource()); + $this->fail('Expected MissingMappedValueException'); + } catch (MissingMappedValueException $exception) { + $this->assertStringContainsString('has no value', $exception->getMessage()); + + $trace = $exception->getMappingTrace(); + $this->assertNotEmpty($trace); + + $castStep = array_values(array_filter($trace, fn($s) => + ($s['phase'] ?? null) === 'constructor.parameter.cast' + )); + $this->assertNotEmpty($castStep); + $this->assertArrayHasKey('parameter', $castStep[0]); + $this->assertArrayHasKey('caster', $castStep[0]); + } + } + + public function testMappingExecutionExceptionWrapsUnknownErrors() + { + $mapper = new Mapper(); + $mapper->bind(ValidSource::class, TargetWithPropertyThatThrows::class); + + try { + $mapper->map(new ValidSource()); + $this->fail('Expected MappingExecutionException'); + } catch (MappingExecutionException $exception) { + $this->assertStringContainsString('Mapping execution failed', $exception->getMessage()); + + // Verify original exception is preserved + $this->assertNotNull($exception->getPrevious()); + $this->assertInstanceOf(\RuntimeException::class, $exception->getPrevious()); + $this->assertStringContainsString('Caster intentional error', $exception->getPrevious()->getMessage()); + + // Verify trace + $trace = $exception->getMappingTrace(); + $this->assertNotEmpty($trace); + + // Should have the cast phase where it failed + $castStep = array_values(array_filter($trace, fn($s) => + str_contains($s['phase'] ?? '', 'cast') + )); + $this->assertNotEmpty($castStep, 'Should have a cast step in trace'); + } + } + + #[DataProvider('allExceptionClassesProvider')] + public function testAllExceptionsAreRemapExceptions(string $exceptionClass) + { + $reflection = new \ReflectionClass($exceptionClass); + $this->assertTrue( + $reflection->isSubclassOf(RemapException::class), + "{$exceptionClass} should extend RemapException" + ); + } + + public static function allExceptionClassesProvider(): array + { + return [ + 'BindingNotFoundException' => [BindingNotFoundException::class], + 'BindingResolutionException' => [BindingResolutionException::class], + 'InvalidTargetTypeException' => [InvalidTargetTypeException::class], + 'MapGetterResolutionException' => [MapGetterResolutionException::class], + 'MappingExecutionException' => [MappingExecutionException::class], + 'MissingMappedValueException' => [MissingMappedValueException::class], + ]; + } + + public function testMappingTraceCanBeAppended() + { + $exception = BindingNotFoundException::forType('SomeType'); + + $this->assertEmpty($exception->getMappingTrace()); + + $exception->appendMappingTrace([ + ['phase' => 'test1', 'data' => 'value1'], + ['phase' => 'test2', 'data' => 'value2'], + ]); + + $trace = $exception->getMappingTrace(); + $this->assertCount(2, $trace); + $this->assertSame('test1', $trace[0]['phase']); + $this->assertSame('test2', $trace[1]['phase']); + + // Append more + $exception->appendMappingTrace([ + ['phase' => 'test3', 'data' => 'value3'], + ]); + + $trace = $exception->getMappingTrace(); + $this->assertCount(3, $trace); + $this->assertSame('test3', $trace[2]['phase']); + } + + public function testMappingTraceDeduplicatesSteps() + { + $exception = BindingNotFoundException::forType('SomeType'); + + $step = ['phase' => 'duplicate', 'data' => 'same']; + + $exception->appendMappingTrace([$step]); + $exception->appendMappingTrace([$step]); // Same step again + + $trace = $exception->getMappingTrace(); + $this->assertCount(1, $trace, 'Duplicate steps should be deduplicated'); + } +} diff --git a/tests/ExceptionTest/SourceWithoutGetter.php b/tests/ExceptionTest/SourceWithoutGetter.php new file mode 100644 index 0000000..e11780d --- /dev/null +++ b/tests/ExceptionTest/SourceWithoutGetter.php @@ -0,0 +1,8 @@ +bind(Input::class, Output::class); - $result = $mapper->map(new Input(name: 'Luis', birthdate: new DateTimeImmutable('1988-01-01'))); + $result = $mapper->map(new Input(name: $name, birthdate: $birthdate)); + $this->assertInstanceOf(Output::class, $result); - - $this->assertSame('Luis', $result->name); - $this->assertSame('1988-01-01T00:00:00+00:00', $result->birthdate); + $this->assertSame($name, $result->name); + $this->assertSame($expectedDate, $result->birthdate); $this->assertSame('demo', $result->type); } - public function testIterableMapping() + public static function mappingInputProvider(): array + { + return [ + 'basic date' => ['Luis', new DateTimeImmutable('1988-01-01'), '1988-01-01T00:00:00+00:00'], + 'with time' => ['Ana', new DateTimeImmutable('1990-05-15 14:30:00'), '1990-05-15T14:30:00+00:00'], + 'leap year' => ['Pedro', new DateTimeImmutable('2020-02-29'), '2020-02-29T00:00:00+00:00'], + 'end of year' => ['Maria', new DateTimeImmutable('2025-12-31 23:59:59'), '2025-12-31T23:59:59+00:00'], + ]; + } + + #[DataProvider('iterableMappingProvider')] + public function testIterableMapping(array $inputs, int $expectedCount) { $mapper = new Mapper(); - $mapper->bind(Input::class, Output::class); - $inputs = [ - new Input(name: 'Luis', birthdate: new DateTimeImmutable('1988-01-01')), - new Input(name: 'Ana', birthdate: new DateTimeImmutable('1990-05-15')), - ]; - $results = $mapper->mapAsIterable($inputs); + $this->assertIsArray($results); + $this->assertCount($expectedCount, $results); - $this->assertCount(2, $results); - $this->assertInstanceOf(Output::class, $results[0]); - $this->assertInstanceOf(Output::class, $results[1]); + foreach ($results as $result) { + $this->assertInstanceOf(Output::class, $result); + $this->assertNotEmpty($result->name); + $this->assertNotEmpty($result->birthdate); + } - $this->assertSame('Luis', $results[0]->name); - $this->assertSame('1988-01-01T00:00:00+00:00', $results[0]->birthdate); + // Verify first item specifically + if ($expectedCount > 0) { + $this->assertSame($inputs[0]->name, $results[0]->name); + } + } - $this->assertSame('Ana', $results[1]->name); - $this->assertSame('1990-05-15T00:00:00+00:00', $results[1]->birthdate); + public static function iterableMappingProvider(): array + { + return [ + 'two items' => [ + [ + new Input(name: 'Luis', birthdate: new DateTimeImmutable('1988-01-01')), + new Input(name: 'Ana', birthdate: new DateTimeImmutable('1990-05-15')), + ], + 2, + ], + 'single item' => [ + [new Input(name: 'Solo', birthdate: new DateTimeImmutable('2000-01-01'))], + 1, + ], + 'empty array' => [[], 0], + 'three items' => [ + [ + new Input(name: 'First', birthdate: new DateTimeImmutable('2020-01-01')), + new Input(name: 'Second', birthdate: new DateTimeImmutable('2021-02-02')), + new Input(name: 'Third', birthdate: new DateTimeImmutable('2022-03-03')), + ], + 3, + ], + ]; } public function testCastIterable() From 5501353875265732ea4cf02d96c8d84f91a21f19 Mon Sep 17 00:00:00 2001 From: Luis Medina <6675716+luimedi@users.noreply.github.com> Date: Sat, 7 Mar 2026 16:39:24 +0100 Subject: [PATCH 7/8] Refactor GitHub Actions workflow to include PHP version matrix for testing --- .github/workflows/php.yml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index 8a0e60d..2c86968 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -14,9 +14,22 @@ jobs: runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php-version: ['8.0', '8.1', '8.2', '8.3', '8.4', '8.5'] + + name: PHP ${{ matrix.php-version }} Test + steps: - uses: actions/checkout@v4 + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + coverage: none + - name: Validate composer.json and composer.lock run: composer validate --strict From d0392f39f6430d7f32df4a5c3cc3dc00988b0606 Mon Sep 17 00:00:00 2001 From: Luis Medina <6675716+luimedi@users.noreply.github.com> Date: Sat, 7 Mar 2026 16:42:55 +0100 Subject: [PATCH 8/8] Update PHP version requirements and adjust GitHub Actions matrix for testing --- .github/workflows/php.yml | 2 +- composer.json | 2 +- composer.lock | 109 +++++++++++++++++++++----------------- 3 files changed, 63 insertions(+), 50 deletions(-) diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index 2c86968..81fc866 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -17,7 +17,7 @@ jobs: strategy: fail-fast: false matrix: - php-version: ['8.0', '8.1', '8.2', '8.3', '8.4', '8.5'] + php-version: ['8.3', '8.4', '8.5'] name: PHP ${{ matrix.php-version }} Test diff --git a/composer.json b/composer.json index 0aa7633..16d3fad 100644 --- a/composer.json +++ b/composer.json @@ -21,7 +21,7 @@ } ], "require": { - "php": ">=8.0" + "php": ">=8.3" }, "require-dev": { "phpunit/phpunit": "^12" diff --git a/composer.lock b/composer.lock index 8700062..981a2e3 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "fe001ad08bc57da3b0858b1c9bf29b05", + "content-hash": "8d019dd40dabba605d62613558f070cf", "packages": [], "packages-dev": [ { @@ -69,16 +69,16 @@ }, { "name": "nikic/php-parser", - "version": "v5.6.2", + "version": "v5.7.0", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "3a454ca033b9e06b63282ce19562e892747449bb" + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/3a454ca033b9e06b63282ce19562e892747449bb", - "reference": "3a454ca033b9e06b63282ce19562e892747449bb", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82", "shasum": "" }, "require": { @@ -121,9 +121,9 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v5.6.2" + "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0" }, - "time": "2025-10-21T19:32:17+00:00" + "time": "2025-12-06T11:56:16+00:00" }, { "name": "phar-io/manifest", @@ -245,23 +245,23 @@ }, { "name": "phpunit/php-code-coverage", - "version": "12.4.0", + "version": "12.5.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "67e8aed88f93d0e6e1cb7effe1a2dfc2fee6022c" + "reference": "b015312f28dd75b75d3422ca37dff2cd1a565e8d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/67e8aed88f93d0e6e1cb7effe1a2dfc2fee6022c", - "reference": "67e8aed88f93d0e6e1cb7effe1a2dfc2fee6022c", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/b015312f28dd75b75d3422ca37dff2cd1a565e8d", + "reference": "b015312f28dd75b75d3422ca37dff2cd1a565e8d", "shasum": "" }, "require": { "ext-dom": "*", "ext-libxml": "*", "ext-xmlwriter": "*", - "nikic/php-parser": "^5.6.1", + "nikic/php-parser": "^5.7.0", "php": ">=8.3", "phpunit/php-file-iterator": "^6.0", "phpunit/php-text-template": "^5.0", @@ -269,10 +269,10 @@ "sebastian/environment": "^8.0.3", "sebastian/lines-of-code": "^4.0", "sebastian/version": "^6.0", - "theseer/tokenizer": "^1.2.3" + "theseer/tokenizer": "^2.0.1" }, "require-dev": { - "phpunit/phpunit": "^12.3.7" + "phpunit/phpunit": "^12.5.1" }, "suggest": { "ext-pcov": "PHP extension that provides line coverage", @@ -281,7 +281,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "12.4.x-dev" + "dev-main": "12.5.x-dev" } }, "autoload": { @@ -310,7 +310,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", - "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.4.0" + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.5.3" }, "funding": [ { @@ -330,20 +330,20 @@ "type": "tidelift" } ], - "time": "2025-09-24T13:44:41+00:00" + "time": "2026-02-06T06:01:44+00:00" }, { "name": "phpunit/php-file-iterator", - "version": "6.0.0", + "version": "6.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-file-iterator.git", - "reference": "961bc913d42fe24a257bfff826a5068079ac7782" + "reference": "3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/961bc913d42fe24a257bfff826a5068079ac7782", - "reference": "961bc913d42fe24a257bfff826a5068079ac7782", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5", + "reference": "3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5", "shasum": "" }, "require": { @@ -383,15 +383,27 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", - "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/6.0.0" + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/6.0.1" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-file-iterator", + "type": "tidelift" } ], - "time": "2025-02-07T04:58:37+00:00" + "time": "2026-02-02T14:04:18+00:00" }, { "name": "phpunit/php-invoker", @@ -579,16 +591,16 @@ }, { "name": "phpunit/phpunit", - "version": "12.4.2", + "version": "12.5.14", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "a94ea4d26d865875803b23aaf78c3c2c670ea2ea" + "reference": "47283cfd98d553edcb1353591f4e255dc1bb61f0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/a94ea4d26d865875803b23aaf78c3c2c670ea2ea", - "reference": "a94ea4d26d865875803b23aaf78c3c2c670ea2ea", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/47283cfd98d553edcb1353591f4e255dc1bb61f0", + "reference": "47283cfd98d553edcb1353591f4e255dc1bb61f0", "shasum": "" }, "require": { @@ -602,18 +614,19 @@ "phar-io/manifest": "^2.0.4", "phar-io/version": "^3.2.1", "php": ">=8.3", - "phpunit/php-code-coverage": "^12.4.0", - "phpunit/php-file-iterator": "^6.0.0", + "phpunit/php-code-coverage": "^12.5.3", + "phpunit/php-file-iterator": "^6.0.1", "phpunit/php-invoker": "^6.0.0", "phpunit/php-text-template": "^5.0.0", "phpunit/php-timer": "^8.0.0", "sebastian/cli-parser": "^4.2.0", - "sebastian/comparator": "^7.1.3", + "sebastian/comparator": "^7.1.4", "sebastian/diff": "^7.0.0", "sebastian/environment": "^8.0.3", "sebastian/exporter": "^7.0.2", "sebastian/global-state": "^8.0.2", "sebastian/object-enumerator": "^7.0.0", + "sebastian/recursion-context": "^7.0.1", "sebastian/type": "^6.0.3", "sebastian/version": "^6.0.0", "staabm/side-effects-detector": "^1.0.5" @@ -624,7 +637,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "12.4-dev" + "dev-main": "12.5-dev" } }, "autoload": { @@ -656,7 +669,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/12.4.2" + "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.14" }, "funding": [ { @@ -680,7 +693,7 @@ "type": "tidelift" } ], - "time": "2025-10-30T08:41:39+00:00" + "time": "2026-02-18T12:38:40+00:00" }, { "name": "sebastian/cli-parser", @@ -753,16 +766,16 @@ }, { "name": "sebastian/comparator", - "version": "7.1.3", + "version": "7.1.4", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "dc904b4bb3ab070865fa4068cd84f3da8b945148" + "reference": "6a7de5df2e094f9a80b40a522391a7e6022df5f6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/dc904b4bb3ab070865fa4068cd84f3da8b945148", - "reference": "dc904b4bb3ab070865fa4068cd84f3da8b945148", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/6a7de5df2e094f9a80b40a522391a7e6022df5f6", + "reference": "6a7de5df2e094f9a80b40a522391a7e6022df5f6", "shasum": "" }, "require": { @@ -821,7 +834,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/comparator/issues", "security": "https://github.com/sebastianbergmann/comparator/security/policy", - "source": "https://github.com/sebastianbergmann/comparator/tree/7.1.3" + "source": "https://github.com/sebastianbergmann/comparator/tree/7.1.4" }, "funding": [ { @@ -841,7 +854,7 @@ "type": "tidelift" } ], - "time": "2025-08-20T11:27:00+00:00" + "time": "2026-01-24T09:28:48+00:00" }, { "name": "sebastian/complexity", @@ -1633,23 +1646,23 @@ }, { "name": "theseer/tokenizer", - "version": "1.2.3", + "version": "2.0.1", "source": { "type": "git", "url": "https://github.com/theseer/tokenizer.git", - "reference": "737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2" + "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/theseer/tokenizer/zipball/737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2", - "reference": "737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/7989e43bf381af0eac72e4f0ca5bcbfa81658be4", + "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4", "shasum": "" }, "require": { "ext-dom": "*", "ext-tokenizer": "*", "ext-xmlwriter": "*", - "php": "^7.2 || ^8.0" + "php": "^8.1" }, "type": "library", "autoload": { @@ -1671,7 +1684,7 @@ "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", "support": { "issues": "https://github.com/theseer/tokenizer/issues", - "source": "https://github.com/theseer/tokenizer/tree/1.2.3" + "source": "https://github.com/theseer/tokenizer/tree/2.0.1" }, "funding": [ { @@ -1679,7 +1692,7 @@ "type": "github" } ], - "time": "2024-03-03T12:36:25+00:00" + "time": "2025-12-08T11:19:18+00:00" } ], "aliases": [], @@ -1688,8 +1701,8 @@ "prefer-stable": false, "prefer-lowest": false, "platform": { - "php": ">=8.0" + "php": ">=8.3" }, "platform-dev": {}, - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.9.0" }