From 85bdc90b6879de38ae6197f294dbc113ca7668f9 Mon Sep 17 00:00:00 2001 From: Toon Verwerft Date: Thu, 15 Oct 2026 12:19:00 +0200 Subject: [PATCH 1/7] Find custom object lenses behind the element decorators ObjectAccess only unwrapped DecoratingEncoder, which only ErrorHandlingEncoder implemented. A custom encoder providing an object lens was never asked for it once the detector wrapped it in OptionalElementEncoder, RepeatingElementEncoder or XsiTypeEncoder, so a lens on an optional, repeating or xsi detected element was silently replaced by the default one. The three wrappers now expose their inner encoder. AnyElementEncoder, the only lens provider in src, is ListAware and OptionalAware and is never wrapped by them, so its lens is found exactly as before. --- src/Encoder/OptionalElementEncoder.php | 11 +- src/Encoder/RepeatingElementEncoder.php | 11 +- src/Encoder/XsiTypeEncoder.php | 11 +- .../Encoder/XsiDetectedLensEncoder.php | 51 +++++++ .../Encoder/ObjectAccessDecoratedLensTest.php | 128 ++++++++++++++++++ 5 files changed, 209 insertions(+), 3 deletions(-) create mode 100644 tests/Fixture/Encoder/XsiDetectedLensEncoder.php create mode 100644 tests/Unit/Encoder/ObjectAccessDecoratedLensTest.php diff --git a/src/Encoder/OptionalElementEncoder.php b/src/Encoder/OptionalElementEncoder.php index 78078f8..fb77a14 100644 --- a/src/Encoder/OptionalElementEncoder.php +++ b/src/Encoder/OptionalElementEncoder.php @@ -16,8 +16,9 @@ /** * @template T of mixed * @implements XmlEncoder + * @implements Feature\DecoratingEncoder */ -final class OptionalElementEncoder implements Feature\ElementAware, Feature\OptionalAware, XmlEncoder +final class OptionalElementEncoder implements Feature\DecoratingEncoder, Feature\ElementAware, Feature\OptionalAware, XmlEncoder { /** * @param XmlEncoder $elementEncoder @@ -27,6 +28,14 @@ public function __construct( ) { } + /** + * @return XmlEncoder + */ + public function decoratedEncoder(): XmlEncoder + { + return $this->elementEncoder; + } + /** * @return IsoInterface */ diff --git a/src/Encoder/RepeatingElementEncoder.php b/src/Encoder/RepeatingElementEncoder.php index f20ec56..8df4ae8 100644 --- a/src/Encoder/RepeatingElementEncoder.php +++ b/src/Encoder/RepeatingElementEncoder.php @@ -13,8 +13,9 @@ /** * @template T * @implements XmlEncoder|null, iterable, string, Element|ElementList|string> + * @implements Feature\DecoratingEncoder */ -final class RepeatingElementEncoder implements Feature\ElementAware, Feature\ListAware, XmlEncoder +final class RepeatingElementEncoder implements Feature\DecoratingEncoder, Feature\ElementAware, Feature\ListAware, XmlEncoder { /** * @param XmlEncoder $typeEncoder @@ -24,6 +25,14 @@ public function __construct( ) { } + /** + * @return XmlEncoder + */ + public function decoratedEncoder(): XmlEncoder + { + return $this->typeEncoder; + } + /** * @return Iso|null, iterable, string, Element|ElementList|string> */ diff --git a/src/Encoder/XsiTypeEncoder.php b/src/Encoder/XsiTypeEncoder.php index 1f92fb1..d3fc082 100644 --- a/src/Encoder/XsiTypeEncoder.php +++ b/src/Encoder/XsiTypeEncoder.php @@ -11,8 +11,9 @@ /** * @implements XmlEncoder + * @implements Feature\DecoratingEncoder */ -final readonly class XsiTypeEncoder implements Feature\ElementAware, XmlEncoder +final readonly class XsiTypeEncoder implements Feature\DecoratingEncoder, Feature\ElementAware, XmlEncoder { /** * @param XmlEncoder $encoder @@ -22,6 +23,14 @@ public function __construct( ) { } + /** + * @return XmlEncoder + */ + public function decoratedEncoder(): XmlEncoder + { + return $this->encoder; + } + /** * @return Iso */ diff --git a/tests/Fixture/Encoder/XsiDetectedLensEncoder.php b/tests/Fixture/Encoder/XsiDetectedLensEncoder.php new file mode 100644 index 0000000..7cae268 --- /dev/null +++ b/tests/Fixture/Encoder/XsiDetectedLensEncoder.php @@ -0,0 +1,51 @@ + + * @template-implements Feature\ProvidesObjectEncoderLens + * @template-implements Feature\ProvidesObjectDecoderLens + */ +final class XsiDetectedLensEncoder implements + Feature\ProvidesObjectDecoderLens, + Feature\ProvidesObjectEncoderLens, + XmlEncoder +{ + /** + * @return Lens + */ + public static function createObjectEncoderLens(Type $parentType, Property $currentProperty): Lens + { + return ThrowingLensEncoder::createObjectEncoderLens($parentType, $currentProperty); + } + + /** + * @return Lens + */ + public static function createObjectDecoderLens(Type $parentType, Property $currentProperty): Lens + { + return ThrowingLensEncoder::createObjectDecoderLens($parentType, $currentProperty); + } + + /** + * @return Iso + */ + public function iso(Context $context): Iso + { + return (new ThrowingLensEncoder())->iso($context); + } +} diff --git a/tests/Unit/Encoder/ObjectAccessDecoratedLensTest.php b/tests/Unit/Encoder/ObjectAccessDecoratedLensTest.php new file mode 100644 index 0000000..3aea214 --- /dev/null +++ b/tests/Unit/Encoder/ObjectAccessDecoratedLensTest.php @@ -0,0 +1,128 @@ + + + + + + + + + + + + + + + XSD; + + protected function setUp(): void + { + ThrowingLensEncoder::$encoderLensReads = 0; + ThrowingLensEncoder::$decoderLensReads = 0; + } + + public function test_it_encodes_through_custom_lenses_behind_decorating_encoders(): void + { + $actual = $this->iso()->to((object) [ + 'id' => 1, + 'optional' => 'o', + 'repeating' => ['r1', 'r2'], + 'xsi' => 'x', + ]); + + static::assertSame( + '1or1r2x', + $actual + ); + static::assertSame(3, ThrowingLensEncoder::$encoderLensReads); + } + + public function test_it_decodes_through_custom_lenses_behind_decorating_encoders(): void + { + $actual = $this->iso()->from( + '1or1r2x' + ); + + static::assertEquals( + (object) ['id' => 1, 'optional' => 'o', 'repeating' => ['r1', 'r2'], 'xsi' => 'x'], + $actual + ); + static::assertSame(3, ThrowingLensEncoder::$decoderLensReads); + } + + #[DataProvider('provideDecoratedProperties')] + public function test_each_custom_encoder_sits_behind_the_decorator_under_test(string $property, string $decorator): void + { + $context = $this->context(); + $propertyType = ObjectAccess::forContext($context)->properties[$property]->getType(); + $encoder = $context->registry->detectEncoderForContext($context->withType($propertyType)); + + static::assertInstanceOf($decorator, $encoder->decoratedEncoder()); + } + + /** + * @return iterable + */ + public static function provideDecoratedProperties(): iterable + { + yield 'optional' => ['optional', OptionalElementEncoder::class]; + yield 'repeating' => ['repeating', RepeatingElementEncoder::class]; + yield 'xsi' => ['xsi', XsiTypeEncoder::class]; + } + + private function context(): Context + { + $metadata = self::createMetadataFromWsdl(self::SCHEMA, 'type="tns:user"', use: BindingUse::LITERAL); + $registry = EncoderRegistry::default() + ->addComplexTypeConverter('https://test', 'customType', new ThrowingLensEncoder()) + ->addComplexTypeConverter('https://test', 'xsiType', new XsiDetectedLensEncoder()); + + return new Context( + $metadata->getTypes()->fetchByNameAndXmlNamespace('user', 'https://test')->getXsdType(), + $metadata, + $registry, + self::buildNamespaces(), + ); + } + + /** + * @return IsoInterface + */ + private function iso(): IsoInterface + { + return (new ObjectEncoder(stdClass::class))->iso($this->context()); + } +} From d654b9cbacb53c66205126f50280bba7c5a53d3b Mon Sep 17 00:00:00 2001 From: Toon Verwerft Date: Thu, 15 Oct 2026 12:19:39 +0200 Subject: [PATCH 2/7] Require the reflecta branch with property_is_initialized veewee/reflecta#41 adds Reflect\property_is_initialized(). Until 1.2.0 is tagged the branch comes in through a VCS repository aliased as 1.2.0. This commit gets replaced by a ^1.2 constraint after the release, so the branch cannot be merged before that. --- composer.json | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 9d9d21a..e38cbba 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.1", + "veewee/reflecta": "dev-feature/property-is-initialized as 1.2.0", "veewee/xml": "^4.10", "php-soap/engine": "^2.21", "php-soap/wsdl": "^1.19", From 9a6515f3e19e18bf30ccd5bb7a280ef73d949eba Mon Sep 17 00:00:00 2001 From: Toon Verwerft Date: Thu, 15 Oct 2026 12:25:13 +0200 Subject: [PATCH 3/7] Leave out absent optional nillable elements on encode An element with minOccurs="0" and nillable="true" was written as xsi:nil when the property was missing, so a request could not tell "I did not set this" apart from "I set this to null". The encoder now leaves the element out when the property is absent: a missing array key or dynamic property, an uninitialised or unset typed property, or a property the class does not declare. An explicit null still writes xsi:nil. The check only runs after the default lens read null and only for optional nillable elements, because for every other element, attribute and the text value null and absent already encode the same way. Presence comes from reflecta's property_is_initialized, which follows the same lookup order as optional_property. A custom lens that reads null or throws keeps writing xsi:nil, since it may read something other than the property of that name. Co-authored-by: NickSdot --- src/Encoder/ObjectAccess.php | 21 +- src/Encoder/ObjectEncoder.php | 13 +- tests/Fixture/Encoder/NullLensEncoder.php | 42 ++++ tests/Fixture/Model/PrivateAddressItem.php | 13 ++ .../IntermediateThrowScenariosTest.php | 57 ++++- tests/Support/Throws/allowed-throws.php | 15 ++ .../Encoder/ObjectPropertyPresenceTest.php | 210 ++++++++++++++++++ 7 files changed, 361 insertions(+), 10 deletions(-) create mode 100644 tests/Fixture/Encoder/NullLensEncoder.php create mode 100644 tests/Fixture/Model/PrivateAddressItem.php create mode 100644 tests/Unit/Encoder/ObjectPropertyPresenceTest.php diff --git a/src/Encoder/ObjectAccess.php b/src/Encoder/ObjectAccess.php index 93a09b1..07a3c06 100644 --- a/src/Encoder/ObjectAccess.php +++ b/src/Encoder/ObjectAccess.php @@ -28,6 +28,7 @@ final class ObjectAccess * @param array> $isos * @param array $isAttribute * @param array|null> $decodeDefaults + * @param array $omitWhenAbsent true when encoding leaves the element out for an absent property instead of writing xsi:nil */ public function __construct( public readonly array $properties, @@ -37,6 +38,7 @@ public function __construct( public readonly bool $isAnyPropertyQualified, public readonly array $isAttribute, public readonly array $decodeDefaults, + public readonly array $omitWhenAbsent, ) { } @@ -82,6 +84,7 @@ private static function build(Context $context): self $isos = []; $isAttribute = []; $decodeDefaults = []; + $omitWhenAbsent = []; $isAnyPropertyQualified = false; foreach ($sortedProperties as $property) { @@ -95,13 +98,14 @@ private static function build(Context $context): self $shouldLensBeOptional = self::shouldLensBeOptional($propertyTypeMeta); $normalizedProperties[$normalizedName] = $property; - $encoderLenses[$normalizedName] = self::createCustomEncoderLens($shouldLensBeOptional, $encoder, $type, $property) - ?? optional_property($normalizedName); + $customEncoderLens = self::createCustomEncoderLens($shouldLensBeOptional, $encoder, $type, $property); + $encoderLenses[$normalizedName] = $customEncoderLens ?? optional_property($normalizedName); $decoderLenses[$normalizedName] = self::createCustomDecoderLens($shouldLensBeOptional, $encoder, $type, $property) ?? optional_index($name); $isos[$normalizedName] = $encoder->iso($propertyContext); $isAttribute[$normalizedName] = $propertyTypeMeta->isAttribute()->unwrapOr(false); $decodeDefaults[$normalizedName] = $propertyTypeMeta->isList()->unwrapOr(false) ? [] : null; + $omitWhenAbsent[$normalizedName] = $customEncoderLens === null && self::mayBeAbsent($name, $propertyTypeMeta); $isAnyPropertyQualified = $isAnyPropertyQualified || $propertyTypeMeta->isQualified()->unwrapOr(false); } @@ -114,9 +118,22 @@ private static function build(Context $context): self $isAnyPropertyQualified, $isAttribute, $decodeDefaults, + $omitWhenAbsent, ); } + /** + * Null and absent only encode differently for an optional nillable element: null writes xsi:nil, absent writes nothing. + * Every other element, attribute and the '_' text value encodes null and absent the same way. + */ + private static function mayBeAbsent(string $propertyName, TypeMeta $meta): bool + { + return $propertyName !== '_' + && !$meta->isAttribute()->unwrapOr(false) + && $meta->isNil()->unwrapOr(false) + && $meta->minOccurs()->unwrapOr(1) === 0; + } + /** * @return LensInterface|null */ diff --git a/src/Encoder/ObjectEncoder.php b/src/Encoder/ObjectEncoder.php index d866e3b..253f822 100644 --- a/src/Encoder/ObjectEncoder.php +++ b/src/Encoder/ObjectEncoder.php @@ -15,6 +15,7 @@ use function is_array; use function Psl\Fun\lazy; use function VeeWee\Reflecta\Iso\object_data; +use function VeeWee\Reflecta\Reflect\property_is_initialized; use function VeeWee\Xml\Writer\Builder\children as writeChildren; use function VeeWee\Xml\Writer\Builder\raw; use function VeeWee\Xml\Writer\Builder\value as buildValue; @@ -70,9 +71,8 @@ function (string|Element $value) use ($context, $objectAccess) : object { */ private function to(Context $context, ObjectAccess $objectAccess, object|array $data): string { - if (is_array($data)) { - $data = (object) $data; - } + /** @var object $data */ + $data = is_array($data) ? (object) $data : $data; $defaultAction = writeChildren([]); $children = [ @@ -89,6 +89,13 @@ private function to(Context $context, ObjectAccess $objectAccess, object|array $ $objectAccess->encoderLenses[$normalizePropertyName], $data ); + if ($value === null + && $objectAccess->omitWhenAbsent[$normalizePropertyName] + && !property_is_initialized($data, $normalizePropertyName) + ) { + continue; + } + $iso = $objectAccess->isos[$normalizePropertyName]; $children[] = match(true) { diff --git a/tests/Fixture/Encoder/NullLensEncoder.php b/tests/Fixture/Encoder/NullLensEncoder.php new file mode 100644 index 0000000..f38389e --- /dev/null +++ b/tests/Fixture/Encoder/NullLensEncoder.php @@ -0,0 +1,42 @@ + + * @template-implements Feature\ProvidesObjectEncoderLens + */ +final class NullLensEncoder implements + Feature\DisregardXsiInformation, + Feature\ProvidesObjectEncoderLens, + XmlEncoder +{ + /** + * @return Lens + */ + public static function createObjectEncoderLens(Type $parentType, Property $currentProperty): Lens + { + /** @var Lens */ + return Lens::readonly(static fn (object $data): mixed => null); + } + + /** + * @return Iso + */ + public function iso(Context $context): Iso + { + return (new ThrowingLensEncoder())->iso($context); + } +} diff --git a/tests/Fixture/Model/PrivateAddressItem.php b/tests/Fixture/Model/PrivateAddressItem.php new file mode 100644 index 0000000..56f07e1 --- /dev/null +++ b/tests/Fixture/Model/PrivateAddressItem.php @@ -0,0 +1,13 @@ + [ 'operation' => static fn (): string => $literal->encode('test', [(object) ['id' => 1]])->getRequest(), 'expected' => self::literalRequest( - '1
' + '1' ), ]; yield 'encode sparse array input' => [ 'operation' => static fn (): string => $literal->encode('test', [['id' => 1, 'address' => ['city' => 'C']]])->getRequest(), 'expected' => self::literalRequest( - '1
C
' + '1
C
' ), ]; @@ -167,7 +168,7 @@ public static function provideScenarios(): iterable public int $id = 1; }])->getRequest(), 'expected' => self::literalRequest( - '1
' + '1' ), ]; @@ -179,7 +180,36 @@ public static function provideScenarios(): iterable return $typed->encode('test', [$item])->getRequest(); }, 'expected' => self::literalRequest( - '42
' + '42' + ), + ]; + + yield 'encode explicit null of an optional nillable property' => [ + 'operation' => static fn (): string => $literal->encode('test', [(object) ['id' => 1, 'address' => null]])->getRequest(), + 'expected' => self::literalRequest( + '1
' + ), + ]; + + yield 'encode unset declared property' => [ + 'operation' => static function () use ($typed): string { + $address = new Address(); + $address->city = 'C'; + unset($address->zip); + $item = new Item(); + $item->id = 42; + $item->address = $address; + + return $typed->encode('test', [$item])->getRequest(); + }, + 'expected' => self::literalRequest('42
C
'), + ]; + + yield 'encode private parent property holding null' => [ + 'operation' => static fn (): string => $literal->encode('test', [new class extends PrivateAddressItem { + }])->getRequest(), + 'expected' => self::literalRequest( + '1
' ), ]; @@ -228,6 +258,23 @@ public static function provideScenarios(): iterable ), ]; + yield 'encode soap-enc array items with an absent, a null and a present optional nillable property' => [ + 'operation' => static fn (): string => $addressArray->encode('test', [[ + (object) ['city' => 'C'], + (object) ['city' => 'C', 'zip' => null], + (object) ['city' => 'C', 'zip' => '1000'], + ]])->getRequest(), + 'expected' => self::encodedRequest( + '' + .'C' + .'C' + .'' + .'C' + .'1000' + .'' + ), + ]; + $arrayOfArrays = self::driver('tns:intArrayArray', 'encoded'); yield 'decode soap-enc array of arrays' => [ 'operation' => static fn (): mixed => $arrayOfArrays->decode( @@ -387,7 +434,7 @@ public static function provideScenarios(): iterable yield 'encode soap 1.2 literal envelope' => [ 'operation' => static fn (): string => $soap12Literal->encode('test', [(object) ['id' => 1]])->getRequest(), 'expected' => self::literalRequest( - '1
', + '1', soap12: true, ), ]; diff --git a/tests/Support/Throws/allowed-throws.php b/tests/Support/Throws/allowed-throws.php index d42c8fc..3fad8c3 100644 --- a/tests/Support/Throws/allowed-throws.php +++ b/tests/Support/Throws/allowed-throws.php @@ -2,6 +2,7 @@ declare(strict_types=1); use Soap\Encoding\Test\Unit\Encoder\ObjectEncoderCustomLensTest; +use Soap\Encoding\Test\Unit\Encoder\ObjectPropertyPresenceTest; use VeeWee\Reflecta\ArrayAccess\Exception\ArrayAccessException; use VeeWee\Reflecta\Reflect\Exception\UnreflectableException; @@ -23,4 +24,18 @@ '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.', ], + [ + 'test' => ObjectPropertyPresenceTest::class.'::custom_lens_reading_null_keeps_writing_nil#lens throws on the absent property', + 'throw' => UnreflectableException::class, + '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 that a failing custom lens keeps writing xsi:nil instead of counting as absent.', + ], + [ + 'test' => ObjectPropertyPresenceTest::class.'::optional_property_preserves_absence_null_and_value#lazy object whose initializer throws', + 'throw' => RuntimeException::class, + 'via' => 'src/Encoder/ObjectEncoder.php -> VeeWee\\Reflecta\\Lens\\Lens->get', + 'throws' => 3, + 'reason' => 'The fixture is a lazy object whose initializer throws on every property read, once per property of the type, to prove that the encoder keeps writing xsi:nil for it.', + ], ]; diff --git a/tests/Unit/Encoder/ObjectPropertyPresenceTest.php b/tests/Unit/Encoder/ObjectPropertyPresenceTest.php new file mode 100644 index 0000000..a882d9a --- /dev/null +++ b/tests/Unit/Encoder/ObjectPropertyPresenceTest.php @@ -0,0 +1,210 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + XML; + + /** @return iterable */ + public static function property_values(): iterable + { + yield 'absent array property' => [[], 0.0, '', '']; + yield 'null array property' => [['note' => null], 1.0, 'true', '']; + yield 'present array property' => [['note' => 'text'], 1.0, '', 'text']; + yield 'absent object property' => [(object) [], 0.0, '', '']; + yield 'null object property' => [(object) ['note' => null], 1.0, 'true', '']; + yield 'present object property' => [(object) ['note' => 'text'], 1.0, '', 'text']; + yield 'uninitialized typed property' => [new class { + public ?string $note; + }, 0.0, '', '']; + yield 'unset declared property' => [self::withUnsetNote(), 0.0, '', '']; + yield 'property not declared on the class' => [new class { + public ?string $other = null; + }, 0.0, '', '']; + yield 'lazy object whose initializer throws' => [self::failingLazyObject(), 1.0, 'true', '']; + } + + #[Test, DataProvider('property_values')] + public function optional_property_preserves_absence_null_and_value(array|object $value, float $count, string $nil, string $text): void + { + $xpath = self::encode($value); + + static::assertSame($count, $xpath->evaluate('count(/*/note)')); + static::assertSame($nil, $xpath->evaluate('string(/*/note/@xsi:nil)')); + static::assertSame($text, $xpath->evaluate('string(/*/note)')); + } + + #[Test] + public function repeated_children_preserve_independent_property_presence(): void + { + $xpath = self::encode(['child' => [[], ['note' => null], ['note' => 'text']]]); + + static::assertSame(3.0, $xpath->evaluate('count(/*/child)')); + static::assertSame(0.0, $xpath->evaluate('count(/*/child[1]/note)')); + static::assertSame('true', $xpath->evaluate('string(/*/child[2]/note/@xsi:nil)')); + static::assertSame('text', $xpath->evaluate('string(/*/child[3]/note)')); + } + + #[Test] + public function absent_required_nullable_property_keeps_existing_nil_behavior(): void + { + $xpath = self::encode([]); + + static::assertSame('true', $xpath->evaluate('string(/*/requiredNote/@xsi:nil)')); + } + + #[Test] + public function only_optional_nillable_elements_with_the_default_lens_are_left_out_when_absent(): void + { + $eligibility = ObjectAccess::forContext(self::context('eligibilityType', new ThrowingLensEncoder()))->omitWhenAbsent; + $text = ObjectAccess::forContext(self::context('textType'))->omitWhenAbsent; + + static::assertSame( + [ + 'attribute' => false, + 'optionalNillable' => true, + 'optionalNillableList' => true, + 'requiredNillable' => false, + 'optional' => false, + 'custom' => false, + ], + $eligibility + ); + static::assertSame(['attribute' => false, '_' => false], $text); + } + + /** + * A custom lens may read something else than the property of that name, so its null is never taken for absence. + */ + #[Test, DataProvider('custom_lenses')] + public function custom_lens_reading_null_keeps_writing_nil(XmlEncoder $customEncoder): void + { + $context = self::context('eligibilityType', $customEncoder); + $xml = (new ObjectEncoder(stdClass::class))->iso($context)->to((object) ['requiredNillable' => 'r']); + $xpath = self::xpath($xml); + + static::assertSame('true', $xpath->evaluate('string(/*/custom/@xsi:nil)')); + } + + /** @return iterable */ + public static function custom_lenses(): iterable + { + yield 'lens throws on the absent property' => [new ThrowingLensEncoder()]; + yield 'lens returns null' => [new NullLensEncoder()]; + } + + private static function withUnsetNote(): object + { + $value = new class { + public ?string $note = 'text'; + }; + unset($value->note); + + return $value; + } + + private static function failingLazyObject(): object + { + $class = new class { + public ?string $note = null; + }; + + return (new ReflectionClass($class))->newLazyGhost( + static fn (): never => throw new RuntimeException('The initializer fails.') + ); + } + + private static function encode(array|object $value): DOMXPath + { + $metadata = self::createMetadataFromWsdl(self::SCHEMA, 'type="tns:testType"'); + $context = self::createContextFromMetadata($metadata, 'testType')->withBindingUse(BindingUse::LITERAL); + $iso = new ObjectEncoder(stdClass::class)->iso($context); + + return self::xpath($iso->to($value)); + } + + private static function context(string $typeName, ?XmlEncoder $customEncoder = null): Context + { + $metadata = self::createMetadataFromWsdl(self::SCHEMA, 'type="tns:'.$typeName.'"'); + $registry = EncoderRegistry::default(); + if ($customEncoder !== null) { + $registry = $registry->addComplexTypeConverter('https://test', 'customType', $customEncoder); + } + + return new Context( + $metadata->getTypes()->fetchByNameAndXmlNamespace($typeName, 'https://test')->getXsdType(), + $metadata, + $registry, + self::buildNamespaces(), + BindingUse::LITERAL, + ); + } + + private static function xpath(string $xml): DOMXPath + { + $document = new DOMDocument(); + + static::assertTrue($document->loadXML($xml)); + + $xpath = new DOMXPath($document); + $xpath->registerNamespace('xsi', 'http://www.w3.org/2001/XMLSchema-instance'); + + return $xpath; + } +} From 0149fad625504b675a96d0ee232ed2d424b84a93 Mon Sep 17 00:00:00 2001 From: Toon Verwerft Date: Thu, 15 Oct 2026 12:27:01 +0200 Subject: [PATCH 4/7] Add sparse encode benchmarks on optional nillable elements EncodeBench only has required elements, so it cannot show what leaving out absent elements costs or saves. EncodeSparseBench runs the same itemType with every element minOccurs="0" nillable="true", for a complete item, an item of explicit nulls and an item with only id and address.city set. EncodeBench keeps its schema so earlier numbers stay comparable. --- benchmarks/ComplexTypeBenchTrait.php | 29 ++++--- benchmarks/EncodeSparseBench.php | 122 +++++++++++++++++++++++++++ 2 files changed, 138 insertions(+), 13 deletions(-) create mode 100644 benchmarks/EncodeSparseBench.php diff --git a/benchmarks/ComplexTypeBenchTrait.php b/benchmarks/ComplexTypeBenchTrait.php index 5b9acc3..264482f 100644 --- a/benchmarks/ComplexTypeBenchTrait.php +++ b/benchmarks/ComplexTypeBenchTrait.php @@ -113,7 +113,10 @@ public function setUp(): void $this->extSoapEncoded->__soapCall('test', ['testParam' => $this->singleItem]); } - private function buildPhpSoapDriver(string $use): Driver + /** + * @param string $occurs extra attributes for every element of addressType and itemType, for example minOccurs="0" + */ + private function buildPhpSoapDriver(string $use, string $occurs = ''): Driver { $encodingStyle = $use === 'encoded' ? ' encodingStyle="http://schemas.xmlsoap.org/soap/encoding/"' @@ -134,22 +137,22 @@ private function buildPhpSoapDriver(string $use): Driver - - - + + + - - - - - - - - - + + + + + + + + + diff --git a/benchmarks/EncodeSparseBench.php b/benchmarks/EncodeSparseBench.php new file mode 100644 index 0000000..96510f3 --- /dev/null +++ b/benchmarks/EncodeSparseBench.php @@ -0,0 +1,122 @@ + */ + private array $allPresent; + /** @var list */ + private array $allNull; + /** @var list */ + private array $sparse; + + public function setUp(): void + { + $this->optionalDriver = $this->buildPhpSoapDriver('literal', ' minOccurs="0" nillable="true"'); + + $this->allPresent = []; + $this->allNull = []; + $this->sparse = []; + for ($i = 0; $i < 500; $i++) { + $this->allPresent[] = (object) [ + 'id' => $i, + 'name' => 'Widget ' . $i, + 'description' => 'Description for widget ' . $i, + 'price' => 9.99 + $i, + 'quantity' => $i * 10, + 'active' => $i % 2 === 0, + 'sku' => 'WDG-' . str_pad((string) $i, 5, '0', STR_PAD_LEFT), + 'category' => 'Category ' . ($i % 10), + 'address' => (object) [ + 'street' => $i . ' Elm St', + 'city' => 'City ' . ($i % 50), + 'zip' => str_pad((string) ($i % 99999), 5, '0', STR_PAD_LEFT), + ], + ]; + $this->allNull[] = (object) [ + 'id' => null, + 'name' => null, + 'description' => null, + 'price' => null, + 'quantity' => null, + 'active' => null, + 'sku' => null, + 'category' => null, + 'address' => null, + ]; + $this->sparse[] = (object) [ + 'id' => $i, + 'address' => (object) ['city' => 'City ' . ($i % 50)], + ]; + } + } + + #[Revs(100)] + #[Groups(['literal', 'x1', 'sparse'])] + public function benchOptionalAllPresent_x1(): void + { + $this->optionalDriver->encode('test', [$this->allPresent[0]]); + } + + #[Groups(['literal', 'x500', 'sparse'])] + public function benchOptionalAllPresent_x500(): void + { + foreach ($this->allPresent as $item) { + $this->optionalDriver->encode('test', [$item]); + } + } + + #[Revs(100)] + #[Groups(['literal', 'x1', 'sparse'])] + public function benchOptionalAllNull_x1(): void + { + $this->optionalDriver->encode('test', [$this->allNull[0]]); + } + + #[Groups(['literal', 'x500', 'sparse'])] + public function benchOptionalAllNull_x500(): void + { + foreach ($this->allNull as $item) { + $this->optionalDriver->encode('test', [$item]); + } + } + + #[Revs(100)] + #[Groups(['literal', 'x1', 'sparse'])] + public function benchOptionalSparse_x1(): void + { + $this->optionalDriver->encode('test', [$this->sparse[0]]); + } + + #[Groups(['literal', 'x500', 'sparse'])] + public function benchOptionalSparse_x500(): void + { + foreach ($this->sparse as $item) { + $this->optionalDriver->encode('test', [$item]); + } + } +} From 9ca889c86659e098b149ef8b8176d080b87efa67 Mon Sep 17 00:00:00 2001 From: Toon Verwerft Date: Thu, 15 Oct 2026 17:51:39 +0200 Subject: [PATCH 5/7] Count intermediate throws the same way locally as on CI CI runs with the production php.ini, where zend.exception_ignore_args is on. With arguments in the Xdebug call stack, an argument that spans lines cuts the stack short and hides the src frame, so locally one throw of the lazy object test was counted as outside src. The throw scripts now turn the arguments off, and the lazy object test is allowed its real 4 throws. --- composer.json | 4 ++-- tests/Support/Throws/allowed-throws.php | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/composer.json b/composer.json index e38cbba..ea54020 100644 --- a/composer.json +++ b/composer.json @@ -59,8 +59,8 @@ "phpbench/phpbench": "^1.6" }, "scripts": { - "test:throws": "@php -d xdebug.mode=develop vendor/bin/phpunit --group no-throws --fail-on-skipped", - "throws:report": "@php -d xdebug.mode=develop vendor/bin/phpunit --no-progress --no-results --do-not-fail-on-risky --extension 'Soap\\Encoding\\Test\\Support\\Throws\\ThrowAttributionExtension'" + "test:throws": "@php -d xdebug.mode=develop -d zend.exception_ignore_args=1 vendor/bin/phpunit --group no-throws --fail-on-skipped", + "throws:report": "@php -d xdebug.mode=develop -d zend.exception_ignore_args=1 vendor/bin/phpunit --no-progress --no-results --do-not-fail-on-risky --extension 'Soap\\Encoding\\Test\\Support\\Throws\\ThrowAttributionExtension'" }, "config": { "allow-plugins": { diff --git a/tests/Support/Throws/allowed-throws.php b/tests/Support/Throws/allowed-throws.php index 3fad8c3..cf173e1 100644 --- a/tests/Support/Throws/allowed-throws.php +++ b/tests/Support/Throws/allowed-throws.php @@ -35,7 +35,7 @@ 'test' => ObjectPropertyPresenceTest::class.'::optional_property_preserves_absence_null_and_value#lazy object whose initializer throws', 'throw' => RuntimeException::class, 'via' => 'src/Encoder/ObjectEncoder.php -> VeeWee\\Reflecta\\Lens\\Lens->get', - 'throws' => 3, - 'reason' => 'The fixture is a lazy object whose initializer throws on every property read, once per property of the type, to prove that the encoder keeps writing xsi:nil for it.', + 'throws' => 4, + 'reason' => 'The fixture is a lazy object whose initializer throws on every access the encoder lenses make to it, to prove that the encoder keeps writing xsi:nil for it.', ], ]; From 0168dea809a2777215a7135a8eb6cbc5bffd85bd Mon Sep 17 00:00:00 2001 From: Toon Verwerft Date: Fri, 16 Oct 2026 09:19:25 +0200 Subject: [PATCH 6/7] Require reflecta 1.2 now that it is released --- composer.json | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/composer.json b/composer.json index ea54020..9c1120f 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/property-is-initialized as 1.2.0", + "veewee/reflecta": "^1.2", "veewee/xml": "^4.10", "php-soap/engine": "^2.21", "php-soap/wsdl": "^1.19", From db283dafb6988f891c5061164c57c4a48135014e Mon Sep 17 00:00:00 2001 From: Toon Verwerft Date: Fri, 16 Oct 2026 09:23:18 +0200 Subject: [PATCH 7/7] Require veewee/xml 4.14 for the faster writer --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 9c1120f..559ad1a 100644 --- a/composer.json +++ b/composer.json @@ -35,7 +35,7 @@ "php-standard-library/type": "^6.1", "php-standard-library/vec": "^6.1", "veewee/reflecta": "^1.2", - "veewee/xml": "^4.10", + "veewee/xml": "^4.14", "php-soap/engine": "^2.21", "php-soap/wsdl": "^1.19", "php-soap/xml": "^1.10",