diff --git a/src/Encoder/ErrorHandlingEncoder.php b/src/Encoder/ErrorHandlingEncoder.php index 4f3133f..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, XmlEncoder +final class ErrorHandlingEncoder implements Feature\DecoratingEncoder, Feature\NullValueAware, XmlEncoder { /** * @param XmlEncoder $encoder @@ -34,6 +34,13 @@ public function decoratedEncoder(): XmlEncoder return $this->encoder; } + public function nullValueHandling(Context $context): NullValueHandling + { + return $this->encoder instanceof Feature\NullValueAware + ? $this->encoder->nullValueHandling($context) + : NullValueHandling::Write; + } + /** * @return Iso */ 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 $onNull What encoding does with a null value for this property. */ public function __construct( public readonly array $properties, @@ -42,7 +42,7 @@ public function __construct( public readonly bool $isAnyPropertyQualified, public readonly array $isAttribute, public readonly array $decodeDefaultValues, - public readonly array $omitWhenAbsent, + public readonly array $onNull, ) { } @@ -88,7 +88,7 @@ private static function build(Context $context): self $isos = []; $isAttribute = []; $decodeDefaultValues = []; - $omitWhenAbsent = []; + $onNull = []; $isAnyPropertyQualified = false; foreach ($sortedProperties as $property) { @@ -109,7 +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); + $onNull[$normalizedName] = self::onNull($name, $propertyTypeMeta, $encoder, $propertyContext, $customEncoderLens !== null); $isAnyPropertyQualified = $isAnyPropertyQualified || $propertyTypeMeta->isQualified()->unwrapOr(false); } @@ -122,20 +122,28 @@ private static function build(Context $context): self $isAnyPropertyQualified, $isAttribute, $decodeDefaultValues, - $omitWhenAbsent, + $onNull, ); } /** - * 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. + * 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 mayBeAbsent(string $propertyName, TypeMeta $meta): bool - { - return $propertyName !== '_' - && !$meta->isAttribute()->unwrapOr(false) - && $meta->isNil()->unwrapOr(false) - && $meta->minOccurs()->unwrapOr(1) === 0; + private static function onNull( + string $propertyName, + TypeMeta $meta, + XmlEncoder $encoder, + Context $context, + bool $hasCustomEncoderLens, + ): NullValueHandling { + 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), + }; } /** diff --git a/src/Encoder/ObjectEncoder.php b/src/Encoder/ObjectEncoder.php index a0831e6..d0fb151 100644 --- a/src/Encoder/ObjectEncoder.php +++ b/src/Encoder/ObjectEncoder.php @@ -89,10 +89,11 @@ private function to(Context $context, ObjectAccess $objectAccess, object|array $ $objectAccess->encoderLenses[$normalizePropertyName], $data ); - 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 fb77a14..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, XmlEncoder +final class OptionalElementEncoder implements Feature\DecoratingEncoder, Feature\ElementAware, Feature\NullValueAware, Feature\OptionalAware, XmlEncoder { /** * @param XmlEncoder $elementEncoder @@ -36,6 +36,18 @@ public function decoratedEncoder(): XmlEncoder return $this->elementEncoder; } + public function nullValueHandling(Context $context): NullValueHandling + { + $meta = $context->type->getMeta(); + + 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, + }; + } + /** * @return IsoInterface */ 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/Feature/NullValueAwareTest.php b/tests/Unit/Encoder/Feature/NullValueAwareTest.php new file mode 100644 index 0000000..1186bd6 --- /dev/null +++ b/tests/Unit/Encoder/Feature/NullValueAwareTest.php @@ -0,0 +1,78 @@ + + */ + public static function provideMeta(): iterable + { + 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_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, $minOccurs)); + + 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, int $minOccurs, NullValueHandling $expected): void + { + $encoder = new ErrorHandlingEncoder(new OptionalElementEncoder(new ElementEncoder(new StringTypeEncoder()))); + + static::assertSame($expected, $encoder->nullValueHandling(self::createContext(self::type($nullable, $nil, $minOccurs)))); + } + + #[Test] + public function error_handling_encoder_writes_for_an_encoder_that_does_not_tell(): void + { + $encoder = new ErrorHandlingEncoder(new ElementEncoder(new StringTypeEncoder())); + + static::assertSame(NullValueHandling::Write, $encoder->nullValueHandling(self::createContext(self::type(true, false, 0)))); + } + + private static function type(bool $nullable, bool $nil, int $minOccurs): XsdType + { + return XsdType::guess('string') + ->withXmlTargetNodeName('hello') + ->withMeta( + 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 new file mode 100644 index 0000000..ddc8bbe --- /dev/null +++ b/tests/Unit/Encoder/ObjectEncoderNullElementTest.php @@ -0,0 +1,176 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + XML; + + /** @return iterable */ + 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 'attribute and all optional elements null' => [ + 'optionalType', + $optional(['attribute' => 'a']), + '', + ]; + yield 'first optional element null' => [ + 'optionalType', + $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', + (object) [ + 'attribute' => null, + 'optional' => null, + 'optionalList' => null, + 'optionalNillable' => null, + 'requiredNillable' => null, + 'required' => 'r', + 'custom' => null, + 'nullWriting' => null, + ], + '' + . '' + . '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, object $data, string $expected): void + { + $actual = (new ObjectEncoder(stdClass::class))->iso(self::context($typeName))->to($data); + + static::assertSame($expected, $actual); + } + + #[Test] + public function the_encoder_decides_what_null_does_for_an_element(): void + { + static::assertSame( + [ + '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'))->onNull + ); + static::assertSame( + ['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.'"'); + $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, + ); + } +} 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); } /**