From 0dd6cb9ba1e1054ba287b3bb43f684d0ba96bc82 Mon Sep 17 00:00:00 2001 From: BowlOfSoup Date: Tue, 2 Dec 2025 12:58:25 +0100 Subject: [PATCH 1/3] ISSUE-62: Upgrade to PHP 8.4, fully tested, improved pipeline, support for PHP 8 attributes. --- .github/workflows/ci.yaml | 17 +- .gitignore | 1 + .php-cs-fixer.php | 13 +- AGENTS.md | 326 ++++++++++++++++++ CLAUDE.md | 204 +---------- LICENSE | 4 +- composer.json | 26 +- rector.php | 2 +- src/Annotation/AbstractAnnotation.php | 29 +- src/Annotation/Normalize.php | 86 +++-- src/Annotation/Serialize.php | 59 +++- src/Annotation/Translate.php | 57 ++- src/BowlOfSoupNormalizerBundle.php | 3 + .../BowlOfSoupNormalizerExtension.php | 2 +- .../RegisterAnnotationsListener.php | 11 +- src/Exception/BosNormalizerException.php | 4 +- src/Exception/BosSerializerException.php | 4 +- src/Model/ObjectBag.php | 22 +- src/Model/ObjectCache.php | 54 +-- src/Model/Store.php | 17 +- src/Service/Encoder/AbstractEncoder.php | 3 +- src/Service/Encoder/EncoderFactory.php | 22 +- src/Service/Encoder/EncoderInterface.php | 5 +- src/Service/Encoder/EncoderJson.php | 16 +- src/Service/Encoder/EncoderXml.php | 12 +- src/Service/Extractor/AnnotationExtractor.php | 53 ++- src/Service/Extractor/ClassExtractor.php | 7 +- src/Service/Extractor/MethodExtractor.php | 8 +- src/Service/Extractor/PropertyExtractor.php | 23 +- src/Service/Normalize/AbstractNormalizer.php | 88 ++--- src/Service/Normalize/MethodNormalizer.php | 43 +-- src/Service/Normalize/PropertyNormalizer.php | 38 +- src/Service/Normalizer.php | 32 +- src/Service/ObjectHelper.php | 21 +- src/Service/Serializer.php | 38 +- tests/Annotation/NormalizeTest.php | 57 +++ tests/Annotation/SerializeTest.php | 47 +++ tests/Annotation/TranslateTest.php | 93 +++++ tests/ArraySubset.php | 61 +--- tests/NormalizerTestTrait.php | 31 +- tests/SerializerTestTrait.php | 3 - tests/Service/Encoder/EncoderFactoryTest.php | 2 + tests/Service/Encoder/EncoderJsonTest.php | 3 +- tests/Service/Encoder/EncoderXmlTest.php | 7 +- .../Extractor/AnnotationExtractorTest.php | 82 +++-- .../Service/Extractor/ClassExtractorTest.php | 2 +- .../Service/Extractor/MethodExtractorTest.php | 12 +- .../Extractor/PropertyExtractorTest.php | 18 +- tests/Service/NormalizerTest.php | 11 +- tests/Service/ObjectHelperTest.php | 2 +- tests/Service/SerializerTest.php | 254 +++++++++++++- tests/Service/UnknownPropertyTest.php | 7 +- tests/assets/AbstractClass.php | 5 +- tests/assets/AbstractPerson.php | 4 +- tests/assets/Address.php | 67 +--- tests/assets/AddressWithAttributes.php | 101 ++++++ tests/assets/BrokenAttributeClass.php | 31 ++ tests/assets/BrokenAttributeMethod.php | 34 ++ tests/assets/BrokenAttributeProperty.php | 37 ++ tests/assets/Group.php | 34 +- tests/assets/Hobbies.php | 32 +- tests/assets/HobbyType.php | 28 +- tests/assets/MixedAnnotations.php | 92 +++++ tests/assets/OrderWithAttributes.php | 166 +++++++++ tests/assets/Person.php | 194 +++-------- tests/assets/PersonWithAttributes.php | 117 +++++++ tests/assets/ProductWithAttributes.php | 126 +++++++ tests/assets/ProxyObject.php | 24 +- tests/assets/ProxySocial.php | 7 +- tests/assets/ProxySocialNotInitialized.php | 2 +- tests/assets/Social.php | 79 ++--- tests/assets/SomeClass.php | 15 +- tests/assets/TelephoneNumbers.php | 38 +- .../assets/UnknownPropertyNormalizeMethod.php | 3 +- .../UnknownPropertyNormalizeProperty.php | 4 +- tests/assets/UnknownPropertySerialize.php | 4 +- tests/assets/UnknownPropertyTranslate.php | 4 +- 77 files changed, 2129 insertions(+), 1161 deletions(-) create mode 100644 AGENTS.md mode change 100644 => 120000 CLAUDE.md create mode 100644 tests/Annotation/TranslateTest.php create mode 100644 tests/assets/AddressWithAttributes.php create mode 100644 tests/assets/BrokenAttributeClass.php create mode 100644 tests/assets/BrokenAttributeMethod.php create mode 100644 tests/assets/BrokenAttributeProperty.php create mode 100644 tests/assets/MixedAnnotations.php create mode 100644 tests/assets/OrderWithAttributes.php create mode 100644 tests/assets/PersonWithAttributes.php create mode 100644 tests/assets/ProductWithAttributes.php diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 44d8c5a..c1d21dc 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -11,11 +11,8 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - php-version: [7.4, 8.2] - include: - - php-version: 8.2 - env: - SYMFONY_VERSION: ~5.4 + php-version: [8.4] + symfony-version: ['~5.4', '~6.4', '~7.4'] fail-fast: false steps: @@ -38,14 +35,20 @@ jobs: - name: Install dependencies run: | - if [ "${{ matrix.env.SYMFONY_VERSION }}" != "" ]; then composer require "symfony/symfony:${{ matrix.env.SYMFONY_VERSION }}" --no-update; fi; - COMPOSER_MEMORY_LIMIT=-1 composer install + rm -rf vendor composer.lock + composer require "symfony/symfony:${{ matrix.symfony-version }}" --no-update + COMPOSER_MEMORY_LIMIT=-1 composer install --prefer-dist --no-progress - name: Setup Codecov run: | curl -Os https://uploader.codecov.io/latest/linux/codecov chmod +x codecov + - name: Run PHP CS Fixer + env: + PHP_CS_FIXER_TARGET_BRANCH: master + run: vendor/bin/php-cs-fixer fix --dry-run --diff --verbose --using-cache=no + - name: Run Rector run: vendor/bin/rector process --dry-run --no-progress-bar --ansi diff --git a/.gitignore b/.gitignore index c984fef..8417e4d 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ /build/ *.cache /.idea +/.claude/ /var/** !/var/**/ !/var/**/.gitkeep diff --git a/.php-cs-fixer.php b/.php-cs-fixer.php index a52d786..19c3b35 100644 --- a/.php-cs-fixer.php +++ b/.php-cs-fixer.php @@ -107,14 +107,19 @@ private function initFilesFromStdin(): void private function initFilesFromGit(): void { $this->input = 'Git'; - $branch = $this->pipedExec('git rev-parse --abbrev-ref HEAD 2>/dev/null'); - echo sprintf('What is the destination branch of %s [master]: ', $branch); - $destinationBranch = trim(fgets(STDIN)) ?: 'master'; + + // Get destination branch from environment variable (required) + $destinationBranch = getenv('PHP_CS_FIXER_TARGET_BRANCH'); + + if ($destinationBranch === false || $destinationBranch === '') { + $destinationBranch = 'master'; + } + $branchExists = $this->pipedExec(sprintf('git branch --remotes 2>/dev/null | grep --extended-regexp "^(\*| ) origin/%s( |$)" 2>/dev/null', $destinationBranch)); if ($branchExists === false) { die(sprintf("fatal: Couldn't find remote ref %s", $destinationBranch) . PHP_EOL); } - $this->pipedExec(sprintf('(git diff origin/%s.. --name-only --diff-filter=ACMRTUXB 2>/dev/null; git diff --cached --name-only --diff-filter=ACMRTUXB 2>/dev/null) | grep "\.php$" 2>/dev/null | sort 2>/dev/null | uniq 2>/dev/null', $destinationBranch), $this->files); + $this->pipedExec(sprintf('(git diff origin/%s.. --name-only --diff-filter=ACMRTUXB 2>/dev/null; git diff --cached --name-only --diff-filter=ACMRTUXB 2>/dev/null; git diff HEAD --name-only --diff-filter=ACMRTUXB 2>/dev/null) | grep "\.php$" 2>/dev/null | sort 2>/dev/null | uniq 2>/dev/null', $destinationBranch), $this->files); $repositoryRoot = $this->pipedExec('git rev-parse --show-toplevel 2>/dev/null'); chdir($repositoryRoot); } diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..af814c2 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,326 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Bowl Of Soup Normalizer is a Symfony bundle that provides annotation-based normalization and serialization of objects. It uses an opt-in mechanism where properties/methods must be explicitly marked for normalization using annotations or PHP 8 attributes. + +**Key Features:** +- Normalizes class properties and methods (public, protected, private) via annotations or PHP 8 attributes +- Supports both docblock annotations and native PHP 8 attributes (can be mixed in same codebase) +- Handles Doctrine proxy objects and circular references +- Object caching via `getId()` method to avoid re-normalizing same objects +- Annotation caching (per normalize command and permanent in prod mode) +- Supports Symfony translations with locale and domain configuration +- Context groups for different normalization scenarios + +## Development Commands + +### Running Tests Directly + +```bash +# Run all tests +vendor/bin/phpunit + +# Run tests with coverage (requires Xdebug) +XDEBUG_MODE=coverage php -dzend_extension=xdebug.so vendor/bin/phpunit +# Coverage output: tests/coverage/ + +# Run specific test file +vendor/bin/phpunit tests/Service/NormalizerTest.php +``` + +### Code Quality Tools + +```bash +# Static analysis (level 3) +vendor/bin/phpstan + +# Code style fixing +vendor/bin/php-cs-fixer fix + +# Automated refactoring (dry-run recommended first) +vendor/bin/rector process --dry-run --no-progress-bar --ansi + +# Apply rector changes +vendor/bin/rector process +``` + +### Running GitHub Actions Locally with act + +You can run the complete CI pipeline locally using [act](https://nektosact.com). +If act isn't installed, try to install it (on macOS via homebrew). + +```bash +# Run CI with PHP 8.4 +act -j build --matrix php-version:8.4 + +# Run all PHP versions (8.4) +act -j build + +# List available jobs +act --list +``` + +## Architecture + +### Core Components + +**Normalizer** (`src/Service/Normalizer.php`) +- Entry point for normalization operations +- Handles both single objects and collections +- Manages ObjectCache for circular reference detection +- Delegates to PropertyNormalizer and MethodNormalizer + +**Serializer** (`src/Service/Serializer.php`) +- Wraps Normalizer with encoding capabilities (JSON, XML) +- Uses EncoderFactory to create encoders +- Supports sorting via `@Serialize` annotation + +### Annotation System + +The bundle supports both docblock annotations and PHP 8 attributes. Both syntaxes can be used interchangeably and even mixed within the same codebase. + +Three main annotations/attributes in `src/Annotation/`: + +**@Normalize / #[Normalize]** - Properties/methods to include in normalization +- `name`: Output key name +- `group`: Array of context groups +- `type`: Special handling (collection, datetime, object) +- `format`: Date format for datetime types +- `callback`: Method to call for value transformation +- `normalizeCallbackResult`: Whether to normalize callback return value +- `skipEmpty`: Skip if value is empty +- `maxDepth`: Limit object nesting depth + +**@Serialize / #[Serialize]** - Class-level serialization configuration +- `wrapElement`: Wraps output in a root element with this name +- `sortProperties`: Sort output keys alphabetically +- `group`: Context group for serialization + +**@Translate / #[Translate]** - Translate values using Symfony translator +- `locale`: Translation locale +- `domain`: Translation domain (filename) +- `group`: Context group for translation + +#### Docblock Annotation Syntax + +```php +use BowlOfSoup\NormalizerBundle\Annotation as Bos; + +/** + * @Bos\Serialize(wrapElement="person", group={"api"}) + */ +class Person +{ + /** + * @Bos\Normalize(name="full_name", group={"api", "admin"}) + * @Bos\Translate(group={"api"}, domain="messages") + */ + private ?string $name = null; + + /** + * @Bos\Normalize(group={"admin"}, skipEmpty=true) + */ + private ?string $email = null; + + /** + * @Bos\Normalize(group={"api"}, type="DateTime", format="Y-m-d") + */ + public function getCreatedAt(): ?\DateTime + { + return $this->createdAt; + } +} +``` + +#### PHP 8 Attribute Syntax + +```php +use BowlOfSoup\NormalizerBundle\Annotation as Bos; + +#[Bos\Serialize(wrapElement: 'person', group: ['api'])] +class Person +{ + #[Bos\Normalize(name: 'full_name', group: ['api', 'admin'])] + #[Bos\Translate(group: ['api'], domain: 'messages')] + private ?string $name = null; + + #[Bos\Normalize(group: ['admin'], skipEmpty: true)] + private ?string $email = null; + + #[Bos\Normalize(group: ['api'], type: 'DateTime', format: 'Y-m-d')] + public function getCreatedAt(): ?\DateTime + { + return $this->createdAt; + } +} +``` + +#### Mixed Syntax (Docblock + Attributes) + +Both syntaxes can coexist in the same codebase and even on the same element: + +```php +/** + * @Bos\Serialize(wrapElement="data", group={"legacy"}) + */ +#[Bos\Serialize(wrapElement: 'product', group: ['api'])] +class Product +{ + /** + * @Bos\Normalize(group={"legacy"}) + */ + private ?int $id = null; + + #[Bos\Normalize(group: ['api'], name: 'product_name')] + private ?string $name = null; +} +``` + +**Important: When both exist on the same element, BOTH are processed:** +- PHP 8 attributes are read first, then docblock annotations +- If they define the **same output name**, the value will be overwritten (last processed wins - docblock) +- If they define **different names**, both will appear in the output +- **Best practice**: Avoid mixing both on the same element to prevent confusion and duplicate output + +**Example of duplicate output:** +```php +/** + * @Bos\Normalize(group={"api"}, name="docblock_name") + */ +#[Bos\Normalize(group: ['api'], name: 'attribute_name')] +private ?string $field = 'value'; + +// Output will contain BOTH: +// {"docblock_name": "value", "attribute_name": "value"} +``` + +### Extractors + +Located in `src/Service/Extractor/`: +- `AnnotationExtractor`: Parses both docblock annotations (via Doctrine) and PHP 8 attributes (via Reflection) from classes/properties/methods +- `PropertyExtractor`: Extracts property metadata +- `MethodExtractor`: Extracts method metadata +- `ClassExtractor`: Coordinates extraction process + +**How Annotation Reading Works:** +1. PHP 8 attributes are read first using native reflection (`ReflectionClass::getAttributes()`, etc.) +2. Docblock annotations are read second using Doctrine's `AnnotationReader` +3. Both are merged into a single array and all are processed +4. If duplicate annotations define different output names, both will appear in the final output +5. If duplicate annotations define the same output name, the last one processed wins (docblock overwrites attribute) + +### Normalizers + +Located in `src/Service/Normalize/`: +- `PropertyNormalizer`: Normalizes object properties +- `MethodNormalizer`: Normalizes method return values +- Both extend `AbstractNormalizer` which handles type-specific normalization logic + +### Encoders + +Located in `src/Service/Encoder/`: +- `EncoderJson`: JSON encoding +- `EncoderXml`: XML encoding +- `EncoderFactory`: Creates encoder instances by type string + +## Important Patterns + +### Context Groups + +Annotations use `group` parameter to control normalization context. Properties/methods are only included when their group matches the context. + +**Docblock syntax:** +```php +/** + * @Bos\Normalize(name="email", group={"api", "admin"}) + */ +private ?string $email = null; + +/** + * @Bos\Normalize(name="internalId", group={"admin"}) + */ +private ?int $internalId = null; +``` + +**PHP 8 attribute syntax:** +```php +#[Bos\Normalize(name: 'email', group: ['api', 'admin'])] +private ?string $email = null; + +#[Bos\Normalize(name: 'internalId', group: ['admin'])] +private ?int $internalId = null; +``` + +When normalizing with group `'api'`, only `$email` is included. When normalizing with group `'admin'`, both are included. + +### Circular Reference Handling +Objects implementing `getId()` are cached and reused. If a circular reference is detected, the object's ID value is returned instead of re-normalizing. + +### Doctrine Proxy Support +The bundle handles Doctrine proxy objects by extracting the real class name before processing. + +### Additional PHP Coding Standards + +#### General PHP standards + +- `declare(strict_types=1);` MUST be declared at the top for *new* PHP files +- Short array notation MUST be used +- Use Monolog for all logging operations (Psr\Log\LoggerInterface) +- Declare statements MUST be terminated by a semicolon +- Classes from the global namespace MUST NOT be imported (but prefixed with \) +- Import statements MUST be alphabetized +- The PHP features "parameter type widening" and "contravariant argument types" MUST NOT be used +- Boolean operators between conditions MUST always be at the beginning of the line +- The concatenation operator MUST be preceded and followed by one space +- Do not use YODA conditions +- All boolean API property names MUST be prepended with "is", "has" or "should" + +#### PHPDoc + +- There MUST NOT be an @author, @version or @copyright tag +- Extended type information SHOULD be used when possible (PHPStan array types) +- Arguments MUST be documented as relaxed as possible, while return values MUST be documented as precise as possible +- The @param tag MUST be omitted when the argument is properly type-hinted +- The @return tag MUST be omitted when the method or function has a proper return type-hint +- An entire docblock MUST be omitted in case it does not add "any value" over the method name, argument types and return type +- Constants MUST NOT be documented using the @var tag + +#### PHPUnit + +- We use Mockery for mocking, so all tests that use Mockery MUST extend Mockery\Adapter\Phpunit\MockeryTestCase +- Unit tests MUST not use the `@testdox` tag, but use a descriptive human-readable test method name instead +- Data providers MUST be used when possible and applicable +- All unit test class members MUST be unset in the `tearDown()` method + +## Configuration Files + +- `phpunit.xml`: Test configuration, excludes DI/EventListener/Exception/Model from coverage +- `.php-cs-fixer.php`: Custom finder supporting Git diff, STDIN, or CLI input; PSR-2/Symfony standards +- `rector.php`: Configured for PHP 8.4 and Symfony 5.4 +- `phpstan.neon.dist`: Level 3 analysis, Symfony extension enabled + +## Testing + +Tests use fixtures in `tests/assets/` directory: +- Traditional docblock annotations: `Person`, `Address`, `Social`, etc. +- PHP 8 attributes: `PersonWithAttributes`, `AddressWithAttributes`, `ProductWithAttributes`, `OrderWithAttributes` +- Mixed annotations: `MixedAnnotations` + +The `NormalizerTestTrait` provides common test setup for service instantiation. + +Test structure mirrors source: `tests/Service/`, `tests/Annotation/`, etc. + +### Test Coverage for PHP 8 Attributes + +The test suite includes comprehensive scenarios for PHP 8 attributes: +- Basic property and method normalization +- Translation with `#[Translate]` +- Collections and nested objects +- Context groups (api vs internal vs admin) +- Skip empty fields with `skipEmpty: true` +- Computed method values +- DateTime formatting +- Mixed docblock and attribute annotations diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 0791d4f..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,203 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## Project Overview - -Bowl Of Soup Normalizer is a Symfony bundle that provides annotation-based normalization and serialization of objects. It uses an opt-in mechanism where properties/methods must be explicitly marked for normalization using annotations. - -**Key Features:** -- Normalizes class properties and methods (public, protected, private) via annotations -- Handles Doctrine proxy objects and circular references -- Object caching via `getId()` method to avoid re-normalizing same objects -- Annotation caching (per normalize command and permanent in prod mode) -- Supports Symfony translations with locale and domain configuration -- Context groups for different normalization scenarios - -## Development Commands - -### Running Tests Directly - -```bash -# Run all tests -vendor/bin/phpunit - -# Run tests with coverage (requires Xdebug) -XDEBUG_MODE=coverage php -dzend_extension=xdebug.so vendor/bin/phpunit -# Coverage output: tests/coverage/ - -# Run specific test file -vendor/bin/phpunit tests/Service/NormalizerTest.php -``` - -### Code Quality Tools - -```bash -# Static analysis (level 3) -vendor/bin/phpstan - -# Code style fixing -vendor/bin/php-cs-fixer fix - -# Automated refactoring (dry-run recommended first) -vendor/bin/rector process --dry-run --no-progress-bar --ansi - -# Apply rector changes -vendor/bin/rector process -``` - -### Running GitHub Actions Locally with act - -You can run the complete CI pipeline locally using [act](https://nektosact.com). -If act isn't installed, try to install it (on macOS via homebrew). - -```bash -# Run CI with specific PHP version -act -j build --matrix php-version:7.4 - -# Run CI with PHP 8.2 -act -j build --matrix php-version:8.2 - -# Run all PHP versions (7.2, 7.4, 8.2) -act -j build - -# List available jobs -act --list -``` - -**Codecov Token**: To upload coverage to Codecov, create a `.env.local` file in the project root (copy from `.env`): -```bash -cp .env .env.local -# Edit .env.local and add your token: -CODECOV_TOKEN=your-codecov-token-here -``` -The `.env.local` file is gitignored and will be automatically picked up by act. The `.env` file is committed as a template. - -**Note**: The first run will be slow as it downloads the container image and sets up PHP. Subsequent runs are much faster due to container reuse; configure this! - -**Configuration**: act MUST be configured to: -- Use `catthehacker/ubuntu:act-latest` as the base image (medium size) -- Reuse containers between runs for faster execution -- Use linux/amd64 architecture (required for M-series Macs) -- Read environment variables from `.env.local` only (`.env` is just a template) - -## Architecture - -### Core Components - -**Normalizer** (`src/Service/Normalizer.php`) -- Entry point for normalization operations -- Handles both single objects and collections -- Manages ObjectCache for circular reference detection -- Delegates to PropertyNormalizer and MethodNormalizer - -**Serializer** (`src/Service/Serializer.php`) -- Wraps Normalizer with encoding capabilities (JSON, XML) -- Uses EncoderFactory to create encoders -- Supports sorting via `@Serialize` annotation - -### Annotation System - -Three main annotations in `src/Annotation/`: - -**@Normalize** - Properties/methods to include in normalization -- `name`: Output key name -- `group`: Array of context groups -- `type`: Special handling (collection, datetime, object) -- `format`: Date format for datetime types -- `callback`: Method to call for value transformation -- `normalizeCallbackResult`: Whether to normalize callback return value -- `skipEmpty`: Skip if value is empty -- `maxDepth`: Limit object nesting depth - -**@Serialize** - Class-level serialization configuration -- `sortProperties`: Sort output keys alphabetically -- `group`: Context group for serialization - -**@Translate** - Translate values using Symfony translator -- `locale`: Translation locale -- `domain`: Translation domain (filename) - -### Extractors - -Located in `src/Service/Extractor/`: -- `AnnotationExtractor`: Parses annotations from classes/properties/methods -- `PropertyExtractor`: Extracts property metadata -- `MethodExtractor`: Extracts method metadata -- `ClassExtractor`: Coordinates extraction process - -### Normalizers - -Located in `src/Service/Normalize/`: -- `PropertyNormalizer`: Normalizes object properties -- `MethodNormalizer`: Normalizes method return values -- Both extend `AbstractNormalizer` which handles type-specific normalization logic - -### Encoders - -Located in `src/Service/Encoder/`: -- `EncoderJson`: JSON encoding -- `EncoderXml`: XML encoding -- `EncoderFactory`: Creates encoder instances by type string - -## Important Patterns - -### Context Groups -Annotations use `group` parameter to control normalization context: -```php -@Normalize(name="email", group={"api", "admin"}) -@Normalize(name="internalId", group={"admin"}) -``` -When normalizing, pass group to include only matching annotations. - -### Circular Reference Handling -Objects implementing `getId()` are cached and reused. If a circular reference is detected, the object's ID value is returned instead of re-normalizing. - -### Doctrine Proxy Support -The bundle handles Doctrine proxy objects by extracting the real class name before processing. - -### Additional PHP Coding Standards - -#### General PHP standards - -- `declare(strict_types=1);` MUST be declared at the top for *new* PHP files -- Short array notation MUST be used -- Use Monolog for all logging operations (Psr\Log\LoggerInterface) -- Declare statements MUST be terminated by a semicolon -- Classes from the global namespace MUST NOT be imported (but prefixed with \) -- Import statements MUST be alphabetized -- The PHP features "parameter type widening" and "contravariant argument types" MUST NOT be used -- Boolean operators between conditions MUST always be at the beginning of the line -- The concatenation operator MUST be preceded and followed by one space -- Do not use YODA conditions -- All boolean API property names MUST be prepended with "is", "has" or "should" - -#### PHPDoc - -- There MUST NOT be an @author, @version or @copyright tag -- Extended type information SHOULD be used when possible (PHPStan array types) -- Arguments MUST be documented as relaxed as possible, while return values MUST be documented as precise as possible -- The @param tag MUST be omitted when the argument is properly type-hinted -- The @return tag MUST be omitted when the method or function has a proper return type-hint -- An entire docblock MUST be omitted in case it does not add "any value" over the method name, argument types and return type -- Constants MUST NOT be documented using the @var tag - -#### PHPUnit - -- We use Mockery for mocking, so all tests that use Mockery MUST extend Mockery\Adapter\Phpunit\MockeryTestCase -- Unit tests MUST not use the `@testdox` tag, but use a descriptive human-readable test method name instead -- Data providers MUST be used when possible and applicable -- All unit test class members MUST be unset in the `tearDown()` method - -## Configuration Files - -- `phpunit.xml`: Test configuration, excludes DI/EventListener/Exception/Model from coverage -- `.php-cs-fixer.php`: Custom finder supporting Git diff, STDIN, or CLI input; PSR-2/Symfony standards -- `rector.php`: Configured for PHP 7.2+ and Symfony 5.4 -- `phpstan.neon.dist`: Level 3 analysis, Symfony extension enabled - -## Testing - -Tests use fixtures in `tests/assets/` directory (Person, Address, Social, etc.). The `NormalizerTestTrait` provides common test setup for service instantiation. - -Test structure mirrors source: `tests/Service/`, `tests/Annotation/`, etc. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/LICENSE b/LICENSE index 1fd9f67..5de095c 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2016-2020 BowlOfSoup +Copyright (c) 2016-2025 BowlOfSoup Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -14,7 +14,7 @@ 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 NONINFINGEMENT. IN NO EVENT SHALL THE +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 diff --git a/composer.json b/composer.json index 754ad94..0be2690 100644 --- a/composer.json +++ b/composer.json @@ -15,20 +15,20 @@ "ext-simplexml": "*", "ext-libxml": "*", "ext-dom": "*", - "doctrine/annotations": "^1.13.0", - "php": ">=7.2", - "symfony/framework-bundle": "~5.4", - "symfony/translation": "~5.4", - "symfony/cache": "~5.4" + "doctrine/annotations": "~1.14", + "php": ">=8.4", + "symfony/framework-bundle": "~5.4|~6.4|~7.4", + "symfony/translation": "~5.4|~6.4|~7.4", + "symfony/cache": "~5.4|~6.4|~7.4" }, "require-dev": { - "doctrine/common": "~3.0", - "doctrine/collections": "1.*", - "phpunit/phpunit": "^8.0", - "friendsofphp/php-cs-fixer": "^3.0", - "rector/rector": "^1.0.0", - "phpstan/phpstan": "^1.10", - "phpstan/phpstan-symfony": "^1.3" + "doctrine/common": "~3.5", + "doctrine/collections": "~1.8", + "phpunit/phpunit": "~9.6", + "friendsofphp/php-cs-fixer": "~3.0", + "rector/rector": "~2.1", + "phpstan/phpstan": "~2.1", + "phpstan/phpstan-symfony": "~2.0" }, "autoload": { "psr-4": { "BowlOfSoup\\NormalizerBundle\\": "src/" }, @@ -46,7 +46,7 @@ }, "symfony": { "allow-contrib": false, - "require": "5.4.*" + "require": "~5.4|~6.4|~7.4" } } } diff --git a/rector.php b/rector.php index d1f4505..70d66f9 100644 --- a/rector.php +++ b/rector.php @@ -19,7 +19,7 @@ ]); $rectorConfig->sets([ - LevelSetList::UP_TO_PHP_72, + LevelSetList::UP_TO_PHP_84, ]); $rectorConfig->importNames(true, false); diff --git a/src/Annotation/AbstractAnnotation.php b/src/Annotation/AbstractAnnotation.php index 9e807cf..9d459a7 100644 --- a/src/Annotation/AbstractAnnotation.php +++ b/src/Annotation/AbstractAnnotation.php @@ -6,13 +6,12 @@ abstract class AbstractAnnotation { - protected const EXCEPTION_EMPTY = 'Parameter "%s" of annotation "%s" cannot be empty.'; - protected const EXCEPTION_TYPE = 'Wrong datatype used for property "%s" for annotation "%s"'; - protected const EXCEPTION_TYPE_SUPPORTED = 'Type "%s" of annotation "%s" is not supported.'; - protected const EXCEPTION_UNKNOWN_PROPERTY = 'Property "%s" of annotation "%s" is unknown.'; + protected const string EXCEPTION_EMPTY = 'Parameter "%s" of annotation "%s" cannot be empty.'; + protected const string EXCEPTION_TYPE = 'Wrong datatype used for property "%s" for annotation "%s"'; + protected const string EXCEPTION_TYPE_SUPPORTED = 'Type "%s" of annotation "%s" is not supported.'; + protected const string EXCEPTION_UNKNOWN_PROPERTY = 'Property "%s" of annotation "%s" is unknown.'; - /** @var array */ - protected $group = []; + protected array $group = []; public function getGroup(): array { @@ -26,13 +25,10 @@ public function isGroupValidForConstruct(?string $group): bool { $annotationGroup = $this->getGroup(); - return (empty($group) || in_array($group, $annotationGroup, false)) && (!empty($group) || empty($annotationGroup)); + return (empty($group) || in_array($group, $annotationGroup)) && (!empty($group) || empty($annotationGroup)); } - /** - * @param mixed $property - */ - protected function validateProperties($property, string $propertyName, array $propertyOptions, string $annotation): bool + protected function validateProperties(mixed $property, string $propertyName, array $propertyOptions, string $annotation): bool { if ($this->isEmpty($property)) { throw new \InvalidArgumentException(sprintf(static::EXCEPTION_EMPTY, $propertyName, $annotation)); @@ -53,19 +49,12 @@ protected function validateProperties($property, string $propertyName, array $pr return true; } - /** - * @param mixed $property - */ - private function isEmpty($property): bool + private function isEmpty(mixed $property): bool { return 0 !== $property && empty($property) && false !== $property; } - /** - * @param mixed $type - * @param mixed $property - */ - private function hasCorrectType($type, $property): bool + private function hasCorrectType(mixed $type, mixed $property): bool { return $type === gettype($property); } diff --git a/src/Annotation/Normalize.php b/src/Annotation/Normalize.php index eb7727f..3570fa5 100644 --- a/src/Annotation/Normalize.php +++ b/src/Annotation/Normalize.php @@ -4,17 +4,20 @@ namespace BowlOfSoup\NormalizerBundle\Annotation; +use Attribute; + /** * Register normalization properties. * * @Annotation - * * @Target({"CLASS","PROPERTY","METHOD"}) */ +#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_PROPERTY | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)] class Normalize extends AbstractAnnotation { - /** @var array */ - private $supportedProperties = [ + protected ?string $type = null; + + private array $supportedProperties = [ 'name' => ['type' => 'string'], 'group' => ['type' => 'array'], 'type' => ['type' => 'string', 'assert' => ['collection', 'datetime', 'object']], @@ -25,35 +28,72 @@ class Normalize extends AbstractAnnotation 'maxDepth' => ['type' => 'integer'], ]; - /** @var string|null */ - private $name = null; - - /** @var string|null */ - private $format = null; - - /** @var string|null */ - private $callback = null; - - /** @var bool */ - private $normalizeCallbackResult = false; + private ?string $name = null; + private ?string $format = null; + private ?string $callback = null; + private bool $normalizeCallbackResult = false; + private bool $skipEmpty = false; + private ?int $maxDepth = null; + + public function __construct( + array|string|null $name = null, + array|string|null $group = null, + ?string $type = null, + ?string $format = null, + ?string $callback = null, + ?bool $normalizeCallbackResult = null, + ?bool $skipEmpty = null, + ?int $maxDepth = null, + ) { + // Support old array-based initialization (for Doctrine annotations) + if (is_array($name) && null === $group && 1 === func_num_args()) { + $properties = $name; + foreach ($properties as $propertyName => $propertyValue) { + if (!array_key_exists($propertyName, $this->supportedProperties)) { + throw new \InvalidArgumentException(sprintf(static::EXCEPTION_UNKNOWN_PROPERTY, $propertyName, self::class)); + } + + if ($this->validateProperties($propertyValue, $propertyName, $this->supportedProperties[$propertyName], self::class)) { + $this->$propertyName = $propertyValue; + } + } - /** @var bool */ - private $skipEmpty = false; + return; + } - /** @var int|null */ - private $maxDepth = null; + // Support PHP 8 attribute named parameters + $groupValue = $this->group; + if (is_array($group)) { + $groupValue = $group; + } elseif (is_string($group)) { + $groupValue = [$group]; + } - /** @var string|null */ - protected $type = null; + $properties = [ + 'name' => is_string($name) ? $name : null, + 'group' => $groupValue, + 'type' => $type, + 'format' => $format, + 'callback' => $callback, + 'normalizeCallbackResult' => $normalizeCallbackResult ?? false, + 'skipEmpty' => $skipEmpty ?? false, + 'maxDepth' => $maxDepth, + ]; - public function __construct(array $properties) - { foreach ($properties as $propertyName => $propertyValue) { + if (null === $propertyValue && 'name' !== $propertyName && 'format' !== $propertyName && 'callback' !== $propertyName && 'maxDepth' !== $propertyName && 'type' !== $propertyName) { + // @codeCoverageIgnoreStart + continue; + // @codeCoverageIgnoreEnd + } + if (!array_key_exists($propertyName, $this->supportedProperties)) { + // @codeCoverageIgnoreStart throw new \InvalidArgumentException(sprintf(static::EXCEPTION_UNKNOWN_PROPERTY, $propertyName, self::class)); + // @codeCoverageIgnoreEnd } - if ($this->validateProperties($propertyValue, $propertyName, $this->supportedProperties[$propertyName], self::class)) { + if (null !== $propertyValue && $this->validateProperties($propertyValue, $propertyName, $this->supportedProperties[$propertyName], self::class)) { $this->$propertyName = $propertyValue; } } diff --git a/src/Annotation/Serialize.php b/src/Annotation/Serialize.php index cfeaf1d..f7dc366 100644 --- a/src/Annotation/Serialize.php +++ b/src/Annotation/Serialize.php @@ -4,36 +4,75 @@ namespace BowlOfSoup\NormalizerBundle\Annotation; +use Attribute; + /** * Register serialization, encoding properties. * * @Annotation - * * @Target({"CLASS"}) */ +#[Attribute(Attribute::TARGET_CLASS | Attribute::IS_REPEATABLE)] class Serialize extends AbstractAnnotation { - /** @var array|array[] */ - private $supportedProperties = [ + private array $supportedProperties = [ 'group' => ['type' => 'array'], 'wrapElement' => ['type' => 'string'], 'sortProperties' => ['type' => 'boolean'], ]; - /** @var string|null */ - private $wrapElement = null; + private ?string $wrapElement = null; + private bool $sortProperties = false; - /** @var bool */ - private $sortProperties = false; + public function __construct( + array|string|null $wrapElement = null, + array|string|null $group = null, + ?bool $sortProperties = null, + ) { + // Support old array-based initialization (for Doctrine annotations) + if (is_array($wrapElement) && null === $group && 1 === func_num_args()) { + $properties = $wrapElement; + foreach ($properties as $propertyName => $propertyValue) { + if (!array_key_exists($propertyName, $this->supportedProperties)) { + throw new \InvalidArgumentException(sprintf(static::EXCEPTION_UNKNOWN_PROPERTY, $propertyName, self::class)); + } + + if ($this->validateProperties($propertyValue, $propertyName, $this->supportedProperties[$propertyName], self::class)) { + $this->$propertyName = $propertyValue; + } + } + + return; + } + + // Support PHP 8 attribute named parameters + $groupValue = $this->group; + if (is_array($group)) { + $groupValue = $group; + } elseif (is_string($group)) { + $groupValue = [$group]; + } + + $properties = [ + 'wrapElement' => is_string($wrapElement) ? $wrapElement : null, + 'group' => $groupValue, + 'sortProperties' => $sortProperties ?? false, + ]; - public function __construct(array $properties) - { foreach ($properties as $propertyName => $propertyValue) { + if (null === $propertyValue && 'wrapElement' !== $propertyName) { + // @codeCoverageIgnoreStart + continue; + // @codeCoverageIgnoreEnd + } + if (!array_key_exists($propertyName, $this->supportedProperties)) { + // @codeCoverageIgnoreStart throw new \InvalidArgumentException(sprintf(static::EXCEPTION_UNKNOWN_PROPERTY, $propertyName, self::class)); + // @codeCoverageIgnoreEnd } - if ($this->validateProperties($propertyValue, $propertyName, $this->supportedProperties[$propertyName], self::class)) { + if (null !== $propertyValue && $this->validateProperties($propertyValue, $propertyName, $this->supportedProperties[$propertyName], self::class)) { $this->$propertyName = $propertyValue; } } diff --git a/src/Annotation/Translate.php b/src/Annotation/Translate.php index 53e7ba0..8d88ee9 100644 --- a/src/Annotation/Translate.php +++ b/src/Annotation/Translate.php @@ -4,31 +4,70 @@ namespace BowlOfSoup\NormalizerBundle\Annotation; +use Attribute; + /** * @Annotation - * * @Target({"PROPERTY","METHOD"}) */ +#[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)] class Translate extends AbstractAnnotation { - /** @var array|array[] */ - private $supportedProperties = [ + private array $supportedProperties = [ 'group' => ['type' => 'array'], 'domain' => ['type' => 'string'], 'locale' => ['type' => 'string'], ]; - /** @var string|null */ - private $domain = null; + private ?string $domain = null; + private ?string $locale = null; - /** @var null */ - private $locale = null; + public function __construct( + array|string|null $domain = null, + array|string|null $group = null, + ?string $locale = null, + ) { + // Support old array-based initialization (for Doctrine annotations) + if (is_array($domain) && null === $group && 1 === func_num_args()) { + $properties = $domain; + foreach ($properties as $propertyName => $propertyValue) { + if (!array_key_exists($propertyName, $this->supportedProperties)) { + throw new \InvalidArgumentException(sprintf(static::EXCEPTION_UNKNOWN_PROPERTY, $propertyName, self::class)); + } + + if ($this->validateProperties($propertyValue, $propertyName, $this->supportedProperties[$propertyName], self::class)) { + $this->$propertyName = $propertyValue; + } + } + + return; + } + + // Support PHP 8 attribute named parameters + $groupValue = $this->group; + if (is_array($group)) { + $groupValue = $group; + } elseif (is_string($group)) { + $groupValue = [$group]; + } + + $properties = [ + 'domain' => is_string($domain) ? $domain : null, + 'group' => $groupValue, + 'locale' => $locale, + ]; - public function __construct(array $properties) - { foreach ($properties as $propertyName => $propertyValue) { + if (null === $propertyValue) { + // @codeCoverageIgnoreStart + continue; + // @codeCoverageIgnoreEnd + } + if (!array_key_exists($propertyName, $this->supportedProperties)) { + // @codeCoverageIgnoreStart throw new \InvalidArgumentException(sprintf(static::EXCEPTION_UNKNOWN_PROPERTY, $propertyName, self::class)); + // @codeCoverageIgnoreEnd } if ($this->validateProperties($propertyValue, $propertyName, $this->supportedProperties[$propertyName], self::class)) { diff --git a/src/BowlOfSoupNormalizerBundle.php b/src/BowlOfSoupNormalizerBundle.php index 2795807..b15e805 100644 --- a/src/BowlOfSoupNormalizerBundle.php +++ b/src/BowlOfSoupNormalizerBundle.php @@ -1,11 +1,14 @@ processConfiguration($configuration, $configs); diff --git a/src/EventListener/RegisterAnnotationsListener.php b/src/EventListener/RegisterAnnotationsListener.php index 8dde414..7dc3add 100644 --- a/src/EventListener/RegisterAnnotationsListener.php +++ b/src/EventListener/RegisterAnnotationsListener.php @@ -8,14 +8,11 @@ use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\HttpKernel\Event\KernelEvent; -class RegisterAnnotationsListener implements EventSubscriberInterface +readonly class RegisterAnnotationsListener implements EventSubscriberInterface { - /** @var bool|null */ - private $parameterRegisterAnnotations = null; - - public function __construct(bool $parameterRegisterAnnotations = null) - { - $this->parameterRegisterAnnotations = $parameterRegisterAnnotations; + public function __construct( + private ?bool $parameterRegisterAnnotations = null, + ) { } public static function getSubscribedEvents(): array diff --git a/src/Exception/BosNormalizerException.php b/src/Exception/BosNormalizerException.php index 45099a6..1f81134 100644 --- a/src/Exception/BosNormalizerException.php +++ b/src/Exception/BosNormalizerException.php @@ -4,8 +4,6 @@ namespace BowlOfSoup\NormalizerBundle\Exception; -use Exception; - -class BosNormalizerException extends Exception +class BosNormalizerException extends \Exception { } diff --git a/src/Exception/BosSerializerException.php b/src/Exception/BosSerializerException.php index 81fd0c2..1f5f5c7 100644 --- a/src/Exception/BosSerializerException.php +++ b/src/Exception/BosSerializerException.php @@ -4,8 +4,6 @@ namespace BowlOfSoup\NormalizerBundle\Exception; -use Exception; - -class BosSerializerException extends Exception +class BosSerializerException extends \Exception { } diff --git a/src/Model/ObjectBag.php b/src/Model/ObjectBag.php index 3b856b8..e69598a 100644 --- a/src/Model/ObjectBag.php +++ b/src/Model/ObjectBag.php @@ -4,28 +4,16 @@ namespace BowlOfSoup\NormalizerBundle\Model; -class ObjectBag +readonly class ObjectBag { - /** @var object */ - private $object; + private string $objectIdentifier; - /** @var string */ - private $objectIdentifier; - - /** @var string */ - private $objectName; - - /** - * @param mixed $objectIdentifier - */ public function __construct( - object $object, - $objectIdentifier, - string $objectName + private object $object, + mixed $objectIdentifier, + private string $objectName, ) { - $this->object = $object; $this->objectIdentifier = (string) $objectIdentifier; - $this->objectName = $objectName; } public function getObject(): object diff --git a/src/Model/ObjectCache.php b/src/Model/ObjectCache.php index fd82ec6..e4a0035 100644 --- a/src/Model/ObjectCache.php +++ b/src/Model/ObjectCache.php @@ -6,67 +6,47 @@ final class ObjectCache { - /** @var array */ - private static $processedObjects = []; + private static array $processedObjects = []; + private static array $processedObjectCache = []; - /** @var array */ - private static $processedObjectCache = []; - - /** - * @param mixed $objectIdentifier - */ - public static function hasObjectByNameAndIdentifier(string $objectName, $objectIdentifier): bool + public static function hasObjectByNameAndIdentifier(string $objectName, mixed $objectIdentifier): bool { return - array_key_exists($objectName, static::$processedObjectCache) - && array_key_exists($objectIdentifier, static::$processedObjectCache[$objectName]); + array_key_exists($objectName, self::$processedObjectCache) + && array_key_exists($objectIdentifier, self::$processedObjectCache[$objectName]); } - /** - * @param mixed $objectIdentifier - */ - public static function setObjectByName(string $objectName, $objectIdentifier): void + public static function setObjectByName(string $objectName, mixed $objectIdentifier): void { - static::$processedObjects[$objectName] = $objectIdentifier; + self::$processedObjects[$objectName] = $objectIdentifier; } - /** - * @param mixed $objectIdentifier - */ public static function setNormalizedPropertiesByNameAndIdentifier( string $objectName, - $objectIdentifier, - array $normalizedProperties + mixed $objectIdentifier, + array $normalizedProperties, ): void { - static::$processedObjectCache[$objectName][$objectIdentifier] = $normalizedProperties; + self::$processedObjectCache[$objectName][$objectIdentifier] = $normalizedProperties; } - /** - * @param mixed $objectIdentifier - * - * @return mixed - */ - public static function getObjectByNameAndIdentifier(string $objectName, $objectIdentifier) + public static function getObjectByNameAndIdentifier(string $objectName, mixed $objectIdentifier): mixed { - return static::$processedObjectCache[$objectName][$objectIdentifier]; + return self::$processedObjectCache[$objectName][$objectIdentifier]; } - /** - * @param mixed $objectIdentifier - */ - public static function resetObjectByNameAndIdentifier(string $objectName, $objectIdentifier): void + public static function resetObjectByNameAndIdentifier(string $objectName, mixed $objectIdentifier): void { - static::$processedObjectCache[$objectName][$objectIdentifier] = []; + self::$processedObjectCache[$objectName][$objectIdentifier] = []; } public static function popCache(): void { - array_pop(static::$processedObjects); + array_pop(self::$processedObjects); } public static function clear(): void { - static::$processedObjects = []; - static::$processedObjectCache = []; + self::$processedObjects = []; + self::$processedObjectCache = []; } } diff --git a/src/Model/Store.php b/src/Model/Store.php index 122102c..7bc47b5 100644 --- a/src/Model/Store.php +++ b/src/Model/Store.php @@ -6,25 +6,16 @@ class Store { - /** @var array */ - private $values = []; - - /** - * @param mixed $value - * - * @return $this - */ - public function set(string $key, $value = null): self + private array $values = []; + + public function set(string $key, mixed $value = null): self { $this->values[$key] = $value; return $this; } - /** - * @return mixed|null - */ - public function get(string $key) + public function get(string $key): mixed { if (!$this->has($key)) { return null; diff --git a/src/Service/Encoder/AbstractEncoder.php b/src/Service/Encoder/AbstractEncoder.php index e468989..470ddfb 100644 --- a/src/Service/Encoder/AbstractEncoder.php +++ b/src/Service/Encoder/AbstractEncoder.php @@ -8,8 +8,7 @@ abstract class AbstractEncoder implements EncoderInterface { - /** @var string|null */ - protected $wrapElement = null; + protected ?string $wrapElement = null; public function setWrapElement(string $wrapElement): void { diff --git a/src/Service/Encoder/EncoderFactory.php b/src/Service/Encoder/EncoderFactory.php index f93a1da..a63a777 100644 --- a/src/Service/Encoder/EncoderFactory.php +++ b/src/Service/Encoder/EncoderFactory.php @@ -8,24 +8,18 @@ class EncoderFactory { - /** @var string */ - public const TYPE_JSON = 'json'; - - /** @var string */ - public const TYPE_XML = 'xml'; + public const string TYPE_JSON = 'json'; + public const string TYPE_XML = 'xml'; /** - * @throws \BowlOfSoup\NormalizerBundle\Exception\BosSerializerException + * @throws BosSerializerException */ public static function getEncoder(string $type): EncoderInterface { - switch ($type) { - case static::TYPE_JSON: - return new EncoderJson(); - case static::TYPE_XML: - return new EncoderXml(); - default: - throw new BosSerializerException('Unknown encoder type.'); - } + return match ($type) { + static::TYPE_JSON => new EncoderJson(), + static::TYPE_XML => new EncoderXml(), + default => throw new BosSerializerException('Unknown encoder type.'), + }; } } diff --git a/src/Service/Encoder/EncoderInterface.php b/src/Service/Encoder/EncoderInterface.php index e7e6b59..881979e 100644 --- a/src/Service/Encoder/EncoderInterface.php +++ b/src/Service/Encoder/EncoderInterface.php @@ -10,10 +10,7 @@ interface EncoderInterface { public function getType(): string; - /** - * @param array|mixed $value - */ - public function encode($value): ?string; + public function encode(mixed $value): ?string; public function populateFromAnnotation(Serialize $serializeAnnotation): void; } diff --git a/src/Service/Encoder/EncoderJson.php b/src/Service/Encoder/EncoderJson.php index 3ba0cbe..db3e4aa 100644 --- a/src/Service/Encoder/EncoderJson.php +++ b/src/Service/Encoder/EncoderJson.php @@ -8,14 +8,10 @@ class EncoderJson extends AbstractEncoder { - /** @var string */ - protected const EXCEPTION_PREFIX = 'Error when encoding JSON: '; + protected const string EXCEPTION_PREFIX = 'Error when encoding JSON: '; + protected const string ERROR_NO_ERROR = 'No error'; - /** @var string */ - protected const ERROR_NO_ERROR = 'No error'; - - /** @var int|null */ - private $options = null; + private ?int $options = null; public function getType(): string { @@ -31,9 +27,9 @@ public function setOptions(int $options): void } /** - * @throws \BowlOfSoup\NormalizerBundle\Exception\BosSerializerException + * @throws BosSerializerException */ - public function encode($value): string + public function encode(mixed $value): string { if (null !== $this->wrapElement) { $value = [$this->wrapElement => $value]; @@ -49,7 +45,7 @@ public function encode($value): string /** * Throws error messages. * - * @throws \BowlOfSoup\NormalizerBundle\Exception\BosSerializerException + * @throws BosSerializerException */ protected function getError(): void { diff --git a/src/Service/Encoder/EncoderXml.php b/src/Service/Encoder/EncoderXml.php index 9fa022a..ce49b26 100644 --- a/src/Service/Encoder/EncoderXml.php +++ b/src/Service/Encoder/EncoderXml.php @@ -8,11 +8,9 @@ class EncoderXml extends AbstractEncoder { - /** @var string */ - public const DEFAULT_WRAP_ELEMENT = 'data'; + public const string DEFAULT_WRAP_ELEMENT = 'data'; - /** @var string */ - protected const EXCEPTION_PREFIX = 'Error when encoding XML: '; + protected const string EXCEPTION_PREFIX = 'Error when encoding XML: '; public function getType(): string { @@ -20,10 +18,10 @@ public function getType(): string } /** - * @throws \BowlOfSoup\NormalizerBundle\Exception\BosSerializerException + * @throws BosSerializerException * @throws \Exception */ - public function encode($value): ?string + public function encode(mixed $value): ?string { if (!is_array($value)) { return null; @@ -66,7 +64,7 @@ protected function arrayToXml(array $data, \SimpleXMLElement $xmlData): \SimpleX } /** - * @throws \BowlOfSoup\NormalizerBundle\Exception\BosSerializerException + * @throws BosSerializerException */ protected function getError(string $xmlData): void { diff --git a/src/Service/Extractor/AnnotationExtractor.php b/src/Service/Extractor/AnnotationExtractor.php index 3097166..ca5ca5f 100644 --- a/src/Service/Extractor/AnnotationExtractor.php +++ b/src/Service/Extractor/AnnotationExtractor.php @@ -13,19 +13,16 @@ class AnnotationExtractor { - /** @var \Doctrine\Common\Annotations\Reader */ - protected $annotationReader; + public const string CACHE_NS = 'bos_annotations'; - /** @var array */ - private $annotationCache = []; - - /** @var string */ - public const CACHE_NS = 'bos_annotations'; + protected Reader $annotationReader; + + private array $annotationCache = []; /** * @codeCoverageIgnore */ - public function __construct(string $cacheDir = null, bool $debugMode = false) + public function __construct(?string $cacheDir = null, bool $debugMode = false) { if (null !== $cacheDir) { $this->createDirectory($cacheDir); @@ -60,6 +57,17 @@ public function getAnnotationsForProperty(string $annotationClass, \ReflectionPr } else { $validPropertyAnnotations = []; + // Read PHP 8 attributes first + $attributes = $property->getAttributes($annotationClass); + foreach ($attributes as $attribute) { + try { + $validPropertyAnnotations[] = $attribute->newInstance(); + } catch (\Error $e) { + throw new \InvalidArgumentException(sprintf('%s (%s): %s', $objectName, $propertyName, $e->getMessage()), 0, $e); + } + } + + // Read docblock annotations (Doctrine) try { $allPropertyAnnotations = $this->annotationReader->getPropertyAnnotations($property); } catch (\InvalidArgumentException $e) { @@ -96,11 +104,23 @@ public function getAnnotationsForMethod(string $annotationClass, \ReflectionMeth if ($objectMethod->getDeclaringClass()->implementsInterface(Proxy::class) && false !== $objectMethod->getDeclaringClass()->getParentClass() && empty($this->annotationReader->getMethodAnnotations($objectMethod)) + && empty($objectMethod->getAttributes($annotationClass)) && $objectMethod->getDeclaringClass()->getParentClass()->hasMethod($objectMethod->getName()) ) { $objectMethod = $objectMethod->getDeclaringClass()->getParentClass()->getMethod($objectMethod->getName()); } + // Read PHP 8 attributes first + $attributes = $objectMethod->getAttributes($annotationClass); + foreach ($attributes as $attribute) { + try { + $validMethodAnnotations[] = $attribute->newInstance(); + } catch (\Error $e) { + throw new \InvalidArgumentException(sprintf('%s (%s): %s', $objectName, $methodName, $e->getMessage()), 0, $e); + } + } + + // Read docblock annotations (Doctrine) try { $allMethodAnnotations = $this->annotationReader->getMethodAnnotations($objectMethod); } catch (\InvalidArgumentException $e) { @@ -121,11 +141,9 @@ public function getAnnotationsForMethod(string $annotationClass, \ReflectionMeth /** * Extract annotations set on class level. * - * @param object|array $object - * * @throws \ReflectionException */ - public function getAnnotationsForClass(string $annotation, $object): array + public function getAnnotationsForClass(string $annotation, object|array $object): array { if (!is_object($object)) { return []; @@ -138,6 +156,17 @@ public function getAnnotationsForClass(string $annotation, $object): array } else { $validClassAnnotations = []; + // Read PHP 8 attributes first + $attributes = $reflectedClass->getAttributes($annotation); + foreach ($attributes as $attribute) { + try { + $validClassAnnotations[] = $attribute->newInstance(); + } catch (\Error $e) { + throw new \InvalidArgumentException(sprintf('%s: %s', $className, $e->getMessage()), 0, $e); + } + } + + // Read docblock annotations (Doctrine) try { $allClassAnnotations = $this->annotationReader->getClassAnnotations($reflectedClass); } catch (\InvalidArgumentException $e) { @@ -159,7 +188,7 @@ public function getAnnotationsForClass(string $annotation, $object): array /** * @codeCoverageIgnore */ - private function directoryExits($dir): bool + private function directoryExits(string $dir): bool { return is_dir($dir); } diff --git a/src/Service/Extractor/ClassExtractor.php b/src/Service/Extractor/ClassExtractor.php index da03c49..ce00b98 100644 --- a/src/Service/Extractor/ClassExtractor.php +++ b/src/Service/Extractor/ClassExtractor.php @@ -8,15 +8,12 @@ class ClassExtractor { - /** @var string */ - public const TYPE = 'class'; + public const string TYPE = 'class'; /** * Gets the id from an object if available through getter. - * - * @return string|int|null */ - public function getId(object $object) + public function getId(object $object): string|int|null { return ObjectHelper::getObjectId($object); } diff --git a/src/Service/Extractor/MethodExtractor.php b/src/Service/Extractor/MethodExtractor.php index aaa0dab..b7d445a 100644 --- a/src/Service/Extractor/MethodExtractor.php +++ b/src/Service/Extractor/MethodExtractor.php @@ -6,13 +6,9 @@ class MethodExtractor { - /** @var string */ - public const TYPE = 'method'; + public const string TYPE = 'method'; - /** - * @param object|string $object - */ - public function getMethods($object): array + public function getMethods(object|string $object): array { if (!is_object($object)) { return []; diff --git a/src/Service/Extractor/PropertyExtractor.php b/src/Service/Extractor/PropertyExtractor.php index 5a6757c..e11ad76 100644 --- a/src/Service/Extractor/PropertyExtractor.php +++ b/src/Service/Extractor/PropertyExtractor.php @@ -9,18 +9,13 @@ class PropertyExtractor { - /** @var bool */ - public const GET_ONLY_PRIVATES = true; - - /** @var string */ - public const TYPE = 'property'; + public const bool GET_ONLY_PRIVATES = true; + public const string TYPE = 'property'; /** * Get all properties for a given class. - * - * @param object|string $object */ - public function getProperties($object): array + public function getProperties(object|string $object): array { if (!is_object($object)) { return []; @@ -59,11 +54,9 @@ private function getClassProperties(\ReflectionClass $reflectedClass, bool $only /** * Returns a value for a (reflected) property. * - * @throws \BowlOfSoup\NormalizerBundle\Exception\BosNormalizerException - * - * @return mixed|null + * @throws BosNormalizerException */ - public function getPropertyValue(object $object, \ReflectionProperty $property) + public function getPropertyValue(object $object, \ReflectionProperty $property): mixed { $propertyName = $property->getName(); $propertyValue = null; @@ -71,7 +64,7 @@ public function getPropertyValue(object $object, \ReflectionProperty $property) try { $propertyValue = $property->getValue($object); - } catch (\ReflectionException $e) { + } catch (\ReflectionException) { $forceGetMethod = true; } @@ -96,10 +89,8 @@ public function getPropertyValue(object $object, \ReflectionProperty $property) /** * Returns a value by specified method. - * - * @return mixed */ - public function getPropertyValueByMethod(object $object, string $method) + public function getPropertyValueByMethod(object $object, string $method): mixed { if (is_callable([$object, $method])) { return $object->$method(); diff --git a/src/Service/Normalize/AbstractNormalizer.php b/src/Service/Normalize/AbstractNormalizer.php index 482238c..2950fd7 100644 --- a/src/Service/Normalize/AbstractNormalizer.php +++ b/src/Service/Normalize/AbstractNormalizer.php @@ -17,41 +17,19 @@ abstract class AbstractNormalizer { - /** @var \BowlOfSoup\NormalizerBundle\Service\Normalizer|null */ - protected $sharedNormalizer = null; + protected ?Normalizer $sharedNormalizer = null; - /** @var \BowlOfSoup\NormalizerBundle\Service\Extractor\ClassExtractor */ - protected $classExtractor; - - /** @var \Symfony\Contracts\Translation\TranslatorInterface */ - protected $translator; - - /** @var \BowlOfSoup\NormalizerBundle\Service\Extractor\AnnotationExtractor */ - protected $annotationExtractor; - - /** @var string|null */ - protected $group = null; - - /** @var int|null */ - protected $maxDepth = null; - - /** @var array */ - protected $processedDepthObjects = []; - - /** @var int */ - protected $processedDepth = 0; - - /** @var \BowlOfSoup\NormalizerBundle\Model\Store[]|array|null */ - protected $nameAndClassStore = null; + protected ?string $group = null; + protected ?int $maxDepth = null; + protected array $processedDepthObjects = []; + protected int $processedDepth = 0; + protected ?array $nameAndClassStore = null; public function __construct( - ClassExtractor $classExtractor, - TranslatorInterface $translator, - AnnotationExtractor $annotationExtractor + protected ClassExtractor $classExtractor, + protected TranslatorInterface $translator, + protected AnnotationExtractor $annotationExtractor, ) { - $this->classExtractor = $classExtractor; - $this->translator = $translator; - $this->annotationExtractor = $annotationExtractor; } public function cleanUp(): void @@ -76,15 +54,13 @@ protected function hasMaxDepth(): bool } /** - * @throws \BowlOfSoup\NormalizerBundle\Exception\BosNormalizerException - * - * @return int|string + * @throws BosNormalizerException */ - protected function getValueForMaxDepth(object $object) + protected function getValueForMaxDepth(object $object): int|string { $value = $this->classExtractor->getId($object); if (null === $value) { - throw new BosNormalizerException('Maximal depth reached, but no identifier found. Prevent this by adding a getId() method to ' . get_class($object)); + throw new BosNormalizerException('Maximal depth reached, but no identifier found. Prevent this by adding a getId() method to ' . $object::class); } return $value; @@ -104,7 +80,7 @@ protected function getClassAnnotation(object $object): ?Normalize return null; } - /** @var \BowlOfSoup\NormalizerBundle\Annotation\Normalize $classAnnotation */ + /** @var Normalize $classAnnotation */ foreach ($classAnnotations as $classAnnotation) { if ($classAnnotation->isGroupValidForConstruct($this->group)) { $this->maxDepth = $classAnnotation->getMaxDepth(); @@ -116,10 +92,7 @@ protected function getClassAnnotation(object $object): ?Normalize return null; } - /** - * @param mixed $value - */ - protected function skipEmptyValue($value, Normalize $annotation, Normalize $classAnnotation = null): bool + protected function skipEmptyValue(mixed $value, Normalize $annotation, ?Normalize $classAnnotation = null): bool { $skipEmpty = (null !== $classAnnotation ? $classAnnotation->getSkipEmpty() : false); @@ -129,13 +102,13 @@ protected function skipEmptyValue($value, Normalize $annotation, Normalize $clas /** * Normalize a referenced object, handles circular references. * - * @throws \BowlOfSoup\NormalizerBundle\Exception\BosNormalizerException + * @throws BosNormalizerException * @throws \ReflectionException */ protected function normalizeReferencedObject(object $object, object $parentObject): ?array { $normalizedConstruct = null; - $objectName = get_class($object); + $objectName = $object::class; if (is_object($object) && !$this->isCircularReference($object, $objectName)) { $normalizedConstruct = $this->sharedNormalizer->normalizeObject($object, $this->group); @@ -148,7 +121,7 @@ protected function normalizeReferencedObject(object $object, object $parentObjec if (empty($normalizedConstruct)) { $normalizedConstruct = $this->classExtractor->getId($object); if (null === $normalizedConstruct) { - throw new BosNormalizerException('Circular reference on: ' . $objectName . ' called from: ' . get_class($parentObject) . '. If possible, prevent this by adding a getId() method to ' . $objectName); + throw new BosNormalizerException('Circular reference on: ' . $objectName . ' called from: ' . $parentObject::class . '. If possible, prevent this by adding a getId() method to ' . $objectName); } return ['id' => $normalizedConstruct]; @@ -160,14 +133,12 @@ protected function normalizeReferencedObject(object $object, object $parentObjec /** * Normalize a property with 'collection' type. * - * A Collection can be anything that is iteratable, such as a Doctrine ArrayCollection, or just an array. + * A Collection can be anything that is iterable, such as a Doctrine ArrayCollection, or just an array. * - * @param mixed $propertyValue - * - * @throws \BowlOfSoup\NormalizerBundle\Exception\BosNormalizerException + * @throws BosNormalizerException * @throws \ReflectionException */ - protected function normalizeReferencedCollection($propertyValue, Normalize $propertyAnnotation): ?array + protected function normalizeReferencedCollection(mixed $propertyValue, Normalize $propertyAnnotation): ?array { $normalizedCollection = []; @@ -204,14 +175,10 @@ protected function normalizeReferencedCollection($propertyValue, Normalize $prop } /** - * @param mixed $propertyValue - * - * @throws \BowlOfSoup\NormalizerBundle\Exception\BosNormalizerException + * @throws BosNormalizerException * @throws \ReflectionException - * - * @return array|string|null */ - protected function handleCallbackResult($propertyValue, Normalize $propertyAnnotation) + protected function handleCallbackResult(mixed $propertyValue, Normalize $propertyAnnotation): mixed { if (!$propertyAnnotation->mustNormalizeCallbackResult()) { return $propertyValue; @@ -241,7 +208,7 @@ protected function handleCallbackResult($propertyValue, Normalize $propertyAnnot } /** - * @param \BowlOfSoup\NormalizerBundle\Annotation\Translate[] $translateAnnotations + * @param Translate[] $translateAnnotations */ protected function getTranslationAnnotation(array $translateAnnotations, bool $emptyGroup = false): ?Translate { @@ -262,19 +229,14 @@ protected function getTranslationAnnotation(array $translateAnnotations, bool $e } // Annotation found, but no explicit group. Try again with no group. if (null === $translationAnnotation) { - // Don't try again if get with no group given to prevent + // Don't try again if we get with no group given to prevent return (!$emptyGroup) ? $this->getTranslationAnnotation($translateAnnotations, true) : null; } return $translationAnnotation; } - /** - * @param mixed $value - * - * @return mixed - */ - protected function translateValue($value, Translate $translationAnnotation) + protected function translateValue(mixed $value, Translate $translationAnnotation): mixed { if (!is_string($value)) { return $value; diff --git a/src/Service/Normalize/MethodNormalizer.php b/src/Service/Normalize/MethodNormalizer.php index 5e978e4..b177e82 100644 --- a/src/Service/Normalize/MethodNormalizer.php +++ b/src/Service/Normalize/MethodNormalizer.php @@ -16,28 +16,23 @@ class MethodNormalizer extends AbstractNormalizer { - /** @var \BowlOfSoup\NormalizerBundle\Service\Extractor\MethodExtractor */ - private $methodExtractor; - public function __construct( ClassExtractor $classExtractor, TranslatorInterface $translator, AnnotationExtractor $annotationExtractor, - MethodExtractor $methodExtractor + private readonly MethodExtractor $methodExtractor, ) { parent::__construct($classExtractor, $translator, $annotationExtractor); - - $this->methodExtractor = $methodExtractor; } /** - * @throws \BowlOfSoup\NormalizerBundle\Exception\BosNormalizerException + * @throws BosNormalizerException * @throws \ReflectionException */ public function normalize( Normalizer $sharedNormalizer, ObjectBag $objectBag, - ?string $group + ?string $group, ): array { $object = $objectBag->getObject(); $objectName = $objectBag->getObjectName(); @@ -76,18 +71,18 @@ public function normalize( } /** - * @throws \BowlOfSoup\NormalizerBundle\Exception\BosNormalizerException + * @throws BosNormalizerException * @throws \ReflectionException */ private function normalizeMethod( object $object, \ReflectionMethod $method, array $methodAnnotations, - ?Normalize $classAnnotation + ?Normalize $classAnnotation, ): array { $normalizedProperties = []; - /** @var \BowlOfSoup\NormalizerBundle\Annotation\Normalize $methodAnnotation */ + /** @var Normalize $methodAnnotation */ foreach ($methodAnnotations as $methodAnnotation) { if (!$methodAnnotation->isGroupValidForConstruct($this->group)) { continue; @@ -96,7 +91,6 @@ private function normalizeMethod( $translateAnnotations = $this->annotationExtractor->getAnnotationsForMethod(Translate::class, $method); $translationAnnotation = $this->getTranslationAnnotation($translateAnnotations); - /** @var string $methodName */ $methodName = $method->getName(); $methodValue = $method->invoke($object); @@ -139,20 +133,16 @@ private function normalizeMethod( /** * Returns values for methods with the annotation property 'type'. * - * @param mixed $methodValue - * * @throws \ReflectionException - * @throws \BowlOfSoup\NormalizerBundle\Exception\BosNormalizerException - * - * @return mixed|null + * @throws BosNormalizerException */ private function getValueForMethodWithType( object $object, \ReflectionMethod $method, - $methodValue, + mixed $methodValue, Normalize $methodAnnotation, - string $annotationMethodType - ) { + string $annotationMethodType, + ): string|int|array|null { $newMethodValue = null; $annotationMethodType = strtolower($annotationMethodType); @@ -170,11 +160,11 @@ private function getValueForMethodWithType( /** * Returns values for methods with annotation type 'datetime'. * - * @throws \BowlOfSoup\NormalizerBundle\Exception\BosNormalizerException + * @throws BosNormalizerException + * @throws \ReflectionException */ private function getValueForMethodWithDateTime(object $object, \ReflectionMethod $method, Normalize $methodAnnotation): ?string { - /** @var string $methodName */ $methodName = $method->getName(); $methodValue = null; @@ -197,14 +187,11 @@ private function getValueForMethodWithDateTime(object $object, \ReflectionMethod * * @param mixed $methodValue * + * @throws BosNormalizerException * @throws \ReflectionException - * @throws \BowlOfSoup\NormalizerBundle\Exception\BosNormalizerException - * - * @return mixed|null */ - private function getValueForMethodWithTypeObject(object $object, \ReflectionMethod $method, $methodValue, Normalize $propertyAnnotation) + private function getValueForMethodWithTypeObject(object $object, \ReflectionMethod $method, $methodValue, Normalize $propertyAnnotation): string|array|int|null { - /** @var string $methodName */ $methodName = $method->getName(); if ($this->hasMaxDepth()) { @@ -230,7 +217,7 @@ private function getValueForMethodWithTypeObject(object $object, \ReflectionMeth } /** - * @throws \BowlOfSoup\NormalizerBundle\Exception\BosNormalizerException + * @throws BosNormalizerException */ private function callbackException(string $methodName): void { diff --git a/src/Service/Normalize/PropertyNormalizer.php b/src/Service/Normalize/PropertyNormalizer.php index 83d0eff..8a4aafc 100644 --- a/src/Service/Normalize/PropertyNormalizer.php +++ b/src/Service/Normalize/PropertyNormalizer.php @@ -6,6 +6,7 @@ use BowlOfSoup\NormalizerBundle\Annotation\Normalize; use BowlOfSoup\NormalizerBundle\Annotation\Translate; +use BowlOfSoup\NormalizerBundle\Exception\BosNormalizerException; use BowlOfSoup\NormalizerBundle\Model\ObjectBag; use BowlOfSoup\NormalizerBundle\Model\Store; use BowlOfSoup\NormalizerBundle\Service\Extractor\AnnotationExtractor; @@ -17,28 +18,23 @@ class PropertyNormalizer extends AbstractNormalizer { - /** @var \BowlOfSoup\NormalizerBundle\Service\Extractor\PropertyExtractor */ - private $propertyExtractor; - public function __construct( ClassExtractor $classExtractor, TranslatorInterface $translator, AnnotationExtractor $annotationExtractor, - PropertyExtractor $propertyExtractor + private readonly PropertyExtractor $propertyExtractor, ) { parent::__construct($classExtractor, $translator, $annotationExtractor); - - $this->propertyExtractor = $propertyExtractor; } /** - * @throws \BowlOfSoup\NormalizerBundle\Exception\BosNormalizerException + * @throws BosNormalizerException * @throws \ReflectionException */ public function normalize( Normalizer $sharedNormalizer, ObjectBag $objectBag, - ?string $group + ?string $group, ): array { $object = $objectBag->getObject(); $objectName = $objectBag->getObjectName(); @@ -66,8 +62,6 @@ public function normalize( continue; } - $classProperty->setAccessible(true); - if ($object instanceof Proxy && !$object->__isInitialized()) { $object->__load(); } @@ -94,18 +88,18 @@ public function normalize( /** * Normalization per (reflected) property. * - * @throws \BowlOfSoup\NormalizerBundle\Exception\BosNormalizerException + * @throws BosNormalizerException * @throws \ReflectionException */ private function normalizeProperty( object $object, \ReflectionProperty $property, array $propertyAnnotations, - ?Normalize $classAnnotation + ?Normalize $classAnnotation, ): array { $normalizedProperties = []; - /** @var \BowlOfSoup\NormalizerBundle\Annotation\Normalize $propertyAnnotation */ + /** @var Normalize $propertyAnnotation */ foreach ($propertyAnnotations as $propertyAnnotation) { if (!$propertyAnnotation->isGroupValidForConstruct($this->group)) { continue; @@ -161,20 +155,18 @@ private function normalizeProperty( /** * Returns values for properties with the annotation property 'type'. * - * @param mixed $propertyValue - * * @throws \ReflectionException - * @throws \BowlOfSoup\NormalizerBundle\Exception\BosNormalizerException + * @throws BosNormalizerException * * @return mixed|null */ private function getValueForPropertyWithType( object $object, \ReflectionProperty $property, - $propertyValue, + mixed $propertyValue, Normalize $propertyAnnotation, - string $annotationPropertyType - ) { + string $annotationPropertyType, + ): mixed { $newPropertyValue = null; $annotationPropertyType = strtolower($annotationPropertyType); @@ -192,7 +184,7 @@ private function getValueForPropertyWithType( /** * Returns values for properties with annotation type 'datetime'. * - * @throws \BowlOfSoup\NormalizerBundle\Exception\BosNormalizerException + * @throws BosNormalizerException * @throws \ReflectionException */ private function getValueForPropertyWithDateTime(object $object, \ReflectionProperty $property, Normalize $propertyAnnotation): ?string @@ -217,14 +209,12 @@ private function getValueForPropertyWithDateTime(object $object, \ReflectionProp /** * Returns values for properties with annotation type 'object'. * - * @param mixed $propertyValue - * + * @throws BosNormalizerException * @throws \ReflectionException - * @throws \BowlOfSoup\NormalizerBundle\Exception\BosNormalizerException * * @return mixed|null */ - private function getValueForPropertyWithTypeObject(object $object, $propertyValue, Normalize $propertyAnnotation) + private function getValueForPropertyWithTypeObject(object $object, mixed $propertyValue, Normalize $propertyAnnotation): mixed { if ($this->hasMaxDepth()) { return $this->getValueForMaxDepth($propertyValue); diff --git a/src/Service/Normalizer.php b/src/Service/Normalizer.php index 3b97145..89d00fe 100644 --- a/src/Service/Normalizer.php +++ b/src/Service/Normalizer.php @@ -13,34 +13,20 @@ class Normalizer { - /** @var \BowlOfSoup\NormalizerBundle\Service\Extractor\ClassExtractor */ - protected $classExtractor; - - /** @var \BowlOfSoup\NormalizerBundle\Service\Normalize\PropertyNormalizer */ - private $propertyNormalizer; - - /** @var \BowlOfSoup\NormalizerBundle\Service\Normalize\MethodNormalizer */ - private $methodNormalizer; - public function __construct( - ClassExtractor $classExtractor, - PropertyNormalizer $propertyNormalizer, - MethodNormalizer $methodNormalizer + protected ClassExtractor $classExtractor, + private readonly PropertyNormalizer $propertyNormalizer, + private readonly MethodNormalizer $methodNormalizer, ) { - $this->classExtractor = $classExtractor; - $this->propertyNormalizer = $propertyNormalizer; - $this->methodNormalizer = $methodNormalizer; } /** * Normalize an object or an array of objects, for a specific group. * - * @param mixed $data - * - * @throws \BowlOfSoup\NormalizerBundle\Exception\BosNormalizerException + * @throws BosNormalizerException * @throws \ReflectionException */ - public function normalize($data, string $group = null): array + public function normalize(mixed $data, ?string $group = null): array { if (empty($data)) { return []; @@ -54,13 +40,13 @@ public function normalize($data, string $group = null): array /** * Get properties for given object, annotations per property and begin normalizing. * + * @throws BosNormalizerException * @throws \ReflectionException - * @throws \BowlOfSoup\NormalizerBundle\Exception\BosNormalizerException */ public function normalizeObject(object $object, ?string $group): array { $normalizedConstructs = []; - $objectName = get_class($object); + $objectName = $object::class; $objectIdentifier = ObjectHelper::getObjectIdentifier($object); ObjectCache::setObjectByName($objectName, $objectIdentifier); @@ -89,10 +75,10 @@ public function normalizeObject(object $object, ?string $group): array } /** - * @throws \BowlOfSoup\NormalizerBundle\Exception\BosNormalizerException + * @throws BosNormalizerException * @throws \ReflectionException */ - private function normalizeData($data, ?string $group): array + private function normalizeData(mixed $data, ?string $group): array { $this->propertyNormalizer->cleanUp(); $normalizedData = []; diff --git a/src/Service/ObjectHelper.php b/src/Service/ObjectHelper.php index dd01380..9011820 100644 --- a/src/Service/ObjectHelper.php +++ b/src/Service/ObjectHelper.php @@ -6,24 +6,14 @@ class ObjectHelper { - /** - * @param mixed $object - * - * @return int|string - */ - public static function getObjectIdentifier($object) + public static function getObjectIdentifier(mixed $object): int|string|null { $objectId = self::getObjectId($object); return $objectId ?? self::hashObject($object); } - /** - * @param object $object - * - * @return int|string|null - */ - public static function getObjectId($object) + public static function getObjectId(mixed $object): int|string|null { $method = 'getId'; if (is_callable([$object, $method])) { @@ -33,10 +23,7 @@ public static function getObjectId($object) return null; } - /** - * @param object $object - */ - private static function hashObject($object, string $algorithm = 'md5'): ?string + private static function hashObject(mixed $object, string $algorithm = 'md5'): ?string { if (!is_object($object) || $object instanceof \Closure) { return null; @@ -54,7 +41,7 @@ private static function serializeObject(object $object): ?string { try { return serialize($object); - } catch (\Throwable $t) { + } catch (\Throwable) { // For some reason this object can's be serialized. return null; } diff --git a/src/Service/Serializer.php b/src/Service/Serializer.php index e129cc3..3612ef0 100644 --- a/src/Service/Serializer.php +++ b/src/Service/Serializer.php @@ -5,35 +5,26 @@ namespace BowlOfSoup\NormalizerBundle\Service; use BowlOfSoup\NormalizerBundle\Annotation\Serialize; +use BowlOfSoup\NormalizerBundle\Exception\BosNormalizerException; +use BowlOfSoup\NormalizerBundle\Exception\BosSerializerException; use BowlOfSoup\NormalizerBundle\Service\Encoder\EncoderFactory; use BowlOfSoup\NormalizerBundle\Service\Encoder\EncoderInterface; use BowlOfSoup\NormalizerBundle\Service\Extractor\AnnotationExtractor; class Serializer { - /** @var \BowlOfSoup\NormalizerBundle\Service\Extractor\AnnotationExtractor */ - private $annotationExtractor; - - /** @var \BowlOfSoup\NormalizerBundle\Service\Normalizer */ - private $normalizer; - public function __construct( - AnnotationExtractor $annotationExtractor, - Normalizer $normalizer + private readonly AnnotationExtractor $annotationExtractor, + private readonly Normalizer $normalizer, ) { - $this->annotationExtractor = $annotationExtractor; - $this->normalizer = $normalizer; } /** - * @param mixed $value - * @param string|\BowlOfSoup\NormalizerBundle\Service\Encoder\EncoderInterface $encoding - * - * @throws \BowlOfSoup\NormalizerBundle\Exception\BosNormalizerException - * @throws \BowlOfSoup\NormalizerBundle\Exception\BosSerializerException + * @throws BosNormalizerException + * @throws BosSerializerException * @throws \ReflectionException */ - public function serialize($value, $encoding, string $group = null): string + public function serialize(mixed $value, string|EncoderInterface $encoding, ?string $group = null): string { $serializeAnnotation = null; @@ -71,22 +62,13 @@ private function getClassAnnotation(object $object, ?string $group): ?Serialize return null; } - /** @var \BowlOfSoup\NormalizerBundle\Annotation\Serialize $classAnnotation */ - foreach ($classAnnotations as $classAnnotation) { - if ($classAnnotation->isGroupValidForConstruct($group)) { - return $classAnnotation; - } - } - - return null; + return array_find($classAnnotations, fn (Serialize $classAnnotation) => $classAnnotation->isGroupValidForConstruct($group)); } /** - * @param string|\BowlOfSoup\NormalizerBundle\Service\Encoder\EncoderInterface $encoding - * - * @throws \BowlOfSoup\NormalizerBundle\Exception\BosSerializerException + * @throws BosSerializerException */ - private function getEncoder($encoding): EncoderInterface + private function getEncoder(string|EncoderInterface $encoding): EncoderInterface { if ($encoding instanceof EncoderInterface) { return $encoding; diff --git a/tests/Annotation/NormalizeTest.php b/tests/Annotation/NormalizeTest.php index a946dd8..7f0a0b1 100644 --- a/tests/Annotation/NormalizeTest.php +++ b/tests/Annotation/NormalizeTest.php @@ -1,5 +1,7 @@ expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Property "unknownProperty" of annotation "BowlOfSoup\NormalizerBundle\Annotation\Normalize" is unknown.'); + + $properties = $this->getValidSetOfProperties(); + $properties['unknownProperty'] = 'value'; + new Normalize($properties); + } + + /** + * @testdox Test annotation with attribute-style named parameters with explicit nulls + */ + public function testNormalizeWithAttributeStyleNullParameters(): void + { + // This tests the attribute-style path with null parameters + $normalize = new Normalize( + name: null, + group: ['api'], + type: null, + format: null, + callback: null, + normalizeCallbackResult: null, + skipEmpty: null, + maxDepth: null + ); + + $this->assertNull($normalize->getName()); + $this->assertSame(['api'], $normalize->getGroup()); + } + + /** + * @testdox Test annotation with attribute-style string group parameter + */ + public function testNormalizeWithAttributeStyleStringGroup(): void + { + // This tests the elseif branch where group is a string + $normalize = new Normalize( + name: 'test', + group: 'api', // String instead of array + type: null, + format: null, + callback: null, + normalizeCallbackResult: null, + skipEmpty: null, + maxDepth: null + ); + + $this->assertSame('test', $normalize->getName()); + $this->assertSame(['api'], $normalize->getGroup()); // Should be converted to array + } + private function getValidSetOfProperties(): array { return [ diff --git a/tests/Annotation/SerializeTest.php b/tests/Annotation/SerializeTest.php index dfd2d23..768e942 100644 --- a/tests/Annotation/SerializeTest.php +++ b/tests/Annotation/SerializeTest.php @@ -1,5 +1,7 @@ expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Property "unknownProperty" of annotation "BowlOfSoup\NormalizerBundle\Annotation\Serialize" is unknown.'); + + $properties = $this->getValidSetOfProperties(); + $properties['unknownProperty'] = 'value'; + new Serialize($properties); + } + + /** + * @testdox Test annotation with attribute-style named parameters with explicit nulls + */ + public function testSerializeWithAttributeStyleNullParameters(): void + { + // This tests the attribute-style path with null parameters + $serialize = new Serialize( + wrapElement: null, + group: ['api'], + sortProperties: null + ); + + $this->assertNull($serialize->getWrapElement()); + $this->assertSame(['api'], $serialize->getGroup()); + } + + /** + * @testdox Test annotation with attribute-style string group parameter + */ + public function testSerializeWithAttributeStyleStringGroup(): void + { + // This tests the elseif branch where group is a string + $serialize = new Serialize( + wrapElement: 'data', + group: 'api', // String instead of array + sortProperties: null + ); + + $this->assertSame('data', $serialize->getWrapElement()); + $this->assertSame(['api'], $serialize->getGroup()); // Should be converted to array + } + private function getValidSetOfProperties(): array { return [ diff --git a/tests/Annotation/TranslateTest.php b/tests/Annotation/TranslateTest.php new file mode 100644 index 0000000..bda0440 --- /dev/null +++ b/tests/Annotation/TranslateTest.php @@ -0,0 +1,93 @@ +getValidSetOfProperties(); + $translate = new Translate($properties); + + $this->assertSame($properties['group'], $translate->getGroup()); + $this->assertSame($properties['domain'], $translate->getDomain()); + $this->assertSame($properties['locale'], $translate->getLocale()); + } + + /** + * @testdox Test annotation, validation if property input type is valid + */ + public function testTranslateValidationPropertyType(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Wrong datatype used for property "group" for annotation "BowlOfSoup\NormalizerBundle\Annotation\Translate"'); + + $properties = $this->getValidSetOfProperties(); + $properties['group'] = 'dummy'; + new Translate($properties); + } + + /** + * @testdox Test annotation with unknown property in array-based initialization + */ + public function testTranslateWithUnknownProperty(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Property "unknownProperty" of annotation "BowlOfSoup\NormalizerBundle\Annotation\Translate" is unknown.'); + + $properties = $this->getValidSetOfProperties(); + $properties['unknownProperty'] = 'value'; + new Translate($properties); + } + + /** + * @testdox Test annotation with attribute-style named parameters with explicit nulls + */ + public function testTranslateWithAttributeStyleNullParameters(): void + { + // This tests the attribute-style path with null parameters + $translate = new Translate( + domain: null, + group: ['api'], + locale: null + ); + + $this->assertNull($translate->getDomain()); + $this->assertSame(['api'], $translate->getGroup()); + $this->assertNull($translate->getLocale()); + } + + /** + * @testdox Test annotation with attribute-style string group parameter + */ + public function testTranslateWithAttributeStyleStringGroup(): void + { + // This tests the elseif branch where group is a string + $translate = new Translate( + domain: 'messages', + group: 'api', // String instead of array + locale: 'en' + ); + + $this->assertSame('messages', $translate->getDomain()); + $this->assertSame(['api'], $translate->getGroup()); // Should be converted to array + $this->assertSame('en', $translate->getLocale()); + } + + private function getValidSetOfProperties(): array + { + return [ + 'domain' => 'messages', + 'group' => ['group1', 'group2'], + 'locale' => 'en', + ]; + } +} diff --git a/tests/ArraySubset.php b/tests/ArraySubset.php index 25436ec..8519c54 100644 --- a/tests/ArraySubset.php +++ b/tests/ArraySubset.php @@ -8,8 +8,8 @@ use PHPUnit\Framework\Constraint\Constraint; use PHPUnit\Framework\Exception; use PHPUnit\Framework\ExpectationFailedException; -use PHPUnit\Framework\InvalidArgumentException; use SebastianBergmann\Comparator\ComparisonFailure; +use SebastianBergmann\RecursionContext\InvalidArgumentException; /** * Constraint that asserts that the array it is evaluated for has a specified subset. @@ -21,59 +21,34 @@ */ class ArraySubset extends Constraint { - /** @var iterable */ - private $subset; - - /** @var bool */ - private $strict; - - public function __construct(iterable $subset, bool $strict = false) - { - $this->strict = $strict; - $this->subset = $subset; + public function __construct( + private iterable $subset, + private readonly bool $strict = false, + ) { } /** * Asserts that an array has a specified subset. * - * @param array|\ArrayAccess $subset - * @param array|\ArrayAccess $array - * * @throws ExpectationFailedException - * @throws \SebastianBergmann\RecursionContext\InvalidArgumentException + * @throws InvalidArgumentException * @throws Exception * * @codeCoverageIgnore */ - public static function assert($subset, $array, bool $checkForObjectIdentity = false, string $message = ''): void + public static function assert(array|\ArrayAccess $subset, array|\ArrayAccess $array, bool $checkForObjectIdentity = false, string $message = ''): void { - if (!(\is_array($subset) || $subset instanceof \ArrayAccess)) { - throw InvalidArgumentException::create(1, 'array or ArrayAccess'); - } - - if (!(\is_array($array) || $array instanceof \ArrayAccess)) { - throw InvalidArgumentException::create(2, 'array or ArrayAccess'); - } - $constraint = new self($subset, $checkForObjectIdentity); Assert::assertThat($array, $constraint, $message); } /** - * Evaluates the constraint for parameter $other. - * - * If $returnResult is set to false (the default), an exception is thrown - * in case of a failure. null is returned otherwise. - * - * If $returnResult is true, the result of the evaluation is returned as - * a boolean value instead: true in case of success, false in case of a - * failure. - * * @throws ExpectationFailedException - * @throws \SebastianBergmann\RecursionContext\InvalidArgumentException + * @throws InvalidArgumentException */ - public function evaluate($other, string $description = '', bool $returnResult = false): ?bool + #[\Override] + public function evaluate(mixed $other, string $description = '', bool $returnResult = false): ?bool { // type cast $other & $this->subset as an array to allow // support in standard array functions. @@ -107,9 +82,7 @@ public function evaluate($other, string $description = '', bool $returnResult = } /** - * Returns a string representation of the constraint. - * - * @throws \SebastianBergmann\RecursionContext\InvalidArgumentException + * @throws InvalidArgumentException */ public function toString(): string { @@ -117,16 +90,10 @@ public function toString(): string } /** - * Returns the description of the failure. - * - * The beginning of failure messages is "Failed asserting that" in most - * cases. This method should return the second part of that sentence. - * - * @param mixed $other evaluated value or object - * - * @throws \SebastianBergmann\RecursionContext\InvalidArgumentException + * @throws InvalidArgumentException */ - protected function failureDescription($other): string + #[\Override] + protected function failureDescription(mixed $other): string { return 'an array ' . $this->toString(); } diff --git a/tests/NormalizerTestTrait.php b/tests/NormalizerTestTrait.php index 2d435cb..e10dc16 100644 --- a/tests/NormalizerTestTrait.php +++ b/tests/NormalizerTestTrait.php @@ -11,40 +11,27 @@ use BowlOfSoup\NormalizerBundle\Service\Normalize\MethodNormalizer; use BowlOfSoup\NormalizerBundle\Service\Normalize\PropertyNormalizer; use BowlOfSoup\NormalizerBundle\Service\Normalizer; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\MockObject\Stub; use Symfony\Contracts\Translation\TranslatorInterface; trait NormalizerTestTrait { - /** @var \BowlOfSoup\NormalizerBundle\Service\Extractor\ClassExtractor|\PHPUnit\Framework\MockObject\Stub\Stub */ - protected $classExtractor; - - /** @var \BowlOfSoup\NormalizerBundle\Service\Normalize\PropertyNormalizer|\PHPUnit\Framework\MockObject\Stub\Stub */ - protected $propertyNormalizer; - - /** @var \BowlOfSoup\NormalizerBundle\Service\Normalize\MethodNormalizer|\PHPUnit\Framework\MockObject\Stub\Stub */ - protected $methodNormalizer; - - /** @var \BowlOfSoup\NormalizerBundle\Service\Extractor\PropertyExtractor|\PHPUnit\Framework\MockObject\Stub\Stub */ - protected $propertyExtractor; - - /** @var \BowlOfSoup\NormalizerBundle\Service\Extractor\MethodExtractor|\PHPUnit\Framework\MockObject\Stub\Stub */ - protected $methodExtractor; - - /** @var \BowlOfSoup\NormalizerBundle\Service\Extractor\AnnotationExtractor|\PHPUnit\Framework\MockObject\Stub\Stub */ - protected $annotationExtractor; - - /** @var \Symfony\Contracts\Translation\TranslatorInterface|\PHPUnit\Framework\MockObject\Stub\Stub|\PHPUnit\Framework\MockObject\MockObject */ - protected $translator; + protected ClassExtractor|Stub $classExtractor; + protected PropertyNormalizer|Stub $propertyNormalizer; + protected MethodNormalizer|Stub $methodNormalizer; + protected PropertyExtractor|Stub $propertyExtractor; + protected MethodExtractor|Stub $methodExtractor; + protected AnnotationExtractor|Stub $annotationExtractor; + protected TranslatorInterface|MockObject $translator; public function getNormalizer(): Normalizer { $propertyExtractor = $this->propertyExtractor ?? new PropertyExtractor(); $methodExtractor = $this->methodExtractor ?? new MethodExtractor(); $classExtractor = $this->classExtractor ?? new ClassExtractor(); - $annotationExtractor = $this->annotationExtractor ?? new AnnotationExtractor(); - /** @var \PHPUnit\Framework\MockObject\MockBuilder|\PHPUnit\Framework\MockObject\MockObject $translationMockBuilder */ $translationMockBuilder = $this->getMockBuilder(TranslatorInterface::class) ->disableOriginalConstructor(); diff --git a/tests/SerializerTestTrait.php b/tests/SerializerTestTrait.php index 5947316..f37f46b 100644 --- a/tests/SerializerTestTrait.php +++ b/tests/SerializerTestTrait.php @@ -11,9 +11,6 @@ trait SerializerTestTrait { use NormalizerTestTrait; - /** @var \BowlOfSoup\NormalizerBundle\Service\Extractor\AnnotationExtractor|\PHPUnit\Framework\MockObject\Stub\Stub */ - protected $annotationExtractor; - public function getSerializer(): Serializer { $annotationExtractor = $this->annotationExtractor ?? new AnnotationExtractor(); diff --git a/tests/Service/Encoder/EncoderFactoryTest.php b/tests/Service/Encoder/EncoderFactoryTest.php index 1810e49..38445cc 100644 --- a/tests/Service/Encoder/EncoderFactoryTest.php +++ b/tests/Service/Encoder/EncoderFactoryTest.php @@ -1,5 +1,7 @@ disableOriginalConstructor() ->onlyMethods(['jsonLastErrorMsgExists']); - /** @var \BowlOfSoup\NormalizerBundle\Service\Encoder\EncoderJson|\PHPUnit\Framework\MockObject\MockObject $encoderJson */ + /** @var EncoderJson&MockObject $encoderJson */ $encoderJson = $mockBuilder->getMock(); $encoderJson ->expects($this->any()) diff --git a/tests/Service/Encoder/EncoderXmlTest.php b/tests/Service/Encoder/EncoderXmlTest.php index 99014c7..ed6aab1 100644 --- a/tests/Service/Encoder/EncoderXmlTest.php +++ b/tests/Service/Encoder/EncoderXmlTest.php @@ -1,5 +1,7 @@ setWrapElement('data'); @@ -110,7 +112,6 @@ public function testError(): void $reflectionClass = new \ReflectionClass($encoderXml); $reflectionMethod = $reflectionClass->getMethod('getError'); - $reflectionMethod->setAccessible(true); $reflectionMethod->invokeArgs($encoderXml, ['Faulty XML']); } @@ -140,6 +141,6 @@ public function testPopulate(): void private function flatten(string $value): string { - return trim(preg_replace('/\s+/', '', $value)); + return trim((string) preg_replace('/\s+/', '', $value)); } } diff --git a/tests/Service/Extractor/AnnotationExtractorTest.php b/tests/Service/Extractor/AnnotationExtractorTest.php index 6be2764..a6be9c7 100644 --- a/tests/Service/Extractor/AnnotationExtractorTest.php +++ b/tests/Service/Extractor/AnnotationExtractorTest.php @@ -9,14 +9,20 @@ use BowlOfSoup\NormalizerBundle\Service\Extractor\MethodExtractor; use BowlOfSoup\NormalizerBundle\Service\Extractor\PropertyExtractor; use BowlOfSoup\NormalizerBundle\Tests\ArraySubset; +use BowlOfSoup\NormalizerBundle\Tests\assets\BrokenAnnotation; +use BowlOfSoup\NormalizerBundle\Tests\assets\BrokenAttributeClass; +use BowlOfSoup\NormalizerBundle\Tests\assets\BrokenAttributeMethod; +use BowlOfSoup\NormalizerBundle\Tests\assets\BrokenAttributeProperty; +use BowlOfSoup\NormalizerBundle\Tests\assets\BrokenClassAnnotation; +use BowlOfSoup\NormalizerBundle\Tests\assets\BrokenMethodAnnotation; use BowlOfSoup\NormalizerBundle\Tests\assets\SomeClass; use Doctrine\Common\Annotations\AnnotationReader; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; class AnnotationExtractorTest extends TestCase { - /** @var string */ - protected const ANNOTATION_NORMALIZE = Normalize::class; + protected const string ANNOTATION_NORMALIZE = Normalize::class; public function testExtractClassAnnotation(): void { @@ -26,7 +32,7 @@ public function testExtractClassAnnotation(): void $someClass = new SomeClass(); $reflectedClass = new \ReflectionClass($someClass); - /** @var \Doctrine\Common\Annotations\AnnotationReader|\PHPUnit\Framework\MockObject\MockObject $mockAnnotationReader */ + /** @var AnnotationReader&MockObject $mockAnnotationReader */ $mockAnnotationReader = $this ->getMockBuilder(AnnotationReader::class) ->disableOriginalConstructor() @@ -48,7 +54,7 @@ public function testExtractClassAnnotationWithException(): void $someClass = new SomeClass(); $reflectedClass = new \ReflectionClass($someClass); - /** @var \Doctrine\Common\Annotations\AnnotationReader|\PHPUnit\Framework\MockObject\MockObject $mockAnnotationReader */ + /** @var AnnotationReader&MockObject $mockAnnotationReader */ $mockAnnotationReader = $this ->getMockBuilder(AnnotationReader::class) ->disableOriginalConstructor() @@ -70,7 +76,7 @@ public function testExtractClassAnnotationWithException(): void public function testExtractClassAnnotationNoClassGiven(): void { - /** @var \Doctrine\Common\Annotations\AnnotationReader $mockAnnotationReader */ + /** @var AnnotationReader&MockObject $mockAnnotationReader */ $mockAnnotationReader = $this ->getMockBuilder(AnnotationReader::class) ->disableOriginalConstructor() @@ -86,7 +92,7 @@ public function testExtractMethodAnnotations(): void $annotation = new Normalize([]); $someClass = new SomeClass(); - /** @var \BowlOfSoup\NormalizerBundle\Service\Extractor\MethodExtractor|\PHPUnit\Framework\MockObject\Stub\Stub $methodExtractor */ + /** @var MethodExtractor&MockObject $methodExtractor */ $methodExtractor = $this ->getMockBuilder(MethodExtractor::class) ->disableOriginalConstructor() @@ -96,7 +102,7 @@ public function testExtractMethodAnnotations(): void $annotationResult = [$annotation]; - /** @var \Doctrine\Common\Annotations\AnnotationReader|\PHPUnit\Framework\MockObject\MockObject $mockAnnotationReader */ + /** @var AnnotationReader&MockObject $mockAnnotationReader */ $mockAnnotationReader = $this ->getMockBuilder(AnnotationReader::class) ->disableOriginalConstructor() @@ -110,7 +116,7 @@ public function testExtractMethodAnnotations(): void $methodExtractor = new AnnotationExtractor(); $methodExtractor->setAnnotationReader($mockAnnotationReader); - $result = $methodExtractor->getAnnotationsForMethod(get_class($annotation), $methods[0]); + $result = $methodExtractor->getAnnotationsForMethod($annotation::class, $methods[0]); ArraySubset::assert([$annotation], $result); } @@ -120,7 +126,7 @@ public function testExtractMethodAnnotationsWithException(): void $annotation = new Normalize([]); $someClass = new SomeClass(); - /** @var \BowlOfSoup\NormalizerBundle\Service\Extractor\MethodExtractor|\PHPUnit\Framework\MockObject\Stub\Stub $methodExtractor */ + /** @var MethodExtractor&MockObject $methodExtractor */ $methodExtractor = $this ->getMockBuilder(MethodExtractor::class) ->disableOriginalConstructor() @@ -128,9 +134,7 @@ public function testExtractMethodAnnotationsWithException(): void ->getMock(); $methods = $methodExtractor->getMethods($someClass); - $annotationResult = [$annotation]; - - /** @var \Doctrine\Common\Annotations\AnnotationReader|\PHPUnit\Framework\MockObject\MockObject $mockAnnotationReader */ + /** @var AnnotationReader&MockObject $mockAnnotationReader */ $mockAnnotationReader = $this ->getMockBuilder(AnnotationReader::class) ->disableOriginalConstructor() @@ -147,7 +151,7 @@ public function testExtractMethodAnnotationsWithException(): void $methodExtractor = new AnnotationExtractor(); $methodExtractor->setAnnotationReader($mockAnnotationReader); - $methodExtractor->getAnnotationsForMethod(get_class($annotation), $methods[0]); + $methodExtractor->getAnnotationsForMethod($annotation::class, $methods[0]); } public function testExtractPropertyAnnotations(): void @@ -155,7 +159,7 @@ public function testExtractPropertyAnnotations(): void $annotation = new Normalize([]); $someClass = new SomeClass(); - /** @var \BowlOfSoup\NormalizerBundle\Service\Extractor\PropertyExtractor|\PHPUnit\Framework\MockObject\Stub\Stub $propertyExtractor */ + /** @var PropertyExtractor&MockObject $propertyExtractor */ $propertyExtractor = $this ->getMockBuilder(PropertyExtractor::class) ->disableOriginalConstructor() @@ -165,7 +169,7 @@ public function testExtractPropertyAnnotations(): void $annotationResult = [$annotation]; - /** @var \Doctrine\Common\Annotations\AnnotationReader|\PHPUnit\Framework\MockObject\MockObject $mockAnnotationReader */ + /** @var AnnotationReader&MockObject $mockAnnotationReader */ $mockAnnotationReader = $this ->getMockBuilder(AnnotationReader::class) ->disableOriginalConstructor() @@ -179,7 +183,7 @@ public function testExtractPropertyAnnotations(): void $propertyExtractor = new AnnotationExtractor(); $propertyExtractor->setAnnotationReader($mockAnnotationReader); - $result = $propertyExtractor->getAnnotationsForProperty(get_class($annotation), $properties[0]); + $result = $propertyExtractor->getAnnotationsForProperty($annotation::class, $properties[0]); ArraySubset::assert([$annotation], $result); } @@ -189,7 +193,7 @@ public function testExtractPropertyAnnotationsWithException(): void $annotation = new Normalize([]); $someClass = new SomeClass(); - /** @var \BowlOfSoup\NormalizerBundle\Service\Extractor\PropertyExtractor|\PHPUnit\Framework\MockObject\Stub\Stub $propertyExtractor */ + /** @var PropertyExtractor&MockObject $propertyExtractor */ $propertyExtractor = $this ->getMockBuilder(PropertyExtractor::class) ->disableOriginalConstructor() @@ -197,9 +201,7 @@ public function testExtractPropertyAnnotationsWithException(): void ->getMock(); $properties = $propertyExtractor->getProperties($someClass); - $annotationResult = [$annotation]; - - /** @var \Doctrine\Common\Annotations\AnnotationReader|\PHPUnit\Framework\MockObject\MockObject $mockAnnotationReader */ + /** @var AnnotationReader&MockObject $mockAnnotationReader */ $mockAnnotationReader = $this ->getMockBuilder(AnnotationReader::class) ->disableOriginalConstructor() @@ -216,6 +218,44 @@ public function testExtractPropertyAnnotationsWithException(): void $propertyExtractor = new AnnotationExtractor(); $propertyExtractor->setAnnotationReader($mockAnnotationReader); - $propertyExtractor->getAnnotationsForProperty(get_class($annotation), $properties[0]); + $propertyExtractor->getAnnotationsForProperty($annotation::class, $properties[0]); + } + + public function testExtractPropertyAnnotationsWithAttributeError(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/BrokenAttributeProperty \(brokenProperty\):/'); + + $brokenObject = new BrokenAttributeProperty(); + $propertyExtractor = new PropertyExtractor(); + $properties = $propertyExtractor->getProperties($brokenObject); + + $annotationExtractor = new AnnotationExtractor(); + // Use the BrokenAnnotation class that will actually cause a TypeError + $annotationExtractor->getAnnotationsForProperty(BrokenAnnotation::class, $properties[0]); + } + + public function testExtractMethodAnnotationsWithAttributeError(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/BrokenAttributeMethod \(getBrokenMethod\):/'); + + $brokenObject = new BrokenAttributeMethod(); + $methodExtractor = new MethodExtractor(); + $methods = $methodExtractor->getMethods($brokenObject); + + $annotationExtractor = new AnnotationExtractor(); + $annotationExtractor->getAnnotationsForMethod(BrokenMethodAnnotation::class, $methods[0]); + } + + public function testExtractClassAnnotationsWithAttributeError(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/BrokenAttributeClass:/'); + + $brokenObject = new BrokenAttributeClass(); + + $annotationExtractor = new AnnotationExtractor(); + $annotationExtractor->getAnnotationsForClass(BrokenClassAnnotation::class, $brokenObject); } } diff --git a/tests/Service/Extractor/ClassExtractorTest.php b/tests/Service/Extractor/ClassExtractorTest.php index 58a565c..ed1f2d8 100644 --- a/tests/Service/Extractor/ClassExtractorTest.php +++ b/tests/Service/Extractor/ClassExtractorTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace BowlOfSoup\NormalizerBundle\Tests\Service; +namespace BowlOfSoup\NormalizerBundle\Tests\Service\Extractor; use BowlOfSoup\NormalizerBundle\Service\Extractor\ClassExtractor; use BowlOfSoup\NormalizerBundle\Tests\assets\SomeClass; diff --git a/tests/Service/Extractor/MethodExtractorTest.php b/tests/Service/Extractor/MethodExtractorTest.php index b78d964..68027b4 100644 --- a/tests/Service/Extractor/MethodExtractorTest.php +++ b/tests/Service/Extractor/MethodExtractorTest.php @@ -6,12 +6,12 @@ use BowlOfSoup\NormalizerBundle\Service\Extractor\MethodExtractor; use BowlOfSoup\NormalizerBundle\Tests\assets\SomeClass; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; class MethodExtractorTest extends TestCase { - /** @var \PHPUnit\Framework\MockObject\MockObject|(\BowlOfSoup\NormalizerBundle\Service\Extractor\MethodExtractor&\PHPUnit\Framework\MockObject\MockObject) */ - private $methodExtractor; + private MethodExtractor&MockObject $methodExtractor; protected function setUp(): void { @@ -27,7 +27,6 @@ protected function setUp(): void */ public function testGetMethodsForNothing(): void { - /** @var \BowlOfSoup\NormalizerBundle\Service\Extractor\MethodExtractor $methodExtractor */ $methodExtractor = $this->methodExtractor; $result = $methodExtractor->getMethods('foo'); @@ -38,11 +37,10 @@ public function testGetMethodsForNothing(): void /** * @testdox Get all methods of a class. */ - public function testGetMethods() + public function testGetMethods(): void { $someClass = new SomeClass(); - /** @var \BowlOfSoup\NormalizerBundle\Service\Extractor\MethodExtractor $methodExtractor */ $methodExtractor = $this->methodExtractor; $methods = $methodExtractor->getMethods($someClass); $this->assertCount(8, $methods); @@ -65,13 +63,11 @@ public function testGetMethods() $method = $methods[3]; $this->assertSame('itProtec', $method->getName()); $this->assertInstanceOf(\ReflectionMethod::class, $method); - $method->setAccessible(true); $this->assertIsCallable($method->invoke($someClass)); $method = $methods[4]; $this->assertSame('secret', $method->getName()); $this->assertInstanceOf(\ReflectionMethod::class, $method); - $method->setAccessible(true); $this->assertIsObject($method->invoke($someClass)); $method = $methods[5]; @@ -82,13 +78,11 @@ public function testGetMethods() $method = $methods[6]; $this->assertSame('someParentMethod', $method->getName()); $this->assertInstanceOf(\ReflectionMethod::class, $method); - $method->setAccessible(true); $this->assertSame('hello', $method->invoke($someClass)); $method = $methods[7]; $this->assertSame('moreSecrets', $method->getName()); $this->assertInstanceOf(\ReflectionMethod::class, $method); - $method->setAccessible(true); $this->assertIsObject($method->invoke($someClass)); } } diff --git a/tests/Service/Extractor/PropertyExtractorTest.php b/tests/Service/Extractor/PropertyExtractorTest.php index a0e103b..fd4e16c 100644 --- a/tests/Service/Extractor/PropertyExtractorTest.php +++ b/tests/Service/Extractor/PropertyExtractorTest.php @@ -9,12 +9,12 @@ use BowlOfSoup\NormalizerBundle\Tests\assets\Person; use BowlOfSoup\NormalizerBundle\Tests\assets\ProxyObject; use BowlOfSoup\NormalizerBundle\Tests\assets\SomeClass; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; class PropertyExtractorTest extends TestCase { - /** @var (\BowlOfSoup\NormalizerBundle\Service\Extractor\PropertyExtractor&\PHPUnit\Framework\MockObject\MockObject)|\PHPUnit\Framework\MockObject\MockObject */ - private $propertyExtractor; + private PropertyExtractor&MockObject $propertyExtractor; protected function setUp(): void { @@ -30,7 +30,6 @@ protected function setUp(): void */ public function testGetMethodsForNothing(): void { - /** @var \BowlOfSoup\NormalizerBundle\Service\Extractor\PropertyExtractor $propertyExtractor */ $propertyExtractor = $this->propertyExtractor; $result = $propertyExtractor->getProperties('foo'); @@ -41,9 +40,9 @@ public function testGetMethodsForNothing(): void /** * @testdox Get all properties of a class. */ - public function testGetProperties() + public function testGetProperties(): void { - /** @var \BowlOfSoup\NormalizerBundle\Service\Extractor\PropertyExtractor $stubPropertyExtractor */ + /** @var PropertyExtractor&MockObject $stubPropertyExtractor */ $stubPropertyExtractor = $this ->getMockBuilder(PropertyExtractor::class) ->disableOriginalConstructor() @@ -58,7 +57,6 @@ public function testGetProperties() $property = $properties[0]; $this->assertSame('property32', $property->getName()); $this->assertInstanceOf(\ReflectionProperty::class, $property); - $property->setAccessible(true); $this->assertSame(123, $property->getValue($someClass)); $property = $properties[1]; @@ -73,13 +71,11 @@ public function testGetProperties() $property = $properties[3]; $this->assertSame('property2', $property->getName()); $this->assertInstanceOf(\ReflectionProperty::class, $property); - $property->setAccessible(true); $this->assertSame([], $property->getValue($someClass)); $property = $properties[4]; $this->assertSame('property1', $property->getName()); $this->assertInstanceOf(\ReflectionProperty::class, $property); - $property->setAccessible(true); $this->assertSame('string', $property->getValue($someClass)); } @@ -90,7 +86,6 @@ public function testGetPropertyValue(): void { $someClass = new SomeClass(); - /** @var \BowlOfSoup\NormalizerBundle\Service\Extractor\PropertyExtractor $propertyExtractor */ $propertyExtractor = $this->propertyExtractor; $properties = $propertyExtractor->getProperties($someClass); foreach ($properties as $property) { @@ -112,7 +107,6 @@ public function testGetPropertyValueForceGetMethodNoMethodAvailableDoctrineProxy $proxyObject = new ProxyObject(); - /** @var \BowlOfSoup\NormalizerBundle\Service\Extractor\PropertyExtractor $propertyExtractor */ $propertyExtractor = $this->propertyExtractor; $properties = $propertyExtractor->getProperties($proxyObject); foreach ($properties as $property) { @@ -134,7 +128,6 @@ public function testGetPropertyDoctrineProxyForceGetMethodAssertIdInteger(): voi $proxyObject = new ProxyObject(); - /** @var \BowlOfSoup\NormalizerBundle\Service\Extractor\PropertyExtractor $propertyExtractor */ $propertyExtractor = $this->propertyExtractor; $properties = $propertyExtractor->getProperties($proxyObject); foreach ($properties as $property) { @@ -154,7 +147,6 @@ public function testGetPropertyForceGetMethodBecauseOfException(): void $person = new Person(); $person->setSurName('BowlOfSoup'); - /** @var \BowlOfSoup\NormalizerBundle\Service\Extractor\PropertyExtractor $propertyExtractor */ $propertyExtractor = $this->propertyExtractor; $reflectionPropertyMock = $this @@ -188,7 +180,6 @@ public function testGetPropertyValueByMethod(): void { $someClass = new SomeClass(); - /** @var \BowlOfSoup\NormalizerBundle\Service\Extractor\PropertyExtractor $propertyExtractor */ $propertyExtractor = $this->propertyExtractor; $result = $propertyExtractor->getPropertyValueByMethod($someClass, 'getProperty32'); @@ -202,7 +193,6 @@ public function testGetPropertyValueByMethodNoMethodAvailable(): void { $someClass = new SomeClass(); - /** @var \BowlOfSoup\NormalizerBundle\Service\Extractor\PropertyExtractor $propertyExtractor */ $propertyExtractor = $this->propertyExtractor; $result = $propertyExtractor->getPropertyValueByMethod($someClass, 'getProperty53'); diff --git a/tests/Service/NormalizerTest.php b/tests/Service/NormalizerTest.php index f943a4d..89b4aff 100644 --- a/tests/Service/NormalizerTest.php +++ b/tests/Service/NormalizerTest.php @@ -27,8 +27,7 @@ class NormalizerTest extends TestCase { use NormalizerTestTrait; - /** @var \BowlOfSoup\NormalizerBundle\Service\Normalizer */ - private $normalizer; + private Normalizer $normalizer; protected function setUp(): void { @@ -53,7 +52,7 @@ public function testNormalizeSuccess(): void } /** - * @testdox Normalize an integer, but only objects or an array of objects are allowed. + * @testdox Normalize an integer, but only objects or an array of objects is allowed. */ public function testNormalizeInvalidDataType() { @@ -175,7 +174,7 @@ public function testNormalizeCircularReferenceNoFallback(): void $this->expectException(BosNormalizerException::class); $this->expectExceptionMessage('Circular reference on: BowlOfSoup\NormalizerBundle\Tests\assets\Person called from: BowlOfSoup\NormalizerBundle\Tests\assets\Social. If possible, prevent this by adding a getId() method to BowlOfSoup\NormalizerBundle\Tests\assets\Person'); - /* @var \BowlOfSoup\NormalizerBundle\Service\Extractor\ClassExtractor $classExtractor */ + /* @var ClassExtractor $classExtractor */ $this->classExtractor = $this ->getMockBuilder(ClassExtractor::class) ->onlyMethods(['getId']) @@ -199,7 +198,7 @@ public function testNormalizeCircularReferenceNoFallbackOnMethods(): void $this->expectException(BosNormalizerException::class); $this->expectExceptionMessage('Circular reference on: BowlOfSoup\NormalizerBundle\Tests\assets\Person called from: BowlOfSoup\NormalizerBundle\Tests\assets\Social. If possible, prevent this by adding a getId() method to BowlOfSoup\NormalizerBundle\Tests\assets\Person'); - /* @var \BowlOfSoup\NormalizerBundle\Service\Extractor\ClassExtractor $classExtractor */ + /* @var ClassExtractor $classExtractor */ $this->classExtractor = $this ->getMockBuilder(ClassExtractor::class) ->onlyMethods(['getId']) @@ -427,7 +426,7 @@ public function testNormalizeDateTimeString(): void public function testDontOverwriteChildConstructWithParent(): void { - $person = (new Person()) + $person = new Person() ->setName('child-foo') ->setDateOfBirth(new \DateTime('1987-11-17')); diff --git a/tests/Service/ObjectHelperTest.php b/tests/Service/ObjectHelperTest.php index 457b073..ba227be 100644 --- a/tests/Service/ObjectHelperTest.php +++ b/tests/Service/ObjectHelperTest.php @@ -12,7 +12,7 @@ class ObjectHelperTest extends TestCase { public function testObjectIdentifiedByGetIdMethod(): void { - $person = (new Person()) + $person = new Person() ->setId(123); $this->assertSame(123, ObjectHelper::getObjectIdentifier($person)); diff --git a/tests/Service/SerializerTest.php b/tests/Service/SerializerTest.php index ceb7f05..914411b 100644 --- a/tests/Service/SerializerTest.php +++ b/tests/Service/SerializerTest.php @@ -6,9 +6,13 @@ use BowlOfSoup\NormalizerBundle\Service\Encoder\EncoderFactory; use BowlOfSoup\NormalizerBundle\Service\Encoder\EncoderJson; -use BowlOfSoup\NormalizerBundle\Service\Normalizer; use BowlOfSoup\NormalizerBundle\Service\Serializer; +use BowlOfSoup\NormalizerBundle\Tests\assets\AddressWithAttributes; +use BowlOfSoup\NormalizerBundle\Tests\assets\MixedAnnotations; +use BowlOfSoup\NormalizerBundle\Tests\assets\OrderWithAttributes; use BowlOfSoup\NormalizerBundle\Tests\assets\Person; +use BowlOfSoup\NormalizerBundle\Tests\assets\PersonWithAttributes; +use BowlOfSoup\NormalizerBundle\Tests\assets\ProductWithAttributes; use BowlOfSoup\NormalizerBundle\Tests\assets\Social; use BowlOfSoup\NormalizerBundle\Tests\SerializerTestTrait; use PHPUnit\Framework\TestCase; @@ -17,11 +21,7 @@ class SerializerTest extends TestCase { use SerializerTestTrait; - /** @var \BowlOfSoup\NormalizerBundle\Service\Normalizer */ - private $normalizer; - - /** @var \BowlOfSoup\NormalizerBundle\Service\Serializer */ - private $serializer; + private Serializer $serializer; protected function setUp(): void { @@ -74,10 +74,250 @@ public function testSerializeNoClassAnnotation(): void $this->assertSame( '@bos', - trim(preg_replace('/\s+/', '', $this->serializer->serialize($social, EncoderFactory::TYPE_XML, 'default'))) + trim((string) preg_replace('/\s+/', '', $this->serializer->serialize($social, EncoderFactory::TYPE_XML, 'default'))) ); } + /** + * @testdox Serialize object with PHP 8 attributes instead of docblock annotations. + */ + public function testSerializeWithPhp8Attributes(): void + { + $person = new PersonWithAttributes(); + $person->setId(999); + $person->setName('John Doe'); + $person->setEmail('john@example.com'); + $person->setBirthDate(new \DateTime('1990-05-15')); + + $address = new AddressWithAttributes(); + $address->setId(1); + $address->setStreet('Main Street'); + $address->setNumber(123); + $address->setCity('Amsterdam'); + $address->setPostalCode('1234AB'); + $person->setPrimaryAddress($address); + + $result = $this->serializer->serialize($person, EncoderFactory::TYPE_JSON, 'default'); + $decoded = json_decode($result, true); + + $this->assertArrayHasKey('data', $decoded); + $this->assertSame(999, $decoded['data']['id']); + $this->assertSame('John Doe', $decoded['data']['full_name']); + $this->assertSame('john@example.com', $decoded['data']['email']); + $this->assertSame('1990-05-15', $decoded['data']['birthDate']); + $this->assertSame('1990', $decoded['data']['birthYear']); + $this->assertSame('john@example.com', $decoded['data']['contactEmail']); + $this->assertArrayHasKey('primaryAddress', $decoded['data']); + $this->assertSame('Main Street', $decoded['data']['primaryAddress']['street']); + $this->assertSame(123, $decoded['data']['primaryAddress']['number']); + $this->assertSame('Amsterdam', $decoded['data']['primaryAddress']['city']); + } + + /** + * @testdox Serialize object with mixed docblock and PHP 8 attribute annotations. + */ + public function testSerializeWithMixedAnnotations(): void + { + $mixed = new MixedAnnotations(); + $mixed->setIdFromDocblock(100); + $mixed->setNameFromAttribute('Mixed Test'); + $mixed->setDualAnnotated('Dual Value'); + + $resultDocblock = $this->serializer->serialize($mixed, EncoderFactory::TYPE_JSON, 'docblock'); + $resultAttribute = $this->serializer->serialize($mixed, EncoderFactory::TYPE_JSON, 'attribute'); + $resultDefault = $this->serializer->serialize($mixed, EncoderFactory::TYPE_JSON, 'default'); + + $decodedDocblock = json_decode($resultDocblock, true); + $decodedAttribute = json_decode($resultAttribute, true); + $decodedDefault = json_decode($resultDefault, true); + + $this->assertArrayHasKey('mixed', $decodedDocblock); + $this->assertArrayHasKey('idFromDocblock', $decodedDocblock['mixed']); + $this->assertSame(100, $decodedDocblock['mixed']['idFromDocblock']); + + $this->assertArrayHasKey('attributes', $decodedAttribute); + $this->assertArrayHasKey('nameFromAttribute', $decodedAttribute['attributes']); + $this->assertSame('Mixed Test', $decodedAttribute['attributes']['nameFromAttribute']); + + $this->assertArrayHasKey('nameFromAttribute', $decodedDefault); + $this->assertSame('Mixed Test', $decodedDefault['nameFromAttribute']); + } + + /** + * @testdox Serialize with PHP 8 attributes using different groups (api vs default). + */ + public function testSerializeAttributesWithGroups(): void + { + $person = new PersonWithAttributes(); + $person->setId(555); + $person->setName('Jane Doe'); + $person->setEmail('jane@example.com'); + + $resultApi = $this->serializer->serialize($person, EncoderFactory::TYPE_JSON, 'api'); + $decodedApi = json_decode($resultApi, true); + + $this->assertArrayHasKey('id', $decodedApi); + $this->assertArrayHasKey('email', $decodedApi); + $this->assertArrayNotHasKey('full_name', $decodedApi); + } + + /** + * @testdox Serialize with PHP 8 attributes using translation annotations. + */ + public function testSerializeAttributesWithTranslation(): void + { + $product = new ProductWithAttributes(); + $product->setId(100); + $product->setName('laptop'); + $product->setDescription('gaming.laptop'); + $product->setPrice(1299.99); + $product->setCost(800.00); + $product->setCreatedAt(new \DateTime('2024-01-15')); + + $result = $this->serializer->serialize($product, EncoderFactory::TYPE_JSON, 'api'); + $decoded = json_decode($result, true); + + $this->assertArrayHasKey('product', $decoded); + $this->assertSame(100, $decoded['product']['id']); + $this->assertSame('translatedValue', $decoded['product']['product_name']); + $this->assertSame('translatedValue', $decoded['product']['description']); + $this->assertSame('2024-01-15', $decoded['product']['createdAt']); + $this->assertSame(1299.99, $decoded['product']['price']); + $this->assertSame('$1299.99', $decoded['product']['formatted_price']); + $this->assertArrayNotHasKey('cost', $decoded['product']); + } + + /** + * @testdox Serialize with PHP 8 attributes testing different groups (api vs internal). + */ + public function testSerializeAttributesWithDifferentGroups(): void + { + $product = new ProductWithAttributes(); + $product->setId(200); + $product->setName('keyboard'); + $product->setPrice(99.99); + $product->setCost(45.00); + + $resultApi = $this->serializer->serialize($product, EncoderFactory::TYPE_JSON, 'api'); + $decodedApi = json_decode($resultApi, true); + + $resultInternal = $this->serializer->serialize($product, EncoderFactory::TYPE_JSON, 'internal'); + $decodedInternal = json_decode($resultInternal, true); + + // API group should not include cost or profit_margin + $this->assertArrayHasKey('product', $decodedApi); + $this->assertArrayNotHasKey('cost', $decodedApi['product']); + $this->assertArrayNotHasKey('profit_margin', $decodedApi['product']); + + // Internal group should include cost and profit_margin + $this->assertArrayHasKey('product', $decodedInternal); + $this->assertEqualsWithDelta(45.00, $decodedInternal['product']['cost'], 0.01); + $this->assertEqualsWithDelta(122.2, $decodedInternal['product']['profit_margin'], 0.1); + $this->assertArrayNotHasKey('formatted_price', $decodedInternal['product']); + } + + /** + * @testdox Serialize complex object with PHP 8 attributes including collections and nested objects. + */ + public function testSerializeAttributesWithCollectionsAndNestedObjects(): void + { + $order = new OrderWithAttributes(); + $order->setId(1000); + $order->setOrderNumber('ORD-2024-001'); + $order->setOrderDate(new \DateTime('2024-01-01T10:00:00')); + $order->setEmail('customer@example.com'); + + $product1 = new ProductWithAttributes(); + $product1->setId(1); + $product1->setName('Mouse'); + $product1->setPrice(29.99); + + $product2 = new ProductWithAttributes(); + $product2->setId(2); + $product2->setName('Keyboard'); + $product2->setPrice(79.99); + + $order->addItem($product1); + $order->addItem($product2); + + $address = new AddressWithAttributes(); + $address->setId(500); + $address->setStreet('Main St'); + $address->setNumber(123); + $address->setCity('Amsterdam'); + $address->setPostalCode('1000AA'); + $order->setShippingAddress($address); + + $result = $this->serializer->serialize($order, EncoderFactory::TYPE_JSON, 'api'); + $decoded = json_decode($result, true); + + $this->assertArrayHasKey('order', $decoded); + $this->assertSame(1000, $decoded['order']['id']); + $this->assertSame('ORD-2024-001', $decoded['order']['order_number']); + $this->assertSame('customer@example.com', $decoded['order']['customer_email']); + $this->assertSame(2, $decoded['order']['total_items']); + $this->assertIsString($decoded['order']['order_status']); + $this->assertIsArray($decoded['order']['items']); + $this->assertCount(2, $decoded['order']['items']); + $this->assertArrayHasKey('shippingAddress', $decoded['order']); + $this->assertIsArray($decoded['order']['shippingAddress']); + $this->assertSame('Amsterdam', $decoded['order']['shippingAddress']['city']); + } + + /** + * @testdox Serialize with PHP 8 attributes using skipEmpty functionality. + */ + public function testSerializeAttributesWithSkipEmpty(): void + { + $order1 = new OrderWithAttributes(); + $order1->setId(2000); + $order1->setOrderNumber('ORD-2024-002'); + $order1->setOrderDate(new \DateTime('2024-01-01')); + // email and internalNotes are null + + $result1 = $this->serializer->serialize($order1, EncoderFactory::TYPE_JSON, 'admin'); + $decoded1 = json_decode($result1, true); + + // Empty fields with skipEmpty should not be present + $this->assertArrayNotHasKey('customer_email', $decoded1['order']); + $this->assertArrayNotHasKey('internalNotes', $decoded1['order']); + + $order2 = new OrderWithAttributes(); + $order2->setId(3000); + $order2->setOrderNumber('ORD-2024-003'); + $order2->setOrderDate(new \DateTime('2024-01-01')); + $order2->setEmail('test@example.com'); + $order2->setInternalNotes('Priority shipping'); + + $result2 = $this->serializer->serialize($order2, EncoderFactory::TYPE_JSON, 'admin'); + $decoded2 = json_decode($result2, true); + + // Non-empty fields should be present + $this->assertArrayHasKey('customer_email', $decoded2['order']); + $this->assertSame('test@example.com', $decoded2['order']['customer_email']); + $this->assertArrayHasKey('internalNotes', $decoded2['order']); + $this->assertSame('Priority shipping', $decoded2['order']['internalNotes']); + } + + /** + * @testdox Serialize with PHP 8 attributes testing method normalization with computed values. + */ + public function testSerializeAttributesWithComputedMethodValues(): void + { + $product = new ProductWithAttributes(); + $product->setId(300); + $product->setName('monitor'); + $product->setPrice(399.99); + $product->setCost(250.00); + + $result = $this->serializer->serialize($product, EncoderFactory::TYPE_JSON, 'internal'); + $decoded = json_decode($result, true); + + $this->assertArrayHasKey('product', $decoded); + $this->assertArrayHasKey('profit_margin', $decoded['product']); + $this->assertEqualsWithDelta(60.0, $decoded['product']['profit_margin'], 0.1); + } + private function getPersonObject(): Person { $person = new Person(); diff --git a/tests/Service/UnknownPropertyTest.php b/tests/Service/UnknownPropertyTest.php index 826588c..ab69c4d 100644 --- a/tests/Service/UnknownPropertyTest.php +++ b/tests/Service/UnknownPropertyTest.php @@ -19,11 +19,8 @@ class UnknownPropertyTest extends TestCase { use SerializerTestTrait; - /** @var \BowlOfSoup\NormalizerBundle\Service\Normalizer */ - private $normalizer; - - /** @var \BowlOfSoup\NormalizerBundle\Service\Serializer */ - private $serializer; + private Normalizer $normalizer; + private Serializer $serializer; public function setUp(): void { diff --git a/tests/assets/AbstractClass.php b/tests/assets/AbstractClass.php index c088f0a..5556655 100644 --- a/tests/assets/AbstractClass.php +++ b/tests/assets/AbstractClass.php @@ -6,10 +6,9 @@ abstract class AbstractClass { - /** @var string */ - private $property1 = 'string'; + protected array $property2 = []; - protected $property2 = []; + private string $property1 = 'string'; protected function someParentMethod(): string { diff --git a/tests/assets/AbstractPerson.php b/tests/assets/AbstractPerson.php index 2987fc3..1690e63 100644 --- a/tests/assets/AbstractPerson.php +++ b/tests/assets/AbstractPerson.php @@ -10,10 +10,8 @@ abstract class AbstractPerson { /** * @Bos\Normalize(group={"parent_test"}) - * - * @var string */ - private $name = 'parent-foo'; + private string $name = 'parent-foo'; /** * @Bos\Normalize(type="DateTime", group={"parent_test"}) diff --git a/tests/assets/Address.php b/tests/assets/Address.php index 65f18b2..1ce5bc8 100644 --- a/tests/assets/Address.php +++ b/tests/assets/Address.php @@ -10,122 +10,89 @@ class Address { /** - * @var string - * * @Bos\Normalize(group={"default"}) */ - private $street; + private ?string $street = null; /** - * @var int - * * @Bos\Normalize(group={"default"}) */ - private $number; + private ?int $number = null; /** - * @var string - * * @Bos\Normalize(group={"default"}) */ - private $postalCode; + private ?string $postalCode = null; /** - * @var string - * * @Bos\Normalize(group={"default"}, callback="getCityWithFormat") */ - private $city; + private ?string $city = null; /** * @Bos\Normalize(group={"maxDepthTestDepth1"}, type="collection") - * - * @var Collection|null */ - private $group = null; + private ?Collection $group = null; - /** - * @return string - */ - public function getStreet() + public function getStreet(): ?string { return $this->street; } /** - * @param string $street - * * @return $this */ - public function setStreet($street) + public function setStreet(?string $street): self { $this->street = $street; return $this; } - /** - * @return int - */ - public function getNumber() + public function getNumber(): ?int { return $this->number; } /** - * @param int $number - * * @return $this */ - public function setNumber($number) + public function setNumber(?int $number): self { $this->number = $number; return $this; } - /** - * @return string - */ - public function getPostalCode() + public function getPostalCode(): ?string { return $this->postalCode; } /** - * @param string $postalCode - * * @return $this */ - public function setPostalCode($postalCode) + public function setPostalCode(?string $postalCode): self { $this->postalCode = $postalCode; return $this; } - /** - * @return string - */ - public function getCity() + public function getCity(): ?string { return $this->city; } - /** - * @return string - */ - public function getCityWithFormat() + public function getCityWithFormat(): string { return 'The City Is: ' . $this->city; } /** - * @param string $city - * * @return $this */ - public function setCity($city) + public function setCity(?string $city): self { $this->city = $city; @@ -133,11 +100,9 @@ public function setCity($city) } /** - * @return Collection - * * @Bos\Normalize(type="collection", name="group", group={"maxDepthTestDepth1OnMethod"}) */ - public function getGroup() + public function getGroup(): ?Collection { return $this->group; } @@ -145,7 +110,7 @@ public function getGroup() /** * @return $this */ - public function setGroup(Collection $group) + public function setGroup(Collection $group): self { $this->group = $group; diff --git a/tests/assets/AddressWithAttributes.php b/tests/assets/AddressWithAttributes.php new file mode 100644 index 0000000..a23abc4 --- /dev/null +++ b/tests/assets/AddressWithAttributes.php @@ -0,0 +1,101 @@ +id; + } + + public function setId(?int $id): self + { + $this->id = $id; + + return $this; + } + + public function getStreet(): ?string + { + return $this->street; + } + + public function setStreet(?string $street): self + { + $this->street = $street; + + return $this; + } + + public function getNumber(): ?int + { + return $this->number; + } + + public function setNumber(?int $number): self + { + $this->number = $number; + + return $this; + } + + public function getCity(): ?string + { + return $this->city; + } + + public function setCity(?string $city): self + { + $this->city = $city; + + return $this; + } + + public function getPostalCode(): ?string + { + return $this->postalCode; + } + + public function setPostalCode(?string $postalCode): self + { + $this->postalCode = $postalCode; + + return $this; + } + + #[Bos\Normalize(group: ['default', 'api', 'admin'], name: 'fullAddress')] + public function getFullAddress(): string + { + return sprintf( + '%s %s, %s %s', + $this->street ?? '', + $this->number ?? '', + $this->postalCode ?? '', + $this->city ?? '' + ); + } +} diff --git a/tests/assets/BrokenAttributeClass.php b/tests/assets/BrokenAttributeClass.php new file mode 100644 index 0000000..0467421 --- /dev/null +++ b/tests/assets/BrokenAttributeClass.php @@ -0,0 +1,31 @@ +brokenProperty; + } +} diff --git a/tests/assets/Group.php b/tests/assets/Group.php index 8ccc392..8741372 100644 --- a/tests/assets/Group.php +++ b/tests/assets/Group.php @@ -10,59 +10,45 @@ class Group { /** * @Bos\Normalize(group={"maxDepthTestDepth1", "default"}) - * - * @var int */ - private $id; + private int $id; /** * @Bos\Normalize(group={"maxDepthTestDepth1", "default"}) - * - * @var string */ - private $name; + private string $name; /** * @Bos\Normalize(group={"default"}) * - * @var \BowlOfSoup\NormalizerBundle\Tests\assets\Person[]|array|null + * @var Person[]|null */ - private $persons = null; + private ?array $persons = null; - /** - * @return int - */ - public function getId() + public function getId(): int { return $this->id; } /** - * @param int $id - * * @return $this */ - public function setId($id) + public function setId(int $id): self { $this->id = $id; return $this; } - /** - * @return mixed - */ - public function getName() + public function getName(): string { return $this->name; } /** - * @param mixed $name - * * @return $this */ - public function setName($name) + public function setName(string $name): self { $this->name = $name; @@ -70,7 +56,7 @@ public function setName($name) } /** - * @return \BowlOfSoup\NormalizerBundle\Tests\assets\Person[] + * @return Person[] */ public function getPersons(): array { @@ -78,7 +64,7 @@ public function getPersons(): array } /** - * @param \BowlOfSoup\NormalizerBundle\Tests\assets\Person[] $persons + * @param Person[] $persons * * @return $this */ diff --git a/tests/assets/Hobbies.php b/tests/assets/Hobbies.php index 3989889..caa302b 100644 --- a/tests/assets/Hobbies.php +++ b/tests/assets/Hobbies.php @@ -10,24 +10,18 @@ class Hobbies { /** * @Bos\Normalize(group={"default", "duplicateObjectId"}) - * - * @var int|null */ - private $id = null; + private ?int $id = null; /** - * @var string - * * @Bos\Normalize(group={"default", "duplicateObjectId"}) */ - private $description; + private string $description; /** - * @var HobbyType - * - * @Bos\Normalize(group={"default", "duplicateObjectId"}, type="object")"}, type="object") + * @Bos\Normalize(group={"default", "duplicateObjectId"}, type="object") */ - private $hobbyType; + private HobbyType $hobbyType; public function getId(): int { @@ -44,40 +38,30 @@ public function setId(int $id): self return $this; } - /** - * @return string - */ - public function getDescription() + public function getDescription(): string { return $this->description; } /** - * @param string $description - * * @return $this */ - public function setDescription($description) + public function setDescription(string $description): self { $this->description = $description; return $this; } - /** - * @return HobbyType - */ - public function getHobbyType() + public function getHobbyType(): HobbyType { return $this->hobbyType; } /** - * @param HobbyType $hobbyType - * * @return $this */ - public function setHobbyType($hobbyType) + public function setHobbyType(HobbyType $hobbyType): self { $this->hobbyType = $hobbyType; diff --git a/tests/assets/HobbyType.php b/tests/assets/HobbyType.php index 7b28316..9721373 100644 --- a/tests/assets/HobbyType.php +++ b/tests/assets/HobbyType.php @@ -1,5 +1,7 @@ id; } /** - * @param int $id - * * @return $this */ - public function setId($id) + public function setId(int $id): self { $this->id = $id; return $this; } - /** - * @return mixed - */ - public function getName() + public function getName(): string { return $this->name; } /** - * @param mixed $name - * * @return $this */ - public function setName($name) + public function setName(string $name): self { $this->name = $name; diff --git a/tests/assets/MixedAnnotations.php b/tests/assets/MixedAnnotations.php new file mode 100644 index 0000000..ebd535f --- /dev/null +++ b/tests/assets/MixedAnnotations.php @@ -0,0 +1,92 @@ +idFromDocblock; + } + + public function setIdFromDocblock(?int $idFromDocblock): self + { + $this->idFromDocblock = $idFromDocblock; + + return $this; + } + + public function getNameFromAttribute(): ?string + { + return $this->nameFromAttribute; + } + + public function setNameFromAttribute(?string $nameFromAttribute): self + { + $this->nameFromAttribute = $nameFromAttribute; + + return $this; + } + + public function getDualAnnotated(): ?string + { + return $this->dualAnnotated; + } + + public function setDualAnnotated(?string $dualAnnotated): self + { + $this->dualAnnotated = $dualAnnotated; + + return $this; + } + + /** + * This method uses docblock annotation. + * + * @Bos\Normalize(group={"docblock", "default"}, type="DateTime", format="Y-m-d") + */ + public function getCreatedAtDocblock(): \DateTime + { + return new \DateTime('2024-01-01'); + } + + /** + * This method uses PHP 8 attribute. + */ + #[Bos\Normalize(group: ['attribute', 'default'], type: 'DateTime', format: 'Y-m-d H:i:s')] + public function getCreatedAtAttribute(): \DateTime + { + return new \DateTime('2024-01-01 12:00:00'); + } +} diff --git a/tests/assets/OrderWithAttributes.php b/tests/assets/OrderWithAttributes.php new file mode 100644 index 0000000..e490a53 --- /dev/null +++ b/tests/assets/OrderWithAttributes.php @@ -0,0 +1,166 @@ +items = new ArrayCollection(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function setId(?int $id): self + { + $this->id = $id; + + return $this; + } + + public function getOrderNumber(): ?string + { + return $this->orderNumber; + } + + public function setOrderNumber(?string $orderNumber): self + { + $this->orderNumber = $orderNumber; + + return $this; + } + + public function getOrderDate(): ?\DateTime + { + return $this->orderDate; + } + + public function setOrderDate(?\DateTime $orderDate): self + { + $this->orderDate = $orderDate; + + return $this; + } + + public function getItems(): ?Collection + { + return $this->items; + } + + public function setItems(?Collection $items): self + { + $this->items = $items; + + return $this; + } + + public function addItem(ProductWithAttributes $item): self + { + if (!$this->items->contains($item)) { + $this->items->add($item); + } + + return $this; + } + + public function getShippingAddress(): ?AddressWithAttributes + { + return $this->shippingAddress; + } + + public function setShippingAddress(?AddressWithAttributes $shippingAddress): self + { + $this->shippingAddress = $shippingAddress; + + return $this; + } + + public function getInternalNotes(): ?string + { + return $this->internalNotes; + } + + public function setInternalNotes(?string $internalNotes): self + { + $this->internalNotes = $internalNotes; + + return $this; + } + + public function getEmail(): ?string + { + return $this->email; + } + + public function setEmail(?string $email): self + { + $this->email = $email; + + return $this; + } + + #[Bos\Normalize(group: ['api', 'admin'], name: 'total_items')] + public function getTotalItems(): int + { + return $this->items ? $this->items->count() : 0; + } + + #[Bos\Normalize(group: ['api', 'admin'], name: 'order_status')] + public function getStatus(): string + { + if (null === $this->orderDate) { + return 'draft'; + } + + $now = new \DateTime(); + $diff = $now->diff($this->orderDate)->days; + + if ($diff < 1) { + return 'new'; + } + + if ($diff < 7) { + return 'processing'; + } + + return 'shipped'; + } +} diff --git a/tests/assets/Person.php b/tests/assets/Person.php index 5a6abad..d408d60 100644 --- a/tests/assets/Person.php +++ b/tests/assets/Person.php @@ -5,7 +5,6 @@ namespace BowlOfSoup\NormalizerBundle\Tests\assets; use BowlOfSoup\NormalizerBundle\Annotation as Bos; -use DateTime; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; @@ -22,234 +21,182 @@ class Person extends AbstractPerson { /** - * @var int - * * @Bos\Normalize(group={"default", "translation"}) * * @Bos\Translate(group={"translation"}) */ - private $id; + private ?int $id = null; /** - * @var string - * * @Bos\Normalize(group={"default"}, name="name_value") * @Bos\Normalize(group={"parent_test"}) */ - private $name; + private ?string $name = null; /** - * @var string - * * @Bos\Normalize(group={"default", "anotherGroup"}) */ - private $surName; + private ?string $surName = null; /** - * @var string - * * @Bos\Normalize(group={"default"}, type="collection") */ - private $initials; + private ?string $initials = null; /** - * @var string - * * @Bos\Normalize() * @Bos\Normalize(group={"translation"}) * * @Bos\Translate(group={"translation"}) */ - private $gender; + private ?string $gender = null; /** * @Bos\Normalize(group={"default"}, type="DateTime", format="Y-m-d") - * - * @var \DateTime|null */ - private $dateOfBirth = null; + private ?\DateTime $dateOfBirth = null; - /** @var \DateTime|null */ - private $dateOfRegistration = null; + private ?\DateTime $dateOfRegistration = null; /** - * @var DateTime - * * @Bos\Normalize(group={"dateTimeTest"}, type="DateTime", format="M. Y", callback="calculateDeceasedDate") */ - private $deceasedDate; + private ?\DateTime $deceasedDate = null; /** * @Bos\Normalize(group={"default", "maxDepthTestDepth1", "maxDepthTestDepthNoIdentifier"}, type="collection") * @Bos\Normalize(group={"noContentForCollectionTest"}, type="collection") * @Bos\Normalize(group={"anotherGroup"}, type="collection") - * - * @var Collection|null */ - private $addresses = null; + private ?Collection $addresses = null; /** * @Bos\Normalize(group={"default", "maxDepthTestDepth0"}, type="object") * @Bos\Normalize(group={"noContentForCollectionTest"}, type="object") - * - * @var Social|null */ - private $social = null; + private ?Social $social = null; /** * @Bos\Normalize(group={"default"}, type="object", callback="toArray") - * - * @var TelephoneNumbers|null */ - private $telephoneNumbers = null; + private ?TelephoneNumbers $telephoneNumbers = null; /** - * @var \BowlOfSoup\NormalizerBundle\Tests\assets\Hobbies[] - * * @Bos\Normalize(group={"default", "duplicateObjectId"}, type="collection") + * + * @var Hobbies[]|null */ - private $hobbies; + private mixed $hobbies = null; /** * @Bos\Normalize(group={"default"}, type="object") */ - protected $validEmptyObjectProperty; + protected mixed $validEmptyObjectProperty = null; /** - * @var array - * * @Bos\Normalize(group={"default"}, type="collection") */ - protected $nonValidCollectionProperty = ['123', '456']; + protected array $nonValidCollectionProperty = ['123', '456']; /** * @Bos\Normalize(group={"default"}, type="collection", callback="getProperty32") - * - * @var array|null */ - private $validCollectionPropertyWithCallback = null; + private ?array $validCollectionPropertyWithCallback = null; /** * @Bos\Normalize(group={"default"}, callback="getTestForNormalizingCallback", normalizeCallbackResult=true) */ - private $testForNormalizingCallback; + private mixed $testForNormalizingCallback = null; /** * @Bos\Normalize(group={"default"}, callback="getTestForNormalizingCallbackObject", normalizeCallbackResult=true) */ - private $testForNormalizingCallbackObject; + private mixed $testForNormalizingCallbackObject = null; /** * @Bos\Normalize(group={"default"}, callback="getTestForNormalizingCallbackString", normalizeCallbackResult=true) */ - private $testForNormalizingCallbackString; + private mixed $testForNormalizingCallbackString = null; /** * @Bos\Normalize(group={"default"}, callback="getTestForNormalizingCallbackArray", normalizeCallbackResult=true) */ - private $testForNormalizingCallbackArray; + private mixed $testForNormalizingCallbackArray = null; /** * @Bos\Normalize(group={"default"}, type="object") - * - * @var ProxyObject|null */ - private $testForProxy = null; + private ?ProxyObject $testForProxy = null; - /** - * @return int - */ - public function getId() + public function getId(): ?int { return $this->id; } /** - * @param int $id - * * @return $this */ - public function setId($id) + public function setId(?int $id): self { $this->id = $id; return $this; } - /** - * @return string - */ - public function getName() + public function getName(): ?string { return $this->name; } /** - * @param string $name - * * @return $this */ - public function setName($name) + public function setName(?string $name): self { $this->name = $name; return $this; } - /** - * @return string - */ - public function getSurName() + public function getSurName(): ?string { return $this->surName; } /** - * @param string $surName - * * @return $this */ - public function setSurName($surName) + public function setSurName(?string $surName): self { $this->surName = $surName; return $this; } - /** - * @return string - */ - public function getInitials() + public function getInitials(): ?string { return $this->initials; } /** - * @param string $initials - * * @return $this */ - public function setInitials($initials) + public function setInitials(?string $initials): self { $this->initials = $initials; return $this; } - /** - * @return string - */ - public function getGender() + public function getGender(): ?string { return $this->gender; } /** - * @param string $gender - * * @return $this */ - public function setGender($gender) + public function setGender(?string $gender): self { $this->gender = $gender; @@ -257,12 +204,11 @@ public function setGender($gender) } /** - * @return \DateTime - * * @Bos\Normalize(type="DateTime", name="dateOfBirth") * @Bos\Normalize(type="DateTime", group={"parent_test"}) */ - public function getDateOfBirth() + #[\Override] + public function getDateOfBirth(): \DateTime { return $this->dateOfBirth; } @@ -270,7 +216,7 @@ public function getDateOfBirth() /** * @return $this */ - public function setDateOfBirth(DateTime $dateOfBirth) + public function setDateOfBirth(\DateTime $dateOfBirth): self { $this->dateOfBirth = $dateOfBirth; @@ -278,14 +224,12 @@ public function setDateOfBirth(DateTime $dateOfBirth) } /** - * @return DateTime - * * @Bos\Normalize(group={"default"}, name="dateOfRegistration", type="DateTime", format="M. Y") */ - public function getDateOfRegistration() + public function getDateOfRegistration(): \DateTime { if (null === $this->dateOfRegistration) { - return new DateTime('2015-04-23'); + return new \DateTime('2015-04-23'); } return $this->dateOfRegistration; @@ -294,7 +238,7 @@ public function getDateOfRegistration() /** * @return $this */ - public function setDateOfRegistration(DateTime $dateOfRegistration) + public function setDateOfRegistration(\DateTime $dateOfRegistration): self { $this->dateOfRegistration = $dateOfRegistration; @@ -305,7 +249,7 @@ public function setDateOfRegistration(DateTime $dateOfRegistration) * @Bos\Normalize(group={"dateTimeTest"}, type="DateTime", format="M. Y") * @Bos\Normalize(group={"methodWithCallback"}, type="DateTime", format="M. Y", callback="getAddresses") */ - public function calculateDeceasedDate(): DateTime + public function calculateDeceasedDate(): \DateTime { return new \DateTime('2020-01-01'); } @@ -313,7 +257,7 @@ public function calculateDeceasedDate(): DateTime /** * @Bos\Normalize(group={"methodWithCallbackAndNoType"}, callback="getAddresses") */ - public function calculateDeceasedDate2(): DateTime + public function calculateDeceasedDate2(): \DateTime { return new \DateTime('2020-01-01'); } @@ -327,12 +271,10 @@ public function calculateDeceasedDateAsString(): string } /** - * @return Collection - * * @Bos\Normalize(type="collection", group={"maxDepthTestDepth0OnMethod"}) * @Bos\Normalize(type="collection", group={"maxDepthTestDepth1OnMethod"}) */ - public function getAddresses() + public function getAddresses(): ?Collection { return $this->addresses; } @@ -340,7 +282,7 @@ public function getAddresses() /** * @return $this */ - public function setAddresses(Collection $addresses) + public function setAddresses(Collection $addresses): self { $this->addresses = $addresses; @@ -348,13 +290,11 @@ public function setAddresses(Collection $addresses) } /** - * @return Social - * * @Bos\Normalize(type="object", group={"circRefMethod"}) * @Bos\Normalize(type="object", group={"maxDepthTestDepth0OnMethodWithObject"}) * @Bos\Normalize(type="object", group={"callbackOnMethodWithObject"}, callback="getAddresses") */ - public function getSocial() + public function getSocial(): ?Social { return $this->social; } @@ -362,7 +302,7 @@ public function getSocial() /** * @return $this */ - public function setSocial(Social $social) + public function setSocial(Social $social): self { $this->social = $social; @@ -370,11 +310,9 @@ public function setSocial(Social $social) } /** - * @return TelephoneNumbers - * * @Bos\Normalize(group={"noContentForCollectionTest"}, type="object") */ - public function getTelephoneNumbers() + public function getTelephoneNumbers(): ?TelephoneNumbers { return $this->telephoneNumbers; } @@ -382,7 +320,7 @@ public function getTelephoneNumbers() /** * @return $this */ - public function setTelephoneNumbers(TelephoneNumbers $telephoneNumbers) + public function setTelephoneNumbers(TelephoneNumbers $telephoneNumbers): self { $this->telephoneNumbers = $telephoneNumbers; @@ -390,7 +328,7 @@ public function setTelephoneNumbers(TelephoneNumbers $telephoneNumbers) } /** - * @return \BowlOfSoup\NormalizerBundle\Tests\assets\Hobbies[] + * @return Hobbies[] */ public function getHobbies(): array { @@ -398,11 +336,11 @@ public function getHobbies(): array } /** - * @param \BowlOfSoup\NormalizerBundle\Tests\assets\Hobbies[] $hobbies + * @param Hobbies[] $hobbies * * @return $this */ - public function setHobbies($hobbies) + public function setHobbies(mixed $hobbies): self { $this->hobbies = $hobbies; @@ -412,25 +350,19 @@ public function setHobbies($hobbies) /** * @return $this */ - public function setValidCollectionPropertyWithCallback(array $validCollectionPropertyWithCallback) + public function setValidCollectionPropertyWithCallback(array $validCollectionPropertyWithCallback): self { $this->validCollectionPropertyWithCallback = $validCollectionPropertyWithCallback; return $this; } - /** - * @return string - */ - public function normalizeValidCollectionPropertyWithCallback() + public function normalizeValidCollectionPropertyWithCallback(): string { return 'test'; } - /** - * @return ArrayCollection - */ - public function getTestForNormalizingCallback() + public function getTestForNormalizingCallback(): ArrayCollection { $addressCollection = new ArrayCollection(); $address1 = new Address(); @@ -445,10 +377,7 @@ public function getTestForNormalizingCallback() return $addressCollection; } - /** - * @return Address - */ - public function getTestForNormalizingCallbackObject() + public function getTestForNormalizingCallbackObject(): Address { $address1 = new Address(); $address1->setStreet('Dummy Street'); @@ -457,18 +386,12 @@ public function getTestForNormalizingCallbackObject() return $address1; } - /** - * @return string - */ - public function getTestForNormalizingCallbackString() + public function getTestForNormalizingCallbackString(): string { return 'asdasd'; } - /** - * @return array - */ - public function getTestForNormalizingCallbackArray() + public function getTestForNormalizingCallbackArray(): array { return [ '123', @@ -477,15 +400,12 @@ public function getTestForNormalizingCallbackArray() ]; } - /** - * @return ProxyObject - */ - public function getTestForProxy() + public function getTestForProxy(): ?ProxyObject { return $this->testForProxy; } - public function setTestForProxy(ProxyObject $proxyObject) + public function setTestForProxy(ProxyObject $proxyObject): void { $this->testForProxy = $proxyObject; } diff --git a/tests/assets/PersonWithAttributes.php b/tests/assets/PersonWithAttributes.php new file mode 100644 index 0000000..260fe7a --- /dev/null +++ b/tests/assets/PersonWithAttributes.php @@ -0,0 +1,117 @@ +id; + } + + public function setId(?int $id): self + { + $this->id = $id; + + return $this; + } + + public function getName(): ?string + { + return $this->name; + } + + public function setName(?string $name): self + { + $this->name = $name; + + return $this; + } + + public function getEmail(): ?string + { + return $this->email; + } + + public function setEmail(?string $email): self + { + $this->email = $email; + + return $this; + } + + public function getBirthDate(): ?\DateTime + { + return $this->birthDate; + } + + public function setBirthDate(?\DateTime $birthDate): self + { + $this->birthDate = $birthDate; + + return $this; + } + + public function getAddresses(): ?Collection + { + return $this->addresses; + } + + public function setAddresses(?Collection $addresses): self + { + $this->addresses = $addresses; + + return $this; + } + + public function getPrimaryAddress(): ?AddressWithAttributes + { + return $this->primaryAddress; + } + + public function setPrimaryAddress(?AddressWithAttributes $primaryAddress): self + { + $this->primaryAddress = $primaryAddress; + + return $this; + } + + #[Bos\Normalize(group: ['default'], name: 'birthYear', type: 'DateTime', format: 'Y')] + public function getBirthYear(): ?\DateTime + { + return $this->birthDate; + } + + #[Bos\Normalize(group: ['default'], name: 'contactEmail')] + public function getContactEmail(): ?string + { + return $this->email; + } +} diff --git a/tests/assets/ProductWithAttributes.php b/tests/assets/ProductWithAttributes.php new file mode 100644 index 0000000..8e3035a --- /dev/null +++ b/tests/assets/ProductWithAttributes.php @@ -0,0 +1,126 @@ +id; + } + + public function setId(?int $id): self + { + $this->id = $id; + + return $this; + } + + public function getName(): ?string + { + return $this->name; + } + + public function setName(?string $name): self + { + $this->name = $name; + + return $this; + } + + public function getDescription(): ?string + { + return $this->description; + } + + public function setDescription(?string $description): self + { + $this->description = $description; + + return $this; + } + + public function getCreatedAt(): ?\DateTime + { + return $this->createdAt; + } + + public function setCreatedAt(?\DateTime $createdAt): self + { + $this->createdAt = $createdAt; + + return $this; + } + + public function getCost(): ?float + { + return $this->cost; + } + + public function setCost(?float $cost): self + { + $this->cost = $cost; + + return $this; + } + + public function getPrice(): ?float + { + return $this->price; + } + + public function setPrice(?float $price): self + { + $this->price = $price; + + return $this; + } + + #[Bos\Normalize(group: ['internal'], name: 'profit_margin')] + public function calculateProfitMargin(): ?float + { + if (null === $this->price || null === $this->cost || 0.0 === $this->cost) { + return null; + } + + return round((($this->price - $this->cost) / $this->cost) * 100, 2); + } + + #[Bos\Normalize(group: ['api'], name: 'formatted_price')] + public function getFormattedPrice(): ?string + { + if (null === $this->price) { + return null; + } + + return sprintf('$%.2f', $this->price); + } +} diff --git a/tests/assets/ProxyObject.php b/tests/assets/ProxyObject.php index 2b91784..7c9c955 100644 --- a/tests/assets/ProxyObject.php +++ b/tests/assets/ProxyObject.php @@ -9,40 +9,30 @@ class ProxyObject implements Proxy { - /** @var string */ - private $id = '123'; + private string $id = '123'; /** * @Bos\Normalize(group={"default"}) - * - * @var string */ - private $value = 'Hello'; + private string $value = 'Hello'; - /** @var string */ - private $proxyProperty = 'string'; + private string $proxyProperty = 'string'; - public function __load() + public function __load(): void { } - public function __isInitialized() + public function __isInitialized(): bool { return true; } - /** - * @return int - */ - public function getId() + public function getId(): int { return (int) $this->id; } - /** - * @return string - */ - public function getValue() + public function getValue(): string { return $this->value; } diff --git a/tests/assets/ProxySocial.php b/tests/assets/ProxySocial.php index 1a0c482..771e102 100644 --- a/tests/assets/ProxySocial.php +++ b/tests/assets/ProxySocial.php @@ -8,16 +8,17 @@ class ProxySocial extends Social implements Proxy { - public function __load() + public function __load(): void { } - public function __isInitialized() + public function __isInitialized(): bool { return true; } - public function getFacebook() + #[\Override] + public function getFacebook(): string { return parent::getFacebook(); } diff --git a/tests/assets/ProxySocialNotInitialized.php b/tests/assets/ProxySocialNotInitialized.php index 18b15bb..acec65b 100644 --- a/tests/assets/ProxySocialNotInitialized.php +++ b/tests/assets/ProxySocialNotInitialized.php @@ -8,7 +8,7 @@ class ProxySocialNotInitialized extends Social implements Proxy { - public function __load() + public function __load(): void { } diff --git a/tests/assets/Social.php b/tests/assets/Social.php index 6752435..4d80d98 100644 --- a/tests/assets/Social.php +++ b/tests/assets/Social.php @@ -1,5 +1,7 @@ id; } /** - * @param int $id - * * @return $this */ - public function setId($id) + public function setId(?int $id): self { $this->id = $id; @@ -70,81 +56,62 @@ public function setId($id) } /** - * @return string - * * @Bos\Normalize(name="facebook", group={"proxy-method"}) */ - public function getFacebook() + public function getFacebook(): ?string { return $this->facebook; } /** - * @param string $facebook - * * @return $this */ - public function setFacebook($facebook) + public function setFacebook(?string $facebook): self { $this->facebook = $facebook; return $this; } - /** - * @return string - */ - public function getTwitter() + public function getTwitter(): ?string { return $this->twitter; } /** - * @param string $twitter - * * @return $this */ - public function setTwitter($twitter) + public function setTwitter(?string $twitter): self { $this->twitter = $twitter; return $this; } - /** - * @return string - */ - public function getInstagram() + public function getInstagram(): ?string { return $this->instagram; } /** - * @param string $instagram - * * @return $this */ - public function setInstagram($instagram) + public function setInstagram(?string $instagram): self { $this->instagram = $instagram; return $this; } - /** - * @return string - */ - public function getSnapchat() + public function getSnapchat(): ?string { return $this->snapchat; } /** - * @param string $snapchat - * * @return $this */ - public function setSnapchat($snapchat) + public function setSnapchat(?string $snapchat): self { $this->snapchat = $snapchat; @@ -152,21 +119,17 @@ public function setSnapchat($snapchat) } /** - * @return Person - * * @Bos\Normalize(type="object", group={"circRefMethod"}) */ - public function getPerson() + public function getPerson(): ?Person { return $this->person; } /** - * @param Person $person - * * @return $this */ - public function setPerson($person) + public function setPerson(?Person $person): self { $this->person = $person; diff --git a/tests/assets/SomeClass.php b/tests/assets/SomeClass.php index b8addb1..4df213b 100644 --- a/tests/assets/SomeClass.php +++ b/tests/assets/SomeClass.php @@ -6,21 +6,16 @@ class SomeClass extends AbstractClass { - /** @var int */ - private $property32 = 123; + private int $property32 = 123; + public string $property53 = 'string'; + private string $property76 = 'another value'; - /** @var string */ - public $property53 = 'string'; - - /** @var string */ - private $property76 = 'another value'; - - public function getProperty32() + public function getProperty32(): int { return $this->property32; } - public function getId() + public function getId(): int { return 777; } diff --git a/tests/assets/TelephoneNumbers.php b/tests/assets/TelephoneNumbers.php index c676602..cd85265 100644 --- a/tests/assets/TelephoneNumbers.php +++ b/tests/assets/TelephoneNumbers.php @@ -1,25 +1,17 @@ $this->home, @@ -30,11 +22,9 @@ public function toArray() } /** - * @param string $home - * * @return $this */ - public function setHome($home) + public function setHome(string|int $home): self { $this->home = $home; @@ -42,11 +32,9 @@ public function setHome($home) } /** - * @param string $mobile - * * @return $this */ - public function setMobile($mobile) + public function setMobile(string|int $mobile): self { $this->mobile = $mobile; @@ -54,11 +42,9 @@ public function setMobile($mobile) } /** - * @param string $work - * * @return $this */ - public function setWork($work) + public function setWork(string|int $work): self { $this->work = $work; @@ -66,11 +52,9 @@ public function setWork($work) } /** - * @param string $wife - * * @return $this */ - public function setWife($wife) + public function setWife(string|int $wife): self { $this->wife = $wife; diff --git a/tests/assets/UnknownPropertyNormalizeMethod.php b/tests/assets/UnknownPropertyNormalizeMethod.php index 102d1a8..c6505e5 100644 --- a/tests/assets/UnknownPropertyNormalizeMethod.php +++ b/tests/assets/UnknownPropertyNormalizeMethod.php @@ -8,8 +8,7 @@ class UnknownPropertyNormalizeMethod { - /** @var string|null */ - private $name = null; + private ?string $name = null; /** * @Bos\Normalize(group={"default"}, asdsad="asdsad") diff --git a/tests/assets/UnknownPropertyNormalizeProperty.php b/tests/assets/UnknownPropertyNormalizeProperty.php index 6182389..b5888b0 100644 --- a/tests/assets/UnknownPropertyNormalizeProperty.php +++ b/tests/assets/UnknownPropertyNormalizeProperty.php @@ -10,10 +10,8 @@ class UnknownPropertyNormalizeProperty { /** * @Bos\Normalize(group={"default"}, asdsad="asdsad") - * - * @var string|null */ - private $name = null; + private ?string $name = null; /** * @return $this diff --git a/tests/assets/UnknownPropertySerialize.php b/tests/assets/UnknownPropertySerialize.php index 94b7a39..77e9dd4 100644 --- a/tests/assets/UnknownPropertySerialize.php +++ b/tests/assets/UnknownPropertySerialize.php @@ -13,10 +13,8 @@ class UnknownPropertySerialize { /** * @Bos\Normalize(group={"default"}, asdsad="asdsad") - * - * @var string */ - private $name; + private string $name; /** * @return $this diff --git a/tests/assets/UnknownPropertyTranslate.php b/tests/assets/UnknownPropertyTranslate.php index c48c73a..36272d1 100644 --- a/tests/assets/UnknownPropertyTranslate.php +++ b/tests/assets/UnknownPropertyTranslate.php @@ -12,8 +12,6 @@ class UnknownPropertyTranslate * @Bos\Normalize(group={"default"}) * * @Bos\Translate(groupp={"default"}) - * - * @var string */ - private $name = 'foo'; + private string $name = 'foo'; } From 445ef35dde290b2a0c45b8439500f6be72b7b3bd Mon Sep 17 00:00:00 2001 From: BowlOfSoup Date: Mon, 12 Jan 2026 18:45:26 +0100 Subject: [PATCH 2/3] ISSUE-62: Small improvement in validating and assigning annotation properties --- src/Annotation/AbstractAnnotation.php | 15 +++++++++ src/Annotation/Normalize.php | 45 ++++++++++----------------- src/Annotation/Serialize.php | 41 +++++++++--------------- src/Annotation/Translate.php | 41 +++++++++--------------- 4 files changed, 62 insertions(+), 80 deletions(-) diff --git a/src/Annotation/AbstractAnnotation.php b/src/Annotation/AbstractAnnotation.php index 9d459a7..85c78d3 100644 --- a/src/Annotation/AbstractAnnotation.php +++ b/src/Annotation/AbstractAnnotation.php @@ -13,6 +13,8 @@ abstract class AbstractAnnotation protected array $group = []; + abstract protected function getSupportedProperties(): array; + public function getGroup(): array { return $this->group; @@ -28,6 +30,19 @@ public function isGroupValidForConstruct(?string $group): bool return (empty($group) || in_array($group, $annotationGroup)) && (!empty($group) || empty($annotationGroup)); } + protected function validateProperty(string $propertyName, mixed $propertyValue): void + { + $supportedProperties = $this->getSupportedProperties(); + + if (!array_key_exists($propertyName, $supportedProperties)) { + throw new \InvalidArgumentException(sprintf(static::EXCEPTION_UNKNOWN_PROPERTY, $propertyName, static::class)); + } + + if (null !== $propertyValue) { + $this->validateProperties($propertyValue, $propertyName, $supportedProperties[$propertyName], static::class); + } + } + protected function validateProperties(mixed $property, string $propertyName, array $propertyOptions, string $annotation): bool { if ($this->isEmpty($property)) { diff --git a/src/Annotation/Normalize.php b/src/Annotation/Normalize.php index 3570fa5..7fc54a1 100644 --- a/src/Annotation/Normalize.php +++ b/src/Annotation/Normalize.php @@ -15,9 +15,7 @@ #[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_PROPERTY | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)] class Normalize extends AbstractAnnotation { - protected ?string $type = null; - - private array $supportedProperties = [ + private const array SUPPORTED_PROPERTIES = [ 'name' => ['type' => 'string'], 'group' => ['type' => 'array'], 'type' => ['type' => 'string', 'assert' => ['collection', 'datetime', 'object']], @@ -28,6 +26,8 @@ class Normalize extends AbstractAnnotation 'maxDepth' => ['type' => 'integer'], ]; + protected ?string $type = null; + private ?string $name = null; private ?string $format = null; private ?string $callback = null; @@ -45,18 +45,9 @@ public function __construct( ?bool $skipEmpty = null, ?int $maxDepth = null, ) { - // Support old array-based initialization (for Doctrine annotations) + // Legacy Doctrine annotations: single array argument if (is_array($name) && null === $group && 1 === func_num_args()) { - $properties = $name; - foreach ($properties as $propertyName => $propertyValue) { - if (!array_key_exists($propertyName, $this->supportedProperties)) { - throw new \InvalidArgumentException(sprintf(static::EXCEPTION_UNKNOWN_PROPERTY, $propertyName, self::class)); - } - - if ($this->validateProperties($propertyValue, $propertyName, $this->supportedProperties[$propertyName], self::class)) { - $this->$propertyName = $propertyValue; - } - } + $this->applyProperties($name); return; } @@ -69,7 +60,7 @@ public function __construct( $groupValue = [$group]; } - $properties = [ + $this->applyProperties([ 'name' => is_string($name) ? $name : null, 'group' => $groupValue, 'type' => $type, @@ -78,27 +69,25 @@ public function __construct( 'normalizeCallbackResult' => $normalizeCallbackResult ?? false, 'skipEmpty' => $skipEmpty ?? false, 'maxDepth' => $maxDepth, - ]; + ]); + } + private function applyProperties(array $properties): void + { foreach ($properties as $propertyName => $propertyValue) { - if (null === $propertyValue && 'name' !== $propertyName && 'format' !== $propertyName && 'callback' !== $propertyName && 'maxDepth' !== $propertyName && 'type' !== $propertyName) { - // @codeCoverageIgnoreStart - continue; - // @codeCoverageIgnoreEnd - } - - if (!array_key_exists($propertyName, $this->supportedProperties)) { - // @codeCoverageIgnoreStart - throw new \InvalidArgumentException(sprintf(static::EXCEPTION_UNKNOWN_PROPERTY, $propertyName, self::class)); - // @codeCoverageIgnoreEnd - } + $this->validateProperty($propertyName, $propertyValue); - if (null !== $propertyValue && $this->validateProperties($propertyValue, $propertyName, $this->supportedProperties[$propertyName], self::class)) { + if (null !== $propertyValue) { $this->$propertyName = $propertyValue; } } } + protected function getSupportedProperties(): array + { + return self::SUPPORTED_PROPERTIES; + } + public function getName(): ?string { return $this->name; diff --git a/src/Annotation/Serialize.php b/src/Annotation/Serialize.php index f7dc366..4814fef 100644 --- a/src/Annotation/Serialize.php +++ b/src/Annotation/Serialize.php @@ -15,7 +15,7 @@ #[Attribute(Attribute::TARGET_CLASS | Attribute::IS_REPEATABLE)] class Serialize extends AbstractAnnotation { - private array $supportedProperties = [ + private const array SUPPORTED_PROPERTIES = [ 'group' => ['type' => 'array'], 'wrapElement' => ['type' => 'string'], 'sortProperties' => ['type' => 'boolean'], @@ -29,18 +29,9 @@ public function __construct( array|string|null $group = null, ?bool $sortProperties = null, ) { - // Support old array-based initialization (for Doctrine annotations) + // Legacy Doctrine annotations: single array argument if (is_array($wrapElement) && null === $group && 1 === func_num_args()) { - $properties = $wrapElement; - foreach ($properties as $propertyName => $propertyValue) { - if (!array_key_exists($propertyName, $this->supportedProperties)) { - throw new \InvalidArgumentException(sprintf(static::EXCEPTION_UNKNOWN_PROPERTY, $propertyName, self::class)); - } - - if ($this->validateProperties($propertyValue, $propertyName, $this->supportedProperties[$propertyName], self::class)) { - $this->$propertyName = $propertyValue; - } - } + $this->applyProperties($wrapElement); return; } @@ -53,31 +44,29 @@ public function __construct( $groupValue = [$group]; } - $properties = [ + $this->applyProperties([ 'wrapElement' => is_string($wrapElement) ? $wrapElement : null, 'group' => $groupValue, 'sortProperties' => $sortProperties ?? false, - ]; + ]); + } + private function applyProperties(array $properties): void + { foreach ($properties as $propertyName => $propertyValue) { - if (null === $propertyValue && 'wrapElement' !== $propertyName) { - // @codeCoverageIgnoreStart - continue; - // @codeCoverageIgnoreEnd - } + $this->validateProperty($propertyName, $propertyValue); - if (!array_key_exists($propertyName, $this->supportedProperties)) { - // @codeCoverageIgnoreStart - throw new \InvalidArgumentException(sprintf(static::EXCEPTION_UNKNOWN_PROPERTY, $propertyName, self::class)); - // @codeCoverageIgnoreEnd - } - - if (null !== $propertyValue && $this->validateProperties($propertyValue, $propertyName, $this->supportedProperties[$propertyName], self::class)) { + if (null !== $propertyValue) { $this->$propertyName = $propertyValue; } } } + protected function getSupportedProperties(): array + { + return self::SUPPORTED_PROPERTIES; + } + public function getWrapElement(): ?string { return $this->wrapElement; diff --git a/src/Annotation/Translate.php b/src/Annotation/Translate.php index 8d88ee9..546ba8e 100644 --- a/src/Annotation/Translate.php +++ b/src/Annotation/Translate.php @@ -13,7 +13,7 @@ #[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)] class Translate extends AbstractAnnotation { - private array $supportedProperties = [ + private const SUPPORTED_PROPERTIES = [ 'group' => ['type' => 'array'], 'domain' => ['type' => 'string'], 'locale' => ['type' => 'string'], @@ -27,18 +27,9 @@ public function __construct( array|string|null $group = null, ?string $locale = null, ) { - // Support old array-based initialization (for Doctrine annotations) + // Legacy Doctrine annotations: single array argument if (is_array($domain) && null === $group && 1 === func_num_args()) { - $properties = $domain; - foreach ($properties as $propertyName => $propertyValue) { - if (!array_key_exists($propertyName, $this->supportedProperties)) { - throw new \InvalidArgumentException(sprintf(static::EXCEPTION_UNKNOWN_PROPERTY, $propertyName, self::class)); - } - - if ($this->validateProperties($propertyValue, $propertyName, $this->supportedProperties[$propertyName], self::class)) { - $this->$propertyName = $propertyValue; - } - } + $this->applyProperties($domain); return; } @@ -51,31 +42,29 @@ public function __construct( $groupValue = [$group]; } - $properties = [ + $this->applyProperties([ 'domain' => is_string($domain) ? $domain : null, 'group' => $groupValue, 'locale' => $locale, - ]; + ]); + } + private function applyProperties(array $properties): void + { foreach ($properties as $propertyName => $propertyValue) { - if (null === $propertyValue) { - // @codeCoverageIgnoreStart - continue; - // @codeCoverageIgnoreEnd - } + $this->validateProperty($propertyName, $propertyValue); - if (!array_key_exists($propertyName, $this->supportedProperties)) { - // @codeCoverageIgnoreStart - throw new \InvalidArgumentException(sprintf(static::EXCEPTION_UNKNOWN_PROPERTY, $propertyName, self::class)); - // @codeCoverageIgnoreEnd - } - - if ($this->validateProperties($propertyValue, $propertyName, $this->supportedProperties[$propertyName], self::class)) { + if (null !== $propertyValue) { $this->$propertyName = $propertyValue; } } } + protected function getSupportedProperties(): array + { + return self::SUPPORTED_PROPERTIES; + } + public function getDomain(): ?string { return $this->domain; From e9faa6b3bf5a9e598db6b4bba17789eb987524ee Mon Sep 17 00:00:00 2001 From: BowlOfSoup Date: Mon, 12 Jan 2026 18:48:04 +0100 Subject: [PATCH 3/3] ISSUE-62: Make Rector happy --- src/Annotation/Translate.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Annotation/Translate.php b/src/Annotation/Translate.php index 546ba8e..3c0a71e 100644 --- a/src/Annotation/Translate.php +++ b/src/Annotation/Translate.php @@ -13,7 +13,7 @@ #[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)] class Translate extends AbstractAnnotation { - private const SUPPORTED_PROPERTIES = [ + private const array SUPPORTED_PROPERTIES = [ 'group' => ['type' => 'array'], 'domain' => ['type' => 'string'], 'locale' => ['type' => 'string'],