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]); + } + } +} diff --git a/composer.json b/composer.json index 9d9d21a..559ad1a 100644 --- a/composer.json +++ b/composer.json @@ -34,8 +34,8 @@ "php-standard-library/str": "^6.1", "php-standard-library/type": "^6.1", "php-standard-library/vec": "^6.1", - "veewee/reflecta": "^1.1", - "veewee/xml": "^4.10", + "veewee/reflecta": "^1.2", + "veewee/xml": "^4.14", "php-soap/engine": "^2.21", "php-soap/wsdl": "^1.19", "php-soap/xml": "^1.10", @@ -53,8 +53,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/src/Encoder/ObjectAccess.php b/src/Encoder/ObjectAccess.php index 2698d36..17edde3 100644 --- a/src/Encoder/ObjectAccess.php +++ b/src/Encoder/ObjectAccess.php @@ -32,6 +32,7 @@ final class ObjectAccess * @param bool $isAnyPropertyQualified Whether at least one property is namespace qualified. * @param array $isAttribute Whether the property is an XML attribute instead of a child element. * @param array|null> $decodeDefaultValues The value a property decodes to when its element is missing from the XML. + * @param array $omitWhenAbsent Whether encoding leaves the element out, instead of writing xsi:nil, when the property is absent from the PHP value. */ public function __construct( public readonly array $properties, @@ -41,6 +42,7 @@ public function __construct( public readonly bool $isAnyPropertyQualified, public readonly array $isAttribute, public readonly array $decodeDefaultValues, + public readonly array $omitWhenAbsent, ) { } @@ -86,6 +88,7 @@ private static function build(Context $context): self $isos = []; $isAttribute = []; $decodeDefaultValues = []; + $omitWhenAbsent = []; $isAnyPropertyQualified = false; foreach ($sortedProperties as $property) { @@ -99,13 +102,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); $decodeDefaultValues[$normalizedName] = $propertyTypeMeta->isList()->unwrapOr(false) ? [] : null; + $omitWhenAbsent[$normalizedName] = $customEncoderLens === null && self::mayBeAbsent($name, $propertyTypeMeta); $isAnyPropertyQualified = $isAnyPropertyQualified || $propertyTypeMeta->isQualified()->unwrapOr(false); } @@ -118,9 +122,22 @@ private static function build(Context $context): self $isAnyPropertyQualified, $isAttribute, $decodeDefaultValues, + $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 88245e1..a0831e6 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/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/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/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/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..cf173e1 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' => 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.', + ], ]; 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()); + } +} 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; + } +}