Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion src/Encoder/ErrorHandlingEncoder.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
* @implements XmlEncoder<TDataIn, TDataOut, TXmlOut, TXmlIn>
* @implements Feature\DecoratingEncoder<TDataIn, TDataOut, TXmlOut, TXmlIn>
*/
final class ErrorHandlingEncoder implements Feature\DecoratingEncoder, XmlEncoder
final class ErrorHandlingEncoder implements Feature\DecoratingEncoder, Feature\NullValueAware, XmlEncoder
{
/**
* @param XmlEncoder<TDataIn, TDataOut, TXmlOut, TXmlIn> $encoder
Expand All @@ -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<TDataIn, TDataOut, TXmlOut, TXmlIn>
*/
Expand Down
14 changes: 14 additions & 0 deletions src/Encoder/Feature/NullValueAware.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?php declare(strict_types=1);

namespace Soap\Encoding\Encoder\Feature;

use Soap\Encoding\Encoder\Context;
use Soap\Encoding\Encoder\NullValueHandling;

/**
* Tells an object encoder what to do with a null value in this context, so it can leave the element out without calling the encoder.
*/
interface NullValueAware
{
public function nullValueHandling(Context $context): NullValueHandling;
}
27 changes: 27 additions & 0 deletions src/Encoder/NullValueHandling.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php declare(strict_types=1);

namespace Soap\Encoding\Encoder;

/**
* 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
{
/**
* 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;

/**
* Write nothing for the property, so the element is left out.
*/
case Skip;

/**
* Write nothing when the property is absent from the PHP value.
* When it is set to null, behave like Write.
*/
case SkipWhenAbsent;
}
34 changes: 21 additions & 13 deletions src/Encoder/ObjectAccess.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ final class ObjectAccess
* @param bool $isAnyPropertyQualified Whether at least one property is namespace qualified.
* @param array<string, bool> $isAttribute Whether the property is an XML attribute instead of a child element.
* @param array<string, list<never>|null> $decodeDefaultValues The value a property decodes to when its element is missing from the XML.
* @param array<string, bool> $omitWhenAbsent Whether encoding leaves the element out, instead of writing xsi:nil, when the property is absent from the PHP value.
* @param array<string, NullValueHandling> $onNull What encoding does with a null value for this property.
*/
public function __construct(
public readonly array $properties,
Expand All @@ -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,
) {
}

Expand Down Expand Up @@ -88,7 +88,7 @@ private static function build(Context $context): self
$isos = [];
$isAttribute = [];
$decodeDefaultValues = [];
$omitWhenAbsent = [];
$onNull = [];
$isAnyPropertyQualified = false;

foreach ($sortedProperties as $property) {
Expand All @@ -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);
}
Expand All @@ -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),
};
}

/**
Expand Down
9 changes: 5 additions & 4 deletions src/Encoder/ObjectEncoder.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
14 changes: 13 additions & 1 deletion src/Encoder/OptionalElementEncoder.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
* @implements XmlEncoder<T, T, string, ElementList|Element|string>
* @implements Feature\DecoratingEncoder<T, T, string|null, ElementList|Element|string|null>
*/
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<T, T, string|null, ElementList|Element|string|null> $elementEncoder
Expand All @@ -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<T, T, string, ElementList|Element|string>
*/
Expand Down
29 changes: 29 additions & 0 deletions tests/Fixture/Encoder/NullWritingEncoder.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);

namespace Soap\Encoding\Test\Fixture\Encoder;

use Soap\Encoding\Encoder\Context;
use Soap\Encoding\Encoder\Feature;
use Soap\Encoding\Encoder\XmlEncoder;
use Soap\Encoding\Xml\Node\Element;
use VeeWee\Reflecta\Iso\Iso;

/**
* A third-party encoder that handles null itself and writes an element for it.
*
* @implements XmlEncoder<string|null, string, string, Element|string>
*/
final class NullWritingEncoder implements Feature\OptionalAware, XmlEncoder
{
/**
* @return Iso<string|null, string, string, Element|string>
*/
public function iso(Context $context): Iso
{
return new Iso(
static fn (?string $value): string => '<custom>' . ($value ?? 'none') . '</custom>',
static fn (Element|string $xml): string => ($xml instanceof Element ? $xml : Element::fromString($xml))->element()->textContent,
);
}
}
78 changes: 78 additions & 0 deletions tests/Unit/Encoder/Feature/NullValueAwareTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);

namespace Soap\Encoding\Test\Unit\Encoder\Feature;

use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
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;
use Soap\Engine\Metadata\Model\TypeMeta;
use Soap\Engine\Metadata\Model\XsdType;

#[CoversClass(OptionalElementEncoder::class), CoversClass(ErrorHandlingEncoder::class)]
final class NullValueAwareTest extends TestCase
{
use ContextCreatorTrait;

/**
* @return iterable<string, array{bool, bool, int, NullValueHandling}>
*/
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)
);
}
}
Loading
Loading