From e8e9ca2b87eb529437fd4df90543b5ce7946fc32 Mon Sep 17 00:00:00 2001 From: Toon Verwerft Date: Thu, 15 Oct 2026 18:28:50 +0200 Subject: [PATCH 1/8] Skip the encoder for null optional elements that write nothing A null value on an optional element that is not nillable goes through ErrorHandlingEncoder and OptionalElementEncoder only to come back as an empty string. ObjectAccess now records per property whether that is the case, and ObjectEncoder skips the iso for those nulls. The empty write still closes the start tag of the parent, so the first skipped element of an object keeps it to write as before. Custom encoders that handle null themselves are not wrapped in the optional element encoder and keep their output. --- src/Encoder/ObjectAccess.php | 19 +++ src/Encoder/ObjectEncoder.php | 10 ++ tests/Fixture/Encoder/NullWritingEncoder.php | 29 ++++ .../Encoder/ObjectEncoderNullElementTest.php | 134 ++++++++++++++++++ 4 files changed, 192 insertions(+) create mode 100644 tests/Fixture/Encoder/NullWritingEncoder.php create mode 100644 tests/Unit/Encoder/ObjectEncoderNullElementTest.php diff --git a/src/Encoder/ObjectAccess.php b/src/Encoder/ObjectAccess.php index 17edde3..189791e 100644 --- a/src/Encoder/ObjectAccess.php +++ b/src/Encoder/ObjectAccess.php @@ -33,6 +33,7 @@ final class ObjectAccess * @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. + * @param array $nullWritesNothing Whether a null value encodes to an empty string for this element, so encoding can skip its iso. */ public function __construct( public readonly array $properties, @@ -43,6 +44,7 @@ public function __construct( public readonly array $isAttribute, public readonly array $decodeDefaultValues, public readonly array $omitWhenAbsent, + public readonly array $nullWritesNothing, ) { } @@ -89,6 +91,7 @@ private static function build(Context $context): self $isAttribute = []; $decodeDefaultValues = []; $omitWhenAbsent = []; + $nullWritesNothing = []; $isAnyPropertyQualified = false; foreach ($sortedProperties as $property) { @@ -110,6 +113,7 @@ private static function build(Context $context): self $isAttribute[$normalizedName] = $propertyTypeMeta->isAttribute()->unwrapOr(false); $decodeDefaultValues[$normalizedName] = $propertyTypeMeta->isList()->unwrapOr(false) ? [] : null; $omitWhenAbsent[$normalizedName] = $customEncoderLens === null && self::mayBeAbsent($name, $propertyTypeMeta); + $nullWritesNothing[$normalizedName] = self::nullWritesNothing($name, $propertyTypeMeta, $encoder); $isAnyPropertyQualified = $isAnyPropertyQualified || $propertyTypeMeta->isQualified()->unwrapOr(false); } @@ -123,9 +127,24 @@ private static function build(Context $context): self $isAttribute, $decodeDefaultValues, $omitWhenAbsent, + $nullWritesNothing, ); } + /** + * The optional element encoder turns null into an empty string for a nullable element that is not nillable. + * A custom encoder that handles null itself is never wrapped in it, so it keeps writing whatever it writes for null. + */ + private static function nullWritesNothing(string $propertyName, TypeMeta $meta, XmlEncoder $encoder): bool + { + return $propertyName !== '_' + && !$meta->isAttribute()->unwrapOr(false) + && $meta->isNullable()->unwrapOr(false) + && !$meta->isNil()->unwrapOr(false) + && $encoder instanceof ErrorHandlingEncoder + && $encoder->decoratedEncoder() instanceof OptionalElementEncoder; + } + /** * 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. diff --git a/src/Encoder/ObjectEncoder.php b/src/Encoder/ObjectEncoder.php index a0831e6..fc9279d 100644 --- a/src/Encoder/ObjectEncoder.php +++ b/src/Encoder/ObjectEncoder.php @@ -74,6 +74,7 @@ private function to(Context $context, ObjectAccess $objectAccess, object|array $ /** @var object $data */ $data = is_array($data) ? (object) $data : $data; $defaultAction = writeChildren([]); + $keptEmptyWrite = false; $children = [ XsiAttributeBuilder::forEncodedValue( @@ -89,6 +90,15 @@ private function to(Context $context, ObjectAccess $objectAccess, object|array $ $objectAccess->encoderLenses[$normalizePropertyName], $data ); + if ($value === null && $objectAccess->nullWritesNothing[$normalizePropertyName]) { + // Writing the empty string still closes the start tag, which turns into . + // The first skipped element keeps that write, the later ones change nothing in the output. + if (!$keptEmptyWrite) { + $children[] = raw(''); + $keptEmptyWrite = true; + } + continue; + } if ($value === null && $objectAccess->omitWhenAbsent[$normalizePropertyName] && !property_is_initialized($data, $normalizePropertyName) diff --git a/tests/Fixture/Encoder/NullWritingEncoder.php b/tests/Fixture/Encoder/NullWritingEncoder.php new file mode 100644 index 0000000..089cf52 --- /dev/null +++ b/tests/Fixture/Encoder/NullWritingEncoder.php @@ -0,0 +1,29 @@ + + */ +final class NullWritingEncoder implements Feature\OptionalAware, XmlEncoder +{ + /** + * @return Iso + */ + public function iso(Context $context): Iso + { + return new Iso( + static fn (?string $value): string => '' . ($value ?? 'none') . '', + static fn (Element|string $xml): string => ($xml instanceof Element ? $xml : Element::fromString($xml))->element()->textContent, + ); + } +} diff --git a/tests/Unit/Encoder/ObjectEncoderNullElementTest.php b/tests/Unit/Encoder/ObjectEncoderNullElementTest.php new file mode 100644 index 0000000..44f7f59 --- /dev/null +++ b/tests/Unit/Encoder/ObjectEncoderNullElementTest.php @@ -0,0 +1,134 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + XML; + + /** @return iterable, string}> */ + public static function encoded_values(): iterable + { + yield 'all optional elements null' => ['optionalType', [], '']; + yield 'attribute and all optional elements null' => [ + 'optionalType', + ['attribute' => 'a'], + '', + ]; + yield 'one optional element null' => [ + 'optionalType', + ['second' => 's'], + 's', + ]; + yield 'mixed null elements' => [ + 'mixedType', + ['required' => 'r'], + '' + . '' + . 'rnone', + ]; + } + + /** + * An empty element keeps its separate end tag when its null optional elements write nothing. + */ + #[Test, DataProvider('encoded_values')] + public function null_optional_elements_encode_byte_for_byte(string $typeName, array $data, string $expected): void + { + $actual = (new ObjectEncoder(stdClass::class))->iso(self::context($typeName))->to((object) [ + ...array_fill_keys(array_keys(ObjectAccess::forContext(self::context($typeName))->properties), null), + ...$data, + ]); + + static::assertSame($expected, $actual); + } + + #[Test] + public function only_default_optional_non_nillable_elements_write_nothing_for_null(): void + { + static::assertSame( + [ + 'attribute' => false, + 'optional' => true, + 'optionalList' => false, + 'optionalNillable' => false, + 'requiredNillable' => false, + 'required' => false, + 'custom' => true, + 'nullWriting' => false, + ], + ObjectAccess::forContext(self::context('mixedType'))->nullWritesNothing + ); + static::assertSame( + ['attribute' => false, '_' => false], + ObjectAccess::forContext(self::context('textType'))->nullWritesNothing + ); + } + + private static function context(string $typeName): Context + { + $metadata = self::createMetadataFromWsdl(self::SCHEMA, 'type="tns:'.$typeName.'"'); + $registry = EncoderRegistry::default() + ->addComplexTypeConverter('https://test', 'customType', new ThrowingLensEncoder()) + ->addComplexTypeConverter('https://test', 'nullWritingType', new NullWritingEncoder()); + + return new Context( + $metadata->getTypes()->fetchByNameAndXmlNamespace($typeName, 'https://test')->getXsdType(), + $metadata, + $registry, + self::buildNamespaces(), + BindingUse::LITERAL, + ); + } +} From c976f421fc1e3b23e6211a069932a7034c29034b Mon Sep 17 00:00:00 2001 From: Toon Verwerft Date: Thu, 15 Oct 2026 18:34:17 +0200 Subject: [PATCH 2/8] Pin absent, trailing and nested null optional elements byte for byte Every expectation also holds on the base branch, so the skip leaves the output of these cases as it was. --- .../Encoder/ObjectEncoderNullElementTest.php | 47 ++++++++++++++----- 1 file changed, 36 insertions(+), 11 deletions(-) diff --git a/tests/Unit/Encoder/ObjectEncoderNullElementTest.php b/tests/Unit/Encoder/ObjectEncoderNullElementTest.php index 44f7f59..f63b49e 100644 --- a/tests/Unit/Encoder/ObjectEncoderNullElementTest.php +++ b/tests/Unit/Encoder/ObjectEncoderNullElementTest.php @@ -30,6 +30,12 @@ final class ObjectEncoderNullElementTest extends TestCase + + + + + + @@ -57,23 +63,45 @@ final class ObjectEncoderNullElementTest extends TestCase XML; - /** @return iterable, string}> */ + /** @return iterable */ public static function encoded_values(): iterable { - yield 'all optional elements null' => ['optionalType', [], '']; + $optional = static fn (array $data): object => (object) [...['attribute' => null, 'first' => null, 'second' => null], ...$data]; + + yield 'all optional elements null' => ['optionalType', $optional([]), '']; + yield 'all optional elements absent' => ['optionalType', (object) [], '']; yield 'attribute and all optional elements null' => [ 'optionalType', - ['attribute' => 'a'], + $optional(['attribute' => 'a']), '', ]; - yield 'one optional element null' => [ + yield 'first optional element null' => [ 'optionalType', - ['second' => 's'], + $optional(['second' => 's']), 's', ]; + yield 'last optional element null' => [ + 'optionalType', + $optional(['first' => 'f']), + 'f', + ]; + yield 'nested object with all optional elements null' => [ + 'parentType', + (object) ['child' => $optional([]), 'last' => null], + '', + ]; yield 'mixed null elements' => [ 'mixedType', - ['required' => 'r'], + (object) [ + 'attribute' => null, + 'optional' => null, + 'optionalList' => null, + 'optionalNillable' => null, + 'requiredNillable' => null, + 'required' => 'r', + 'custom' => null, + 'nullWriting' => null, + ], '' . '' . 'rnone', @@ -84,12 +112,9 @@ public static function encoded_values(): iterable * An empty element keeps its separate end tag when its null optional elements write nothing. */ #[Test, DataProvider('encoded_values')] - public function null_optional_elements_encode_byte_for_byte(string $typeName, array $data, string $expected): void + public function null_optional_elements_encode_byte_for_byte(string $typeName, object $data, string $expected): void { - $actual = (new ObjectEncoder(stdClass::class))->iso(self::context($typeName))->to((object) [ - ...array_fill_keys(array_keys(ObjectAccess::forContext(self::context($typeName))->properties), null), - ...$data, - ]); + $actual = (new ObjectEncoder(stdClass::class))->iso(self::context($typeName))->to($data); static::assertSame($expected, $actual); } From 085fe111bb9d1fde966564cc08e3804dcbfb8a9b Mon Sep 17 00:00:00 2001 From: Toon Verwerft Date: Fri, 16 Oct 2026 07:32:46 +0200 Subject: [PATCH 3/8] Let the encoder say whether null writes nothing ObjectAccess no longer depends on ErrorHandlingEncoder wrapping OptionalElementEncoder directly. An encoder that implements WritesNothingForNull answers for itself, ErrorHandlingEncoder forwards the question, and any other encoder keeps its own null handling. --- src/Encoder/ErrorHandlingEncoder.php | 7 +- src/Encoder/Feature/WritesNothingForNull.php | 13 ++++ src/Encoder/ObjectAccess.php | 13 ++-- src/Encoder/OptionalElementEncoder.php | 9 ++- .../Feature/WritesNothingForNullTest.php | 75 +++++++++++++++++++ 5 files changed, 107 insertions(+), 10 deletions(-) create mode 100644 src/Encoder/Feature/WritesNothingForNull.php create mode 100644 tests/Unit/Encoder/Feature/WritesNothingForNullTest.php diff --git a/src/Encoder/ErrorHandlingEncoder.php b/src/Encoder/ErrorHandlingEncoder.php index 4f3133f..40f0df1 100644 --- a/src/Encoder/ErrorHandlingEncoder.php +++ b/src/Encoder/ErrorHandlingEncoder.php @@ -16,7 +16,7 @@ * @implements XmlEncoder * @implements Feature\DecoratingEncoder */ -final class ErrorHandlingEncoder implements Feature\DecoratingEncoder, XmlEncoder +final class ErrorHandlingEncoder implements Feature\DecoratingEncoder, Feature\WritesNothingForNull, XmlEncoder { /** * @param XmlEncoder $encoder @@ -34,6 +34,11 @@ public function decoratedEncoder(): XmlEncoder return $this->encoder; } + public function writesNothingForNull(Context $context): bool + { + return $this->encoder instanceof Feature\WritesNothingForNull && $this->encoder->writesNothingForNull($context); + } + /** * @return Iso */ diff --git a/src/Encoder/Feature/WritesNothingForNull.php b/src/Encoder/Feature/WritesNothingForNull.php new file mode 100644 index 0000000..782126c --- /dev/null +++ b/src/Encoder/Feature/WritesNothingForNull.php @@ -0,0 +1,13 @@ +isAttribute()->unwrapOr(false); $decodeDefaultValues[$normalizedName] = $propertyTypeMeta->isList()->unwrapOr(false) ? [] : null; $omitWhenAbsent[$normalizedName] = $customEncoderLens === null && self::mayBeAbsent($name, $propertyTypeMeta); - $nullWritesNothing[$normalizedName] = self::nullWritesNothing($name, $propertyTypeMeta, $encoder); + $nullWritesNothing[$normalizedName] = self::nullWritesNothing($name, $propertyTypeMeta, $encoder, $propertyContext); $isAnyPropertyQualified = $isAnyPropertyQualified || $propertyTypeMeta->isQualified()->unwrapOr(false); } @@ -132,17 +132,14 @@ private static function build(Context $context): self } /** - * The optional element encoder turns null into an empty string for a nullable element that is not nillable. - * A custom encoder that handles null itself is never wrapped in it, so it keeps writing whatever it writes for null. + * The encoder decides whether null encodes to nothing; one that does not say so keeps its own null handling. */ - private static function nullWritesNothing(string $propertyName, TypeMeta $meta, XmlEncoder $encoder): bool + private static function nullWritesNothing(string $propertyName, TypeMeta $meta, XmlEncoder $encoder, Context $context): bool { return $propertyName !== '_' && !$meta->isAttribute()->unwrapOr(false) - && $meta->isNullable()->unwrapOr(false) - && !$meta->isNil()->unwrapOr(false) - && $encoder instanceof ErrorHandlingEncoder - && $encoder->decoratedEncoder() instanceof OptionalElementEncoder; + && $encoder instanceof Feature\WritesNothingForNull + && $encoder->writesNothingForNull($context); } /** diff --git a/src/Encoder/OptionalElementEncoder.php b/src/Encoder/OptionalElementEncoder.php index fb77a14..baddf3e 100644 --- a/src/Encoder/OptionalElementEncoder.php +++ b/src/Encoder/OptionalElementEncoder.php @@ -18,7 +18,7 @@ * @implements XmlEncoder * @implements Feature\DecoratingEncoder */ -final class OptionalElementEncoder implements Feature\DecoratingEncoder, Feature\ElementAware, Feature\OptionalAware, XmlEncoder +final class OptionalElementEncoder implements Feature\DecoratingEncoder, Feature\ElementAware, Feature\OptionalAware, Feature\WritesNothingForNull, XmlEncoder { /** * @param XmlEncoder $elementEncoder @@ -36,6 +36,13 @@ public function decoratedEncoder(): XmlEncoder return $this->elementEncoder; } + public function writesNothingForNull(Context $context): bool + { + $meta = $context->type->getMeta(); + + return $meta->isNullable()->unwrapOr(false) && !$meta->isNil()->unwrapOr(false); + } + /** * @return IsoInterface */ diff --git a/tests/Unit/Encoder/Feature/WritesNothingForNullTest.php b/tests/Unit/Encoder/Feature/WritesNothingForNullTest.php new file mode 100644 index 0000000..347becd --- /dev/null +++ b/tests/Unit/Encoder/Feature/WritesNothingForNullTest.php @@ -0,0 +1,75 @@ + + */ + public static function provideMeta(): iterable + { + yield 'optional, not nillable' => [true, false, true]; + yield 'optional, nillable' => [true, true, false]; + yield 'required' => [false, false, false]; + } + + #[Test] + #[DataProvider('provideMeta')] + public function optional_element_encoder_writes_nothing_for_null_unless_nillable(bool $nullable, bool $nil, bool $expected): void + { + $encoder = new OptionalElementEncoder(new ElementEncoder(new StringTypeEncoder())); + $context = self::createContext(self::type($nullable, $nil)); + + static::assertSame($expected, $encoder->writesNothingForNull($context)); + if ($expected) { + static::assertSame('', $encoder->iso($context)->to(null)); + } + } + + #[Test] + #[DataProvider('provideMeta')] + public function error_handling_encoder_forwards_the_answer(bool $nullable, bool $nil, bool $expected): void + { + $encoder = new ErrorHandlingEncoder(new OptionalElementEncoder(new ElementEncoder(new StringTypeEncoder()))); + + static::assertSame($expected, $encoder->writesNothingForNull(self::createContext(self::type($nullable, $nil)))); + } + + #[Test] + public function error_handling_encoder_answers_no_for_an_encoder_that_does_not_tell(): void + { + $encoder = new ErrorHandlingEncoder(new ElementEncoder(new StringTypeEncoder())); + + static::assertFalse($encoder->writesNothingForNull(self::createContext(self::type(true, false)))); + } + + private static function type(bool $nullable, bool $nil): XsdType + { + return XsdType::guess('string') + ->withXmlTargetNodeName('hello') + ->withMeta( + static fn (TypeMeta $meta): TypeMeta => $meta + ->withIsNullable($nullable) + ->withIsNil($nil) + ->withIsSimple(true) + ->withIsElement(true) + ); + } +} From d8b7f19a70156e64ff5a021a6924b3f0603daef8 Mon Sep 17 00:00:00 2001 From: Toon Verwerft Date: Fri, 16 Oct 2026 07:33:31 +0200 Subject: [PATCH 4/8] Skip null optional elements without writing anything An object whose elements are all null now encodes as instead of . Both are the same empty element; the short form is what an object without children already gets elsewhere. --- src/Encoder/ObjectEncoder.php | 7 ------- tests/Unit/Encoder/ObjectEncoderNullElementTest.php | 8 ++++---- 2 files changed, 4 insertions(+), 11 deletions(-) diff --git a/src/Encoder/ObjectEncoder.php b/src/Encoder/ObjectEncoder.php index fc9279d..b8ec961 100644 --- a/src/Encoder/ObjectEncoder.php +++ b/src/Encoder/ObjectEncoder.php @@ -74,7 +74,6 @@ private function to(Context $context, ObjectAccess $objectAccess, object|array $ /** @var object $data */ $data = is_array($data) ? (object) $data : $data; $defaultAction = writeChildren([]); - $keptEmptyWrite = false; $children = [ XsiAttributeBuilder::forEncodedValue( @@ -91,12 +90,6 @@ private function to(Context $context, ObjectAccess $objectAccess, object|array $ $data ); if ($value === null && $objectAccess->nullWritesNothing[$normalizePropertyName]) { - // Writing the empty string still closes the start tag, which turns into . - // The first skipped element keeps that write, the later ones change nothing in the output. - if (!$keptEmptyWrite) { - $children[] = raw(''); - $keptEmptyWrite = true; - } continue; } if ($value === null diff --git a/tests/Unit/Encoder/ObjectEncoderNullElementTest.php b/tests/Unit/Encoder/ObjectEncoderNullElementTest.php index f63b49e..274410a 100644 --- a/tests/Unit/Encoder/ObjectEncoderNullElementTest.php +++ b/tests/Unit/Encoder/ObjectEncoderNullElementTest.php @@ -68,12 +68,12 @@ public static function encoded_values(): iterable { $optional = static fn (array $data): object => (object) [...['attribute' => null, 'first' => null, 'second' => null], ...$data]; - yield 'all optional elements null' => ['optionalType', $optional([]), '']; - yield 'all optional elements absent' => ['optionalType', (object) [], '']; + yield 'all optional elements null' => ['optionalType', $optional([]), '']; + yield 'all optional elements absent' => ['optionalType', (object) [], '']; yield 'attribute and all optional elements null' => [ 'optionalType', $optional(['attribute' => 'a']), - '', + '', ]; yield 'first optional element null' => [ 'optionalType', @@ -88,7 +88,7 @@ public static function encoded_values(): iterable yield 'nested object with all optional elements null' => [ 'parentType', (object) ['child' => $optional([]), 'last' => null], - '', + '', ]; yield 'mixed null elements' => [ 'mixedType', From 6e7abd15dcd8b2eac943088f6c750e28a0431240 Mon Sep 17 00:00:00 2001 From: Toon Verwerft Date: Fri, 16 Oct 2026 08:09:23 +0200 Subject: [PATCH 5/8] Let the encoder decide what null does for a property Two booleans per property, one for absent nillable elements and one for elements that write nothing, described a single decision from two sides. The encoder now answers it once through NullValueAware with a NullValueHandling case, and ObjectAccess keeps one map of those answers. A custom OptionalAware encoder that is not wrapped in OptionalElementEncoder no longer has its absent optional nillable elements left out, unless it implements NullValueAware itself, because only the encoder can tell what it writes for null. --- src/Encoder/ErrorHandlingEncoder.php | 8 ++-- src/Encoder/Feature/NullValueAware.php | 14 ++++++ src/Encoder/Feature/WritesNothingForNull.php | 13 ----- src/Encoder/NullValueHandling.php | 24 ++++++++++ src/Encoder/ObjectAccess.php | 48 ++++++++----------- src/Encoder/ObjectEncoder.php | 12 ++--- src/Encoder/OptionalElementEncoder.php | 11 +++-- ...ForNullTest.php => NullValueAwareTest.php} | 31 ++++++------ .../Encoder/ObjectEncoderNullElementTest.php | 41 +++++++++++----- .../Encoder/ObjectPropertyPresenceTest.php | 19 ++++---- 10 files changed, 132 insertions(+), 89 deletions(-) create mode 100644 src/Encoder/Feature/NullValueAware.php delete mode 100644 src/Encoder/Feature/WritesNothingForNull.php create mode 100644 src/Encoder/NullValueHandling.php rename tests/Unit/Encoder/Feature/{WritesNothingForNullTest.php => NullValueAwareTest.php} (57%) diff --git a/src/Encoder/ErrorHandlingEncoder.php b/src/Encoder/ErrorHandlingEncoder.php index 40f0df1..aa90e9f 100644 --- a/src/Encoder/ErrorHandlingEncoder.php +++ b/src/Encoder/ErrorHandlingEncoder.php @@ -16,7 +16,7 @@ * @implements XmlEncoder * @implements Feature\DecoratingEncoder */ -final class ErrorHandlingEncoder implements Feature\DecoratingEncoder, Feature\WritesNothingForNull, XmlEncoder +final class ErrorHandlingEncoder implements Feature\DecoratingEncoder, Feature\NullValueAware, XmlEncoder { /** * @param XmlEncoder $encoder @@ -34,9 +34,11 @@ public function decoratedEncoder(): XmlEncoder return $this->encoder; } - public function writesNothingForNull(Context $context): bool + public function nullValueHandling(Context $context): NullValueHandling { - return $this->encoder instanceof Feature\WritesNothingForNull && $this->encoder->writesNothingForNull($context); + return $this->encoder instanceof Feature\NullValueAware + ? $this->encoder->nullValueHandling($context) + : NullValueHandling::Write; } /** diff --git a/src/Encoder/Feature/NullValueAware.php b/src/Encoder/Feature/NullValueAware.php new file mode 100644 index 0000000..f71b00a --- /dev/null +++ b/src/Encoder/Feature/NullValueAware.php @@ -0,0 +1,14 @@ + $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. - * @param array $nullWritesNothing Whether a null value encodes to an empty string for this element, so encoding can skip its iso. + * @param array $onNull What encoding does with a null value for this property. */ public function __construct( public readonly array $properties, @@ -43,8 +42,7 @@ public function __construct( public readonly bool $isAnyPropertyQualified, public readonly array $isAttribute, public readonly array $decodeDefaultValues, - public readonly array $omitWhenAbsent, - public readonly array $nullWritesNothing, + public readonly array $onNull, ) { } @@ -90,8 +88,7 @@ private static function build(Context $context): self $isos = []; $isAttribute = []; $decodeDefaultValues = []; - $omitWhenAbsent = []; - $nullWritesNothing = []; + $onNull = []; $isAnyPropertyQualified = false; foreach ($sortedProperties as $property) { @@ -112,8 +109,7 @@ private static function build(Context $context): self $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); - $nullWritesNothing[$normalizedName] = self::nullWritesNothing($name, $propertyTypeMeta, $encoder, $propertyContext); + $onNull[$normalizedName] = self::onNull($name, $propertyTypeMeta, $encoder, $propertyContext, $customEncoderLens !== null); $isAnyPropertyQualified = $isAnyPropertyQualified || $propertyTypeMeta->isQualified()->unwrapOr(false); } @@ -126,32 +122,28 @@ private static function build(Context $context): self $isAnyPropertyQualified, $isAttribute, $decodeDefaultValues, - $omitWhenAbsent, - $nullWritesNothing, + $onNull, ); } /** - * The encoder decides whether null encodes to nothing; one that does not say so keeps its own null handling. + * Attributes and the '_' text value always go through their encoder; elements follow what their encoder says. + * A custom lens may read something else than the property of that name, so its null never counts as absent. */ - private static function nullWritesNothing(string $propertyName, TypeMeta $meta, XmlEncoder $encoder, Context $context): bool - { - return $propertyName !== '_' - && !$meta->isAttribute()->unwrapOr(false) - && $encoder instanceof Feature\WritesNothingForNull - && $encoder->writesNothingForNull($context); - } + private static function onNull( + string $propertyName, + TypeMeta $meta, + XmlEncoder $encoder, + Context $context, + bool $hasCustomEncoderLens, + ): NullValueHandling { + if ($propertyName === '_' || $meta->isAttribute()->unwrapOr(false) || !$encoder instanceof Feature\NullValueAware) { + return NullValueHandling::Write; + } - /** - * 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; + $handling = $encoder->nullValueHandling($context); + + return $hasCustomEncoderLens && $handling === NullValueHandling::SkipWhenAbsent ? NullValueHandling::Write : $handling; } /** diff --git a/src/Encoder/ObjectEncoder.php b/src/Encoder/ObjectEncoder.php index b8ec961..d0fb151 100644 --- a/src/Encoder/ObjectEncoder.php +++ b/src/Encoder/ObjectEncoder.php @@ -89,13 +89,11 @@ private function to(Context $context, ObjectAccess $objectAccess, object|array $ $objectAccess->encoderLenses[$normalizePropertyName], $data ); - if ($value === null && $objectAccess->nullWritesNothing[$normalizePropertyName]) { - continue; - } - if ($value === null - && $objectAccess->omitWhenAbsent[$normalizePropertyName] - && !property_is_initialized($data, $normalizePropertyName) - ) { + if ($value === null && match ($objectAccess->onNull[$normalizePropertyName]) { + NullValueHandling::Write => false, + NullValueHandling::Skip => true, + NullValueHandling::SkipWhenAbsent => !property_is_initialized($data, $normalizePropertyName), + }) { continue; } diff --git a/src/Encoder/OptionalElementEncoder.php b/src/Encoder/OptionalElementEncoder.php index baddf3e..032387a 100644 --- a/src/Encoder/OptionalElementEncoder.php +++ b/src/Encoder/OptionalElementEncoder.php @@ -18,7 +18,7 @@ * @implements XmlEncoder * @implements Feature\DecoratingEncoder */ -final class OptionalElementEncoder implements Feature\DecoratingEncoder, Feature\ElementAware, Feature\OptionalAware, Feature\WritesNothingForNull, XmlEncoder +final class OptionalElementEncoder implements Feature\DecoratingEncoder, Feature\ElementAware, Feature\NullValueAware, Feature\OptionalAware, XmlEncoder { /** * @param XmlEncoder $elementEncoder @@ -36,11 +36,16 @@ public function decoratedEncoder(): XmlEncoder return $this->elementEncoder; } - public function writesNothingForNull(Context $context): bool + public function nullValueHandling(Context $context): NullValueHandling { $meta = $context->type->getMeta(); - return $meta->isNullable()->unwrapOr(false) && !$meta->isNil()->unwrapOr(false); + return match (true) { + !$meta->isNullable()->unwrapOr(false) => NullValueHandling::Write, + !$meta->isNil()->unwrapOr(false) => NullValueHandling::Skip, + $meta->minOccurs()->unwrapOr(1) === 0 => NullValueHandling::SkipWhenAbsent, + default => NullValueHandling::Write, + }; } /** diff --git a/tests/Unit/Encoder/Feature/WritesNothingForNullTest.php b/tests/Unit/Encoder/Feature/NullValueAwareTest.php similarity index 57% rename from tests/Unit/Encoder/Feature/WritesNothingForNullTest.php rename to tests/Unit/Encoder/Feature/NullValueAwareTest.php index 347becd..1186bd6 100644 --- a/tests/Unit/Encoder/Feature/WritesNothingForNullTest.php +++ b/tests/Unit/Encoder/Feature/NullValueAwareTest.php @@ -9,6 +9,7 @@ use PHPUnit\Framework\TestCase; use Soap\Encoding\Encoder\ElementEncoder; use Soap\Encoding\Encoder\ErrorHandlingEncoder; +use Soap\Encoding\Encoder\NullValueHandling; use Soap\Encoding\Encoder\OptionalElementEncoder; use Soap\Encoding\Encoder\SimpleType\StringTypeEncoder; use Soap\Encoding\Test\Unit\ContextCreatorTrait; @@ -16,51 +17,52 @@ use Soap\Engine\Metadata\Model\XsdType; #[CoversClass(OptionalElementEncoder::class), CoversClass(ErrorHandlingEncoder::class)] -final class WritesNothingForNullTest extends TestCase +final class NullValueAwareTest extends TestCase { use ContextCreatorTrait; /** - * @return iterable + * @return iterable */ public static function provideMeta(): iterable { - yield 'optional, not nillable' => [true, false, true]; - yield 'optional, nillable' => [true, true, false]; - yield 'required' => [false, false, false]; + yield 'required' => [false, false, 1, NullValueHandling::Write]; + yield 'optional, not nillable' => [true, false, 0, NullValueHandling::Skip]; + yield 'optional, nillable' => [true, true, 0, NullValueHandling::SkipWhenAbsent]; + yield 'required, nillable' => [true, true, 1, NullValueHandling::Write]; } #[Test] #[DataProvider('provideMeta')] - public function optional_element_encoder_writes_nothing_for_null_unless_nillable(bool $nullable, bool $nil, bool $expected): void + public function optional_element_encoder_decides_from_the_meta(bool $nullable, bool $nil, int $minOccurs, NullValueHandling $expected): void { $encoder = new OptionalElementEncoder(new ElementEncoder(new StringTypeEncoder())); - $context = self::createContext(self::type($nullable, $nil)); + $context = self::createContext(self::type($nullable, $nil, $minOccurs)); - static::assertSame($expected, $encoder->writesNothingForNull($context)); - if ($expected) { + static::assertSame($expected, $encoder->nullValueHandling($context)); + if ($expected === NullValueHandling::Skip) { static::assertSame('', $encoder->iso($context)->to(null)); } } #[Test] #[DataProvider('provideMeta')] - public function error_handling_encoder_forwards_the_answer(bool $nullable, bool $nil, bool $expected): void + public function error_handling_encoder_forwards_the_answer(bool $nullable, bool $nil, int $minOccurs, NullValueHandling $expected): void { $encoder = new ErrorHandlingEncoder(new OptionalElementEncoder(new ElementEncoder(new StringTypeEncoder()))); - static::assertSame($expected, $encoder->writesNothingForNull(self::createContext(self::type($nullable, $nil)))); + static::assertSame($expected, $encoder->nullValueHandling(self::createContext(self::type($nullable, $nil, $minOccurs)))); } #[Test] - public function error_handling_encoder_answers_no_for_an_encoder_that_does_not_tell(): void + public function error_handling_encoder_writes_for_an_encoder_that_does_not_tell(): void { $encoder = new ErrorHandlingEncoder(new ElementEncoder(new StringTypeEncoder())); - static::assertFalse($encoder->writesNothingForNull(self::createContext(self::type(true, false)))); + static::assertSame(NullValueHandling::Write, $encoder->nullValueHandling(self::createContext(self::type(true, false, 0)))); } - private static function type(bool $nullable, bool $nil): XsdType + private static function type(bool $nullable, bool $nil, int $minOccurs): XsdType { return XsdType::guess('string') ->withXmlTargetNodeName('hello') @@ -68,6 +70,7 @@ private static function type(bool $nullable, bool $nil): XsdType static fn (TypeMeta $meta): TypeMeta => $meta ->withIsNullable($nullable) ->withIsNil($nil) + ->withMinOccurs($minOccurs) ->withIsSimple(true) ->withIsElement(true) ); diff --git a/tests/Unit/Encoder/ObjectEncoderNullElementTest.php b/tests/Unit/Encoder/ObjectEncoderNullElementTest.php index 274410a..ddc8bbe 100644 --- a/tests/Unit/Encoder/ObjectEncoderNullElementTest.php +++ b/tests/Unit/Encoder/ObjectEncoderNullElementTest.php @@ -8,6 +8,7 @@ use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; use Soap\Encoding\Encoder\Context; +use Soap\Encoding\Encoder\NullValueHandling; use Soap\Encoding\Encoder\ObjectAccess; use Soap\Encoding\Encoder\ObjectEncoder; use Soap\Encoding\EncoderRegistry; @@ -55,6 +56,11 @@ final class ObjectEncoderNullElementTest extends TestCase + + + + + @@ -120,27 +126,38 @@ public function null_optional_elements_encode_byte_for_byte(string $typeName, ob } #[Test] - public function only_default_optional_non_nillable_elements_write_nothing_for_null(): void + public function the_encoder_decides_what_null_does_for_an_element(): void { static::assertSame( [ - 'attribute' => false, - 'optional' => true, - 'optionalList' => false, - 'optionalNillable' => false, - 'requiredNillable' => false, - 'required' => false, - 'custom' => true, - 'nullWriting' => false, + 'attribute' => NullValueHandling::Write, + 'optional' => NullValueHandling::Skip, + 'optionalList' => NullValueHandling::Write, + 'optionalNillable' => NullValueHandling::SkipWhenAbsent, + 'requiredNillable' => NullValueHandling::Write, + 'required' => NullValueHandling::Write, + 'custom' => NullValueHandling::Skip, + 'nullWriting' => NullValueHandling::Write, ], - ObjectAccess::forContext(self::context('mixedType'))->nullWritesNothing + ObjectAccess::forContext(self::context('mixedType'))->onNull ); static::assertSame( - ['attribute' => false, '_' => false], - ObjectAccess::forContext(self::context('textType'))->nullWritesNothing + ['attribute' => NullValueHandling::Write, '_' => NullValueHandling::Write], + ObjectAccess::forContext(self::context('textType'))->onNull ); } + /** + * Only the encoder can tell that it leaves an absent value out, so an OptionalAware encoder that does not implement NullValueAware is called for it. + */ + #[Test] + public function absent_optional_nillable_element_is_written_by_an_encoder_that_does_not_tell(): void + { + $actual = (new ObjectEncoder(stdClass::class))->iso(self::context('nillableNullWritingType'))->to((object) []); + + static::assertSame('none', $actual); + } + private static function context(string $typeName): Context { $metadata = self::createMetadataFromWsdl(self::SCHEMA, 'type="tns:'.$typeName.'"'); diff --git a/tests/Unit/Encoder/ObjectPropertyPresenceTest.php b/tests/Unit/Encoder/ObjectPropertyPresenceTest.php index a882d9a..0bc3e8b 100644 --- a/tests/Unit/Encoder/ObjectPropertyPresenceTest.php +++ b/tests/Unit/Encoder/ObjectPropertyPresenceTest.php @@ -12,6 +12,7 @@ use ReflectionClass; use RuntimeException; use Soap\Encoding\Encoder\Context; +use Soap\Encoding\Encoder\NullValueHandling; use Soap\Encoding\Encoder\ObjectAccess; use Soap\Encoding\Encoder\ObjectEncoder; use Soap\Encoding\Encoder\XmlEncoder; @@ -112,21 +113,21 @@ public function absent_required_nullable_property_keeps_existing_nil_behavior(): #[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; + $eligibility = ObjectAccess::forContext(self::context('eligibilityType', new ThrowingLensEncoder()))->onNull; + $text = ObjectAccess::forContext(self::context('textType'))->onNull; static::assertSame( [ - 'attribute' => false, - 'optionalNillable' => true, - 'optionalNillableList' => true, - 'requiredNillable' => false, - 'optional' => false, - 'custom' => false, + 'attribute' => NullValueHandling::Write, + 'optionalNillable' => NullValueHandling::SkipWhenAbsent, + 'optionalNillableList' => NullValueHandling::SkipWhenAbsent, + 'requiredNillable' => NullValueHandling::Write, + 'optional' => NullValueHandling::Skip, + 'custom' => NullValueHandling::Write, ], $eligibility ); - static::assertSame(['attribute' => false, '_' => false], $text); + static::assertSame(['attribute' => NullValueHandling::Write, '_' => NullValueHandling::Write], $text); } /** From 41bb8e70710f64186720473361d68df4fa18b848 Mon Sep 17 00:00:00 2001 From: Toon Verwerft Date: Fri, 16 Oct 2026 08:36:53 +0200 Subject: [PATCH 6/8] Describe SkipWhenAbsent without xsi:nil --- src/Encoder/NullValueHandling.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Encoder/NullValueHandling.php b/src/Encoder/NullValueHandling.php index 20ece35..db4fcf9 100644 --- a/src/Encoder/NullValueHandling.php +++ b/src/Encoder/NullValueHandling.php @@ -18,7 +18,7 @@ enum NullValueHandling case Skip; /** - * Leave the element out when the property is absent from the PHP value, let the encoder write xsi:nil when it is set to null. + * Leave the element out when the property is absent from the PHP value, behave like Write when it is set to null. */ case SkipWhenAbsent; } From 64a9906abe8e8c30b85850cf632082f67d5f099f Mon Sep 17 00:00:00 2001 From: Toon Verwerft Date: Fri, 16 Oct 2026 08:37:57 +0200 Subject: [PATCH 7/8] Spell out what each null value handling case writes --- src/Encoder/NullValueHandling.php | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/Encoder/NullValueHandling.php b/src/Encoder/NullValueHandling.php index db4fcf9..23cd0cc 100644 --- a/src/Encoder/NullValueHandling.php +++ b/src/Encoder/NullValueHandling.php @@ -3,22 +3,25 @@ namespace Soap\Encoding\Encoder; /** - * What an object encoder does with a property whose value is null. + * What ObjectEncoder does with a property that is null or absent from the PHP value. + * An absent property reads as null, unless the case below tells them apart. */ enum NullValueHandling { /** - * Let the encoder write it. + * Pass null to the property's encoder and write what it returns. + * For a nillable element that is the element with xsi:nil="true". */ case Write; /** - * The encoder writes nothing for null, so the element is left out. + * Write nothing for the property, so the element is left out. */ case Skip; /** - * Leave the element out when the property is absent from the PHP value, behave like Write when it is set to null. + * Write nothing when the property is absent from the PHP value. + * When it is set to null, behave like Write. */ case SkipWhenAbsent; } From 36e63a30b7d0db8bc88ebfa6f37432d8dd6ceeee Mon Sep 17 00:00:00 2001 From: Toon Verwerft Date: Fri, 16 Oct 2026 08:40:16 +0200 Subject: [PATCH 8/8] Write onNull as one match --- src/Encoder/ObjectAccess.php | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/Encoder/ObjectAccess.php b/src/Encoder/ObjectAccess.php index 08d39dc..7f794f1 100644 --- a/src/Encoder/ObjectAccess.php +++ b/src/Encoder/ObjectAccess.php @@ -137,13 +137,13 @@ private static function onNull( Context $context, bool $hasCustomEncoderLens, ): NullValueHandling { - if ($propertyName === '_' || $meta->isAttribute()->unwrapOr(false) || !$encoder instanceof Feature\NullValueAware) { - return NullValueHandling::Write; - } - - $handling = $encoder->nullValueHandling($context); - - return $hasCustomEncoderLens && $handling === NullValueHandling::SkipWhenAbsent ? NullValueHandling::Write : $handling; + return match (true) { + $propertyName === '_' => NullValueHandling::Write, + $meta->isAttribute()->unwrapOr(false) => NullValueHandling::Write, + !$encoder instanceof Feature\NullValueAware => NullValueHandling::Write, + $hasCustomEncoderLens && $encoder->nullValueHandling($context) === NullValueHandling::SkipWhenAbsent => NullValueHandling::Write, + default => $encoder->nullValueHandling($context), + }; } /**