diff --git a/.github/workflows/php-tests.yml b/.github/workflows/php-tests.yml index 2cd6846..20f0329 100644 --- a/.github/workflows/php-tests.yml +++ b/.github/workflows/php-tests.yml @@ -1,4 +1,4 @@ -name: PHP Composer +name: CI Tests on: push: @@ -38,9 +38,6 @@ jobs: - name: Install dependencies run: composer install --prefer-dist --no-progress - - name: Get directory - run: ls -la - - name: Run PHPUnit run: composer run-script ci-test diff --git a/.github/workflows/phpstan-tests.yml b/.github/workflows/phpstan-tests.yml new file mode 100644 index 0000000..712394d --- /dev/null +++ b/.github/workflows/phpstan-tests.yml @@ -0,0 +1,37 @@ +name: PHPStan Checks + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: 8.4 + + - name: Cache Composer packages + id: composer-cache + uses: actions/cache@v3 + with: + path: vendor + key: ${{ runner.os }}-php-${{ hashFiles('**/composer.lock') }} + restore-keys: | + ${{ runner.os }}-php- + + - name: Install dependencies + run: composer install --prefer-dist --no-progress + + - name: Run PHPStan + run: composer run-script phpstan diff --git a/.github/workflows/pr-label-check.yml b/.github/workflows/pr-label-check.yml new file mode 100644 index 0000000..c1de4fc --- /dev/null +++ b/.github/workflows/pr-label-check.yml @@ -0,0 +1,29 @@ +name: Enforce PR Label + +on: + pull_request: + types: [opened, edited, unlabeled] + +jobs: + check-label: + runs-on: ubuntu-latest + steps: + - name: Check for labels + uses: actions/github-script@v6 + with: + script: | + const labels = context.payload.pull_request.labels; + if (labels.length === 0) { + throw new Error('This pull request must have at least one label.'); + } + - name: Comment on missing labels + if: failure() + uses: actions/github-script@v6 + with: + script: | + github.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: 'This pull request must have at least one label.' + }) diff --git a/composer.json b/composer.json index 8c8c991..51145ba 100644 --- a/composer.json +++ b/composer.json @@ -27,6 +27,7 @@ } }, "scripts": { - "ci-test": "XDEBUG_MODE=coverage vendor/bin/phpunit --testsuite=ci --configuration phpunit.xml" + "ci-test": "XDEBUG_MODE=coverage vendor/bin/phpunit --testsuite=ci --configuration phpunit.xml", + "phpstan": "vendor/bin/phpstan analyse --configuration phpstan.neon" } } diff --git a/src/Attributes/PropertyAttributes/Computed.php b/src/Attributes/PropertyAttributes/Computed.php new file mode 100644 index 0000000..4f24546 --- /dev/null +++ b/src/Attributes/PropertyAttributes/Computed.php @@ -0,0 +1,10 @@ +> */ private static array $_enumReflections = []; /** @var list> $enums */ - private(set) public array $enums; + private(set) array $enums; - /** @var ReflectionEnum */ - private(set) public array $resolvedBackedEnumReflections = []; + /** @var array> */ + private(set) array $resolvedBackedEnumReflections = []; /** * @param class-string|list> $enums diff --git a/src/Attributes/PropertyAttributes/Types/ArrayOfData.php b/src/Attributes/PropertyAttributes/Types/ArrayOfData.php index 96fa0da..f102563 100644 --- a/src/Attributes/PropertyAttributes/Types/ArrayOfData.php +++ b/src/Attributes/PropertyAttributes/Types/ArrayOfData.php @@ -12,14 +12,14 @@ #[Attribute(Attribute::TARGET_PROPERTY | Attribute::IS_REPEATABLE)] class ArrayOfData { - /** @var ReflectionClass */ + /** @var array> */ private static array $_dataReflections = []; /** @var list> */ - private(set) public array $dataClasses; + private(set) array $dataClasses; - /** @var ReflectionClass */ - private(set) public array $resolvedDataReflections = []; + /** @var array> */ + private(set) array $resolvedDataReflections = []; /** * @param class-string|list> $dataClasses diff --git a/src/Attributes/PropertyAttributes/Types/ArrayOfDateTimes.php b/src/Attributes/PropertyAttributes/Types/ArrayOfDateTimes.php index a8edd01..8f4d41d 100644 --- a/src/Attributes/PropertyAttributes/Types/ArrayOfDateTimes.php +++ b/src/Attributes/PropertyAttributes/Types/ArrayOfDateTimes.php @@ -13,14 +13,14 @@ #[Attribute(Attribute::TARGET_PROPERTY | Attribute::IS_REPEATABLE)] class ArrayOfDateTimes { - /** @var ReflectionClass */ + /** @var array> */ private static array $_dateTimeReflections = []; /** @var list> */ - private(set) public array $dateTimes; + private(set) array $dateTimes; - /** @var ReflectionClass */ - private(set) public array $resolvedDateTimeReflections = []; + /** @var array> */ + private(set) array $resolvedDateTimeReflections = []; /** * @param class-string|list> $dateTimes diff --git a/src/Attributes/PropertyAttributes/Types/ArrayOfScalarTypes.php b/src/Attributes/PropertyAttributes/Types/ArrayOfScalarTypes.php index 26f2275..03112a0 100644 --- a/src/Attributes/PropertyAttributes/Types/ArrayOfScalarTypes.php +++ b/src/Attributes/PropertyAttributes/Types/ArrayOfScalarTypes.php @@ -10,7 +10,7 @@ class ArrayOfScalarTypes { /** @var list $types */ - private(set) public array $types; + private(set) array $types; /** * @param Type|list $types diff --git a/src/Concerns/BaseData.php b/src/Concerns/BaseData.php index 29677c2..24328df 100644 --- a/src/Concerns/BaseData.php +++ b/src/Concerns/BaseData.php @@ -29,41 +29,64 @@ final public static function from(mixed $value): static /** @var ClassContext $context */ $context = ClassContext::getInstance(new ReflectionClass(static::class)); - $instance = $context->newInstanceWithoutConstructor(); - foreach ($context->properties as $propertyContext) { - $serializers = $propertyContext->serializers(); + return $context->hasComputedProperties + ? static::instanceWithConstructorCallFrom($context, $value) + : static::instanceWithoutConstructorFrom($context, $value); + } catch (Throwable $e) { + throw new DeserializeException($e->getMessage(), $e->getCode(), $e); + } + } - if (!$serializers) { - throw new DeserializeException( - code: DeserializeException::NO_SERIALIZERS_ERROR_CODE - ); - } + /** + * @param ClassContext $context + * @param array $value + * + * @throws Throwable + */ + protected static function instanceWithoutConstructorFrom(ClassContext $context, array $value): static + { + $instance = $context->newInstanceWithoutConstructor(); - $propertyName = $propertyContext->propertyName; - $propertyDeserialized = false; - foreach ($serializers as $serializer) { - try { - $propertyValue = $serializer->deserialize($propertyContext, $value); + foreach ($context->properties as $propertyContext) { + $propertyName = $propertyContext->propertyName; - $instance->{$propertyName} = $propertyValue; + $instance->{$propertyName} = $propertyContext->deserializeFrom($value); + } - $propertyDeserialized = true; + return $instance; + } - break; - } catch (DeserializeException) { - } - } + /** + * @param ClassContext $context + * @param array $value + * + * @throws Throwable + */ + protected static function instanceWithConstructorCallFrom(ClassContext $context, array $value): static + { + /** @var array $args */ + $args = []; - if (!$propertyDeserialized) { - throw new DeserializeException("Could not deserialize value for property: $propertyName"); - } + foreach ($context->constructorParams as $paramName) { + $propertyContext = $context->properties[$paramName] ?? null; + + if (!$propertyContext) { + throw new DeserializeException( + "Could not find property context for constructor param: $paramName" + ); } - return $instance; - } catch (Throwable $e) { - throw new DeserializeException($e->getMessage(), $e->getCode(), $e); + $args[$paramName] = $propertyContext->deserializeFrom($value); + } + + $instance = $context->newInstanceWithConstructorCall(...$args); + + if (!$instance instanceof static) { + throw new DeserializeException('Could not create instance of ' . static::class); } + + return $instance; } /** @@ -77,36 +100,14 @@ final public function jsonSerialize(): array $context = ClassContext::getInstance(new ReflectionClass($this)); $serializableArray = []; - foreach ($context->properties as $propertyContext) { - $serializers = $propertyContext->serializers(); - - if (!$serializers) { - throw new SerializeException( - code: SerializeException::NO_SERIALIZERS_ERROR_CODE - ); + if ($propertyContext->isComputed) { + continue; } $propertyName = $propertyContext->propertyName; - $propertySerialized = false; - foreach ($serializers as $serializer) { - try { - $propertyValue = $serializer->serialize($propertyContext, $this); - - $serializableArray[$propertyName] = $propertyValue[$propertyName]; - $propertySerialized = true; - - break; - } catch (SerializeException) { - } - } - - if (!$propertySerialized) { - throw new SerializeException( - "Could not serialize property: $propertyName", - ); - } + $serializableArray[$propertyName] = $propertyContext->serializeFrom($this)[$propertyName]; } return $serializableArray; diff --git a/src/Contexts/ClassContext.php b/src/Contexts/ClassContext.php index e399542..9d79ef0 100644 --- a/src/Contexts/ClassContext.php +++ b/src/Contexts/ClassContext.php @@ -5,6 +5,7 @@ use Nuxtifyts\PhpDto\Exceptions\UnsupportedTypeException; use ReflectionClass; use ReflectionException; +use ReflectionParameter; /** * @template T of object @@ -22,7 +23,10 @@ class ClassContext /** * @var array */ - protected readonly array $_properties; + protected(set) readonly array $properties; + + /** @var list List of param names */ + public readonly array $constructorParams; /** * @param ReflectionClass $_reflectionClass @@ -32,12 +36,20 @@ class ClassContext final private function __construct( protected readonly ReflectionClass $_reflectionClass ) { - $this->_properties = self::getPropertyContexts($this->_reflectionClass); + $this->properties = self::getPropertyContexts($this->_reflectionClass); + $this->constructorParams = array_map( + static fn (ReflectionParameter $param) => $param->getName(), + $this->_reflectionClass->getConstructor()?->getParameters() ?? [], + ); } - /** @var array */ - public array $properties { - get => $this->_properties; + public bool $hasComputedProperties { + get => count( + array_filter( + $this->properties, + static fn (PropertyContext $property) => $property->isComputed + ) + ) > 0; } /** @@ -86,4 +98,12 @@ public function newInstanceWithoutConstructor(): mixed { return $this->_reflectionClass->newInstanceWithoutConstructor(); } + + /** + * @throws ReflectionException + */ + public function newInstanceWithConstructorCall(mixed ...$args): mixed + { + return $this->_reflectionClass->newInstance(...$args); + } } diff --git a/src/Contexts/PropertyContext.php b/src/Contexts/PropertyContext.php index 23d0780..6089c0e 100644 --- a/src/Contexts/PropertyContext.php +++ b/src/Contexts/PropertyContext.php @@ -2,7 +2,10 @@ namespace Nuxtifyts\PhpDto\Contexts; +use Nuxtifyts\PhpDto\Attributes\PropertyAttributes\Computed; use Nuxtifyts\PhpDto\Enums\Property\Type; +use Nuxtifyts\PhpDto\Exceptions\DeserializeException; +use Nuxtifyts\PhpDto\Exceptions\SerializeException; use Nuxtifyts\PhpDto\Exceptions\UnknownTypeException; use Nuxtifyts\PhpDto\Exceptions\UnsupportedTypeException; use Nuxtifyts\PhpDto\Serializers\Serializer; @@ -23,6 +26,8 @@ class PropertyContext */ private static array $_instances = []; + private(set) bool $isComputed = false; + /** * @throws UnsupportedTypeException */ @@ -30,6 +35,7 @@ final private function __construct( protected readonly ReflectionProperty $_reflectionProperty ) { $this->syncTypesFromReflectionProperty($this->_reflectionProperty); + $this->syncPropertyAttributes(); } public string $propertyName { @@ -65,6 +71,11 @@ private static function getKey(ReflectionProperty $property): string return $property->getDeclaringClass()->getName() . '@' . $property->getName(); } + private function syncPropertyAttributes(): void + { + $this->isComputed = !empty($this->_reflectionProperty->getAttributes(Computed::class)); + } + public function getValue(object $object): mixed { return $this->_reflectionProperty->getValue($object); @@ -107,4 +118,40 @@ protected function resolveSerializers(): array { return $this->getSerializersFromPropertyContext($this); } + + /** + * @param array $value + * + * @throws DeserializeException + * @throws UnknownTypeException + */ + public function deserializeFrom(array $value): mixed + { + foreach ($this->serializers() as $serializer) { + try { + return $serializer->deserialize($this, $value); + } catch (DeserializeException) { + } + } + + throw new DeserializeException('Could not deserialize value for property: ' . $this->propertyName); + } + + /** + * @return array + * + * @throws SerializeException + * @throws UnknownTypeException + */ + public function serializeFrom(object $object): array + { + foreach ($this->serializers() as $serializer) { + try { + return $serializer->serialize($this, $object); + } catch (SerializeException) { + } + } + + throw new SerializeException('Could not serialize value for property: ' . $this->propertyName); + } } diff --git a/tests/Dummies/ComputedPropertiesData.php b/tests/Dummies/ComputedPropertiesData.php new file mode 100644 index 0000000..43d3634 --- /dev/null +++ b/tests/Dummies/ComputedPropertiesData.php @@ -0,0 +1,19 @@ +c = $this->a . $this->b; + } +} diff --git a/tests/Unit/Attributes/ArrayOfBackedEnumTest.php b/tests/Unit/Attributes/ArrayOfBackedEnumTest.php index 90b2f4a..f05dc1b 100644 --- a/tests/Unit/Attributes/ArrayOfBackedEnumTest.php +++ b/tests/Unit/Attributes/ArrayOfBackedEnumTest.php @@ -26,7 +26,7 @@ public function will_throw_an_exception_if_passed_class_is_not_backed_enum_class { self::expectException(InvalidArgumentException::class); - // @phpstan-ignore-next-line INTENTIONALLY PASSING A STRING TO TEST EXCEPTION + // @phpstan-ignore-next-line new ArrayOfBackedEnums(PersonData::class); } @@ -35,7 +35,7 @@ public function wilL_throw_an_exception_if_class_does_not_even_exit(): void { self::expectException(InvalidArgumentException::class); - // @phpstan-ignore-next-line INTENTIONALLY PASSING A STRING TO TEST EXCEPTION + // @phpstan-ignore-next-line new ArrayOfBackedEnums('NonExistentClass'); } @@ -44,6 +44,7 @@ public function wiLL_throw_an_exception_if_a_non_backed_enum_class_is_passed(): { self::expectException(InvalidArgumentException::class); + // @phpstan-ignore-next-line new ArrayOfBackedEnums([YesNoEnum::class]); } } diff --git a/tests/Unit/Attributes/ArrayOfDataTest.php b/tests/Unit/Attributes/ArrayOfDataTest.php index 03d6df3..a786ee8 100644 --- a/tests/Unit/Attributes/ArrayOfDataTest.php +++ b/tests/Unit/Attributes/ArrayOfDataTest.php @@ -25,7 +25,7 @@ public function wilL_throw_an_exception_if_class_does_not_even_exit(): void { self::expectException(InvalidArgumentException::class); - // @phpstan-ignore-next-line INTENTIONALLY PASSING A STRING TO TEST EXCEPTION + // @phpstan-ignore-next-line new ArrayOfData('NonExistentClass'); } @@ -34,6 +34,7 @@ public function wiLL_throw_an_exception_if_a_non_data_class_is_passed(): void { self::expectException(InvalidArgumentException::class); + // @phpstan-ignore-next-line new ArrayOfData([YesNoEnum::class]); } } diff --git a/tests/Unit/Concerns/BaseDataTest.php b/tests/Unit/Concerns/BaseDataTest.php index 9e54da6..caa7635 100644 --- a/tests/Unit/Concerns/BaseDataTest.php +++ b/tests/Unit/Concerns/BaseDataTest.php @@ -7,6 +7,7 @@ use Nuxtifyts\PhpDto\Exceptions\DeserializeException; use Nuxtifyts\PhpDto\Exceptions\SerializeException; use Nuxtifyts\PhpDto\Tests\Dummies\AddressData; +use Nuxtifyts\PhpDto\Tests\Dummies\ComputedPropertiesData; use Nuxtifyts\PhpDto\Tests\Dummies\CoordinatesData; use Nuxtifyts\PhpDto\Tests\Dummies\CountryData; use Nuxtifyts\PhpDto\Tests\Dummies\Enums\YesNoBackedEnum; @@ -42,6 +43,7 @@ #[UsesClass(UnionMultipleComplexData::class)] #[UsesClass(UserLocationData::class)] #[UsesClass(UserGroupData::class)] +#[UsesClass(ComputedPropertiesData::class)] final class BaseDataTest extends UnitCase { /** @@ -464,6 +466,19 @@ public static function will_perform_serialization_and_deserialization_data_provi 'users' => [1, 2] ], 'expectedSerializedData' => $data + ], + 'Computed properties data' => [ + 'dtoClass' => ComputedPropertiesData::class, + 'data' => $data = [ + 'a' => 'a', + 'b' => 'b' + ], + 'expectedProperties' => [ + 'a' => 'a', + 'b' => 'b', + 'c' => 'ab' + ], + 'expectedSerializedData' => $data ] ]; } diff --git a/tests/Unit/Contexts/PropertyContextTest.php b/tests/Unit/Contexts/PropertyContextTest.php index e7c2f55..8860938 100644 --- a/tests/Unit/Contexts/PropertyContextTest.php +++ b/tests/Unit/Contexts/PropertyContextTest.php @@ -2,11 +2,14 @@ namespace Nuxtifyts\PhpDto\Tests\Unit\Contexts; +use Nuxtifyts\PhpDto\Attributes\PropertyAttributes\Computed; +use Nuxtifyts\PhpDto\Contexts\ClassContext; use Nuxtifyts\PhpDto\Contexts\PropertyContext; use Nuxtifyts\PhpDto\Contexts\TypeContext; use Nuxtifyts\PhpDto\Data; use Nuxtifyts\PhpDto\Enums\Property\Type; use Nuxtifyts\PhpDto\Serializers\ScalarTypeSerializer; +use Nuxtifyts\PhpDto\Tests\Dummies\ComputedPropertiesData; use Nuxtifyts\PhpDto\Tests\Dummies\Enums\YesNoBackedEnum; use Nuxtifyts\PhpDto\Tests\Dummies\UnionMultipleTypeData; use Nuxtifyts\PhpDto\Tests\Dummies\CoordinatesData; @@ -24,6 +27,8 @@ #[CoversClass(PropertyContext::class)] #[CoversClass(TypeContext::class)] +#[CoversClass(Computed::class)] +#[UsesClass(ComputedPropertiesData::class)] #[UsesClass(ScalarTypeSerializer::class)] #[UsesClass(PersonData::class)] #[UsesClass(Data::class)] @@ -117,6 +122,24 @@ public function can_resolve_union_types(): void ); } + /** + * @throws Throwable + */ + #[Test] + public function resolves_computed_properties(): void + { + $computedData = new ComputedPropertiesData(a: 'a', b: 'b'); + + $aReflectionProperty = new ReflectionProperty(ComputedPropertiesData::class, 'a'); + $cReflectionProperty = new ReflectionProperty(ComputedPropertiesData::class, 'c'); + + $aPropertyContext = PropertyContext::getInstance($aReflectionProperty); + $cPropertyContext = PropertyContext::getInstance($cReflectionProperty); + + self::assertTrue($cPropertyContext->isComputed); + self::assertFalse($aPropertyContext->isComputed); + } + /** * @throws Throwable */ diff --git a/tests/Unit/Serializers/BackedEnumSerializerTest.php b/tests/Unit/Serializers/BackedEnumSerializerTest.php index f8fcfd9..b28672a 100644 --- a/tests/Unit/Serializers/BackedEnumSerializerTest.php +++ b/tests/Unit/Serializers/BackedEnumSerializerTest.php @@ -20,6 +20,7 @@ #[CoversClass(Serializer::class)] #[CoversClass(BackedEnumSerializer::class)] +#[CoversClass(PropertyContext::class)] #[UsesClass(PropertyContext::class)] #[UsesClass(YesOrNoData::class)] #[UsesClass(YesNoBackedEnum::class)] diff --git a/tests/Unit/Serializers/DataSerializerTest.php b/tests/Unit/Serializers/DataSerializerTest.php index 678946b..e1ce31f 100644 --- a/tests/Unit/Serializers/DataSerializerTest.php +++ b/tests/Unit/Serializers/DataSerializerTest.php @@ -19,6 +19,7 @@ #[CoversClass(Serializer::class)] #[CoversClass(DataSerializer::class)] +#[CoversClass(PropertyContext::class)] #[UsesClass(PropertyContext::class)] #[UsesClass(AddressData::class)] #[UsesClass(CountryData::class)] diff --git a/tests/Unit/Serializers/DateTimeSerializerTest.php b/tests/Unit/Serializers/DateTimeSerializerTest.php index 2fa1082..57665b0 100644 --- a/tests/Unit/Serializers/DateTimeSerializerTest.php +++ b/tests/Unit/Serializers/DateTimeSerializerTest.php @@ -20,6 +20,7 @@ #[CoversClass(Serializer::class)] #[CoversClass(DateTimeSerializer::class)] +#[CoversClass(PropertyContext::class)] #[UsesClass(PropertyContext::class)] #[UsesClass(YesNoBackedEnum::class)] #[UsesClass(UserBirthdateData::class)] diff --git a/tests/Unit/Serializers/ScalarTypeSerializerTest.php b/tests/Unit/Serializers/ScalarTypeSerializerTest.php index a7c45e1..cdc4e11 100644 --- a/tests/Unit/Serializers/ScalarTypeSerializerTest.php +++ b/tests/Unit/Serializers/ScalarTypeSerializerTest.php @@ -20,6 +20,7 @@ #[CoversClass(ScalarTypeSerializer::class)] #[CoversClass(Serializer::class)] +#[CoversClass(PropertyContext::class)] #[UsesClass(PropertyContext::class)] #[UsesClass(PersonData::class)] #[UsesClass(CoordinatesData::class)]