From 95d89a6603a31eccd140a5fdfa3af665ce0cf7ad Mon Sep 17 00:00:00 2001 From: Toon Verwerft Date: Wed, 14 Oct 2026 18:54:19 +0200 Subject: [PATCH 01/12] Require the reflecta branch with the optional lenses veewee/reflecta#39 adds optional_index() and optional_property(), stacked on #37. Until 1.1.0 is tagged the branch comes in through a VCS repository aliased as 1.1.0, so this cannot be merged before the constraint is back on a release. --- composer.json | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/composer.json b/composer.json index bc4c53e..5393b6b 100644 --- a/composer.json +++ b/composer.json @@ -20,6 +20,12 @@ "email": "toonverwerft@gmail.com" } ], + "repositories": [ + { + "type": "vcs", + "url": "https://github.com/veewee/reflecta" + } + ], "require": { "php": "~8.4.0 || ~8.5.0", "php-standard-library/collection": "^6.1", @@ -34,7 +40,7 @@ "php-standard-library/str": "^6.1", "php-standard-library/type": "^6.1", "php-standard-library/vec": "^6.1", - "veewee/reflecta": "^1.0.0", + "veewee/reflecta": "dev-feature/optional-lenses as 1.1.0", "veewee/xml": "^4.10", "php-soap/engine": "^2.20", "php-soap/wsdl": "^1.19", From 2e983ff5d3d4daa5fdd103fbd6df1bee0084c753 Mon Sep 17 00:00:00 2001 From: Toon Verwerft Date: Wed, 14 Oct 2026 18:57:26 +0200 Subject: [PATCH 02/12] Read absent elements and properties without throwing The built-in lenses were index() and property(), which throw for every absent element, attribute or property, and the exception was caught again by optional() or runLens. reflecta's optional_index() and optional_property() return null in those cases without throwing, which is what the caught exception turned into before, so they replace the built-in lenses unwrapped whether or not the property is optional. Custom lenses from ProvidesObjectEncoderLens and ProvidesObjectDecoderLens keep the optional() wrapping and the runLens catch. --- src/Encoder/ObjectAccess.php | 40 ++++---- tests/Fixture/Encoder/ThrowingLensEncoder.php | 73 +++++++++++++++ .../IntermediateThrowScenariosTest.php | 9 ++ .../Integration/NoIntermediateThrowsTest.php | 50 ++++++++++ .../Encoder/ObjectEncoderCustomLensTest.php | 93 +++++++++++++++++++ 5 files changed, 249 insertions(+), 16 deletions(-) create mode 100644 tests/Fixture/Encoder/ThrowingLensEncoder.php create mode 100644 tests/Integration/NoIntermediateThrowsTest.php create mode 100644 tests/Unit/Encoder/ObjectEncoderCustomLensTest.php diff --git a/src/Encoder/ObjectAccess.php b/src/Encoder/ObjectAccess.php index 64ebd84..4fdecf3 100644 --- a/src/Encoder/ObjectAccess.php +++ b/src/Encoder/ObjectAccess.php @@ -15,9 +15,9 @@ use VeeWee\Reflecta\Iso\IsoInterface; use VeeWee\Reflecta\Lens\LensInterface; use function Psl\Vec\sort_by; -use function VeeWee\Reflecta\Lens\index; use function VeeWee\Reflecta\Lens\optional; -use function VeeWee\Reflecta\Lens\property; +use function VeeWee\Reflecta\Lens\optional_index; +use function VeeWee\Reflecta\Lens\optional_property; final class ObjectAccess { @@ -89,8 +89,10 @@ private static function build(Context $context): self $shouldLensBeOptional = self::shouldLensBeOptional($propertyTypeMeta); $normalizedProperties[$normalizedName] = $property; - $encoderLenses[$normalizedName] = self::createEncoderLensForType($shouldLensBeOptional, $normalizedName, $encoder, $type, $property); - $decoderLenses[$normalizedName] = self::createDecoderLensForType($shouldLensBeOptional, $name, $encoder, $type, $property); + $encoderLenses[$normalizedName] = self::createCustomEncoderLens($shouldLensBeOptional, $encoder, $type, $property) + ?? optional_property($normalizedName); + $decoderLenses[$normalizedName] = self::createCustomDecoderLens($shouldLensBeOptional, $encoder, $type, $property) + ?? optional_index($name); $isos[$normalizedName] = $encoder->iso($propertyContext); $isAnyPropertyQualified = $isAnyPropertyQualified || $propertyTypeMeta->isQualified()->unwrapOr(false); @@ -106,40 +108,46 @@ private static function build(Context $context): self } /** - * @return LensInterface + * @return LensInterface|null */ - private static function createEncoderLensForType( + private static function createCustomEncoderLens( bool $shouldLensBeOptional, - string $normalizedName, XmlEncoder $encoder, Type $type, Property $property, - ): LensInterface { + ): ?LensInterface { $lens = match (true) { - $encoder instanceof Feature\DecoratingEncoder => self::createEncoderLensForType($shouldLensBeOptional, $normalizedName, $encoder->decoratedEncoder(), $type, $property), + $encoder instanceof Feature\DecoratingEncoder => self::createCustomEncoderLens($shouldLensBeOptional, $encoder->decoratedEncoder(), $type, $property), $encoder instanceof Feature\ProvidesObjectEncoderLens => $encoder::createObjectEncoderLens($type, $property), - default => property($normalizedName) + default => null, }; + if ($lens === null) { + return null; + } + return $shouldLensBeOptional ? optional($lens) : $lens; } /** - * @return LensInterface|null, array|null, mixed, mixed> + * @return LensInterface|null, array|null, mixed, mixed>|null */ - private static function createDecoderLensForType( + private static function createCustomDecoderLens( bool $shouldLensBeOptional, - string $name, XmlEncoder $encoder, Type $type, Property $property, - ): LensInterface { + ): ?LensInterface { $lens = match(true) { - $encoder instanceof Feature\DecoratingEncoder => self::createDecoderLensForType($shouldLensBeOptional, $name, $encoder->decoratedEncoder(), $type, $property), + $encoder instanceof Feature\DecoratingEncoder => self::createCustomDecoderLens($shouldLensBeOptional, $encoder->decoratedEncoder(), $type, $property), $encoder instanceof Feature\ProvidesObjectDecoderLens => $encoder::createObjectDecoderLens($type, $property), - default => index($name), + default => null, }; + if ($lens === null) { + return null; + } + return $shouldLensBeOptional ? optional($lens) : $lens; } diff --git a/tests/Fixture/Encoder/ThrowingLensEncoder.php b/tests/Fixture/Encoder/ThrowingLensEncoder.php new file mode 100644 index 0000000..4978b35 --- /dev/null +++ b/tests/Fixture/Encoder/ThrowingLensEncoder.php @@ -0,0 +1,73 @@ + + * @template-implements Feature\ProvidesObjectEncoderLens + * @template-implements Feature\ProvidesObjectDecoderLens + */ +final class ThrowingLensEncoder implements + Feature\DisregardXsiInformation, + Feature\ProvidesObjectDecoderLens, + Feature\ProvidesObjectEncoderLens, + XmlEncoder +{ + public static int $encoderLensReads = 0; + public static int $decoderLensReads = 0; + + /** + * @return Lens + */ + public static function createObjectEncoderLens(Type $parentType, Property $currentProperty): Lens + { + $lens = property($currentProperty->getName()); + + /** @var Lens */ + return Lens::readonly(static function (object $data) use ($lens): mixed { + self::$encoderLensReads++; + + return $lens->get($data); + }); + } + + /** + * @return Lens + */ + public static function createObjectDecoderLens(Type $parentType, Property $currentProperty): Lens + { + $lens = index($currentProperty->getName()); + + /** @var Lens */ + return Lens::readonly(static function (array $data) use ($lens): mixed { + self::$decoderLensReads++; + + return $lens->get($data); + }); + } + + /** + * @return Iso + */ + public function iso(Context $context): Iso + { + return new Iso( + static fn (?string $value): string => $value === null ? '' : '' . $value . '', + static fn (Element|string $xml): string => ($xml instanceof Element ? $xml : Element::fromString($xml))->element()->textContent, + ); + } +} diff --git a/tests/Integration/IntermediateThrowScenariosTest.php b/tests/Integration/IntermediateThrowScenariosTest.php index 6f1b0d2..325ee1f 100644 --- a/tests/Integration/IntermediateThrowScenariosTest.php +++ b/tests/Integration/IntermediateThrowScenariosTest.php @@ -141,6 +141,15 @@ public static function provideScenarios(): iterable ), ]; + yield 'encode object of a class that does not declare the properties' => [ + 'operation' => static fn (): string => $literal->encode('test', [new class { + public int $id = 1; + }])->getRequest(), + 'expected' => self::literalRequest( + '1
' + ), + ]; + yield 'encode uninitialised typed properties' => [ 'operation' => static function () use ($typed): string { $item = new Item(); diff --git a/tests/Integration/NoIntermediateThrowsTest.php b/tests/Integration/NoIntermediateThrowsTest.php new file mode 100644 index 0000000..3c862bd --- /dev/null +++ b/tests/Integration/NoIntermediateThrowsTest.php @@ -0,0 +1,50 @@ + + */ + public static function provideScenarios(): iterable + { + foreach (IntermediateThrowScenariosTest::provideScenarios() as $name => $scenario) { + if (!in_array($name, self::STILL_THROWING, true)) { + yield $name => $scenario; + } + } + } +} diff --git a/tests/Unit/Encoder/ObjectEncoderCustomLensTest.php b/tests/Unit/Encoder/ObjectEncoderCustomLensTest.php new file mode 100644 index 0000000..0bd3c7c --- /dev/null +++ b/tests/Unit/Encoder/ObjectEncoderCustomLensTest.php @@ -0,0 +1,93 @@ + + + + + + + + + + + XSD; + + protected function setUp(): void + { + ThrowingLensEncoder::$encoderLensReads = 0; + ThrowingLensEncoder::$decoderLensReads = 0; + } + + public function test_it_decodes_through_custom_lenses_that_throw_on_a_missing_element(): void + { + $actual = $this->iso()->from('1x'); + + static::assertEquals((object) ['id' => 1, 'custom' => null, 'any' => ['x']], $actual); + static::assertSame(1, ThrowingLensEncoder::$decoderLensReads); + } + + public function test_it_decodes_through_custom_lenses_when_the_element_is_present(): void + { + $actual = $this->iso()->from('1c'); + + static::assertEquals((object) ['id' => 1, 'custom' => 'c', 'any' => []], $actual); + static::assertSame(1, ThrowingLensEncoder::$decoderLensReads); + } + + public function test_it_encodes_through_custom_lenses_that_throw_on_a_missing_property(): void + { + $actual = $this->iso()->to((object) ['id' => 1, 'any' => ['x']]); + + static::assertSame('1x', $actual); + static::assertSame(1, ThrowingLensEncoder::$encoderLensReads); + } + + public function test_it_encodes_through_custom_lenses_when_the_property_is_present(): void + { + $actual = $this->iso()->to((object) ['id' => 1, 'custom' => 'c']); + + static::assertSame('1c', $actual); + static::assertSame(1, ThrowingLensEncoder::$encoderLensReads); + } + + /** + * @return \VeeWee\Reflecta\Iso\IsoInterface + */ + private function iso(): \VeeWee\Reflecta\Iso\IsoInterface + { + $metadata = self::createMetadataFromWsdl(self::SCHEMA, 'type="tns:user"', use: BindingUse::LITERAL); + $registry = EncoderRegistry::default() + ->addComplexTypeConverter('https://test', 'customType', new ThrowingLensEncoder()); + $context = new Context( + $metadata->getTypes()->fetchByNameAndXmlNamespace('user', 'https://test')->getXsdType(), + $metadata, + $registry, + self::buildNamespaces(), + ); + + return (new ObjectEncoder(stdClass::class))->iso($context); + } +} From a46d7e079fd415f02639c44fd688db277e762950 Mon Sep 17 00:00:00 2001 From: Toon Verwerft Date: Wed, 14 Oct 2026 18:59:13 +0200 Subject: [PATCH 03/12] Decode untyped scalars without throwing An untyped value went through Psl's int, float and bool coercion in turn and every miss threw a CoercionException, so a plain string threw three or four times. The decoder now checks the same rules up front: the int and float checks repeat Psl's string coercion, including its trimming of leading zeros that decodes '0-5' as -5. The test compares the result with Psl itself over every short string, so a Psl upgrade that changes those rules shows up as a failure. --- src/Encoder/SimpleType/ScalarTypeEncoder.php | 63 ++++--- .../Integration/NoIntermediateThrowsTest.php | 60 +++++- .../ScalarTypeEncoderUntypedDecodeTest.php | 175 ++++++++++++++++++ 3 files changed, 267 insertions(+), 31 deletions(-) create mode 100644 tests/Unit/Encoder/SimpleType/ScalarTypeEncoderUntypedDecodeTest.php diff --git a/src/Encoder/SimpleType/ScalarTypeEncoder.php b/src/Encoder/SimpleType/ScalarTypeEncoder.php index 0e5612d..55e398b 100644 --- a/src/Encoder/SimpleType/ScalarTypeEncoder.php +++ b/src/Encoder/SimpleType/ScalarTypeEncoder.php @@ -3,7 +3,6 @@ namespace Soap\Encoding\Encoder\SimpleType; -use Psl\Type; use Soap\Encoding\Encoder\Context; use Soap\Encoding\Encoder\XmlEncoder; use Soap\Encoding\Exception\RestrictionException; @@ -12,6 +11,8 @@ use function is_float; use function is_int; use function is_string; +use function ltrim; +use function preg_match; /** * @implements XmlEncoder @@ -41,36 +42,40 @@ public function iso(Context $context): Iso is_bool($value) => (new BoolTypeEncoder())->iso($context)->to($value), default => throw RestrictionException::unsupportedValueType($context->type, $value) }, - static function (string $value) use ($context): mixed { - try { - return Type\int()->coerce($value); - } catch (Type\Exception\CoercionException) { - } + self::decodeUntyped(...), + )); + } + + /** + * Decodes like coercing to int, float, the 'true' or 'false' literal and string in that order, without throwing on each miss. + * The int and float checks repeat Psl's IntType and FloatType coercion of a string. + */ + private static function decodeUntyped(string $value): int|float|bool|string + { + $int = (int) $value; + if ($value === (string) $int) { + return $int; + } - try { - return Type\float()->coerce($value); - } catch (Type\Exception\CoercionException) { - } + // Psl trims leading zeros before comparing, which also turns '0-5' into -5. + $trimmed = ltrim($value, '0'); + $int = (int) $trimmed; + if ($trimmed === (string) $int) { + return $int; + } - try { - return Type\converted( - Type\string(), - Type\bool(), - static fn (string $value): bool => match ($value) { - 'true' => true, - 'false' => false, - default => throw RestrictionException::unexpectedEnumType( - $context->type, - ['true', 'false'], - $value - ) - } - )->coerce($value); - } catch (Type\Exception\CoercionException) { - } + if ($trimmed === '' && $value !== '') { + return 0; + } - return Type\string()->coerce($value); - } - )); + if (preg_match('/^[+-]?(\d+([.]\d*)?([eE][+-]?\d+)?|[.]\d+([eE][+-]?\d+)?)$/', $value) === 1) { + return (float) $value; + } + + return match ($value) { + 'true' => true, + 'false' => false, + default => $value, + }; } } diff --git a/tests/Integration/NoIntermediateThrowsTest.php b/tests/Integration/NoIntermediateThrowsTest.php index 3c862bd..b0a3842 100644 --- a/tests/Integration/NoIntermediateThrowsTest.php +++ b/tests/Integration/NoIntermediateThrowsTest.php @@ -8,20 +8,28 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; +use Soap\Encoding\Encoder\SimpleType\ScalarTypeEncoder; +use Soap\Encoding\Encoder\SimpleType\SimpleListEncoder; +use Soap\Encoding\Encoder\SoapEnc\ApacheMapEncoder; +use Soap\Encoding\Encoder\SoapEnc\SoapObjectEncoder; use Soap\Encoding\Test\Support\Throws\AssertsNoThrows; +use Soap\Encoding\Test\Unit\ContextCreatorTrait; +use Soap\Engine\Metadata\Model\TypeMeta; +use Soap\Engine\Metadata\Model\XsdType; use function in_array; +use function var_export; #[CoversNothing] #[Group('no-throws')] final class NoIntermediateThrowsTest extends TestCase { use AssertsNoThrows; + use ContextCreatorTrait; /** - * The untyped scalar decode and the engine type lookup for soap-enc arrays of a builtin item type still throw. + * The engine type lookup for soap-enc arrays of a builtin item type still throws. */ private const STILL_THROWING = [ - 'decode untyped values', 'decode soap-enc array of a builtin type', ]; @@ -36,6 +44,54 @@ public function test_it_does_not_throw_internally(Closure $operation, mixed $exp static::assertNoThrows($operation); } + /** + * @param Closure(): mixed $decode + */ + #[DataProvider('provideUntypedDecodes')] + public function test_it_decodes_untyped_values_without_throwing(Closure $decode, mixed $expected): void + { + static::assertEquals($expected, $decode()); + + static::assertNoThrows($decode); + } + + /** + * @return iterable + */ + public static function provideUntypedDecodes(): iterable + { + $scalar = ScalarTypeEncoder::default()->iso(self::createContext(XsdType::any())); + foreach (['1.5' => 1.5, '1e3' => 1000.0, 'abc' => 'abc', '' => '', 'true' => true, 'false' => false, '12' => 12] as $value => $expected) { + $value = (string) $value; + yield 'scalar ' . var_export($value, true) => [ + 'decode' => static fn (): mixed => $scalar->from($value), + 'expected' => $expected, + ]; + } + + $list = (new SimpleListEncoder(ScalarTypeEncoder::default()))->iso(self::createContext( + XsdType::any()->withMeta(static fn (TypeMeta $meta): TypeMeta => $meta->withIsList(true)->withIsSimple(true)) + )); + yield 'simple list' => [ + 'decode' => static fn (): mixed => $list->from('1 1.5 abc true'), + 'expected' => [1, 1.5, 'abc', true], + ]; + + $map = (new ApacheMapEncoder())->iso(self::createContext(XsdType::guess('Map'))); + yield 'apache map values' => [ + 'decode' => static fn (): mixed => $map->from( + 'aabcb1.5' + ), + 'expected' => ['a' => 'abc', 'b' => 1.5], + ]; + + $object = (new SoapObjectEncoder())->iso(self::createContext(XsdType::guess('Struct'))); + yield 'soap-enc struct values' => [ + 'decode' => static fn (): mixed => $object->from('abc1.5false'), + 'expected' => (object) ['a' => 'abc', 'b' => 1.5, 'c' => false], + ]; + } + /** * @return iterable */ diff --git a/tests/Unit/Encoder/SimpleType/ScalarTypeEncoderUntypedDecodeTest.php b/tests/Unit/Encoder/SimpleType/ScalarTypeEncoderUntypedDecodeTest.php new file mode 100644 index 0000000..dd10fe0 --- /dev/null +++ b/tests/Unit/Encoder/SimpleType/ScalarTypeEncoderUntypedDecodeTest.php @@ -0,0 +1,175 @@ + + */ + public static function provideNamedCases(): iterable + { + yield 'zero' => ['0', 0]; + yield 'one is an int, not a bool' => ['1', 1]; + yield 'int' => ['7', 7]; + yield 'negative int' => ['-12', -12]; + yield 'int max' => ['9223372036854775807', PHP_INT_MAX]; + yield 'leading zeros' => ['007', 7]; + yield 'more leading zeros' => ['0012', 12]; + yield 'only zeros' => ['000', 0]; + yield 'two zeros' => ['00', 0]; + yield 'psl quirk: zeros before a minus are trimmed into a negative int' => ['0-5', -5]; + yield 'psl quirk with two zeros' => ['00-5', -5]; + yield 'negative with leading zero is a float' => ['-012', -12.0]; + yield 'plus sign is a float' => ['+5', 5.0]; + yield 'negative zero is a float' => ['-0', -0.0]; + yield 'above int max is a float' => ['9223372036854775808', 9223372036854775808.0]; + yield 'far above int max is a float' => ['99999999999999999999', 99999999999999999999.0]; + yield 'decimal' => ['1.5', 1.5]; + yield 'decimal without leading digit' => ['.5', 0.5]; + yield 'decimal without trailing digit' => ['5.', 5.0]; + yield 'exponent' => ['1e3', 1000.0]; + yield 'negative upper case exponent' => ['1E-3', 0.001]; + yield 'signed exponent' => ['+1.5e+2', 150.0]; + yield 'overflowing exponent' => ['1e999', INF]; + yield 'decimal with trailing newline' => ["1.5\n", 1.5]; + yield 'int with trailing newline is a float' => ["12\n", 12.0]; + yield 'leading space' => [' 12', ' 12']; + yield 'trailing space' => ['12 ', '12 ']; + yield 'leading tab' => ["\t1", "\t1"]; + yield 'underscore separator' => ['1_000', '1_000']; + yield 'hexadecimal' => ['0x1A', '0x1A']; + yield 'decimal comma' => ['1,5', '1,5']; + yield 'exponent without digits' => ['1e', '1e']; + yield 'exponent without mantissa' => ['e1', 'e1']; + yield 'double minus' => ['--1', '--1']; + yield 'plus' => ['+', '+']; + yield 'minus' => ['-', '-']; + yield 'dot' => ['.', '.']; + yield 'empty' => ['', '']; + yield 'true' => ['true', true]; + yield 'false' => ['false', false]; + yield 'upper case true stays a string' => ['TRUE', 'TRUE']; + yield 'capitalised false stays a string' => ['False', 'False']; + yield 'yes' => ['yes', 'yes']; + yield 'on' => ['on', 'on']; + yield 'null' => ['null', 'null']; + yield 'nan' => ['NaN', 'NaN']; + yield 'inf' => ['INF', 'INF']; + yield 'word' => ['abc', 'abc']; + } + + private static function decode(string $value): mixed + { + set_error_handler(static function (int $severity, string $message, string $file, int $line): never { + throw new ErrorException($message, 0, $severity, $file, $line); + }); + + try { + return ScalarTypeEncoder::default()->iso(self::createContext(XsdType::any()))->from($value); + } finally { + restore_error_handler(); + } + } + + private static function oracle(string $value): int|float|bool|string + { + try { + return Type\int()->coerce($value); + } catch (Type\Exception\CoercionException) { + } + + try { + return Type\float()->coerce($value); + } catch (Type\Exception\CoercionException) { + } + + return match ($value) { + 'true' => true, + 'false' => false, + default => $value, + }; + } + + /** + * @return list + */ + private static function sweep(): array + { + $values = ['']; + $layer = ['']; + for ($length = 1; $length <= self::SWEEP_MAX_LENGTH; $length++) { + $next = []; + foreach ($layer as $prefix) { + foreach (self::SWEEP_ALPHABET as $char) { + $next[] = $prefix . $char; + } + } + $values = [...$values, ...$next]; + $layer = $next; + } + + return $values; + } + + /** + * @return list + */ + private static function boundaries(): array + { + return [ + '9223372036854775807', + '9223372036854775808', + '-9223372036854775808', + '-9223372036854775809', + '09223372036854775807', + '+9223372036854775807', + '9223372036854775807.0', + '18446744073709551616', + ]; + } +} From 6df7cf420fd863d255140297eba9e765851248df Mon Sep 17 00:00:00 2001 From: Toon Verwerft Date: Wed, 14 Oct 2026 19:23:40 +0200 Subject: [PATCH 04/12] Require the engine branch with the non-throwing type lookup php-soap/engine#30 adds TypeCollection::findByNameAndXmlNamespace(). Until it is released, the branch is aliased as 2.21.0 so php-soap/wsdl-reader's ^2.20 constraint still resolves. --- composer.json | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 5393b6b..1d3ea8a 100644 --- a/composer.json +++ b/composer.json @@ -24,6 +24,10 @@ { "type": "vcs", "url": "https://github.com/veewee/reflecta" + }, + { + "type": "vcs", + "url": "https://github.com/veewee/engine" } ], "require": { @@ -42,7 +46,7 @@ "php-standard-library/vec": "^6.1", "veewee/reflecta": "dev-feature/optional-lenses as 1.1.0", "veewee/xml": "^4.10", - "php-soap/engine": "^2.20", + "php-soap/engine": "dev-feature/find-type-by-name-and-namespace as 2.21.0", "php-soap/wsdl": "^1.19", "php-soap/xml": "^1.10", "php-soap/wsdl-reader": "~0.32" From e30709a0390c5a5d8c90b16d54c787df1634fbbe Mon Sep 17 00:00:00 2001 From: Toon Verwerft Date: Wed, 14 Oct 2026 19:24:23 +0200 Subject: [PATCH 05/12] Look up soap-enc array item types without throwing Every soap-enc array of a builtin item type, such as xsd:int or xsd:anyType, missed the metadata lookup and threw a MetadataException before falling back to xsd:anyType. The engine's findByNameAndXmlNamespace() returns null instead, so the fallback no longer needs an exception. --- src/Encoder/SoapEnc/SoapArrayAccess.php | 13 +++++-------- tests/Integration/NoIntermediateThrowsTest.php | 14 +------------- 2 files changed, 6 insertions(+), 21 deletions(-) diff --git a/src/Encoder/SoapEnc/SoapArrayAccess.php b/src/Encoder/SoapEnc/SoapArrayAccess.php index 437dfc5..69409f8 100644 --- a/src/Encoder/SoapEnc/SoapArrayAccess.php +++ b/src/Encoder/SoapEnc/SoapArrayAccess.php @@ -11,7 +11,6 @@ use Soap\WsdlReader\Model\Definitions\BindingUse; use Soap\WsdlReader\Parser\Xml\QnameParser; use Soap\Xml\Xmlns; -use function Psl\Result\try_catch; final class SoapArrayAccess { @@ -38,16 +37,14 @@ public static function forContext(Context $context): self $itemNodeName = $meta->arrayNodeName()->unwrapOr(null); $xsiType = ltrim($nodePrefix . ':' . $nodeType, ':'); - $xsdType = try_catch( - static fn (): XsdType => $context->metadata->getTypes() - ->fetchByNameAndXmlNamespace($nodeType, $namespace) - ->getXsdType(), - static fn (): XsdType => XsdType::any() + $xsdType = $context->metadata->getTypes() + ->findByNameAndXmlNamespace($nodeType, $namespace) + ?->getXsdType() + ?? XsdType::any() ->copy($nodeType) ->withXmlTypeName($nodeType) ->withXmlNamespace($namespace) - ->withMeta(static fn (TypeMeta $meta): TypeMeta => $meta->withIsElement(true)) - ); + ->withMeta(static fn (TypeMeta $meta): TypeMeta => $meta->withIsElement(true)); if ($context->bindingUse === BindingUse::ENCODED || $itemNodeName !== null) { $xsdType = $xsdType->withXmlTargetNodeName($itemNodeName ?? 'item'); diff --git a/tests/Integration/NoIntermediateThrowsTest.php b/tests/Integration/NoIntermediateThrowsTest.php index b0a3842..cfadffe 100644 --- a/tests/Integration/NoIntermediateThrowsTest.php +++ b/tests/Integration/NoIntermediateThrowsTest.php @@ -16,7 +16,6 @@ use Soap\Encoding\Test\Unit\ContextCreatorTrait; use Soap\Engine\Metadata\Model\TypeMeta; use Soap\Engine\Metadata\Model\XsdType; -use function in_array; use function var_export; #[CoversNothing] @@ -26,13 +25,6 @@ final class NoIntermediateThrowsTest extends TestCase use AssertsNoThrows; use ContextCreatorTrait; - /** - * The engine type lookup for soap-enc arrays of a builtin item type still throws. - */ - private const STILL_THROWING = [ - 'decode soap-enc array of a builtin type', - ]; - /** * @param Closure(): mixed $operation */ @@ -97,10 +89,6 @@ public static function provideUntypedDecodes(): iterable */ public static function provideScenarios(): iterable { - foreach (IntermediateThrowScenariosTest::provideScenarios() as $name => $scenario) { - if (!in_array($name, self::STILL_THROWING, true)) { - yield $name => $scenario; - } - } + return IntermediateThrowScenariosTest::provideScenarios(); } } From 8f6b93ebfc2502564517b079423dde23c4623acd Mon Sep 17 00:00:00 2001 From: Toon Verwerft Date: Wed, 14 Oct 2026 19:29:41 +0200 Subject: [PATCH 06/12] Write the element-aware test fixture without document_encode veewee/xml's document_encode() checks every element for @attributes, @namespaces, @cdata and @value through Psl unions that throw on null, so the fixture threw four times per encode. Writing the element directly keeps what the test proves: an element-aware simple type encoder writes its own element. --- .../Encoder/Feature/ElementAwareEncoderTest.php | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/tests/Unit/Encoder/Feature/ElementAwareEncoderTest.php b/tests/Unit/Encoder/Feature/ElementAwareEncoderTest.php index 9d72a02..d1c4202 100644 --- a/tests/Unit/Encoder/Feature/ElementAwareEncoderTest.php +++ b/tests/Unit/Encoder/Feature/ElementAwareEncoderTest.php @@ -2,6 +2,7 @@ namespace Soap\Encoding\Test\Unit\Encoder\Feature; +use Closure; use PHPUnit\Framework\Attributes\CoversClass; use Soap\Encoding\Encoder\Context; use Soap\Encoding\Encoder\Feature\ElementAware; @@ -12,8 +13,13 @@ use Soap\Engine\Metadata\Model\XsdType; use Soap\Xml\Xmlns; use VeeWee\Reflecta\Iso\Iso; -use function VeeWee\Xml\Encoding\document_encode; +use VeeWee\Xml\Writer\Writer; +use function Psl\Dict\map_with_key; use function VeeWee\Xml\Encoding\xml_decode; +use function VeeWee\Xml\Writer\Builder\children; +use function VeeWee\Xml\Writer\Builder\element; +use function VeeWee\Xml\Writer\Builder\value; +use function VeeWee\Xml\Writer\Mapper\memory_output; #[CoversClass(EncoderDetector::class)] final class ElementAwareEncoderTest extends AbstractEncoderTests @@ -31,7 +37,12 @@ public function iso(Context $context): Iso { $typeName = $context->type->getXmlTargetNodeName(); return new Iso( - to: static fn (array $data): string => document_encode([$typeName => $data])->stringifyDocumentElement(), + to: static fn (array $data): string => Writer::inMemory() + ->write(element($typeName, children(map_with_key( + $data, + static fn (string $key, string $value): Closure => element($key, value($value)), + )))) + ->map(memory_output()), from: static fn (string $xml): array => xml_decode($xml)[$typeName], ); } From a32d3ef4f675c58f296a860dbacd28f720abf9ef Mon Sep 17 00:00:00 2001 From: Toon Verwerft Date: Wed, 14 Oct 2026 19:29:41 +0200 Subject: [PATCH 07/12] Fail CI on intermediate throws over the whole suite composer throws:report now exits with 1 when an encode or decode throws internally in a test that does not expect an exception. The custom lens tests throw on purpose, so they are listed per test and site in allowed-throws.php with the number of throws they may make; an entry that no longer throws as listed fails the run too, so the list only shrinks. --- .github/workflows/throws.yaml | 2 + tests/Support/Throws/AllowedThrows.php | 94 +++++++++++++++++++ tests/Support/Throws/ThrowAttribution.php | 49 ++++++++-- .../Throws/ThrowAttributionExtension.php | 18 +++- tests/Support/Throws/ThrowEvent.php | 23 ++++- tests/Support/Throws/allowed-throws.php | 26 +++++ .../Unit/Support/Throws/AllowedThrowsTest.php | 78 +++++++++++++++ 7 files changed, 280 insertions(+), 10 deletions(-) create mode 100644 tests/Support/Throws/AllowedThrows.php create mode 100644 tests/Support/Throws/allowed-throws.php create mode 100644 tests/Unit/Support/Throws/AllowedThrowsTest.php diff --git a/.github/workflows/throws.yaml b/.github/workflows/throws.yaml index 336b9fe..e8f5218 100644 --- a/.github/workflows/throws.yaml +++ b/.github/workflows/throws.yaml @@ -24,3 +24,5 @@ jobs: run: composer update --prefer-dist --no-progress --no-suggest - name: Run the throw counting tests run: composer test:throws + - name: Fail on intermediate throws over the whole suite + run: composer throws:report diff --git a/tests/Support/Throws/AllowedThrows.php b/tests/Support/Throws/AllowedThrows.php new file mode 100644 index 0000000..89682df --- /dev/null +++ b/tests/Support/Throws/AllowedThrows.php @@ -0,0 +1,94 @@ +, via: string, throws: int, reason: string} + */ +final class AllowedThrows +{ + /** @var array */ + private array $entries = []; + /** @var array */ + private array $seen = []; + + /** + * @param list $entries + */ + public function __construct(array $entries) + { + foreach ($entries as $entry) { + $this->entries[self::key($entry['test'], $entry['throw'], $entry['via'])] = $entry; + } + } + + public static function fromFile(): self + { + /** @var list $entries */ + $entries = require __DIR__.'/allowed-throws.php'; + + return new self($entries); + } + + public function allows(string $test, ThrowEvent $event): bool + { + $key = self::key($test, $event->class, $event->srcSite() ?? ''); + if (!isset($this->entries[$key])) { + return false; + } + + $this->seen[$key] = ($this->seen[$key] ?? 0) + 1; + + return $this->seen[$key] <= $this->entries[$key]['throws']; + } + + public function allowed(): int + { + $allowed = 0; + foreach ($this->seen as $key => $seen) { + $allowed += min($seen, $this->entries[$key]['throws']); + } + + return $allowed; + } + + /** + * Entries whose test ran but threw less often than listed, so the list can shrink. + * + * @param array $ranTests + * + * @return list + */ + public function unused(array $ranTests): array + { + $unused = []; + foreach ($this->entries as $key => $entry) { + $seen = $this->seen[$key] ?? 0; + if (isset($ranTests[$entry['test']]) && $seen < $entry['throws']) { + $unused[] = sprintf( + '%s: %s via %s threw %d of %d times', + $entry['test'], + $entry['throw'], + $entry['via'], + $seen, + $entry['throws'], + ); + } + } + + return $unused; + } + + private static function key(string $test, string $throw, string $via): string + { + return $test."\0".$throw."\0".$via; + } +} diff --git a/tests/Support/Throws/ThrowAttribution.php b/tests/Support/Throws/ThrowAttribution.php index 6921047..9efee1f 100644 --- a/tests/Support/Throws/ThrowAttribution.php +++ b/tests/Support/Throws/ThrowAttribution.php @@ -6,6 +6,7 @@ use PHPUnit\Event\Code\Test; use PHPUnit\Event\Code\TestMethod; use ReflectionMethod; +use function array_keys; use function array_slice; use function count; use function file; @@ -16,16 +17,20 @@ /** * Attributes the throws of a suite run to their sites, keeping only the intermediate ones: - * thrown while the stack passes through src/, in a test that does not expect an exception. + * thrown while the stack passes through src/, in a test that does not expect an exception + * and not listed as deliberate in allowed-throws.php. */ final class ThrowAttribution { + private const SHOWN_TESTS_PER_SITE = 3; + /** @var array|null */ private ?array $previousIni = null; private string $currentTest = ''; private ?string $currentExclusion = null; private int $all = 0; private int $outsideSrc = 0; + private int $intermediate = 0; /** @var array{expectException: int, catch: int} */ private array $excluded = ['expectException' => 0, 'catch' => 0]; /** @var array> */ @@ -36,10 +41,18 @@ final class ThrowAttribution private array $tests = []; /** @var array */ private array $unsuccessfulTests = []; + /** @var array */ + private array $ranTests = []; + + public function __construct( + private readonly AllowedThrows $allowedThrows, + ) { + } public function testStarted(Test $test): void { $this->currentTest = $test->id(); + $this->ranTests[$this->currentTest] = true; $this->currentExclusion = $test instanceof TestMethod ? $this->exclusion($test) : null; $this->previousIni ??= ThrowCounter::enable(); } @@ -56,7 +69,11 @@ public function testPrinted(string $output): void $this->outsideSrc++; continue; } + if ($this->allowedThrows->allows($this->currentTest, $event)) { + continue; + } + $this->intermediate++; $site = $event->describe(); $this->sites[$site] ??= ['throws' => 0, 'tests' => []]; $this->sites[$site]['throws']++; @@ -83,32 +100,50 @@ public function stopTracing(): void } } + /** + * True when the run has intermediate throws, allowlist entries that no longer throw as listed, + * or failed tests, whose throws cannot be trusted. + */ + public function failed(): bool + { + return $this->intermediate > 0 + || $this->allowedThrows->unused($this->ranTests) !== [] + || $this->unsuccessfulTests !== []; + } + public function render(): string { $sites = $this->sites; uasort($sites, static fn (array $a, array $b): int => $b['throws'] <=> $a['throws']); $lines = [ + sprintf('Intermediate throws: %d in %d tests', $this->intermediate, count($this->tests)), sprintf( - 'Intermediate throws: %d in %d tests', - $this->all - $this->outsideSrc - $this->excluded['expectException'] - $this->excluded['catch'], - count($this->tests), - ), - sprintf( - 'Not counted: %d outside src/, %d in tests using expectException(), %d in tests catching an exception themselves (%d throws in total)', + 'Not counted: %d outside src/, %d in tests using expectException(), %d in tests catching an exception themselves, %d allowed in tests/Support/Throws/allowed-throws.php (%d throws in total)', $this->outsideSrc, $this->excluded['expectException'], $this->excluded['catch'], + $this->allowedThrows->allowed(), $this->all, ), ]; if ($this->unsuccessfulTests !== []) { $lines[] = sprintf('Warning: %d tests failed or errored, run composer test to see them', count($this->unsuccessfulTests)); } + foreach ($this->allowedThrows->unused($this->ranTests) as $unused) { + $lines[] = 'Allowed throw no longer thrown as listed, update allowed-throws.php: '.$unused; + } $lines[] = ''; $lines[] = sprintf('%7s %6s %s', 'throws', 'tests', 'site'); foreach ($sites as $site => $count) { $lines[] = sprintf('%7d %6d %s', $count['throws'], count($count['tests']), $site); + $tests = array_keys($count['tests']); + foreach (array_slice($tests, 0, self::SHOWN_TESTS_PER_SITE) as $test) { + $lines[] = sprintf('%15s %s', '', $test); + } + if (count($tests) > self::SHOWN_TESTS_PER_SITE) { + $lines[] = sprintf('%15s and %d more', '', count($tests) - self::SHOWN_TESTS_PER_SITE); + } } return implode("\n", $lines)."\n"; diff --git a/tests/Support/Throws/ThrowAttributionExtension.php b/tests/Support/Throws/ThrowAttributionExtension.php index cd63ecd..f46ddec 100644 --- a/tests/Support/Throws/ThrowAttributionExtension.php +++ b/tests/Support/Throws/ThrowAttributionExtension.php @@ -3,6 +3,8 @@ namespace Soap\Encoding\Test\Support\Throws; +use PHPUnit\Event\Application\Finished as ApplicationFinished; +use PHPUnit\Event\Application\FinishedSubscriber as ApplicationFinishedSubscriber; use PHPUnit\Event\Test\Errored; use PHPUnit\Event\Test\ErroredSubscriber; use PHPUnit\Event\Test\Failed; @@ -24,7 +26,7 @@ use RuntimeException; /** - * Prints the intermediate throws of a suite run per site, see composer throws:report. + * Prints the intermediate throws of a suite run per site and fails the run when there are any, see composer throws:report. * * Xdebug prints each exception trace into the output buffer of the running test, so every test that throws * counts as printing output. The composer script runs with --no-results and --do-not-fail-on-risky for that @@ -38,7 +40,7 @@ public function bootstrap(Configuration $configuration, Facade $facade, Paramete throw new RuntimeException('The throws report needs Xdebug with xdebug.mode=develop, run composer throws:report.'); } - $attribution = new ThrowAttribution(); + $attribution = new ThrowAttribution(AllowedThrows::fromFile()); $facade->registerSubscribers( new class($attribution) implements PreparationStartedSubscriber { @@ -112,6 +114,18 @@ public function notify(ExecutionFinished $event): void print "\n".$this->attribution->render(); } }, + new class($attribution) implements ApplicationFinishedSubscriber { + public function __construct(private readonly ThrowAttribution $attribution) + { + } + + public function notify(ApplicationFinished $event): void + { + if ($event->shellExitCode() === 0 && $this->attribution->failed()) { + exit(1); + } + } + }, ); } } diff --git a/tests/Support/Throws/ThrowEvent.php b/tests/Support/Throws/ThrowEvent.php index 5ed6a8a..66a60f6 100644 --- a/tests/Support/Throws/ThrowEvent.php +++ b/tests/Support/Throws/ThrowEvent.php @@ -40,9 +40,30 @@ public function srcCaller(): ?string return $this->site(); } + $frame = $this->srcFrame(); + + return $frame !== null ? self::relative($frame->file).':'.$frame->line.' -> '.$frame->function : null; + } + + /** + * The innermost file and line in src/ that led to this throw, without the function it called. + */ + public function srcSite(): ?string + { + if (self::isSrc($this->file)) { + return $this->site(); + } + + $frame = $this->srcFrame(); + + return $frame !== null ? self::relative($frame->file).':'.$frame->line : null; + } + + private function srcFrame(): ?ThrowFrame + { foreach (array_reverse($this->frames) as $frame) { if (self::isSrc($frame->file)) { - return self::relative($frame->file).':'.$frame->line.' -> '.$frame->function; + return $frame; } } diff --git a/tests/Support/Throws/allowed-throws.php b/tests/Support/Throws/allowed-throws.php new file mode 100644 index 0000000..cfc34c3 --- /dev/null +++ b/tests/Support/Throws/allowed-throws.php @@ -0,0 +1,26 @@ + ObjectEncoderCustomLensTest::class.'::test_it_decodes_through_custom_lenses_that_throw_on_a_missing_element', + 'throw' => ArrayAccessException::class, + 'via' => 'src/Encoder/ObjectEncoder.php:160', + 'throws' => 1, + 'reason' => 'The fixture is a third-party encoder whose decoder lens throws on a missing element, to prove custom lenses still get the optional() and runLens treatment.', + ], + [ + 'test' => ObjectEncoderCustomLensTest::class.'::test_it_encodes_through_custom_lenses_that_throw_on_a_missing_property', + 'throw' => UnreflectableException::class, + 'via' => 'src/Encoder/ObjectEncoder.php:160', + 'throws' => 1, + 'reason' => 'The fixture is a third-party encoder whose encoder lens throws on a missing property, to prove custom lenses still get the optional() and runLens treatment.', + ], +]; diff --git a/tests/Unit/Support/Throws/AllowedThrowsTest.php b/tests/Unit/Support/Throws/AllowedThrowsTest.php new file mode 100644 index 0000000..cb24298 --- /dev/null +++ b/tests/Unit/Support/Throws/AllowedThrowsTest.php @@ -0,0 +1,78 @@ +allows(self::TEST, self::event(RuntimeException::class, 160))); + static::assertTrue($allowed->allows(self::TEST, self::event(RuntimeException::class, 160))); + static::assertFalse($allowed->allows(self::TEST, self::event(RuntimeException::class, 160))); + static::assertSame(2, $allowed->allowed()); + } + + public function test_it_only_allows_the_listed_test_class_and_site(): void + { + $allowed = self::allowed(throws: 1); + + static::assertFalse($allowed->allows('Some\\Test::test_something_else', self::event(RuntimeException::class, 160))); + static::assertFalse($allowed->allows(self::TEST, self::event(LogicException::class, 160))); + static::assertFalse($allowed->allows(self::TEST, self::event(RuntimeException::class, 161))); + static::assertSame(0, $allowed->allowed()); + } + + public function test_it_reports_entries_of_tests_that_ran_without_throwing_as_listed(): void + { + $allowed = self::allowed(throws: 2); + $allowed->allows(self::TEST, self::event(RuntimeException::class, 160)); + + static::assertSame([], $allowed->unused([])); + static::assertSame( + [self::TEST.': RuntimeException via src/Encoder/ObjectEncoder.php:160 threw 1 of 2 times'], + $allowed->unused([self::TEST => true]), + ); + } + + private static function allowed(int $throws): AllowedThrows + { + return new AllowedThrows([ + [ + 'test' => self::TEST, + 'throw' => RuntimeException::class, + 'via' => 'src/Encoder/ObjectEncoder.php:160', + 'throws' => $throws, + 'reason' => 'The fixture throws on purpose.', + ], + ]); + } + + /** + * @param class-string $class + */ + private static function event(string $class, int $srcLine): ThrowEvent + { + $root = dirname(__DIR__, 4); + + return new ThrowEvent($class, 'message', $root.'/vendor/some/Thrower.php', 12, [ + new ThrowFrame('{main}', $root.'/vendor/bin/phpunit', 0), + new ThrowFrame('Some\\Lens->get', $root.'/src/Encoder/ObjectEncoder.php', $srcLine), + ]); + } +} From 1715a4dc7465ef5a06b751b9e4a4b40999ee93a6 Mon Sep 17 00:00:00 2001 From: Toon Verwerft Date: Wed, 14 Oct 2026 19:40:08 +0200 Subject: [PATCH 08/12] Match allowed throws on the src file and function, not the line A line number in allowed-throws.php broke the guard on any unrelated edit above the call. The entry is already scoped to one test with an exact count, so the file and the function it calls are precise enough. --- tests/Support/Throws/AllowedThrows.php | 3 ++- .../Throws/ThrowAttributionExtension.php | 1 + tests/Support/Throws/ThrowEvent.php | 9 ++++---- tests/Support/Throws/allowed-throws.php | 4 ++-- .../Unit/Support/Throws/AllowedThrowsTest.php | 22 +++++++++---------- 5 files changed, 21 insertions(+), 18 deletions(-) diff --git a/tests/Support/Throws/AllowedThrows.php b/tests/Support/Throws/AllowedThrows.php index 89682df..0c6163d 100644 --- a/tests/Support/Throws/AllowedThrows.php +++ b/tests/Support/Throws/AllowedThrows.php @@ -10,6 +10,7 @@ /** * The intermediate throws the suite makes on purpose, listed per test and site in allowed-throws.php. * Each entry allows exactly the listed number of throws, so a new throw in the same test still counts. + * The test is PHPUnit's test id, so a test with a data provider is listed as Class::method#data set name. * * @psalm-type Entry = array{test: string, throw: class-string, via: string, throws: int, reason: string} */ @@ -40,7 +41,7 @@ public static function fromFile(): self public function allows(string $test, ThrowEvent $event): bool { - $key = self::key($test, $event->class, $event->srcSite() ?? ''); + $key = self::key($test, $event->class, $event->srcCall() ?? ''); if (!isset($this->entries[$key])) { return false; } diff --git a/tests/Support/Throws/ThrowAttributionExtension.php b/tests/Support/Throws/ThrowAttributionExtension.php index f46ddec..7060533 100644 --- a/tests/Support/Throws/ThrowAttributionExtension.php +++ b/tests/Support/Throws/ThrowAttributionExtension.php @@ -121,6 +121,7 @@ public function __construct(private readonly ThrowAttribution $attribution) public function notify(ApplicationFinished $event): void { + // An extension cannot change PHPUnit's exit code, and this is the last event before PHPUnit exits. if ($event->shellExitCode() === 0 && $this->attribution->failed()) { exit(1); } diff --git a/tests/Support/Throws/ThrowEvent.php b/tests/Support/Throws/ThrowEvent.php index 66a60f6..5358d1b 100644 --- a/tests/Support/Throws/ThrowEvent.php +++ b/tests/Support/Throws/ThrowEvent.php @@ -46,17 +46,18 @@ public function srcCaller(): ?string } /** - * The innermost file and line in src/ that led to this throw, without the function it called. + * The innermost file in src/ that led to this throw and the function it called there, without line numbers, + * so it stays the same when unrelated lines in that file move. */ - public function srcSite(): ?string + public function srcCall(): ?string { if (self::isSrc($this->file)) { - return $this->site(); + return self::relative($this->file); } $frame = $this->srcFrame(); - return $frame !== null ? self::relative($frame->file).':'.$frame->line : null; + return $frame !== null ? self::relative($frame->file).' -> '.$frame->function : null; } private function srcFrame(): ?ThrowFrame diff --git a/tests/Support/Throws/allowed-throws.php b/tests/Support/Throws/allowed-throws.php index cfc34c3..d42c8fc 100644 --- a/tests/Support/Throws/allowed-throws.php +++ b/tests/Support/Throws/allowed-throws.php @@ -12,14 +12,14 @@ [ 'test' => ObjectEncoderCustomLensTest::class.'::test_it_decodes_through_custom_lenses_that_throw_on_a_missing_element', 'throw' => ArrayAccessException::class, - 'via' => 'src/Encoder/ObjectEncoder.php:160', + 'via' => 'src/Encoder/ObjectEncoder.php -> VeeWee\\Reflecta\\Lens\\Lens->get', 'throws' => 1, 'reason' => 'The fixture is a third-party encoder whose decoder lens throws on a missing element, to prove custom lenses still get the optional() and runLens treatment.', ], [ 'test' => ObjectEncoderCustomLensTest::class.'::test_it_encodes_through_custom_lenses_that_throw_on_a_missing_property', 'throw' => UnreflectableException::class, - 'via' => 'src/Encoder/ObjectEncoder.php:160', + 'via' => 'src/Encoder/ObjectEncoder.php -> VeeWee\\Reflecta\\Lens\\Lens->get', 'throws' => 1, 'reason' => 'The fixture is a third-party encoder whose encoder lens throws on a missing property, to prove custom lenses still get the optional() and runLens treatment.', ], diff --git a/tests/Unit/Support/Throws/AllowedThrowsTest.php b/tests/Unit/Support/Throws/AllowedThrowsTest.php index cb24298..ef4469a 100644 --- a/tests/Unit/Support/Throws/AllowedThrowsTest.php +++ b/tests/Unit/Support/Throws/AllowedThrowsTest.php @@ -22,9 +22,9 @@ public function test_it_allows_a_listed_throw_as_often_as_listed(): void { $allowed = self::allowed(throws: 2); - static::assertTrue($allowed->allows(self::TEST, self::event(RuntimeException::class, 160))); - static::assertTrue($allowed->allows(self::TEST, self::event(RuntimeException::class, 160))); - static::assertFalse($allowed->allows(self::TEST, self::event(RuntimeException::class, 160))); + static::assertTrue($allowed->allows(self::TEST, self::event(RuntimeException::class))); + static::assertTrue($allowed->allows(self::TEST, self::event(RuntimeException::class))); + static::assertFalse($allowed->allows(self::TEST, self::event(RuntimeException::class))); static::assertSame(2, $allowed->allowed()); } @@ -32,20 +32,20 @@ public function test_it_only_allows_the_listed_test_class_and_site(): void { $allowed = self::allowed(throws: 1); - static::assertFalse($allowed->allows('Some\\Test::test_something_else', self::event(RuntimeException::class, 160))); - static::assertFalse($allowed->allows(self::TEST, self::event(LogicException::class, 160))); - static::assertFalse($allowed->allows(self::TEST, self::event(RuntimeException::class, 161))); + static::assertFalse($allowed->allows('Some\\Test::test_something_else', self::event(RuntimeException::class))); + static::assertFalse($allowed->allows(self::TEST, self::event(LogicException::class))); + static::assertFalse($allowed->allows(self::TEST, self::event(RuntimeException::class, 'Other\\Lens->get'))); static::assertSame(0, $allowed->allowed()); } public function test_it_reports_entries_of_tests_that_ran_without_throwing_as_listed(): void { $allowed = self::allowed(throws: 2); - $allowed->allows(self::TEST, self::event(RuntimeException::class, 160)); + $allowed->allows(self::TEST, self::event(RuntimeException::class)); static::assertSame([], $allowed->unused([])); static::assertSame( - [self::TEST.': RuntimeException via src/Encoder/ObjectEncoder.php:160 threw 1 of 2 times'], + [self::TEST.': RuntimeException via src/Encoder/ObjectEncoder.php -> Some\\Lens->get threw 1 of 2 times'], $allowed->unused([self::TEST => true]), ); } @@ -56,7 +56,7 @@ private static function allowed(int $throws): AllowedThrows [ 'test' => self::TEST, 'throw' => RuntimeException::class, - 'via' => 'src/Encoder/ObjectEncoder.php:160', + 'via' => 'src/Encoder/ObjectEncoder.php -> Some\\Lens->get', 'throws' => $throws, 'reason' => 'The fixture throws on purpose.', ], @@ -66,13 +66,13 @@ private static function allowed(int $throws): AllowedThrows /** * @param class-string $class */ - private static function event(string $class, int $srcLine): ThrowEvent + private static function event(string $class, string $srcFunction = 'Some\\Lens->get'): ThrowEvent { $root = dirname(__DIR__, 4); return new ThrowEvent($class, 'message', $root.'/vendor/some/Thrower.php', 12, [ new ThrowFrame('{main}', $root.'/vendor/bin/phpunit', 0), - new ThrowFrame('Some\\Lens->get', $root.'/src/Encoder/ObjectEncoder.php', $srcLine), + new ThrowFrame($srcFunction, $root.'/src/Encoder/ObjectEncoder.php', 160), ]); } } From 69ed19adb59e7b982d97aaf69d8d986c88a80bcd Mon Sep 17 00:00:00 2001 From: Toon Verwerft Date: Wed, 14 Oct 2026 20:31:01 +0200 Subject: [PATCH 09/12] Cover soap-enc arrays, structs, maps and SOAP 1.2 in the throw scenarios Arrays of complex types, arrays of arrays, empty arrays, nil items, an xsi:type per item, apache maps, soap-enc structs, SOAP 1.2 encoded arrays and SOAP 1.2 envelopes all encode and decode without intermediate throws. These scenarios keep it that way. --- .../IntermediateThrowScenariosTest.php | 258 +++++++++++++++++- 1 file changed, 248 insertions(+), 10 deletions(-) diff --git a/tests/Integration/IntermediateThrowScenariosTest.php b/tests/Integration/IntermediateThrowScenariosTest.php index 325ee1f..49a04cb 100644 --- a/tests/Integration/IntermediateThrowScenariosTest.php +++ b/tests/Integration/IntermediateThrowScenariosTest.php @@ -19,7 +19,7 @@ /** * Encodes and decodes that succeed while exceptions are thrown and caught internally along the way: * stdClass hydration, absent elements and attributes, absent and uninitialised properties on encode, - * untyped scalar values and soap-enc arrays of a builtin item type. + * untyped scalar values and the soap-enc arrays, structs and apache maps of SOAP 1.1 and SOAP 1.2 encoding. */ #[CoversClass(Driver::class)] final class IntermediateThrowScenariosTest extends TestCase @@ -45,6 +45,27 @@ final class IntermediateThrowScenariosTest extends TestCase + + + + + + + + + + + + + + + + + + + + + @@ -179,18 +200,211 @@ public static function provideScenarios(): iterable ), 'expected' => [1, 2], ]; + + $addressArray = self::driver('tns:addressArray', 'encoded'); + yield 'decode soap-enc array of a complex type' => [ + 'operation' => static fn (): mixed => $addressArray->decode( + 'test', + self::response( + '' + .'SC' + .'' + ), + ), + 'expected' => [ + (object) ['street' => 'S', 'city' => 'C', 'zip' => null], + (object) ['street' => null, 'city' => null, 'zip' => null], + ], + ]; + + yield 'encode soap-enc array of a complex type' => [ + 'operation' => static fn (): string => $addressArray->encode('test', [[(object) ['city' => 'C', 'zip' => '1000']]])->getRequest(), + 'expected' => self::encodedRequest( + '' + .'' + .'C' + .'1000' + .'' + ), + ]; + + $arrayOfArrays = self::driver('tns:intArrayArray', 'encoded'); + yield 'decode soap-enc array of arrays' => [ + 'operation' => static fn (): mixed => $arrayOfArrays->decode( + 'test', + self::response( + '' + .'12' + .'' + .'' + ), + ), + 'expected' => [[1, 2], []], + ]; + + yield 'encode soap-enc array of arrays' => [ + 'operation' => static fn (): string => $arrayOfArrays->encode('test', [[[1], []]])->getRequest(), + 'expected' => self::encodedRequest( + '' + .'' + .'1' + .'' + .'' + .'' + ), + ]; + + yield 'decode soap-enc array with nil items' => [ + 'operation' => static fn (): mixed => $soapEncArray->decode( + 'test', + self::response('1'), + ), + 'expected' => [1, null], + ]; + + yield 'encode empty soap-enc array' => [ + 'operation' => static fn (): string => $soapEncArray->encode('test', [[]])->getRequest(), + 'expected' => self::encodedRequest( + '' + ), + ]; + + $anyTypeArray = self::driver('tns:anyTypeArray', 'encoded'); + yield 'decode soap-enc array with an xsi:type per item' => [ + 'operation' => static fn (): mixed => $anyTypeArray->decode( + 'test', + self::response( + '' + .'1a' + .'1.5true' + .'' + ), + ), + 'expected' => [1, 'a', 1.5, true], + ]; + + yield 'encode soap-enc array with an xsi:type per item' => [ + 'operation' => static fn (): string => $anyTypeArray->encode('test', [[1, 'a']])->getRequest(), + 'expected' => self::encodedRequest( + '' + .'1' + .'a' + .'' + ), + ]; + + $apacheMap = self::driver('apache:Map', 'encoded'); + yield 'decode apache map' => [ + 'operation' => static fn (): mixed => $apacheMap->decode( + 'test', + self::response( + '' + .'a1' + .'bx' + .'' + ), + ), + 'expected' => ['a' => 1, 'b' => 'x'], + ]; + + yield 'encode apache map' => [ + 'operation' => static fn (): string => $apacheMap->encode('test', [['a' => 1]])->getRequest(), + 'expected' => self::encodedRequest( + '' + .'a' + .'1' + .'' + ), + ]; + + $struct = self::driver('SOAP-ENC:Struct', 'encoded'); + yield 'decode soap-enc struct' => [ + 'operation' => static fn (): mixed => $struct->decode( + 'test', + self::response('1x'), + ), + 'expected' => (object) ['a' => 1, 'b' => 'x'], + ]; + + yield 'encode soap-enc struct' => [ + 'operation' => static fn (): string => $struct->encode('test', [(object) ['a' => 1]])->getRequest(), + 'expected' => self::encodedRequest( + '' + .'1' + .'' + ), + ]; + + $soap12Array = self::driver('tns:soap12IntArray', 'encoded', soap12: true); + yield 'decode soap 1.2 encoded array' => [ + 'operation' => static fn (): mixed => $soap12Array->decode( + 'test', + self::response('12', soap12: true), + ), + 'expected' => [1, 2], + ]; + + yield 'encode soap 1.2 encoded array' => [ + 'operation' => static fn (): string => $soap12Array->encode('test', [[1]])->getRequest(), + 'expected' => self::encodedRequest( + '' + .'1' + .'', + soap12: true, + ), + ]; + + $soap12AddressArray = self::driver('tns:soap12AddressArray', 'encoded', soap12: true); + yield 'decode soap 1.2 encoded array of a complex type' => [ + 'operation' => static fn (): mixed => $soap12AddressArray->decode( + 'test', + self::response( + 'C', + soap12: true, + ), + ), + 'expected' => [(object) ['street' => null, 'city' => 'C', 'zip' => null]], + ]; + + $soap12Literal = self::driver('tns:itemType', 'literal', soap12: true); + yield 'decode soap 1.2 literal envelope' => [ + 'operation' => static fn (): mixed => $soap12Literal->decode( + 'test', + self::response('42', soap12: true), + ), + 'expected' => (object) [ + 'code' => null, + 'rank' => null, + 'id' => 42, + 'name' => null, + 'price' => null, + 'active' => null, + 'address' => null, + 'tags' => [], + ], + ]; + + yield 'encode soap 1.2 literal envelope' => [ + 'operation' => static fn (): string => $soap12Literal->encode('test', [(object) ['id' => 1]])->getRequest(), + 'expected' => self::literalRequest( + '1
', + soap12: true, + ), + ]; } - private static function driver(string $partType, string $use, ?EncoderRegistry $registry = null): Driver + private static function driver(string $partType, string $use, ?EncoderRegistry $registry = null, bool $soap12 = false): Driver { $schema = self::ITEM_SCHEMA; - $encodingStyle = $use === 'encoded' ? ' encodingStyle="http://schemas.xmlsoap.org/soap/encoding/"' : ''; + $encodingStyle = $use === 'encoded' ? ' encodingStyle="'.self::encodingNamespace($soap12).'"' : ''; + $bindingNamespace = $soap12 ? 'http://schemas.xmlsoap.org/wsdl/soap12/' : 'http://schemas.xmlsoap.org/wsdl/soap/'; $wsdl = << @@ -228,22 +442,46 @@ private static function driver(string $partType, string $use, ?EncoderRegistry $ ); } - private static function response(string $testParam): SoapResponse + private static function response(string $testParam, bool $soap12 = false): SoapResponse { return new SoapResponse( - '' + .' xmlns:SOAP-ENC="'.self::encodingNamespace($soap12).'"' + .' xmlns:apache="http://xml.apache.org/xml-soap" xmlns:tns="http://test-uri/">' .''.$testParam.'' .'' ); } - private static function literalRequest(string $testParam): string + private static function literalRequest(string $testParam, bool $soap12 = false): string + { + $envelope = self::envelopeNamespace($soap12); + + return '' + .'' + .''.$testParam.'' + .''."\n"; + } + + private static function encodedRequest(string $testParam, bool $soap12 = false): string { - return '' - .'' + $envelope = self::envelopeNamespace($soap12); + $encoding = self::encodingNamespace($soap12); + + return '' + .'' .''.$testParam.'' .''."\n"; } + + private static function envelopeNamespace(bool $soap12): string + { + return $soap12 ? 'http://www.w3.org/2003/05/soap-envelope' : 'http://schemas.xmlsoap.org/soap/envelope/'; + } + + private static function encodingNamespace(bool $soap12): string + { + return $soap12 ? 'http://www.w3.org/2003/05/soap-encoding' : 'http://schemas.xmlsoap.org/soap/encoding/'; + } } From d6b917a31142e00d44b67daa39f861d8673f6fa5 Mon Sep 17 00:00:00 2001 From: Toon Verwerft Date: Thu, 15 Oct 2026 08:26:41 +0200 Subject: [PATCH 10/12] Require the released php-soap/engine 2.21 --- composer.json | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/composer.json b/composer.json index 1d3ea8a..a23454c 100644 --- a/composer.json +++ b/composer.json @@ -24,10 +24,6 @@ { "type": "vcs", "url": "https://github.com/veewee/reflecta" - }, - { - "type": "vcs", - "url": "https://github.com/veewee/engine" } ], "require": { @@ -46,7 +42,7 @@ "php-standard-library/vec": "^6.1", "veewee/reflecta": "dev-feature/optional-lenses as 1.1.0", "veewee/xml": "^4.10", - "php-soap/engine": "dev-feature/find-type-by-name-and-namespace as 2.21.0", + "php-soap/engine": "^2.21", "php-soap/wsdl": "^1.19", "php-soap/xml": "^1.10", "php-soap/wsdl-reader": "~0.32" From 138ee3a290db9350c199e74fa44c214f12d1e508 Mon Sep 17 00:00:00 2001 From: Toon Verwerft Date: Thu, 15 Oct 2026 08:47:10 +0200 Subject: [PATCH 11/12] Require the released veewee/reflecta 1.1 --- composer.json | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/composer.json b/composer.json index 5393b6b..fb4a72c 100644 --- a/composer.json +++ b/composer.json @@ -20,12 +20,6 @@ "email": "toonverwerft@gmail.com" } ], - "repositories": [ - { - "type": "vcs", - "url": "https://github.com/veewee/reflecta" - } - ], "require": { "php": "~8.4.0 || ~8.5.0", "php-standard-library/collection": "^6.1", @@ -40,7 +34,7 @@ "php-standard-library/str": "^6.1", "php-standard-library/type": "^6.1", "php-standard-library/vec": "^6.1", - "veewee/reflecta": "dev-feature/optional-lenses as 1.1.0", + "veewee/reflecta": "^1.1", "veewee/xml": "^4.10", "php-soap/engine": "^2.20", "php-soap/wsdl": "^1.19", From 8fa9a02aadb8093369738b5d71a53dc41cb6fcd3 Mon Sep 17 00:00:00 2001 From: Toon Verwerft Date: Thu, 15 Oct 2026 08:47:13 +0200 Subject: [PATCH 12/12] Require the released veewee/reflecta 1.1 --- composer.json | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/composer.json b/composer.json index a23454c..12ee339 100644 --- a/composer.json +++ b/composer.json @@ -20,12 +20,6 @@ "email": "toonverwerft@gmail.com" } ], - "repositories": [ - { - "type": "vcs", - "url": "https://github.com/veewee/reflecta" - } - ], "require": { "php": "~8.4.0 || ~8.5.0", "php-standard-library/collection": "^6.1", @@ -40,7 +34,7 @@ "php-standard-library/str": "^6.1", "php-standard-library/type": "^6.1", "php-standard-library/vec": "^6.1", - "veewee/reflecta": "dev-feature/optional-lenses as 1.1.0", + "veewee/reflecta": "^1.1", "veewee/xml": "^4.10", "php-soap/engine": "^2.21", "php-soap/wsdl": "^1.19",