From 442db29b9f95d82edb38f5c62750a421b0f6a51e Mon Sep 17 00:00:00 2001 From: NickSdot Date: Tue, 13 Oct 2026 14:21:51 +0800 Subject: [PATCH] fix: preserve absent optional properties during encoding --- src/Encoder/ObjectAccess.php | 9 +- src/Encoder/ObjectEncoder.php | 16 +++- .../Encoder/ObjectPropertyPresenceTest.php | 96 +++++++++++++++++++ 3 files changed, 111 insertions(+), 10 deletions(-) create mode 100644 tests/Unit/Encoder/ObjectPropertyPresenceTest.php diff --git a/src/Encoder/ObjectAccess.php b/src/Encoder/ObjectAccess.php index 64ebd84..0d3d07e 100644 --- a/src/Encoder/ObjectAccess.php +++ b/src/Encoder/ObjectAccess.php @@ -89,7 +89,7 @@ private static function build(Context $context): self $shouldLensBeOptional = self::shouldLensBeOptional($propertyTypeMeta); $normalizedProperties[$normalizedName] = $property; - $encoderLenses[$normalizedName] = self::createEncoderLensForType($shouldLensBeOptional, $normalizedName, $encoder, $type, $property); + $encoderLenses[$normalizedName] = self::createEncoderLensForType($normalizedName, $encoder, $type, $property); $decoderLenses[$normalizedName] = self::createDecoderLensForType($shouldLensBeOptional, $name, $encoder, $type, $property); $isos[$normalizedName] = $encoder->iso($propertyContext); @@ -109,19 +109,16 @@ private static function build(Context $context): self * @return LensInterface */ private static function createEncoderLensForType( - bool $shouldLensBeOptional, string $normalizedName, XmlEncoder $encoder, Type $type, Property $property, ): LensInterface { - $lens = match (true) { - $encoder instanceof Feature\DecoratingEncoder => self::createEncoderLensForType($shouldLensBeOptional, $normalizedName, $encoder->decoratedEncoder(), $type, $property), + return match (true) { + $encoder instanceof Feature\DecoratingEncoder => self::createEncoderLensForType($normalizedName, $encoder->decoratedEncoder(), $type, $property), $encoder instanceof Feature\ProvidesObjectEncoderLens => $encoder::createObjectEncoderLens($type, $property), default => property($normalizedName) }; - - return $shouldLensBeOptional ? optional($lens) : $lens; } /** diff --git a/src/Encoder/ObjectEncoder.php b/src/Encoder/ObjectEncoder.php index 619cf32..79492a5 100644 --- a/src/Encoder/ObjectEncoder.php +++ b/src/Encoder/ObjectEncoder.php @@ -95,11 +95,19 @@ static function (string $normalizePropertyName, Property $property) use ($object $meta = $type->getMeta(); $isAttribute = $meta->isAttribute()->unwrapOr(false); + /** @var object $data */ + $read = $objectAccess->encoderLenses[$normalizePropertyName]->tryGet($data); + $mayBeAbsent = $isAttribute + ? $meta->use()->unwrapOr('optional') === 'optional' + : $meta->minOccurs()->unwrapOr(1) === 0; + + if ($read->isFailed() && $mayBeAbsent) { + return $defaultAction; + } + + // Preserve existing required-property fallback and explicit null values. /** @var mixed $value */ - $value = self::runLens( - $objectAccess->encoderLenses[$normalizePropertyName], - $data - ); + $value = $read->unwrapOr(null); $iso = $objectAccess->isos[$normalizePropertyName]; return match(true) { diff --git a/tests/Unit/Encoder/ObjectPropertyPresenceTest.php b/tests/Unit/Encoder/ObjectPropertyPresenceTest.php new file mode 100644 index 0000000..9a0ac70 --- /dev/null +++ b/tests/Unit/Encoder/ObjectPropertyPresenceTest.php @@ -0,0 +1,96 @@ + + + + + + + + + + + + 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, '', '']; + } + + #[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)')); + } + + 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); + + $xml = $iso->to($value); + $document = new DOMDocument(); + + static::assertTrue($document->loadXML($xml)); + + $xpath = new DOMXPath($document); + $xpath->registerNamespace('xsi', 'http://www.w3.org/2001/XMLSchema-instance'); + + return $xpath; + } +}