From d4ae52243866717e08032ca4faa74c012c989da7 Mon Sep 17 00:00:00 2001 From: SerafimArts Date: Tue, 14 Jul 2026 01:47:39 +0300 Subject: [PATCH] Add mapper stub --- .php-cs-fixer.php | 2 + README.md | 15 +- composer.json | 27 +- libs/mapper/.editorconfig | 15 + libs/mapper/.gitattributes | 18 + libs/mapper/.github/workflows/tests.yml | 37 ++ libs/mapper/.gitignore | 9 + libs/mapper/LICENSE | 21 + libs/mapper/composer.json | 67 +++ .../libs/property-accessor/composer.json | 47 ++ .../libs/property-accessor/phpstan.neon | 6 + .../MissingPropertyReadException.php | 15 + .../MissingPropertyWriteException.php | 15 + .../src/Exception/PropertyAccessException.php | 19 + .../PropertyAccessorExceptionInterface.php | 16 + .../PropertyAlreadyInitializedException.php | 19 + .../PropertyNotReadableException.php | 15 + .../PropertyNotWritableException.php | 15 + .../Exception/ReadOnlyPropertyException.php | 19 + .../Exception/StaticPropertyReadException.php | 15 + .../StaticPropertyWriteException.php | 15 + .../UninitializedPropertyReadException.php | 15 + .../Exception/WriteOnlyPropertyException.php | 19 + .../src/NullPropertyAccessor.php | 37 ++ .../src/PropertyAccessorInterface.php | 40 ++ .../src/ReflectionPropertyAccessor.php | 134 ++++++ .../Exception/PropertyAccessExceptionTest.php | 81 ++++ .../tests/NullPropertyAccessorTest.php | 74 ++++ .../tests/PropertyAccessorTestCase.php | 25 ++ .../tests/ReflectionPropertyAccessorTest.php | 401 ++++++++++++++++++ .../property-accessor/tests/Stub/BaseStub.php | 22 + .../tests/Stub/ExampleTrait.php | 17 + .../property-accessor/tests/Stub/HookStub.php | 23 + .../tests/Stub/InheritedStub.php | 17 + .../tests/Stub/ReadonlyStub.php | 15 + .../tests/Stub/StaticStub.php | 16 + .../tests/Stub/TraitConsumerStub.php | 12 + .../tests/Stub/UninitializedStub.php | 13 + .../tests/Stub/VisibilityStub.php | 27 ++ .../libs/property-accessor/tests/TestCase.php | 9 + libs/mapper/phpstan.neon | 6 + libs/mapper/phpunit.xml | 30 ++ libs/mapper/resources/config.schema.json | 117 +++++ libs/mapper/src/DenormalizerInterface.php | 38 ++ libs/mapper/src/NormalizerInterface.php | 44 ++ libs/mapper/tests/TestCase.php | 9 + libs/parser/composer.json | 4 +- libs/phpdoc/composer.json | 4 +- libs/printer/composer.json | 4 +- libs/reader/composer.json | 4 +- libs/types/composer.json | 4 +- phpstan.neon | 1 + phpunit.xml | 4 + 53 files changed, 1666 insertions(+), 27 deletions(-) create mode 100644 libs/mapper/.editorconfig create mode 100644 libs/mapper/.gitattributes create mode 100644 libs/mapper/.github/workflows/tests.yml create mode 100644 libs/mapper/.gitignore create mode 100644 libs/mapper/LICENSE create mode 100644 libs/mapper/composer.json create mode 100644 libs/mapper/libs/property-accessor/composer.json create mode 100644 libs/mapper/libs/property-accessor/phpstan.neon create mode 100644 libs/mapper/libs/property-accessor/src/Exception/MissingPropertyReadException.php create mode 100644 libs/mapper/libs/property-accessor/src/Exception/MissingPropertyWriteException.php create mode 100644 libs/mapper/libs/property-accessor/src/Exception/PropertyAccessException.php create mode 100644 libs/mapper/libs/property-accessor/src/Exception/PropertyAccessorExceptionInterface.php create mode 100644 libs/mapper/libs/property-accessor/src/Exception/PropertyAlreadyInitializedException.php create mode 100644 libs/mapper/libs/property-accessor/src/Exception/PropertyNotReadableException.php create mode 100644 libs/mapper/libs/property-accessor/src/Exception/PropertyNotWritableException.php create mode 100644 libs/mapper/libs/property-accessor/src/Exception/ReadOnlyPropertyException.php create mode 100644 libs/mapper/libs/property-accessor/src/Exception/StaticPropertyReadException.php create mode 100644 libs/mapper/libs/property-accessor/src/Exception/StaticPropertyWriteException.php create mode 100644 libs/mapper/libs/property-accessor/src/Exception/UninitializedPropertyReadException.php create mode 100644 libs/mapper/libs/property-accessor/src/Exception/WriteOnlyPropertyException.php create mode 100644 libs/mapper/libs/property-accessor/src/NullPropertyAccessor.php create mode 100644 libs/mapper/libs/property-accessor/src/PropertyAccessorInterface.php create mode 100644 libs/mapper/libs/property-accessor/src/ReflectionPropertyAccessor.php create mode 100644 libs/mapper/libs/property-accessor/tests/Exception/PropertyAccessExceptionTest.php create mode 100644 libs/mapper/libs/property-accessor/tests/NullPropertyAccessorTest.php create mode 100644 libs/mapper/libs/property-accessor/tests/PropertyAccessorTestCase.php create mode 100644 libs/mapper/libs/property-accessor/tests/ReflectionPropertyAccessorTest.php create mode 100644 libs/mapper/libs/property-accessor/tests/Stub/BaseStub.php create mode 100644 libs/mapper/libs/property-accessor/tests/Stub/ExampleTrait.php create mode 100644 libs/mapper/libs/property-accessor/tests/Stub/HookStub.php create mode 100644 libs/mapper/libs/property-accessor/tests/Stub/InheritedStub.php create mode 100644 libs/mapper/libs/property-accessor/tests/Stub/ReadonlyStub.php create mode 100644 libs/mapper/libs/property-accessor/tests/Stub/StaticStub.php create mode 100644 libs/mapper/libs/property-accessor/tests/Stub/TraitConsumerStub.php create mode 100644 libs/mapper/libs/property-accessor/tests/Stub/UninitializedStub.php create mode 100644 libs/mapper/libs/property-accessor/tests/Stub/VisibilityStub.php create mode 100644 libs/mapper/libs/property-accessor/tests/TestCase.php create mode 100644 libs/mapper/phpstan.neon create mode 100644 libs/mapper/phpunit.xml create mode 100644 libs/mapper/resources/config.schema.json create mode 100644 libs/mapper/src/DenormalizerInterface.php create mode 100644 libs/mapper/src/NormalizerInterface.php create mode 100644 libs/mapper/tests/TestCase.php diff --git a/.php-cs-fixer.php b/.php-cs-fixer.php index d95cfb6..deadf4f 100644 --- a/.php-cs-fixer.php +++ b/.php-cs-fixer.php @@ -5,9 +5,11 @@ use PhpCsFixer\Runner\Parallel\ParallelConfigFactory; const PHPCS_DIRECTORIES = [ + __DIR__ . '/libs/mapper/src', __DIR__ . '/libs/parser/src', __DIR__ . '/libs/phpdoc/src', __DIR__ . '/libs/printer/src', + __DIR__ . '/libs/property-accessor/src', __DIR__ . '/libs/reader/src', __DIR__ . '/libs/types/src', ]; diff --git a/README.md b/README.md index a5a993a..3383b9f 100644 --- a/README.md +++ b/README.md @@ -19,13 +19,14 @@ listed below is also published as a standalone, read-only repository via ## Packages -| Package | Description | Tests | -|---------------------------------------------------------------------------|--------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| [`type-lang/types`](https://packagist.org/packages/type-lang/types) | AST node classes (`TypeLang\Type\*`) — the shared vocabulary of the ecosystem. | [![Tests](https://img.shields.io/github/actions/workflow/status/php-type-language/types/tests.yml?label=Tests&style=flat-square&logo=unpkg)](https://github.com/php-type-language/types/actions/workflows/tests.yml) | -| [`type-lang/parser`](https://packagist.org/packages/type-lang/parser) | Parses TypeLang syntax into an AST, validating the grammar. | [![Tests](https://img.shields.io/github/actions/workflow/status/php-type-language/parser/tests.yml?label=Tests&style=flat-square&logo=unpkg)](https://github.com/php-type-language/parser/actions/workflows/tests.yml) | -| [`type-lang/printer`](https://packagist.org/packages/type-lang/printer) | Renders AST nodes back into their string representation. | [![Tests](https://img.shields.io/github/actions/workflow/status/php-type-language/printer/tests.yml?label=Tests&style=flat-square&logo=unpkg)](https://github.com/php-type-language/printer/actions/workflows/tests.yml) | -| [`type-lang/reader`](https://packagist.org/packages/type-lang/reader) | Builds AST nodes from types exposed by PHP Reflection objects. | [![Tests](https://img.shields.io/github/actions/workflow/status/php-type-language/reader/tests.yml?label=Tests&style=flat-square&logo=unpkg)](https://github.com/php-type-language/reader/actions/workflows/tests.yml) | -| [`type-lang/phpdoc`](https://packagist.org/packages/type-lang/phpdoc) | Parses `/** ... */` DocBlock comments into a graph of description and tags. | [![Tests](https://img.shields.io/github/actions/workflow/status/php-type-language/phpdoc/tests.yml?label=Tests&style=flat-square&logo=unpkg)](https://github.com/php-type-language/phpdoc/actions/workflows/tests.yml) | +| Package | Description | Tests | +|---------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| [`type-lang/mapper`](https://packagist.org/packages/type-lang/mapper) | Maps variable types to other types. | [![Tests](https://img.shields.io/github/actions/workflow/status/php-type-language/mapper/tests.yml?label=Tests&style=flat-square&logo=unpkg)](https://github.com/php-type-language/mapper/actions/workflows/tests.yml) | +| [`type-lang/parser`](https://packagist.org/packages/type-lang/parser) | Parses TypeLang syntax into an AST, validating the grammar. | [![Tests](https://img.shields.io/github/actions/workflow/status/php-type-language/parser/tests.yml?label=Tests&style=flat-square&logo=unpkg)](https://github.com/php-type-language/parser/actions/workflows/tests.yml) | +| [`type-lang/phpdoc`](https://packagist.org/packages/type-lang/phpdoc) | Parses `/** ... */` DocBlock comments into a graph of description and tags. | [![Tests](https://img.shields.io/github/actions/workflow/status/php-type-language/phpdoc/tests.yml?label=Tests&style=flat-square&logo=unpkg)](https://github.com/php-type-language/phpdoc/actions/workflows/tests.yml) | +| [`type-lang/printer`](https://packagist.org/packages/type-lang/printer) | Renders AST nodes back into their string representation. | [![Tests](https://img.shields.io/github/actions/workflow/status/php-type-language/printer/tests.yml?label=Tests&style=flat-square&logo=unpkg)](https://github.com/php-type-language/printer/actions/workflows/tests.yml) | +| [`type-lang/reader`](https://packagist.org/packages/type-lang/reader) | Builds AST nodes from types exposed by PHP Reflection objects. | [![Tests](https://img.shields.io/github/actions/workflow/status/php-type-language/reader/tests.yml?label=Tests&style=flat-square&logo=unpkg)](https://github.com/php-type-language/reader/actions/workflows/tests.yml) | +| [`type-lang/types`](https://packagist.org/packages/type-lang/types) | AST node classes (`TypeLang\Type\*`) — the shared vocabulary of the ecosystem. | [![Tests](https://img.shields.io/github/actions/workflow/status/php-type-language/types/tests.yml?label=Tests&style=flat-square&logo=unpkg)](https://github.com/php-type-language/types/actions/workflows/tests.yml) | ## Development diff --git a/composer.json b/composer.json index cc49d4e..b93cf20 100644 --- a/composer.json +++ b/composer.json @@ -8,6 +8,7 @@ ], "require": { "php": "^8.4", + "type-lang/mapper": "^2.0", "type-lang/parser": "^2.0", "type-lang/phpdoc": "^2.0", "type-lang/printer": "^2.0", @@ -15,20 +16,26 @@ "type-lang/types": "^2.0" }, "require-dev": { - "friendsofphp/php-cs-fixer": "^3.95.13", + "doctrine/instantiator": "^2.0", + "friendsofphp/php-cs-fixer": "^3.95", "jetbrains/phpstorm-attributes": "^1.2", - "phpbench/phpbench": "^1.7.0", - "phpdocumentor/reflection-docblock": "^6.0.3", - "phplrt/compiler": "^3.7.5", - "phpstan/phpdoc-parser": "^2.3.3", - "phpstan/phpstan": "^2.2.5", - "phpstan/phpstan-deprecation-rules": "^2.0.4", - "phpstan/phpstan-strict-rules": "^2.0.11", - "phpunit/phpunit": "^13.2.4", - "symfony/var-dumper": "^7.4|^8.0" + "justinrainbow/json-schema": "^6.10", + "nette/neon": "^3.4", + "phpbench/phpbench": "^1.7", + "phpdocumentor/reflection-docblock": "^6.0", + "phplrt/compiler": "^3.7", + "phpstan/phpdoc-parser": "^2.3", + "phpstan/phpstan": "^2.2", + "phpstan/phpstan-deprecation-rules": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^13.2", + "symfony/var-dumper": "^7.4|^8.0", + "symfony/yaml": "^8.1" }, "autoload-dev": { "psr-4": { + "TypeLang\\Mapper\\Tests\\": "libs/mapper/tests", + "TypeLang\\PropertyAccessor\\Tests\\": "libs/mapper/libs/property-accessor/tests", "TypeLang\\Parser\\Tests\\": "libs/parser/tests", "TypeLang\\Printer\\Tests\\": "libs/printer/tests", "TypeLang\\PhpDoc\\Tests\\": "libs/phpdoc/tests", diff --git a/libs/mapper/.editorconfig b/libs/mapper/.editorconfig new file mode 100644 index 0000000..dd7983d --- /dev/null +++ b/libs/mapper/.editorconfig @@ -0,0 +1,15 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 4 +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false + +[*.{yml,yaml,neon}] +indent_size = 2 diff --git a/libs/mapper/.gitattributes b/libs/mapper/.gitattributes new file mode 100644 index 0000000..cfa4eea --- /dev/null +++ b/libs/mapper/.gitattributes @@ -0,0 +1,18 @@ +* text=auto eol=lf +*.pp eol=lf linguist-language=EBNF +*.pp2 eol=lf linguist-language=EBNF + +# GIT +.editorconfig export-ignore +.gitattributes export-ignore +.gitignore export-ignore + +# Tools +.php-cs-fixer.php export-ignore +phpstan.neon export-ignore + +# Tests + CI +phpunit.xml export-ignore + +/.github export-ignore +/tests export-ignore diff --git a/libs/mapper/.github/workflows/tests.yml b/libs/mapper/.github/workflows/tests.yml new file mode 100644 index 0000000..a7df15d --- /dev/null +++ b/libs/mapper/.github/workflows/tests.yml @@ -0,0 +1,37 @@ +name: Tests + +on: + push: + pull_request: + +jobs: + tests: + name: Tests (${{matrix.php}}, ${{ matrix.os }}, ${{ matrix.stability }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + php: [ '8.4', '8.5' ] + os: [ ubuntu-latest, macos-latest, windows-latest ] + stability: [ lowest, stable ] + steps: + - name: Checkout + uses: actions/checkout@v7 + - name: Set Git To Use LF + run: | + git config --global core.autocrlf false + git config --global core.eol lf + - name: Setup PHP ${{ matrix.php }} + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + ini-values: "memory_limit=-1" + - name: Install Dependencies + uses: nick-invision/retry@v4 + with: + timeout_minutes: 5 + max_attempts: 3 + command: composer update --prefer-${{ matrix.stability }} --prefer-dist --no-interaction --no-progress + # Action + - name: Execute Tests + run: composer test diff --git a/libs/mapper/.gitignore b/libs/mapper/.gitignore new file mode 100644 index 0000000..f5fffcf --- /dev/null +++ b/libs/mapper/.gitignore @@ -0,0 +1,9 @@ +# IDE +/.idea/ + +# Composer +/composer.lock +/vendor/ + +# Testing +test.php diff --git a/libs/mapper/LICENSE b/libs/mapper/LICENSE new file mode 100644 index 0000000..4602ec1 --- /dev/null +++ b/libs/mapper/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) Nesmeyanov Kirill + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/libs/mapper/composer.json b/libs/mapper/composer.json new file mode 100644 index 0000000..d9fd9e5 --- /dev/null +++ b/libs/mapper/composer.json @@ -0,0 +1,67 @@ +{ + "name": "type-lang/mapper", + "type": "library", + "description": "Library for mapping variable types to other types", + "keywords": ["types", "serializer", "mapper", "hydrator", "transformer", "normalizer", "denormalizer", "marshal", "unmarshal"], + "license": "MIT", + "support": { + "source": "https://github.com/php-type-language/mapper", + "issues": "https://github.com/php-type-language/mapper/issues" + }, + "require": { + "php": "^8.4", + "type-lang/parser": "^2.0" + }, + "autoload": { + "psr-4": { + "TypeLang\\Mapper\\": "src", + "TypeLang\\PropertyAccessor\\": "libs/property-accessor/src" + } + }, + "require-dev": { + "doctrine/instantiator": "^2.0", + "jetbrains/phpstorm-attributes": "^1.2", + "justinrainbow/json-schema": "^6.0", + "nette/neon": "^3.4", + "phpstan/phpstan": "^2.2", + "phpunit/phpunit": "^13.2", + "symfony/yaml": "^7.4|^8.0" + }, + "autoload-dev": { + "psr-4": { + "TypeLang\\Mapper\\Tests\\": "tests", + "TypeLang\\PropertyAccessor\\Tests\\": "libs/property-accessor/tests" + } + }, + "suggest": { + "ext-json": "Required for JSON mapping configuration files", + "doctrine/instantiator": "(^2.0) A faster way to create class instances", + "nette/neon": "(^3.0) Required for NEON mapping configuration files", + "symfony/yaml": "(^7.4|^8.0) Required for YAML mapping configuration files", + "type-lang/phpdoc": "(^2.0) Required for PhpDoc mapping configuration", + "justinrainbow/json-schema": "(^6.0) Required for file-based configuration validation" + }, + "extra": { + "branch-alias": { + "dev-master": "2.x-dev", + "dev-main": "2.x-dev" + } + }, + "config": { + "sort-packages": true, + "platform-check": true, + "bin-compat": "full", + "optimize-autoloader": true, + "preferred-install": { + "*": "dist" + } + }, + "scripts-aliases": { + "test:unit": ["test", "phpunit", "unit"] + }, + "scripts": { + "test:unit": "phpunit --testdox" + }, + "minimum-stability": "dev", + "prefer-stable": true +} diff --git a/libs/mapper/libs/property-accessor/composer.json b/libs/mapper/libs/property-accessor/composer.json new file mode 100644 index 0000000..f99c672 --- /dev/null +++ b/libs/mapper/libs/property-accessor/composer.json @@ -0,0 +1,47 @@ +{ + "name": "local/property-accessor", + "type": "library", + "description": "Library for property availability and read/write operations", + "keywords": ["property", "index", "access", "extraction", "injection", "reflection"], + "license": "MIT", + "require": { + "php": "^8.4" + }, + "autoload": { + "psr-4": { + "TypeLang\\PropertyAccessor\\": "src" + } + }, + "require-dev": { + "phpstan/phpstan": "^2.2", + "phpunit/phpunit": "^13.2" + }, + "autoload-dev": { + "psr-4": { + "TypeLang\\PropertyAccessor\\Tests\\": "tests" + } + }, + "extra": { + "branch-alias": { + "dev-master": "2.x-dev", + "dev-main": "2.x-dev" + } + }, + "config": { + "sort-packages": true, + "platform-check": true, + "bin-compat": "full", + "optimize-autoloader": true, + "preferred-install": { + "*": "dist" + } + }, + "scripts-aliases": { + "test:unit": ["test", "phpunit", "unit"] + }, + "scripts": { + "test:unit": "phpunit --testdox" + }, + "minimum-stability": "dev", + "prefer-stable": true +} diff --git a/libs/mapper/libs/property-accessor/phpstan.neon b/libs/mapper/libs/property-accessor/phpstan.neon new file mode 100644 index 0000000..018e46e --- /dev/null +++ b/libs/mapper/libs/property-accessor/phpstan.neon @@ -0,0 +1,6 @@ +parameters: + level: max + fileExtensions: + - php + paths: + - src diff --git a/libs/mapper/libs/property-accessor/src/Exception/MissingPropertyReadException.php b/libs/mapper/libs/property-accessor/src/Exception/MissingPropertyReadException.php new file mode 100644 index 0000000..8dc0c80 --- /dev/null +++ b/libs/mapper/libs/property-accessor/src/Exception/MissingPropertyReadException.php @@ -0,0 +1,15 @@ +isStatic()) { + throw StaticPropertyReadException::becausePropertyIsStatic($object, $property); + } + + $isVirtual = $reflection->isVirtual(); + + if ($isVirtual) { + // A virtual property has no backing store: it can only be read + // through its get hook. + if ($reflection->getHook(\PropertyHookType::Get) === null) { + throw WriteOnlyPropertyException::becausePropertyIsWriteOnly($object, $property); + } + } elseif (!$reflection->isInitialized($object)) { + // A non-virtual property is read straight from the backing store, + // which must therefore be initialized. + throw UninitializedPropertyReadException::becausePropertyIsNotInitialized($object, $property); + } + + try { + // Non-virtual properties are read from the backing store, bypassing + // any get hook; virtual ones can only be read through the hook. + return $isVirtual + ? $reflection->getValue($object) + : $reflection->getRawValue($object); + } catch (\Throwable $e) { + throw PropertyNotReadableException::becausePropertyIsNotReadable($object, $property, $e); + } + } + + public function isReadable(object $object, string $property): bool + { + try { + $reflection = new \ReflectionProperty($object, $property); + } catch (\ReflectionException) { + return false; + } + + return match (true) { + $reflection->isStatic() => false, + $reflection->isVirtual() => $reflection->getHook(\PropertyHookType::Get) !== null, + default => $reflection->isInitialized($object), + }; + } + + /** + * @throws PropertyNotWritableException + */ + public function setValue(object $object, string $property, mixed $value): void + { + try { + $reflection = new \ReflectionProperty($object, $property); + } catch (\ReflectionException $e) { + throw MissingPropertyWriteException::becausePropertyDoesNotExist($object, $property, $e); + } + + if ($reflection->isStatic()) { + throw StaticPropertyWriteException::becausePropertyIsStatic($object, $property); + } + + // A readonly property may be initialized only once. + if ($reflection->isReadOnly() && $reflection->isInitialized($object)) { + throw PropertyAlreadyInitializedException::becausePropertyIsAlreadyInitialized($object, $property); + } + + $isVirtual = $reflection->isVirtual(); + + // A virtual property has no backing store: it can only be written + // through its set hook. + if ($isVirtual && $reflection->getHook(\PropertyHookType::Set) === null) { + throw ReadOnlyPropertyException::becausePropertyIsReadOnly($object, $property); + } + + try { + if ($isVirtual) { + $reflection->setValue($object, $value); + + return; + } + + // A non-virtual property is written straight to the backing store, + // bypassing any set hook and without triggering lazy-initialization + // of a ghost/proxy object. + $reflection->setRawValueWithoutLazyInitialization($object, $value); + } catch (\Throwable $e) { + throw PropertyNotWritableException::becausePropertyIsNotWritable($object, $property, $e); + } + } + + public function isWritable(object $object, string $property): bool + { + try { + $reflection = new \ReflectionProperty($object, $property); + } catch (\ReflectionException) { + return false; + } + + return match (true) { + $reflection->isStatic() => false, + $reflection->isVirtual() => $reflection->getHook(\PropertyHookType::Set) !== null, + $reflection->isReadOnly() => !$reflection->isInitialized($object), + default => true, + }; + } +} diff --git a/libs/mapper/libs/property-accessor/tests/Exception/PropertyAccessExceptionTest.php b/libs/mapper/libs/property-accessor/tests/Exception/PropertyAccessExceptionTest.php new file mode 100644 index 0000000..599164c --- /dev/null +++ b/libs/mapper/libs/property-accessor/tests/Exception/PropertyAccessExceptionTest.php @@ -0,0 +1,81 @@ +object); + self::assertSame('publicProperty', $error->property); + self::assertSame( + \sprintf('The %s::$publicProperty property is not readable', VisibilityStub::class), + $error->getMessage(), + ); + } + + public function testNotWritableFactoryCapturesContext(): void + { + $object = new VisibilityStub(); + + $error = PropertyNotWritableException::becausePropertyIsNotWritable($object, 'publicProperty'); + + self::assertSame($object, $error->object); + self::assertSame('publicProperty', $error->property); + self::assertSame( + \sprintf('The %s::$publicProperty property is not writable', VisibilityStub::class), + $error->getMessage(), + ); + } + + public function testNotReadablePreservesPreviousThrowable(): void + { + $previous = new \RuntimeException('root cause'); + + $error = PropertyNotReadableException::becausePropertyIsNotReadable( + new VisibilityStub(), + 'publicProperty', + $previous, + ); + + self::assertSame($previous, $error->getPrevious()); + } + + public function testNotWritablePreservesPreviousThrowable(): void + { + $previous = new \RuntimeException('root cause'); + + $error = PropertyNotWritableException::becausePropertyIsNotWritable( + new VisibilityStub(), + 'publicProperty', + $previous, + ); + + self::assertSame($previous, $error->getPrevious()); + } + + public function testExceptionsImplementPackageInterface(): void + { + $readable = PropertyNotReadableException::becausePropertyIsNotReadable(new VisibilityStub(), 'p'); + $writable = PropertyNotWritableException::becausePropertyIsNotWritable(new VisibilityStub(), 'p'); + + self::assertInstanceOf(PropertyAccessorExceptionInterface::class, $readable); + self::assertInstanceOf(PropertyAccessorExceptionInterface::class, $writable); + self::assertInstanceOf(PropertyAccessException::class, $readable); + self::assertInstanceOf(PropertyAccessException::class, $writable); + self::assertInstanceOf(\LogicException::class, $readable); + } +} diff --git a/libs/mapper/libs/property-accessor/tests/NullPropertyAccessorTest.php b/libs/mapper/libs/property-accessor/tests/NullPropertyAccessorTest.php new file mode 100644 index 0000000..5e18dac --- /dev/null +++ b/libs/mapper/libs/property-accessor/tests/NullPropertyAccessorTest.php @@ -0,0 +1,74 @@ + $this->accessor ??= new NullPropertyAccessor(); + } + + /** + * @return iterable + */ + public static function propertyNameProvider(): iterable + { + yield 'public property' => ['publicProperty']; + yield 'missing property' => ['doesNotExist']; + } + + #[DataProvider('propertyNameProvider')] + public function testIsReadableAlwaysReturnsFalse(string $property): void + { + self::assertFalse($this->accessor->isReadable(new VisibilityStub(), $property)); + } + + #[DataProvider('propertyNameProvider')] + public function testIsWritableAlwaysReturnsFalse(string $property): void + { + self::assertFalse($this->accessor->isWritable(new VisibilityStub(), $property)); + } + + public function testGetValueThrowsNotReadable(): void + { + $object = new VisibilityStub(); + + $error = self::captureThrowable(static fn(): mixed => new NullPropertyAccessor() + ->getValue($object, 'publicProperty')); + + self::assertInstanceOf(PropertyNotReadableException::class, $error); + self::assertSame($object, $error->object); + self::assertSame('publicProperty', $error->property); + self::assertStringContainsString('not readable', $error->getMessage()); + } + + public function testSetValueThrowsNotWritable(): void + { + $object = new VisibilityStub(); + + $error = self::captureThrowable(static fn(): mixed => new NullPropertyAccessor() + ->setValue($object, 'publicProperty', 'x')); + + self::assertInstanceOf(PropertyNotWritableException::class, $error); + self::assertSame($object, $error->object); + self::assertSame('publicProperty', $error->property); + self::assertStringContainsString('not writable', $error->getMessage()); + } + + public function testGetValueDoesNotMutateReadableState(): void + { + $object = new VisibilityStub(); + + self::captureThrowable(fn(): mixed => $this->accessor->getValue($object, 'publicProperty')); + + self::assertFalse($this->accessor->isReadable($object, 'publicProperty')); + } +} diff --git a/libs/mapper/libs/property-accessor/tests/PropertyAccessorTestCase.php b/libs/mapper/libs/property-accessor/tests/PropertyAccessorTestCase.php new file mode 100644 index 0000000..761f2b8 --- /dev/null +++ b/libs/mapper/libs/property-accessor/tests/PropertyAccessorTestCase.php @@ -0,0 +1,25 @@ + $this->accessor ??= new ReflectionPropertyAccessor(); + } + + // --------------------------------------------------------------------- + // Access modifiers (public / protected / private, instance properties) + // --------------------------------------------------------------------- + + /** + * @return iterable + */ + public static function visibilityProvider(): iterable + { + yield 'public' => ['publicProperty', 'public-value']; + yield 'protected' => ['protectedProperty', 'protected-value']; + yield 'private' => ['privateProperty', 'private-value']; + } + + #[DataProvider('visibilityProvider')] + public function testReadsPropertyOfAnyVisibility(string $property, string $expected): void + { + self::assertSame($expected, $this->accessor->getValue(new VisibilityStub(), $property)); + } + + #[DataProvider('visibilityProvider')] + public function testPropertyOfAnyVisibilityIsReadable(string $property, string $expected): void + { + self::assertTrue($this->accessor->isReadable(new VisibilityStub(), $property)); + } + + #[DataProvider('visibilityProvider')] + public function testPropertyOfAnyVisibilityIsWritable(string $property, string $expected): void + { + self::assertTrue($this->accessor->isWritable(new VisibilityStub(), $property)); + } + + public function testWritesPublicProperty(): void + { + $object = new VisibilityStub(); + + $this->accessor->setValue($object, 'publicProperty', 'changed'); + + self::assertSame('changed', $object->publicProperty); + } + + public function testWritesProtectedProperty(): void + { + $object = new VisibilityStub(); + + $this->accessor->setValue($object, 'protectedProperty', 'changed'); + + self::assertSame('changed', $object->getProtectedProperty()); + } + + public function testWritesPrivateProperty(): void + { + $object = new VisibilityStub(); + + $this->accessor->setValue($object, 'privateProperty', 'changed'); + + self::assertSame('changed', $object->getPrivateProperty()); + } + + // --------------------------------------------------------------------- + // Static properties (expected to be inaccessible via an instance accessor) + // --------------------------------------------------------------------- + + /** + * @return iterable + */ + public static function staticPropertyProvider(): iterable + { + yield 'public static' => ['publicStatic']; + yield 'protected static' => ['protectedStatic']; + yield 'private static' => ['privateStatic']; + } + + #[DataProvider('staticPropertyProvider')] + public function testStaticPropertyIsNotReadable(string $property): void + { + self::assertFalse($this->accessor->isReadable(new StaticStub(), $property)); + } + + #[DataProvider('staticPropertyProvider')] + public function testStaticPropertyIsNotWritable(string $property): void + { + self::assertFalse($this->accessor->isWritable(new StaticStub(), $property)); + } + + #[DataProvider('staticPropertyProvider')] + public function testGetValueOnStaticPropertyThrows(string $property): void + { + $this->expectException(StaticPropertyReadException::class); + + $this->accessor->getValue(new StaticStub(), $property); + } + + #[DataProvider('staticPropertyProvider')] + public function testSetValueOnStaticPropertyThrows(string $property): void + { + $this->expectException(StaticPropertyWriteException::class); + + $this->accessor->setValue(new StaticStub(), $property, 'x'); + } + + public function testInstancePropertyNextToStaticsRemainsAccessible(): void + { + $object = new StaticStub(); + + self::assertTrue($this->accessor->isReadable($object, 'instanceProperty')); + self::assertSame('instance-value', $this->accessor->getValue($object, 'instanceProperty')); + } + + // --------------------------------------------------------------------- + // Readonly properties + // --------------------------------------------------------------------- + + public function testReadonlyPropertyIsReadable(): void + { + self::assertTrue($this->accessor->isReadable(new ReadonlyStub(), 'readonlyProperty')); + } + + public function testReadsReadonlyValue(): void + { + $actual = $this->accessor->getValue(new ReadonlyStub(), 'readonlyProperty'); + + self::assertSame('readonly-value', $actual); + } + + public function testInitializedReadonlyPropertyIsNotWritable(): void + { + self::assertFalse($this->accessor->isWritable(new ReadonlyStub(), 'readonlyProperty')); + } + + public function testWritingInitializedReadonlyPropertyThrowsNotWritable(): void + { + $this->expectException(PropertyAlreadyInitializedException::class); + + $this->accessor->setValue(new ReadonlyStub(), 'readonlyProperty', 'other'); + } + + public function testUninitializedReadonlyPropertyIsWritable(): void + { + $object = new \ReflectionClass(ReadonlyStub::class) + ->newInstanceWithoutConstructor(); + + self::assertTrue($this->accessor->isWritable($object, 'readonlyProperty')); + } + + public function testUninitializedReadonlyPropertyCanBeWrittenOnce(): void + { + $object = new \ReflectionClass(ReadonlyStub::class) + ->newInstanceWithoutConstructor(); + + $this->accessor->setValue($object, 'readonlyProperty', 'assigned'); + + self::assertSame('assigned', $this->accessor->getValue($object, 'readonlyProperty')); + + $error = self::captureThrowable(fn(): mixed + => $this->accessor->setValue($object, 'readonlyProperty', 'again')); + + self::assertInstanceOf(PropertyAlreadyInitializedException::class, $error); + } + + // --------------------------------------------------------------------- + // Property hooks (PHP 8.4) + // --------------------------------------------------------------------- + + public function testVirtualGetOnlyPropertyIsReadable(): void + { + self::assertTrue($this->accessor->isReadable(new HookStub(), 'virtualReadOnly')); + } + + public function testReadsVirtualGetOnlyProperty(): void + { + self::assertSame('computed', $this->accessor->getValue(new HookStub(), 'virtualReadOnly')); + } + + public function testVirtualGetOnlyPropertyIsNotWritable(): void + { + self::assertFalse($this->accessor->isWritable(new HookStub(), 'virtualReadOnly')); + } + + public function testWritingVirtualGetOnlyPropertyThrowsNotWritable(): void + { + $this->expectException(ReadOnlyPropertyException::class); + + $this->accessor->setValue(new HookStub(), 'virtualReadOnly', 'x'); + } + + public function testHookedPropertyIsReadableAndWritable(): void + { + $object = new HookStub(); + + self::assertTrue($this->accessor->isReadable($object, 'hooked')); + self::assertTrue($this->accessor->isWritable($object, 'hooked')); + } + + public function testGetValueReadsRawValueBypassingGetHook(): void + { + // The get hook would return 'via-get-hook'; raw access reads the backing store. + self::assertSame('raw-init', $this->accessor->getValue(new HookStub(), 'hooked')); + } + + public function testSetValueWritesRawValueBypassingSetHook(): void + { + $object = new HookStub(); + + $this->accessor->setValue($object, 'hooked', 'written'); + + // The set hook would store 'via-set-hook:written'; raw access stores the value verbatim. + self::assertSame('written', new \ReflectionProperty($object, 'hooked')->getRawValue($object)); + // Reading back through the accessor likewise bypasses the get hook. + self::assertSame('written', $this->accessor->getValue($object, 'hooked')); + } + + // --------------------------------------------------------------------- + // Inheritance / scope (parent, child, parent-private) + // --------------------------------------------------------------------- + + public function testReadsInheritedPublicProperty(): void + { + self::assertSame('base-public', $this->accessor->getValue(new InheritedStub(), 'basePublic')); + } + + public function testReadsInheritedProtectedProperty(): void + { + self::assertSame('base-protected', $this->accessor->getValue(new InheritedStub(), 'baseProtected')); + } + + /** + * A private property belongs to the scope of the class that declares it, so + * it is not visible by name from a derived class. The accessor must treat it + * as inaccessible through a child instance (as PHP itself does), rather than + * walking the hierarchy to reach it. + */ + public function testParentPrivatePropertyIsNotReadableThroughChildInstance(): void + { + self::assertFalse($this->accessor->isReadable(new InheritedStub(), 'basePrivate')); + } + + public function testGetValueOnParentPrivatePropertyThroughChildInstanceThrows(): void + { + $this->expectException(MissingPropertyReadException::class); + + $this->accessor->getValue(new InheritedStub(), 'basePrivate'); + } + + public function testReadsChildOwnProperty(): void + { + self::assertSame('child-public', $this->accessor->getValue(new InheritedStub(), 'childPublic')); + self::assertSame('child-private', $this->accessor->getValue(new InheritedStub(), 'childPrivate')); + } + + public function testWritesInheritedProperty(): void + { + $object = new InheritedStub(); + + $this->accessor->setValue($object, 'basePublic', 'updated'); + + self::assertSame('updated', $object->basePublic); + } + + public function testParentPrivatePropertyIsNotWritableThroughChildInstance(): void + { + self::assertFalse($this->accessor->isWritable(new InheritedStub(), 'basePrivate')); + } + + public function testSetValueOnParentPrivatePropertyThroughChildInstanceThrows(): void + { + $this->expectException(MissingPropertyWriteException::class); + + $this->accessor->setValue(new InheritedStub(), 'basePrivate', 'updated'); + } + + public function testReadsPropertyOnParentInstanceDirectly(): void + { + self::assertSame('base-private', $this->accessor + ->getValue(new BaseStub(), 'basePrivate')); + } + + // --------------------------------------------------------------------- + // Trait properties + // --------------------------------------------------------------------- + + public function testReadsPublicTraitProperty(): void + { + self::assertSame('trait-public', $this->accessor + ->getValue(new TraitConsumerStub(), 'traitPublic')); + } + + public function testReadsPrivateTraitProperty(): void + { + self::assertSame('trait-private', $this->accessor + ->getValue(new TraitConsumerStub(), 'traitPrivate')); + } + + public function testWritesTraitProperty(): void + { + $object = new TraitConsumerStub(); + + $this->accessor->setValue($object, 'traitPrivate', 'updated'); + + self::assertSame('updated', $object->getTraitPrivate()); + } + + // --------------------------------------------------------------------- + // Uninitialized typed property + // --------------------------------------------------------------------- + + public function testUninitializedPropertyIsNotReadable(): void + { + self::assertFalse($this->accessor->isReadable(new UninitializedStub(), 'uninitialized')); + } + + public function testGetValueOnUninitializedPropertyThrowsNotReadable(): void + { + $this->expectException(UninitializedPropertyReadException::class); + + $this->accessor->getValue(new UninitializedStub(), 'uninitialized'); + } + + public function testUninitializedPropertyIsWritable(): void + { + self::assertTrue($this->accessor->isWritable(new UninitializedStub(), 'uninitialized')); + } + + public function testWritingUninitializedPropertyMakesItReadable(): void + { + $object = new UninitializedStub(); + + $this->accessor->setValue($object, 'uninitialized', 'now-set'); + + self::assertTrue($this->accessor->isReadable($object, 'uninitialized')); + self::assertSame('now-set', $this->accessor->getValue($object, 'uninitialized')); + } + + // --------------------------------------------------------------------- + // Missing property + // --------------------------------------------------------------------- + + public function testMissingPropertyIsNotReadable(): void + { + self::assertFalse($this->accessor->isReadable(new VisibilityStub(), 'doesNotExist')); + } + + public function testMissingPropertyIsNotWritable(): void + { + self::assertFalse($this->accessor->isWritable(new VisibilityStub(), 'doesNotExist')); + } + + public function testGetValueOnMissingPropertyThrowsNotReadable(): void + { + $object = new VisibilityStub(); + + $error = self::captureThrowable(fn(): mixed + => $this->accessor->getValue($object, 'doesNotExist')); + + self::assertInstanceOf(MissingPropertyReadException::class, $error); + self::assertSame($object, $error->object); + self::assertSame('doesNotExist', $error->property); + } + + public function testSetValueOnMissingPropertyThrowsNotWritable(): void + { + $object = new VisibilityStub(); + + $error = self::captureThrowable( fn(): mixed + => $this->accessor->setValue($object, 'doesNotExist', 'x')); + + self::assertInstanceOf(MissingPropertyWriteException::class, $error); + self::assertSame($object, $error->object); + self::assertSame('doesNotExist', $error->property); + } +} diff --git a/libs/mapper/libs/property-accessor/tests/Stub/BaseStub.php b/libs/mapper/libs/property-accessor/tests/Stub/BaseStub.php new file mode 100644 index 0000000..4b2c54c --- /dev/null +++ b/libs/mapper/libs/property-accessor/tests/Stub/BaseStub.php @@ -0,0 +1,22 @@ +basePrivate; + } +} diff --git a/libs/mapper/libs/property-accessor/tests/Stub/ExampleTrait.php b/libs/mapper/libs/property-accessor/tests/Stub/ExampleTrait.php new file mode 100644 index 0000000..d649f0a --- /dev/null +++ b/libs/mapper/libs/property-accessor/tests/Stub/ExampleTrait.php @@ -0,0 +1,17 @@ +traitPrivate; + } +} diff --git a/libs/mapper/libs/property-accessor/tests/Stub/HookStub.php b/libs/mapper/libs/property-accessor/tests/Stub/HookStub.php new file mode 100644 index 0000000..9c89c7f --- /dev/null +++ b/libs/mapper/libs/property-accessor/tests/Stub/HookStub.php @@ -0,0 +1,23 @@ + 'computed'; + } + + /** + * Non-virtual property with hooks over its own backing store. + */ + public string $hooked = 'raw-init' { + get => 'via-get-hook'; + set (string $value) => $this->hooked = 'via-set-hook:' . $value; + } +} diff --git a/libs/mapper/libs/property-accessor/tests/Stub/InheritedStub.php b/libs/mapper/libs/property-accessor/tests/Stub/InheritedStub.php new file mode 100644 index 0000000..d439d66 --- /dev/null +++ b/libs/mapper/libs/property-accessor/tests/Stub/InheritedStub.php @@ -0,0 +1,17 @@ +childPrivate; + } +} diff --git a/libs/mapper/libs/property-accessor/tests/Stub/ReadonlyStub.php b/libs/mapper/libs/property-accessor/tests/Stub/ReadonlyStub.php new file mode 100644 index 0000000..5c9737c --- /dev/null +++ b/libs/mapper/libs/property-accessor/tests/Stub/ReadonlyStub.php @@ -0,0 +1,15 @@ +readonlyProperty = $value; + } +} diff --git a/libs/mapper/libs/property-accessor/tests/Stub/StaticStub.php b/libs/mapper/libs/property-accessor/tests/Stub/StaticStub.php new file mode 100644 index 0000000..6b0ae5e --- /dev/null +++ b/libs/mapper/libs/property-accessor/tests/Stub/StaticStub.php @@ -0,0 +1,16 @@ +privateProperty; + } + + public function getProtectedProperty(): string + { + return $this->protectedProperty; + } +} diff --git a/libs/mapper/libs/property-accessor/tests/TestCase.php b/libs/mapper/libs/property-accessor/tests/TestCase.php new file mode 100644 index 0000000..06e0690 --- /dev/null +++ b/libs/mapper/libs/property-accessor/tests/TestCase.php @@ -0,0 +1,9 @@ + + + + + tests + + + + + + + + src + + + + + + + + diff --git a/libs/mapper/resources/config.schema.json b/libs/mapper/resources/config.schema.json new file mode 100644 index 0000000..3eb3967 --- /dev/null +++ b/libs/mapper/resources/config.schema.json @@ -0,0 +1,117 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/php-type-language/mapper/refs/heads/master/resources/config.schema.json", + "title": "Config", + "type": "object", + "description": "A mapper configuration schema", + "additionalProperties": false, + "properties": { + "normalize_as_array": { + "type": "boolean", + "description": "Responsible for enabling or disabling the conversion to an associative array" + }, + "discriminator": { + "type": "object", + "description": "Configuration of discriminator map", + "additionalProperties": false, + "required": ["field", "map"], + "properties": { + "field": { + "type": "string", + "description": "The name of the field with which the discriminator map values should be compared", + "minLength": 1 + }, + "map": { + "type": "object", + "description": "List of values for discriminator map. Each key must contain a value for the configured field", + "minProperties": 1, + "patternProperties": { + "^.+?$": { + "type": "string", + "description": "Concrete type for discriminator map in case of key matches discriminator field", + "minLength": 1 + } + }, + "additionalProperties": false + }, + "otherwise": { + "type": "string", + "description": "An optional type in case the field's value is not found in the discriminator map", + "minLength": 1 + } + } + }, + "properties": { + "type": "object", + "description": "List of properties for the specified class. Each key must contain the actual name of the class property", + "patternProperties": { + "^[a-zA-Z_\\x80-\\xff][a-zA-Z0-9_\\x80-\\xff]*$": { + "anyOf": [ + { + "type": "object", + "description": "Configuration of a specific class property", + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "description": "Public name of the property", + "minLength": 1 + }, + "type": { + "type": "string", + "description": "Specific property type", + "minLength": 1 + }, + "write": { + "type": "string", + "description": "Specific property write type", + "minLength": 1 + }, + "strict": { + "type": "boolean", + "description": "Determines how strictly the type should be checked" + }, + "type_error_message": { + "type": "string", + "minLength": 1, + "description": "Custom error message on property type error" + }, + "undefined_error_message": { + "type": "string", + "minLength": 1, + "description": "Custom error message on property value missing error" + }, + "skip": { + "description": "Rules according to which a property should be excluded during normalization", + "anyOf": [ + { + "type": "string", + "description": "A specific rule to exclude: Can contain 'null' (if a field should be excluded when it contains null), 'empty' (if a field should be excluded when it empty), or an expression (if a complex expression is required for exclusion)", + "minLength": 1 + }, + { + "type": "array", + "description": "List of multiple rules to exclude. Only one of the rules is taken into account (logical disjunction)", + "minItems": 1, + "items": { + "type": "string", + "description": "A specific rule to exclude: Can contain 'null' (if a field should be excluded when it contains null), 'empty' (if a field should be excluded when it empty), or an expression (if a complex expression is required for exclusion)", + "minLength": 1 + } + } + ] + } + } + }, + { + "type": "string", + "description": "Specific property type", + "minLength": 1 + } + ] + } + }, + "additionalProperties": false + } + } +} diff --git a/libs/mapper/src/DenormalizerInterface.php b/libs/mapper/src/DenormalizerInterface.php new file mode 100644 index 0000000..d0aea2e --- /dev/null +++ b/libs/mapper/src/DenormalizerInterface.php @@ -0,0 +1,38 @@ +denormalize(\json_decode('{ + * "id": 42, + * "name": "Kirill" + * }'), ExampleClass::class); + * ``` + * + * @param non-empty-string $type + * + * @throws \Throwable in case of any internal error occurs + */ + public function denormalize(mixed $value, #[Language('PHP')] string $type): mixed; + + /** + * Returns {@see true} if the value can be denormalized for the given type. + * + * @param non-empty-string $type + * + * @throws \Throwable in case of any internal error occurs + */ + public function isDenormalizable(mixed $value, #[Language('PHP')] string $type): bool; +} diff --git a/libs/mapper/src/NormalizerInterface.php b/libs/mapper/src/NormalizerInterface.php new file mode 100644 index 0000000..dfe24cb --- /dev/null +++ b/libs/mapper/src/NormalizerInterface.php @@ -0,0 +1,44 @@ +normalize(new ExampleClass( + * id: 42, + * name: 'Kirill', + * )); + * ``` + * + * @param non-empty-string|null $type + * + * @throws \Throwable in case of any internal error occurs + */ + public function normalize(mixed $value, #[Language('PHP')] ?string $type = null): mixed; + + /** + * Returns {@see true} if the value can be normalized for the given type. + * + * In case that the type is specified as {@see null}, it is automatically + * inferred from the passed value. + * + * @param non-empty-string|null $type + * + * @throws \Throwable in case of any internal error occurs + */ + public function isNormalizable(mixed $value, #[Language('PHP')] ?string $type = null): bool; +} diff --git a/libs/mapper/tests/TestCase.php b/libs/mapper/tests/TestCase.php new file mode 100644 index 0000000..2674c73 --- /dev/null +++ b/libs/mapper/tests/TestCase.php @@ -0,0 +1,9 @@ + + libs/mapper/tests + libs/mapper/libs/property-accessor/tests libs/parser/tests libs/phpdoc/tests libs/printer/tests @@ -23,6 +25,8 @@ + libs/mapper/src + libs/mapper/libs/property-accessor/src libs/parser/src libs/phpdoc/src libs/printer/src