diff --git a/composer.json b/composer.json index 137a44f..bd34deb 100644 --- a/composer.json +++ b/composer.json @@ -34,7 +34,7 @@ "php-standard-library/str": "^6.1", "php-standard-library/type": "^6.1", "php-standard-library/vec": "^6.1", - "veewee/reflecta": "^1.0.0", + "veewee/reflecta": "^1.1", "veewee/xml": "^4.10", "php-soap/engine": "^2.20", "php-soap/wsdl": "^1.19", diff --git a/src/Coerce/Scalar.php b/src/Coerce/Scalar.php new file mode 100644 index 0000000..42ea5b3 --- /dev/null +++ b/src/Coerce/Scalar.php @@ -0,0 +1,66 @@ +coerce() accepts. + */ + public static function int(string $value): ?int + { + $int = (int) $value; + if ($value === (string) $int) { + return $int; + } + + // Psl trims leading zeros before comparing, which also turns '0-5' into -5. + $trimmed = ltrim($value, '0'); + $int = (int) $trimmed; + if ($trimmed === (string) $int) { + return $int; + } + + if ($trimmed === '' && $value !== '') { + return 0; + } + + return null; + } + + /** + * Accepts exactly the strings Psl\Type\float()->coerce() accepts. + */ + public static function float(string $value): ?float + { + if (preg_match('/^[+-]?(\d+([.]\d*)?([eE][+-]?\d+)?|[.]\d+([eE][+-]?\d+)?)$/', $value) === 1) { + return (float) $value; + } + + return null; + } + + public static function bool(string $value): ?bool + { + return match ($value) { + 'true' => true, + 'false' => false, + default => null, + }; + } + + /** + * Returns the first of int, float and bool that accepts the value, else the value itself. + */ + public static function guess(string $value): int|float|bool|string + { + return self::int($value) ?? self::float($value) ?? self::bool($value) ?? $value; + } +} diff --git a/src/Encoder/ObjectAccess.php b/src/Encoder/ObjectAccess.php index 64ebd84..4fdecf3 100644 --- a/src/Encoder/ObjectAccess.php +++ b/src/Encoder/ObjectAccess.php @@ -15,9 +15,9 @@ use VeeWee\Reflecta\Iso\IsoInterface; use VeeWee\Reflecta\Lens\LensInterface; use function Psl\Vec\sort_by; -use function VeeWee\Reflecta\Lens\index; use function VeeWee\Reflecta\Lens\optional; -use function VeeWee\Reflecta\Lens\property; +use function VeeWee\Reflecta\Lens\optional_index; +use function VeeWee\Reflecta\Lens\optional_property; final class ObjectAccess { @@ -89,8 +89,10 @@ private static function build(Context $context): self $shouldLensBeOptional = self::shouldLensBeOptional($propertyTypeMeta); $normalizedProperties[$normalizedName] = $property; - $encoderLenses[$normalizedName] = self::createEncoderLensForType($shouldLensBeOptional, $normalizedName, $encoder, $type, $property); - $decoderLenses[$normalizedName] = self::createDecoderLensForType($shouldLensBeOptional, $name, $encoder, $type, $property); + $encoderLenses[$normalizedName] = self::createCustomEncoderLens($shouldLensBeOptional, $encoder, $type, $property) + ?? optional_property($normalizedName); + $decoderLenses[$normalizedName] = self::createCustomDecoderLens($shouldLensBeOptional, $encoder, $type, $property) + ?? optional_index($name); $isos[$normalizedName] = $encoder->iso($propertyContext); $isAnyPropertyQualified = $isAnyPropertyQualified || $propertyTypeMeta->isQualified()->unwrapOr(false); @@ -106,40 +108,46 @@ private static function build(Context $context): self } /** - * @return LensInterface + * @return LensInterface|null */ - private static function createEncoderLensForType( + private static function createCustomEncoderLens( bool $shouldLensBeOptional, - string $normalizedName, XmlEncoder $encoder, Type $type, Property $property, - ): LensInterface { + ): ?LensInterface { $lens = match (true) { - $encoder instanceof Feature\DecoratingEncoder => self::createEncoderLensForType($shouldLensBeOptional, $normalizedName, $encoder->decoratedEncoder(), $type, $property), + $encoder instanceof Feature\DecoratingEncoder => self::createCustomEncoderLens($shouldLensBeOptional, $encoder->decoratedEncoder(), $type, $property), $encoder instanceof Feature\ProvidesObjectEncoderLens => $encoder::createObjectEncoderLens($type, $property), - default => property($normalizedName) + default => null, }; + if ($lens === null) { + return null; + } + return $shouldLensBeOptional ? optional($lens) : $lens; } /** - * @return LensInterface|null, array|null, mixed, mixed> + * @return LensInterface|null, array|null, mixed, mixed>|null */ - private static function createDecoderLensForType( + private static function createCustomDecoderLens( bool $shouldLensBeOptional, - string $name, XmlEncoder $encoder, Type $type, Property $property, - ): LensInterface { + ): ?LensInterface { $lens = match(true) { - $encoder instanceof Feature\DecoratingEncoder => self::createDecoderLensForType($shouldLensBeOptional, $name, $encoder->decoratedEncoder(), $type, $property), + $encoder instanceof Feature\DecoratingEncoder => self::createCustomDecoderLens($shouldLensBeOptional, $encoder->decoratedEncoder(), $type, $property), $encoder instanceof Feature\ProvidesObjectDecoderLens => $encoder::createObjectDecoderLens($type, $property), - default => index($name), + default => null, }; + if ($lens === null) { + return null; + } + return $shouldLensBeOptional ? optional($lens) : $lens; } diff --git a/src/Encoder/SimpleType/ScalarTypeEncoder.php b/src/Encoder/SimpleType/ScalarTypeEncoder.php index 0e5612d..22c6e91 100644 --- a/src/Encoder/SimpleType/ScalarTypeEncoder.php +++ b/src/Encoder/SimpleType/ScalarTypeEncoder.php @@ -3,7 +3,7 @@ namespace Soap\Encoding\Encoder\SimpleType; -use Psl\Type; +use Soap\Encoding\Coerce\Scalar; use Soap\Encoding\Encoder\Context; use Soap\Encoding\Encoder\XmlEncoder; use Soap\Encoding\Exception\RestrictionException; @@ -41,36 +41,7 @@ public function iso(Context $context): Iso is_bool($value) => (new BoolTypeEncoder())->iso($context)->to($value), default => throw RestrictionException::unsupportedValueType($context->type, $value) }, - static function (string $value) use ($context): mixed { - try { - return Type\int()->coerce($value); - } catch (Type\Exception\CoercionException) { - } - - try { - return Type\float()->coerce($value); - } catch (Type\Exception\CoercionException) { - } - - try { - return Type\converted( - Type\string(), - Type\bool(), - static fn (string $value): bool => match ($value) { - 'true' => true, - 'false' => false, - default => throw RestrictionException::unexpectedEnumType( - $context->type, - ['true', 'false'], - $value - ) - } - )->coerce($value); - } catch (Type\Exception\CoercionException) { - } - - return Type\string()->coerce($value); - } + Scalar::guess(...), )); } } diff --git a/tests/Fixture/Encoder/ThrowingLensEncoder.php b/tests/Fixture/Encoder/ThrowingLensEncoder.php new file mode 100644 index 0000000..4978b35 --- /dev/null +++ b/tests/Fixture/Encoder/ThrowingLensEncoder.php @@ -0,0 +1,73 @@ + + * @template-implements Feature\ProvidesObjectEncoderLens + * @template-implements Feature\ProvidesObjectDecoderLens + */ +final class ThrowingLensEncoder implements + Feature\DisregardXsiInformation, + Feature\ProvidesObjectDecoderLens, + Feature\ProvidesObjectEncoderLens, + XmlEncoder +{ + public static int $encoderLensReads = 0; + public static int $decoderLensReads = 0; + + /** + * @return Lens + */ + public static function createObjectEncoderLens(Type $parentType, Property $currentProperty): Lens + { + $lens = property($currentProperty->getName()); + + /** @var Lens */ + return Lens::readonly(static function (object $data) use ($lens): mixed { + self::$encoderLensReads++; + + return $lens->get($data); + }); + } + + /** + * @return Lens + */ + public static function createObjectDecoderLens(Type $parentType, Property $currentProperty): Lens + { + $lens = index($currentProperty->getName()); + + /** @var Lens */ + return Lens::readonly(static function (array $data) use ($lens): mixed { + self::$decoderLensReads++; + + return $lens->get($data); + }); + } + + /** + * @return Iso + */ + public function iso(Context $context): Iso + { + return new Iso( + static fn (?string $value): string => $value === null ? '' : '' . $value . '', + static fn (Element|string $xml): string => ($xml instanceof Element ? $xml : Element::fromString($xml))->element()->textContent, + ); + } +} diff --git a/tests/Integration/IntermediateThrowScenariosTest.php b/tests/Integration/IntermediateThrowScenariosTest.php index 6f1b0d2..325ee1f 100644 --- a/tests/Integration/IntermediateThrowScenariosTest.php +++ b/tests/Integration/IntermediateThrowScenariosTest.php @@ -141,6 +141,15 @@ public static function provideScenarios(): iterable ), ]; + yield 'encode object of a class that does not declare the properties' => [ + 'operation' => static fn (): string => $literal->encode('test', [new class { + public int $id = 1; + }])->getRequest(), + 'expected' => self::literalRequest( + '1
' + ), + ]; + yield 'encode uninitialised typed properties' => [ 'operation' => static function () use ($typed): string { $item = new Item(); diff --git a/tests/Integration/NoIntermediateThrowsTest.php b/tests/Integration/NoIntermediateThrowsTest.php new file mode 100644 index 0000000..b0a3842 --- /dev/null +++ b/tests/Integration/NoIntermediateThrowsTest.php @@ -0,0 +1,106 @@ + + */ + public static function provideUntypedDecodes(): iterable + { + $scalar = ScalarTypeEncoder::default()->iso(self::createContext(XsdType::any())); + foreach (['1.5' => 1.5, '1e3' => 1000.0, 'abc' => 'abc', '' => '', 'true' => true, 'false' => false, '12' => 12] as $value => $expected) { + $value = (string) $value; + yield 'scalar ' . var_export($value, true) => [ + 'decode' => static fn (): mixed => $scalar->from($value), + 'expected' => $expected, + ]; + } + + $list = (new SimpleListEncoder(ScalarTypeEncoder::default()))->iso(self::createContext( + XsdType::any()->withMeta(static fn (TypeMeta $meta): TypeMeta => $meta->withIsList(true)->withIsSimple(true)) + )); + yield 'simple list' => [ + 'decode' => static fn (): mixed => $list->from('1 1.5 abc true'), + 'expected' => [1, 1.5, 'abc', true], + ]; + + $map = (new ApacheMapEncoder())->iso(self::createContext(XsdType::guess('Map'))); + yield 'apache map values' => [ + 'decode' => static fn (): mixed => $map->from( + 'aabcb1.5' + ), + 'expected' => ['a' => 'abc', 'b' => 1.5], + ]; + + $object = (new SoapObjectEncoder())->iso(self::createContext(XsdType::guess('Struct'))); + yield 'soap-enc struct values' => [ + 'decode' => static fn (): mixed => $object->from('abc1.5false'), + 'expected' => (object) ['a' => 'abc', 'b' => 1.5, 'c' => false], + ]; + } + + /** + * @return iterable + */ + public static function provideScenarios(): iterable + { + foreach (IntermediateThrowScenariosTest::provideScenarios() as $name => $scenario) { + if (!in_array($name, self::STILL_THROWING, true)) { + yield $name => $scenario; + } + } + } +} diff --git a/tests/Unit/Coerce/ScalarTest.php b/tests/Unit/Coerce/ScalarTest.php new file mode 100644 index 0000000..efcbe8a --- /dev/null +++ b/tests/Unit/Coerce/ScalarTest.php @@ -0,0 +1,200 @@ + Scalar::guess($value)), true)); + } + + /** + * @return iterable + */ + public static function provideMethods(): iterable + { + yield 'int' => [Scalar::int(...), self::intOracle(...)]; + yield 'float' => [Scalar::float(...), self::floatOracle(...)]; + yield 'bool' => [Scalar::bool(...), self::boolOracle(...)]; + yield 'guess' => [Scalar::guess(...), self::guessOracle(...)]; + } + + /** + * @param Closure(string): mixed $method + * @param Closure(string): mixed $oracle + */ + #[DataProvider('provideMethods')] + public function test_it_matches_its_oracle_for_every_short_string(Closure $method, Closure $oracle): void + { + $mismatches = []; + foreach ([...self::sweep(), ...self::boundaries()] as $value) { + $expected = var_export($oracle($value), true); + $actual = var_export(self::strict(static fn () => $method($value)), true); + if ($expected !== $actual) { + $mismatches[] = var_export($value, true) . ': expected ' . $expected . ', got ' . $actual; + } + } + + static::assertSame([], $mismatches); + } + + /** + * @return iterable + */ + public static function provideNamedCases(): iterable + { + yield 'zero' => ['0', 0]; + yield 'one is an int, not a bool' => ['1', 1]; + yield 'int' => ['7', 7]; + yield 'negative int' => ['-12', -12]; + yield 'int max' => ['9223372036854775807', PHP_INT_MAX]; + yield 'leading zeros' => ['007', 7]; + yield 'more leading zeros' => ['0012', 12]; + yield 'only zeros' => ['000', 0]; + yield 'two zeros' => ['00', 0]; + yield 'psl quirk: zeros before a minus are trimmed into a negative int' => ['0-5', -5]; + yield 'psl quirk with two zeros' => ['00-5', -5]; + yield 'negative with leading zero is a float' => ['-012', -12.0]; + yield 'plus sign is a float' => ['+5', 5.0]; + yield 'negative zero is a float' => ['-0', -0.0]; + yield 'above int max is a float' => ['9223372036854775808', 9223372036854775808.0]; + yield 'far above int max is a float' => ['99999999999999999999', 99999999999999999999.0]; + yield 'decimal' => ['1.5', 1.5]; + yield 'decimal without leading digit' => ['.5', 0.5]; + yield 'decimal without trailing digit' => ['5.', 5.0]; + yield 'exponent' => ['1e3', 1000.0]; + yield 'negative upper case exponent' => ['1E-3', 0.001]; + yield 'signed exponent' => ['+1.5e+2', 150.0]; + yield 'overflowing exponent' => ['1e999', INF]; + yield 'decimal with trailing newline' => ["1.5\n", 1.5]; + yield 'int with trailing newline is a float' => ["12\n", 12.0]; + yield 'leading space' => [' 12', ' 12']; + yield 'trailing space' => ['12 ', '12 ']; + yield 'leading tab' => ["\t1", "\t1"]; + yield 'underscore separator' => ['1_000', '1_000']; + yield 'hexadecimal' => ['0x1A', '0x1A']; + yield 'decimal comma' => ['1,5', '1,5']; + yield 'exponent without digits' => ['1e', '1e']; + yield 'exponent without mantissa' => ['e1', 'e1']; + yield 'double minus' => ['--1', '--1']; + yield 'plus' => ['+', '+']; + yield 'minus' => ['-', '-']; + yield 'dot' => ['.', '.']; + yield 'empty' => ['', '']; + yield 'true' => ['true', true]; + yield 'false' => ['false', false]; + yield 'upper case true stays a string' => ['TRUE', 'TRUE']; + yield 'capitalised false stays a string' => ['False', 'False']; + yield 'yes' => ['yes', 'yes']; + yield 'on' => ['on', 'on']; + yield 'null' => ['null', 'null']; + yield 'nan' => ['NaN', 'NaN']; + yield 'inf' => ['INF', 'INF']; + yield 'word' => ['abc', 'abc']; + } + + private static function strict(Closure $call): mixed + { + set_error_handler(static function (int $severity, string $message, string $file, int $line): never { + throw new ErrorException($message, 0, $severity, $file, $line); + }); + + try { + return $call(); + } finally { + restore_error_handler(); + } + } + + private static function intOracle(string $value): ?int + { + try { + return Type\int()->coerce($value); + } catch (Type\Exception\CoercionException) { + return null; + } + } + + private static function floatOracle(string $value): ?float + { + try { + return Type\float()->coerce($value); + } catch (Type\Exception\CoercionException) { + return null; + } + } + + private static function boolOracle(string $value): ?bool + { + return match ($value) { + 'true' => true, + 'false' => false, + default => null, + }; + } + + private static function guessOracle(string $value): int|float|bool|string + { + return self::intOracle($value) ?? self::floatOracle($value) ?? self::boolOracle($value) ?? $value; + } + + /** + * @return list + */ + private static function sweep(): array + { + $values = ['']; + $layer = ['']; + for ($length = 1; $length <= self::SWEEP_MAX_LENGTH; $length++) { + $next = []; + foreach ($layer as $prefix) { + foreach (self::SWEEP_ALPHABET as $char) { + $next[] = $prefix . $char; + } + } + $values = [...$values, ...$next]; + $layer = $next; + } + + return $values; + } + + /** + * @return list + */ + private static function boundaries(): array + { + return [ + '9223372036854775807', + '9223372036854775808', + '-9223372036854775808', + '-9223372036854775809', + '09223372036854775807', + '+9223372036854775807', + '9223372036854775807.0', + '18446744073709551616', + ]; + } +} diff --git a/tests/Unit/Encoder/ObjectEncoderCustomLensTest.php b/tests/Unit/Encoder/ObjectEncoderCustomLensTest.php new file mode 100644 index 0000000..0bd3c7c --- /dev/null +++ b/tests/Unit/Encoder/ObjectEncoderCustomLensTest.php @@ -0,0 +1,93 @@ + + + + + + + + + + + XSD; + + protected function setUp(): void + { + ThrowingLensEncoder::$encoderLensReads = 0; + ThrowingLensEncoder::$decoderLensReads = 0; + } + + public function test_it_decodes_through_custom_lenses_that_throw_on_a_missing_element(): void + { + $actual = $this->iso()->from('1x'); + + static::assertEquals((object) ['id' => 1, 'custom' => null, 'any' => ['x']], $actual); + static::assertSame(1, ThrowingLensEncoder::$decoderLensReads); + } + + public function test_it_decodes_through_custom_lenses_when_the_element_is_present(): void + { + $actual = $this->iso()->from('1c'); + + static::assertEquals((object) ['id' => 1, 'custom' => 'c', 'any' => []], $actual); + static::assertSame(1, ThrowingLensEncoder::$decoderLensReads); + } + + public function test_it_encodes_through_custom_lenses_that_throw_on_a_missing_property(): void + { + $actual = $this->iso()->to((object) ['id' => 1, 'any' => ['x']]); + + static::assertSame('1x', $actual); + static::assertSame(1, ThrowingLensEncoder::$encoderLensReads); + } + + public function test_it_encodes_through_custom_lenses_when_the_property_is_present(): void + { + $actual = $this->iso()->to((object) ['id' => 1, 'custom' => 'c']); + + static::assertSame('1c', $actual); + static::assertSame(1, ThrowingLensEncoder::$encoderLensReads); + } + + /** + * @return \VeeWee\Reflecta\Iso\IsoInterface + */ + private function iso(): \VeeWee\Reflecta\Iso\IsoInterface + { + $metadata = self::createMetadataFromWsdl(self::SCHEMA, 'type="tns:user"', use: BindingUse::LITERAL); + $registry = EncoderRegistry::default() + ->addComplexTypeConverter('https://test', 'customType', new ThrowingLensEncoder()); + $context = new Context( + $metadata->getTypes()->fetchByNameAndXmlNamespace('user', 'https://test')->getXsdType(), + $metadata, + $registry, + self::buildNamespaces(), + ); + + return (new ObjectEncoder(stdClass::class))->iso($context); + } +} diff --git a/tests/Unit/Encoder/SimpleType/ScalarTypeEncoderUntypedDecodeTest.php b/tests/Unit/Encoder/SimpleType/ScalarTypeEncoderUntypedDecodeTest.php new file mode 100644 index 0000000..6375778 --- /dev/null +++ b/tests/Unit/Encoder/SimpleType/ScalarTypeEncoderUntypedDecodeTest.php @@ -0,0 +1,40 @@ +iso(self::createContext(XsdType::any()))->from($value); + + static::assertSame(var_export($expected, true), var_export($decoded, true)); + static::assertSame(var_export(Scalar::guess($value), true), var_export($decoded, true)); + } + + /** + * @return iterable + */ + public static function provideValues(): iterable + { + yield 'int' => ['7', 7]; + yield 'psl quirk' => ['0-5', -5]; + yield 'float' => ['1.5', 1.5]; + yield 'bool' => ['true', true]; + yield 'string' => ['abc', 'abc']; + } +}