diff --git a/src/Encoder/ObjectAccess.php b/src/Encoder/ObjectAccess.php index 3f8f824..2c8a250 100644 --- a/src/Encoder/ObjectAccess.php +++ b/src/Encoder/ObjectAccess.php @@ -3,6 +3,7 @@ namespace Soap\Encoding\Encoder; +use Closure; use Soap\Encoding\Cache\ScopedCache; use Soap\Encoding\EncoderRegistry; use Soap\Encoding\Normalizer\PhpPropertyNameNormalizer; @@ -15,10 +16,8 @@ use Soap\Engine\Metadata\Model\TypeMeta; use Soap\Engine\Metadata\Model\XsdType; use VeeWee\Reflecta\Iso\IsoInterface; -use VeeWee\Reflecta\Lens\Lens; use VeeWee\Reflecta\Lens\LensInterface; use function Psl\Vec\sort_by; -use function VeeWee\Reflecta\Lens\optional; use function VeeWee\Reflecta\Lens\optional_property; final class ObjectAccess @@ -28,8 +27,8 @@ final class ObjectAccess * Every array is keyed by the normalized PHP property name. * * @param array $properties The metadata properties, attributes first. - * @param array> $encoderLenses Reads the property value from the PHP object on encode. - * @param array> $decoderLenses Reads the raw XML value from the element on decode. + * @param array $encodeReaders Reads the property value from the PHP object on encode. + * @param array $decodeReaders Reads the raw XML value from the element on decode. * @param array> $isos Converts between the PHP value and its XML form. * @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. @@ -38,8 +37,8 @@ final class ObjectAccess */ public function __construct( public readonly array $properties, - public readonly array $encoderLenses, - public readonly array $decoderLenses, + public readonly array $encodeReaders, + public readonly array $decodeReaders, public readonly array $isos, public readonly bool $isAnyPropertyQualified, public readonly array $isAttribute, @@ -85,8 +84,8 @@ private static function build(Context $context): self ); $normalizedProperties = []; - $encoderLenses = []; - $decoderLenses = []; + $encodeReaders = []; + $decodeReaders = []; $isos = []; $isAttribute = []; $decodeDefaultValues = []; @@ -101,13 +100,22 @@ private static function build(Context $context): self $normalizedName = PhpPropertyNameNormalizer::normalize($name); $encoder = $context->registry->detectEncoderForContext($propertyContext); - $shouldLensBeOptional = self::shouldLensBeOptional($propertyTypeMeta); $normalizedProperties[$normalizedName] = $property; - $customEncoderLens = self::createCustomEncoderLens($shouldLensBeOptional, $encoder, $type, $property); - $encoderLenses[$normalizedName] = $customEncoderLens ?? optional_property($normalizedName); - $decoderLenses[$normalizedName] = self::createCustomDecoderLens($shouldLensBeOptional, $encoder, $type, $property) - ?? self::createDecoderLens($name, $propertyType); + $customEncoderLens = self::createCustomEncoderLens($encoder, $type, $property); + /** @var Closure(object): mixed $encodeReader */ + $encodeReader = $customEncoderLens !== null + ? $customEncoderLens->get(...) + : optional_property($normalizedName)->get(...); + $encodeReaders[$normalizedName] = $encodeReader; + + $customDecoderLens = self::createCustomDecoderLens($encoder, $type, $property); + /** @var Closure(ElementLookup): mixed $decodeReader */ + $decodeReader = $customDecoderLens !== null + ? $customDecoderLens->get(...) + : self::createDecoderReader($name, $propertyType); + $decodeReaders[$normalizedName] = $decodeReader; + $isos[$normalizedName] = $encoder->iso($propertyContext); $isAttribute[$normalizedName] = $propertyTypeMeta->isAttribute()->unwrapOr(false); $decodeDefaultValues[$normalizedName] = $propertyTypeMeta->isList()->unwrapOr(false) ? [] : null; @@ -118,8 +126,8 @@ private static function build(Context $context): self return new self( $normalizedProperties, - $encoderLenses, - $decoderLenses, + $encodeReaders, + $decodeReaders, $isos, $isAnyPropertyQualified, $isAttribute, @@ -152,78 +160,48 @@ private static function onNull( * @return LensInterface|null */ private static function createCustomEncoderLens( - bool $shouldLensBeOptional, XmlEncoder $encoder, Type $type, Property $property, ): ?LensInterface { - $lens = match (true) { - $encoder instanceof Feature\DecoratingEncoder => self::createCustomEncoderLens($shouldLensBeOptional, $encoder->decoratedEncoder(), $type, $property), + return match (true) { + $encoder instanceof Feature\DecoratingEncoder => self::createCustomEncoderLens($encoder->decoratedEncoder(), $type, $property), $encoder instanceof Feature\ProvidesObjectEncoderLens => $encoder::createObjectEncoderLens($type, $property), default => null, }; - - if ($lens === null) { - return null; - } - - return $shouldLensBeOptional ? optional($lens) : $lens; } /** - * @return LensInterface + * @return Closure(ElementLookup): mixed */ - private static function createDecoderLens(string $name, XsdType $type): LensInterface + private static function createDecoderReader(string $name, XsdType $type): Closure { $meta = $type->getMeta(); if ($meta->isAttribute()->unwrapOr(false)) { $namespace = $meta->isQualified()->unwrapOr(false) ? $type->getXmlTargetNamespace() : ''; - return Lens::readonly(static fn (ElementLookup $lookup): ?string => $lookup->attribute($name, $namespace)); + return static fn (ElementLookup $lookup): ?string => $lookup->attribute($name, $namespace); } if ($name === '_') { - return Lens::readonly(static fn (ElementLookup $lookup): ?string => $lookup->text()); + return static fn (ElementLookup $lookup): ?string => $lookup->text(); } - return Lens::readonly(static fn (ElementLookup $lookup): Element|ElementList|null => $lookup->element($name)); + return static fn (ElementLookup $lookup): Element|ElementList|null => $lookup->element($name); } /** * @return LensInterface|null */ private static function createCustomDecoderLens( - bool $shouldLensBeOptional, XmlEncoder $encoder, Type $type, Property $property, ): ?LensInterface { - $lens = match(true) { - $encoder instanceof Feature\DecoratingEncoder => self::createCustomDecoderLens($shouldLensBeOptional, $encoder->decoratedEncoder(), $type, $property), + return match(true) { + $encoder instanceof Feature\DecoratingEncoder => self::createCustomDecoderLens($encoder->decoratedEncoder(), $type, $property), $encoder instanceof Feature\ProvidesObjectDecoderLens => $encoder::createObjectDecoderLens($type, $property), default => null, }; - - if ($lens === null) { - return null; - } - - return $shouldLensBeOptional ? optional($lens) : $lens; - } - - private static function shouldLensBeOptional(TypeMeta $meta): bool - { - if ($meta->isNullable()->unwrapOr(false)) { - return true; - } - - if ( - $meta->isAttribute()->unwrapOr(false) && - $meta->use()->unwrapOr('optional') === 'optional' - ) { - return true; - } - - return false; } } diff --git a/src/Encoder/ObjectEncoder.php b/src/Encoder/ObjectEncoder.php index a3234ae..9d68be3 100644 --- a/src/Encoder/ObjectEncoder.php +++ b/src/Encoder/ObjectEncoder.php @@ -3,15 +3,14 @@ namespace Soap\Encoding\Encoder; -use Exception; use Soap\Encoding\Xml\Node\Element; +use Soap\Encoding\Xml\Node\ElementList; use Soap\Encoding\Xml\Node\ElementLookup; use Soap\Encoding\Xml\Writer\AttributeBuilder; use Soap\Encoding\Xml\Writer\NilAttributeBuilder; use Soap\Encoding\Xml\Writer\XsdTypeXmlElementWriter; use Soap\Encoding\Xml\Writer\XsiAttributeBuilder; use VeeWee\Reflecta\Iso\Iso; -use VeeWee\Reflecta\Lens\LensInterface; use function is_array; use function Psl\Fun\lazy; use function VeeWee\Reflecta\Iso\object_data; @@ -85,10 +84,7 @@ private function to(Context $context, ObjectAccess $objectAccess, object|array $ ]; foreach ($objectAccess->properties as $normalizePropertyName => $property) { /** @var mixed $value */ - $value = self::runLens( - $objectAccess->encoderLenses[$normalizePropertyName], - $data - ); + $value = $objectAccess->encodeReaders[$normalizePropertyName]($data); if ($value === null && match ($objectAccess->onNull[$normalizePropertyName]) { NullValueHandling::Write => false, NullValueHandling::Skip => true, @@ -128,11 +124,8 @@ private function from(Context $context, ObjectAccess $objectAccess, Element $dat $result = []; foreach ($objectAccess->isos as $normalizePropertyName => $iso) { - /** @var string|null $value */ - $value = self::runLens( - $objectAccess->decoderLenses[$normalizePropertyName], - $lookup, - ); + /** @var Element|ElementList|string|null $value */ + $value = $objectAccess->decodeReaders[$normalizePropertyName]($lookup); /** * @var mixed @@ -147,14 +140,4 @@ private function from(Context $context, ObjectAccess $objectAccess, Element $dat return $objectData->from($result); } - - private static function runLens(LensInterface $lens, mixed $data, mixed $default = null): mixed - { - try { - /** @var mixed */ - return $lens->get($data); - } catch (Exception $e) { - return $default; - } - } } diff --git a/src/Encoder/OptionalElementEncoder.php b/src/Encoder/OptionalElementEncoder.php index 032387a..cee8c95 100644 --- a/src/Encoder/OptionalElementEncoder.php +++ b/src/Encoder/OptionalElementEncoder.php @@ -63,6 +63,8 @@ public function iso(Context $context): IsoInterface return $elementIso; } + $xsiNamespace = Xmlns::xsi()->value(); + return new Iso( /** * @param T|null $raw @@ -75,7 +77,7 @@ public function iso(Context $context): IsoInterface /** * @return T|null */ - static function (ElementList|Element|string $xml) use ($elementIso, $isList) : mixed { + static function (ElementList|Element|string $xml) use ($elementIso, $isList, $xsiNamespace) : mixed { if ($xml === '') { return null; } @@ -91,7 +93,7 @@ static function (ElementList|Element|string $xml) use ($elementIso, $isList) : m $parsedXml instanceof Element => $parsedXml->element(), default => null }; - if ($documentElement && $documentElement->getAttributeNS(Xmlns::xsi()->value(), 'nil') === 'true') { + if ($documentElement && $documentElement->getAttributeNS($xsiNamespace, 'nil') === 'true') { return null; } diff --git a/src/Xml/Reader/ElementValueReader.php b/src/Xml/Reader/ElementValueReader.php index e5865c3..48c2210 100644 --- a/src/Xml/Reader/ElementValueReader.php +++ b/src/Xml/Reader/ElementValueReader.php @@ -7,8 +7,6 @@ use Soap\Encoding\Encoder\Context; use Soap\Encoding\Encoder\XmlEncoder; use VeeWee\Reflecta\Iso\IsoInterface; -use function Psl\Type\string; -use function VeeWee\Xml\Dom\Locator\Node\value as readValue; final class ElementValueReader { @@ -30,9 +28,7 @@ public function __invoke( */ public static function forEncoder(Context $context, XmlEncoder $encoder, Element $element): mixed { - return $encoder->iso($context)->from( - readValue($element, string()) - ); + return $encoder->iso($context)->from($element->substitutedNodeValue); } /** @@ -41,8 +37,6 @@ public static function forEncoder(Context $context, XmlEncoder $encoder, Element */ public static function forIso(IsoInterface $iso, Element $element): mixed { - return $iso->from( - readValue($element, string()) - ); + return $iso->from($element->substitutedNodeValue); } } diff --git a/tests/Support/Throws/allowed-throws.php b/tests/Support/Throws/allowed-throws.php index cf173e1..a62f603 100644 --- a/tests/Support/Throws/allowed-throws.php +++ b/tests/Support/Throws/allowed-throws.php @@ -1,36 +1,12 @@ ObjectEncoderCustomLensTest::class.'::test_it_decodes_through_custom_lenses_that_throw_on_a_missing_element', - 'throw' => ArrayAccessException::class, - 'via' => 'src/Encoder/ObjectEncoder.php -> VeeWee\\Reflecta\\Lens\\Lens->get', - 'throws' => 1, - 'reason' => 'The fixture is a third-party encoder whose decoder lens throws on a missing element, to prove custom lenses still get the optional() and runLens treatment.', - ], - [ - 'test' => ObjectEncoderCustomLensTest::class.'::test_it_encodes_through_custom_lenses_that_throw_on_a_missing_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 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, diff --git a/tests/Unit/Encoder/ObjectAccessDecodeReadTest.php b/tests/Unit/Encoder/ObjectAccessDecodeReadTest.php new file mode 100644 index 0000000..2e40161 --- /dev/null +++ b/tests/Unit/Encoder/ObjectAccessDecodeReadTest.php @@ -0,0 +1,134 @@ + + + + + + + + + + + + + + + + + + + + + + XML; + + #[Test] + public function every_property_decodes_through_a_plain_reader(): void + { + $mixed = ObjectAccess::forContext(self::context('mixedType'))->decodeReaders; + $text = ObjectAccess::forContext(self::context('textType'))->decodeReaders; + + static::assertSame(['plain', 'qualified', 'lang', 'element', 'list', 'custom'], array_keys($mixed)); + static::assertSame(['plain', '_'], array_keys($text)); + static::assertContainsOnlyInstancesOf(Closure::class, [...$mixed, ...$text]); + } + + #[Test] + public function custom_property_decodes_through_its_custom_lens(): void + { + $access = ObjectAccess::forContext(self::context('mixedType')); + $lookup = ElementLookup::fromElement(Element::fromString('c')); + ThrowingLensEncoder::$decoderLensReads = 0; + + $actual = $access->decodeReaders['custom']($lookup); + + static::assertSame($lookup->element('custom'), $actual); + static::assertSame(1, ThrowingLensEncoder::$decoderLensReads); + } + + /** @return iterable */ + public static function lookups(): iterable + { + yield 'every property present' => [ + 'mixedType', + '' + . 'e12c', + ]; + yield 'every property absent' => ['mixedType', '']; + yield 'attributes in the wrong namespace' => [ + 'mixedType', + '', + ]; + yield 'text present' => ['textType', 'content']; + yield 'text nil' => [ + 'textType', + '', + ]; + yield 'text with child elements' => ['textType', '']; + } + + #[Test, DataProvider('lookups')] + public function built_in_decoder_readers_read_the_same_value_as_the_direct_read(string $typeName, string $xml): void + { + $access = ObjectAccess::forContext(self::context($typeName)); + $lookup = ElementLookup::fromElement(Element::fromString($xml)); + + $direct = [ + 'mixedType' => [ + 'plain' => $lookup->attribute('plain', ''), + 'qualified' => $lookup->attribute('qualified', 'https://test'), + 'lang' => $lookup->attribute('lang', 'http://www.w3.org/XML/1998/namespace'), + 'element' => $lookup->element('element'), + 'list' => $lookup->element('list'), + ], + 'textType' => [ + 'plain' => $lookup->attribute('plain', ''), + '_' => $lookup->text(), + ], + ][$typeName]; + + foreach ($direct as $property => $expected) { + static::assertSame($expected, $access->decodeReaders[$property]($lookup), $property); + } + } + + private static function context(string $typeName): Context + { + $metadata = self::createMetadataFromWsdl(self::SCHEMA, 'type="tns:'.$typeName.'"'); + $registry = EncoderRegistry::default() + ->addComplexTypeConverter('https://test', 'customType', new ThrowingLensEncoder()); + + return new Context( + $metadata->getTypes()->fetchByNameAndXmlNamespace($typeName, 'https://test')->getXsdType(), + $metadata, + $registry, + self::buildNamespaces(), + BindingUse::LITERAL, + ); + } +} diff --git a/tests/Unit/Encoder/ObjectEncoderCustomLensTest.php b/tests/Unit/Encoder/ObjectEncoderCustomLensTest.php index a9013da..32362aa 100644 --- a/tests/Unit/Encoder/ObjectEncoderCustomLensTest.php +++ b/tests/Unit/Encoder/ObjectEncoderCustomLensTest.php @@ -7,13 +7,17 @@ use PHPUnit\Framework\TestCase; use Soap\Encoding\Encoder\AnyElementEncoder; use Soap\Encoding\Encoder\Context; +use Soap\Encoding\Encoder\ErrorHandlingEncoder; use Soap\Encoding\Encoder\ObjectAccess; use Soap\Encoding\Encoder\ObjectEncoder; use Soap\Encoding\EncoderRegistry; +use Soap\Encoding\Exception\EncodingException; use Soap\Encoding\Test\Fixture\Encoder\ThrowingLensEncoder; use Soap\Encoding\Test\Unit\ContextCreatorTrait; use Soap\WsdlReader\Model\Definitions\BindingUse; use stdClass; +use VeeWee\Reflecta\ArrayAccess\Exception\ArrayAccessException; +use VeeWee\Reflecta\Reflect\Exception\UnreflectableException; #[CoversClass(ObjectEncoder::class)] #[CoversClass(ObjectAccess::class)] @@ -41,11 +45,15 @@ protected function setUp(): void ThrowingLensEncoder::$decoderLensReads = 0; } - public function test_it_decodes_through_custom_lenses_that_throw_on_a_missing_element(): void + public function test_it_fails_decoding_when_a_custom_lens_throws_on_a_missing_element(): void { - $actual = $this->iso()->from('1x'); + try { + $this->iso()->from('1x'); + static::fail('Expected the throwing custom lens to fail decoding.'); + } catch (EncodingException $exception) { + static::assertInstanceOf(ArrayAccessException::class, $exception->getPrevious()); + } - static::assertEquals((object) ['id' => 1, 'custom' => null, 'any' => ['x']], $actual); static::assertSame(1, ThrowingLensEncoder::$decoderLensReads); } @@ -72,11 +80,15 @@ public function test_it_keeps_any_children_when_an_attribute_has_the_same_name() static::assertEquals((object) ['id' => 1, 'custom' => 'c', 'any' => ['x']], $actual); } - public function test_it_encodes_through_custom_lenses_that_throw_on_a_missing_property(): void + public function test_it_fails_encoding_when_a_custom_lens_throws_on_a_missing_property(): void { - $actual = $this->iso()->to((object) ['id' => 1, 'any' => ['x']]); + try { + $this->iso()->to((object) ['id' => 1, 'any' => ['x']]); + static::fail('Expected the throwing custom lens to fail encoding.'); + } catch (EncodingException $exception) { + static::assertInstanceOf(UnreflectableException::class, $exception->getPrevious()); + } - static::assertSame('1x', $actual); static::assertSame(1, ThrowingLensEncoder::$encoderLensReads); } @@ -103,6 +115,6 @@ private function iso(): \VeeWee\Reflecta\Iso\IsoInterface self::buildNamespaces(), ); - return (new ObjectEncoder(stdClass::class))->iso($context); + return (new ErrorHandlingEncoder(new ObjectEncoder(stdClass::class)))->iso($context); } } diff --git a/tests/Unit/Encoder/ObjectPropertyPresenceTest.php b/tests/Unit/Encoder/ObjectPropertyPresenceTest.php index 0bc3e8b..7a74ba6 100644 --- a/tests/Unit/Encoder/ObjectPropertyPresenceTest.php +++ b/tests/Unit/Encoder/ObjectPropertyPresenceTest.php @@ -12,16 +12,19 @@ use ReflectionClass; use RuntimeException; use Soap\Encoding\Encoder\Context; +use Soap\Encoding\Encoder\ErrorHandlingEncoder; use Soap\Encoding\Encoder\NullValueHandling; use Soap\Encoding\Encoder\ObjectAccess; use Soap\Encoding\Encoder\ObjectEncoder; use Soap\Encoding\Encoder\XmlEncoder; use Soap\Encoding\EncoderRegistry; +use Soap\Encoding\Exception\EncodingException; use Soap\Encoding\Test\Fixture\Encoder\NullLensEncoder; use Soap\Encoding\Test\Fixture\Encoder\ThrowingLensEncoder; use Soap\Encoding\Test\Unit\ContextCreatorTrait; use Soap\WsdlReader\Model\Definitions\BindingUse; use stdClass; +use VeeWee\Reflecta\Reflect\Exception\UnreflectableException; #[CoversClass(ObjectEncoder::class), CoversClass(ObjectAccess::class)] final class ObjectPropertyPresenceTest extends TestCase @@ -133,21 +136,28 @@ public function only_optional_nillable_elements_with_the_default_lens_are_left_o /** * 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 + #[Test] + public function custom_lens_reading_null_keeps_writing_nil(): void { - $context = self::context('eligibilityType', $customEncoder); + $context = self::context('eligibilityType', new NullLensEncoder()); $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 + #[Test] + public function custom_lens_throwing_on_the_absent_property_fails_encoding(): void { - yield 'lens throws on the absent property' => [new ThrowingLensEncoder()]; - yield 'lens returns null' => [new NullLensEncoder()]; + $context = self::context('eligibilityType', new ThrowingLensEncoder()); + $iso = (new ErrorHandlingEncoder(new ObjectEncoder(stdClass::class)))->iso($context); + + try { + $iso->to((object) ['requiredNillable' => 'r']); + static::fail('Expected the throwing custom lens to fail encoding.'); + } catch (EncodingException $exception) { + static::assertInstanceOf(UnreflectableException::class, $exception->getPrevious()); + } } private static function withUnsetNote(): object